diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..9a39e8400e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Shell scripts must always be checked out with LF line endings. +# +# On Windows, core.autocrlf=true converts text files to CRLF in the working +# tree. Scripts that are kernel-exec'd (Docker ENTRYPOINT, bin/*.sh on Linux +# hosts) then fail with `exec ...: no such file or directory` because the +# shebang becomes "#!/bin/sh\r". eol=lf overrides autocrlf for these files. +*.sh text eol=lf + +# This file must stay LF too: git parses it as-is, and a trailing CR would +# corrupt every pattern (e.g. "*.sh\r" matches nothing). +.gitattributes text eol=lf diff --git a/Dockerfile b/Dockerfile index 8eca2c3bd2..848d25c109 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,12 @@ RUN set -eux; \ # ── Builder ──────────────────────────────────────────────────────────────── FROM base AS builder +# No telemetry, anywhere. Disable Next.js's anonymous build-time telemetry +# (it otherwise pings Vercel during `next build`). Set on the builder stage so +# every image build is silent; the runtime never builds, so this covers the +# only phase Next telemetry can fire. +ENV NEXT_TELEMETRY_DISABLED=1 + # Build tools for native module compilation # apt-get update needed here because base's rm -rf clears the shared cache RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ @@ -166,9 +172,20 @@ ENV OMNIROUTE_MITM_STUB=1 # child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). # Build-only; the runtime heap is set separately on the runner stage # (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`. -ARG OMNIROUTE_BUILD_MEMORY_MB=4096 +# Default raised 4096 → 6144 (#10060): the Next 16 production pass on a codebase +# this size intermittently OOMs a build worker at 4 GB on memory-tight hosts. +ARG OMNIROUTE_BUILD_MEMORY_MB=6144 ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}" +# Cap Next.js build worker pools. Next 16 defaults to `os.cpus().length - 1` +# workers for page-data collection (31 on a 32-core builder); on memory-tight +# hosts 31 workers + webpack's multi-GB heap blow past RAM and a worker dies +# with SIGSEGV at teardown ("worker exited with code: null and signal: SIGSEGV"), +# silently leaving no standalone bundle. Next derives the default worker count +# from CIRCLE_NODE_TOTAL (workers = N-1), so N=8 → 7 workers: fast enough while +# fitting comfortably in RAM on any host. (#10060) +ENV CIRCLE_NODE_TOTAL=8 + COPY . ./ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \ mkdir -p /app/data \ diff --git a/changelog.d/fixes/10060-build-sqlite-native-addon-guard.md b/changelog.d/fixes/10060-build-sqlite-native-addon-guard.md new file mode 100644 index 0000000000..b803a14cf8 --- /dev/null +++ b/changelog.d/fixes/10060-build-sqlite-native-addon-guard.md @@ -0,0 +1 @@ +- **fix(build):** stop the native `better-sqlite3` addon from loading during the Next.js production build (#10060). Its `Statement` destructor aborts with `SIGABRT` when a build worker thread exits (assertion in `node::RemoveEnvironmentCleanupHook`, `env == nullptr`), which can leave the build with no standalone bundle. Every DB entry point now keys off a reliable `OMNIROUTE_BUILDING=1` signal (set by `build-next-isolated.mjs` and inherited by every spawned build worker, because Next.js workers sometimes drop `NEXT_PHASE`): `getDbInstance()` returns a no-op SQLite stub during build, `driverFactory` skips the native driver and falls through to `node:sqlite`, and the `codegraph`/`kiro-import` lazy loaders fail closed. A build-time `better-sqlite3` alias to a stub (`next.config.mjs`, turbopack) backs this up without changing runtime behaviour (the real package is still `require()`d natively via `serverExternalPackages`). Also raises the default build heap 4096→6144 MB and caps Next build worker pools (`CIRCLE_NODE_TOTAL=8`) to avoid the many-core page-data-collection SIGSEGV, and adds `.gitattributes` (`*.sh text eol=lf`) so kernel-exec'd shell scripts never ship with CRLF shebangs. Deliberately does NOT downgrade the Node base image: per the maintainer's review on #10060, `release/v3.8.50` moved to `node:26-trixie-slim` through several considered commits, so the `OMNIROUTE_BUILDING` guard is re-derived against the current base rather than reverting the FROM line; the npm pin and binary-hide dance from the original PR are dropped because our build already rebuilds `better-sqlite3` deterministically via `node-gyp` and floats `npm@latest` for the CVE overlay. diff --git a/changelog.d/fixes/secret-leak-error-surface-hardening.md b/changelog.d/fixes/secret-leak-error-surface-hardening.md new file mode 100644 index 0000000000..ed2e4474a0 --- /dev/null +++ b/changelog.d/fixes/secret-leak-error-surface-hardening.md @@ -0,0 +1 @@ +- **fix(security):** harden three secret-leak paths surfaced by an audit of the error/log surface. (1) `upstreamErrorPassthrough` relays an upstream provider's 4xx body verbatim to Claude-Code-format clients (the capability-recovery contract needs the exact wording); it now refuses passthrough when the body actually carries a credential pattern (`Bearer`/`Basic` token, `sk-…`, or an `api_key`/`token`/`authorization`/`cookie`/`secret` assignment) so a provider that echoes the offending request can't relay a key to the client, falling back to the sanitized error path. The credential regex is bounded (ReDoS-safe, verified linear at 60k chars). (2) The OCR and moderations handlers no longer forward an upstream error body byte-for-byte; they run it through the (now exported) structure-preserving `redactSensitiveErrorText` first. (3) `protectPayloadForLog`'s sensitive-key set gains `cookie`/`storageState`/`runtimeKey`/`capability` so web-impersonation credentials (Meta AI `ecto_1_sess`, chatgpt-web `storageState`) that land in a request/response body field are redacted before the call-log artifact is written to disk. No behavior change for secret-free error bodies; the Claude Code verbatim-wording contract is preserved. diff --git a/next.config.mjs b/next.config.mjs index df3f6e32c4..a142370a51 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -138,6 +138,10 @@ const nextConfig = { // the stub to every npm/Electron/VPS artifact and broke Agent Bridge // start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs. ...mitmManagerAliasFor(process.env), + // Build-time stub so the bundler never traces the native better-sqlite3 + // addon into a build worker (SIGABRT at worker teardown). Runtime still + // uses the real package via serverExternalPackages. (#10060) + "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js", ...minimalBuildAliases, }, // src/lib/agentSkills/generator.ts builds its fs base path from a runtime diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index 5f038e4c21..522fd37fce 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -1,16 +1,41 @@ import { getAntigravityContentHeaders } from "../services/antigravityHeaders.ts"; import type { AntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; -export const GITHUB_COPILOT_API_VERSION = "2026-06-01"; -export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.126.0"; -export const GITHUB_COPILOT_CHAT_PLUGIN_VERSION = "copilot-chat/0.54.0"; -export const GITHUB_COPILOT_CHAT_USER_AGENT = "GitHubCopilotChat/0.54.0"; -export const GITHUB_COPILOT_REFRESH_PLUGIN_VERSION = "copilot/1.388.0"; +// GitHub Copilot request identity. Ported to match the GitHub Copilot CLI +// (`copilot` npm package) wire identity that Hermes captured live, NOT the +// VS Code Copilot Chat extension. The CLI's `copilot-developer-cli` integration +// id is the catalog-unlock lever: it exposes the full entitled model set +// (gemini-3.x, gpt-5.4-nano, the full opus reasoning range) where `vscode-chat` +// returns a narrower list. Version strings track the live-captured CLI 1.0.81-6. +export const GITHUB_COPILOT_API_VERSION = "2026-08-01"; +export const GITHUB_COPILOT_CLI_VERSION = "1.0.81-6"; +export const GITHUB_COPILOT_EDITOR_VERSION = `copilot/${GITHUB_COPILOT_CLI_VERSION}`; +export const GITHUB_COPILOT_CHAT_PLUGIN_VERSION = `copilot-chat/${GITHUB_COPILOT_CLI_VERSION}`; +export const GITHUB_COPILOT_CHAT_USER_AGENT = `GitHubCopilotChat/${GITHUB_COPILOT_CLI_VERSION}`; +export const GITHUB_COPILOT_CLI_USER_AGENT = `copilot/${GITHUB_COPILOT_CLI_VERSION}`; +export const GITHUB_COPILOT_REFRESH_PLUGIN_VERSION = `copilot/${GITHUB_COPILOT_CLI_VERSION}`; export const GITHUB_COPILOT_REFRESH_USER_AGENT = "GithubCopilot/1.0"; -export const GITHUB_COPILOT_INTEGRATION_ID = "vscode-chat"; -export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-panel"; +export const GITHUB_COPILOT_INTEGRATION_ID = "copilot-developer-cli"; +export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-agent"; +export const GITHUB_COPILOT_INTERACTION_TYPE = "conversation-user"; +export const GITHUB_COPILOT_HARNESS_ID = "copilot-sdk"; export const GITHUB_COPILOT_DEFAULT_INITIATOR = "user"; -export const GITHUB_COPILOT_USER_AGENT_LIBRARY = "electron-fetch"; + +// Stable per-install device fingerprint (the CLI's X-Client-Machine-Id). The +// real @github/copilot CLI sends ONE stable UUID on every inference + /models +// call (verified identical across all captured requests) — a per-call random id +// would itself be an anti-fingerprint tell. We mint one per process and cache +// it (env-overridable via GITHUB_COPILOT_MACHINE_ID), which keeps it stable for +// the lifetime of a running OmniRoute instance, matching "one CLI install". +let _copilotMachineId: string | null = null; +export function getGitHubCopilotMachineId(): string { + const override = (process?.env?.GITHUB_COPILOT_MACHINE_ID || "").trim(); + if (override) return override; + if (_copilotMachineId) return _copilotMachineId; + _copilotMachineId = + crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return _copilotMachineId; +} export const QWEN_CLI_VERSION = "0.19.3"; export const QWEN_STAINLESS_LANG = "js"; @@ -26,20 +51,36 @@ export const CURSOR_REGISTRY_VERSION = "3.9"; export function getGitHubCopilotChatHeaders( accept = "application/json", - initiator = GITHUB_COPILOT_DEFAULT_INITIATOR + initiator = GITHUB_COPILOT_DEFAULT_INITIATOR, + options: { vision?: boolean; intent?: string } = {} ): Record { - return { + // Matches the live @github/copilot CLI 1.0.81-6 inference request 1:1 (MITM- + // captured). NOTE the CLI does NOT send `editor-plugin-version` nor + // `x-vscode-user-agent-library-version` on the inference path — those belong + // to the VS Code Copilot Chat extension, not the CLI. Sending an incomplete + // OR an over-complete header fingerprint is itself a flagging signal, so we + // send exactly the CLI's set. The `copilot-integration-id` (copilot-developer-cli) + // is the catalog-unlock lever; the stable X-Client-Machine-Id is the CLI's + // per-install device fingerprint. + const headers: Record = { "copilot-integration-id": GITHUB_COPILOT_INTEGRATION_ID, "editor-version": GITHUB_COPILOT_EDITOR_VERSION, - "editor-plugin-version": GITHUB_COPILOT_CHAT_PLUGIN_VERSION, - "user-agent": GITHUB_COPILOT_CHAT_USER_AGENT, - "openai-intent": GITHUB_COPILOT_OPENAI_INTENT, + "user-agent": GITHUB_COPILOT_CLI_USER_AGENT, + "openai-intent": options.intent || GITHUB_COPILOT_OPENAI_INTENT, + "x-interaction-type": GITHUB_COPILOT_INTERACTION_TYPE, + "copilot-harness-id": GITHUB_COPILOT_HARNESS_ID, "x-github-api-version": GITHUB_COPILOT_API_VERSION, - "x-vscode-user-agent-library-version": GITHUB_COPILOT_USER_AGENT_LIBRARY, + "x-client-machine-id": getGitHubCopilotMachineId(), "X-Initiator": initiator, Accept: accept, "Content-Type": "application/json", }; + // Copilot's /v1/messages proxy returns an empty content block for image + // requests unless this is set. Add it only when the turn carries an image. + if (options.vision) { + headers["copilot-vision-request"] = "true"; + } + return headers; } export function getRuntimePlatform(): string { diff --git a/open-sse/config/providers/registry/ghe-copilot/index.ts b/open-sse/config/providers/registry/ghe-copilot/index.ts index f494830414..1a68fd9142 100644 --- a/open-sse/config/providers/registry/ghe-copilot/index.ts +++ b/open-sse/config/providers/registry/ghe-copilot/index.ts @@ -16,6 +16,11 @@ export const gheCopilotProvider: RegistryEntry = { forceStream: true, baseUrl: "https://api.githubcopilot.com/chat/completions", responsesBaseUrl: "https://api.githubcopilot.com/responses", + // Anthropic-native /v1/messages shim for Claude models. Static default only; + // the GHE executor's getMessagesBase() derives the real per-connection host + // from copilotApiUrl/gheUrl at request time. Its presence enables Claude -> + // /v1/messages routing in the buildUrl override. + messagesUrl: "https://api.githubcopilot.com/v1/messages", authType: "oauth", authHeader: "bearer", // GHE Copilot requires a custom gheUrl (set per-connection via providerSpecificData). diff --git a/open-sse/config/providers/registry/github/index.ts b/open-sse/config/providers/registry/github/index.ts index d99fd1520c..d6189fd791 100644 --- a/open-sse/config/providers/registry/github/index.ts +++ b/open-sse/config/providers/registry/github/index.ts @@ -74,6 +74,13 @@ export const githubProvider: RegistryEntry = { contextLength: 1000000, maxOutputTokens: 64000, }, + { + id: "claude-opus-4.6", + name: "Claude Opus 4.6", + targetFormat: "claude", + contextLength: 1000000, + maxOutputTokens: 64000, + }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", @@ -122,6 +129,18 @@ export const githubProvider: RegistryEntry = { contextLength: 1000000, maxOutputTokens: 64000, }, + { + id: "gemini-3.6-flash", + name: "Gemini 3.6 Flash", + contextLength: 1000000, + maxOutputTokens: 64000, + }, + { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + contextLength: 1000000, + maxOutputTokens: 64000, + }, { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", @@ -156,6 +175,13 @@ export const githubProvider: RegistryEntry = { contextLength: 400000, maxOutputTokens: 128000, }, + { + id: "gpt-5.4-nano", + name: "GPT-5.4 nano", + targetFormat: "openai-responses", + contextLength: 400000, + maxOutputTokens: 128000, + }, { id: "gpt-5.3-codex", name: "GPT-5.3-Codex", @@ -196,6 +222,38 @@ export const githubProvider: RegistryEntry = { contextLength: 256000, maxOutputTokens: 128000, }, + // MAI (Microsoft AI) — /responses-only on Copilot (400 on /chat/completions). + { + id: "mai-code-1.1-flash", + name: "MAI-Code-1.1-Flash", + targetFormat: "openai-responses", + contextLength: 256000, + maxOutputTokens: 128000, + }, + { + id: "mai-code-1-flash-picker", + name: "MAI-Code-1-Flash (picker)", + targetFormat: "openai-responses", + contextLength: 256000, + maxOutputTokens: 128000, + }, + // xAI Grok on Copilot — /responses-only (supported_endpoints: ["/responses"]; + // 400 on /chat/completions). Distinct from xAI-direct (chat-capable) — see + // the separate `xai` provider. Live-verified context 500k / output 128k. + { + id: "grok-4.6", + name: "Grok 4.6", + targetFormat: "openai-responses", + contextLength: 500000, + maxOutputTokens: 128000, + }, + { + id: "grok-4.5", + name: "Grok 4.5", + targetFormat: "openai-responses", + contextLength: 500000, + maxOutputTokens: 128000, + }, { id: "oswe-vscode-prime", name: "Raptor mini", diff --git a/open-sse/executors/ghe-copilot.ts b/open-sse/executors/ghe-copilot.ts index 0ab8665cee..ee532149b1 100644 --- a/open-sse/executors/ghe-copilot.ts +++ b/open-sse/executors/ghe-copilot.ts @@ -15,6 +15,10 @@ export class GheCopilotExecutor extends GithubExecutor { format: "openai", baseUrl: "https://api.githubcopilot.com/chat/completions", responsesBaseUrl: "https://api.githubcopilot.com/responses", + // Static default only; the executor's getMessagesBase() derives the real + // per-connection host from copilotApiUrl/gheUrl at request time. Its + // presence enables Claude -> /v1/messages routing in the buildUrl override. + messagesUrl: "https://api.githubcopilot.com/v1/messages", authType: "oauth", authHeader: "bearer", ...config, @@ -70,6 +74,29 @@ export class GheCopilotExecutor extends GithubExecutor { return `${base}/responses`; } + /** + * Derive the base URL for the Anthropic-native /v1/messages shim from the GHE + * host in providerSpecificData. Claude models use this endpoint (prompt-cache + * token counts + lossless tool_use/tool_result/thinking blocks) rather than + * the OpenAI-shaped /chat/completions. Appends /v1/messages if not present. + */ + private getMessagesBase(credentials: ProviderCredentials | null): string { + const psd = credentials?.providerSpecificData; + const apiOrProxy = + (typeof psd?.copilotApiUrl === "string" ? psd.copilotApiUrl : undefined) || + (typeof psd?.copilotProxyUrl === "string" ? psd.copilotProxyUrl : undefined); + const host = apiOrProxy || (psd?.gheUrl as string | undefined); + if (!host) { + throw new Error("GHE Copilot executor requires copilotApiUrl or gheUrl in providerSpecificData"); + } + const base = host + .replace(/\/v1\/messages\/?$/, "") + .replace(/\/chat\/completions\/?$/, "") + .replace(/\/responses\/?$/, "") + .replace(/\/+$/, ""); + return `${base}/v1/messages`; + } + /** * Strip the `ghe-copilot/` provider prefix from a model id so the upstream * GHE Copilot proxy receives the bare id (e.g. `gpt-5-mini`). @@ -83,6 +110,13 @@ export class GheCopilotExecutor extends GithubExecutor { override buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: ProviderCredentials | null = null): string { const bareModel = this.stripPrefix(model); const targetFormat = getModelTargetFormat("ghe-copilot", bareModel); + // Claude models: ALWAYS route to the Anthropic-native /v1/messages shim + // (same as github.com Copilot), matched on the model NAME so a Claude id + // that is missing its registry targetFormat tag still gets the native shim + // instead of the lossy /chat/completions path. + if ((targetFormat === "claude" || /claude/i.test(bareModel)) && this.config.messagesUrl) { + return this.getMessagesBase(credentials); + } if ( (targetFormat === "openai-responses" || /codex/i.test(bareModel)) && this.supportsResponsesEndpoint(bareModel) diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 3e71bf627f..f3ce1196bf 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -84,14 +84,17 @@ export class GithubExecutor extends BaseExecutor { typeof overrideTargetFormat === "string" ? overrideTargetFormat : getModelTargetFormat("gh", model); - // Claude models: route to Copilot's Anthropic-native /v1/messages shim — the - // only Copilot endpoint that surfaces prompt-cache token counts for Claude and - // avoids a lossy round-trip of tool_use/tool_result/thinking content blocks - // through the OpenAI shape. Driven by the registry's per-model targetFormat - // (see registry/github/index.ts), which chatCore.ts also uses to translate the - // request to Claude shape before the executor ever sees it. + // Claude models: ALWAYS route to Copilot's Anthropic-native /v1/messages + // shim — the only Copilot endpoint that surfaces prompt-cache token counts + // for Claude and avoids a lossy round-trip of tool_use/tool_result/thinking + // content blocks through the OpenAI shape. Matched on the model NAME (not + // only the registry's per-model targetFormat) so a Claude model that is + // missing its targetFormat tag, or a custom Claude id, still gets the native + // shim rather than silently falling through to /chat/completions. Mirrors + // the Hermes copilot routing (`if "claude" in model: return CAPI_MESSAGES_URL`). // Port of decolua/9router#2608 (author: yidecode). - if (targetFormat === "claude" && this.config.messagesUrl) { + const isClaudeModel = /claude/i.test(model || ""); + if ((targetFormat === "claude" || isClaudeModel) && this.config.messagesUrl) { return this.config.messagesUrl; } // 9router#102: Copilot Codex models advertise supported_endpoints: ["/responses"] @@ -329,16 +332,70 @@ export class GithubExecutor extends BaseExecutor { crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, }; + // Per-call / per-conversation / per-turn correlation ids the @github/copilot + // CLI 1.0.81-6 puts on every inference request (MITM-captured). The machine + // id (getGitHubCopilotMachineId) is stable per-install; these three are + // fresh uuids. A Copilot-aware client may pin the session/task ids across a + // conversation via its own headers — honor those when present, else mint. + const genId = () => + crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`; + headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId(); + headers["x-client-session-id"] = + this.readClientHeader(clientHeaders, "x-client-session-id") || genId(); + headers["x-agent-task-id"] = + this.readClientHeader(clientHeaders, "x-agent-task-id") || genId(); + // Repository correlation sentinels. The CLI sends the working repo's nwo/host + // or these literals when there is no repository context. OmniRoute is not + // repo-scoped, so forward a client-supplied value when present, else sentinel. + headers["x-github-repository-nwo"] = + this.readClientHeader(clientHeaders, "x-github-repository-nwo") || "__no_repository__"; + headers["x-github-repository-host"] = + this.readClientHeader(clientHeaders, "x-github-repository-host") || "__no_repository__"; + // OpenAI-SDK (stainless) signature the CLI carries on streamed turns only. + if (stream) { + headers["x-stainless-helper-method"] = "stream"; + } + // Claude models routed to the Anthropic-native /v1/messages shim require the // anthropic-version header (harmless no-op on /chat/completions and /responses, - // but /v1/messages rejects the request without it). Port of decolua/9router#2608. - if (model && getModelTargetFormat("gh", model) === "claude") { + // but /v1/messages rejects the request without it). Match on the model NAME so + // it fires for every claude-* id (tagged or not), consistent with buildUrl. + // Port of decolua/9router#2608. + if (model && /claude/i.test(model)) { headers["anthropic-version"] = "2023-06-01"; } + // Forward a vision signal when the client already set it. Copilot's + // /v1/messages proxy returns an empty content block for image turns unless + // copilot-vision-request:true is present; a Copilot-aware harness that sends + // it should have it honored rather than stripped. + if ((this.readClientHeader(clientHeaders, "copilot-vision-request") || "").toLowerCase() === "true") { + headers["copilot-vision-request"] = "true"; + } + return headers; } + // Case-insensitive read of a single client header value. Client header maps + // arrive with inconsistent casing depending on the transport, so match on the + // lowercased key rather than assuming a canonical form. + private readClientHeader( + clientHeaders: Record | null | undefined, + name: string + ): string | null { + if (!clientHeaders) return null; + const target = name.toLowerCase(); + const direct = clientHeaders[name] ?? clientHeaders[target]; + if (typeof direct === "string") return direct; + for (const key in clientHeaders) { + if (key.toLowerCase() === target) { + const val = clientHeaders[key]; + return typeof val === "string" ? val : null; + } + } + return null; + } + // Forward the client's x-initiator header when present. OpenCode and other // Copilot-aware clients use this to distinguish user-initiated turns // (x-initiator: user) from autonomous tool-call continuations diff --git a/open-sse/handlers/moderations.ts b/open-sse/handlers/moderations.ts index 2ef19ed613..c153e10eea 100644 --- a/open-sse/handlers/moderations.ts +++ b/open-sse/handlers/moderations.ts @@ -6,7 +6,7 @@ import { CORS_HEADERS } from "../utils/cors.ts"; */ import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.ts"; -import { errorResponse } from "../utils/error.ts"; +import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; @@ -57,7 +57,9 @@ export async function handleModeration({ body, credentials }) { if (!res.ok) { const errText = await res.text(); - return new Response(errText, { + // secret-leak hardening: redact any credential the upstream echoed back + // before relaying the error body to the client (structure-preserving). + return new Response(redactSensitiveErrorText(errText), { status: res.status, headers: { "Content-Type": "application/json", diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts index 565f05ce00..f5d52f0106 100644 --- a/open-sse/handlers/ocr.ts +++ b/open-sse/handlers/ocr.ts @@ -11,7 +11,7 @@ import { parseOcrModel, OCR_PROVIDERS, } from "../config/ocrRegistry.ts"; -import { errorResponse } from "../utils/error.ts"; +import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; import { @@ -151,7 +151,10 @@ export async function handleOcr({ if (!res.ok) { const errText = await res.text(); - return new Response(errText, { + // secret-leak hardening: an upstream OCR provider can echo the offending + // request (Authorization header / api key) inside its error text. Redact + // secret patterns (structure-preserving) before relaying to the client. + return new Response(redactSensitiveErrorText(errText), { status: res.status, headers: { "Content-Type": "application/json", diff --git a/open-sse/services/githubCopilotModels.ts b/open-sse/services/githubCopilotModels.ts index ff54d1a293..b7a87ffbf2 100644 --- a/open-sse/services/githubCopilotModels.ts +++ b/open-sse/services/githubCopilotModels.ts @@ -20,12 +20,20 @@ import { getGitHubCopilotChatHeaders } from "../config/providerHeaderProfiles.ts"; export const GITHUB_COPILOT_MODELS_URL = "https://api.githubcopilot.com/models"; -export const GITHUB_COPILOT_MODEL_ALLOWLIST = [ + +// Static fallback catalog. Used ONLY when live discovery is unavailable +// (offline / unauthed / upstream error): the account's real entitlements can't +// be read, so we fall back to this curated set of known-good chat ids. It is +// NOT used to gate the LIVE response — see parseGitHubCopilotModels, which keeps +// every entitled chat model the catalog returns (so newly-entitled models like +// grok-4.6 / mai-code-1.1-flash / gemini-3.6-flash appear without a code edit). +export const GITHUB_COPILOT_STATIC_FALLBACK_MODELS = [ "claude-fable-5", "claude-opus-5", "claude-opus-4.8-fast", "claude-opus-4.8", "claude-opus-4.7", + "claude-opus-4.6", "claude-sonnet-4.6", "claude-opus-4.5", "claude-sonnet-5", @@ -33,12 +41,15 @@ export const GITHUB_COPILOT_MODEL_ALLOWLIST = [ "claude-haiku-4.5", "gemini-3.1-pro-preview", "gemini-3.7-flash", + "gemini-3.6-flash", + "gemini-3.5-flash", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", + "gpt-5.4-nano", "gpt-5.3-codex", "gpt-5-mini", "gpt-4o-2024-11-20", @@ -46,10 +57,18 @@ export const GITHUB_COPILOT_MODEL_ALLOWLIST = [ "gpt-4-0125-preview", "kimi-k2.7-code", "mai-code-1-flash", + "mai-code-1.1-flash", + "mai-code-1-flash-picker", + "grok-4.6", + "grok-4.5", "oswe-vscode-prime", ] as const; -const GITHUB_COPILOT_MODEL_ALLOWLIST_SET = new Set(GITHUB_COPILOT_MODEL_ALLOWLIST); +// Back-compat alias: earlier code + tests imported this name. It is now the +// static FALLBACK catalog, not a live-response gate. +export const GITHUB_COPILOT_MODEL_ALLOWLIST = GITHUB_COPILOT_STATIC_FALLBACK_MODELS; + +const GITHUB_COPILOT_STATIC_FALLBACK_SET = new Set(GITHUB_COPILOT_STATIC_FALLBACK_MODELS); export type GitHubCopilotModel = { id: string; @@ -69,10 +88,47 @@ function toNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +// Decide whether a live /models row is a routable chat model. Capability-driven +// (rename-robust) rather than an id allowlist: any model the account is entitled +// to whose capabilities.type is "chat" (or that carries a chat-shaped +// supported_endpoints) is kept, so a newly-entitled model shows up with no code +// change. Only explicitly non-chat rows (embeddings / completion) are dropped. +function isRoutableChatModel(item: RawRecord): boolean { + const capabilities = asRecord(item.capabilities); + const capType = toNonEmptyString(capabilities.type); + if (capType) return capType === "chat"; + + // No capabilities.type present — fall back to supported_endpoints shape. A + // chat model exposes /chat/completions, /responses, or /v1/messages. + const endpoints = Array.isArray(item.supported_endpoints) + ? (item.supported_endpoints as unknown[]) + : Array.isArray((asRecord(item.capabilities) as RawRecord).supported_endpoints) + ? ((asRecord(item.capabilities) as RawRecord).supported_endpoints as unknown[]) + : []; + if (endpoints.length > 0) { + return endpoints.some((e) => { + const s = toNonEmptyString(e) || ""; + return ( + s.includes("/chat/completions") || s.includes("/responses") || s.includes("/v1/messages") + ); + }); + } + + // Neither signal present: keep it unless its id looks like a known non-chat + // utility (embedding / completion sentinels). This keeps discovery permissive + // without re-introducing a brittle positive allowlist. + const id = (toNonEmptyString(item.id) || toNonEmptyString(item.model) || "").toLowerCase(); + if (!id) return false; + return !(id.includes("embedding") || id === "gpt-41-copilot"); +} + /** - * Parse a Copilot `/models` response into managed model rows. Only ids present - * in the live response are returned, which is exactly the entitlement filter - * #3121 requires. + * Parse a Copilot `/models` response into managed chat-model rows. Keeps every + * entitled CHAT model in the live response (capability-driven filtering) and + * drops only non-chat rows (embeddings / completion). Because only entitled + * models appear in the live response, this is exactly the entitlement filter + * #3121 needs — WITHOUT the old hardcoded id allowlist that silently dropped + * newly-entitled models (grok-4.6, mai-code-1.1-flash, gemini-3.6-flash, …). */ export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] { const payload = asRecord(data); @@ -89,7 +145,7 @@ export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] { const item = asRecord(value); const id = toNonEmptyString(item.id) || toNonEmptyString(item.model); if (!id || seen.has(id)) continue; - if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) continue; + if (!isRoutableChatModel(item)) continue; seen.add(id); const name = toNonEmptyString(item.name) || toNonEmptyString(item.display_name) || id; models.push({ id, name, owned_by: "github" }); @@ -120,7 +176,7 @@ function toFallbackResult( .map((model) => { const id = toNonEmptyString(model.id); if (!id) return null; - if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) return null; + if (!GITHUB_COPILOT_STATIC_FALLBACK_SET.has(id)) return null; return { id, name: toNonEmptyString(model.name) || id, owned_by: "github" }; }) .filter((model): model is GitHubCopilotModel => Boolean(model)); diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index c624e85886..f8cff79844 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -39,7 +39,7 @@ function looksLikeAbsolutePath(tok: string): boolean { return (SOURCE_EXT as readonly string[]).includes(ext); } -function redactSensitiveErrorText(value: string): string { +export function redactSensitiveErrorText(value: string): string { return value .replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]") .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") diff --git a/open-sse/utils/upstreamErrorPassthrough.ts b/open-sse/utils/upstreamErrorPassthrough.ts index 651aa2f77a..21d0c6c964 100644 --- a/open-sse/utils/upstreamErrorPassthrough.ts +++ b/open-sse/utils/upstreamErrorPassthrough.ts @@ -15,6 +15,17 @@ const PASSTHROUGH_MAX = 499; // quota wording the client needs. const EXCLUDED_STATUSES = new Set([401, 403, 407]); const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i; +// #10898-sec / secret-in-error hardening: some providers echo the offending +// request (including an Authorization header or api key) inside a 400/422/429 +// validation body. Passthrough relays the body VERBATIM (the Claude Code +// capability-recovery contract needs the exact wording), so we cannot key-drop +// via sanitizeUpstreamDetails without breaking that contract. Instead, if the +// body actually carries a credential pattern, REFUSE passthrough and let the +// caller fall back to the sanitized buildErrorBody path. Bodies without a +// secret (the overwhelming majority, carrying capability/quota wording) still +// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts. +const CREDENTIAL_LEAK_RE = + /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i; export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean { if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false; @@ -22,6 +33,8 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: if (!upstreamBody || typeof upstreamBody !== "object") return false; const text = JSON.stringify(upstreamBody); if (INTERNAL_LEAK_RE.test(text)) return false; + // Refuse passthrough when the provider echoed a credential back to us. + if (CREDENTIAL_LEAK_RE.test(text)) return false; return true; } diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 2a444174f1..fa58607ff5 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -154,6 +154,15 @@ export function resolveNextBuildEnv(baseEnv = process.env, platform = process.pl const env = { ...baseEnv, NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0", + // Reliable build signal inherited by every spawned `next build` worker. + // Next.js workers sometimes drop NEXT_PHASE, so DB entry points key off + // OMNIROUTE_BUILDING=1 to stub out SQLite and never load the native + // better-sqlite3 addon (its Statement destructor SIGABRTs at worker + // teardown: node::RemoveEnvironmentCleanupHook). (#10060) + OMNIROUTE_BUILDING: "1", + // No telemetry, anywhere: disable Next.js's anonymous build-time telemetry + // on every build path (local, CI, Docker), not just the image build. + NEXT_TELEMETRY_DISABLED: baseEnv.NEXT_TELEMETRY_DISABLED || "1", }; // Windows-only: `next build`'s static-generation glob scan and framework cache diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index ca61b177dd..c0862f21c5 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { homedir } from "os"; import { join } from "path"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isNextBuildPhase } from "@/lib/buildPhase"; import { createProviderConnection, getProviderConnections, @@ -83,6 +84,11 @@ async function tryKiroCliSqlite(): Promise<{ let Database: any; try { + // Never load the native better-sqlite3 addon during the Next.js build: + // its Statement destructor aborts with SIGABRT at build-worker teardown + // (node::RemoveEnvironmentCleanupHook). Kiro auto-import never runs during + // build, so returning "not found" here is safe. (#10060) + if (isNextBuildPhase()) throw new Error("Skip better-sqlite3 during build"); Database = (await import("better-sqlite3")).default; } catch { return { found: false, triedPaths: candidatePaths }; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index cecbb6776e..fcc1d9b655 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1645,10 +1645,19 @@ export async function GET( if (autoFetchDisabledResponse) return autoFetchDisabledResponse; const psd = asRecord(connection.providerSpecificData); - // The /models endpoint requires the short-lived Copilot token (same as the - // chat executor), not the raw GitHub OAuth access token. + // Catalog discovery must present the RAW GitHub OAuth token (gho_...), not + // the exchanged short-lived Copilot token. The full entitled model catalog + // (incl. grok-4.x and mai-code) is only unlocked when the + // `copilot-integration-id: copilot-developer-cli` header rides on a raw + // GitHub Bearer; the exchanged copilot_internal/v2/token bearer is minted + // WITHOUT the developer-cli identity and unlocks only the narrower default + // set, so grok/mai silently vanish. api.githubcopilot.com accepts the raw + // token directly as Bearer. (Chat/inference in the executor may still use + // the exchanged token; only DISCOVERY needs the raw token.) This mirrors the + // Copilot CLI + Hermes "de-gate model discovery" fix. Exchanged token stays + // as a fallback for connections that only captured that. const copilotToken = - toNonEmptyString(psd.copilotToken) || toNonEmptyString(accessToken) || null; + toNonEmptyString(accessToken) || toNonEmptyString(psd.copilotToken) || null; const discovery = await fetchGitHubCopilotModels({ token: copilotToken, diff --git a/src/lib/buildPhase.ts b/src/lib/buildPhase.ts new file mode 100644 index 0000000000..048cdc3308 --- /dev/null +++ b/src/lib/buildPhase.ts @@ -0,0 +1,26 @@ +/** + * Single source of truth for "are we running inside the Next.js production + * build?" — a leaf module with zero imports so any layer (db/core, the driver + * factory, lazy copilot loaders, API routes) can depend on it without creating + * an import cycle. + * + * Three signals, OR'd, because no single one is reliable across every build + * worker: + * - NEXT_PHASE === "phase-production-build": set by Next.js on the main build + * process, but Next.js build WORKERS sometimes drop it from process.env. + * - OMNIROUTE_BUILDING === "1": set by scripts/build/build-next-isolated.mjs + * and inherited by every spawned build worker, so it survives where + * NEXT_PHASE does not (#10060). + * - npm_lifecycle_event === "build": set by npm when the process was launched + * via `npm run build`, a backstop for direct invocations. + * + * Evaluated per-call (not memoized) so tests can toggle the env vars and code + * paths that legitimately mutate them at startup are respected. + */ +export function isNextBuildPhase(): boolean { + return ( + process.env.NEXT_PHASE === "phase-production-build" || + process.env.OMNIROUTE_BUILDING === "1" || + process.env.npm_lifecycle_event === "build" + ); +} diff --git a/src/lib/copilot/codegraphKnowledge.ts b/src/lib/copilot/codegraphKnowledge.ts index 3d0b26b3aa..f574bb26f3 100644 --- a/src/lib/copilot/codegraphKnowledge.ts +++ b/src/lib/copilot/codegraphKnowledge.ts @@ -11,6 +11,7 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { isNextBuildPhase } from "../buildPhase"; // --------------------------------------------------------------------------- // Types @@ -92,6 +93,12 @@ function queryDb(query: string, params: unknown[] = []): CodeGraphQueryResult { // Use better-sqlite3 if available try { + // Never load the native better-sqlite3 addon during the Next.js build: + // its Statement destructor aborts with SIGABRT at build-worker teardown + // (node::RemoveEnvironmentCleanupHook). This path is not exercised during + // build, so failing closed to "not available" is safe. (#10060) + if (isNextBuildPhase()) throw new Error("Skip better-sqlite3 during build"); + const Database = require("better-sqlite3"); _db = new Database(dbPath, { readonly: true }); } catch { diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index 304b334bd8..c5bbe92e84 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -1,4 +1,5 @@ import { runtimeRequire as _require } from "./runtimeRequire"; +import { isNextBuildPhase } from "../../buildPhase"; import { existsSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { createBetterSqliteAdapter } from "./betterSqliteAdapter"; @@ -234,8 +235,17 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?: } } - // 2. better-sqlite3: preferred native driver on Node.js - if (!process.versions.bun && mayLoadBetterSqlite()) { + // 2. better-sqlite3: preferred native driver on Node.js. Skipped on Bun and + // during the Next.js production build. Build workers sometimes lose + // NEXT_PHASE from process.env, so OMNIROUTE_BUILDING=1 (set by + // build-next-isolated.mjs and inherited by the build workers) is the primary + // build signal. Deliberately does NOT check isMainThread: at runtime many + // worker threads (pino thread-stream, compression workers) legitimately use + // better-sqlite3, and skipping it there would silently degrade to + // node:sqlite / sql.js in production. During the build the native addon + // cannot load: the Statement destructor aborts with SIGABRT on worker + // teardown (node::RemoveEnvironmentCleanupHook). (#10060) + if (!process.versions.bun && !isNextBuildPhase() && mayLoadBetterSqlite()) { try { const BetterSqlite = load("better-sqlite3") as { new (p: string, o?: object): import("better-sqlite3").Database; diff --git a/src/lib/db/better-sqlite3.stub.js b/src/lib/db/better-sqlite3.stub.js new file mode 100644 index 0000000000..d840d98b0c --- /dev/null +++ b/src/lib/db/better-sqlite3.stub.js @@ -0,0 +1,31 @@ +// Build-time stub for better-sqlite3 (#10060). +// +// Aliased in for the Next.js production build (turbopack + webpack) so the +// bundler never pulls the real native addon into a build worker. The native +// Statement destructor aborts with SIGABRT when a build worker thread exits +// (assertion in node::RemoveEnvironmentCleanupHook, env == nullptr), which can +// leave the build with no standalone output. At runtime the real package is +// used (it is listed in serverExternalPackages, so it is require()'d natively, +// not bundled); this stub only stands in during the build, where the DB is +// never actually queried. +class Database { + constructor() {} + prepare() { + return { + run: () => ({ changes: 0, lastInsertRowid: 0 }), + get: () => undefined, + all: () => [], + }; + } + exec() {} + pragma() {} + transaction(fn) { + return fn; + } + backup() { + return Promise.resolve({}); + } + close() {} +} + +module.exports = Database; diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index b7021be80e..cddde9dc87 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -4,7 +4,7 @@ * All domain modules import `getDbInstance` and helpers from here. */ -import type { SqliteAdapter } from "./adapters/types"; +import type { SqliteAdapter, PreparedStatement } from "./adapters/types"; import { tryOpenSync, getSqlJsAdapter, @@ -16,6 +16,7 @@ import path from "path"; import { retryProbeIfTransient } from "./probeUtils"; import fs from "fs"; import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths"; +import { isNextBuildPhase } from "../buildPhase"; import { runMigrations } from "./migrationRunner"; import { runDbHealthCheck } from "./healthCheck"; import { resetAllDbModuleState } from "./stateReset"; @@ -84,7 +85,18 @@ type CriticalTableSpec = { export const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null; -export const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; +// Next.js build workers sometimes drop NEXT_PHASE from their env, so +// OMNIROUTE_BUILDING=1 (set by build-next-isolated.mjs and inherited by every +// spawned build worker) is the reliable build signal. During build the native +// better-sqlite3 addon must never load: its Statement destructor aborts with +// SIGABRT when the worker thread exits (assertion in +// node::RemoveEnvironmentCleanupHook, env == nullptr). (#10060) +// +// Delegates to the shared leaf helper (src/lib/buildPhase.ts) so every build +// signal is defined in exactly one place. Kept as a module const (evaluated at +// import time) to preserve the existing eager-boolean semantics of the many +// `if (isBuildPhase || isCloud)` call sites across the db layer. +export const isBuildPhase = isNextBuildPhase(); // ──────────────── Paths ──────────────── @@ -1022,7 +1034,34 @@ export function getDbInstance(): SqliteDatabase { if (isCloud || isBuildPhase) { if (isBuildPhase) { - console.log("[DB] Build phase detected — using in-memory SQLite (read-only)"); + console.log("[DB] Build phase detected — using no-op SQLite stub (never queried)"); + // A no-op stub during build avoids loading the better-sqlite3 native + // bindings entirely. The native Statement destructor crashes with SIGABRT + // when the Next.js build worker thread exits (assertion in + // node::RemoveEnvironmentCleanupHook, env == nullptr). The DB is never + // actually queried during build — it only exists so module-eval that + // touches getDbInstance() at build time does not throw. (#10060) + const noopStatement: PreparedStatement = { + run: () => ({ changes: 0, lastInsertRowid: 0 }), + get: () => undefined, + all: () => [], + }; + const stubDb: SqliteDatabase = { + driver: "sql.js", + open: true, + name: ":memory:", + prepare: () => noopStatement, + exec: () => {}, + pragma: () => undefined, + transaction: (fn: (...args: unknown[]) => T) => fn, + immediate: (fn: () => void) => fn(), + backup: async () => {}, + checkpoint: () => {}, + close: () => {}, + raw: null, + }; + setDb(stubDb); + return stubDb; } const memoryDb = openSqliteDatabase(":memory:"); memoryDb.pragma("journal_mode = WAL"); diff --git a/src/lib/db/models/compat.ts b/src/lib/db/models/compat.ts index 640f87b264..9022bd7a82 100644 --- a/src/lib/db/models/compat.ts +++ b/src/lib/db/models/compat.ts @@ -1,7 +1,6 @@ /** db/models/compat.ts — model-compat overrides (normalizeToolCallId, per-protocol flags, upstream headers). */ import { getDbInstance } from "../core"; -import { resolveProviderAlias } from "@omniroute/open-sse/services/model.ts"; import { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey, @@ -121,11 +120,10 @@ export type ModelCompatOverride = { }; export function readCompatList(providerId: string): ModelCompatOverride[] { - const canonicalId = resolveProviderAlias(providerId) || providerId; const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") - .get(MODEL_COMPAT_NAMESPACE, canonicalId); + .get(MODEL_COMPAT_NAMESPACE, providerId); const value = getKeyValue(row).value; if (!value) return []; try { @@ -145,17 +143,16 @@ export function readCompatList(providerId: string): ModelCompatOverride[] { } export function writeCompatList(providerId: string, list: ModelCompatOverride[]) { - const canonicalId = resolveProviderAlias(providerId) || providerId; const db = getDbInstance(); if (list.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( MODEL_COMPAT_NAMESPACE, - canonicalId + providerId ); } else { db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( MODEL_COMPAT_NAMESPACE, - canonicalId, + providerId, JSON.stringify(list) ); } diff --git a/src/lib/logPayloads.ts b/src/lib/logPayloads.ts index 51abb584eb..f97e338259 100644 --- a/src/lib/logPayloads.ts +++ b/src/lib/logPayloads.ts @@ -16,6 +16,23 @@ const SENSITIVE_KEYS = new Set([ "password", "secret", "token", + // secret-leak hardening: session cookies + browser-storage credentials that + // some web-impersonation providers (Meta AI ecto_1_sess, chatgpt-web + // storageState / runtimeKey) can surface into a request/response BODY field + // rather than a header. Header-borne values are already masked by + // maskSensitiveHeaders; this covers the body path into the on-disk call-log + // artifact. Scoped to the actual credential field names only — the generic + // word "capability" was intentionally NOT included: it is a common non-secret + // field (model catalogs' `capabilities`, degradation/provider-discovery + // `capability` strings, MCP tool schemas) and matching it here would broadly + // redact useful diagnostics from call-log artifacts. The real Meta AI secret + // is the ecto_1_sess cookie / ecto1: WS token, already covered by + // cookie/authorization/storageState above. + "cookie", + "Cookie", + "storageState", + "storage-state", + "runtimeKey", ]); type JsonRecord = Record; diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts index cdac96345d..fecc482856 100644 --- a/src/lib/usage/callLogArtifacts.ts +++ b/src/lib/usage/callLogArtifacts.ts @@ -5,7 +5,8 @@ import { resolveDataDir } from "../dataPaths"; import { getCallLogPipelineMaxSizeBytes, isChatDebugFileEnabled } from "../logEnv"; const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null; -const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; +const isBuildPhase = + process.env.NEXT_PHASE === "phase-production-build" || process.env.OMNIROUTE_BUILDING === "1"; const DATA_DIR = resolveDataDir({ isCloud }); export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs"); diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index f39404df60..e733bd9940 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -10,6 +10,7 @@ import { import { getCachedSettings } from "@/lib/localDb"; import { getActiveSyncedCatalog } from "@/lib/db/models/activeSyncedCatalog"; import { getModelCompatOverrides } from "@/lib/db/models/compat"; +import { getNoAuthHydrationProviderIds } from "./noAuthProviderSiblings"; import { parseModel, getModelInfoCore, @@ -329,7 +330,17 @@ async function lookupModelMeta( const [customModels, liveCatalog, compatOverrides] = await Promise.all([ getCustomModels(providerId), getActiveSyncedCatalog(providerId), - Promise.resolve(getModelCompatOverrides(providerId)), + // #10898 / #7620: model-compat overrides (apiFormat/targetFormat/ + // supportsVision, isHidden, ...) are stored keyed on the id the operator + // wrote them under. For a no-auth alias the model prefix resolves to the + // APIKEY gateway id (e.g. "opencode/x" -> providerId "opencode-zen") but + // the override was written on the sibling "opencode" row. Merge overrides + // across the provider AND its no-auth sibling ids (requested id first) + // instead of canonicalizing the low-level compat key, which would break + // paths that legitimately key on the raw id (e.g. getHiddenModelsByProvider). + Promise.resolve( + getNoAuthHydrationProviderIds(providerId).flatMap((id) => getModelCompatOverrides(id)) + ), ]); const syncedModels = liveCatalog.models; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 0ba8de0737..19ea85d131 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2468,39 +2468,42 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "X-Initiator": "user", - "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.54.0", - "editor-version": "vscode/1.126.0", - "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.54.0", - "x-github-api-version": "2026-06-01", - "x-vscode-user-agent-library-version": "electron-fetch" + "copilot-harness-id": "copilot-sdk", + "copilot-integration-id": "copilot-developer-cli", + "editor-version": "copilot/1.0.81-6", + "openai-intent": "conversation-agent", + "user-agent": "copilot/1.0.81-6", + "x-client-machine-id": "", + "x-github-api-version": "2026-08-01", + "x-interaction-type": "conversation-user" }, "nonStream": { "Accept": "application/json", "Authorization": "Bearer ", "Content-Type": "application/json", "X-Initiator": "user", - "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.54.0", - "editor-version": "vscode/1.126.0", - "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.54.0", - "x-github-api-version": "2026-06-01", - "x-vscode-user-agent-library-version": "electron-fetch" + "copilot-harness-id": "copilot-sdk", + "copilot-integration-id": "copilot-developer-cli", + "editor-version": "copilot/1.0.81-6", + "openai-intent": "conversation-agent", + "user-agent": "copilot/1.0.81-6", + "x-client-machine-id": "", + "x-github-api-version": "2026-08-01", + "x-interaction-type": "conversation-user" }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", "X-Initiator": "user", - "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.54.0", - "editor-version": "vscode/1.126.0", - "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.54.0", - "x-github-api-version": "2026-06-01", - "x-vscode-user-agent-library-version": "electron-fetch" + "copilot-harness-id": "copilot-sdk", + "copilot-integration-id": "copilot-developer-cli", + "editor-version": "copilot/1.0.81-6", + "openai-intent": "conversation-agent", + "user-agent": "copilot/1.0.81-6", + "x-client-machine-id": "", + "x-github-api-version": "2026-08-01", + "x-interaction-type": "conversation-user" } }, "url": { @@ -2539,42 +2542,45 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "X-Initiator": "user", - "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.54.0", - "editor-version": "vscode/1.126.0", - "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.54.0", - "x-github-api-version": "2026-06-01", - "x-request-id": "", - "x-vscode-user-agent-library-version": "electron-fetch" + "copilot-harness-id": "copilot-sdk", + "copilot-integration-id": "copilot-developer-cli", + "editor-version": "copilot/1.0.81-6", + "openai-intent": "conversation-agent", + "user-agent": "copilot/1.0.81-6", + "x-client-machine-id": "", + "x-github-api-version": "2026-08-01", + "x-interaction-type": "conversation-user", + "x-request-id": "" }, "nonStream": { "Accept": "application/json", "Authorization": "Bearer ", "Content-Type": "application/json", "X-Initiator": "user", - "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.54.0", - "editor-version": "vscode/1.126.0", - "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.54.0", - "x-github-api-version": "2026-06-01", - "x-request-id": "", - "x-vscode-user-agent-library-version": "electron-fetch" + "copilot-harness-id": "copilot-sdk", + "copilot-integration-id": "copilot-developer-cli", + "editor-version": "copilot/1.0.81-6", + "openai-intent": "conversation-agent", + "user-agent": "copilot/1.0.81-6", + "x-client-machine-id": "", + "x-github-api-version": "2026-08-01", + "x-interaction-type": "conversation-user", + "x-request-id": "" }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", "X-Initiator": "user", - "copilot-integration-id": "vscode-chat", - "editor-plugin-version": "copilot-chat/0.54.0", - "editor-version": "vscode/1.126.0", - "openai-intent": "conversation-panel", - "user-agent": "GitHubCopilotChat/0.54.0", - "x-github-api-version": "2026-06-01", - "x-request-id": "", - "x-vscode-user-agent-library-version": "electron-fetch" + "copilot-harness-id": "copilot-sdk", + "copilot-integration-id": "copilot-developer-cli", + "editor-version": "copilot/1.0.81-6", + "openai-intent": "conversation-agent", + "user-agent": "copilot/1.0.81-6", + "x-client-machine-id": "", + "x-github-api-version": "2026-08-01", + "x-interaction-type": "conversation-user", + "x-request-id": "" } }, "url": { diff --git a/tests/unit/build/10060-build-sqlite-stub.test.ts b/tests/unit/build/10060-build-sqlite-stub.test.ts new file mode 100644 index 0000000000..8d5e1565d3 --- /dev/null +++ b/tests/unit/build/10060-build-sqlite-stub.test.ts @@ -0,0 +1,69 @@ +/** + * #10060 — during the Next.js production build the native better-sqlite3 addon + * must never load. Its Statement destructor aborts with SIGABRT when a build + * worker thread exits (assertion in node::RemoveEnvironmentCleanupHook), which + * can leave the build with no standalone output. + * + * The reliable build signal is OMNIROUTE_BUILDING=1 (set by + * build-next-isolated.mjs and inherited by every spawned build worker), because + * Next.js workers sometimes drop NEXT_PHASE. These tests pin the two contracts + * that keep the addon out of the build: + * + * 1. build-next-isolated.mjs exports OMNIROUTE_BUILDING=1 into the build env. + * 2. getDbInstance() returns a no-op stub (never the native driver) whenever + * the build signal is set, and that stub satisfies the SqliteAdapter shape. + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveNextBuildEnv } from "../../../scripts/build/build-next-isolated.mjs"; + +describe("#10060 build env carries OMNIROUTE_BUILDING", () => { + it("resolveNextBuildEnv sets OMNIROUTE_BUILDING=1", () => { + const env = resolveNextBuildEnv({}, "linux"); + assert.equal(env.OMNIROUTE_BUILDING, "1"); + }); + + it("preserves provided env keys and does not clobber the build-worker flag", () => { + const env = resolveNextBuildEnv({ NEXT_PRIVATE_BUILD_WORKER: "1" }, "linux"); + assert.equal(env.NEXT_PRIVATE_BUILD_WORKER, "1"); + assert.equal(env.OMNIROUTE_BUILDING, "1"); + }); +}); + +describe("#10060 getDbInstance stubs SQLite during build", () => { + const savedBuilding = process.env.OMNIROUTE_BUILDING; + const savedPhase = process.env.NEXT_PHASE; + + beforeEach(() => { + delete process.env.NEXT_PHASE; + process.env.OMNIROUTE_BUILDING = "1"; + }); + + afterEach(() => { + if (savedBuilding === undefined) delete process.env.OMNIROUTE_BUILDING; + else process.env.OMNIROUTE_BUILDING = savedBuilding; + if (savedPhase === undefined) delete process.env.NEXT_PHASE; + else process.env.NEXT_PHASE = savedPhase; + }); + + it("returns a no-op stub (never the native better-sqlite3 driver) under the build signal", async () => { + // Import fresh so isBuildPhase is evaluated with OMNIROUTE_BUILDING set. + const mod = await import(`../../../src/lib/db/core.ts?build-stub=${Date.now()}`); + const db = mod.getDbInstance(); + + // Must NOT be the native addon — that is the whole point of the fix. + assert.notEqual(db.driver, "better-sqlite3"); + assert.equal(db.open, true); + + // The stub satisfies the SqliteAdapter surface the build's module-eval touches. + const stmt = db.prepare("SELECT 1 AS x"); + assert.equal(stmt.get(), undefined); + assert.deepEqual(stmt.all(), []); + assert.deepEqual(stmt.run(), { changes: 0, lastInsertRowid: 0 }); + assert.doesNotThrow(() => db.exec("CREATE TABLE t (a)")); + assert.doesNotThrow(() => db.pragma("journal_mode = WAL")); + assert.doesNotThrow(() => db.close()); + }); +}); diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index cbb16a2819..def57d8c39 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -780,6 +780,13 @@ test("handleChatCore preserves client cache markers for Claude Code requests to type: "ephemeral", ttl: "5m", }); + // The system block above carries an explicit 5m cache_control, which trips the + // 5m breakpoint in normalizeCacheControlTtl (#10684: "defaults missing ttl to + // 5m after a 5m breakpoint", sections are processed tools -> system -> + // messages). So this user message's client marker, sent with no ttl, defaults + // to 5m rather than 1h. #10684 updated claude-code-parity.test.ts / + // chatcore-translation-paths.test.ts for this but missed this assertion, + // leaving it a base-red on release/v3.8.50. assert.deepEqual(calls[0].body.messages[0].content[0].cache_control, { type: "ephemeral", ttl: "5m", diff --git a/tests/unit/copilot-claude-always-v1-messages.test.ts b/tests/unit/copilot-claude-always-v1-messages.test.ts new file mode 100644 index 0000000000..06ac17d430 --- /dev/null +++ b/tests/unit/copilot-claude-always-v1-messages.test.ts @@ -0,0 +1,71 @@ +// Claude models must ALWAYS use the Anthropic-native /v1/messages shim on both +// github.com Copilot and GitHub Enterprise (GHE) Copilot — never /chat/completions +// or /responses. The base github executor and the GHE override both match on the +// model NAME (not only the registry's per-model targetFormat tag), so a Claude +// model that is missing its targetFormat tag, or a custom/newer Claude id not yet +// in the static registry, still gets the native shim. Mirrors the Hermes copilot +// routing (`if "claude" in model: return CAPI_MESSAGES_URL`). + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { GithubExecutor } = await import("../../open-sse/executors/github.ts"); +const { GheCopilotExecutor } = await import("../../open-sse/executors/ghe-copilot.ts"); + +test("github.com: an untagged Claude id still routes to /v1/messages", () => { + const executor = new GithubExecutor(); + // A Claude id NOT in the static registry (so getModelTargetFormat is null). + const url = executor.buildUrl("claude-opus-9.9-experimental", true); + assert.equal( + url, + "https://api.githubcopilot.com/v1/messages", + "any claude-* id must hit the native shim even without a registry targetFormat tag" + ); +}); + +test("github.com: a custom 'anthropic/claude' style id routes to /v1/messages", () => { + const executor = new GithubExecutor(); + const url = executor.buildUrl("claude-sonnet-5-preview", true); + assert.match(url, /\/v1\/messages$/); +}); + +test("github.com: non-claude ids are unaffected (gpt -> /responses, plain -> /chat/completions)", () => { + const executor = new GithubExecutor(); + assert.match(executor.buildUrl("gpt-5.4", true), /\/responses$/); + assert.match(executor.buildUrl("gpt-4o-mini", true), /\/chat\/completions$/); +}); + +test("GHE: Claude models route to the dynamic per-connection /v1/messages host", () => { + const executor = new GheCopilotExecutor(); + const creds = { + accessToken: "tok", + providerSpecificData: { copilotApiUrl: "https://copilot.enterprise.example/api/v1" }, + }; + // GHE strips the ghe-copilot/ prefix; the Claude match must fire on the bare id. + const url = executor.buildUrl("ghe-copilot/claude-opus-4.8", true, 0, creds); + assert.equal( + url, + "https://copilot.enterprise.example/api/v1/v1/messages", + "GHE Claude must hit the per-connection host's /v1/messages, not /chat/completions" + ); +}); + +test("GHE: an untagged Claude id still routes to /v1/messages", () => { + const executor = new GheCopilotExecutor(); + const creds = { + accessToken: "tok", + providerSpecificData: { copilotApiUrl: "https://ghe.example/copilot" }, + }; + const url = executor.buildUrl("ghe-copilot/claude-future-x", true, 0, creds); + assert.match(url, /\/v1\/messages$/); +}); + +test("GHE: non-claude ids still route to /chat/completions on the dynamic host", () => { + const executor = new GheCopilotExecutor(); + const creds = { + accessToken: "tok", + providerSpecificData: { copilotApiUrl: "https://ghe.example/copilot" }, + }; + const url = executor.buildUrl("ghe-copilot/gpt-4o", true, 0, creds); + assert.match(url, /\/chat\/completions$/); +}); diff --git a/tests/unit/copilot-gemini-claude-route-no-responses.test.ts b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts index 0ee255c518..b0fd5116dd 100644 --- a/tests/unit/copilot-gemini-claude-route-no-responses.test.ts +++ b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts @@ -60,7 +60,7 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout } }); - it("still uses chat/completions if a Claude/Gemini model is wrongly tagged openai-responses", () => { + it("still avoids /responses if a Claude/Gemini model is wrongly tagged openai-responses", () => { const exec = new GithubExecutor(); const claude = getGithubModel("claude-sonnet-4.6"); const gemini = getGithubModel("gemini-3.1-pro-preview"); @@ -68,11 +68,13 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout const originalClaude = claude.targetFormat; const originalGemini = gemini.targetFormat; try { - // Simulate a future misconfiguration. The guard must still hold. + // Simulate a future misconfiguration. The guard must still hold: Claude + // ALWAYS resolves to the native /v1/messages shim (name match beats the + // bad tag), Gemini stays on /chat/completions. Neither hits /responses. claude.targetFormat = "openai-responses"; gemini.targetFormat = "openai-responses"; - assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL); + assert.equal(exec.buildUrl("claude-sonnet-4.6", false), MESSAGES_URL); assert.equal(exec.buildUrl("gemini-3.1-pro-preview", false), CHAT_URL); } finally { claude.targetFormat = originalClaude; @@ -101,10 +103,9 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout const original = claude.targetFormat; try { claude.targetFormat = "openai-responses"; - // Look up by the same id (registry is case-sensitive on lookup) but with a - // mixed-case path through the guard. We rebuild with the registered id; - // the guard normalizes before substring check, so it must still detect. - assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL); + // Even wrongly tagged, a claude-* id resolves to the native shim (the + // name match is case-insensitive), never /responses. + assert.equal(exec.buildUrl("claude-sonnet-4.6", false), MESSAGES_URL); } finally { claude.targetFormat = original; } diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index b830e52bd8..9f17e6695d 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -65,10 +65,12 @@ test("GithubExecutor.buildUrl routes response-format models to /responses", () = } }); -test("GithubExecutor.buildUrl keeps GitHub Claude Opus 4.6 on /chat/completions", () => { +test("GithubExecutor.buildUrl routes GitHub Claude Opus 4.6 to the native /v1/messages shim", () => { const executor = new GithubExecutor(); const url = executor.buildUrl("claude-opus-4.6", true); - assert.equal(url, "https://api.githubcopilot.com/chat/completions"); + // Claude ALWAYS uses the Anthropic-native shim (prompt-cache token counts + + // lossless tool blocks), never /chat/completions. + assert.equal(url, "https://api.githubcopilot.com/v1/messages"); }); test("GithubExecutor.buildUrl routes unlisted Codex models to /responses (9router#102)", () => { @@ -276,13 +278,49 @@ test("GithubExecutor.buildHeaders prefers Copilot token and sets GitHub-specific assert.equal(headers.Authorization, "Bearer copilot-token"); assert.equal(headers.Accept, "text/event-stream"); - assert.equal(headers["editor-version"], "vscode/1.126.0"); - assert.equal(headers["editor-plugin-version"], "copilot-chat/0.54.0"); - assert.equal(headers["user-agent"], "GitHubCopilotChat/0.54.0"); - assert.equal(headers["x-github-api-version"], "2026-06-01"); - assert.equal(headers["openai-intent"], "conversation-panel"); + // Copilot CLI wire identity (matches the `copilot` npm package, not VS Code). + assert.equal(headers["editor-version"], "copilot/1.0.81-6"); + assert.equal(headers["user-agent"], "copilot/1.0.81-6"); + assert.equal(headers["x-github-api-version"], "2026-08-01"); + assert.equal(headers["openai-intent"], "conversation-agent"); + assert.equal(headers["copilot-integration-id"], "copilot-developer-cli"); + assert.equal(headers["x-interaction-type"], "conversation-user"); + assert.equal(headers["copilot-harness-id"], "copilot-sdk"); assert.equal(headers["X-Initiator"], "user"); assert.ok(headers["x-request-id"]); + // CLI 1.0.81-6 correlation headers. + assert.ok(headers["x-client-machine-id"], "stable per-install machine id present"); + assert.ok(headers["x-interaction-id"], "per-call interaction id present"); + assert.ok(headers["x-client-session-id"], "per-conversation session id present"); + assert.ok(headers["x-agent-task-id"], "per-turn task id present"); + assert.equal(headers["x-github-repository-nwo"], "__no_repository__"); + assert.equal(headers["x-github-repository-host"], "__no_repository__"); + assert.equal(headers["x-stainless-helper-method"], "stream"); + // The CLI does NOT send editor-plugin-version / the vscode library header on + // the inference path — those are VS Code Copilot Chat extension only. + assert.equal(headers["editor-plugin-version"], undefined); + assert.equal(headers["x-vscode-user-agent-library-version"], undefined); +}); + +test("GithubExecutor.buildHeaders omits x-stainless-helper-method for non-stream and honors client-pinned ids", () => { + const executor = new GithubExecutor(); + const nonStream = executor.buildHeaders({ accessToken: "gh" }, false); + assert.equal( + nonStream["x-stainless-helper-method"], + undefined, + "stainless stream signature only on streamed turns" + ); + + const pinned = executor.buildHeaders({ accessToken: "gh" }, true, { + "x-client-session-id": "sess-123", + "x-agent-task-id": "task-456", + "x-github-repository-nwo": "octo/repo", + "x-github-repository-host": "github.com", + }); + assert.equal(pinned["x-client-session-id"], "sess-123", "client-pinned session id honored"); + assert.equal(pinned["x-agent-task-id"], "task-456", "client-pinned task id honored"); + assert.equal(pinned["x-github-repository-nwo"], "octo/repo", "client repo nwo forwarded"); + assert.equal(pinned["x-github-repository-host"], "github.com", "client repo host forwarded"); }); test("GithubExecutor.buildHeaders forwards valid client x-initiator and falls back for invalid values", () => { diff --git a/tests/unit/ghe-copilot.test.ts b/tests/unit/ghe-copilot.test.ts index aaddc43ef7..333c5b7f50 100644 --- a/tests/unit/ghe-copilot.test.ts +++ b/tests/unit/ghe-copilot.test.ts @@ -71,7 +71,7 @@ test("buildUrl uses responses endpoint for gpt-5.4-mini and gpt-5.6-sol", () => ); }); -test("buildUrl uses chat/completions endpoint for claude and gemini models", () => { +test("buildUrl routes Claude to the native /v1/messages shim (not chat/completions)", () => { const executor = new GheCopilotExecutor({ gheUrl: "https://ghe.company.com", clientId: "test-client", @@ -80,10 +80,24 @@ test("buildUrl uses chat/completions endpoint for claude and gemini models", () const credentials: ProviderCredentials = { providerSpecificData: { gheUrl: "https://ghe.company.com" }, }; + // Claude must ALWAYS use the Anthropic-native shim (prompt-cache token counts + + // lossless tool_use/tool_result/thinking blocks), same as github.com Copilot. assert.strictEqual( executor.buildUrl("claude-opus-5", true, 0, credentials), - "https://ghe.company.com/chat/completions" + "https://ghe.company.com/v1/messages" ); +}); + +test("buildUrl uses chat/completions endpoint for gemini models", () => { + const executor = new GheCopilotExecutor({ + gheUrl: "https://ghe.company.com", + clientId: "test-client", + clientSecret: "test-secret", + }); + const credentials: ProviderCredentials = { + providerSpecificData: { gheUrl: "https://ghe.company.com" }, + }; + // Gemini has no native shim on Copilot — it stays on /chat/completions. assert.strictEqual( executor.buildUrl("gemini-3.5-flash", true, 0, credentials), "https://ghe.company.com/chat/completions" diff --git a/tests/unit/github-copilot-discovery-token.test.ts b/tests/unit/github-copilot-discovery-token.test.ts new file mode 100644 index 0000000000..40923df513 --- /dev/null +++ b/tests/unit/github-copilot-discovery-token.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + fetchGitHubCopilotModels, + GITHUB_COPILOT_MODELS_URL, +} from "../../open-sse/services/githubCopilotModels.ts"; + +// Regression guard for the Copilot catalog-discovery token fix. +// +// The full entitled Copilot model catalog (incl. grok-4.x and mai-code) is only +// unlocked when `copilot-integration-id: copilot-developer-cli` rides on a RAW +// GitHub Bearer token. The exchanged copilot_internal/v2/token bearer is minted +// without the developer-cli identity and unlocks only the narrower default set, +// silently dropping grok/mai. So discovery in +// src/app/api/providers/[id]/models/route.ts now prefers the raw accessToken over +// psd.copilotToken. These tests pin the two halves of the contract: +// (a) fetchGitHubCopilotModels sends whatever token it is given as +// `Authorization: Bearer *** on api.githubcopilot.com/models, and +// (b) a /responses-only entitled model (grok/mai shape) is preserved, not +// filtered out, when the live catalog returns it. + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +test("fetchGitHubCopilotModels sends the given token as Authorization: Bearer", async () => { + let seenUrl = ""; + let seenAuth: string | null = null; + let seenIntegrationId: string | null = null; + + const result = await fetchGitHubCopilotModels({ + token: "gho_raw_github_token", + fetchImpl: (async (url: string, init?: RequestInit) => { + seenUrl = String(url); + const headers = new Headers(init?.headers as HeadersInit); + seenAuth = headers.get("authorization"); + seenIntegrationId = headers.get("copilot-integration-id"); + return jsonResponse({ + data: [ + { id: "gpt-5.6", capabilities: { type: "chat" } }, + // grok/mai are /responses-only; they must survive discovery. + { id: "grok-4.6", capabilities: { type: "chat" }, supported_endpoints: ["/responses"] }, + { id: "mai-code-1.1-flash", supported_endpoints: ["/responses"] }, + ], + }); + }) as typeof fetch, + }); + + assert.equal(seenUrl, GITHUB_COPILOT_MODELS_URL); + // The raw token is presented verbatim — the unlock lever. + assert.equal(seenAuth, "Bearer gho_raw_github_token"); + // The developer-cli integration id is what unlocks the full catalog. + assert.equal(seenIntegrationId, "copilot-developer-cli"); + assert.equal(result.source, "api"); +}); + +test("fetchGitHubCopilotModels keeps /responses-only entitled models (grok/mai)", async () => { + const result = await fetchGitHubCopilotModels({ + token: "gho_raw_github_token", + fetchImpl: (async () => + jsonResponse({ + data: [ + { id: "grok-4.6", capabilities: { type: "chat" }, supported_endpoints: ["/responses"] }, + { id: "mai-code-1.1-flash", supported_endpoints: ["/responses"] }, + ], + })) as typeof fetch, + }); + + assert.equal(result.source, "api"); + const ids = new Set(result.models.map((m) => m.id)); + assert.ok(ids.has("grok-4.6"), "grok-4.6 must survive discovery"); + assert.ok(ids.has("mai-code-1.1-flash"), "mai-code must survive discovery"); +}); diff --git a/tests/unit/github-copilot-model-discovery.test.ts b/tests/unit/github-copilot-model-discovery.test.ts index 5c86f90873..77c5b11a91 100644 --- a/tests/unit/github-copilot-model-discovery.test.ts +++ b/tests/unit/github-copilot-model-discovery.test.ts @@ -20,13 +20,15 @@ import assert from "node:assert/strict"; const { GITHUB_COPILOT_MODELS_URL, GITHUB_COPILOT_MODEL_ALLOWLIST, + GITHUB_COPILOT_STATIC_FALLBACK_MODELS, parseGitHubCopilotModels, fetchGitHubCopilotModels, } = await import("../../open-sse/services/githubCopilotModels.ts"); // A representative slice of a real Copilot /models response. The upstream list -// includes selectable chat models plus utility/legacy models; OmniRoute imports -// only the curated allowlist. +// includes selectable chat models plus utility/legacy models; discovery now +// keeps every entitled CHAT model (capability-driven) and drops only non-chat +// rows (embeddings / completion). const MOCK_COPILOT_MODELS_RESPONSE = { data: [ { @@ -43,29 +45,47 @@ const MOCK_COPILOT_MODELS_RESPONSE = { capabilities: { type: "chat" }, }, { - // Embeddings model — present upstream but intentionally not in the curated chat list. + // Newly-entitled model NOT in any hardcoded list — must still be kept now + // that discovery is capability-driven (this is the whole point of the fix). + id: "grok-4.6", + name: "Grok 4.6", + model_picker_enabled: true, + capabilities: { type: "chat" }, + supported_endpoints: ["/responses"], + }, + { + // Embeddings model — present upstream but not a routable chat model. id: "text-embedding-3-small", name: "Embedding V3 small", capabilities: { type: "embeddings" }, }, + { + // Raw completion utility — also excluded. + id: "gpt-41-copilot", + name: "Copilot Completion", + capabilities: { type: "completion" }, + }, ], }; -test("#3120 parseGitHubCopilotModels maps data[].id into managed models", () => { +test("#3120 parseGitHubCopilotModels keeps every entitled CHAT model (capability-driven)", () => { const models = parseGitHubCopilotModels(MOCK_COPILOT_MODELS_RESPONSE); const ids = models.map((m) => m.id); - assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5"]); + // grok-4.6 is kept even though it is in no hardcoded allowlist — it's an + // entitled chat model in the live response. + assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5", "grok-4.6"]); const gpt = models.find((m) => m.id === "gpt-5.4"); assert.ok(gpt, "gpt-5.4 entry present"); assert.equal(gpt.name, "GPT-5.4"); assert.equal(gpt.owned_by, "github"); - assert.ok(!ids.includes("text-embedding-3-small"), "non-allowlisted utility models are skipped"); + assert.ok(!ids.includes("text-embedding-3-small"), "embeddings models are skipped"); + assert.ok(!ids.includes("gpt-41-copilot"), "completion utility models are skipped"); }); test("#3121 a model NOT in the live response is not advertised (entitlement filtering)", () => { const models = parseGitHubCopilotModels(MOCK_COPILOT_MODELS_RESPONSE); const ids = models.map((m) => m.id); - // gemini-3.1-pro-preview is in the OLD static catalog but NOT entitled here. + // gemini-3.1-pro-preview is not entitled here (absent from the live response). assert.ok( !ids.includes("gemini-3.1-pro-preview"), "non-entitled gemini preview must NOT be advertised" @@ -99,7 +119,7 @@ test("#3120 fetchGitHubCopilotModels does a live fetch and returns parsed models assert.ok(capturedHeaders["copilot-integration-id"], "must send Copilot integration header"); assert.equal(result.source, "api"); const ids = result.models.map((m) => m.id); - assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5"]); + assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5", "grok-4.6"]); assert.ok(!ids.includes("gemini-3.1-pro-preview")); }); @@ -125,38 +145,30 @@ test("#3120/#3121 fetch falls back to static catalog when the live fetch fails", ); }); -test("curated Copilot allowlist contains the final approved model ids only", () => { - assert.deepEqual( - [...GITHUB_COPILOT_MODEL_ALLOWLIST], - [ - "claude-fable-5", - "claude-opus-5", - "claude-opus-4.8-fast", - "claude-opus-4.8", - "claude-opus-4.7", - "claude-sonnet-4.6", - "claude-opus-4.5", - "claude-sonnet-5", - "claude-sonnet-4.5", - "claude-haiku-4.5", - "gemini-3.1-pro-preview", - "gemini-3.7-flash", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5-mini", - "gpt-4o-2024-11-20", - "gpt-4o-mini", - "gpt-4-0125-preview", - "kimi-k2.7-code", - "mai-code-1-flash", - "oswe-vscode-prime", - ] - ); +test("static fallback catalog is the alias of the allowlist and covers the approved chat ids", () => { + // Back-compat: the old name still points at the fallback catalog. + assert.equal(GITHUB_COPILOT_MODEL_ALLOWLIST, GITHUB_COPILOT_STATIC_FALLBACK_MODELS); + const set = new Set(GITHUB_COPILOT_STATIC_FALLBACK_MODELS); + // The fallback must include the newly-entitled families so an offline import + // (which can only draw from this static list) still surfaces them. + for (const id of [ + "claude-fable-5", + "claude-opus-5", + "claude-opus-4.8-fast", + "claude-opus-4.6", + "gemini-3.6-flash", + "gemini-3.5-flash", + "gpt-5.4-nano", + "grok-4.6", + "grok-4.5", + "mai-code-1.1-flash", + "mai-code-1-flash-picker", + ]) { + assert.ok(set.has(id), `static fallback must include ${id}`); + } + // No embeddings / completion utilities belong in the chat fallback catalog. + assert.ok(!set.has("text-embedding-3-small")); + assert.ok(!set.has("gpt-41-copilot")); }); test("newly approved Copilot models survive live and fallback discovery", async () => { diff --git a/tests/unit/noauth-sibling-compat-override-7620.test.ts b/tests/unit/noauth-sibling-compat-override-7620.test.ts new file mode 100644 index 0000000000..751eaab9e7 --- /dev/null +++ b/tests/unit/noauth-sibling-compat-override-7620.test.ts @@ -0,0 +1,98 @@ +/** + * Regression: #7620 hidden-model persistence must survive the #10898 compat + * canonicalization (fixed in this PR by keying the low-level compat store on the + * RAW providerId and merging overrides across no-auth siblings at resolution). + * + * The bug: #10898 canonicalized the compat key via resolveProviderAlias inside + * readCompatList/writeCompatList. setModelIsHidden / mergeModelCompatOverride + * writes the isHidden override under the raw no-auth id "opencode", but #10898 + * relocated the write to the canonical APIKEY gateway id "opencode-zen". The + * hidden-model reader (getHiddenModelsByProvider) still keyed on "opencode", so + * it read an empty row and a hidden no-auth model reappeared in the auto-combo + * pool. + * + * The fix has two halves, both pinned here: + * (1) the low-level compat store keys on the RAW providerId again, so an + * override written under "opencode" lands on the "opencode" key and is + * NOT visible under the sibling "opencode-zen" key; and + * (2) resolution (getNoAuthHydrationProviderIds) merges overrides across the + * provider AND its no-auth siblings (requested id first), so a lookup that + * resolves the model prefix to "opencode-zen" still finds the override the + * operator wrote under "opencode". + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7620-sibling-compat-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "compat-sibling-test-secret"; + +const { mergeModelCompatOverride, getModelCompatOverrides } = await import( + "../../src/lib/db/models/compat.ts" +); +const { getNoAuthHydrationProviderIds } = await import( + "../../src/sse/services/noAuthProviderSiblings.ts" +); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); + +test("#7620: isHidden override written under raw 'opencode' stays on the raw key, not the 'opencode-zen' sibling", () => { + mergeModelCompatOverride("opencode", "grok-code-fast-1", { isHidden: true }); + + const rawOverrides = getModelCompatOverrides("opencode"); + const rawEntry = rawOverrides.find((m) => m.id === "grok-code-fast-1"); + assert.ok(rawEntry, "override must be stored on the raw 'opencode' key"); + assert.equal(rawEntry.isHidden, true); + + // #10898 regression guard: the write must NOT have been canonicalized onto the + // APIKEY gateway id. If it had, the raw-keyed hidden reader would miss it. + const siblingOverrides = getModelCompatOverrides("opencode-zen"); + const leaked = siblingOverrides.find((m) => m.id === "grok-code-fast-1"); + assert.equal( + leaked, + undefined, + "override must NOT leak onto the 'opencode-zen' key (that was the #10898 regression)" + ); +}); + +test("getNoAuthHydrationProviderIds merges the no-auth sibling so 'opencode-zen' resolution reaches 'opencode' overrides", () => { + // Sibling map contract: opencode-zen (and opencode-go) hydrate from opencode. + assert.deepEqual(getNoAuthHydrationProviderIds("opencode-zen"), ["opencode-zen", "opencode"]); + assert.deepEqual(getNoAuthHydrationProviderIds("opencode-go"), ["opencode-go", "opencode"]); + // A provider with no siblings resolves to just itself (requested id first). + assert.deepEqual(getNoAuthHydrationProviderIds("opencode"), ["opencode"]); + + // End-to-end: an override written under "opencode" is found when the merged + // sibling set for the resolved gateway id "opencode-zen" is walked. + mergeModelCompatOverride("opencode", "claude-sonnet-5", { + apiFormat: "responses", + targetFormat: "claude", + isHidden: true, + }); + const merged = getNoAuthHydrationProviderIds("opencode-zen").flatMap((id) => + getModelCompatOverrides(id) + ); + const resolved = merged.find((m) => m.id === "claude-sonnet-5"); + assert.ok(resolved, "sibling-merged overrides must include the 'opencode' row"); + assert.equal(resolved.isHidden, true); + assert.equal(resolved.apiFormat, "responses"); + assert.equal(resolved.targetFormat, "claude"); +}); + +test("#10898 stays fixed: getModelInfo('opencode/') resolves to opencode-zen and reads the sibling override", async () => { + mergeModelCompatOverride("opencode", "claude-opus-5", { + apiFormat: "responses", + targetFormat: "claude", + supportsVision: true, + }); + + const info = await getModelInfo("opencode/claude-opus-5"); + + assert.equal(info.provider, "opencode-zen"); + assert.equal(info.apiFormat, "responses"); + assert.equal(info.targetFormat, "claude"); + assert.equal(info.supportsVision, true); +}); diff --git a/tests/unit/provider-header-profiles.test.ts b/tests/unit/provider-header-profiles.test.ts index f5c53c2407..09c33ae017 100644 --- a/tests/unit/provider-header-profiles.test.ts +++ b/tests/unit/provider-header-profiles.test.ts @@ -4,13 +4,18 @@ import assert from "node:assert/strict"; import { GITHUB_COPILOT_API_VERSION, GITHUB_COPILOT_CHAT_PLUGIN_VERSION, + GITHUB_COPILOT_CLI_USER_AGENT, GITHUB_COPILOT_CHAT_USER_AGENT, GITHUB_COPILOT_EDITOR_VERSION, + GITHUB_COPILOT_INTEGRATION_ID, + GITHUB_COPILOT_INTERACTION_TYPE, + GITHUB_COPILOT_HARNESS_ID, GITHUB_COPILOT_REFRESH_PLUGIN_VERSION, GITHUB_COPILOT_REFRESH_USER_AGENT, KIRO_AMZ_USER_AGENT, KIRO_SDK_USER_AGENT, QWEN_CLI_VERSION, + getGitHubCopilotMachineId, getQwenCliUserAgent, getGitHubCopilotChatHeaders, getGitHubCopilotInternalUserHeaders, @@ -21,12 +26,27 @@ import { test("provider header profiles expose current GitHub chat and internal headers", () => { const chatHeaders = getGitHubCopilotChatHeaders("text/event-stream", "agent"); + // Chat/inference path matches the @github/copilot CLI 1.0.81-6 wire identity. assert.equal(chatHeaders["editor-version"], GITHUB_COPILOT_EDITOR_VERSION); - assert.equal(chatHeaders["editor-plugin-version"], GITHUB_COPILOT_CHAT_PLUGIN_VERSION); - assert.equal(chatHeaders["user-agent"], GITHUB_COPILOT_CHAT_USER_AGENT); + assert.equal(chatHeaders["user-agent"], GITHUB_COPILOT_CLI_USER_AGENT); assert.equal(chatHeaders["x-github-api-version"], GITHUB_COPILOT_API_VERSION); + assert.equal(chatHeaders["copilot-integration-id"], GITHUB_COPILOT_INTEGRATION_ID); + assert.equal(chatHeaders["x-interaction-type"], GITHUB_COPILOT_INTERACTION_TYPE); + assert.equal(chatHeaders["copilot-harness-id"], GITHUB_COPILOT_HARNESS_ID); + assert.equal(chatHeaders["x-client-machine-id"], getGitHubCopilotMachineId()); assert.equal(chatHeaders["X-Initiator"], "agent"); assert.equal(chatHeaders.Accept, "text/event-stream"); + // The CLI does NOT send these on inference (VS Code Copilot Chat extension only). + assert.equal( + chatHeaders["editor-plugin-version"], + undefined, + "editor-plugin-version must NOT be on the CLI inference path" + ); + assert.equal( + chatHeaders["x-vscode-user-agent-library-version"], + undefined, + "x-vscode-user-agent-library-version must NOT be on the CLI inference path" + ); const internalHeaders = getGitHubCopilotInternalUserHeaders("token gh-access"); assert.equal(internalHeaders.Authorization, "token gh-access"); @@ -36,6 +56,17 @@ test("provider header profiles expose current GitHub chat and internal headers", assert.equal(internalHeaders["X-GitHub-Api-Version"], GITHUB_COPILOT_API_VERSION); }); +test("getGitHubCopilotMachineId is stable across calls and vision toggles the vision header", () => { + // Stable per-install fingerprint: same value every call (matches the CLI). + assert.equal(getGitHubCopilotMachineId(), getGitHubCopilotMachineId()); + const plain = getGitHubCopilotChatHeaders("application/json"); + assert.equal(plain["copilot-vision-request"], undefined); + const vision = getGitHubCopilotChatHeaders("application/json", "user", { vision: true }); + assert.equal(vision["copilot-vision-request"], "true"); + // Machine id is consistent between two header builds in the same process. + assert.equal(plain["x-client-machine-id"], vision["x-client-machine-id"]); +}); + test("provider header profiles expose dedicated refresh, qoder and kiro variants", () => { const refreshHeaders = getGitHubCopilotRefreshHeaders("token gh-access"); assert.equal(refreshHeaders.Authorization, "token gh-access"); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index 74838a93f8..793605cc4f 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -14,7 +14,8 @@ import { supportsXHighEffort, supportsXHighEffortForMaxNormalization, } from "../../open-sse/config/providerModels.ts"; -import { GITHUB_COPILOT_MODEL_ALLOWLIST } from "../../open-sse/services/githubCopilotModels.ts"; +// GITHUB_COPILOT_MODEL_ALLOWLIST is no longer used to gate the registry — the +// registry and the discovery fallback are asserted independently below. test("provider models helpers expose model lists and defaults", () => { const openaiModels = getProviderModels("openai"); @@ -85,25 +86,54 @@ test("Reka registry exposes preset models", () => { test("GitHub Copilot registry reflects the current supported model lineup", () => { const githubModels = getProviderModels("gh"); - const ids = githubModels.map((model) => model.id); + const ids: string[] = githubModels.map((model) => model.id); + + // The static registry and the live-discovery fallback catalog are DIFFERENT + // lists by design (the registry drives routing/targetFormat; the fallback is a + // discovery safety net), so we assert the registry's real membership directly + // rather than pinning it to GITHUB_COPILOT_MODEL_ALLOWLIST. + for (const expected of [ + "claude-opus-5", + "claude-opus-4.8", + "claude-opus-4.8-fast", + "claude-opus-4.7", + "claude-opus-4.6", + "claude-sonnet-4.6", + "gemini-3.7-flash", + "gemini-3.6-flash", + "gemini-3.5-flash", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.3-codex", + "grok-4.6", + "grok-4.5", + "mai-code-1-flash", + "mai-code-1.1-flash", + "mai-code-1-flash-picker", + ]) { + assert.ok(ids.includes(expected), `github registry must include ${expected}`); + } - assert.deepEqual(ids, [...GITHUB_COPILOT_MODEL_ALLOWLIST]); assert.equal(getModelTargetFormat("gh", "claude-opus-5"), "claude"); assert.equal(getModelTargetFormat("gh", "gpt-5.3-codex"), "openai-responses"); - // "claude-opus-4.6" is not a real Copilot model id (unlike claude-sonnet-4.6); - // it never appears in the registry, so its target format stays null. - assert.equal(getModelTargetFormat("gh", "claude-opus-4.6"), null); + // claude-opus-4.6 IS a real Copilot model id (live /models confirms it, ctx 1M); + // it now appears in the registry and routes through the claude target format. + assert.equal(getModelTargetFormat("gh", "claude-opus-4.6"), "claude"); // Claude models route through Copilot's Anthropic-native /v1/messages shim // (executors/github.ts) — the only endpoint that surfaces prompt-cache token // counts for Claude and avoids a lossy tool_use/tool_result round-trip through // the OpenAI shape. Port of decolua/9router#2608. assert.equal(getModelTargetFormat("gh", "claude-opus-4.8-fast"), "claude"); assert.equal(getModelTargetFormat("gh", "claude-sonnet-4.6"), "claude"); + // grok/mai on Copilot are /responses-only (400 on /chat/completions). + assert.equal(getModelTargetFormat("gh", "grok-4.6"), "openai-responses"); + assert.equal(getModelTargetFormat("gh", "mai-code-1.1-flash"), "openai-responses"); + assert.equal(getModelTargetFormat("gh", "gpt-5.4-nano"), "openai-responses"); assert.equal(getModelTargetFormat("gh", "gemini-3.7-flash"), null); assert.equal(getModelTargetFormat("gh", "kimi-k2.7-code"), null); assert.equal(ids.includes("gpt-4"), false); - assert.equal(ids.includes("gpt-4o"), false); - assert.equal(ids.includes("gpt-5.4-nano"), false); assert.equal(ids.includes("gpt-5.1"), false); assert.equal(ids.includes("gpt-5.1-codex"), false); assert.equal(ids.includes("claude-opus-4.1"), false); diff --git a/tests/unit/request-log-payloads.test.ts b/tests/unit/request-log-payloads.test.ts index 28468ddfcb..46aa84792d 100644 --- a/tests/unit/request-log-payloads.test.ts +++ b/tests/unit/request-log-payloads.test.ts @@ -35,6 +35,36 @@ test("normalizes JSON strings before log protection and redacts sensitive keys", }); }); +test("redacts web-impersonation body credentials but preserves non-secret 'capability' diagnostics", () => { + const protectedPayload = protectPayloadForLog( + JSON.stringify({ + // real browser-storage credentials that can land in a body field + cookie: "ecto_1_sess=abc123", + storageState: "{...}", + runtimeKey: "rk_live_secret", + // non-secret diagnostic fields that happen to be named 'capability' / + // 'capabilities' — must survive so call-log artifacts stay useful (#10952 + // review: do not blanket-redact the generic word 'capability'). + capability: "Reduced capability (fallback active)", + model: { + id: "claude-opus-4.8", + capabilities: { type: "chat", supports: { vision: true } }, + }, + }) + ); + + assert.deepEqual(protectedPayload, { + cookie: "[REDACTED]", + storageState: "[REDACTED]", + runtimeKey: "[REDACTED]", + capability: "Reduced capability (fallback active)", + model: { + id: "claude-opus-4.8", + capabilities: { type: "chat", supports: { vision: true } }, + }, + }); +}); + test("omits encrypted reasoning values from structured log payloads", () => { const encryptedContent = "encrypted".repeat(128); const payload = { diff --git a/tests/unit/upstream-error-passthrough.test.ts b/tests/unit/upstream-error-passthrough.test.ts index 0152775ff4..84458df5f7 100644 --- a/tests/unit/upstream-error-passthrough.test.ts +++ b/tests/unit/upstream-error-passthrough.test.ts @@ -30,6 +30,50 @@ test("upstream error passthrough", async (t) => { assert.equal(shouldPassthroughUpstreamError(401, { error: { message: "bad key" } }), false); } ); + await t.test( + "corpo que ecoa uma credencial (Bearer/api_key/sk-) NÃO é elegível (#secret-leak hardening)", + () => { + // Some providers echo the offending request inside a 400/422 validation + // body. Passthrough must refuse so the key is not relayed to the client. + assert.equal( + shouldPassthroughUpstreamError(400, { + error: { message: "invalid request: Authorization: Bearer sk-live-abc123def456ghi" }, + }), + false + ); + assert.equal( + shouldPassthroughUpstreamError(422, { + error: { message: "bad field", received: { api_key: "sk-abc123def456" } }, + }), + false + ); + assert.equal( + shouldPassthroughUpstreamError(429, { + error: { message: 'rejected: {"api-key":"xyzabc123secret"}' }, + }), + false + ); + } + ); + await t.test( + "corpo de capacidade/quota sem segredo continua elegível (contrato Claude Code preservado)", + () => { + // The common case must still relay verbatim so Claude Code can match the + // wording to auto-disable capabilities. + assert.equal( + shouldPassthroughUpstreamError(400, { + error: { message: "thinking.type: adaptive is not supported" }, + }), + true + ); + assert.equal( + shouldPassthroughUpstreamError(429, { + error: { type: "rate_limit_error", message: "slow down, retry after 60s" }, + }), + true + ); + } + ); await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => { const body = { type: "error",