From 889fffddbe68346a9094fc474d3dabb3c7985be1 Mon Sep 17 00:00:00 2001 From: SeaXen <71036788+SeaXen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:43:19 +0600 Subject: [PATCH 1/4] fix(cloudflare-relay): use Service Worker syntax with body_part metadata (#6416) (#6496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): Cloudflare relay Worker uses Service Worker syntax + body_part CONTEXT: #6416/#6618 fixed the multipart Content-Type but the emitted worker source still used ES-module syntax (`export default { fetch }`) with `main_module` metadata. Cloudflare's Workers upload API parses a plain `application/javascript` script part as Service Worker syntax regardless of `main_module`, and `main_module` requires the script to actually be an ES module — so the upload was still rejected. CHANGE: buildCloudflareWorkerScript() now emits Service Worker syntax (`addEventListener("fetch", ...)`, no top-level `export`) and the upload metadata uses `body_part` instead of `main_module`. Also restores the SSRF-guard bracket-stripping regex for bracketed IPv6 hosts (`[::1]`, `[fd00::1]`) that an earlier revision of this change accidentally double-escaped, with regression coverage added to tests/unit/relay-deploy-5128.test.ts. Updates the sibling tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts assertion that still expected the old ES-module contract. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): restore CHANGELOG bullets eaten by release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore CHANGELOG bullet after further release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore CHANGELOG bullet after further release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore CHANGELOG bullet after further release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): correct CHANGELOG restoration (previous attempt had a script-path bug) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: SeaXen --- CHANGELOG.md | 1 + .../settings/proxy/cloudflare-deploy/route.ts | 18 ++-- src/lib/proxyRelay/cloudflareWorkerScript.ts | 101 ++++++++++-------- ...y-pool-cloudflare-workers-deployer.test.ts | 21 ++-- tests/unit/relay-deploy-5128.test.ts | 87 ++++++++++++++- 5 files changed, 166 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fe249bca4..c7ef085701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(cli):** compression CLI REST fallback now reads/writes the canonical `defaultMode` field (surfaced as `strategy`) instead of a nonexistent `engine` key, and table output renders nested objects as JSON instead of `[object Object]` (#6571 — thanks @charleszolot) - **fix(providers):** web-cookie providers without a `providerRegistry.ts` entry (`lmarena`, `gemini-business`, `poe-web`, `venice-web`, `v0-vercel-web`) now report `unsupported: true` instead of silently "OK" ([#6309](https://github.com/diegosouzapw/OmniRoute/pull/6309)) — `validateWebCookieProvider()` (`src/lib/providers/validation.ts`) previously required a registry entry and returned "Provider not found in registry" for these; a fallback to `WEB_COOKIE_PROVIDERS[provider].website` was proposed, but live verification showed the `${website}/models` probe does not reliably signal session validity for these providers (redirects/SPA 200s regardless of cookie validity — e.g. lmarena's real API is `arena.ai`, not `lmarena.ai`; Poe's real endpoint is a GraphQL POST, not a REST `/models`), so it would report an expired or garbage cookie as valid. Until each provider has a verified, side-effect-free auth probe against its real API host, the fallback now returns `unsupported` (no network call) instead of a false positive. Regression guard: `tests/unit/web-cookie-validation-fallback.test.ts`. (thanks @oyi77) - **fix(api):** `POST /api/middleware/hooks` and `PUT /api/middleware/hooks/[name]` no longer leak raw internal error messages in their 500 responses (#6645 — thanks @chirag127) — both catch blocks returned `error?.message` directly (Hard Rule #12), which could surface internal SQLite path fragments on a DB failure; both now route through `sanitizeErrorMessage()` from `open-sse/utils/error.ts`. Regression guard: `tests/unit/middleware-hooks-error-sanitization.test.ts`. +- **fix(providers):** the Cloudflare relay Worker deploy fix in #6416/#6618 still failed uploads in practice — it changed the multipart Content-Type but kept the emitted worker source as an ES module (`export default { fetch(...) }`) with `main_module` metadata; Cloudflare's Workers upload API parses a plain `application/javascript` script part as Service Worker syntax regardless of the `main_module` metadata field, and `main_module` requires the script to actually be an ES module (top-level `export`), so the mismatch still rejected the upload ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)). `buildCloudflareWorkerScript()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now emits Service Worker syntax (`addEventListener("fetch", ...)`, no top-level `export`) and the upload metadata uses `body_part` instead of `main_module`. Regression guard: `tests/unit/relay-deploy-5128.test.ts` (asserts the emitted script has no `export default`, registers a `fetch` listener, and the upload metadata carries `body_part`/omits `main_module`; also proves the inlined `isPrivateHostname()` SSRF guard still rejects bracketed IPv6 loopback/ULA hosts like `[::1]`/`[fd00::1]` after the script-body rewrite). (thanks @SeaXen) - **fix(providers):** ChatGPT Web (`chatgpt-web`) responses rendered raw ChatGPT UI citation markup — private-use marker tokens (e.g. `citeturn0search0`) and `url…` inline-link markers — instead of real Markdown links, since these only ever get resolved client-side by chatgpt.com's own JS using `message.metadata.content_references` ([#6635](https://github.com/diegosouzapw/OmniRoute/pull/6635)) — `cleanChatGptText()` now resolves `content_references` (grouped webpages, footnote sources, inline `webpage`/`url` mentions) into `[label](url)` Markdown links for both the streaming and non-streaming response builders, and for the GPT-5.5 Pro `stream_handoff` polled-answer path, falling back to stripping any marker that has no resolvable source instead of leaking the raw private-use bytes. The citation parsing/rendering logic was extracted into a new pure sibling module (`open-sse/executors/chatgpt-web/citations.ts`) to keep the executor under the frozen file-size cap. Regression guard: `tests/unit/chatgpt-web-citations.test.ts` (non-streaming citation resolution, streaming marker buffering across split SSE chunks, and the Pro-handoff polled-answer path). (thanks @Thinkscape) ### 📝 Maintenance diff --git a/src/app/api/settings/proxy/cloudflare-deploy/route.ts b/src/app/api/settings/proxy/cloudflare-deploy/route.ts index 85f3da7b47..7f8f7a1ef6 100644 --- a/src/app/api/settings/proxy/cloudflare-deploy/route.ts +++ b/src/app/api/settings/proxy/cloudflare-deploy/route.ts @@ -16,7 +16,8 @@ import { // guard work unchanged. Only the deployment surface differs (Cloudflare Workers // API instead of Vercel /v13/deployments). -const CLOUDFLARE_API_BASE = process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4"; +const CLOUDFLARE_API_BASE = + process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4"; export async function POST(request: Request) { const authError = await requireManagementAuth(request); @@ -52,7 +53,7 @@ export async function POST(request: Request) { try { // 1. PUT the Worker script — Cloudflare requires multipart/form-data with - // main_module + a metadata blob describing the upload. + // body_part + a metadata blob describing the upload. // // Built as a raw Buffer with an explicit boundary rather than a native // `FormData` (#6416): in production `globalThis.fetch` is patched with @@ -63,14 +64,19 @@ export async function POST(request: Request) { // with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare // rejects with "Content-Type must be one of: application/javascript, // text/javascript, multipart/form-data" — the same class of bug fixed - // for image edits in #3273. ES-module semantics come from `main_module` - // in the metadata part below, not the script part's Content-Type - // (Cloudflare rejects "application/javascript+module" outright, #5128). + // for image edits in #3273. + // + // The script part itself must stay `application/javascript` (Cloudflare + // rejects `application/javascript+module`, #5128), but with that MIME the + // uploaded body is parsed as a Service Worker, not an ES module. So the + // metadata must point at the script via `body_part`, not `main_module` — + // otherwise Cloudflare rejects the body with `Unexpected token 'export'` + // when it sees module syntax in a non-module upload (#6496 / #6416). const workerScriptUrl = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/scripts/${projectName}`; const { headers: uploadHeaders, body: uploadBody } = buildCloudflareWorkerUploadRequest( workerScript, { - main_module: "index.js", + body_part: "index.js", compatibility_date: "2026-03-20", observability: { enabled: true }, } diff --git a/src/lib/proxyRelay/cloudflareWorkerScript.ts b/src/lib/proxyRelay/cloudflareWorkerScript.ts index 64f062ad17..680254b264 100644 --- a/src/lib/proxyRelay/cloudflareWorkerScript.ts +++ b/src/lib/proxyRelay/cloudflareWorkerScript.ts @@ -13,7 +13,12 @@ * - Strips Host + relay control headers before forwarding upstream. * * The string template is fed to Cloudflare's PUT /accounts/{id}/workers/scripts/{name} - * API with main_module=index.js (ESM Workers Modules format). + * API as a Service Worker (no ES module export). Cloudflare's multipart upload + * API rejects `application/javascript+module` (#5128C) and treats a plain + * `application/javascript` script part as a Service Worker regardless of any + * `main_module` metadata — `main_module` requires the script to be an actual + * ES module (top-level `export`), which Service Worker syntax is not. The + * `body_part` metadata field is the correct way to point at a non-ESM script. * * The OmniRoute variant intentionally diverges from the upstream PR: * - The upstream worker had NO auth check, leaving the deployed workers.dev URL @@ -105,51 +110,53 @@ function isPrivateHostname(h) { return false; } -export default { - async fetch(request, env, ctx) { - const auth = request.headers.get("x-relay-auth"); - if (auth !== "${relayAuth}") { - return new Response("Unauthorized", { status: 401 }); - } - const target = request.headers.get("x-relay-target"); - if (!target) { - return new Response("missing x-relay-target", { status: 400 }); - } - let targetUrl; - try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); } - if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { - return new Response("forbidden x-relay-target protocol", { status: 403 }); - } - if (targetUrl.username || targetUrl.password) { - return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 }); - } - if (isPrivateHostname(targetUrl.hostname)) { - return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); - } - const relayPath = request.headers.get("x-relay-path") || "/"; - const headers = new Headers(request.headers); - ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach((h) => headers.delete(h)); - const init = { - method: request.method, - headers, - }; - if (request.method !== "GET" && request.method !== "HEAD") { - init.body = request.body; - init.duplex = "half"; - } - try { - const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, init); - return new Response(upstream.body, { - status: upstream.status, - headers: upstream.headers, - }); - } catch (error) { - return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), { - status: 502, - headers: { "content-type": "application/json" }, - }); - } - }, -}; +async function handleRelay(request) { + const auth = request.headers.get("x-relay-auth"); + if (auth !== "${relayAuth}") { + return new Response("Unauthorized", { status: 401 }); + } + const target = request.headers.get("x-relay-target"); + if (!target) { + return new Response("missing x-relay-target", { status: 400 }); + } + let targetUrl; + try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); } + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + return new Response("forbidden x-relay-target protocol", { status: 403 }); + } + if (targetUrl.username || targetUrl.password) { + return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 }); + } + if (isPrivateHostname(targetUrl.hostname)) { + return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); + } + const relayPath = request.headers.get("x-relay-path") || "/"; + const headers = new Headers(request.headers); + ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach((h) => headers.delete(h)); + const init = { + method: request.method, + headers, + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + try { + const upstream = await fetch(target.replace(/\\\\/$/, "") + relayPath, init); + return new Response(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } +} + +addEventListener("fetch", (event) => { + event.respondWith(handleRelay(event.request)); +}); `; } diff --git a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts index 6109a52452..2a3c717bfe 100644 --- a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts +++ b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts @@ -87,13 +87,22 @@ test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts ( assert.ok(/169\.254|link-local|fe80/.test(src), "blocks link-local hosts"); }); -test("buildCloudflareWorkerScript uses ESM default-export fetch handler (Workers Modules format)", () => { - // Cloudflare's PUT /workers/scripts API expects a module-format worker - // (main_module = index.js, content-type application/javascript+module). - // The handler must be exposed as `export default { fetch }`. +test("buildCloudflareWorkerScript uses Service Worker syntax, not an ES module (#6416/#6496)", () => { + // Cloudflare's PUT /workers/scripts API parses a plain `application/javascript` + // script part as Service Worker syntax regardless of any `main_module` + // metadata — `main_module` requires the script to actually be an ES module + // (top-level `export`), which rejects the upload with "Unexpected token + // 'export'" (#6496). The handler must instead register a `fetch` event + // listener (`addEventListener("fetch", ...)`), with no top-level `export`. const src = buildCloudflareWorkerScript("tok"); - assert.ok(/export\s+default/.test(src), "must be an ES module (export default)"); - assert.ok(/fetch\s*\(/.test(src), "must export a fetch handler"); + assert.ok( + !/^\s*export\s+default/m.test(src), + "must not be an ES module (no top-level `export default`)" + ); + assert.ok( + /addEventListener\(\s*["']fetch["']/.test(src), + "must register a fetch event listener (Service Worker syntax)" + ); }); // -------------------------------------------------------------------------- diff --git a/tests/unit/relay-deploy-5128.test.ts b/tests/unit/relay-deploy-5128.test.ts index f745280ac0..31ac95240a 100644 --- a/tests/unit/relay-deploy-5128.test.ts +++ b/tests/unit/relay-deploy-5128.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import vm from "node:vm"; // Regression tests for #5128 — one-click relay deployments (Deno + Cloudflare + // Vercel) broken in v3.8.37. Four distinct, independently-reproducible bugs: @@ -95,9 +96,7 @@ test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", a const bodyText = Buffer.isBuffer(init.body) ? (init.body as Buffer).toString("utf8") : String(init.body); - const match = bodyText.match( - /name="index\.js"[^]*?Content-Type: ([^\r\n]+)/ - ); + const match = bodyText.match(/name="index\.js"[^]*?Content-Type: ([^\r\n]+)/); scriptPartContentType = match?.[1]; // Simulate the CF API rejecting the upload so the route short-circuits // without making the follow-up subdomain calls. @@ -138,6 +137,88 @@ test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", a ); }); +// -------------------------------------------------------------------------- +// E) Cloudflare worker script uses Service Worker syntax with body_part (#6416) +// -------------------------------------------------------------------------- +test("#6416: Cloudflare worker script body is Service Worker syntax (no top-level export) + metadata uses body_part", async () => { + const realFetch = globalThis.fetch; + let capturedScriptBody = ""; + let capturedMetadata: Record | undefined; + globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => { + const url = String(input); + if (init.method === "PUT" && url.includes("/workers/scripts/") && !url.includes("/subdomain")) { + const bodyText = Buffer.isBuffer(init.body) + ? (init.body as Buffer).toString("utf8") + : String(init.body); + const scriptMatch = bodyText.match( + /name="index\.js"[^]*?Content-Type: [^\r\n]+\r\n\r\n([^]*?)\r\n--/ + ); + const metadataMatch = bodyText.match( + /name="metadata"[^]*?Content-Type: application\/json\r\n\r\n([^]*?)\r\n--/ + ); + capturedScriptBody = scriptMatch?.[1] ?? ""; + capturedMetadata = metadataMatch?.[1] + ? (JSON.parse(metadataMatch[1]) as Record) + : undefined; + return Response.json({ errors: [{ message: "stubbed" }] }, { status: 400 }); + } + return Response.json({ result: {} }); + }) as unknown as typeof globalThis.fetch; + + try { + const route = await import("../../src/app/api/settings/proxy/cloudflare-deploy/route.ts"); + await route.POST( + new Request("http://localhost/api/settings/proxy/cloudflare-deploy", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + accountId: "abcdef0123456789", + apiToken: "cf-token-aaaaaaaaaaaaaaaaaaaaaa", + projectName: "omniroute-relay", + }), + }) + ); + } finally { + globalThis.fetch = realFetch; + } + + // The Cloudflare multipart upload API parses `application/javascript` script + // parts as Service Workers, so the body must NOT use ES-module syntax + // (`export default {...}`). It must register a fetch event listener instead. + assert.ok( + !/^\s*export\s+default/m.test(capturedScriptBody), + "Cloudflare worker script must not use `export default` (#6416 — CF parses non-`+module` MIME types as Service Workers)" + ); + assert.ok( + /addEventListener\(\s*["']fetch["']/.test(capturedScriptBody), + "Cloudflare worker script must register a fetch event listener" + ); + + const privateHostnameFnSource = capturedScriptBody.match( + /function isPrivateHostname\(h\) \{[\s\S]*?\n\}/ + )?.[0]; + assert.ok(privateHostnameFnSource, "emitted worker script should contain isPrivateHostname"); + const isPrivateHostname = vm.runInNewContext( + `${privateHostnameFnSource}; isPrivateHostname;`, + {} + ) as (host: string) => boolean; + assert.equal(isPrivateHostname("[::1]"), true, "bracketed IPv6 loopback must stay blocked"); + assert.equal(isPrivateHostname("[fd00::1]"), true, "bracketed IPv6 ULA must stay blocked"); + + // Metadata must use `body_part` (Service Worker entry) rather than + // `main_module` (which requires an actual ES module). + assert.equal( + capturedMetadata?.body_part, + "index.js", + "metadata.body_part must point at the script part" + ); + assert.equal( + capturedMetadata?.main_module, + undefined, + "metadata must not use main_module — that requires an ES module script body (#6416)" + ); +}); + // -------------------------------------------------------------------------- // D) proxy-registry schema accepts deno/cloudflare relay types + sources // -------------------------------------------------------------------------- From 2a5a819dbe4a95927093acc78b9deda0fdf08877 Mon Sep 17 00:00:00 2001 From: nowhats-br Date: Thu, 9 Jul 2026 18:57:06 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20update=20Dockerfile=20with=20--allow?= =?UTF-8?q?-scripts=20for=20better-sqlite3=20compil=E2=80=A6=20(#6700)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(docker): compile better-sqlite3 via direct node-gyp rebuild in the Dockerfile The `builder` stage installs dependencies with `npm ci --ignore-scripts` (deliberate supply-chain hardening) and then re-enables the native build for the one package that needs it. `npm rebuild better-sqlite3` re-runs that indirectly through the package's own install script, which under npm 11 depends on npm's script-allowlist machinery correctly re-enabling it — some self-hosted build environments (e.g. Dokploy) hit a broken/mismatched native binding through that indirection. Invoke `node-gyp rebuild` directly inside `node_modules/better-sqlite3` instead, bypassing npm's script-running layer entirely, so the compile step is deterministic regardless of npm version or ignore-scripts allowlist behavior. Rebased onto the current release/v3.8.47 tip: dropped this branch's stale electron/package.json + package-lock.json diff (would have reverted the electron 42->43 ABI-148 fix from #6605) and the unconsumed root `allowScripts` package.json field (npm does not read that key; has zero effect). Regression guard: tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): restore CHANGELOG bullets eaten by release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore CHANGELOG bullet after further release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore CHANGELOG bullet after further release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore CHANGELOG bullet after further release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): correct CHANGELOG restoration (previous attempt had a script-path bug) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6700 bullet after #6496 release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore CHANGELOG bullet after further release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: nowhats-br --- CHANGELOG.md | 1 + Dockerfile | 9 ++- ...rfile-better-sqlite3-node-gyp-6700.test.ts | 79 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ef085701..1fc717836c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(cli):** compression CLI REST fallback now reads/writes the canonical `defaultMode` field (surfaced as `strategy`) instead of a nonexistent `engine` key, and table output renders nested objects as JSON instead of `[object Object]` (#6571 — thanks @charleszolot) - **fix(providers):** web-cookie providers without a `providerRegistry.ts` entry (`lmarena`, `gemini-business`, `poe-web`, `venice-web`, `v0-vercel-web`) now report `unsupported: true` instead of silently "OK" ([#6309](https://github.com/diegosouzapw/OmniRoute/pull/6309)) — `validateWebCookieProvider()` (`src/lib/providers/validation.ts`) previously required a registry entry and returned "Provider not found in registry" for these; a fallback to `WEB_COOKIE_PROVIDERS[provider].website` was proposed, but live verification showed the `${website}/models` probe does not reliably signal session validity for these providers (redirects/SPA 200s regardless of cookie validity — e.g. lmarena's real API is `arena.ai`, not `lmarena.ai`; Poe's real endpoint is a GraphQL POST, not a REST `/models`), so it would report an expired or garbage cookie as valid. Until each provider has a verified, side-effect-free auth probe against its real API host, the fallback now returns `unsupported` (no network call) instead of a false positive. Regression guard: `tests/unit/web-cookie-validation-fallback.test.ts`. (thanks @oyi77) - **fix(api):** `POST /api/middleware/hooks` and `PUT /api/middleware/hooks/[name]` no longer leak raw internal error messages in their 500 responses (#6645 — thanks @chirag127) — both catch blocks returned `error?.message` directly (Hard Rule #12), which could surface internal SQLite path fragments on a DB failure; both now route through `sanitizeErrorMessage()` from `open-sse/utils/error.ts`. Regression guard: `tests/unit/middleware-hooks-error-sanitization.test.ts`. +- **fix(docker):** compile better-sqlite3 for the server Docker image (Dokploy/self-hosted builds) via a direct `node-gyp rebuild` inside `node_modules/better-sqlite3`, instead of `npm rebuild better-sqlite3` ([#6700](https://github.com/diegosouzapw/OmniRoute/pull/6700)) — the `builder` stage installs dependencies with `npm ci --ignore-scripts` (deliberate: closes the supply-chain surface where a transitive dep's install script runs arbitrary code) and re-enables the native build for the one package that needs it; `npm rebuild ` re-runs that indirectly through the package's own install script, which under npm 11 depends on npm's script-allowlist machinery correctly re-enabling it — some self-hosted build environments (e.g. Dokploy) hit a broken/mismatched native binding through that indirection. Invoking `node-gyp rebuild` directly bypasses npm's script-running layer entirely and is deterministic regardless of npm version. Regression guard: `tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts`. (thanks @nowhats-br) - **fix(providers):** the Cloudflare relay Worker deploy fix in #6416/#6618 still failed uploads in practice — it changed the multipart Content-Type but kept the emitted worker source as an ES module (`export default { fetch(...) }`) with `main_module` metadata; Cloudflare's Workers upload API parses a plain `application/javascript` script part as Service Worker syntax regardless of the `main_module` metadata field, and `main_module` requires the script to actually be an ES module (top-level `export`), so the mismatch still rejected the upload ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)). `buildCloudflareWorkerScript()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now emits Service Worker syntax (`addEventListener("fetch", ...)`, no top-level `export`) and the upload metadata uses `body_part` instead of `main_module`. Regression guard: `tests/unit/relay-deploy-5128.test.ts` (asserts the emitted script has no `export default`, registers a `fetch` listener, and the upload metadata carries `body_part`/omits `main_module`; also proves the inlined `isPrivateHostname()` SSRF guard still rejects bracketed IPv6 loopback/ULA hosts like `[::1]`/`[fd00::1]` after the script-body rewrite). (thanks @SeaXen) - **fix(providers):** ChatGPT Web (`chatgpt-web`) responses rendered raw ChatGPT UI citation markup — private-use marker tokens (e.g. `citeturn0search0`) and `url…` inline-link markers — instead of real Markdown links, since these only ever get resolved client-side by chatgpt.com's own JS using `message.metadata.content_references` ([#6635](https://github.com/diegosouzapw/OmniRoute/pull/6635)) — `cleanChatGptText()` now resolves `content_references` (grouped webpages, footnote sources, inline `webpage`/`url` mentions) into `[label](url)` Markdown links for both the streaming and non-streaming response builders, and for the GPT-5.5 Pro `stream_handoff` polled-answer path, falling back to stripping any marker that has no resolvable source instead of leaking the raw private-use bytes. The citation parsing/rendering logic was extracted into a new pure sibling module (`open-sse/executors/chatgpt-web/citations.ts`) to keep the executor under the frozen file-size cap. Regression guard: `tests/unit/chatgpt-web-citations.test.ts` (non-streaming citation resolution, streaming marker buffering across split SSE chunks, and the Pro-handoff polled-answer path). (thanks @Thinkscape) diff --git a/Dockerfile b/Dockerfile index 81df902f13..e57cef96da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,9 +55,16 @@ ENV NPM_CONFIG_LEGACY_PEER_DEPS=true # are reproducible. RUN test -f package-lock.json \ || (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1) +# `npm rebuild ` re-runs the package's own install script, so under npm 11 + +# `--ignore-scripts` on the parent `npm ci` it depends on npm's script-allowlist +# machinery correctly re-enabling that one package's script. Some self-hosted build +# environments (e.g. Dokploy) hit a broken/incomplete better-sqlite3 native binding +# from that indirection. Invoking `node-gyp rebuild` directly inside the package +# directory bypasses npm's script-running layer entirely and is deterministic +# regardless of npm version or ignore-scripts allowlist behavior. RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \ - && npm rebuild better-sqlite3 \ + && (cd node_modules/better-sqlite3 && npx --yes node-gyp rebuild) \ && node -e "require('better-sqlite3')(':memory:').close()" # Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era diff --git a/tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts b/tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts new file mode 100644 index 0000000000..36d0441362 --- /dev/null +++ b/tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts @@ -0,0 +1,79 @@ +/** + * #6700 — Dokploy (and some other self-hosted) Docker builds ended up with a + * broken/mismatched better-sqlite3 native binding under npm 11. The `builder` + * stage installed dependencies with `npm ci --ignore-scripts` (deliberate — it + * closes the supply-chain surface where a transitive dep's install script runs + * arbitrary code) and then re-enabled the native build for the one package that + * needs it via `npm rebuild better-sqlite3`. `npm rebuild` re-runs the package's + * own install script indirectly, which depends on npm's script-allowlist + * machinery correctly re-enabling that single package's script — some + * self-hosted build environments hit a broken build via that indirection. + * + * Fix: invoke `node-gyp rebuild` directly inside `node_modules/better-sqlite3`, + * bypassing npm's script-running layer entirely, so the compile step is + * deterministic regardless of npm version or ignore-scripts allowlist behavior. + * + * This guards the mechanism (the direct node-gyp invocation replaces the + * `npm rebuild` indirection, and a smoke-load still follows it); the end-to-end + * "the Dokploy build now produces a working binding" proof is a successful + * `docker build` in that environment (tracked as a live-validation follow-up — + * this sandbox has no accessible Docker daemon to run the real build). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8"); +const lines = dockerfile.split("\n"); + +/** Line indices that bound the `builder` stage (from its FROM to the next FROM). */ +function builderStageRange(): { start: number; end: number } { + const start = lines.findIndex((l) => /^FROM\s+\S+\s+AS\s+builder\b/i.test(l.trim())); + assert.ok(start >= 0, "Dockerfile must declare a `builder` stage"); + const after = lines.slice(start + 1).findIndex((l) => /^FROM\s+/i.test(l.trim())); + const end = after === -1 ? lines.length : start + 1 + after; + return { start, end }; +} + +test("#6700 builder stage compiles better-sqlite3 via a direct node-gyp rebuild, not `npm rebuild`", () => { + const { start, end } = builderStageRange(); + const stage = lines.slice(start, end).join("\n"); + + assert.match( + stage, + /cd node_modules\/better-sqlite3\s*&&\s*npx\s+(--yes\s+)?node-gyp rebuild/, + "builder stage must compile better-sqlite3 by invoking node-gyp directly inside its " + + "package directory (bypasses npm's rebuild-script indirection)" + ); + assert.doesNotMatch( + stage, + /npm rebuild better-sqlite3/, + "builder stage must not fall back to `npm rebuild better-sqlite3` — that indirection " + + "is the #6700 Dokploy build failure mode" + ); +}); + +test("#6700 the better-sqlite3 rebuild happens after `npm ci --ignore-scripts` and before the smoke-load", () => { + const { start, end } = builderStageRange(); + // Ignore comment lines (`#…`) so prose that merely mentions these commands + // (e.g. explaining *why* in a comment above the RUN step) is not mistaken + // for the real instruction when checking ordering. + const stage = lines.slice(start, end).filter((l) => !l.trim().startsWith("#")); + + const ignoreScriptsIdx = stage.findIndex((l) => /npm ci\b.*--ignore-scripts/.test(l)); + const rebuildIdx = stage.findIndex((l) => /node-gyp rebuild/.test(l)); + const smokeLoadIdx = stage.findIndex((l) => + /node -e ".*require\('better-sqlite3'\)\(':memory:'\)\.close\(\)"/.test(l) + ); + + assert.ok(ignoreScriptsIdx >= 0, "builder stage must run `npm ci --ignore-scripts`"); + assert.ok(rebuildIdx >= 0, "builder stage must run the better-sqlite3 node-gyp rebuild"); + assert.ok(smokeLoadIdx >= 0, "builder stage must smoke-load better-sqlite3 after the rebuild"); + assert.ok( + ignoreScriptsIdx <= rebuildIdx && rebuildIdx <= smokeLoadIdx, + "order must be: npm ci --ignore-scripts -> node-gyp rebuild -> smoke-load" + ); +}); From b9d18dd8c4701b6c995f9c507336da829b3f3e93 Mon Sep 17 00:00:00 2001 From: Moseyuh333 <148680980+Moseyuh333@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:43:43 +0700 Subject: [PATCH 3/4] Continue fix bugs and upgrade skill_collector (#6294) * fix(skills): gate skill-collector CLI detection behind management auth + loopback PR #6294 fork-main bundled genuinely new skill-collector CLI-detection routes (GET /api/skills/collect/detect, POST /api/skills/collect/install) on top of content already shipped via #6186. This reconstructs the PR against the current release tip, keeping only the new detect/install routes and their SKILL.md, and drops the 3 already-merged commits so two post-merge quality fixes on /api/github-skills (Zod validation + sanitizeErrorMessage) are not reverted. - GET /api/skills/collect/detect spawned a child process per CLI_TOOL_IDS entry via getCliRuntimeStatus(), unauthenticated and reachable over any tunnel. All 3 routes (github-skills GET/POST, skills/collect/detect, skills/collect/install) now require requireManagementAuth(), matching every sibling /api/skills/* route. - Classified /api/skills/collect/ in LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES (routeGuard.ts / spawnCapablePrefixes.ts) and added src/app/api/skills/collect to SPAWN_CAPABLE_ROUTE_ROOTS in check-route-guard-membership.ts so the automated gate actually scans it (Hard Rules #15 + #17). - omniroute_github_skills_install MCP tool now reports the honest action: "planned" instead of "installed", matching the REST route. - Dropped docker-compose.drive-d.yml, start.sh, and the unrelated @types/node/settings.ts changes (personal dev-machine / out-of-scope). - Added route-level tests for all 3 routes + the 3 MCP tools (auth-required and no-stack-trace-leak assertions) and a route-guard regression test. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(quality): register new routeGuard covering test in stryker.conf.json check:mutation-test-coverage --strict (Fast Quality Gates) flagged tests/unit/authz/route-guard-skills-collect.test.ts as a covering unit test for src/server/authz/routeGuard.ts that was missing from tap.testFiles, so its mutant kills would silently not count toward the mutation-test baseline. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore: resync CHANGELOG after merging release/v3.8.47 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Moseyuh333 --- CHANGELOG.md | 1 + open-sse/mcp-server/tools/githubSkillTools.ts | 6 +- scripts/check/check-route-guard-membership.ts | 1 + skills/README.md | 102 +++---- skills/cli-skill-collector/SKILL.md | 152 ++++++++++ src/app/api/github-skills/route.ts | 7 + src/app/api/skills/collect/detect/route.ts | 164 +++++++++++ src/app/api/skills/collect/install/route.ts | 130 +++++++++ src/lib/skills/githubCollector.ts | 2 +- src/server/authz/routeGuard.ts | 1 + src/shared/constants/spawnCapablePrefixes.ts | 1 + stryker.conf.json | 1 + .../authz/route-guard-skills-collect.test.ts | 51 ++++ ...spawn-capable-prefixes-client-safe.test.ts | 3 +- tests/unit/github-skill-tools-mcp.test.ts | 104 +++++++ tests/unit/skills-collect-routes.test.ts | 268 ++++++++++++++++++ 16 files changed, 940 insertions(+), 54 deletions(-) create mode 100644 skills/cli-skill-collector/SKILL.md create mode 100644 src/app/api/skills/collect/detect/route.ts create mode 100644 src/app/api/skills/collect/install/route.ts create mode 100644 tests/unit/authz/route-guard-skills-collect.test.ts create mode 100644 tests/unit/github-skill-tools-mcp.test.ts create mode 100644 tests/unit/skills-collect-routes.test.ts 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"); +}); From 9906dfc1ba4a8b7381c2ce166e3047a28543826b Mon Sep 17 00:00:00 2001 From: janeza2 <49841619+janeza2@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:54:05 +0700 Subject: [PATCH 4/4] fix(providers): update web model discovery (#6308) Co-authored-by: Diego Rodrigues de Sa e Souza --- open-sse/executors/kimi-web.ts | 29 ++++++-- .../models/discovery/providerModelsConfig.ts | 38 +++++++--- .../providers/[id]/models/discoveryConfig.ts | 16 +--- src/app/api/providers/[id]/models/route.ts | 4 +- src/lib/providers/validation/webProvidersA.ts | 2 +- .../catalog-updates-v3829-kimi-qwen.test.ts | 6 +- tests/unit/executor-kimi-web.test.ts | 19 +++++ tests/unit/kimi-web-models-discovery.test.ts | 74 +++++++++++++++++++ .../provider-models-discovery-split.test.ts | 2 +- .../provider-validation-specialty.test.ts | 2 +- .../qwen-web-models-discovery-3931.test.ts | 10 ++- 11 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 tests/unit/kimi-web-models-discovery.test.ts diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts index cf4ff4bdbe..a2fe1e6df1 100644 --- a/open-sse/executors/kimi-web.ts +++ b/open-sse/executors/kimi-web.ts @@ -26,7 +26,10 @@ * session; the upstream returns the same response either way. */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts"; +import { + makeExecutorErrorResult as makeErrorResult, + sanitizeErrorMessage, +} from "../utils/error.ts"; import { extractKimiJwt } from "@/lib/providers/webCookieAuth"; export { extractKimiJwt }; @@ -93,7 +96,10 @@ const MAX_FRAME_LEN = 8 * 1024 * 1024; * (caller must treat this as a stream-fatal protocol error) * - `consumed: N` + the parsed frame otherwise */ -export function decodeConnectFrame(buf: Uint8Array, byteOffset: number): { consumed: number; frame: ConnectFrame | null } { +export function decodeConnectFrame( + buf: Uint8Array, + byteOffset: number +): { consumed: number; frame: ConnectFrame | null } { if (byteOffset + 5 > buf.length) return { consumed: 0, frame: null }; const flags = buf[byteOffset]; const len = @@ -130,7 +136,9 @@ type DeltaKind = "text" | "think" | null; * Anything else (heartbeats, chat/message metadata, stage transitions) is * suppressed; we only surface text to the client. */ -export function extractDelta(msg: Record | null): { kind: DeltaKind; text: string } | null { +export function extractDelta( + msg: Record | null +): { kind: DeltaKind; text: string } | null { if (!msg) return null; const op = String(msg.op ?? ""); const mask = String(msg.mask ?? ""); @@ -167,7 +175,11 @@ export function isEndOfStream(msg: Record | null): boolean { if (!msg) return false; // Assistant message flipped to COMPLETED. const message = (msg.message ?? null) as Record | null; - if (message && String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && String(message.role ?? "") === "assistant") { + if ( + message && + String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && + String(message.role ?? "") === "assistant" + ) { return true; } return false; @@ -252,7 +264,7 @@ export class KimiWebExecutor extends BaseExecutor { } const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || []; - const modelId = (bodyObj.model as string) || "kimi-default"; + const modelId = (bodyObj.model as string) || "k2d6"; // Resolve scenario + default thinking flag from the model id (catalog truth), // then honour an explicit `reasoning_effort: "none"` override from the caller. const modelConfig = resolveModelConfig(modelId); @@ -285,7 +297,12 @@ export class KimiWebExecutor extends BaseExecutor { if (!upstream.ok) { const errText = await upstream.text().catch(() => ""); - return makeErrorResult(upstream.status, `Kimi error: ${sanitizeErrorMessage(errText)}`, body, CHAT_URL); + return makeErrorResult( + upstream.status, + `Kimi error: ${sanitizeErrorMessage(errText)}`, + body, + CHAT_URL + ); } const encoder = new TextEncoder(); diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 6e8af36e05..7f7a64e595 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -3,6 +3,7 @@ import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityH import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts"; import { normalizeOpenAiLikeModelsResponse } from "./normalizers"; +import { extractKimiJwt } from "@/lib/providers/webCookieAuth"; export type ProviderModelsConfigEntry = { url: string; @@ -12,6 +13,7 @@ export type ProviderModelsConfigEntry = { authPrefix?: string; authQuery?: string; body?: unknown; + buildHeaders?: (token: string) => Record; parseResponse: (data: any) => any; }; @@ -60,10 +62,10 @@ export const PROVIDER_MODELS_CONFIG: Record = }, // #3931: qwen-web (cookie provider) was missing here, so its discovery page // showed nothing (the OAuth fallback above only fires for provider==="qwen"). - // `chat.qwen.ai/api/v2/models` is public (no auth header configured/sent); + // `chat.qwen.ai/api/v2/models/` is public (no auth header configured/sent); // shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback. "qwen-web": { - url: "https://chat.qwen.ai/api/v2/models", + url: "https://chat.qwen.ai/api/v2/models/", method: "GET", headers: { "Content-Type": "application/json" }, parseResponse: (data) => { @@ -78,18 +80,34 @@ export const PROVIDER_MODELS_CONFIG: Record = }, }, // #5858 follow-up: kimi-web (cookie provider) on the international domain. - // `GetAvailableModels` returns the model list as a plain JSON envelope - // (no Connect framing on either request or response — only the chat - // completion endpoint uses the 5-byte envelope). Auth: Bearer JWT extracted - // from the `kimi-auth` cookie the user pasted. Agent variants + // `GetAvailableModels` returns the model list as a plain JSON envelope. + // Auth mirrors the web app: Bearer JWT plus `Cookie: kimi-auth=`. + // Agent variants // (`k2d6-agent*`) need a different scenario + agent fields this executor // doesn't shape, so they're filtered out. "kimi-web": { url: "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels", - method: "GET", - headers: { accept: "application/json, text/plain, */*", "Content-Type": "application/json" }, - authHeader: "Authorization", - authPrefix: "Bearer ", + method: "POST", + headers: { accept: "*/*", "Content-Type": "application/json" }, + body: {}, + buildHeaders: (token) => { + const jwt = extractKimiJwt(token); + return { + accept: "*/*", + "Content-Type": "application/json", + "connect-protocol-version": "1", + Origin: "https://www.kimi.com", + Referer: "https://www.kimi.com/", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + ...(jwt + ? { + Authorization: `Bearer ${jwt}`, + Cookie: `kimi-auth=${jwt}`, + } + : {}), + }; + }, parseResponse: (data) => { const list = (data?.availableModels || []) as Array<{ key?: string; diff --git a/src/app/api/providers/[id]/models/discoveryConfig.ts b/src/app/api/providers/[id]/models/discoveryConfig.ts index 5432cc8c32..ef6b6c4681 100644 --- a/src/app/api/providers/[id]/models/discoveryConfig.ts +++ b/src/app/api/providers/[id]/models/discoveryConfig.ts @@ -1,4 +1,5 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import type { ProviderModelsConfigEntry } from "./discovery/providerModelsConfig"; /** * Derive a models-discovery config from the provider's registry `modelsUrl` @@ -8,18 +9,9 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts * OpenAI-compatible `/v1/models` endpoint, or `undefined` when the * registry entry has no `modelsUrl`. */ -export function deriveConfigFromRegistryModelsUrl(provider: string): - | { - url: string; - method: "GET"; - headers: Record; - authHeader?: string; - authPrefix?: string; - authQuery?: string; - body?: unknown; - parseResponse: (data: any) => any; - } - | undefined { +export function deriveConfigFromRegistryModelsUrl( + provider: string +): ProviderModelsConfigEntry | undefined { const entry = getRegistryEntry(provider); if (typeof entry?.modelsUrl === "string" && entry.modelsUrl.length > 0) { return { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index c862d4e508..153f2bc232 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1806,8 +1806,8 @@ export async function GET( } // Build headers - const headers = { ...config.headers }; - if (config.authHeader && !config.authQuery) { + const headers = config.buildHeaders ? config.buildHeaders(token) : { ...config.headers }; + if (!config.buildHeaders && config.authHeader && !config.authQuery) { headers[config.authHeader] = (config.authPrefix || "") + token; } diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts index ef8c1fd366..2c537ac035 100644 --- a/src/lib/providers/validation/webProvidersA.ts +++ b/src/lib/providers/validation/webProvidersA.ts @@ -144,7 +144,7 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) { } // qwen-web has no `modelsUrl` in its registry entry, so the generic OpenAI-compatible -// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models` (via +// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models/` (via // addModelsSuffix) — a non-existent path that answers with a 307 redirect, which the // outbound guard blocked and the route then mislabeled as an SSRF block (#3288/#3758). // diff --git a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts index 5e40d43196..badf1a0fe7 100644 --- a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts +++ b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts @@ -66,12 +66,12 @@ test("PROVIDER_MODELS_CONFIG contains a qwen-web entry (issue #3931 bug #3)", () ); }); -test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models", () => { +test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models/", () => { const src = fs.readFileSync(CONFIG_FILE, "utf-8"); assert.match( src, - /chat\.qwen\.ai\/api\/v2\/models/, - "qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models" + /chat\.qwen\.ai\/api\/v2\/models\//, + "qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models/" ); }); diff --git a/tests/unit/executor-kimi-web.test.ts b/tests/unit/executor-kimi-web.test.ts index bff69e30d6..5b3c7de203 100644 --- a/tests/unit/executor-kimi-web.test.ts +++ b/tests/unit/executor-kimi-web.test.ts @@ -9,6 +9,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/kimi-web.ts"); +const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts"); describe("KimiWebExecutor", () => { it("can be instantiated", () => { @@ -79,6 +80,24 @@ describe("resolveModelConfig", () => { }); }); +describe("kimi-web catalog", () => { + it("lists only currently supported non-agent web models", () => { + const models = getModelsByProviderId("kimi-web"); + assert.deepEqual( + models.map((model) => ({ id: model.id, name: model.name })), + [ + { id: "k2d6", name: "K2.6 Instant" }, + { id: "k2d6-thinking", name: "K2.6 Thinking" }, + ] + ); + assert.ok(models.find((model) => model.id === "k2d6-thinking")?.supportsReasoning); + assert.ok(!models.some((model) => model.id.includes("agent"))); + assert.ok( + !models.some((model) => ["kimi-default", "kimi-k2.6", "kimi-128k"].includes(model.id)) + ); + }); +}); + describe("extractKimiJwt", () => { const { extractKimiJwt } = mod; diff --git a/tests/unit/kimi-web-models-discovery.test.ts b/tests/unit/kimi-web-models-discovery.test.ts new file mode 100644 index 0000000000..676347c9e2 --- /dev/null +++ b/tests/unit/kimi-web-models-discovery.test.ts @@ -0,0 +1,74 @@ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kimi-web-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("kimi-web model discovery sends Kimi auth as bearer and cookie", async () => { + await resetStorage(); + const jwt = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIn0.signature"; + const connection = await providersDb.createProviderConnection({ + provider: "kimi-web", + authType: "apikey", + name: "kimi-web-discovery", + apiKey: `_ga=ignored; theme=dark; kimi-auth=${jwt}; __cf_bm=ignored`, + }); + + let captured: { url: string; init?: RequestInit } | null = null; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + captured = { url: String(url), init }; + return Response.json({ + availableModels: [ + { key: "k2d6", displayName: "K2.6 Instant" }, + { key: "k2d6-thinking", displayName: "K2.6 Thinking", thinking: true }, + { key: "k2d6-agent", displayName: "K2.6 Agent" }, + { key: "k2d6-agent-ultra", displayName: "K2.6 Agent Swarm" }, + ], + }); + }) as typeof globalThis.fetch; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, "api"); + assert.deepEqual( + body.models.map((model: { id: string }) => model.id), + ["k2d6", "k2d6-thinking"] + ); + assert.equal( + captured?.url, + "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels" + ); + assert.equal(captured?.init?.method, "POST"); + assert.equal(captured?.init?.body, "{}"); + const headers = captured?.init?.headers as Record; + assert.equal(headers.Authorization, `Bearer ${jwt}`); + assert.equal(headers.Cookie, `kimi-auth=${jwt}`); + assert.equal(headers["connect-protocol-version"], "1"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/provider-models-discovery-split.test.ts b/tests/unit/provider-models-discovery-split.test.ts index e6bcd11ed0..e31eb17601 100644 --- a/tests/unit/provider-models-discovery-split.test.ts +++ b/tests/unit/provider-models-discovery-split.test.ts @@ -117,7 +117,7 @@ test("providerSets.isNamedOpenAIStyleProvider matches Set membership", () => { test("providerModelsConfig.PROVIDER_MODELS_CONFIG keeps core provider entries", () => { assert.equal(PROVIDER_MODELS_CONFIG.claude.url, "https://api.anthropic.com/v1/models"); - assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models"); + assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models/"); }); test("providerModelsConfig keeps the aimlapi live catalog entry", () => { diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index e1afca203e..a97127bc32 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -2766,7 +2766,7 @@ test("gitlawb-gmi validator: accepts custom baseUrl override", async () => { test("isSecurityBlockError: public-host redirect block is NOT a security block", () => { const publicRedirect = new SafeOutboundFetchError("Redirect blocked", { code: "REDIRECT_BLOCKED", - url: "https://chat.qwen.ai/api/v2/models", + url: "https://chat.qwen.ai/api/v2/models/", method: "GET", attempts: 1, status: 307, diff --git a/tests/unit/qwen-web-models-discovery-3931.test.ts b/tests/unit/qwen-web-models-discovery-3931.test.ts index 99e50840f2..3610311767 100644 --- a/tests/unit/qwen-web-models-discovery-3931.test.ts +++ b/tests/unit/qwen-web-models-discovery-3931.test.ts @@ -11,7 +11,7 @@ * streaming endpoint — is a separate upstream/stealth concern, still open.) * * Fix: add a `qwen-web` PROVIDER_MODELS_CONFIG entry pointing at the public - * `https://chat.qwen.ai/api/v2/models` endpoint, parsing the + * `https://chat.qwen.ai/api/v2/models/` endpoint, parsing the * `{ data: { data: [{ id, name, owned_by }] } }` shape. */ import test from "node:test"; @@ -45,7 +45,7 @@ interface ModelsBody { source?: string; } -const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models"; +const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models/"; test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", async () => { await resetStorage(); @@ -83,7 +83,11 @@ test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", assert.equal(response.status, 200); const body = (await response.json()) as ModelsBody; assert.equal(body.provider, "qwen-web"); - assert.equal(body.source, "api", "should serve the live qwen-web catalog, not local_catalog/empty"); + assert.equal( + body.source, + "api", + "should serve the live qwen-web catalog, not local_catalog/empty" + ); assert.ok(fetchedUrl, `should have probed ${QWEN_WEB_MODELS_URL}`); const ids = body.models.map((m) => m.id); assert.ok(ids.includes("qwen3-max"), `live ids missing: ${ids.join(",")}`);