diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc717836c..b7c76f4c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) +- **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) ### 🐛 Bug Fixes diff --git a/open-sse/mcp-server/tools/githubSkillTools.ts b/open-sse/mcp-server/tools/githubSkillTools.ts index 2161ec0945..18ef2a853d 100644 --- a/open-sse/mcp-server/tools/githubSkillTools.ts +++ b/open-sse/mcp-server/tools/githubSkillTools.ts @@ -77,11 +77,13 @@ async function handleInstall(args: z.infer) { try { const dest = resolveInstallPath(target, skillName, args.description); // In a real implementation, this would clone the repo and copy files. - // For now, we return the planned install path as a dry-run result. + // For now, we return the planned install path as a dry-run result — matches + // the honest `action: "planned"` the REST route (/api/github-skills POST) + // reports for the same operation. results.push({ target, ok: true, - action: "installed", + action: "planned", destDir: dest, }); } catch (err) { diff --git a/scripts/check/check-route-guard-membership.ts b/scripts/check/check-route-guard-membership.ts index 9f21c13d13..c73d9e2667 100644 --- a/scripts/check/check-route-guard-membership.ts +++ b/scripts/check/check-route-guard-membership.ts @@ -49,6 +49,7 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray = [ "src/app/api/mcp", "src/app/api/cli-tools/runtime", "src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17) + "src/app/api/skills/collect", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry via getCliRuntimeStatus() (Hard Rules #15 + #17, PR #6294 review) ]; // Frozen pre-existing exceptions: spawn-capable routes NOT yet classified diff --git a/skills/README.md b/skills/README.md index 6fa1823d0e..1cab48496b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -5,10 +5,10 @@ consume OmniRoute via OpenAI-compatible REST in one fetch. ## Entry points -| Type | Skill | Manifest | -| ---- | ----- | -------- | +| Type | Skill | Manifest | +| ---- | ------------------------------------------- | ---------------------------------------- | | API | Authentication (start here for REST access) | [omni-auth/SKILL.md](omni-auth/SKILL.md) | -| CLI | Serve (start here for CLI access) | [cli-serve/SKILL.md](cli-serve/SKILL.md) | +| CLI | Serve (start here for CLI access) | [cli-serve/SKILL.md](cli-serve/SKILL.md) | ## How agents discover capabilities @@ -24,57 +24,58 @@ See [`docs/frameworks/AGENT-SKILLS.md`](../docs/frameworks/AGENT-SKILLS.md) for Each manifest URL follows the pattern: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills//SKILL.md` -| ID | Name | Description | -| -- | ---- | ----------- | -| `omni-auth` | Authentication | Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements. | -| `omni-providers` | Providers | Manage provider connections, API keys, OAuth flows, and connection tests. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+). | -| `omni-models` | Models | Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants. | -| `omni-combos-routing` | Combos & Routing | Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics. | -| `omni-api-keys` | API Keys | Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. | -| `omni-usage-logs` | Usage & Logs | Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage. | -| `omni-budget` | Budget & Rate Limits | Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls. | -| `omni-settings` | Settings | Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration. | -| `omni-proxies` | Proxy Configuration | Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation. | -| `omni-cache` | Cache | Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds. | -| `omni-compression` | Compression | Configure RTK, Caveman, and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%. | -| `omni-context-rtk` | Context & RTK | Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines. | -| `omni-resilience` | Resilience & Monitoring | Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time. | -| `omni-cli-tools` | CLI Tools | Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface. | -| `omni-tunnels` | Tunnels | Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines. | -| `omni-sync-cloud` | Cloud Sync | Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets. | -| `omni-db-backups` | Database & Backups | Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies. | -| `omni-webhooks` | Webhooks | Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. | -| `omni-mcp` | MCP Server | Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes. | -| `omni-agents-a2a` | Agents & A2A Protocol | Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities. | -| `omni-version-manager` | Version Manager | Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start. | -| `omni-inference` | Inference (OpenAI-compatible) | The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. | +| ID | Name | Description | +| ---------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `omni-auth` | Authentication | Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements. | +| `omni-providers` | Providers | Manage provider connections, API keys, OAuth flows, and connection tests. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+). | +| `omni-models` | Models | Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants. | +| `omni-combos-routing` | Combos & Routing | Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics. | +| `omni-api-keys` | API Keys | Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. | +| `omni-usage-logs` | Usage & Logs | Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage. | +| `omni-budget` | Budget & Rate Limits | Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls. | +| `omni-settings` | Settings | Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration. | +| `omni-proxies` | Proxy Configuration | Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation. | +| `omni-cache` | Cache | Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds. | +| `omni-compression` | Compression | Configure RTK, Caveman, and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%. | +| `omni-context-rtk` | Context & RTK | Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines. | +| `omni-resilience` | Resilience & Monitoring | Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time. | +| `omni-cli-tools` | CLI Tools | Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface. | +| `omni-tunnels` | Tunnels | Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines. | +| `omni-sync-cloud` | Cloud Sync | Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets. | +| `omni-db-backups` | Database & Backups | Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies. | +| `omni-webhooks` | Webhooks | Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. | +| `omni-mcp` | MCP Server | Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes. | +| `omni-agents-a2a` | Agents & A2A Protocol | Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities. | +| `omni-version-manager` | Version Manager | Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start. | +| `omni-inference` | Inference (OpenAI-compatible) | The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. | --- -## CLI Skills (20) +## CLI Skills (21) -| ID | Name | Description | -| -- | ---- | ----------- | -| `cli-serve` | CLI: Serve | Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut. | -| `cli-health` | CLI: Health | Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status. | -| `cli-providers` | CLI: Providers | Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics. | -| `cli-keys` | CLI: API Keys | Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration. | -| `cli-models` | CLI: Models | Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants. | -| `cli-chat` | CLI: Chat | Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration. | -| `cli-routing` | CLI: Routing & Combos | Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively. | -| `cli-resilience` | CLI: Resilience & Quotas | Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds. | -| `cli-compression` | CLI: Compression | Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts. | -| `cli-contexts` | CLI: Contexts & Sessions | Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines. | -| `cli-cost-usage` | CLI: Cost & Usage | View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending. | -| `cli-mcp` | CLI: MCP | Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI. | -| `cli-a2a` | CLI: A2A Protocol | Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively. | -| `cli-tunnel` | CLI: Tunnels | Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability. | -| `cli-backup-sync` | CLI: Backup & Sync | Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files. | -| `cli-policy-audit` | CLI: Policy & Audit | Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows. | -| `cli-batches` | CLI: Batches & Files | Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows. | -| `cli-eval` | CLI: Evals | Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI. | -| `cli-plugins-skills` | CLI: Plugins, Skills & Memory | Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI. | -| `cli-setup` | CLI: Setup & Config | Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands. | +| ID | Name | Description | +| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli-serve` | CLI: Serve | Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut. | +| `cli-health` | CLI: Health | Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status. | +| `cli-providers` | CLI: Providers | Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics. | +| `cli-keys` | CLI: API Keys | Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration. | +| `cli-models` | CLI: Models | Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants. | +| `cli-chat` | CLI: Chat | Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration. | +| `cli-routing` | CLI: Routing & Combos | Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively. | +| `cli-resilience` | CLI: Resilience & Quotas | Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds. | +| `cli-compression` | CLI: Compression | Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts. | +| `cli-contexts` | CLI: Contexts & Sessions | Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines. | +| `cli-cost-usage` | CLI: Cost & Usage | View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending. | +| `cli-mcp` | CLI: MCP | Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI. | +| `cli-a2a` | CLI: A2A Protocol | Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively. | +| `cli-tunnel` | CLI: Tunnels | Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability. | +| `cli-backup-sync` | CLI: Backup & Sync | Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files. | +| `cli-policy-audit` | CLI: Policy & Audit | Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows. | +| `cli-batches` | CLI: Batches & Files | Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows. | +| `cli-eval` | CLI: Evals | Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI. | +| `cli-plugins-skills` | CLI: Plugins, Skills & Memory | Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI. | +| `cli-setup` | CLI: Setup & Config | Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands. | +| `cli-skill-collector` | CLI: Skill Collector | Detect installed coding CLI tools, search GitHub for matching agent skills, and plan their installation into the detected tools' skill directories. | --- @@ -87,6 +88,7 @@ https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills//SKILL. ``` Examples: + - API entry: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/omni-auth/SKILL.md` - CLI entry: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/cli-serve/SKILL.md` diff --git a/skills/cli-skill-collector/SKILL.md b/skills/cli-skill-collector/SKILL.md new file mode 100644 index 0000000000..2e3cec2776 --- /dev/null +++ b/skills/cli-skill-collector/SKILL.md @@ -0,0 +1,152 @@ +--- +name: cli-skill-collector +description: "Agent workflow: detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.), search GitHub for matching agent skills, and install them to the detected tools. Replaces the standalone Skill Collector Python app." +--- + +# /cli-skill-collector — Agent Skill Collector + +Discover and install agent skills for your coding CLI tools — all through OmniRoute's built-in APIs. + +This skill teaches you how to: + +1. **Detect** which coding CLIs are installed on this machine +2. **Search** GitHub for relevant agent skills (SKILL.md repos) +3. **Install** discovered skills to the detected coding tools + +No separate Skill Collector app needed — OmniRoute's own CLI detection + GitHub search handles everything. + +--- + +## Step 1 — Detect installed coding tools + +Query OmniRoute's CLI tool detection to find which coding agents are installed: + +```bash +curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/api/skills/collect/detect +``` + +This returns: + +- Every CLI tool in OmniRoute's catalog (`CLI_TOOL_IDS`: claude, codex, cursor, copilot, opencode, cline, kilocode, hermes, hermes-agent, openclaw, droid, continue, qwen, windsurf, devin, antigravity, etc.) +- Whether each is **installed** and **runnable** +- GitHub skills **matched** to your installed tools (scored by relevance) + +Example response: + +```json +{ + "tools": { + "codex": { "installed": true, "runnable": true, "command": "codex" }, + "claude": { "installed": true, "runnable": true, "command": "claude" }, + "cursor": { "installed": false, "runnable": false } + }, + "installedToolIds": ["codex", "claude"], + "matchedSkills": [ + { "toolId": "codex", "repo": "user/skill-codex-xxx", "score": 0.85, "stars": 120 }, + { "toolId": "claude", "repo": "user/claude-agent-rules", "score": 0.92, "stars": 340 } + ], + "totalSkills": 85 +} +``` + +--- + +## Step 2 — Review matched skills + +For each installed tool, the API returns relevant GitHub repos that contain SKILL.md or agent configuration files. Use the `score` field to prioritize: + +| Score | Recommendation | +| ----- | ----------------------------------------------- | +| 0.80+ | Excellent — well-maintained, high stars, active | +| 0.60+ | Good — relevant with decent quality | +| 0.40+ | Fair — may need review | +| <0.40 | Low quality — skip | + +You can also browse manually: + +```bash +curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" \ + "http://localhost:20128/api/github-skills?minStars=3&maxResults=50" +``` + +--- + +## Step 3 — Install skills to detected tools + +Install a chosen skill to one or more detected tools: + +```bash +curl -X POST http://localhost:20128/api/skills/collect/install \ + -H "Authorization: Bearer $OMNIROUTE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "repoName": "user/skill-codex-xxx", + "targets": ["codex", "claude"], + "description": "Agent skill for coding workflows" + }' +``` + +This plans the installation path for each target tool: + +- **claude** → `~/.claude/skills/{category}/` +- **codex** → `~/.codex/skills/{category}/` +- **hermes** → `~/AppData/Local/hermes/skills/{category}/` +- **opencode** → `~/.opencode/skills/{category}/` +- **gemini** → `~/.gemini/skills/{category}/` + +The actual file sync (cloning from GitHub and copying SKILL.md) is done by the agent using standard `curl` + `cp` commands. + +--- + +## Step 4 — Verify installation + +After installing, verify the skill is in place: + +```bash +# For Codex +ls -la ~/.codex/skills/imported-github/*/SKILL.md + +# For Claude Code +ls -la ~/.claude/skills/imported-github/*/SKILL.md + +# For Hermes (Windows) +ls -la ~/AppData/Local/hermes/skills/imported-github/*/SKILL.md +``` + +Also re-check detection: + +```bash +curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/api/skills/collect/detect +``` + +--- + +## Quick start (full workflow) + +```bash +AUTH_HEADER="Authorization: Bearer $OMNIROUTE_API_KEY" + +# 1. Detect +DETECT=$(curl -s -H "$AUTH_HEADER" http://localhost:20128/api/skills/collect/detect) + +# 2. Pick top matched skill for first installed tool +TOOL=$(echo "$DETECT" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['installedToolIds'][0] if d['installedToolIds'] else '')") +SKILL=$(echo "$DETECT" | python3 -c "import sys,json;d=json.load(sys.stdin);ms=d.get('matchedSkills',[]);print(ms[0]['repo'] if ms else '')") + +if [ -n "$TOOL" ] && [ -n "$SKILL" ]; then + # 3. Install + curl -s -X POST http://localhost:20128/api/skills/collect/install \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "{\"repoName\": \"$SKILL\", \"targets\": [\"$TOOL\"]}" + echo "Installed $SKILL to $TOOL" +fi +``` + +--- + +## Notes + +- OmniRoute must be running locally on port 20128 (default) — see `docs/frameworks/SKILLS.md` for custom-port setups. +- The `/api/skills/collect/*` and `/api/github-skills` endpoints require **management-scoped authentication** the same way every other `/api/skills/*` route does: a dashboard session, the loopback CLI token, or an API key with the `manage` scope (`requireManagementAuth()`). Auth is only bypassed when the server has no login/API-key requirement configured at all. +- This replaces the standalone Skill Collector Python app — all logic is now inside OmniRoute. diff --git a/src/app/api/github-skills/route.ts b/src/app/api/github-skills/route.ts index f4687a3847..a8d69a8384 100644 --- a/src/app/api/github-skills/route.ts +++ b/src/app/api/github-skills/route.ts @@ -15,6 +15,7 @@ import { searchGitHubSkills } from "@/lib/skills/githubCollector"; import { matchesSearch } from "@/shared/utils/turkishText"; import { validateBody } from "@/shared/validation/helpers"; import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const installSkillSchema = z.object({ repoName: z.string().min(1), @@ -25,6 +26,9 @@ const installSkillSchema = z.object({ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const minStars = parseInt(searchParams.get("minStars") ?? "1", 10); @@ -64,6 +68,9 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const parsed = validateBody(installSkillSchema, await request.json()); if (!parsed.success) { diff --git a/src/app/api/skills/collect/detect/route.ts b/src/app/api/skills/collect/detect/route.ts new file mode 100644 index 0000000000..5f755c6ec7 --- /dev/null +++ b/src/app/api/skills/collect/detect/route.ts @@ -0,0 +1,164 @@ +/** + * GET /api/skills/collect/detect + * + * Detect installed CLI coding tools + search GitHub for matching agent skills. + * Uses OmniRoute's built-in CLI_TOOL_IDS detection (no Skill Collector bridge needed). + * + * Returns: { + * tools: { toolId, installed, runnable, command, reason }[], + * matchedSkills: { toolId, skillName, repo, score, stars }[], + * totalSkills: number + * } + */ +import { NextRequest, NextResponse } from "next/server"; +import { getCliRuntimeStatus, CLI_TOOL_IDS } from "@/shared/services/cliRuntime"; +import { searchGitHubSkills, type GitHubSkillRepo } from "@/lib/skills/githubCollector"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export const dynamic = "force-dynamic"; + +const CODING_TOOL_KEYWORDS: Record = { + claude: ["claude", "anthropic", "claude-code"], + codex: ["codex", "openai", "gpt"], + cursor: ["cursor", "cursor-ai"], + copilot: ["copilot", "github-copilot"], + opencode: ["opencode"], + cline: ["cline"], + kilocode: ["kilo", "kilocode"], + hermes: ["hermes", "nous-research"], + "hermes-agent": ["hermes", "hermes-agent"], + openclaw: ["openclaw"], + droid: ["droid", "factory-ai"], + continue: ["continue"], + antigravity: ["antigravity"], + qwen: ["qwen", "alibaba"], + windsurf: ["windsurf"], + devin: ["devin", "cognition"], +}; + +interface DetectedTool { + installed: boolean; + runnable: boolean; + command: string | null; + reason: string | null; +} + +interface MatchedSkill { + toolId: string; + toolName: string; + skillName: string; + repo: string; + htmlUrl: string; + score: number; + stars: number; + description: string; +} + +/** Probes every catalog CLI tool in parallel via getCliRuntimeStatus(). */ +async function detectInstalledTools(): Promise> { + const toolIds = CLI_TOOL_IDS as readonly string[]; + const detectedTools: Record = {}; + + await Promise.allSettled( + toolIds.map(async (toolId) => { + try { + const result = await getCliRuntimeStatus(toolId); + detectedTools[toolId] = { + installed: result.installed, + runnable: result.runnable, + command: result.command ?? null, + reason: result.reason ?? null, + }; + } catch { + detectedTools[toolId] = { + installed: false, + runnable: false, + command: null, + reason: "check_failed", + }; + } + }) + ); + + return detectedTools; +} + +function toMatchedSkill(toolId: string, repo: GitHubSkillRepo): MatchedSkill { + return { + toolId, + toolName: toolId, + skillName: repo.fullName?.split("/").pop() ?? "unknown", + repo: repo.fullName ?? "", + htmlUrl: repo.htmlUrl ?? "", + score: repo.score ?? 0, + stars: repo.stars ?? 0, + description: (repo.description ?? "").slice(0, 200), + }; +} + +/** For each repo, matches it to the first installed tool whose keywords hit. */ +function matchSkillsToTools(repos: GitHubSkillRepo[], installedTools: string[]): MatchedSkill[] { + const matchedSkills: MatchedSkill[] = []; + + for (const repo of repos) { + const name = (repo.fullName ?? "").toLowerCase(); + const desc = (repo.description ?? "").toLowerCase(); + + const matchedTool = installedTools.find((toolId) => { + const keywords = CODING_TOOL_KEYWORDS[toolId] ?? [toolId]; + return keywords.some((kw) => name.includes(kw) || desc.includes(kw)); + }); + if (matchedTool) matchedSkills.push(toMatchedSkill(matchedTool, repo)); + } + + return matchedSkills; +} + +/** Fills in tools with zero keyword matches by distributing top-scored skills evenly. */ +function distributeUnmatchedSkills( + repos: GitHubSkillRepo[], + matchedSkills: MatchedSkill[], + installedTools: string[] +): MatchedSkill[] { + const toolsWithoutMatches = installedTools.filter( + (id) => !matchedSkills.some((m) => m.toolId === id) + ); + if (toolsWithoutMatches.length === 0 || repos.length === 0) return matchedSkills; + + const topSkills = repos.filter((r) => (r.score ?? 0) >= 0.4).slice(0, Math.min(10, repos.length)); + const distributed = topSkills.map((r, i) => + toMatchedSkill(toolsWithoutMatches[i % toolsWithoutMatches.length], r) + ); + + return [...matchedSkills, ...distributed]; +} + +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const detectedTools = await detectInstalledTools(); + const installedTools = Object.entries(detectedTools) + .filter(([, v]) => v.installed) + .map(([id]) => id); + + const { repos, errors } = await searchGitHubSkills({ minStars: 1, maxResults: 100 }); + + const directMatches = matchSkillsToTools(repos, installedTools); + const matchedSkills = distributeUnmatchedSkills(repos, directMatches, installedTools); + + return NextResponse.json({ + tools: detectedTools, + installedToolIds: installedTools, + matchedSkills: matchedSkills.slice(0, 50), + totalSkills: repos.length, + totalMatched: matchedSkills.length, + searchErrors: (errors?.length ?? 0) > 0 ? errors : undefined, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json(buildErrorBody(500, msg), { status: 500 }); + } +} diff --git a/src/app/api/skills/collect/install/route.ts b/src/app/api/skills/collect/install/route.ts new file mode 100644 index 0000000000..cecca07196 --- /dev/null +++ b/src/app/api/skills/collect/install/route.ts @@ -0,0 +1,130 @@ +/** + * POST /api/skills/collect/install + * + * Install a discovered GitHub skill to detected CLI tools. + * Uses OmniRoute's skill registry + CLI tool paths (no Skill Collector bridge). + * + * Body: { + * repoName: string, // GitHub full name (e.g. "user/repo") + * targets: string[], // Tool IDs to install to (e.g. ["codex", "claude"]) + * description?: string // Repo description for category inference + * } + * + * Returns: { ok, results: { target, action, destDir, error? }[] } + */ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +const installSchema = z.object({ + repoName: z.string().min(1, "repoName is required"), + targets: z + .array(z.string().min(1, "target toolId must be non-empty")) + .min(1, "at least one target required") + .max(10, "max 10 targets"), + description: z.string().default(""), +}); + +const CODING_TOOL_PATHS: Record = { + claude: "~/.claude/skills/{category}", + codex: "~/.codex/skills/{category}", + hermes: "~/AppData/Local/hermes/skills/{category}", + opencode: "~/.opencode/skills/{category}", + gemini: "~/.gemini/skills/{category}", + cursor: "~/.cursor/skills/{category}", + copilot: "~/.copilot/skills/{category}", + cline: "~/.cline/skills/{category}", + windsurf: "~/.windsurf/skills/{category}", + devin: "~/.devin/skills/{category}", + antigravity: "~/.antigravity/skills/{category}", + qwen: "~/.qwen/skills/{category}", + kilocode: "~/.kilocode/skills/{category}", + openclaw: "~/.openclaw/skills/{category}", + droid: "~/.droid/skills/{category}", + continue: "~/.continue/skills/{category}", +}; + +function inferCategory(skillName: string, description: string): string { + const text = `${skillName} ${description}`.toLowerCase(); + const mapping: Record = { + security: ["security", "pentest", "exploit", "malware", "forensics", "vulnerability"], + "data-science": ["data", "analytics", "pandas", "ml", "model", "train"], + devops: ["deploy", "docker", "k8s", "terraform", "ci/cd", "pipeline"], + creative: ["design", "image", "video", "art", "music"], + productivity: ["email", "doc", "slide", "report", "calendar"], + research: ["paper", "arxiv", "academic", "literature"], + "software-development": ["code", "refactor", "test", "lint", "review", "debug"], + media: ["youtube", "transcript", "gif", "video", "audio"], + }; + for (const [cat, keywords] of Object.entries(mapping)) { + if (keywords.some((k) => text.includes(k))) return cat; + } + return "imported-github"; +} + +function expandHome(dir: string): string { + // Home dir resolution: Windows (USERPROFILE) → Unix fallback (HOME) + const home = + typeof process !== "undefined" ? process.env.USERPROFILE || process.env.HOME || "" : ""; + return dir.replace(/^~/, home); +} + +function resolveDestDir(target: string, skillName: string, description: string): string { + const template = CODING_TOOL_PATHS[target]; + if (!template) { + throw new Error( + `Unknown target tool: "${target}". Supported: ${Object.keys(CODING_TOOL_PATHS).join(", ")}` + ); + } + const category = inferCategory(skillName, description); + const resolved = template.replace("{category}", category).replace("{name}", skillName); + return expandHome(`${resolved}/${skillName}`); +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const rawBody = await request.json(); + const validation = validateBody(installSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(buildErrorBody(400, validation.error.message), { status: 400 }); + } + + const { repoName, targets, description } = validation.data; + const skillName = repoName.split("/").pop() || repoName; + + const results = targets.map((target) => { + try { + const destDir = resolveDestDir(target, skillName, description); + return { + target, + ok: true, + action: "planned", + destDir, + note: `Ready: SKILL.md from ${repoName} can be synced to ${destDir}`, + }; + } catch (err) { + return { + target, + ok: false, + action: "error", + error: (err as Error).message, + }; + } + }); + + return NextResponse.json({ + ok: results.every((r) => r.ok), + repoName, + skillName, + results, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return NextResponse.json(buildErrorBody(500, msg), { status: 500 }); + } +} diff --git a/src/lib/skills/githubCollector.ts b/src/lib/skills/githubCollector.ts index 48a8deb867..6dc9ca167f 100644 --- a/src/lib/skills/githubCollector.ts +++ b/src/lib/skills/githubCollector.ts @@ -37,7 +37,7 @@ export interface ScanFinding { export interface SkillInstallResult { target: string; ok: boolean; - action: "installed" | "already_up_to_date" | "skipped" | "error"; + action: "installed" | "planned" | "already_up_to_date" | "skipped" | "error"; error?: string; destDir?: string; } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index cfd67840fe..f044516ee5 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -43,6 +43,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17) "/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17) "/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. + "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review). "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. ]; diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index 5e83f3d546..87c2186a1d 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -30,6 +30,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17) "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) "/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) + "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, PR #6294 review) "/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17) "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17) ]; diff --git a/stryker.conf.json b/stryker.conf.json index fd55cbcd45..dcdcb45189 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -60,6 +60,7 @@ "tests/unit/auth-terminal-status.test.ts", "tests/unit/authz/discovery-routes-local-only.test.ts", "tests/unit/authz/route-guard-local-prefix.test.ts", + "tests/unit/authz/route-guard-skills-collect.test.ts", "tests/unit/authz/route-guard-version-get-exemption.test.ts", "tests/unit/authz/routeGuard.test.ts", "tests/unit/auto-combo-context-advertising.test.ts", diff --git a/tests/unit/authz/route-guard-skills-collect.test.ts b/tests/unit/authz/route-guard-skills-collect.test.ts new file mode 100644 index 0000000000..795fc3f59e --- /dev/null +++ b/tests/unit/authz/route-guard-skills-collect.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyPath, + isLocalOnlyBypassableByManageScope, +} from "../../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_ROUTE_ROOTS } from "../../../scripts/check/check-route-guard-membership.ts"; + +// ─── PR #6294 review: /api/skills/collect/ is local-only ───────────────── +// +// GET /api/skills/collect/detect calls getCliRuntimeStatus() (which spawns a +// child process) once per CLI_TOOL_IDS entry — a spawn-capable, previously +// unauthenticated route reachable from any tunnel. Must be loopback-enforced +// BEFORE any auth check (Hard Rules #15 + #17), the same as every other +// spawn-capable prefix. + +test("isLocalOnlyPath: /api/skills/collect/ prefix is local-only (Hard Rules #15/#17)", () => { + assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true); + assert.equal(isLocalOnlyPath("/api/skills/collect/install"), true); + assert.equal(isLocalOnlyPath("/api/skills/collect/"), true); +}); + +test("isLocalOnlyPath: the rest of /api/skills/ stays remote-reachable (no over-broadening)", () => { + // Only the spawn-capable collect/* subtree is loopback-locked. The rest of the + // skills surface (registry install, marketplace, skillssh) already gates on + // requireManagementAuth() and must remain reachable remotely. + assert.equal(isLocalOnlyPath("/api/skills"), false); + assert.equal(isLocalOnlyPath("/api/skills/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace/install"), false); +}); + +test("isLocalOnlyBypassableByManageScope: /api/skills/collect/ is NOT bypassable (defence in depth)", () => { + // Even if a DB row tried to whitelist /api/skills/collect/ via the manage-scope + // bypass list, the runtime predicate must reject it because the prefix is in + // SPAWN_CAPABLE_PREFIXES (src/shared/constants/spawnCapablePrefixes.ts). + assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true); + assert.equal(isLocalOnlyBypassableByManageScope("/api/skills/collect/detect"), false); +}); + +test("SPAWN_CAPABLE_ROUTE_ROOTS includes src/app/api/skills/collect (route-guard-membership gate)", () => { + // Regression guard for the "gate's scanned-roots list doesn't include this new + // directory" gap found during PR #6294 review — check:route-guard-membership + // must actually enumerate the new detect/install route.ts files, not silently + // report "0 new gaps" because the directory was never in scope. + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes("src/app/api/skills/collect"), + `Expected SPAWN_CAPABLE_ROUTE_ROOTS to include "src/app/api/skills/collect", got: ${JSON.stringify( + SPAWN_CAPABLE_ROUTE_ROOTS + )}` + ); +}); diff --git a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts index aeeb98bbdf..7c64ca0f6c 100644 --- a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts +++ b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts @@ -77,6 +77,7 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t "/api/tools/traffic-inspector/", "/api/plugins/", "/api/local/", + "/api/skills/collect/", "/api/headroom/start", "/api/headroom/stop", ]) { @@ -85,5 +86,5 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t `SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction` ); } - assert.equal(SPAWN_CAPABLE_PREFIXES.length, 8); + assert.equal(SPAWN_CAPABLE_PREFIXES.length, 9); }); diff --git a/tests/unit/github-skill-tools-mcp.test.ts b/tests/unit/github-skill-tools-mcp.test.ts new file mode 100644 index 0000000000..fe3cfd7831 --- /dev/null +++ b/tests/unit/github-skill-tools-mcp.test.ts @@ -0,0 +1,104 @@ +/** + * Unit tests for the MCP tool handlers in open-sse/mcp-server/tools/githubSkillTools.ts: + * + * - omniroute_github_skills_search + * - omniroute_github_skills_scan + * - omniroute_github_skills_install + * + * global.fetch is monkey-patched for the duration of this file to avoid live + * GitHub API calls from searchGitHubSkills() (20+ queries per invocation). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { githubSkillTools } = await import("../../open-sse/mcp-server/tools/githubSkillTools.ts"); +const { GitHubSkillsSearchSchema, GitHubSkillsScanSchema, GitHubSkillsInstallSchema } = + await import("../../src/lib/skills/githubCollector.ts"); + +const originalFetch = globalThis.fetch; + +test.before(() => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; +}); + +// ─── omniroute_github_skills_search ──────────────────────────────────────── + +test("omniroute_github_skills_search: returns a well-shaped result for a valid search", async () => { + const args = GitHubSkillsSearchSchema.parse({ minStars: 1, maxResults: 5 }); + const result = await githubSkillTools.omniroute_github_skills_search.handler(args); + + assert.ok(Array.isArray(result.skills)); + assert.equal(typeof result.total, "number"); +}); + +// ─── omniroute_github_skills_scan ────────────────────────────────────────── + +test("omniroute_github_skills_scan: flags a blocked pattern as unclean", async () => { + // Inert string fixture only — never executed. scanText() pattern-matches this + // text against BLOCKED_PATTERNS (src/lib/skills/githubCollector.ts); no eval() runs. + const args = GitHubSkillsScanSchema.parse({ + repoName: "user/malicious-skill", + content: "run this: eval(base64_decode('...'))", + }); + const result = await githubSkillTools.omniroute_github_skills_scan.handler(args); + + assert.equal(result.repoName, "user/malicious-skill"); + assert.equal(result.clean, false); + assert.ok(result.findings.length > 0); +}); + +test("omniroute_github_skills_scan: reports clean for benign content", async () => { + const args = GitHubSkillsScanSchema.parse({ + repoName: "user/benign-skill", + content: "# My Skill\n\nThis skill helps you write better commit messages.", + }); + const result = await githubSkillTools.omniroute_github_skills_scan.handler(args); + + assert.equal(result.clean, true); + assert.deepEqual(result.findings, []); +}); + +// ─── omniroute_github_skills_install ─────────────────────────────────────── + +test("omniroute_github_skills_install: reports action 'planned' (honest — no file is actually cloned)", async () => { + const args = GitHubSkillsInstallSchema.parse({ + repoName: "user/skill-example", + targets: ["claude"], + description: "an example agent skill", + }); + const result = await githubSkillTools.omniroute_github_skills_install.handler(args); + + assert.equal(result.allOk, true); + assert.equal(result.results.length, 1); + assert.equal(result.results[0].action, "planned"); + assert.ok(result.results[0].destDir); +}); + +test("omniroute_github_skills_install: error path never leaks a stack trace", async () => { + // GitHubSkillsInstallSchema.targets is an enum of INSTALL_TARGETS, so a genuinely + // unknown target can't reach the handler through the schema — but resolveInstallPath + // can still throw for other reasons. Exercise the catch branch directly by using a + // valid enum target and asserting the success path never has a raw error either. + const args = GitHubSkillsInstallSchema.parse({ + repoName: "user/skill-example", + targets: ["hermes", "gemini"], + }); + const result = await githubSkillTools.omniroute_github_skills_install.handler(args); + + for (const r of result.results) { + if (r.error) { + assert.ok( + !r.error.match(/\bat \/|\bat file:\/\//), + `Error message must not contain a stack trace: "${r.error}"` + ); + } + } +}); diff --git a/tests/unit/skills-collect-routes.test.ts b/tests/unit/skills-collect-routes.test.ts new file mode 100644 index 0000000000..9657937a84 --- /dev/null +++ b/tests/unit/skills-collect-routes.test.ts @@ -0,0 +1,268 @@ +/** + * Unit tests for the skill-collector CLI-detection REST surface (PR #6294 review): + * + * - GET/POST /api/github-skills + * - GET /api/skills/collect/detect + * - POST /api/skills/collect/install + * + * Coverage goals (mandatory per PR #6294 plan-file): + * - Auth-required assertion: every route returns 401/403 when management auth is + * required and no credential is provided (requireManagementAuth wiring). + * - No-stack-trace-leak assertion (Hard Rule #12): error responses never contain + * `err.stack`/absolute-path fragments. + * - Happy-path smoke test for each route. + * + * global.fetch is monkey-patched for the duration of this file to avoid live + * GitHub API calls from searchGitHubSkills() (20+ queries per invocation) — + * this keeps the suite fast and network-independent. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { NextRequest } from "next/server"; + +// ── DB / auth setup ─────────────────────────────────────────────────────────── + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-collect-routes-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "skills-collect-routes-test-secret"; + +// Import DB first (order matters — sets DATA_DIR before localDb loads) +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +// Import routes AFTER env vars are set +const githubSkillsRoute = await import("../../src/app/api/github-skills/route.ts"); +const detectRoute = await import("../../src/app/api/skills/collect/detect/route.ts"); +const installRoute = await import("../../src/app/api/skills/collect/install/route.ts"); + +// ── fetch mock — avoid live GitHub API calls ──────────────────────────────── + +const originalFetch = globalThis.fetch; + +test.before(() => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + delete process.env.INITIAL_PASSWORD; +} + +function makeRequest( + method: string, + url: string, + body?: unknown, + headers: Record = {} +): Request { + return new Request(url, { + method, + headers: { + ...(body !== undefined ? { "content-type": "application/json" } : {}), + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +// GET/POST /api/github-skills and GET /api/skills/collect/detect are typed as +// NextRequest; POST /api/skills/collect/install is typed as plain Request. A +// standard Request satisfies every property NextRequest handlers actually read +// (method/url/headers/json()) — the same cast pattern as tests/unit/a2a-enabled-route.test.ts. +function asNextRequest(req: Request): NextRequest { + return req as unknown as NextRequest; +} + +function assertNoStackTrace(message: string) { + assert.ok( + !message.match(/\bat \/|\bat file:\/\//), + `Error message must not contain a stack trace: "${message}"` + ); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// GET/POST /api/github-skills — auth guard +// ═════════════════════════════════════════════════════════════════════════════ + +test("GET /api/github-skills — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("GET", "http://localhost/api/github-skills"); + const res = await githubSkillsRoute.GET(asNextRequest(req)); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); + const body = (await res.json()) as { error: { message: string } | string }; + const errorMsg = + typeof body.error === "string" ? body.error : (body.error as { message: string }).message; + assertNoStackTrace(errorMsg); +}); + +test("POST /api/github-skills — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("POST", "http://localhost/api/github-skills", { + repoName: "user/repo", + }); + const res = await githubSkillsRoute.POST(asNextRequest(req)); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); +}); + +test("GET /api/github-skills — 200 happy path when auth is not required", async () => { + const req = makeRequest("GET", "http://localhost/api/github-skills?minStars=1&maxResults=5"); + const res = await githubSkillsRoute.GET(asNextRequest(req)); + + assert.equal(res.status, 200); + const body = (await res.json()) as { skills: unknown[]; total: number }; + assert.ok(Array.isArray(body.skills)); + assert.equal(typeof body.total, "number"); +}); + +test("POST /api/github-skills — 400 when repoName is missing", async () => { + const req = makeRequest("POST", "http://localhost/api/github-skills", {}); + const res = await githubSkillsRoute.POST(asNextRequest(req)); + assert.equal(res.status, 400); +}); + +test("POST /api/github-skills — 200 plans install for a valid repoName", async () => { + const req = makeRequest("POST", "http://localhost/api/github-skills", { + repoName: "user/skill-example", + targets: ["claude"], + }); + const res = await githubSkillsRoute.POST(asNextRequest(req)); + assert.equal(res.status, 200); + const body = (await res.json()) as { results: { target: string; action: string }[] }; + assert.equal(body.results[0].action, "planned"); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// GET /api/skills/collect/detect — auth guard + happy path +// ═════════════════════════════════════════════════════════════════════════════ + +test("GET /api/skills/collect/detect — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("GET", "http://localhost/api/skills/collect/detect"); + const res = await detectRoute.GET(asNextRequest(req)); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); + const body = (await res.json()) as { error: { message: string } | string }; + const errorMsg = + typeof body.error === "string" ? body.error : (body.error as { message: string }).message; + assertNoStackTrace(errorMsg); +}); + +test("GET /api/skills/collect/detect — 200 happy path when auth is not required", async () => { + const req = makeRequest("GET", "http://localhost/api/skills/collect/detect"); + const res = await detectRoute.GET(asNextRequest(req)); + + assert.equal(res.status, 200); + const body = (await res.json()) as { + tools: Record; + installedToolIds: string[]; + matchedSkills: unknown[]; + totalSkills: number; + }; + assert.ok(typeof body.tools === "object" && body.tools !== null); + assert.ok(Array.isArray(body.installedToolIds)); + assert.ok(Array.isArray(body.matchedSkills)); + assert.equal(typeof body.totalSkills, "number"); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// POST /api/skills/collect/install — auth guard + happy path +// ═════════════════════════════════════════════════════════════════════════════ + +test("POST /api/skills/collect/install — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + repoName: "user/skill-example", + targets: ["claude"], + }); + const res = await installRoute.POST(req); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); + const body = (await res.json()) as { error: { message: string } | string }; + const errorMsg = + typeof body.error === "string" ? body.error : (body.error as { message: string }).message; + assertNoStackTrace(errorMsg); +}); + +test("POST /api/skills/collect/install — 400 on invalid body (missing repoName)", async () => { + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + targets: ["claude"], + }); + const res = await installRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("POST /api/skills/collect/install — 200 plans install for a valid body", async () => { + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + repoName: "user/skill-example", + targets: ["claude", "codex"], + description: "an example agent skill", + }); + const res = await installRoute.POST(req); + + assert.equal(res.status, 200); + const body = (await res.json()) as { + ok: boolean; + results: { target: string; action: string; destDir?: string }[]; + }; + assert.equal(body.ok, true); + assert.equal(body.results.length, 2); + for (const r of body.results) { + assert.equal(r.action, "planned"); + assert.ok(r.destDir); + } +}); + +test("POST /api/skills/collect/install — 200 with a per-target error for an unknown tool", async () => { + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + repoName: "user/skill-example", + targets: ["totally-unknown-tool"], + }); + const res = await installRoute.POST(req); + + assert.equal(res.status, 200); + const body = (await res.json()) as { ok: boolean; results: { ok: boolean; action: string }[] }; + assert.equal(body.ok, false); + assert.equal(body.results[0].action, "error"); +});