From 45c62cfd89a13408afbf4278e798ff4e4754c425 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:17:37 -0300 Subject: [PATCH 01/13] fix(ci): skip auto-deploy when VPS host is unreachable from the runner (#3299) Integrated into release/v3.8.13 --- .github/workflows/deploy-vps.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 760a53ba1e..bcd5b61c78 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -17,7 +17,33 @@ jobs: name: Deploy OmniRoute to VPS runs-on: ubuntu-latest steps: + - name: Check VPS SSH reachability from runner + id: reach + env: + # Pass the host via env (never interpolate a secret straight into the + # script body) so /dev/tcp gets a shell variable, not inlined text. + VPS_HOST: ${{ secrets.VPS_HOST }} + run: | + set -uo pipefail + # A GitHub-hosted runner can only deploy when it can actually open a TCP + # connection to the VPS SSH port. The Local VPS lives on a private LAN and + # the Akamai host firewalls :22 to known IPs, so the runner is routinely + # unable to reach it (`dial tcp ***:22: i/o timeout`). Treat "unreachable + # from the runner" as a SKIP — the real deploys are run manually from an + # allowed network via the deploy-vps-local / deploy-vps-akamai skills — so + # an unreachable host no longer red-fails every release/push pipeline. + # When the host IS reachable, the deploy step below still runs in full and + # its health gate surfaces any genuine deploy failure. + if timeout 15 bash -c 'exec 3<>"/dev/tcp/${VPS_HOST}/22"' 2>/dev/null; then + echo "reachable=true" >> "$GITHUB_OUTPUT" + echo "✅ VPS_HOST:22 reachable from the runner — proceeding with deploy." + else + echo "reachable=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Auto-deploy skipped::VPS_HOST:22 is not reachable from this GitHub runner (private LAN / firewalled). Deploy manually with the deploy-vps-local or deploy-vps-akamai skill." + fi + - name: Deploy via SSH + if: steps.reach.outputs.reachable == 'true' uses: appleboy/ssh-action@v1 with: host: ${{ secrets.VPS_HOST }} From 5a241ffca996004a4e781300d0db211c4356d310 Mon Sep 17 00:00:00 2001 From: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:17:39 -0300 Subject: [PATCH 02/13] fix(dev): auto-rebuild better-sqlite3 on Node ABI mismatch at dev startup (#3301) Integrated into release/v3.8.13 --- scripts/dev/ensure-native-sqlite.mjs | 114 ++++++++++++++++++++ scripts/dev/run-next.mjs | 7 ++ tests/unit/dev-ensure-native-sqlite.test.ts | 112 +++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 scripts/dev/ensure-native-sqlite.mjs create mode 100644 tests/unit/dev-ensure-native-sqlite.test.ts diff --git a/scripts/dev/ensure-native-sqlite.mjs b/scripts/dev/ensure-native-sqlite.mjs new file mode 100644 index 0000000000..1edbeb8268 --- /dev/null +++ b/scripts/dev/ensure-native-sqlite.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/** + * OmniRoute — Dev-startup native SQLite ABI guard. + * + * `better-sqlite3` is a native addon compiled for a specific Node.js ABI + * (NODE_MODULE_VERSION). This project supports both Node 22 (ABI 127) and + * Node 24 (ABI 137); switching between them via nvm leaves the previously + * built `better_sqlite3.node` incompatible, so `npm run dev` crashes during + * bootstrap with: + * + * "The module '…/better_sqlite3.node' was compiled against a different + * Node.js version using NODE_MODULE_VERSION 127. This version of Node.js + * requires NODE_MODULE_VERSION 137." + * + * `postinstall.mjs` only fixes the published standalone bundle and only runs + * on `npm install` — it does NOT cover "cloned repo, switched Node, ran dev". + * + * This guard probes the root binary against the *current* Node ABI and, ONLY + * when it detects a genuine ABI mismatch, runs `npm rebuild better-sqlite3` + * once. The healthy path (matching ABI) does no work, so dev startup stays + * fast. Unrelated errors are NOT swallowed — they fall through so the normal + * bootstrap surfaces them. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); + +export const SQLITE_BINARY = join( + ROOT, + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); + +/** + * Whether an error message indicates a native-addon ABI / load mismatch + * (as opposed to an unrelated runtime error such as a missing table). + * Mirrors the detection in src/lib/db/core.ts::isNativeSqliteLoadError. + * @param {unknown} message + * @returns {boolean} + */ +export function isNativeAbiMismatch(message) { + const m = String(message ?? ""); + return ( + m.includes("NODE_MODULE_VERSION") || + m.includes("was compiled against a different Node.js version") || + m.includes("Module did not self-register") || + m.includes("ERR_DLOPEN_FAILED") || + m.includes("Could not locate the bindings file") + ); +} + +/** Probe a native binary against the current Node ABI without polluting the require cache. */ +function probeLoad(binaryPath) { + process.dlopen({ exports: {} }, binaryPath); +} + +/** Default rebuild: `npm rebuild better-sqlite3` at the repo root (no shell interpolation). */ +function defaultRebuild() { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(npm, ["rebuild", "better-sqlite3"], { cwd: ROOT, stdio: "inherit" }); + return result.status === 0; +} + +/** + * Ensure better-sqlite3 loads under the current Node. Rebuilds once on ABI + * mismatch. Returns a result object; never throws for the mismatch path. + * + * @param {{ logger?: Pick, rebuild?: () => boolean, probe?: (p: string) => void, binaryPath?: string }} [opts] + * @returns {{ ok: boolean, rebuilt: boolean, error?: unknown }} + */ +export function ensureNativeSqlite(opts = {}) { + const { + logger = console, + rebuild = defaultRebuild, + probe = probeLoad, + binaryPath = SQLITE_BINARY, + } = opts; + + // Nothing built yet (fresh clone before install) — let install/bootstrap handle it. + if (!existsSync(binaryPath)) return { ok: true, rebuilt: false }; + + try { + probe(binaryPath); + return { ok: true, rebuilt: false }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!isNativeAbiMismatch(message)) { + // Not an ABI problem — do not mask it; bootstrap will surface the real error. + return { ok: false, rebuilt: false, error }; + } + logger.warn( + `[dev] better-sqlite3 was built for a different Node ABI than ${process.version} — ` + + "rebuilding (one-time)…" + ); + if (!rebuild()) { + logger.error( + "[dev] Automatic 'npm rebuild better-sqlite3' failed. Run it manually:\n" + + " npm rebuild better-sqlite3" + ); + return { ok: false, rebuilt: false }; + } + logger.log("[dev] better-sqlite3 rebuilt for the current Node. Continuing startup."); + return { ok: true, rebuilt: true }; + } +} diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 7a956d93a9..23a6d326ed 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -9,6 +9,7 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs"; +import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { randomUUID } from "node:crypto"; // Pre-read DATA_DIR from local .env before bootstrap resolves paths @@ -36,6 +37,12 @@ if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) { const mode = process.argv[2] === "start" ? "start" : "dev"; const dev = mode === "dev"; +// Self-heal a stale better-sqlite3 native binary after a Node version switch +// (nvm 22 <-> 24) before bootstrap touches the DB. No-op when the ABI matches. +if (dev) { + ensureNativeSqlite(); +} + const bootstrappedEnv = bootstrapEnv(); const runtimePorts = resolveRuntimePorts(bootstrappedEnv); const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts); diff --git a/tests/unit/dev-ensure-native-sqlite.test.ts b/tests/unit/dev-ensure-native-sqlite.test.ts new file mode 100644 index 0000000000..31fd39c886 --- /dev/null +++ b/tests/unit/dev-ensure-native-sqlite.test.ts @@ -0,0 +1,112 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + ensureNativeSqlite, + isNativeAbiMismatch, +} from "../../scripts/dev/ensure-native-sqlite.mjs"; + +// A binary path that is guaranteed to exist so the existsSync() guard passes; +// the injected probe controls the actual outcome. +const EXISTING_PATH = process.execPath; +const silentLogger = { warn() {}, error() {}, log() {} }; + +// The exact message a Node 24 process produces against a Node 22 (ABI 127) binary. +const ABI_ERROR = + "The module '/x/node_modules/better-sqlite3/build/Release/better_sqlite3.node' " + + "was compiled against a different Node.js version using NODE_MODULE_VERSION 127. " + + "This version of Node.js requires NODE_MODULE_VERSION 137."; + +test("isNativeAbiMismatch detects ABI / native-load errors", () => { + assert.equal(isNativeAbiMismatch(ABI_ERROR), true); + assert.equal(isNativeAbiMismatch("Module did not self-register"), true); + assert.equal(isNativeAbiMismatch("ERR_DLOPEN_FAILED: bad bits"), true); + assert.equal(isNativeAbiMismatch("Could not locate the bindings file"), true); +}); + +test("isNativeAbiMismatch ignores unrelated errors", () => { + assert.equal(isNativeAbiMismatch("SQLITE_ERROR: no such table: foo"), false); + assert.equal(isNativeAbiMismatch("ENOENT: no such file"), false); + assert.equal(isNativeAbiMismatch(""), false); + assert.equal(isNativeAbiMismatch(null), false); + assert.equal(isNativeAbiMismatch(undefined), false); +}); + +test("ensureNativeSqlite: healthy binary does nothing (fast path)", () => { + let rebuilt = 0; + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + /* loads fine */ + }, + rebuild: () => { + rebuilt++; + return true; + }, + }); + assert.deepEqual(res, { ok: true, rebuilt: false }); + assert.equal(rebuilt, 0, "must not rebuild when the ABI already matches"); +}); + +test("ensureNativeSqlite: ABI mismatch triggers exactly one rebuild", () => { + let rebuilt = 0; + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + throw new Error(ABI_ERROR); + }, + rebuild: () => { + rebuilt++; + return true; + }, + }); + assert.equal(res.ok, true); + assert.equal(res.rebuilt, true); + assert.equal(rebuilt, 1, "rebuild must run once on ABI mismatch"); +}); + +test("ensureNativeSqlite: failed rebuild reports ok=false", () => { + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + throw new Error(ABI_ERROR); + }, + rebuild: () => false, + }); + assert.equal(res.ok, false); + assert.equal(res.rebuilt, false); +}); + +test("ensureNativeSqlite: unrelated load error is NOT swallowed and does not rebuild", () => { + let rebuilt = 0; + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + throw new Error("SQLITE_CANTOPEN: unable to open database file"); + }, + rebuild: () => { + rebuilt++; + return true; + }, + }); + assert.equal(res.ok, false); + assert.equal(res.rebuilt, false); + assert.ok(res.error instanceof Error); + assert.equal(rebuilt, 0, "must not rebuild for unrelated errors"); +}); + +test("ensureNativeSqlite: missing binary is a no-op (pre-install)", () => { + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: "/path/that/does/not/exist/better_sqlite3.node", + probe: () => { + throw new Error("should not be called"); + }, + rebuild: () => true, + }); + assert.deepEqual(res, { ok: true, rebuilt: false }); +}); From 744d7ac5fe33e6067e0f82e19ed0eced01932492 Mon Sep 17 00:00:00 2001 From: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:17:42 -0300 Subject: [PATCH 03/13] feat(api): accept path-scoped API keys on client API routes (#3300) Integrated into release/v3.8.13 --- src/server/authz/policies/clientApi.ts | 16 ++-- src/shared/utils/apiAuth.ts | 34 +++++---- src/sse/services/auth.ts | 98 +++++++++++++++++++++---- tests/unit/api-auth.test.ts | 25 +++++++ tests/unit/auth-extract-api-key.test.ts | 19 +++++ tests/unit/sse-auth.test.ts | 8 ++ 6 files changed, 166 insertions(+), 34 deletions(-) diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index 8b90e5b07a..e291205e3b 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -1,19 +1,23 @@ import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth.ts"; import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; +import { extractApiKey } from "@/sse/services/auth.ts"; import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow, reject } from "../context"; -function extractBearer(headers: Headers): string | null { - const raw = headers.get("authorization") ?? headers.get("Authorization"); - const xApiKey = headers.get("x-api-key") ?? headers.get("X-Api-Key"); +function extractBearer(request: Request): string | null { + const raw = request.headers.get("authorization") ?? request.headers.get("Authorization"); + const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key"); if (raw) { const trimmed = raw.trim(); if (!trimmed.toLowerCase().startsWith("bearer ")) return null; return trimmed.slice(7).trim() || null; - } else if (xApiKey) { + } + + if (xApiKey) { return xApiKey?.trim() || null; } - return null; + + return extractApiKey(request); } function maskKeyId(apiKey: string): string { @@ -24,7 +28,7 @@ function maskKeyId(apiKey: string): string { export const clientApiPolicy: RoutePolicy = { routeClass: "CLIENT_API", async evaluate(ctx: PolicyContext): Promise { - const bearer = extractBearer(ctx.request.headers); + const bearer = extractBearer(ctx.request as Request); if (!bearer) { if (await isDashboardSessionAuthenticated(ctx.request)) { return allow({ kind: "dashboard_session", id: "dashboard" }); diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index 9599ba734f..1803465f40 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -11,6 +11,7 @@ import { jwtVerify } from "jose"; import { cookies } from "next/headers"; import { getSettings } from "@/lib/localDb"; import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes"; +import { extractApiKey } from "@/sse/services/auth"; type RequestLike = { cookies?: { @@ -138,15 +139,18 @@ function getCookieValueFromHeader(headers: Headers | undefined, name: string): s return null; } -function getBearerToken(request: RequestLike | Request | null | undefined): string | null { - const headers = - request && typeof request === "object" && "headers" in request ? request.headers : undefined; - const authHeader = headers?.get("authorization") || headers?.get("Authorization"); - if (typeof authHeader !== "string") return null; +function getRequestApiKey(request: RequestLike | Request | null | undefined): string | null { + if (!request || typeof request !== "object") return null; - const trimmedHeader = authHeader.trim(); - if (!trimmedHeader.toLowerCase().startsWith("bearer ")) return null; - return trimmedHeader.slice(7).trim() || null; + const headers = "headers" in request ? request.headers : undefined; + const rawUrl = "url" in request && typeof request.url === "string" ? request.url : null; + const pathname = getRequestPathname(request); + const syntheticUrl = rawUrl || (pathname ? `http://localhost${pathname}` : null); + + return extractApiKey({ + headers, + url: syntheticUrl, + }); } async function validateBearerApiKey(apiKey: string | null): Promise { @@ -248,15 +252,15 @@ export async function verifyAuth(request: any): Promise { return null; } - const bearerToken = getBearerToken(request); + const apiKey = getRequestApiKey(request); if (isManagementApiRequest(request)) { - if (await validateBearerApiKeyForManagement(bearerToken)) { + if (await validateBearerApiKeyForManagement(apiKey)) { return null; } - return bearerToken ? "Invalid management token" : "Authentication required"; + return apiKey ? "Invalid management token" : "Authentication required"; } - if (await validateBearerApiKey(bearerToken)) { + if (await validateBearerApiKey(apiKey)) { return null; } @@ -282,12 +286,12 @@ export async function isAuthenticated(request: Request): Promise { return true; } - const bearerToken = getBearerToken(request); + const apiKey = getRequestApiKey(request); if (isManagementApiRequest(request)) { - return validateBearerApiKeyForManagement(bearerToken); + return validateBearerApiKeyForManagement(apiKey); } - return validateBearerApiKey(bearerToken); + return validateBearerApiKey(apiKey); } /** diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index b81e1cef46..2145f36803 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -183,11 +183,29 @@ function toBooleanOrDefault(value: unknown, fallback: boolean): boolean { } function readHeaderValue( - headers: Headers | { get?: (name: string) => string | null } | null | undefined, + headers: + | Headers + | { get?: (name: string) => string | null } + | Record + | null + | undefined, name: string ): string | null { - if (!headers || typeof headers.get !== "function") return null; - const value = headers.get(name); + if (!headers) return null; + + if (typeof (headers as Headers).get === "function") { + const value = (headers as Headers).get(name) || (headers as Headers).get(name.toLowerCase()); + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + const recordHeaders = headers as Record; + const value = + recordHeaders[name] || recordHeaders[name.toLowerCase()] || recordHeaders[name.toUpperCase()]; + + if (Array.isArray(value)) { + return typeof value[0] === "string" && value[0].trim().length > 0 ? value[0].trim() : null; + } + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -2024,16 +2042,63 @@ export async function clearRecoveredProviderState( await clearAccountError(credentials.connectionId, credentials); } +type AuthRequestHeaders = Headers | Record; + +type AuthRequestLike = { + headers?: AuthRequestHeaders | null; + url?: string | null; +}; + +function readNonEmptyUrlToken(request: AuthRequestLike): string | null { + if (typeof request?.url !== "string" || request.url.trim().length === 0) return null; + + try { + const url = new URL(request.url, "http://localhost"); + + const segments = url.pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + + if (segments[0] === "vscode" && segments[1]) { + const decodedSegment = decodeURIComponent(segments[1]).trim(); + if (decodedSegment.length > 0) return decodedSegment; + } + + if (segments[0] === "api" && segments[1] === "v1" && segments[2] === "vscode") { + if (segments[3] && segments[3] !== "raw" && segments[3] !== "combos") { + const decodedSegment = decodeURIComponent(segments[3]).trim(); + if (decodedSegment.length > 0) return decodedSegment; + } + + if ((segments[3] === "raw" || segments[3] === "combos") && segments[4]) { + const decodedSegment = decodeURIComponent(segments[4]).trim(); + if (decodedSegment.length > 0) return decodedSegment; + } + } + + for (const key of ["apiKey", "api_key", "key", "token"]) { + const token = url.searchParams.get(key)?.trim(); + if (token) return token; + } + } catch { + return null; + } + + return null; +} + /** - * Extract API key from request headers. + * Extract API key from request auth inputs. * - * Honors both: + * Honors both explicit headers and URL-based fallbacks: * - `Authorization: Bearer ` (OpenAI / OmniRoute / Codex CLI / Bearer clients) * - `x-api-key: ` (Anthropic Messages API contract — Claude Code, * `@anthropic-ai/sdk`, any SDK that sets `anthropic-version`) + * - `/vscode//...` (path-scoped tokenized aliases) + * - `?token=` / `?apiKey=` / `?api_key=` / `?key=` * - * When both are present, `Authorization: Bearer` wins for back-compat - * (issue #2225). + * When multiple inputs are present, explicit auth headers win. * * The `x-api-key` fallback only triggers when the request also carries an * `anthropic-version` header — the documented signal that the caller is @@ -2042,28 +2107,35 @@ export async function clearRecoveredProviderState( * with placeholder keys) would be treated as authenticated attempts and * rejected by per-route gates that compare against OmniRoute keys. */ -export function extractApiKey(request: Request) { - const authHeader = request.headers.get("Authorization") || request.headers.get("authorization"); +export function extractApiKey(request: AuthRequestLike) { + const authHeader = + readHeaderValue(request?.headers, "Authorization") || + readHeaderValue(request?.headers, "authorization"); if (typeof authHeader === "string") { const trimmedHeader = authHeader.trim(); if (trimmedHeader.toLowerCase().startsWith("bearer ")) { - return trimmedHeader.slice(7).trim(); + return trimmedHeader.slice(7).trim() || null; } } + // Issue #2225: Anthropic Messages API clients authenticate via x-api-key. // Gate the fallback on the anthropic-version header so we don't trip up // local-mode requests from non-Anthropic clients that send placeholder // x-api-key values (which would otherwise be rejected as Invalid API key). const anthropicVersion = - request.headers.get("anthropic-version") || request.headers.get("Anthropic-Version"); + readHeaderValue(request?.headers, "anthropic-version") || + readHeaderValue(request?.headers, "Anthropic-Version"); if (anthropicVersion) { - const xApiKey = request.headers.get("x-api-key") || request.headers.get("X-Api-Key"); + const xApiKey = + readHeaderValue(request?.headers, "x-api-key") || + readHeaderValue(request?.headers, "X-Api-Key"); if (typeof xApiKey === "string") { const trimmed = xApiKey.trim(); if (trimmed.length > 0) return trimmed; } } - return null; + + return readNonEmptyUrlToken(request); } /** diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index 4685c7e2ff..b7c9fa5c9c 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -104,6 +104,31 @@ test("verifyAuth falls back to bearer API key validation after a bad JWT", async assert.equal(result, null); }); +test("verifyAuth accepts API keys supplied via query string on client-facing routes", async () => { + const key = await apiKeysDb.createApiKey("query-auth", "machine1234567890"); + + const result = await apiAuth.verifyAuth({ + cookies: { + get() { + return undefined; + }, + }, + headers: new Headers(), + url: `https://example.com/api/v1/models?token=${encodeURIComponent(key.key)}`, + }); + + assert.equal(result, null); +}); + +test("isAuthenticated accepts API keys embedded in vscode path aliases", async () => { + const key = await apiKeysDb.createApiKey("path-auth", "machine1234567890"); + const request = new Request(`https://example.com/api/v1/vscode/${encodeURIComponent(key.key)}/models`); + + const result = await apiAuth.isAuthenticated(request); + + assert.equal(result, true); +}); + test("verifyAuth rejects bearer API keys on management routes", async () => { const key = await apiKeysDb.createApiKey("integration", "machine1234567890"); const result = await apiAuth.verifyAuth({ diff --git a/tests/unit/auth-extract-api-key.test.ts b/tests/unit/auth-extract-api-key.test.ts index 8ed964e4c6..557cecc589 100644 --- a/tests/unit/auth-extract-api-key.test.ts +++ b/tests/unit/auth-extract-api-key.test.ts @@ -89,3 +89,22 @@ test("extractApiKey accepts Anthropic-Version (TitleCase) header", () => { }); assert.equal(extractApiKey(req), "sk-titlecase-version"); }); + +test("extractApiKey extracts a path-scoped token from /api/v1/vscode//...", () => { + const req = new Request("https://omniroute.test/api/v1/vscode/sk-test-path-token/models"); + assert.equal(extractApiKey(req), "sk-test-path-token"); +}); + +test("extractApiKey extracts a path-scoped token from /api/v1/vscode/raw//...", () => { + const req = new Request( + "https://omniroute.test/api/v1/vscode/raw/sk-test-path-token/api/version" + ); + assert.equal(extractApiKey(req), "sk-test-path-token"); +}); + +test("extractApiKey extracts a path-scoped token from /api/v1/vscode/combos//...", () => { + const req = new Request( + "https://omniroute.test/api/v1/vscode/combos/sk-test-path-token/api/version" + ); + assert.equal(extractApiKey(req), "sk-test-path-token"); +}); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index 50576acc75..a3904797bd 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -89,6 +89,14 @@ test("extractApiKey parses bearer headers and isValidApiKey validates persisted ), null ); + assert.equal( + auth.extractApiKey(new Request(`http://localhost/v1/chat/completions?token=${created.key}`)), + created.key + ); + assert.equal( + auth.extractApiKey(new Request(`http://localhost/api/v1/vscode/${created.key}/chat/completions`)), + created.key + ); assert.equal(await auth.isValidApiKey(created.key), true); assert.equal(await auth.isValidApiKey("sk-missing"), false); assert.equal(await auth.isValidApiKey(""), false); From bc93ffb347f84bd8a0dec37282fbd50098d8fb73 Mon Sep 17 00:00:00 2001 From: Wilson Date: Sat, 6 Jun 2026 12:17:53 -0300 Subject: [PATCH 04/13] fix(sse): harden against empty responses causing Copilot Chat failures (#3297) Integrated into release/v3.8.13 --- open-sse/utils/stream.ts | 38 ++++++++ tests/unit/empty-response-hardening.test.ts | 83 +++++++++++++++++ tests/unit/stream-utils.test.ts | 99 +++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 tests/unit/empty-response-hardening.test.ts diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 407b0600f1..173e958422 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1600,6 +1600,36 @@ export function createSSEStream(options: StreamOptions = {}) { } else { // Chat Completions: full sanitization pipeline + // Hardening: detect upstream returning empty choices array + // which breaks OpenAI-compatible clients (e.g. Copilot Chat) + if (Array.isArray(parsed.choices) && parsed.choices.length === 0) { + console.warn( + `[STREAM] Upstream returned empty choices array (${provider || "provider"}:${model || "unknown"}) — emitting error chunk` + ); + const errorChunk = { + id: parsed.id || `omniroute-empty-choices-${Date.now()}`, + object: "chat.completion.chunk", + created: parsed.created || Math.floor(Date.now() / 1000), + model: parsed.model || model || "unknown", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: "[OmniRoute] Upstream returned an empty response. Please retry.", + }, + finish_reason: "stop", + }, + ], + }; + output = `data: ${JSON.stringify(errorChunk)}\n`; + injectedUsage = true; + clientPayload = errorChunk; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + continue; + } + // Detect reasoning alias before sanitization strips it const hadReasoningAlias = !!( parsed.choices?.[0]?.delta?.reasoning && @@ -2118,6 +2148,14 @@ export function createSSEStream(options: StreamOptions = {}) { (a, b) => a.index - b.index ); } + // Hardening: log empty assistant response after tool completion + // for observability — helps diagnose Copilot "Sorry, no response was returned" + if (passthroughHasToolCalls && !content.trim() && !reasoning.trim()) { + console.warn( + `[STREAM] Empty assistant response after tool_calls completion (${provider || "provider"}:${model || "unknown"}) — sessionId=${sessionId}` + ); + } + const responseBody = { choices: [ { diff --git a/tests/unit/empty-response-hardening.test.ts b/tests/unit/empty-response-hardening.test.ts new file mode 100644 index 0000000000..0f7ace6296 --- /dev/null +++ b/tests/unit/empty-response-hardening.test.ts @@ -0,0 +1,83 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { hasValuableContent } from "../../open-sse/utils/streamHelpers.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +describe("empty-response hardening", () => { + describe("hasValuableContent edge cases", () => { + it("returns false for choices: [] (empty array)", () => { + const chunk = { choices: [] }; + assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false); + }); + + it("returns false for missing choices entirely", () => { + const chunk = { id: "test-123" }; + assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false); + }); + + it("returns false for choices with null delta", () => { + const chunk = { choices: [{ delta: null, finish_reason: null }] }; + assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false); + }); + + it("returns true for choices with finish_reason stop", () => { + const chunk = { choices: [{ delta: {}, finish_reason: "stop" }] }; + assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true); + }); + + it("returns true for choices with finish_reason tool_calls", () => { + const chunk = { choices: [{ delta: {}, finish_reason: "tool_calls" }] }; + assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true); + }); + }); + + describe("empty choices array detection", () => { + it("should be detected by Array.isArray + length === 0", () => { + const parsed = { choices: [], id: "test", model: "gpt-4" }; + assert.strictEqual(Array.isArray(parsed.choices), true); + assert.strictEqual(parsed.choices.length, 0); + }); + + it("should NOT trigger for choices with one element", () => { + const parsed = { choices: [{ delta: { content: "hi" } }], id: "test" }; + assert.strictEqual(Array.isArray(parsed.choices), true); + assert.strictEqual(parsed.choices.length, 1); + }); + + it("should NOT trigger for choices with finish_reason only", () => { + const parsed = { choices: [{ delta: {}, finish_reason: "stop" }], id: "test" }; + assert.strictEqual(Array.isArray(parsed.choices), true); + assert.strictEqual(parsed.choices.length, 1); + }); + }); + + describe("tool completion empty response detection", () => { + it("detects empty content and reasoning after tool_calls", () => { + const passthroughHasToolCalls = true; + const content = ""; + const reasoning = ""; + assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), true); + }); + + it("does NOT trigger when content exists after tool_calls", () => { + const passthroughHasToolCalls = true; + const content = "Done!"; + const reasoning = ""; + assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false); + }); + + it("does NOT trigger when reasoning exists after tool_calls", () => { + const passthroughHasToolCalls = true; + const content = ""; + const reasoning = "Let me think..."; + assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false); + }); + + it("does NOT trigger when no tool_calls occurred", () => { + const passthroughHasToolCalls = false; + const content = ""; + const reasoning = ""; + assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false); + }); + }); +}); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 7a2bd35034..f8ab86f723 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -1559,3 +1559,102 @@ test("createSSEStream passthrough mode decrements pending requests on failure", `pending request count for ${modelKey} should be 0 after failure, got ${count}` ); }); + +test("createSSEStream passthrough emits synthetic error chunk for empty choices array", async () => { + let onCompletePayload = null; + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_empty", + object: "chat.completion.chunk", + created: 1, + model: "kimi-k2.6", + choices: [], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_empty", + object: "chat.completion.chunk", + created: 1, + model: "kimi-k2.6", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_empty", + object: "chat.completion.chunk", + created: 1, + model: "kimi-k2.6", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "opencode-go", + model: "kimi-k2.6", + body: { messages: [{ role: "user", content: "hello" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + // The empty choices chunk should have been replaced with a synthetic error chunk + assert.match(text, /\[OmniRoute\] Upstream returned an empty response/); + assert.match(text, /"finish_reason":"stop"/); + // Subsequent valid chunks should still be present + assert.match(text, /"content":"Hello"/); + assert.equal(onCompletePayload.status, 200); +}); + +test("createSSEStream passthrough logs empty response after tool_calls completion", async () => { + let onCompletePayload = null; + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_tool_then_empty", + object: "chat.completion.chunk", + created: 1, + model: "gpt-5.5-xhigh", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_tc", + type: "function", + function: { name: "task_complete", arguments: '{}' }, + }, + ], + }, + }, + ], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_tool_then_empty", + object: "chat.completion.chunk", + created: 1, + model: "gpt-5.5-xhigh", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "codex", + model: "gpt-5.5-xhigh", + body: { messages: [{ role: "user", content: "do task" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + assert.match(text, /"finish_reason":"tool_calls"/); + assert.equal(onCompletePayload.status, 200); + assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls"); + assert.equal(onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, "task_complete"); + // Content should be null (empty) since no text was generated + assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); +}); From f49df6e384182d37c6db6eba06f68567608a0452 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:18:18 -0300 Subject: [PATCH 05/13] fix(api): remove Completions.me rickroll provider (discussion #3293) (#3302) Integrated into release/v3.8.13 --- docs/reference/FREE_TIERS.md | 3 --- docs/reference/PROVIDER_REFERENCE.md | 11 ++++---- open-sse/config/freeModelCatalog.data.ts | 8 ------ open-sse/config/providerRegistry.ts | 19 -------------- src/shared/constants/providers.ts | 12 --------- .../unit/completions-provider-removed.test.ts | 26 +++++++++++++++++++ tests/unit/executor-default-base.test.ts | 6 ++--- 7 files changed, 34 insertions(+), 51 deletions(-) create mode 100644 tests/unit/completions-provider-removed.test.ts diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index a5efc566b0..69dde3c47c 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -51,7 +51,6 @@ Biggest **documented** contributors: `mistral` 1.00B, `longcat` 150M, `cloudflar | `ai21` | ToS §4.2/§8.2 prohibits sublicensing or distributing API access to third parties; §3.3 restricts trial/evaluation products to "internal evaluation on… | | `amazon-q` | Product is discontinued for new signups; existing users are subject to AWS Customer Agreement which governs use of managed services — self-hosted pro… | | `blackbox` | ToS explicitly prohibits sublicensing, reselling, making the service available to third parties, and building derivative services — a self-hosted per… | -| `completions` | No published ToS found (404 on /terms, /faq, /docs). The service proxies Anthropic/OpenAI/Google APIs without authorization, violating those upstream… | | `coze` | Coze ToS explicitly restricts use to "personal and non-commercial use" and prohibits renting, distributing, sublicensing, or reselling the service; a… | | `duckduckgo-web` | Duck.ai ToS (duckduckgo.com/duckai/privacy-terms) explicitly prohibits "automated querying and developing or offering AI services" and circumventing … | | `featherless-ai` | Individual plans explicitly restricted to "interactive use or proto-typing and experimentation by the purchaser" — inference resale and proxy use req… | @@ -201,7 +200,6 @@ Biggest **documented** contributors: `mistral` 1.00B, `longcat` 150M, `cloudflar | `bytez` | aggregator | recurring-credit | — | med | ambiguous | Bytez offers $1 in free credits that refresh every 4 weeks (credits expire if unused within the cycle). Free tier is li… | | `chutes` | aggregator | discontinued | — | high | unknown | The free Early Access program (200 requests/day) was officially discontinued on March 15, 2026. Chutes.ai now operates … | | `comfyui` | image | keyless-unlimited | — | high | ok | ComfyUI is a fully open-source (GPL-3.0), self-hosted diffusion model interface that runs entirely on local hardware wi… | -| `completions` | aggregator | keyless-unlimited | — | med | caution | Completions.me claims to offer completely free, unlimited access to Claude Opus 4.6, GPT-5.2, Gemini 3.1 Pro, and 15+ m… | | `coze` | aggregator | recurring-daily | — | med | caution | Coze's free plan provides 10 message credits per day — a platform-level unit (not raw LLM tokens) where each model call… | | `deepinfra` | aggregator | one-time-trial-credit | — | med | caution | DeepInfra is a pay-as-you-go inference provider that explicitly requires a credit card or prepayment to use services; a… | | `deepseek` | llm-chat | one-time-trial-credit | — | high | caution | DeepSeek offers a one-time signup credit of 5 million tokens (no credit card required) valid for 30 days from account c… | @@ -281,7 +279,6 @@ Biggest **documented** contributors: `mistral` 1.00B, `longcat` 150M, `cloudflar - **`byteplus`** — Our catalog shipped "(none)" but BytePlus ModelArk does have a free tier: a one-time trial credit of 500k tokens per LLM model for new accounts. The catalog underreports this. - **`cerebras`** — TPM appears tightened from 60K to 30K on current documented models (gpt-oss-120b, zai-glm-4.7). RPM of 5 is now explicitly documented (was not in our shipped note). Daily token cap of 1M/day is uncha… - **`chutes`** — The shipped freeNote says "Free tier available" but as of March 15, 2026, the free tier has been officially discontinued. The catalog note is stale and should be updated to reflect that there is no r… -- **`completions`** — Our shipped freeNote ("Free unlimited access to Claude, GPT, Gemini — no rate limits") still matches the site's self-described claims. However, the service is a legally dubious, short-lived aggregato… - **`coze`** — The shipped note "Free ByteDance agent platform" is directionally accurate but omits that the free tier is now tightly credit-capped (10 credits/day ≈ 5–100 messages depending on model), a constraint… - **`deepinfra`** — Our shipped freeNote says "Free signup credits for API testing" — this appears stale. The official pricing page now requires card/prepayment with no documented general free signup credit. The free ti… - **`deepseek`** — Our shipped note says "5M free tokens on signup - no credit card required" — this is still accurate for the one-time grant, but importantly the credits expire after 30 days (not mentioned in the ship… diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index a1f5c81146..246e05ce37 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" -version: 3.8.11 -lastUpdated: 2026-06-05 +version: 3.8.12 +lastUpdated: 2026-06-06 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-06-05 +> **Last generated:** 2026-06-06 -Total providers: **224**. See category breakdown below. +Total providers: **223**. See category breakdown below. ## Categories @@ -80,7 +80,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | | `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | -## API Key Providers (paid / paid-with-free-credits) (152) +## API Key Providers (paid / paid-with-free-credits) (151) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -114,7 +114,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | | `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | | `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | -| `completions` | `cpl` | Completions.me | API key | [link](https://completions.me) | Free unlimited access to Claude, GPT, Gemini — no credit card, no rate limits | | `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | | `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | | `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index c0a825cafb..adba8f6bba 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -170,14 +170,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "bazaarlink", modelId: "mistral-medium-3.1", displayName: "Mistral Medium 3.1", monthlyTokens: 3600000, creditTokens: 0, freeType: "recurring-daily", poolKey: "bazaarlink", tos: "caution" }, { provider: "bazaarlink", modelId: "mistral-small-2603", displayName: "Mistral Small 4", monthlyTokens: 3600000, creditTokens: 0, freeType: "recurring-daily", poolKey: "bazaarlink", tos: "caution" }, { provider: "bazaarlink", modelId: "nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super", monthlyTokens: 3600000, creditTokens: 0, freeType: "recurring-daily", poolKey: "bazaarlink", tos: "caution" }, - { provider: "completions", modelId: "claude-opus-4.6", displayName: "Claude Opus 4.6", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gpt-5.2", displayName: "GPT-5.2", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gpt-5-mini", displayName: "GPT-5 Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gpt-4.1", displayName: "GPT-4.1", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gemini-3-flash-preview", displayName: "Gemini 3 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, { provider: "mistral", modelId: "mistral-large-latest", displayName: "Mistral Large 3", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "mistral-small-latest", displayName: "Mistral Small 4", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index a3150d1ba8..90ce824e21 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -2689,25 +2689,6 @@ const _REGISTRY_EAGER: Record = { { id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super" }, ], }, - completions: { - id: "completions", - alias: "cpl", - format: "openai", - executor: "default", - baseUrl: "https://completions.me/api/v1/chat/completions", - authType: "apikey", - authHeader: "bearer", - models: [ - { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, - { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, - { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "gpt-5-mini", name: "GPT-5 Mini" }, - { id: "gpt-4.1", name: "GPT-4.1" }, - { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, - { id: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, - ], - }, xai: { id: "xai", alias: "xai", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 4b7dc2f6a5..faa00d061f 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -933,18 +933,6 @@ export const APIKEY_PROVIDERS = { apiHint: "Get free API key at https://bazaarlink.ai — use model 'auto:free' for zero-cost inference. OpenAI-compatible.", }, - completions: { - id: "completions", - alias: "cpl", - name: "Completions.me", - icon: "bolt", - color: "#F59E0B", - textIcon: "CP", - website: "https://completions.me", - hasFree: true, - freeNote: "Free unlimited access to Claude, GPT, Gemini — no credit card, no rate limits", - apiHint: "Sign up at https://completions.me for free API key. OpenAI-compatible endpoint.", - }, xai: { id: "xai", alias: "xai", diff --git a/tests/unit/completions-provider-removed.test.ts b/tests/unit/completions-provider-removed.test.ts new file mode 100644 index 0000000000..f818902c7c --- /dev/null +++ b/tests/unit/completions-provider-removed.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; +import { APIKEY_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.data.ts"; + +// Regression guard for discussion #3293: "Completions.me" (provider id `completions`, +// alias `cpl`, https://completions.me) was a bundled preset advertising free unlimited +// access to premium models. It is a Rickroll endpoint -- verified empirically on +// 2026-06-06: a real key against /api/v1/chat/completions returns the Rick Astley +// lyrics ("Never gonna give you up...") for every model/prompt, with zeroed usage. +// It must stay out of every provider catalog so nobody wires it up by accident. + +test("completions (Completions.me) provider is removed from the chat registry", () => { + assert.equal(REGISTRY["completions"], undefined); +}); + +test("completions (Completions.me) provider is removed from the API-key provider presets", () => { + assert.equal((APIKEY_PROVIDERS as Record)["completions"], undefined); +}); + +test("completions (Completions.me) has no entries in the free model catalog", () => { + const offenders = FREE_MODEL_BUDGETS.filter((b) => b.provider === "completions"); + assert.deepEqual(offenders, []); +}); diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index fcbf29474e..7469ffce84 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -97,15 +97,15 @@ test("DefaultExecutor.buildUrl handles Gemini, Claude and Qwen variants", () => test("DefaultExecutor.buildUrl uses full chat endpoints for hosted OpenAI-compatible providers", () => { const bazaarlink = new DefaultExecutor("bazaarlink"); - const completions = new DefaultExecutor("completions"); + const crof = new DefaultExecutor("crof"); assert.equal( bazaarlink.buildUrl("auto:free", true), "https://bazaarlink.ai/api/v1/chat/completions" ); assert.equal( - completions.buildUrl("gpt-4.1", true), - "https://completions.me/api/v1/chat/completions" + crof.buildUrl("gpt-4.1", true), + "https://crof.ai/v1/chat/completions" ); }); From 7d06538f3442275654be4f26377ca1e515914709 Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Sat, 6 Jun 2026 17:21:25 +0200 Subject: [PATCH 06/13] fix(opencode-provider): extract contextLength from live model catalog (#3298) Integrated into release/v3.8.13 --- @omniroute/opencode-provider/src/index.ts | 27 ++++++++++++---- .../opencode-provider/tests/index.test.ts | 28 ++++++++++++++++ src/app/api/v1/models/catalog.ts | 11 ++++--- tests/unit/models-catalog-route.test.ts | 32 +++++++++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/@omniroute/opencode-provider/src/index.ts b/@omniroute/opencode-provider/src/index.ts index 6f3a2384ee..c312e90040 100644 --- a/@omniroute/opencode-provider/src/index.ts +++ b/@omniroute/opencode-provider/src/index.ts @@ -129,8 +129,8 @@ export interface OmniRouteProviderOptions { apiKey: string; /** Override the display name shown in OpenCode. Default: `"OmniRoute"`. */ displayName?: string; - /** Override the model catalog. Defaults to `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. */ - models?: readonly string[]; + /** Override the model catalog. Accepts model ids (strings) or live model entries from `fetchLiveModels`. When entries carry a `contextLength`, it is used directly — no hardcoded map needed. */ + models?: readonly (string | { id: string; contextLength?: number })[]; /** Optional human-readable labels keyed by model id. Overridden by `modelCapabilities[id].label`. */ modelLabels?: Record; /** @@ -139,6 +139,12 @@ export interface OmniRouteProviderOptions { * for custom ids the override is used verbatim. */ modelCapabilities?: Record; + /** + * Optional per-model context-length overrides (tokens). Takes precedence + * over the static `OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS` map but is + * superseded by `contextLength` on live model entries passed via `models`. + */ + modelContextLengths?: Record; /** * Primary model for OpenCode (top-level `model` key). * Emitted as `"omniroute/"`. When omitted the key is not written. @@ -248,7 +254,12 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open const models: Record = {}; const seen = new Set(); for (const raw of modelList) { - const id = typeof raw === "string" ? raw.trim() : ""; + const id = + typeof raw === "object" && raw !== null && "id" in raw + ? (raw as { id: string }).id.trim() + : typeof raw === "string" + ? raw.trim() + : ""; if (!id || seen.has(id)) continue; seen.add(id); const defaults = OMNIROUTE_DEFAULT_MODEL_CAPABILITIES[id] ?? {}; @@ -266,9 +277,11 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open if (typeof merged.temperature === "boolean") entry.temperature = merged.temperature; if (typeof merged.tool_call === "boolean") entry.tool_call = merged.tool_call; - // Include context window limit when known — OpenCode reads this to - // determine usable context length for compaction & overflow detection. - const contextLength = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id]; + // Context window: live model entry (from API catalog) > modelContextLengths > static defaults + const liveContext = typeof raw === "object" && raw !== null + ? (raw as { contextLength?: number }).contextLength + : undefined; + const contextLength = liveContext ?? options.modelContextLengths?.[id] ?? OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id]; if (typeof contextLength === "number" && contextLength > 0) { entry.limit = { context: contextLength }; } @@ -508,7 +521,7 @@ export interface OmniRouteLiveModel { * const config = buildOmniRouteOpenCodeConfig({ * baseURL: "http://localhost:20128", * apiKey: "sk_omniroute", - * models: models.map((m) => m.id), + * models, // OmniRouteLiveModel[] — contextLength auto-extracted * modelLabels: Object.fromEntries(models.map((m) => [m.id, m.name])), * }); * ``` diff --git a/@omniroute/opencode-provider/tests/index.test.ts b/@omniroute/opencode-provider/tests/index.test.ts index 50d3439a33..aac6e7a27b 100644 --- a/@omniroute/opencode-provider/tests/index.test.ts +++ b/@omniroute/opencode-provider/tests/index.test.ts @@ -430,6 +430,34 @@ test("createOmniRouteProvider omits limit.context for unknown model ids", () => assert.equal(entry.limit, undefined); }); +test("createOmniRouteProvider reads contextLength from a live model entry for ids absent from the static map", () => { + // #3298 regression guard: the static OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS + // map only covers the legacy 8 Claude/Gemini ids. Before this change, any + // other model got `undefined` context (see the test above, string form) and + // OpenCode silently fell back to its 128K internal default. A live model + // entry carrying `contextLength` must now surface as `limit.context`. + const provider = createOmniRouteProvider({ + baseURL: "http://localhost:20128", + apiKey: "sk_omniroute", + models: [{ id: "completely-unknown-model", contextLength: 262_144 }], + }); + const entry = provider.models["completely-unknown-model"]; + assert.ok(entry.limit, "a live contextLength should produce a limit field even for ids absent from the static map"); + assert.equal(entry.limit!.context, 262_144); +}); + +test("createOmniRouteProvider: a live model contextLength wins over the static default map", () => { + // `cc/claude-opus-4-8` has a static default (1_000_000). A live entry carrying + // a different contextLength must take precedence (live > modelContextLengths > + // static defaults). + const provider = createOmniRouteProvider({ + baseURL: "http://localhost:20128", + apiKey: "sk_omniroute", + models: [{ id: "cc/claude-opus-4-8", contextLength: 524_288 }], + }); + assert.equal(provider.models["cc/claude-opus-4-8"].limit!.context, 524_288); +}); + test("createOmniRouteProvider serialises limit.context to JSON", () => { const provider = createOmniRouteProvider({ baseURL: "http://localhost:20128", diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index f5baf36690..a0fb133473 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -99,8 +99,9 @@ function intersectStringArrays(arrays: string[][]): string[] { } function minKnownNumber(values: Array): number | undefined { - if (values.length === 0 || !values.every(isPositiveFiniteNumber)) return undefined; - return Math.min(...values); + const knownValues = values.filter(isPositiveFiniteNumber); + if (knownValues.length === 0) return undefined; + return Math.min(...knownValues); } const VISION_MODEL_KEYWORDS = [ @@ -579,9 +580,11 @@ export async function getUnifiedModelsResponse( if (targets.length === 0) return baseMetadata; const targetMetadata = targets.map((target) => getComboTargetCatalogMetadata(target)); - if (targetMetadata.some((metadata) => metadata === null)) return baseMetadata; - const knownMetadata = targetMetadata as ComboTargetCatalogMetadata[]; + const knownMetadata = targetMetadata.filter( + (metadata): metadata is ComboTargetCatalogMetadata => metadata !== null + ); + if (knownMetadata.length === 0) return baseMetadata; const contextLength = explicitContextLength ?? minKnownNumber(knownMetadata.map((metadata) => metadata.contextLength)); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 0362778920..1157994208 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -1340,6 +1340,38 @@ test("v1 models catalog prefers manual combo context_length over auto-calculated assert.equal(comboModel.context_length, 64000, "manual context_length should override auto-calc"); }); +test("v1 models catalog computes combo context_length from known targets when some targets have unknown context", async () => { + await seedConnection("openai", { name: "openai-mixed-context" }); + await seedConnection("claude", { + authType: "oauth", + name: "claude-mixed-context", + apiKey: null, + accessToken: "claude-access", + }); + + // Create a combo with targets: one known (gpt-4o = 128K), one unknown (nonexistent-model). + // The combo should still compute context_length = 128K from the known target. + const combo = await combosDb.createCombo({ + name: "mixed-context-combo", + strategy: "priority", + models: ["openai/gpt-4o", "openai/nonexistent-model-xyz"], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const comboModel = body.data.find((item) => item.id === "mixed-context-combo"); + + assert.equal(response.status, 200); + assert.ok(comboModel); + assert.equal( + comboModel.context_length, + 128000, + "combo context_length should be the MIN of known target model limits, ignoring unknown targets" + ); +}); + // Regression test for Issue #2798: noAuth providers (opencode/oc) have no DB connection rows // but their models must still appear in /v1/models. test("v1 models catalog includes noAuth provider models when no DB connections exist (#2798)", async () => { From 264b6baf318af13a59759bccd62a8cd5bd342df4 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:27:35 +0700 Subject: [PATCH 07/13] feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292) Integrated into release/v3.8.13 --- electron/loginManager.js | 386 ++++++++++++++++ electron/main.js | 43 ++ electron/preload.js | 11 +- open-sse/services/autoRefreshDaemon.ts | 204 +++++++++ open-sse/services/inAppLoginService.ts | 256 +++++++++++ open-sse/services/tokenExtractionConfig.ts | 411 ++++++++++++++++++ .../providers/[id]/webSessionCredentials.ts | 30 ++ src/app/api/providers/[id]/login/route.ts | 75 ++++ src/instrumentation-node.ts | 8 + src/server/authz/routeGuard.ts | 21 +- tests/unit/autoRefreshDaemon.test.ts | 87 ++++ ...te-guard-provider-login-local-only.test.ts | 38 ++ tests/unit/tokenExtractionConfig.test.ts | 158 +++++++ 13 files changed, 1726 insertions(+), 2 deletions(-) create mode 100644 electron/loginManager.js create mode 100644 open-sse/services/autoRefreshDaemon.ts create mode 100644 open-sse/services/inAppLoginService.ts create mode 100644 open-sse/services/tokenExtractionConfig.ts create mode 100644 src/app/api/providers/[id]/login/route.ts create mode 100644 tests/unit/autoRefreshDaemon.test.ts create mode 100644 tests/unit/route-guard-provider-login-local-only.test.ts create mode 100644 tests/unit/tokenExtractionConfig.test.ts diff --git a/electron/loginManager.js b/electron/loginManager.js new file mode 100644 index 0000000000..0e0bb2d2f1 --- /dev/null +++ b/electron/loginManager.js @@ -0,0 +1,386 @@ +/** + * LoginManager — Electron BrowserWindow-based web login for cookie providers + * + * Opens a native Electron window navigated to the provider's login page. + * Polls the session cookie store for target cookies after login completes. + * + * Events: + * "status" — { status: string, message: string, providerId: string } + * status values: starting, navigating, waiting, polling, complete, error, cancelled + */ + +const { BrowserWindow, session } = require("electron"); +const { EventEmitter } = require("events"); +const path = require("path"); + +// In production, the tokenExtractionConfig is bundled under open-sse/services/. +// We resolve relative to the Electron resources path. +let TOKEN_EXTRACTION_CONFIGS = null; +function getConfigs() { + if (TOKEN_EXTRACTION_CONFIGS) return TOKEN_EXTRACTION_CONFIGS; + try { + const mod = require("../open-sse/services/tokenExtractionConfig"); + TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS; + } catch { + // Fallback: try from app resources + try { + const mod = require("./open-sse/services/tokenExtractionConfig"); + TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS; + } catch {} + } + return TOKEN_EXTRACTION_CONFIGS; +} + +class LoginManager extends EventEmitter { + constructor() { + super(); + this.window = null; + this.activeProviderId = null; + this.resolvePromise = null; + this.rejectPromise = null; + this.timeoutId = null; + this.isCompleted = false; + this.pollIntervalId = null; + this.loginSession = null; + } + + /** + * Start a login flow for a web-cookie provider. + * @param {string} providerId - e.g. "claude-web", "chatgpt-web" + * @param {object} [options] + * @param {number} [options.timeout] - Total timeout in ms (default: config or 300s) + * @returns {Promise<{success: boolean, credentials?: Record, error?: string}>} + */ + startLogin(providerId, options = {}) { + const configs = getConfigs(); + if (!configs) { + return Promise.resolve({ + success: false, + error: "tokenExtractionConfig module not found", + }); + } + + const extractionConfig = configs.get(providerId); + if (!extractionConfig) { + return Promise.resolve({ + success: false, + error: `No extraction config for provider: ${providerId}`, + }); + } + + if (this.activeProviderId) { + return Promise.resolve({ + success: false, + error: "A login process is already in progress", + }); + } + + this.activeProviderId = providerId; + this.isCompleted = false; + + const timeout = options.timeout || extractionConfig.pollingConfig.timeout || 300_000; + const minLoginTime = extractionConfig.pollingConfig.minLoginTime || 5000; + const pollInterval = extractionConfig.pollingConfig.pollInterval || 1000; + + return new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + + this.emit("status", { + providerId, + status: "starting", + message: `Opening ${extractionConfig.displayName} login...`, + }); + + try { + this._openLoginWindow(providerId, extractionConfig, timeout, minLoginTime, pollInterval); + } catch (err) { + this._cleanup(); + this.emit("status", { + providerId, + status: "error", + message: `Failed to open window: ${err.message}`, + }); + resolve({ success: false, error: err.message }); + } + }); + } + + /** + * Open the Electron BrowserWindow for login + */ + _openLoginWindow(providerId, config, timeout, minLoginTime, pollInterval) { + this.window = new BrowserWindow({ + width: 1000, + height: 750, + title: `Login - ${config.displayName}`, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + session: session.fromPartition(`login-${providerId}-${Date.now()}`), + }, + show: true, + autoHideMenuBar: true, + }); + + const winSession = this.window.webContents.session; + + // Track navigation for success URL detection + let navigatedToLogin = false; + const startTime = Date.now(); + + this.window.webContents.on("did-navigate", (_event, url) => { + if (this.isCompleted) return; + + try { + const parsedUrl = new URL(url); + // Check if we've navigated away from the login page (successful login) + if (navigatedToLogin && config.successUrlPattern) { + if (config.successUrlPattern.test(url)) { + this.emit("status", { + providerId, + status: "detected", + message: "Login page redirect detected — extracting cookies...", + }); + } + } + if (!navigatedToLogin) { + navigatedToLogin = true; + } + + this.emit("status", { + providerId, + status: "navigating", + message: `Navigated to ${parsedUrl.hostname}`, + }); + } catch { + // ignore bad URLs + } + }); + + // Load the login page + this.emit("status", { + providerId, + status: "navigating", + message: `Loading ${config.loginUrl}...`, + }); + this.window.loadURL(config.loginUrl); + + // Show window when ready + this.window.once("ready-to-show", () => { + this.window.show(); + }); + + // Handle window close by user + this.window.on("closed", () => { + if (!this.isCompleted) { + this._cleanup(); + this.emit("status", { + providerId, + status: "cancelled", + message: "Login window closed by user", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: false, error: "Login window closed" }); + } + } + }); + + // Start polling for cookies after minLoginTime has elapsed + this.timeoutId = setTimeout(() => { + if (this.isCompleted) return; + this._startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime); + }, minLoginTime); + + // Overall timeout + this._timeoutTimer = setTimeout(() => { + if (!this.isCompleted) { + this._cleanup(); + this.emit("status", { + providerId, + status: "error", + message: "Login timed out", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: false, error: "Login timed out" }); + } + } + }, timeout); + } + + /** + * Poll the Electron session cookie store for the target cookies + */ + _startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime) { + const maxPolls = Math.floor(config.pollingConfig.timeout / pollInterval); + let pollCount = 0; + + const poll = () => { + if (this.isCompleted) return; + pollCount++; + + // Emit progress every 30 polls + if (pollCount % 30 === 0) { + const elapsed = Math.round((Date.now() - startTime) / 60000); + this.emit("status", { + providerId, + status: "waiting", + message: `Waiting for login... (${elapsed}m)`, + }); + } + + winSession.cookies + .get({}) + .then((cookies) => { + if (this.isCompleted) return; + + const tokenSources = config.tokenSources; + const credentials = {}; + + // Collect all cookie-based sources + const cookieSources = tokenSources.filter((s) => s.type === "cookie"); + for (const source of cookieSources) { + const domain = source.domain || undefined; + const matched = cookies.find( + (c) => c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, ""))) + ); + if (matched) { + credentials[source.name] = matched.value; + } + } + + // Check localStorage-based tokens via executeJavaScript + const storageSources = tokenSources.filter( + (s) => s.type === "localStorage" || s.type === "sessionStorage" + ); + + if (storageSources.length > 0 && this.window && !this.window.isDestroyed()) { + // Execute JS to extract all localStorage/sessionStorage tokens + const storageType = storageSources[0].type === "localStorage" ? "localStorage" : "sessionStorage"; + const keys = storageSources.map((s) => s.key); + const js = `(() => { + const res = {}; + ${JSON.stringify(keys)}.forEach(k => { + try { res[k] = ${storageType}.getItem(k); } catch {} + }); + return res; + })()`; + + this.window.webContents + .executeJavaScript(js) + .then((values) => { + if (values && typeof values === "object") { + Object.assign(credentials, values); + } + this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + }) + .catch(() => { + this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + }); + } else { + this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + } + }) + .catch(() => { + if (!this.isCompleted) { + this.pollIntervalId = setTimeout(poll, pollInterval); + } + }); + }; + + // Start first poll + this.pollIntervalId = setTimeout(poll, 0); + } + + /** + * Check if we have all required credentials, otherwise continue polling + */ + _checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval) { + if (this.isCompleted) return; + + // Collect the required source names/keys + const requiredKeys = [ + ...cookieSources.map((s) => s.name), + ...storageSources.map((s) => s.key), + ]; + const foundKeys = Object.keys(credentials); + const allFound = requiredKeys.every((k) => foundKeys.includes(k)); + + if (allFound && foundKeys.length > 0) { + // Success — all credentials extracted + this._completeLogin(providerId, foundKeys.reduce((acc, k) => { + acc[k] = credentials[k]; + return acc; + }, {})); + } else if (!this.isCompleted) { + // Continue polling using the configured interval + this.pollIntervalId = setTimeout(poll, pollInterval); + } + } + + /** + * Complete the login flow successfully + */ + _completeLogin(providerId, credentials) { + this._cleanup(); + this.emit("status", { + providerId, + status: "complete", + message: "Credentials extracted successfully", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: true, credentials }); + } + } + + /** + * Cancel the current login flow + */ + cancel() { + if (!this.activeProviderId) return; + this._cleanup(); + this.emit("status", { + providerId: this.activeProviderId, + status: "cancelled", + message: "Login cancelled", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: false, error: "Login cancelled" }); + } + } + + /** + * Clean up all resources + */ + _cleanup() { + this.isCompleted = true; + this.activeProviderId = null; + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + if (this._timeoutTimer) { + clearTimeout(this._timeoutTimer); + this._timeoutTimer = null; + } + if (this.pollIntervalId) { + clearTimeout(this.pollIntervalId); + this.pollIntervalId = null; + } + if (this.window && !this.window.isDestroyed()) { + this.window.close(); + } + this.window = null; + this.loginSession = null; + } + + /** + * Get the active provider ID, if any + */ + getActiveProvider() { + return this.activeProviderId; + } +} + +module.exports = { LoginManager, loginManager: new LoginManager() }; diff --git a/electron/main.js b/electron/main.js index ddcaa25bb6..ee0e22a783 100644 --- a/electron/main.js +++ b/electron/main.js @@ -33,6 +33,7 @@ const { spawn } = require("child_process"); const fs = require("fs"); const { autoUpdater } = require("electron-updater"); const { hasEncryptedCredentials } = require("./sqlite-inspection"); +const { loginManager } = require("./loginManager"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -799,6 +800,48 @@ function setupIpcHandlers() { ipcMain.handle("get-app-version", () => app.getVersion()); + // ── Web-Cookie Login IPC Handlers ────────────────────────── + // Forward login status events to the renderer. Registered ONCE here — never + // inside the login:start handler, which would attach a fresh listener (and + // duplicate every subsequent status event) on each invocation. + loginManager.on("status", (status) => { + sendToRenderer("login:status", status); + }); + + ipcMain.handle("login:start", async (_event, providerId, options) => { + const result = await loginManager.startLogin(providerId, options); + + // Persist extracted credentials + if (result.success && result.credentials) { + try { + // Store as JSON blob under the provider ID + const { persistSecret: ps } = require("../src/lib/db/secrets"); + if (typeof ps === "function") { + ps(providerId, JSON.stringify(result.credentials)); + } + sendToRenderer("login:status", { + providerId, + status: "persisted", + message: "Credentials saved", + }); + } catch (err) { + console.error("[Electron] Failed to persist credentials:", err); + return { success: false, error: "Extracted but failed to save credentials" }; + } + } + + return result; + }); + + ipcMain.handle("login:cancel", async () => { + loginManager.cancel(); + return { success: true }; + }); + + ipcMain.handle("login:status", async () => { + return { active: loginManager.getActiveProvider() !== null }; + }); + // Autostart management handlers ipcMain.handle("get-autostart-status", () => { if (process.platform === "linux") { diff --git a/electron/preload.js b/electron/preload.js index 1979c2ef79..0eabaa2748 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -103,9 +103,12 @@ const VALID_CHANNELS = { "get-autostart-status", "enable-autostart", "disable-autostart", + "login:start", + "login:cancel", + "login:status", ], send: ["window-minimize", "window-maximize", "window-close"], - receive: ["server-status", "port-changed", "update-status"], + receive: ["server-status", "port-changed", "update-status", "login:status"], }; // ── Fix #16: Generic IPC wrappers ────────────────────────── @@ -161,6 +164,12 @@ contextBridge.exposeInMainWorld("electronAPI", { onPortChanged: (callback) => safeOn("port-changed", callback), onUpdateStatus: (callback) => safeOn("update-status", callback), + // ── Web-Cookie Login ────────────────────────────────────── + startLogin: (providerId, options) => safeInvoke("login:start", providerId, options), + cancelLogin: () => safeInvoke("login:cancel"), + getLoginStatus: () => safeInvoke("login:status"), + onLoginStatus: (callback) => safeOn("login:status", callback), + // ── Static Properties ──────────────────────────────────── isElectron: true, platform: process.platform, diff --git a/open-sse/services/autoRefreshDaemon.ts b/open-sse/services/autoRefreshDaemon.ts new file mode 100644 index 0000000000..29c22a4a14 --- /dev/null +++ b/open-sse/services/autoRefreshDaemon.ts @@ -0,0 +1,204 @@ +/** + * AutoRefreshDaemon — Background cookie validity checker for web-cookie providers + * + * Periodically checks stored credentials for web-cookie providers by making + * lightweight requests to their home pages. If a credential is expired, it + * logs a warning and marks the credential for re-authentication. + * + * The daemon does NOT automatically re-login (that requires user interaction + * for security). It alerts the system so higher-level components can decide + * what to do (e.g., fallback to another provider, prompt user to re-login). + */ + +import { TOKEN_EXTRACTION_CONFIGS } from "./tokenExtractionConfig"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface DaemonStatus { + running: boolean; + checkedProviderCount: number; + expiredCredentials: string[]; + lastRun: number | null; +} + +interface StoredCredentialEntry { + providerId: string; + value: string; + storedAt: number; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const DEFAULT_CHECK_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +const MIN_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute minimum + +// ─── Daemon ───────────────────────────────────────────────────────────────── + +class AutoRefreshDaemon { + private timerId: ReturnType | null = null; + private running = false; + private checkIntervalMs: number; + private expiredCredentials: string[] = []; + private lastRun: number | null = null; + /** In-memory store of web-cookie credentials (real persistence uses SQLite) */ + private credentialStore = new Map(); + + constructor(checkIntervalMs = DEFAULT_CHECK_INTERVAL_MS) { + this.checkIntervalMs = Math.max(checkIntervalMs, MIN_CHECK_INTERVAL_MS); + } + + /** + * Register a credential for auto-refresh monitoring. + * Called when credentials are extracted/updated. + */ + registerCredential(providerId: string, value: string): void { + this.credentialStore.set(providerId, { + providerId, + value, + storedAt: Date.now(), + }); + } + + /** + * Remove a credential from monitoring (e.g., provider deleted) + */ + unregisterCredential(providerId: string): void { + this.credentialStore.delete(providerId); + } + + /** + * Start the daemon — begins periodic credential checks + */ + start(): void { + if (this.running) return; + this.running = true; + + // Run an initial check immediately + this.check().catch(() => {}); + + this.timerId = setInterval(() => { + this.check().catch(() => {}); + }, this.checkIntervalMs); + + console.log( + `[AutoRefreshDaemon] Started — checking ${this.credentialStore.size} credentials every ${this.checkIntervalMs / 1000}s` + ); + } + + /** + * Stop the daemon + */ + stop(): void { + if (!this.running) return; + this.running = false; + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + } + console.log("[AutoRefreshDaemon] Stopped"); + } + + /** + * Check all stored credentials for validity. + * Makes a lightweight HEAD/GET request to the provider's home page. + */ + async check(): Promise { + this.lastRun = Date.now(); + const newlyExpired: string[] = []; + + const entries = [...this.credentialStore.entries()]; + + for (const [providerId] of entries) { + const config = TOKEN_EXTRACTION_CONFIGS.get(providerId); + if (!config) { + this.credentialStore.delete(providerId); + continue; + } + + try { + const isValid = await this.validateCredential(providerId, config.homeUrl); + if (!isValid) { + newlyExpired.push(providerId); + console.warn( + `[AutoRefreshDaemon] Credential expired for "${providerId}" (${config.displayName})` + ); + } + } catch { + // Network errors are non-fatal — retry next cycle + } + } + + // Update expired list + for (const id of newlyExpired) { + if (!this.expiredCredentials.includes(id)) { + this.expiredCredentials.push(id); + } + } + } + + /** + * Validate a credential by making a request to the provider's home page. + * Returns true if the response suggests the credential is still valid. + */ + private async validateCredential(providerId: string, homeUrl: string): Promise { + const entry = this.credentialStore.get(providerId); + if (!entry) return false; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(homeUrl, { + method: "HEAD", + signal: controller.signal, + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + }, + }); + + // A valid credential typically returns 200 (occasionally 301/302) + // 401/403 strongly suggest expired credential + if (response.status === 401 || response.status === 403) { + return false; + } + + return true; + } catch { + // Network errors (timeout, DNS failure) don't mean the credential is bad + return true; + } finally { + clearTimeout(timeout); + } + } + + /** + * Get the current daemon status + */ + getStatus(): DaemonStatus { + return { + running: this.running, + checkedProviderCount: this.credentialStore.size, + expiredCredentials: [...this.expiredCredentials], + lastRun: this.lastRun, + }; + } + + /** + * Clear expired credentials list (e.g., after re-authentication) + */ + clearExpired(): void { + this.expiredCredentials = []; + } + + /** + * Restart the daemon (useful when config changes) + */ + restart(): void { + this.stop(); + this.start(); + } +} + +// ─── Singleton ────────────────────────────────────────────────────────────── + +export const autoRefreshDaemon = new AutoRefreshDaemon(); diff --git a/open-sse/services/inAppLoginService.ts b/open-sse/services/inAppLoginService.ts new file mode 100644 index 0000000000..0a71b99929 --- /dev/null +++ b/open-sse/services/inAppLoginService.ts @@ -0,0 +1,256 @@ +/** + * InAppLoginService — Playwright-based web login for cookie providers + * + * Opens a Playwright browser context, navigates to the provider's login page, + * and polls for target cookies/tokens after the user completes login. + * + * Used as the dashboard/web fallback path when Electron is not available. + * For Electron-native login, see electron/loginManager.js. + * + * Events: + * "status" — { providerId: string, status: string, message: string } + * status values: starting, navigating, waiting, polling, complete, error, cancelled + */ + +import { EventEmitter } from "events"; +import { TOKEN_EXTRACTION_CONFIGS, TokenExtractionConfig, type TokenSource } from "./tokenExtractionConfig"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface LoginResult { + success: boolean; + credentials?: Record; + error?: string; +} + +interface ActiveLogin { + providerId: string; + aborted: boolean; +} + +// ─── Service ──────────────────────────────────────────────────────────────── + +export class InAppLoginService extends EventEmitter { + private activeLogin: ActiveLogin | null = null; + + /** + * Start a login flow for a web-cookie provider using Playwright. + * @param providerId - e.g. "claude-web", "chatgpt-web" + * @param options.timeout - Total timeout in ms (default: config value or 300s) + */ + async startLogin(providerId: string, options?: { timeout?: number }): Promise { + const config = TOKEN_EXTRACTION_CONFIGS.get(providerId); + if (!config) { + this.emit("status", { providerId, status: "error", message: "No extraction config found" }); + return { success: false, error: `No extraction config for provider: ${providerId}` }; + } + + if (this.activeLogin) { + this.emit("status", { providerId, status: "error", message: "A login is already in progress" }); + return { success: false, error: "A login process is already in progress" }; + } + + this.activeLogin = { providerId, aborted: false }; + this.emit("status", { providerId, status: "starting", message: `Opening ${config.displayName} login...` }); + + try { + const result = await this.runBrowserLogin(config, options?.timeout); + this.emit("status", { + providerId, + status: result.success ? "complete" : "error", + message: result.success ? "Credentials extracted successfully" : (result.error || "Login failed"), + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emit("status", { providerId, status: "error", message }); + return { success: false, error: `Login failed: ${message}` }; + } finally { + this.activeLogin = null; + } + } + + /** + * Run the actual Playwright browser login flow + */ + private async runBrowserLogin( + config: TokenExtractionConfig, + timeout?: number + ): Promise { + const pollInterval = config.pollingConfig.pollInterval || 1000; + const maxTimeout = timeout || config.pollingConfig.timeout || 300_000; + const minLoginTime = config.pollingConfig.minLoginTime || 5000; + const providerId = config.providerId; + + // Dynamically import Playwright (it's a heavy dep, only load when needed) + let playwright: any; + try { + playwright = await import("playwright"); + } catch { + return { success: false, error: "Playwright is not installed. Use Electron for native login." }; + } + + if (this.activeLogin?.aborted) { + return { success: false, error: "Login cancelled" }; + } + + // Launch browser + this.emit("status", { providerId, status: "starting", message: "Launching browser..." }); + const browser = await playwright.chromium.launch({ + headless: false, // User must interact with the login page + }); + + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + locale: "en-US", + }); + const page = await context.newPage(); + + // Navigate to login URL + this.emit("status", { providerId, status: "navigating", message: `Loading ${config.loginUrl}` }); + await page.goto(config.loginUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + + // Poll for success URL + token extraction + const maxPolls = Math.floor(maxTimeout / pollInterval); + const credentials: Record = {}; + const startTime = Date.now(); + + for (let i = 0; i < maxPolls; i++) { + if (this.activeLogin?.aborted) { + this.emit("status", { providerId, status: "cancelled", message: "Login cancelled by user" }); + return { success: false, error: "Login cancelled" }; + } + + // Emit progress every 30 seconds + if (i > 0 && i % 30 === 0) { + this.emit("status", { + providerId, + status: "waiting", + message: `Waiting for login... (${Math.round(i / 60)}m)`, + }); + } + + // Wait before polling (respect minLoginTime on first iteration) + if (Date.now() - startTime < minLoginTime) { + await sleep(pollInterval); + continue; + } + + // Gather cookies from browser context + const cookies = await context.cookies(); + const tokenSources = config.tokenSources; + + // Check cookie-based sources + for (const source of tokenSources) { + if (source.type === "cookie") { + const domain = source.domain || undefined; + const matched = cookies.find( + (c: any) => + c.name === source.name && + (!domain || c.domain.includes(domain.replace(/^\./, ""))) + ); + if (matched && !credentials[source.name]) { + credentials[source.name] = matched.value; + } + } + } + + // Check localStorage-based tokens + for (const source of tokenSources) { + if (source.type === "localStorage" && !credentials[source.key]) { + try { + const value = await page.evaluate((key: string) => localStorage.getItem(key), source.key); + if (value && typeof value === "string") { + credentials[source.key] = value; + } + } catch { + // localStorage access may fail on some domains + } + } + if (source.type === "sessionStorage" && !credentials[source.key]) { + try { + const value = await page.evaluate((key: string) => sessionStorage.getItem(key), source.key); + if (value && typeof value === "string") { + credentials[source.key] = value; + } + } catch { + // sessionStorage access may fail on some domains + } + } + } + + // Check if all required tokens are found + const requiredKeys = tokenSources.map((s) => + s.type === "cookie" ? s.name : s.type === "localStorage" || s.type === "sessionStorage" ? s.key : s.name + ); + const allFound = requiredKeys.every((k) => credentials[k] !== undefined); + + if (allFound && Object.keys(credentials).length > 0) { + return { success: true, credentials }; + } + + // Check for success URL pattern + if (config.successUrlPattern) { + try { + const currentUrl = page.url(); + if (config.successUrlPattern.test(currentUrl) && Object.keys(credentials).length > 0) { + return { success: true, credentials }; + } + } catch { + // URL access may fail on some pages + } + } + + await sleep(pollInterval); + } + + return { success: false, error: "Login timed out" }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emit("status", { providerId, status: "error", message }); + return { success: false, error: `Login failed: ${message}` }; + } finally { + await browser.close().catch(() => {}); + } + } + + /** + * Cancel the current login flow + */ + cancel(): void { + if (this.activeLogin) { + this.emit("status", { + providerId: this.activeLogin.providerId, + status: "cancelled", + message: "Login cancelled by user", + }); + this.activeLogin.aborted = true; + this.activeLogin = null; + } + } + + /** + * Get the active provider ID, if any + */ + getActiveProvider(): string | null { + return this.activeLogin?.providerId || null; + } + + /** + * Check if a login flow is in progress + */ + isActive(): boolean { + return this.activeLogin !== null && !this.activeLogin.aborted; + } +} + +// ─── Sleep helper ─────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ─── Singleton ────────────────────────────────────────────────────────────── + +export const inAppLoginService = new InAppLoginService(); diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts new file mode 100644 index 0000000000..d9f6f0a86c --- /dev/null +++ b/open-sse/services/tokenExtractionConfig.ts @@ -0,0 +1,411 @@ +/** + * TokenExtractionConfig — Login & cookie extraction configs for web-cookie providers + * + * Each config describes how to: + * 1. Open a browser window/navigate to the provider's login page + * 2. Detect successful login (URL change + token presence) + * 3. Extract session cookies / tokens from the browser context + * + * Used by InAppLoginService (Electron BrowserWindow path) and + * the Playwright-based login flow (dashboard API). + */ + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** Describes where to extract credential data from after login */ +export type TokenSource = + | { type: "cookie"; name: string; domain?: string } + | { type: "localStorage"; key: string } + | { type: "sessionStorage"; key: string } + | { type: "header"; name: string }; + +export interface PollingConfig { + /** Milliseconds between extraction polls (default 1000) */ + pollInterval: number; + /** Total timeout in ms (default 300000 = 5 min) */ + timeout: number; + /** Minimum time in ms before first extraction attempt (default 5000) */ + minLoginTime: number; +} + +export interface TokenExtractionConfig { + /** Matches the executor's provider ID (e.g. "claude-web", "gemini-web") */ + providerId: string; + /** Human-readable name shown in dashboard UI */ + displayName: string; + /** The URL to navigate to for login */ + loginUrl: string; + /** The provider's home page URL (for cookie domain binding) */ + homeUrl: string; + /** Optional regex. If current URL matches → login is likely complete */ + successUrlPattern?: RegExp; + /** Sources to extract credentials from after login */ + tokenSources: TokenSource[]; + /** Polling behaviour */ + pollingConfig: PollingConfig; + /** Short instructions shown to the user in the login modal */ + instructions: string; + /** Optional: cookie domain override for cookie injection */ + cookieDomain?: string; +} + +// ─── Defaults ─────────────────────────────────────────────────────────────── + +const DEFAULT_POLLING: PollingConfig = { + pollInterval: 1000, + timeout: 300_000, + minLoginTime: 5000, +}; + +const QUICK_POLLING: PollingConfig = { + pollInterval: 800, + timeout: 120_000, + minLoginTime: 3000, +}; + +// ─── Helper ────────────────────────────────────────────────────────────────── + +function config( + providerId: string, + displayName: string, + loginUrl: string, + homeUrl: string, + tokenSources: TokenSource[], + instructions: string, + opts?: { + successUrlPattern?: RegExp; + pollingConfig?: Partial; + cookieDomain?: string; + } +): TokenExtractionConfig { + return { + providerId, + displayName, + loginUrl, + homeUrl, + tokenSources, + instructions, + pollingConfig: { ...DEFAULT_POLLING, ...opts?.pollingConfig }, + successUrlPattern: opts?.successUrlPattern, + cookieDomain: opts?.cookieDomain, + }; +} + +// ─── Configuration Map ────────────────────────────────────────────────────── + +const RAW_CONFIGS: TokenExtractionConfig[] = [ + // ── Claude Web ──────────────────────────────────────────── + config( + "claude-web", + "Claude Web", + "https://claude.ai/login", + "https://claude.ai", + [{ type: "cookie", name: "sessionKey", domain: ".claude.ai" }], + "Log in to your Claude account at claude.ai. After login, the session cookie will be extracted automatically." + ), + + // ── ChatGPT Web ─────────────────────────────────────────── + config( + "chatgpt-web", + "ChatGPT Web", + "https://chatgpt.com/auth/login", + "https://chatgpt.com", + [ + { type: "cookie", name: "__Secure-next-auth.session-token", domain: ".chatgpt.com" }, + ], + "Log in to ChatGPT. The __Secure-next-auth.session-token cookie will be extracted after login." + ), + + // ── Gemini Web ──────────────────────────────────────────── + config( + "gemini-web", + "Gemini Web", + "https://gemini.google.com/app", + "https://gemini.google.com", + [ + { type: "cookie", name: "__Secure-1PSID", domain: ".google.com" }, + { type: "cookie", name: "__Secure-1PSIDTS", domain: ".google.com" }, + ], + "Log in to your Google account at gemini.google.com. Both __Secure-1PSID and __Secure-1PSIDTS cookies will be extracted.", + { cookieDomain: ".google.com" } + ), + + // ── Grok Web ────────────────────────────────────────────── + config( + "grok-web", + "Grok Web", + "https://grok.com/login", + "https://grok.com", + [{ type: "cookie", name: "sso", domain: ".grok.com" }], + "Log in to your xAI account at grok.com. The sso session cookie will be extracted." + ), + + // ── Perplexity Web ──────────────────────────────────────── + config( + "perplexity-web", + "Perplexity Web", + "https://www.perplexity.ai/login", + "https://www.perplexity.ai", + [ + { type: "cookie", name: "__Secure-next-auth.session-token", domain: ".perplexity.ai" }, + ], + "Log in to Perplexity. The __Secure-next-auth.session-token cookie will be extracted.", + { cookieDomain: ".perplexity.ai" } + ), + + // ── DeepSeek Web ────────────────────────────────────────── + config( + "deepseek-web", + "DeepSeek Web", + "https://chat.deepseek.com/sign_in", + "https://chat.deepseek.com", + [ + { type: "cookie", name: "user-token", domain: ".deepseek.com" }, + { type: "localStorage", key: "userToken" }, + ], + "Log in to DeepSeek at chat.deepseek.com. The user-token cookie will be extracted.", + { cookieDomain: ".deepseek.com" } + ), + + // ── Qwen Web ────────────────────────────────────────────── + config( + "qwen-web", + "Qwen Web (Tongyi)", + "https://chat.qwen.ai/", + "https://chat.qwen.ai", + [ + { type: "cookie", name: "XSRF_TOKEN", domain: ".chat.qwen.ai" }, + { type: "localStorage", key: "token" }, + ], + "Log in to Qwen at chat.qwen.ai using your Alibaba account. The session token will be extracted.", + { cookieDomain: ".chat.qwen.ai" } + ), + + // ── Kimi Web ────────────────────────────────────────────── + config( + "kimi-web", + "Kimi (Moonshot)", + "https://kimi.moonshot.cn/", + "https://kimi.moonshot.cn", + [ + { type: "cookie", name: "kimi_token", domain: ".kimi.moonshot.cn" }, + { type: "localStorage", key: "kimi_token" }, + ], + "Log in to Kimi at kimi.moonshot.cn via phone/WeChat. The session token will be extracted.", + { cookieDomain: ".kimi.moonshot.cn" } + ), + + // ── Blackbox Web ────────────────────────────────────────── + config( + "blackbox-web", + "Blackbox AI", + "https://app.blackbox.ai/login", + "https://app.blackbox.ai", + [ + { type: "cookie", name: "connect.sid", domain: ".blackbox.ai" }, + { type: "localStorage", key: "token" }, + ], + "Log in to Blackbox AI at app.blackbox.ai using Google/GitHub. The session cookie will be extracted.", + { cookieDomain: ".blackbox.ai" } + ), + + // ── Poe Web ─────────────────────────────────────────────── + config( + "poe-web", + "Poe (Quora)", + "https://poe.com/login", + "https://poe.com", + [ + { type: "cookie", name: "p-b", domain: ".poe.com" }, + ], + "Log in to Poe at poe.com. The session cookie will be extracted.", + { cookieDomain: ".poe.com" } + ), + + // ── Copilot Web ─────────────────────────────────────────── + config( + "copilot-web", + "Microsoft Copilot", + "https://copilot.microsoft.com/", + "https://copilot.microsoft.com", + [ + { type: "cookie", name: "RPSCAuth", domain: ".microsoft.com" }, + ], + "Log in with your Microsoft account at copilot.microsoft.com. The session auth cookie will be extracted.", + { cookieDomain: ".microsoft.com" } + ), + + // ── DuckDuckGo Web ──────────────────────────────────────── + config( + "duckduckgo-web", + "DuckDuckGo AI Chat", + "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=1", + "https://duckduckgo.com", + [ + { type: "cookie", name: "duckai", domain: ".duckduckgo.com" }, + ], + "Open DuckDuckGo AI Chat. Some models may require a free account. The duckai cookie will be extracted.", + { + cookieDomain: ".duckduckgo.com", + pollingConfig: QUICK_POLLING, + } + ), + + // ── DouBao Web ──────────────────────────────────────────── + config( + "doubao-web", + "DouBao (ByteDance)", + "https://www.doubao.com/", + "https://www.doubao.com", + [ + { type: "cookie", name: "sessionid", domain: ".doubao.com" }, + ], + "Log in to DouBao at doubao.com with your ByteDance account. The sessionid will be extracted.", + { cookieDomain: ".doubao.com" } + ), + + // ── T3 Chat Web ─────────────────────────────────────────── + config( + "t3-chat-web", + "T3 Chat", + "https://t3.chat/login", + "https://t3.chat", + [ + { type: "localStorage", key: "token" }, + ], + "Log in to T3 Chat at t3.chat using Google/GitHub. The token from localStorage will be extracted.", + { pollingConfig: QUICK_POLLING } + ), + + // ── Venice Web ──────────────────────────────────────────── + config( + "venice-web", + "Venice AI", + "https://venice.ai/login", + "https://venice.ai", + [ + { type: "cookie", name: "venice_session", domain: ".venice.ai" }, + { type: "localStorage", key: "token" }, + ], + "Log in to Venice AI at venice.ai. The session cookie will be extracted.", + { cookieDomain: ".venice.ai" } + ), + + // ── v0 Dev Web ──────────────────────────────────────────── + config( + "v0-vercel-web", + "v0 by Vercel", + "https://v0.dev/login", + "https://v0.dev", + [ + { type: "cookie", name: "__Secure-next-auth.session-token", domain: ".v0.dev" }, + ], + "Log in to v0.dev with your Vercel/Google/GitHub account. The session cookie will be extracted.", + { cookieDomain: ".v0.dev" } + ), + + // ── Muse / Spark Web ────────────────────────────────────── + config( + "muse-spark-web", + "Meta AI (Muse)", + "https://www.meta.ai/", + "https://www.meta.ai", + [ + { type: "cookie", name: "session", domain: ".meta.ai" }, + ], + "Log in to Meta AI at meta.ai with your Facebook/Instagram account. The session cookie will be extracted.", + { cookieDomain: ".meta.ai" } + ), + + // ── Adapta Web ──────────────────────────────────────────── + config( + "adapta-web", + "Adapta AI", + "https://agent.adapta.one/login", + "https://agent.adapta.one", + [ + { type: "cookie", name: "__session", domain: ".adapta.one" }, + ], + "Log in to Adapta at agent.adapta.one. The session token will be extracted.", + { cookieDomain: ".adapta.one" } + ), + + // ── VeoAI Free Web ──────────────────────────────────────── + config( + "veoaifree-web", + "VeoAI Free", + "https://veoaifree.com/", + "https://veoaifree.com", + [ + { type: "cookie", name: "wordpress_logged_in", domain: ".veoaifree.com" }, + ], + "Log in to VeoAI Free at veoaifree.com. The WordPress session cookie will be extracted.", + { + cookieDomain: ".veoaifree.com", + pollingConfig: QUICK_POLLING, + } + ), + + // ── Missing Provider: ChatGLM (Zhipu) ────────────────────── + config( + "chatglm-web", + "ChatGLM (Zhipu AI)", + "https://chatglm.cn/", + "https://chatglm.cn", + [ + { type: "cookie", name: "chatglm_session", domain: ".chatglm.cn" }, + { type: "localStorage", key: "token" }, + ], + "Log in to ChatGLM at chatglm.cn with your phone number. The session token will be extracted.", + { cookieDomain: ".chatglm.cn" } + ), + + // ── Missing Provider: Xiaomi MiMo ────────────────────────── + config( + "xiaomimimo-web", + "Xiaomi MiMo AI Studio", + "https://aistudio.xiaomimimo.com/login", + "https://aistudio.xiaomimimo.com", + [ + { type: "cookie", name: "session", domain: ".xiaomimimo.com" }, + { type: "localStorage", key: "access_token" }, + ], + "Log in to Xiaomi MiMo AI Studio at aistudio.xiaomimimo.com. The session token will be extracted.", + { cookieDomain: ".xiaomimimo.com" } + ), + + // ── Missing Provider: Manus ──────────────────────────────── + config( + "manus-web", + "Manus AI", + "https://manus.im/login", + "https://manus.im", + [ + { type: "cookie", name: "manus_session", domain: ".manus.im" }, + { type: "localStorage", key: "auth_token" }, + ], + "Log in to Manus at manus.im. The session cookie will be extracted.", + { cookieDomain: ".manus.im" } + ), +]; + +// ─── Registry ─────────────────────────────────────────────────────────────── + +const CONFIG_MAP = new Map(); + +for (const cfg of RAW_CONFIGS) { + CONFIG_MAP.set(cfg.providerId, cfg); +} + +/** Get extraction config for a specific provider */ +export function getExtractionConfig(providerId: string): TokenExtractionConfig | undefined { + return CONFIG_MAP.get(providerId); +} + +/** List all registered extraction configs */ +export function listExtractionConfigs(): TokenExtractionConfig[] { + return [...RAW_CONFIGS]; +} + +/** The shared config map — used by LoginManager and InAppLoginService */ +export const TOKEN_EXTRACTION_CONFIGS = CONFIG_MAP; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts index e45b580e54..bdd8a9cf14 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts @@ -137,6 +137,36 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)", acceptsFullCookieHeader: false, }, + "duckduckgo-web": { + kind: "cookie", + credentialName: "duckai", + placeholder: "duckai=... or full Cookie header from duckduckgo.com", + acceptsFullCookieHeader: true, + }, + "t3-chat-web": { + kind: "token", + credentialName: "token", + placeholder: "Paste your T3 Chat token from t3.chat (Local Storage → token)", + acceptsFullCookieHeader: false, + }, + "chatglm-web": { + kind: "cookie", + credentialName: "chatglm_session", + placeholder: "chatglm_session=... or full Cookie header from chatglm.cn", + acceptsFullCookieHeader: true, + }, + "xiaomimimo-web": { + kind: "cookie", + credentialName: "session", + placeholder: "session=... or full Cookie header from aistudio.xiaomimimo.com", + acceptsFullCookieHeader: true, + }, + "manus-web": { + kind: "cookie", + credentialName: "manus_session", + placeholder: "manus_session=... or full Cookie header from manus.im", + acceptsFullCookieHeader: true, + }, } satisfies Record; export function getWebSessionCredentialRequirement( diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts new file mode 100644 index 0000000000..ccc0719622 --- /dev/null +++ b/src/app/api/providers/[id]/login/route.ts @@ -0,0 +1,75 @@ +/** + * POST /api/providers/[id]/login + * + * Web-cookie provider login endpoint. Launches a Playwright browser, + * navigates to the provider's login page, polls for session tokens, + * and persists extracted credentials to the provider connection. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +// ─── POST: Start login flow ──────────────────────────────────────────────── + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const auth = await requireManagementAuth(req); + if (auth) return auth; + + const { id } = await params; + const provider = await getProviderConnectionById(id); + if (!provider) { + return NextResponse.json({ success: false, error: "Provider not found" }, { status: 404 }); + } + + const body = await req.json().catch(() => ({})); + const timeout = typeof body.timeout === "number" ? body.timeout : undefined; + + try { + // Dynamic import — InAppLoginService depends on Playwright (heavy) + const { inAppLoginService } = await import( + "@omniroute/open-sse/services/inAppLoginService.ts" + ); + + const result = await inAppLoginService.startLogin(id, { timeout }); + + // Persist credentials if extraction succeeded + if (result.success && result.credentials) { + try { + const credentialsStr = JSON.stringify(result.credentials); + await updateProviderConnection(id, { + api_key: credentialsStr, + provider_specific_data: result.credentials, + }); + + return NextResponse.json({ + success: true, + credentials: result.credentials, + persisted: true, + }); + } catch (err) { + // Hard Rule #12: never put raw err.message/stack in a response body. + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `Extracted but failed to persist: ${msg}` }, + { status: 500 } + ); + } + } + + return NextResponse.json(result, { + status: result.success ? 200 : 400, + }); + } catch (err) { + // Hard Rule #12: never put raw err.message/stack in a response body. + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `Login endpoint error: ${msg}` }, + { status: 500 } + ); + } +} diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 7e96e9ab93..965b857a8c 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -237,5 +237,13 @@ export async function registerNodejs(): Promise { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] Embed WS proxy failed to start (non-fatal):", msg); } + + try { + const { autoRefreshDaemon } = await import("@/open-sse/services/autoRefreshDaemon"); + autoRefreshDaemon.start(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Auto-refresh daemon failed to start (non-fatal):", msg); + } } } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 5bbf92930b..f97d7a3f97 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -37,6 +37,22 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/plugins", // bare path: GET list + POST install also trigger plugin loading ]; +/** + * LOCAL_ONLY routes whose spawn-capable segment sits AFTER a dynamic path + * parameter, so a flat prefix in `LOCAL_ONLY_API_PREFIXES` cannot target them + * without over-broadening (e.g. locking the entire `/api/providers/` subtree, + * which remote dashboards legitimately use for provider CRUD). These are matched + * by regex instead. + * + * - `POST /api/providers/{id}/login` launches a headful Playwright Chromium + * (a child process) to drive a web-cookie login. Loopback enforcement must + * happen unconditionally before any auth check (Hard Rules #15 + #17), so a + * leaked JWT via tunnel cannot trigger a browser spawn. + */ +export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray = [ + /^\/api\/providers\/[^/]+\/login\/?$/, +]; + /** * Compile-time deny-list: route prefixes that can spawn arbitrary local * subprocesses on behalf of the caller. These MUST NEVER appear in the @@ -141,7 +157,10 @@ export function isPrivateLanHost(hostHeader: string | null): boolean { } export function isLocalOnlyPath(path: string): boolean { - return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p)); + return ( + LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p)) || + LOCAL_ONLY_API_PATTERNS.some((re) => re.test(path)) + ); } /** diff --git a/tests/unit/autoRefreshDaemon.test.ts b/tests/unit/autoRefreshDaemon.test.ts new file mode 100644 index 0000000000..3ea6cd2a48 --- /dev/null +++ b/tests/unit/autoRefreshDaemon.test.ts @@ -0,0 +1,87 @@ +/** + * Tests for AutoRefreshDaemon + */ + +import assert from "node:assert/strict"; +import { describe, it, before, after, beforeEach } from "node:test"; +import { autoRefreshDaemon, type DaemonStatus } from "../../open-sse/services/autoRefreshDaemon"; + +describe("AutoRefreshDaemon", () => { + beforeEach(() => { + autoRefreshDaemon.clearExpired(); + autoRefreshDaemon.stop(); + }); + + describe("start / stop", () => { + it("should start and stop cleanly", () => { + let status = autoRefreshDaemon.getStatus(); + assert.equal(status.running, false); + + autoRefreshDaemon.start(); + status = autoRefreshDaemon.getStatus(); + assert.equal(status.running, true); + + autoRefreshDaemon.stop(); + status = autoRefreshDaemon.getStatus(); + assert.equal(status.running, false); + }); + + it("should be idempotent on start", () => { + autoRefreshDaemon.start(); + autoRefreshDaemon.start(); // second call should noop + assert.equal(autoRefreshDaemon.getStatus().running, true); + autoRefreshDaemon.stop(); + }); + + it("should be idempotent on stop", () => { + autoRefreshDaemon.stop(); // not running — noop + autoRefreshDaemon.start(); + autoRefreshDaemon.stop(); + autoRefreshDaemon.stop(); // already stopped — noop + assert.equal(autoRefreshDaemon.getStatus().running, false); + }); + }); + + describe("registerCredential / unregisterCredential", () => { + it("should register and track a credential", () => { + autoRefreshDaemon.registerCredential("test-provider", "cookie-value-123"); + const status = autoRefreshDaemon.getStatus(); + assert.equal(status.checkedProviderCount, 1); + }); + + it("should unregister and stop tracking", () => { + autoRefreshDaemon.registerCredential("test-provider", "cookie-value-123"); + assert.equal(autoRefreshDaemon.getStatus().checkedProviderCount, 1); + + autoRefreshDaemon.unregisterCredential("test-provider"); + assert.equal(autoRefreshDaemon.getStatus().checkedProviderCount, 0); + }); + }); + + describe("clearExpired", () => { + it("should clear expired list", () => { + // Force-expire by registering and running check against a provider that + // won't resolve — expired list stays empty since network errors are non-fatal + autoRefreshDaemon.registerCredential("fake-nonexistent", "test-value"); + assert.equal(autoRefreshDaemon.getStatus().expiredCredentials.length, 0); + + autoRefreshDaemon.clearExpired(); + assert.equal(autoRefreshDaemon.getStatus().expiredCredentials.length, 0); + }); + }); + + describe("getStatus", () => { + it("should return current daemon state", () => { + autoRefreshDaemon.registerCredential("provider-a", "val-a"); + autoRefreshDaemon.start(); + const status = autoRefreshDaemon.getStatus(); + + assert.ok(typeof status.running === "boolean"); + assert.ok(typeof status.checkedProviderCount === "number"); + assert.ok(Array.isArray(status.expiredCredentials)); + assert.ok(typeof status.lastRun === "number" || status.lastRun === null); + + autoRefreshDaemon.stop(); + }); + }); +}); diff --git a/tests/unit/route-guard-provider-login-local-only.test.ts b/tests/unit/route-guard-provider-login-local-only.test.ts new file mode 100644 index 0000000000..c935292c7e --- /dev/null +++ b/tests/unit/route-guard-provider-login-local-only.test.ts @@ -0,0 +1,38 @@ +/** + * Security regression (#3292): POST /api/providers/[id]/login launches a headful + * Playwright Chromium (a child process) to drive a web-cookie login. It MUST be + * classified as LOCAL_ONLY so loopback enforcement runs unconditionally before + * any auth check — a leaked JWT over a Cloudflared/Ngrok tunnel cannot trigger a + * browser spawn. Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md. + * + * The login segment sits AFTER the dynamic `[id]` param, so it is matched by a + * regex in LOCAL_ONLY_API_PATTERNS rather than a flat prefix — classifying the + * whole `/api/providers/` subtree as LOCAL_ONLY would wrongly lock the remote + * dashboard out of ordinary provider CRUD. These tests pin BOTH the gate AND the + * narrowness (no over-match). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; + +test("/api/providers/[id]/login is LOCAL_ONLY (spawns Playwright Chromium)", () => { + assert.equal(isLocalOnlyPath("/api/providers/claude-web/login"), true); + assert.equal(isLocalOnlyPath("/api/providers/abc-123/login"), true); +}); + +test("/api/providers/[id]/login with a trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/providers/claude-web/login/"), true); +}); + +test("the provider-login gate does NOT over-match the rest of /api/providers", () => { + // Ordinary provider management must stay remotely reachable. + assert.equal(isLocalOnlyPath("/api/providers"), false); + assert.equal(isLocalOnlyPath("/api/providers/"), false); + assert.equal(isLocalOnlyPath("/api/providers/claude-web"), false); + assert.equal(isLocalOnlyPath("/api/providers/claude-web/test"), false); + assert.equal(isLocalOnlyPath("/api/providers/claude-web/models"), false); + // Anchored: extra segments after /login are not the spawn route. + assert.equal(isLocalOnlyPath("/api/providers/claude-web/login/extra"), false); + // "login" must be its own segment, not a substring of the id. + assert.equal(isLocalOnlyPath("/api/providers/login-helper/status"), false); +}); diff --git a/tests/unit/tokenExtractionConfig.test.ts b/tests/unit/tokenExtractionConfig.test.ts new file mode 100644 index 0000000000..30443983ee --- /dev/null +++ b/tests/unit/tokenExtractionConfig.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for open-sse/services/tokenExtractionConfig.ts + * + * Validates that all web-cookie provider configs are well-formed, + * have valid login URLs, and include at least one extraction source. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const { + TOKEN_EXTRACTION_CONFIGS, + getExtractionConfig, + listExtractionConfigs, +} = await import("../../open-sse/services/tokenExtractionConfig.ts"); + +describe("tokenExtractionConfig", () => { + it("exports TOKEN_EXTRACTION_CONFIGS as a Map", () => { + assert.ok(TOKEN_EXTRACTION_CONFIGS instanceof Map); + }); + + it("has at least 21 registered providers (18 existing + 3 new)", () => { + assert.ok(TOKEN_EXTRACTION_CONFIGS.size >= 21); + }); + + it("every config has required fields", () => { + for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) { + assert.ok(typeof cfg.providerId === "string", `${providerId}: missing providerId`); + assert.ok(cfg.providerId.length > 0, `${providerId}: empty providerId`); + assert.ok(typeof cfg.displayName === "string", `${providerId}: missing displayName`); + assert.ok(cfg.displayName.length > 0, `${providerId}: empty displayName`); + assert.ok( + cfg.loginUrl.startsWith("http"), + `${providerId}: loginUrl "${cfg.loginUrl}" must start with http` + ); + assert.ok( + cfg.homeUrl.startsWith("http"), + `${providerId}: homeUrl "${cfg.homeUrl}" must start with http` + ); + assert.ok(Array.isArray(cfg.tokenSources), `${providerId}: tokenSources must be an array`); + assert.ok(cfg.tokenSources.length > 0, `${providerId}: must have at least one tokenSource`); + assert.ok(typeof cfg.instructions === "string", `${providerId}: missing instructions`); + assert.ok(cfg.instructions.length > 0, `${providerId}: empty instructions`); + } + }); + + it("every tokenSource has a valid type", () => { + const validTypes = ["cookie", "localStorage", "sessionStorage", "header"]; + for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) { + for (const src of cfg.tokenSources) { + assert.ok( + validTypes.includes(src.type), + `${providerId}: invalid tokenSource type "${src.type}"` + ); + if (src.type === "cookie") { + assert.ok(typeof src.name === "string", `${providerId}: cookie source missing name`); + assert.ok(src.name.length > 0, `${providerId}: cookie source has empty name`); + } + if (src.type === "localStorage" || src.type === "sessionStorage") { + assert.ok(typeof src.key === "string", `${providerId}: storage source missing key`); + assert.ok(src.key.length > 0, `${providerId}: storage source has empty key`); + } + } + } + }); + + it("loginUrl and homeUrl share the same root domain", () => { + function extractDomain(url: string): string { + try { + const u = new URL(url); + return u.hostname; + } catch { + return ""; + } + } + for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) { + const loginDomain = extractDomain(cfg.loginUrl); + const homeDomain = extractDomain(cfg.homeUrl); + // Allow different subdomains but same root + const loginParts = loginDomain.split("."); + const homeParts = homeDomain.split("."); + const loginRoot = loginParts.slice(-2).join("."); + const homeRoot = homeParts.slice(-2).join("."); + assert.equal( + loginRoot, + homeRoot, + `${providerId}: loginUrl (${cfg.loginUrl}) and homeUrl (${cfg.homeUrl}) should share the same root domain` + ); + } + }); + + it("getExtractionConfig returns undefined for unknown provider", () => { + const result = getExtractionConfig("nonexistent-provider"); + assert.equal(result, undefined); + }); + + it("getExtractionConfig returns config for known providers", () => { + const providers = ["claude-web", "chatgpt-web", "gemini-web", "grok-web", "deepseek-web"]; + for (const id of providers) { + const cfg = getExtractionConfig(id); + assert.ok(cfg !== undefined, `getExtractionConfig("${id}") returned undefined`); + assert.equal(cfg?.providerId, id); + } + }); + + it("listExtractionConfigs returns all configs as an array", () => { + const all = listExtractionConfigs(); + assert.ok(Array.isArray(all)); + assert.equal(all.length, TOKEN_EXTRACTION_CONFIGS.size); + }); + + it("includes the 3 new missing providers", () => { + const newProviders = ["chatglm-web", "xiaomimimo-web", "manus-web"]; + for (const id of newProviders) { + const cfg = getExtractionConfig(id); + assert.ok(cfg !== undefined, `Missing provider "${id}" not found in config`); + } + }); + + it("every provider ID matches the executor naming convention", () => { + for (const providerId of TOKEN_EXTRACTION_CONFIGS.keys()) { + assert.ok( + providerId.endsWith("-web"), + `Provider ID "${providerId}" should follow the "-web" naming convention` + ); + } + }); + + it("each cookie token source has a valid domain when specified", () => { + for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) { + for (const src of cfg.tokenSources) { + if (src.type === "cookie" && src.domain) { + assert.ok( + src.domain.startsWith(".") || src.domain.startsWith("http"), + `${providerId}: cookie domain "${src.domain}" should start with "." or "http"` + ); + } + } + } + }); + + it("pollingConfig has valid values", () => { + for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) { + assert.ok( + cfg.pollingConfig.pollInterval >= 100, + `${providerId}: pollInterval too low (${cfg.pollingConfig.pollInterval})` + ); + assert.ok( + cfg.pollingConfig.timeout >= 10000, + `${providerId}: timeout too low (${cfg.pollingConfig.timeout})` + ); + assert.ok( + cfg.pollingConfig.minLoginTime >= 1000, + `${providerId}: minLoginTime too low (${cfg.pollingConfig.minLoginTime})` + ); + } + }); +}); From a85500e2b7cc34d817bb6523d60084b927741ada Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 6 Jun 2026 12:29:28 -0300 Subject: [PATCH 08/13] docs(changelog): record the v3.8.13 PRs merged this round (#3292/#3300/#3297/#3298/#3301/#3302/#3299) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14023f3b41..6add4aa23a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ _Development cycle in progress — entries are added as work merges into `release/v3.8.13` and finalized by the release flow._ +### ✨ New Features + +- **feat(web-cookie):** self-service login infrastructure for 21 web-cookie providers — three login pathways (Electron BrowserWindow, Playwright dashboard fallback, `POST /api/providers/{id}/login`), token-extraction configs, and a 15-min cookie-validity auto-refresh daemon. Hardened on merge: error bodies sanitized (Hard Rule #12), the spawn-capable login route classified LOCAL_ONLY (Hard Rules #15/#17), and the Electron status listener de-duplicated. ([#3292](https://github.com/diegosouzapw/OmniRoute/pull/3292), closes #3070 — thanks @oyi77 / @diegosouzapw) +- **feat(api):** accept path-scoped API keys on client API routes — keys may now arrive via `/api/v1/vscode//…` path aliases (incl. `raw`/`combos`) or `?token=`/`?apiKey=`/`?api_key=`/`?key=` query params; explicit `Authorization`/`x-api-key` headers still take precedence. Split out of #3073. ([#3300](https://github.com/diegosouzapw/OmniRoute/pull/3300) — thanks @zhiru) + +### 🔧 Bug Fixes + +- **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) +- **fix(opencode-provider):** extract `contextLength` from the live `/v1/models` catalog (live > `modelContextLengths` > static map) so passthrough models outside the legacy 8-model map no longer silently truncate to OpenCode's 128K default. ([#3298](https://github.com/diegosouzapw/OmniRoute/pull/3298) — thanks @herjarsa / @diegosouzapw) +- **fix(dev):** auto-rebuild `better-sqlite3` on a Node ABI mismatch at `npm run dev` startup (nvm 22↔24) — dev-only, no-op on the healthy path, unrelated errors not swallowed. ([#3301](https://github.com/diegosouzapw/OmniRoute/pull/3301) — thanks @zhiru) +- **fix(api):** remove the bundled **Completions.me** provider preset — empirically verified to return Rick Astley lyrics instead of real completions for every model/prompt. ([#3302](https://github.com/diegosouzapw/OmniRoute/pull/3302), discussion #3293 — thanks @diegosouzapw; reported by @mikmaneggahommie) +- **fix(ci):** skip the auto-deploy step when the VPS SSH port is unreachable from the GitHub runner (private LAN / firewall) instead of red-failing every release pipeline; genuine deploy/boot failures still fail honestly. ([#3299](https://github.com/diegosouzapw/OmniRoute/pull/3299) — thanks @diegosouzapw) + --- ## [3.8.12] — 2026-06-06 From 3317b21d09733e55758b220c06771ca8914d7d4f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:48:11 -0300 Subject: [PATCH 09/13] =?UTF-8?q?fix(auth):=20harden=20URL=20token=20extra?= =?UTF-8?q?ction=20=E2=80=94=20drop=20query-string=20fallback,=20gate=20to?= =?UTF-8?q?=20client=20routes=20(security=20follow-up=20to=20#3300)=20(#33?= =?UTF-8?q?09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security follow-up to #3300 — integrated into release/v3.8.13 --- CHANGELOG.md | 6 ++++- src/lib/api/requireManagementAuth.ts | 4 ++- src/server/authz/policies/management.ts | 8 ++++-- src/shared/utils/apiAuth.ts | 25 +++++++++++------- src/sse/services/auth.ts | 34 +++++++++++++++++-------- tests/unit/api-auth.test.ts | 31 +++++++++++++++++++--- tests/unit/auth-extract-api-key.test.ts | 19 ++++++++++++++ tests/unit/sse-auth.test.ts | 22 +++++++++++++--- 8 files changed, 118 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6add4aa23a..11b26db214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,11 @@ _Development cycle in progress — entries are added as work merges into `releas ### ✨ New Features - **feat(web-cookie):** self-service login infrastructure for 21 web-cookie providers — three login pathways (Electron BrowserWindow, Playwright dashboard fallback, `POST /api/providers/{id}/login`), token-extraction configs, and a 15-min cookie-validity auto-refresh daemon. Hardened on merge: error bodies sanitized (Hard Rule #12), the spawn-capable login route classified LOCAL_ONLY (Hard Rules #15/#17), and the Electron status listener de-duplicated. ([#3292](https://github.com/diegosouzapw/OmniRoute/pull/3292), closes #3070 — thanks @oyi77 / @diegosouzapw) -- **feat(api):** accept path-scoped API keys on client API routes — keys may now arrive via `/api/v1/vscode//…` path aliases (incl. `raw`/`combos`) or `?token=`/`?apiKey=`/`?api_key=`/`?key=` query params; explicit `Authorization`/`x-api-key` headers still take precedence. Split out of #3073. ([#3300](https://github.com/diegosouzapw/OmniRoute/pull/3300) — thanks @zhiru) +- **feat(api):** accept path-scoped API keys on client API routes — keys may arrive via `/api/v1/vscode//…` path aliases (incl. `raw`/`combos`); explicit `Authorization`/`x-api-key` headers still take precedence. Split out of #3073. ([#3300](https://github.com/diegosouzapw/OmniRoute/pull/3300) — thanks @zhiru) + +### 🔒 Security + +- **fix(auth):** follow-up hardening of the client-API key extractor (#3300) — removed the generic query-string token fallbacks (`?token=`/`?key=`/`?apiKey=`/`?api_key=`), which leak credentials into access logs / Referer headers, and gated URL-borne tokens to client routes only (management auth is now header-only) so a credential in the URL can never authenticate a management route. The path-scoped `/vscode//…` form the VS Code integration needs is unchanged. (security review follow-up to [#3300](https://github.com/diegosouzapw/OmniRoute/pull/3300) — thanks @zhiru / @diegosouzapw) ### 🔧 Bug Fixes diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts index d8b0beb0f7..c0588ff1ae 100644 --- a/src/lib/api/requireManagementAuth.ts +++ b/src/lib/api/requireManagementAuth.ts @@ -34,7 +34,9 @@ export async function requireManagementAuth(request: Request): Promise>; try { diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 534d3592f6..b5dd75caf9 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -120,7 +120,9 @@ export const managementPolicy: RoutePolicy = { // still hit the same 403 LOCAL_ONLY they did before. if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx) && !isPrivateLanRequest(ctx)) { if (isLocalOnlyBypassableByManageScope(path)) { - const apiKey = extractApiKey(ctx.request as unknown as Request); + // Management auth is header-only — a URL-borne token must never satisfy a + // manage-scope bypass of a LOCAL_ONLY route. See #3300 follow-up. + const apiKey = extractApiKey(ctx.request as unknown as Request, { allowUrl: false }); if (apiKey) { try { if (await isValidApiKey(apiKey)) { @@ -198,7 +200,9 @@ export const managementPolicy: RoutePolicy = { // unhealthy, which is a 503, not a 403 — masking it as an auth failure // would tell callers their credentials are wrong when the real problem // is that the server cannot validate any credential right now. - const apiKey = extractApiKey(ctx.request as unknown as Request); + // Management auth is header-only — a URL-borne token must not authenticate + // a management route. See #3300 follow-up. + const apiKey = extractApiKey(ctx.request as unknown as Request, { allowUrl: false }); if (apiKey) { try { if (await isValidApiKey(apiKey)) { diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index 1803465f40..56d6e00ce0 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -139,7 +139,10 @@ function getCookieValueFromHeader(headers: Headers | undefined, name: string): s return null; } -function getRequestApiKey(request: RequestLike | Request | null | undefined): string | null { +function getRequestApiKey( + request: RequestLike | Request | null | undefined, + opts?: { allowUrl?: boolean } +): string | null { if (!request || typeof request !== "object") return null; const headers = "headers" in request ? request.headers : undefined; @@ -147,10 +150,12 @@ function getRequestApiKey(request: RequestLike | Request | null | undefined): st const pathname = getRequestPathname(request); const syntheticUrl = rawUrl || (pathname ? `http://localhost${pathname}` : null); - return extractApiKey({ - headers, - url: syntheticUrl, - }); + // Management auth never honours a URL-borne credential (defence-in-depth: the + // path-scoped token is a client-API affordance only — a credential in the URL + // must not authenticate a management route). See the #3300 security follow-up. + const allowUrl = opts?.allowUrl !== false; + + return extractApiKey({ headers, url: allowUrl ? syntheticUrl : null }, { allowUrl }); } async function validateBearerApiKey(apiKey: string | null): Promise { @@ -252,8 +257,9 @@ export async function verifyAuth(request: any): Promise { return null; } - const apiKey = getRequestApiKey(request); - if (isManagementApiRequest(request)) { + const isManagement = isManagementApiRequest(request); + const apiKey = getRequestApiKey(request, { allowUrl: !isManagement }); + if (isManagement) { if (await validateBearerApiKeyForManagement(apiKey)) { return null; } @@ -286,8 +292,9 @@ export async function isAuthenticated(request: Request): Promise { return true; } - const apiKey = getRequestApiKey(request); - if (isManagementApiRequest(request)) { + const isManagement = isManagementApiRequest(request); + const apiKey = getRequestApiKey(request, { allowUrl: !isManagement }); + if (isManagement) { return validateBearerApiKeyForManagement(apiKey); } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 2145f36803..620e35a412 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -849,7 +849,11 @@ async function getProviderSearchPool(provider: string): Promise { const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : ""; const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; if (!nodePrefix || !nodeId) continue; - if (nodePrefix === provider || nodePrefix === canonicalProvider || nodePrefix === canonicalAlias) { + if ( + nodePrefix === provider || + nodePrefix === canonicalProvider || + nodePrefix === canonicalAlias + ) { searchPool.add(nodeId); } } @@ -1602,8 +1606,8 @@ export async function getProviderCredentialsWithQuotaPreflight( // false for both "not set" and "explicit false" — we need an explicit check // here to distinguish them. const legacyForceDisable = - (credentials as { providerSpecificData?: Record }) - .providerSpecificData?.quotaPreflightEnabled === false; + (credentials as { providerSpecificData?: Record }).providerSpecificData + ?.quotaPreflightEnabled === false; if (legacyForceDisable) return credentials; const hasConnectionOverrides = Object.keys(perConnectionWindowOverrides).length > 0; @@ -2077,10 +2081,12 @@ function readNonEmptyUrlToken(request: AuthRequestLike): string | null { } } - for (const key of ["apiKey", "api_key", "key", "token"]) { - const token = url.searchParams.get(key)?.trim(); - if (token) return token; - } + // NOTE: query-string token fallbacks (`?token=`/`?key=`/`?apiKey=`/`?api_key=`) + // were intentionally REMOVED. They are a broad credential-in-URL surface that + // leaks into access logs, Referer headers and proxy logs, and — because this + // extractor also feeds management auth — would let `?token=` + // authenticate management routes. The VS Code integration only needs the + // path-scoped `/vscode//…` form above. (security review, #3300 follow-up) } catch { return null; } @@ -2091,12 +2097,12 @@ function readNonEmptyUrlToken(request: AuthRequestLike): string | null { /** * Extract API key from request auth inputs. * - * Honors both explicit headers and URL-based fallbacks: + * Honors explicit auth headers and (for client-facing routes only) a + * path-scoped URL token: * - `Authorization: Bearer ` (OpenAI / OmniRoute / Codex CLI / Bearer clients) * - `x-api-key: ` (Anthropic Messages API contract — Claude Code, * `@anthropic-ai/sdk`, any SDK that sets `anthropic-version`) - * - `/vscode//...` (path-scoped tokenized aliases) - * - `?token=` / `?apiKey=` / `?api_key=` / `?key=` + * - `/vscode//...` (path-scoped tokenized aliases — only when `allowUrl`) * * When multiple inputs are present, explicit auth headers win. * @@ -2106,8 +2112,13 @@ function readNonEmptyUrlToken(request: AuthRequestLike): string | null { * non-Anthropic SDKs that happen to set `x-api-key` (or local-mode tools * with placeholder keys) would be treated as authenticated attempts and * rejected by per-route gates that compare against OmniRoute keys. + * + * `opts.allowUrl` (default `true`) gates the path-scoped URL token. Management + * auth MUST pass `allowUrl: false` — a credential in the URL must never + * authenticate a management route (it leaks into logs/Referer and would widen + * the management surface). See the #3300 security follow-up. */ -export function extractApiKey(request: AuthRequestLike) { +export function extractApiKey(request: AuthRequestLike, opts?: { allowUrl?: boolean }) { const authHeader = readHeaderValue(request?.headers, "Authorization") || readHeaderValue(request?.headers, "authorization"); @@ -2135,6 +2146,7 @@ export function extractApiKey(request: AuthRequestLike) { } } + if (opts?.allowUrl === false) return null; return readNonEmptyUrlToken(request); } diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index b7c9fa5c9c..07b0d4c520 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -104,7 +104,8 @@ test("verifyAuth falls back to bearer API key validation after a bad JWT", async assert.equal(result, null); }); -test("verifyAuth accepts API keys supplied via query string on client-facing routes", async () => { +test("verifyAuth no longer accepts API keys supplied via query string (#3300 follow-up)", async () => { + // Query-string token fallbacks were removed (credential-in-URL leaks into logs). const key = await apiKeysDb.createApiKey("query-auth", "machine1234567890"); const result = await apiAuth.verifyAuth({ @@ -117,18 +118,42 @@ test("verifyAuth accepts API keys supplied via query string on client-facing rou url: `https://example.com/api/v1/models?token=${encodeURIComponent(key.key)}`, }); - assert.equal(result, null); + // No usable credential → authentication fails (was incorrectly accepted before). + assert.notEqual(result, null); }); test("isAuthenticated accepts API keys embedded in vscode path aliases", async () => { const key = await apiKeysDb.createApiKey("path-auth", "machine1234567890"); - const request = new Request(`https://example.com/api/v1/vscode/${encodeURIComponent(key.key)}/models`); + const request = new Request( + `https://example.com/api/v1/vscode/${encodeURIComponent(key.key)}/models` + ); const result = await apiAuth.isAuthenticated(request); assert.equal(result, true); }); +test("verifyAuth never honours a URL-borne token on MANAGEMENT routes (#3300 follow-up)", async () => { + // The historical escalation: a credential in the query string on a management + // route (/api/* but not /api/v1/*). It must not be extracted at all, so the + // failure is "Authentication required" (no credential) — NOT "Invalid + // management token" (which the pre-fix code returned, proving the URL token + // had been picked up and tried against management validation). + const key = await apiKeysDb.createApiKey("mgmt-url", "machine1234567890"); + + const result = await apiAuth.verifyAuth({ + cookies: { + get() { + return undefined; + }, + }, + headers: new Headers(), + url: `https://example.com/api/providers?token=${encodeURIComponent(key.key)}`, + }); + + assert.equal(result, "Authentication required"); +}); + test("verifyAuth rejects bearer API keys on management routes", async () => { const key = await apiKeysDb.createApiKey("integration", "machine1234567890"); const result = await apiAuth.verifyAuth({ diff --git a/tests/unit/auth-extract-api-key.test.ts b/tests/unit/auth-extract-api-key.test.ts index 557cecc589..255abd9d41 100644 --- a/tests/unit/auth-extract-api-key.test.ts +++ b/tests/unit/auth-extract-api-key.test.ts @@ -108,3 +108,22 @@ test("extractApiKey extracts a path-scoped token from /api/v1/vscode/combos/ { + // Query-string fallbacks (?token / ?key / ?apiKey / ?api_key) were removed — + // a credential in the query string leaks into access logs / Referer headers. + for (const q of ["token", "key", "apiKey", "api_key"]) { + const req = new Request(`https://omniroute.test/api/v1/models?${q}=sk-test-query-token`); + assert.equal(extractApiKey(req), null, `?${q}= must not be extracted`); + } +}); + +test("extractApiKey skips the path-scoped token when allowUrl is false (management auth)", () => { + const req = new Request("https://omniroute.test/api/v1/vscode/sk-test-path-token/models"); + assert.equal(extractApiKey(req, { allowUrl: false }), null); + // Headers still work regardless of allowUrl. + const withHeader = new Request("https://omniroute.test/api/v1/vscode/sk-test-path-token/models", { + headers: { Authorization: "Bearer sk-header-wins" }, + }); + assert.equal(extractApiKey(withHeader, { allowUrl: false }), "sk-header-wins"); +}); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index a3904797bd..eef3b244f7 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -89,13 +89,26 @@ test("extractApiKey parses bearer headers and isValidApiKey validates persisted ), null ); + // Security follow-up (#3300): query-string token fallbacks were removed — a + // credential in `?token=` must NOT be extracted (it leaks into logs/Referer). assert.equal( auth.extractApiKey(new Request(`http://localhost/v1/chat/completions?token=${created.key}`)), + null + ); + // The path-scoped `/vscode//…` form (VS Code integration) still works. + assert.equal( + auth.extractApiKey( + new Request(`http://localhost/api/v1/vscode/${created.key}/chat/completions`) + ), created.key ); + // …but never when the caller opts out of URL extraction (management auth path). assert.equal( - auth.extractApiKey(new Request(`http://localhost/api/v1/vscode/${created.key}/chat/completions`)), - created.key + auth.extractApiKey( + new Request(`http://localhost/api/v1/vscode/${created.key}/chat/completions`), + { allowUrl: false } + ), + null ); assert.equal(await auth.isValidApiKey(created.key), true); assert.equal(await auth.isValidApiKey("sk-missing"), false); @@ -389,7 +402,9 @@ test("getProviderCredentialsWithQuotaPreflight: explicit quotaPreflightEnabled:f }); // Give the connection per-window overrides (simulates a user-configured // threshold) — this is the field that previously caused the gate to keep going. - await (await import("../../src/lib/db/providers.ts")).updateProviderConnection(conn.id, { + await ( + await import("../../src/lib/db/providers.ts") + ).updateProviderConnection(conn.id, { quotaWindowThresholds: { primary: 50 }, }); @@ -688,7 +703,6 @@ test("getProviderCredentials retains rate-limited accounts when allowRateLimited assert.equal(bypassed.connectionId, connection.id); }); - test("getProviderCredentials retains terminal accounts for combo live tests", async () => { const connection = await seedConnection("openai", { name: "suppressed-terminal", From 5121959bb88e39b022b52d2d049903e1e8067f80 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 6 Jun 2026 12:55:35 -0300 Subject: [PATCH 10/13] =?UTF-8?q?docs:=20rename=20resolve-issues=20?= =?UTF-8?q?=E2=86=92=20review-issues=20skill=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- docs/architecture/REPOSITORY_MAP.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b26db214..4cfff8bfc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1873,7 +1873,7 @@ Thank you to all **55+ community contributors** who made v3.8.0 possible! 🎉 ### 🧹 Chores -- **chore(workflow):** mandate implementation plan generation in `/resolve-issues` workflow before coding +- **chore(workflow):** mandate implementation plan generation in `/review-issues` workflow before coding - **chore(release):** expand contributor credits to 155 PRs across full project history ### 🏆 Community Contributors Acknowledgment diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index c9c2bbb9b2..bddfefd9d5 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -491,7 +491,7 @@ Shipped configuration templates and sample files (referenced by setup wizard). | `commands/deploy-vps-{local,akamai,both}-cc.md` | Deploy to VPS | | `commands/capture-release-evidences-cc.md` | Browser-record new features as WebP | | `commands/review-{prs,discussions}-cc.md` | Triage GitHub PRs/discussions | -| `commands/{issue-triage,resolve-issues,implement-features}-cc.md` | Issue workflows | +| `commands/{review-issues,implement-features}-cc.md` | Issue workflows | | `settings.local.json` | Per-project Claude Code settings | --- From 0fcfb159f2b23617b8b369210bf2e2d02b4cf7d8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:08:38 -0300 Subject: [PATCH 11/13] fix(dashboard): keep no-auth providers visible under 'Show configured only' (#3290) (#3312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) never create a DB connection row so stats.total stays 0, which the configured-only filter treated as 'unconfigured' and hid them — even though they are always usable and appear unconditionally in /v1/models. filterConfiguredProviderEntries now treats displayAuthType === 'no-auth' as configured. Co-authored-by: uniQta --- CHANGELOG.md | 1 + .../dashboard/providers/providerPageUtils.ts | 7 +++- tests/unit/providers-page-utils.test.ts | 34 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cfff8bfc4..1f7f9d7113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes +- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) - **fix(opencode-provider):** extract `contextLength` from the live `/v1/models` catalog (live > `modelContextLengths` > static map) so passthrough models outside the legacy 8-model map no longer silently truncate to OpenCode's 128K default. ([#3298](https://github.com/diegosouzapw/OmniRoute/pull/3298) — thanks @herjarsa / @diegosouzapw) - **fix(dev):** auto-rebuild `better-sqlite3` on a Node ABI mismatch at `npm run dev` startup (nvm 22↔24) — dev-only, no-op on the healthy path, unrelated errors not swallowed. ([#3301](https://github.com/diegosouzapw/OmniRoute/pull/3301) — thanks @zhiru) diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 845a82a64e..8da41a3e1b 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -107,7 +107,12 @@ export function filterConfiguredProviderEntries( let filtered = entries; if (showConfiguredOnly) { - filtered = filtered.filter((entry) => Number(entry.stats?.total || 0) > 0); + // no-auth providers never create a DB connection row (stats.total === 0) but + // are always usable and appear unconditionally in the /v1/models catalog, so + // they must not be hidden by the configured-only filter (#3290). + filtered = filtered.filter( + (entry) => entry.displayAuthType === "no-auth" || Number(entry.stats?.total || 0) > 0 + ); } if (showFreeOnly) { diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index fc508582fb..eedcd16049 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -80,6 +80,40 @@ test("configured-only filter keeps only providers with saved connections", () => assert.equal(providerPageUtils.filterConfiguredProviderEntries(entries, false).length, 3); }); +test("configured-only filter keeps no-auth providers even without a saved connection (#3290)", () => { + const entries = [ + { + providerId: "claude", + provider: { id: "claude" }, + stats: { total: 0 }, + displayAuthType: "oauth", + toggleAuthType: "oauth", + }, + { + providerId: "opencode", + provider: { id: "opencode" }, + stats: { total: 0 }, + displayAuthType: "no-auth", + toggleAuthType: "no-auth", + }, + { + providerId: "duckduckgo-web", + provider: { id: "duckduckgo-web" }, + stats: { total: 0 }, + displayAuthType: "no-auth", + toggleAuthType: "no-auth", + }, + ]; + + // no-auth providers never create a DB connection row (total === 0) but are + // always usable and appear in /v1/models — they must survive the filter. + const visible = providerPageUtils.filterConfiguredProviderEntries(entries, true); + assert.deepEqual( + visible.map((entry) => entry.providerId), + ["duckduckgo-web", "opencode"] + ); +}); + test("search filter matches provider name and id case-insensitively", () => { const entries = [ { From 7a3eeaca485224ce015ec09ae3c735c540b53585 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:13:37 -0300 Subject: [PATCH 12/13] fix(cli): resolve update paths relative to script + recursive backup (#3295) (#3313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omniroute update always failed on a global install: - getCurrentVersion() read package.json from process.cwd(), which on a global npm/brew install is the user's working dir, not the package root → null → 'Could not determine current version'. - createBackup() resolved bin/ from cwd too, and passed the 'cli' directory to copyFileSync → EISDIR, swallowed by the catch → 'Failed to create backup'. Both now resolve package.json/bin relative to the script via import.meta.url, and the backup uses cpSync({recursive:true}) so the cli/ directory is copied. Co-authored-by: uniQta --- CHANGELOG.md | 1 + bin/cli/commands/update.mjs | 22 +++++-- .../unit/cli-update-global-paths-3295.test.ts | 65 +++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 tests/unit/cli-update-global-paths-3295.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7f9d7113..3dc8b84d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes - **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) +- **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → *"Could not determine current version"*), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → *"Failed to create backup. Aborting"*. (#3295 — thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) - **fix(opencode-provider):** extract `contextLength` from the live `/v1/models` catalog (live > `modelContextLengths` > static map) so passthrough models outside the legacy 8-model map no longer silently truncate to OpenCode's 128K default. ([#3298](https://github.com/diegosouzapw/OmniRoute/pull/3298) — thanks @herjarsa / @diegosouzapw) - **fix(dev):** auto-rebuild `better-sqlite3` on a Node ABI mismatch at `npm run dev` startup (nvm 22↔24) — dev-only, no-op on the healthy path, unrelated errors not swallowed. ([#3301](https://github.com/diegosouzapw/OmniRoute/pull/3301) — thanks @zhiru) diff --git a/bin/cli/commands/update.mjs b/bin/cli/commands/update.mjs index 5fe56e8c49..e3c95cfd81 100644 --- a/bin/cli/commands/update.mjs +++ b/bin/cli/commands/update.mjs @@ -1,16 +1,24 @@ import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { homedir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { t } from "../i18n.mjs"; const execFileAsync = promisify(execFile); -async function getCurrentVersion() { +// This file lives at /bin/cli/commands/update.mjs — resolve package +// paths relative to the script, NOT process.cwd(). On a global npm/brew install +// the user's cwd is not the package root, so cwd-relative lookups break (#3295). +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(SCRIPT_DIR, "..", "..", ".."); +const BIN_DIR = path.join(PKG_ROOT, "bin"); + +export async function getCurrentVersion() { try { const { readFileSync } = await import("node:fs"); - const pkg = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf-8")); + const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8")); return pkg.version; } catch { return null; @@ -38,12 +46,12 @@ function compareVersions(a, b) { return 0; } -async function createBackup() { - const binPath = path.join(process.cwd(), "bin"); +export async function createBackup() { + const binPath = BIN_DIR; const backupDir = path.join(homedir(), ".omniroute", "backups", `omniroute-${Date.now()}`); try { - const { mkdirSync, copyFileSync, existsSync } = await import("node:fs"); + const { mkdirSync, cpSync, existsSync } = await import("node:fs"); if (!existsSync(binPath)) return null; mkdirSync(backupDir, { recursive: true }); @@ -51,7 +59,9 @@ async function createBackup() { for (const f of files) { const src = path.join(binPath, f); if (existsSync(src)) { - copyFileSync(src, path.join(backupDir, f)); + // cpSync handles both files and directories; the old copyFileSync threw + // EISDIR on the "cli" directory, which was swallowed by the catch (#3295). + cpSync(src, path.join(backupDir, f), { recursive: true }); } } return backupDir; diff --git a/tests/unit/cli-update-global-paths-3295.test.ts b/tests/unit/cli-update-global-paths-3295.test.ts new file mode 100644 index 0000000000..e23a2ef3dd --- /dev/null +++ b/tests/unit/cli-update-global-paths-3295.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, mkdtempSync, mkdirSync, existsSync, statSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const update = await import("../../bin/cli/commands/update.mjs"); + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const REAL_VERSION = JSON.parse( + readFileSync(path.join(REPO_ROOT, "package.json"), "utf-8") +).version; + +// #3295 issue 1: getCurrentVersion() must resolve package.json relative to the +// script, not process.cwd(). When OmniRoute is installed globally, the user's +// cwd is not the package root, so a cwd-relative lookup returns null → +// "Could not determine current version". +test("getCurrentVersion resolves the real version from a foreign cwd (#3295)", async () => { + const originalCwd = process.cwd(); + const foreignCwd = mkdtempSync(path.join(tmpdir(), "omniroute-cwd-")); + try { + process.chdir(foreignCwd); // no package.json here → cwd-relative lookup would fail + const version = await update.getCurrentVersion(); + assert.equal(version, REAL_VERSION); + } finally { + process.chdir(originalCwd); + rmSync(foreignCwd, { recursive: true, force: true }); + } +}); + +// #3295 issue 2: createBackup() must (a) resolve bin/ relative to the script, +// and (b) copy the "cli" directory recursively. The old copyFileSync(dir) threw +// EISDIR which the outer catch swallowed → "Failed to create backup. Aborting". +test("createBackup resolves bin/ from a foreign cwd and copies cli/ recursively (#3295)", async () => { + const originalCwd = process.cwd(); + const originalHome = process.env.HOME; + const foreignCwd = mkdtempSync(path.join(tmpdir(), "omniroute-cwd-")); + const fakeHome = mkdtempSync(path.join(tmpdir(), "omniroute-home-")); + try { + process.chdir(foreignCwd); // no bin/ here → cwd-relative binPath would be missing + process.env.HOME = fakeHome; // redirect ~/.omniroute/backups + mkdirSync(fakeHome, { recursive: true }); + + const backupDir = await update.createBackup(); + + assert.ok(backupDir, "createBackup must return a path (not null)"); + // omniroute.mjs is a real file in bin/ and must be copied + assert.ok(existsSync(path.join(backupDir, "omniroute.mjs")), "omniroute.mjs copied"); + // "cli" is a directory — it must be copied recursively, not throw EISDIR + const cliBackup = path.join(backupDir, "cli"); + assert.ok(existsSync(cliBackup), "cli/ directory copied"); + assert.ok(statSync(cliBackup).isDirectory(), "cli/ backup is a directory"); + assert.ok( + existsSync(path.join(cliBackup, "commands")), + "cli/ contents copied recursively" + ); + } finally { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(foreignCwd, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + } +}); From 4baccfbd847a5d089f58630c7181a531903ba80b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:16:23 -0300 Subject: [PATCH 13/13] fix(theoldllm): read upstream body once to avoid [502] body-already-read (#3296) (#3314) On the cached-token path the executor never enters the refresh branch, so the same upstream Response was read with .text() twice (token-rejection check + final body). A Response body is single-use, so the second read threw 'Body is unusable: Body has already been read', caught and surfaced as [502]. Read the body once into finalBody and only re-read after a token-rejection refetch. Co-authored-by: onizukashonan14-png --- CHANGELOG.md | 1 + open-sse/executors/theoldllm.ts | 10 ++-- .../theoldllm-body-double-read-3296.test.ts | 53 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/unit/theoldllm-body-double-read-3296.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc8b84d4c..f76a2c6dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes +- **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path — the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 — thanks @onizukashonan14-png) - **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) - **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → *"Could not determine current version"*), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → *"Failed to create backup. Aborting"*. (#3295 — thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 94ba8d0f7f..a9dfbba9ef 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -407,9 +407,12 @@ export class TheOldLlmExecutor extends BaseExecutor { upstream = await directFetch(token, reqBody, signal); } - const upstreamBody = await upstream.text(); + // Read the body once — a Response body is single-use, so re-reading the + // same Response throws "Body has already been read" (#3296). Only re-read + // when a token rejection forces a fresh fetch below. + let finalBody = await upstream.text(); - if (isTokenRejected(upstream.status, upstreamBody)) { + if (isTokenRejected(upstream.status, finalBody)) { log?.warn?.("THEOLDLLM", `Token rejected (${upstream.status}), refreshing…`); invalidateToken(); try { @@ -419,10 +422,9 @@ export class TheOldLlmExecutor extends BaseExecutor { log?.warn?.("THEOLDLLM", "Token refresh failed, retrying with existing token"); } upstream = await directFetch(token, reqBody, signal); + finalBody = await upstream.text(); } - const finalBody = await upstream.text(); - if (upstream.status === 200 && finalBody) { const payload = stream ? finalBody diff --git a/tests/unit/theoldllm-body-double-read-3296.test.ts b/tests/unit/theoldllm-body-double-read-3296.test.ts new file mode 100644 index 0000000000..c57aec8fdc --- /dev/null +++ b/tests/unit/theoldllm-body-double-read-3296.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { TheOldLlmExecutor, tokenCache } from "../../open-sse/executors/theoldllm.ts"; + +const SSE_BODY = + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n' + + 'data: {"choices":[{"delta":{"content":" world"}}]}\n' + + "data: [DONE]\n"; + +// #3296: with a valid cached token the executor takes the direct-fetch path and +// never enters the token-refresh branch. It read the SAME upstream Response with +// .text() twice (once for the token-rejection check, once for the final body), +// which throws "Body is unusable: Body has already been read" → caught → [502]. +test("theoldllm does not double-read the upstream body on the cached-token path (#3296)", async () => { + const originalFetch = globalThis.fetch; + // Pre-populate the cached token so execute() uses the direct fetch (no Playwright). + tokenCache.value = "cached-token"; + tokenCache.expiresAt = Date.now() + 60_000; + + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(SSE_BODY, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }) as typeof fetch; + + try { + const executor = new TheOldLlmExecutor(); + const result = await executor.execute({ + model: "gpt-5.4", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {} as never, + signal: null, + }); + + // Before the fix this was 502 with "Body has already been read". + assert.equal(result.response.status, 200); + assert.equal(fetchCalls, 1, "should fetch upstream exactly once on the cached-token path"); + + const json = (await result.response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + assert.equal(json.choices?.[0]?.message?.content, "Hello world"); + } finally { + globalThis.fetch = originalFetch; + tokenCache.value = ""; + tokenCache.expiresAt = 0; + } +});