Compare commits

...

8 Commits

Author SHA1 Message Date
Xiangzhe
9df1781065 fix(tests): drain second-wave base reds from the moving tip (#10952, #11205 follow-ups)
The base advanced twice past the branch cut (15 + 2 merges); the PR's CI
surfaced five more reds on the merged head, each discriminated:

1. check-env-doc-sync + Docs Gates: OMNIROUTE_BUILDING referenced in code
   (src/lib/buildPhase.ts, db/core, callLogArtifacts — build-phase stub
   signal from #10060 via #10952) but missing from .env.example /
   ENVIRONMENT.md. Fix: document it in both (never-set-for-server note).

2. db-core-init 'build phase uses an in-memory database' — STALE TEST:
   #10060 deliberately replaced the real in-memory build DB with a no-op
   stub (native better-sqlite3 aborts the Next.js build worker on exit,
   node::RemoveEnvironmentCleanupHook); the stub contract is pinned by
   tests/unit/build/10060-build-sqlite-stub.test.ts. The old test pinned
   the superseded contract (real memory DB with migrations). Aligned:
   stub driver, no-op queries, no file created.

3. 8134-github-t5-fallback-filter — STALE FIXTURE: #10952 added
   claude-opus-4.6 to the github registry, invalidating the fixture's
   'no 4.6 tier' assumption. The provably-absent tier moved to
   claude-opus-4-6-thinking (still absent); test rewritten against the
   real ladder (4.8 -> 4.7 -> 4.6 -> skip 4-6-thinking -> 4.5), keeping
   the original #8134 regression assertion (absent tier never returned).

4. cli-tools Codex/Copilot fingerprint — STALE PIN: #10952 bumped
   GITHUB_COPILOT_CLI_VERSION 0.54.0 -> 1.0.81-6 without the sibling
   fingerprint pin. Assertion aligned to GitHubCopilotChat/1.0.81-6.

5. stryker.conf.json — drop a duplicate tap.testFiles entry for
   quota-exhaustion-cutoff-opencode.test.ts created by the merge (the
   base registered it too after my fix in 17f5e4e0e9).

Validation: env-doc-sync gate green; 8134 2/2, cli-tools 23/23,
db-core-init build-phase test, check-env-doc-sync.test.ts all
RED->GREEN locally (node --import tsx/esm --test).

Refs #9985
2026-08-23 17:45:01 -03:00
Xiangzhe
4f9038f470 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/release-v3.8.50-basereds-cluster 2026-08-23 17:22:11 -03:00
Xiangzhe
17f5e4e0e9 fix(sse): drain new-base reds surfaced on the merged tip (#11178, #11238, #11267)
Three reds the PR's CI surfaced after the base advanced past the branch
cut — each discriminated with its origin PR:

1. chatcore-translation-paths 'Combo skip behavior' (shard 3/4) — REAL
   BUG in #11178: the incompatible-reasoning action derivation switched
   from the explicit fallback config to isComboStep =
   Boolean(comboStepId || comboExecutionKey). Combos whose records carry
   no explicit stepId/executionKey (plain model-list combos) had their
   explicit reasoningTransportFallback: 'skip' config silently degraded
   to 'drop', contradicting the PR's own stated intent ('combos keep
   their explicit strategy'). Fix: isComboStep now honors the isCombo
   marker (isCombo || step ids present). RED->GREEN on the exact CI
   failing test; the #10959 single-target drop defaults stay green.

2. check-db-rules-classification 'recovery zero importers' (shard 1/4)
   — STALE GATE, not dead code: #11238 converted bin/cli/runtime.mjs
   dynamic imports to the Windows-safe projectFileUrl('...') idiom, and
   the gate's importer regexes only recognized static/from/template
   import forms. recovery's only importer became invisible. Fix: gate
   pattern set extended to recognize import(projectFileUrl('…/db/<mod>.ts')).

3. mutation-test-coverage gate — #11267 added
   tests/unit/quota-exhaustion-cutoff-opencode.test.ts covering
   src/sse/services/auth.ts without registering it in stryker.conf.json
   tap.testFiles. Fix: register it (gate green locally).

Refs #9985
2026-08-23 17:01:54 -03:00
Armin Anton” ∴
10276821cd Integration: security tier + self-hosted operator blockers (rebased onto v3.8.51) (#10952)
Validated on the resolved merge against the current tip (527da656 + the post-#11281 rebaseline): the single conflict was a comment-only collision in providers/[id]/models/route.ts (kept the tip's #10828-ordering note). Focused suites 125/125 across all 13 touched test files (build-sqlite-stub, cc-compatible, copilot-claude-messages, copilot-gemini-route, executor-github, ghe-copilot, github-copilot-discovery-token, github-copilot-model-discovery, noauth-sibling-7620, provider-header-profiles, provider-models-config, request-log-payloads, upstream-error-passthrough), typecheck:core clean, file-size/changelog-integrity OK. Merged --admin over the inherited 2026-08-23 base-red cluster (#9985) — the reds are proven tip failures (CLI catalog cluster + @testing-library allowlist, being drained by #11280), not from this diff. Note: the rebase means several items the body listed (relay x-relay-path SSRF, /v1/search blocked-providers, #10736 rotation fence, #10903, #10865, #10899, #10916) already landed upstream and are NOT in this delta — the delta is: better-sqlite3 build guard + build heap/worker caps + telemetry-off (#10060 re-derived), credential-echo passthrough refusal + OCR/moderation redaction + call-log key redaction, Copilot CLI 1.0.81-6 wire identity + Claude→/v1/messages name-matched routing + discovery token fix, CC model_not_found 400, compat overrides for no-auth aliases (#7620-pinned). The Copilot wire-identity change is the one to watch in production. Thank you @arminanton — and the ported-author credits in the commit history (@rqzbeh, yidecode, the #10899/#10916 authors) are preserved. Your config-posture finding (REQUIRE_API_KEY default vs 0.0.0.0) is noted for a maintainer decision, as you scoped it.
2026-08-23 16:51:25 -03:00
Diego Rodrigues de Sa e Souza
bdf63d2171 fix(security): harden codex app-server transport (#11205 post-merge review) (#11281)
TDD: 4 red-to-green tests reproducing the two review findings + 6 new cases; 30/30 in tests/unit/codex-app-server.test.ts; typecheck/eslint/env-doc-sync/file-size clean. Merged --admin over the inherited 2026-08-23 base-red cluster (#9985): the remaining reds (CLI catalog/registry tests, @testing-library allowlist) are proven tip failures unrelated to this diff — shard logs show only CLI-cluster failures, and this PR itself drains the mutation-test-coverage red (stryker registration for #11267's test). Behavior change for app-server deployments is documented in the PR body (sandbox default + binding refusal).
2026-08-23 16:49:19 -03:00
Xiangzhe
950855e168 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/release-v3.8.50-basereds-cluster 2026-08-23 16:13:55 -03:00
Xiangzhe
5c03cfeedc fix(tests): make opencode setup/apply tests hermetic under the container guard (#10057)
Same class as the setup-qwen drain in c2df757610: since #10057 the
config-write guard exits 2 on ephemeral container runtimes, so the four
runSetupOpenCodeCommand tests and the /api/cli-tools/apply JSONC test
were environment-sensitive (red on container devboxes, green on
ubuntu-latest CI). They exercise the plugin install/merge path, not the
guard — pass allowContainerWrite / set the env override so they run
deterministically everywhere. The guard keeps its own dedicated coverage.

Refs #9985
2026-08-23 15:57:55 -03:00
Xiangzhe
c2df757610 fix(tests): drain base-red cluster from 2026-08-23 merges (#9985)
Drain the unit-shard reds the 2026-08-23 merge wave left on
release/v3.8.50, each discriminated as stale-test (contract moved
intentionally, test aligned) vs real bug (fixed in code/messages):

1. check-deps 6A.8 allowlist — #11224 added @testing-library/dom and
   @testing-library/user-event to package.json without the gate
   allowlist. Both are legitimate: @testing-library/dom is a required
   peer of @testing-library/react v16, and user-event is the official
   companion for UI tests. Fix: allowlist entries with a justification
   note referencing #11224/#9985.

2. search-route 400-vs-fallback — #11097 intentionally changed the
   zero-credential /v1/search contract: instead of returning 400 it
   promotes the fallback-only duckduckgo-free provider so out-of-the-box
   search works. The test pinned the OLD 400 contract. Fix (contract
   alignment): the test now pins the new fallback contract — 200,
   provider duckduckgo-free, DuckDuckGo lite endpoint called, results
   parsed from lite HTML.

3. codex catalog token limits (4 named reds + 2 sibling sweeps) —
   #11179 raised GPT_5_6_CODEX_CAPABILITIES from the 272K pricing tier
   to the real usable 872K window (live evidence: 390K served past
   272K with HTTP 200) and updated codex-gpt56-catalog.test.ts but
   missed the sibling pins. Stale tests aligned: models-catalog-combo-
   metadata (max_input_tokens now clamps to min(872000, 500000 override)
   = 500000), vscode-token-routes x3 (872000), and two more found in the
   sibling sweep: vscode-token-routes-gpt56 and provider-models-route-
   codex (conservative merge semantics unchanged: pinned 872000 < live
   999999 still wins).

4. CLI catalog counts (3 reds) — #11166 added prime-agent (agent
   category) without the cardinality pins: EXPECTED_AGENT_COUNT 8->9,
   total 34->35, D15 agent list + prime-agent, cli-tools-schema id list.
   Also added the missing English/Vietnamese cliTools descriptions for
   prime-agent (cli-catalog-display-contract) and corrected the stale
   CLI-TOOLS.md agent count (8->9).

5. setup-qwen container guard — since #10057 the container guard exits 2
   on ephemeral runtimes; the two tests exercising the merge/write path
   were environment-sensitive (red on container devboxes). Fix: pass
   allowContainerWrite so the tests are hermetic everywhere; the guard
   keeps its own dedicated coverage.

6. i18n health verdict namespace (real bug, fixed in messages) — #11224
   added the verdict/diagnostics strings to the `sidebar` namespace but
   health/page.tsx reads them via useTranslations("health"), so the page
   rendered raw keys in every locale and the "direct translation calls
   have English messages" gate went red. Fix: keys moved sidebar->health
   in en.json + vi.json (the only locales that had them), plus
   health.healthSubtitle added. The sidebar never referenced them
   (verified: no usage), and sidebar.healthSubtitle (its real sidebar
   key) is untouched.

7. i18n pt-BR drift (22 keys) — the 08-23 wave (#11224/#11228/#11215/
   #11204/#11195) added English keys never translated: common.batch*,
   endpoint.*, cliCommon.concept.acp.warning, resilienceConnections.*,
   plus the moved health.* keys and the prime-agent cliTools
   description. Fix: pt-BR translations added — 0 missing keys vs en;
   vi strict parity re-verified (0 missing, 0 extra).

Validation: every touched test file RED->GREEN individually
(node --import tsx/esm --test), typecheck:core clean, docs-counts-sync
soft-pass, cli-i18n gate PASS, 8/8 unit shards re-run on the branch.

Refs #9985
2026-08-23 14:45:05 -03:00
72 changed files with 1769 additions and 304 deletions

View File

@@ -65,6 +65,12 @@ INITIAL_PASSWORD=CHANGEME
# OMNIROUTE_RELEASE_REF=origin/main
# OMNIROUTE_ALLOW_CANARY_BUILD=1
# Build-phase signal (#10060). Set to 1 by scripts/build/build-next-isolated.mjs and
# inherited by every spawned build worker so the DB layer returns a no-op stub instead
# of loading the native better-sqlite3 addon (which aborts the worker on exit).
# Never set this for the running server. Used by: src/lib/buildPhase.ts, src/lib/db/core.ts
# OMNIROUTE_BUILDING=1
# Encryption key for SQLite database encryption at rest.
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.
@@ -1324,8 +1330,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Approval policy passed to the app-server turn (e.g. never, on-request).
# OMNIROUTE_CODEX_APPSERVER_APPROVAL=never
# Sandbox policy passed to the app-server turn (e.g. read-only,
# workspace-write, danger-full-access).
# workspace-write, danger-full-access). When unset, the executor defaults to
# "workspace-write" (hardened; used to be "danger-full-access").
# OMNIROUTE_CODEX_APPSERVER_SANDBOX=read-only
# Auto-approve the app-server's own approval prompts (command/file/permission
# execution on the host). Defaults to OFF — prompts are auto-denied. Set to
# true/1/yes only when you trust the deployment to run codex-decided host
# commands. Per-connection override: providerSpecificData.codexAppServerAutoApprove.
# OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE=false
# ═══════════════════════════════════════════════════════════════════════════════
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)

11
.gitattributes vendored Normal file
View File

@@ -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

View File

@@ -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 \

View File

@@ -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.

View File

@@ -0,0 +1 @@
- Hardened the Codex app-server transport after the post-merge security review of #11205: approval prompts from the app-server (its own command/file/permission execution — not the harness tool passthrough) are now auto-denied by default, with opt-in auto-approval via `providerSpecificData.codexAppServerAutoApprove` / `OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE`; the default codex sandbox changed from `danger-full-access` to `workspace-write` (override per connection or env); env-sourced capability tokens are now only sent to env-sourced URLs or operator-local hosts (loopback/RFC1918/link-local/ULA/localhost/single-label LAN names/*.local/*.ts.net/*.internal), so a connection's providerSpecificData URL can no longer exfiltrate the operator's env token; and the `/readyz` health probe no longer follows redirects while carrying the bearer token.

View File

@@ -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.

View File

@@ -1,5 +1,9 @@
{
"_comment": "Allowlist anti-slopsquatting (check-deps.mjs). Toda dep nova exige adicao EXPLICITA aqui apos verificar que e legitima.",
"_justifications": {
"@testing-library/dom": "Peer dep obrigatoria de @testing-library/react v16 (adicionada no PR #11224); Refs #9985.",
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985."
},
"allowed": [
"@atjsh/llmlingua-2",
"@aws-sdk/client-bedrock-runtime",
@@ -20,8 +24,10 @@
"@stryker-mutator/tap-runner",
"@swc/helpers",
"@tailwindcss/postcss",
"@testing-library/dom",
"@testing-library/jest-dom",
"@testing-library/react",
"@testing-library/user-event",
"@toon-format/toon",
"@types/better-sqlite3",
"@types/bun",

View File

@@ -433,7 +433,7 @@
"src/shared/components/analytics/charts.tsx": 1346,
"src/shared/services/cliRuntime.ts": 1459,
"src/sse/handlers/chat.ts": 2493,
"src/sse/services/auth.ts": 3337,
"src/sse/services/auth.ts": 3344,
"_rebaseline_2026_08_23_11186_synced_inventory_routing": "PR #11186 (pacocartones) own growth: src/sse/services/auth.ts 3260->3337 (+77, loadAdvertisedModelsForSelfHostedConnections + the modelNotAdvertised candidate-filter predicate — pins chat routing to the connection whose synced inventory actually advertises the model, fixing spurious model-not-found on multi-host self-hosted setups; at the existing credential-selection chokepoint, not extractable without splitting the selection flow). Covered by tests/unit/chat-routing-synced-inventory-11089.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
"tests/unit/account-fallback-service.test.ts": 2044,
"tests/unit/provider-validation-specialty.test.ts": 3880,
@@ -464,14 +464,15 @@
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1019,
"src/shared/middleware/chatBodyAdmission.ts": 1009,
"src/shared/middleware/chatBodyAdmission.ts": 1118,
"_rebaseline_2026_08_22_11020_sigterm_drain": "PR #11020 (RaviTharuma) own growth: chatBodyAdmission.ts 1005->1009 (+4, heavyweight admission leases now increment the SIGTERM drain counter and releaseChatAdmissionWhenDone holds it for the SSE lifetime — closes #11015; +4 are the lease/drain wiring lines at the existing admission chokepoint). Covered by tests/unit/chat-body-admission.test.ts heavyweight-lease cases. Owner pre-authorized baseline bumps 2026-08-22.",
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
"open-sse/executors/commandCode.ts": 1059,
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts."
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts.",
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -1,19 +1,19 @@
---
title: "CLI Tools — OmniRoute"
version: 3.8.50
lastUpdated: 2026-08-18
lastUpdated: 2026-08-23
---
# CLI Tools — OmniRoute
Last updated: 2026-08-18
Last updated: 2026-08-23
OmniRoute integrates with three categories of CLI tools spread across three dedicated dashboard pages:
| Page | Route | Concept | Count |
| -------------- | ----------------------- | ------------------------------------------------------------------------- | ------------ |
| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client → CLI → OmniRoute → Provider) | 26 |
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 8 |
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 9 |
| **ACP Agents** | `/dashboard/acp-agents` | CLIs that OmniRoute spawns as backend via stdio/ACP (reverse flow) | see registry |
Legacy routes redirect via 308: `/dashboard/cli-tools``/dashboard/cli-code`, `/dashboard/agents``/dashboard/acp-agents`.

View File

@@ -88,6 +88,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_RELEASE_REF` | `origin/main` | `scripts/build/buildProvenance.ts` | Ref the pack-artifact provenance gate checks the build SHA against (#10427). |
| `OMNIROUTE_ALLOW_CANARY_BUILD` | _(unset)_ | `scripts/build/buildProvenance.ts` | Set to `1` to allow packing a build whose SHA is not on the release line, recording it as a deliberate canary instead of failing the gate (#10427). |
| `OMNIROUTE_SMOKE_API_KEY` | _(unset)_ | `scripts/ops/deploy-canary.mjs` | API key for the canary-deploy smoke probe, sent as `Authorization: Bearer` on `/v1/chat/completions`. Only used by the deploy script (#10429), never by the server. Not related to the `OMNIROUTE_SMOKE_*` variables of the opt-in CLI smoke harness (`RUN_CLI_SMOKE=1`, `OMNIROUTE_SMOKE_BASE_URL/MODEL/API_KEY_ENV/TARGETS/TIMEOUT_MS` in `tests/integration/upstream-cli-smoke.int.test.ts`) — see [CLI Integrations → Real smoke sweep](../guides/CLI-INTEGRATIONS.md). |
| `OMNIROUTE_BUILDING` | _(unset)_ | `src/lib/buildPhase.ts` | Build-phase signal (#10060): set to `1` by `scripts/build/build-next-isolated.mjs` and inherited by every spawned build worker so the DB layer returns a no-op stub instead of loading the native better-sqlite3 addon (which aborts the worker on exit). Never set for the running server. |
| `OMNIROUTE_DATA_DIR` | _(unset)_ | `open-sse/executors/promptql/threadSticky.ts` | **Fallback alias** for `DATA_DIR`, checked only when `DATA_DIR` is unset. Used to locate the PromptQL executor's on-disk thread-sticky session cache (`<dir>/promptql-thread-sessions.json`); if neither var is set, the cache stays in-memory only (not persisted across restarts). |
| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. |
| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. |
@@ -742,7 +743,8 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE` | _(unset)_ | Path to a file holding the app-server capability token (from `codex app-server --ws-token-file`). Used when `OMNIROUTE_CODEX_APPSERVER_WS_TOKEN` is unset. Per-connection override: `providerSpecificData.codexAppServerTokenFile`. |
| `OMNIROUTE_CODEX_APPSERVER_CWD` | `/tmp` | Working directory the app-server turn runs in. Per-connection override: `providerSpecificData.codexAppServerCwd`. |
| `OMNIROUTE_CODEX_APPSERVER_APPROVAL` | _(unset)_ | Approval policy passed to the app-server turn (e.g. `never`, `on-request`). Per-connection override: `providerSpecificData.codexAppServerApprovalPolicy`. |
| `OMNIROUTE_CODEX_APPSERVER_SANDBOX` | _(unset)_ | Sandbox policy passed to the app-server turn (e.g. `read-only`, `workspace-write`, `danger-full-access`). Per-connection override: `providerSpecificData.codexAppServerSandbox`. |
| `OMNIROUTE_CODEX_APPSERVER_SANDBOX` | _(unset)_ | Sandbox policy passed to the app-server turn (e.g. `read-only`, `workspace-write`, `danger-full-access`). When unset the executor defaults to `workspace-write` (hardened; previously `danger-full-access`). Per-connection override: `providerSpecificData.codexAppServerSandbox`. |
| `OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE` | `false` | Auto-approve the app-server's own approval prompts (command/file/permission execution on the host). Off by default — prompts are auto-denied; harness tool calls are unaffected (they travel the separate `item/tool/call` passthrough). Accepts `true`/`1`/`yes`. Per-connection override: `providerSpecificData.codexAppServerAutoApprove`. |
| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. |
| `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` | `30000` (30s) | Maximum response-start wait (ms) for each direct no-proxy attempt. A timeout retries once on a fresh socket; set `0` to disable the bound and retain the previous behavior. |
| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. |

View File

@@ -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

View File

@@ -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<string, string> {
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<string, string> = {
"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 {

View File

@@ -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).

View File

@@ -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",

View File

@@ -11,7 +11,7 @@ import {
CodexAppServerClient,
type CodexAppServerClientOptions,
} from "./codex/appServerClient.ts";
import { resolveAppServerConfig, type CodexAppServerConfig } from "./codex/appServerConfig.ts";
import { resolveAppServerConfig, resolveThreadStartPolicy, type CodexAppServerConfig } from "./codex/appServerConfig.ts";
import {
translateNotification,
translateToolCall,
@@ -232,13 +232,20 @@ export class CodexAppServerExecutor extends BaseExecutor {
"codex_app_server_unconfigured"
);
}
// Turn policy (hardened after the #11205 security review): approvalPolicy
// "never", sandbox "workspace-write", autoApprove off unless the operator
// opted in — see resolveThreadStartPolicy.
const policy = resolveThreadStartPolicy(config, psd);
const promptText = extractPromptText(input.body);
const effort = extractEffort(input.body);
const toolMaps = buildAppServerToolMaps(input.body);
const hasTools = toolMaps.specs.length > 0;
const events = new AsyncEventQueue<AdapterEvent>();
const client = new CodexAppServerClient(this.clientOptions);
const client = new CodexAppServerClient({
...this.clientOptions,
autoApproveApprovals: policy.autoApprove,
});
const run = async () => {
let terminated = false;
@@ -284,17 +291,16 @@ export class CodexAppServerExecutor extends BaseExecutor {
cwd: config.cwd,
// OmniRoute is a router: the HARNESS that consumes OmniRoute owns tool
// execution and policy. codex must therefore NEVER block a turn waiting
// on its own interactive approval, and its own sandbox must not gate the
// model — the harness decides what actually runs. So we pair
// approvalPolicy:"never" (non-interactive; codex never prompts) with
// sandbox:"danger-full-access" (codex's own sandbox imposes no
// restriction), mirroring codexInstructions.ts:50 ("never +
// danger-full-access = take advantage of it"). Any server→client
// approval request that still arrives is auto-APPROVED by the client
// (see CodexAppServerClient), never denied — denial would sabotage the
// harness's tool calls. Callers can override both via providerSpecificData.
approvalPolicy: config.approvalPolicy ?? "never",
sandbox: config.sandbox ?? "danger-full-access",
// on its own interactive approval (approvalPolicy "never"). Its own
// sandbox defaults to "workspace-write" (hardened after the #11205
// security review; WAS "danger-full-access") so codex-decided host
// commands are confined to the turn's cwd tree — widen only via an
// explicit operator override. Server→client approval prompts (codex's
// own command/file/permission requests, NOT the harness tool
// passthrough) are auto-DENIED by the client unless the operator opted
// into auto-approval (see CodexAppServerClient).
approvalPolicy: policy.approvalPolicy,
sandbox: policy.sandbox,
// INBOUND harness tools → codex. The client tells the app-server which
// function tools are available for the thread via the `dynamicTools`
// field on thread/start (a DynamicToolSpec[] under the experimental API,

View File

@@ -12,9 +12,10 @@
* command / patch / permission. OmniRoute is a ROUTER — the harness that consumes
* it owns tool execution and policy — so codex must never stall a turn on its own
* interactive approval. Every inbound ServerRequest is always answered: approval
* prompts are auto-APPROVED (so the model's agentic tool calls proceed; the harness
* decides what really runs), and anything else we can't service gets a JSON-RPC
* error so the id is always settled and the turn never hangs.
* prompts are auto-DENIED by default (they gate codex's OWN host execution, not
* the harness's tools; auto-approval is an explicit operator opt-in — hardening
* after the #11205 security review), and anything else we can't service gets a
* JSON-RPC error so the id is always settled and the turn never hangs.
*/
// wreq-js WebSocket surface (mirrors the private type in codex.ts:71-77).
@@ -37,7 +38,12 @@ interface PendingReq {
}
// The set of ServerRequest methods that are approval prompts (see PROTOCOL-DIGEST
// "Server -> client REQUESTS"). All of these get an auto-denial decision.
// "Server -> client REQUESTS"). All of these get an auto-DENIAL decision unless
// the operator explicitly opted into auto-approval (hardening after the #11205
// security review): these prompts gate codex's OWN command/file/permission
// execution on the host, NOT the harness's dynamic tools (those travel the
// separate item/tool/call passthrough), so denying by default never sabotages
// harness tool calls — it closes a prompt-injection → host-execution path.
const APPROVAL_REQUEST_METHODS = new Set<string>([
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
@@ -47,12 +53,20 @@ const APPROVAL_REQUEST_METHODS = new Set<string>([
]);
const ROUTER_APPROVAL_NOTE = "router: harness-controlled execution";
const ROUTER_DENIAL_NOTE =
"router: denied by default (set codexAppServerAutoApprove to opt in)";
export interface CodexAppServerClientOptions {
/** Transport factory. Defaults to the shared wreq-js websocket() when omitted. */
websocketFn?: CodexAppServerWebsocketFn | null;
/** Default per-request timeout (ms). */
defaultTimeoutMs?: number;
/**
* Auto-APPROVE codex's own approval prompts (command/file/permission).
* Defaults to FALSE — prompts are auto-denied. Enable only when the operator
* trusts the app-server deployment to run codex-decided host commands.
*/
autoApproveApprovals?: boolean;
}
/**
@@ -89,11 +103,13 @@ export class CodexAppServerClient {
private toolCallHandler: CodexAppServerToolCallHandler | null = null;
private readonly websocketFn: CodexAppServerWebsocketFn | null;
private readonly defaultTimeoutMs: number;
private readonly autoApproveApprovals: boolean;
private closed = false;
constructor(options: CodexAppServerClientOptions = {}) {
this.websocketFn = options.websocketFn ?? null;
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
this.autoApproveApprovals = options.autoApproveApprovals === true;
}
/**
@@ -232,22 +248,27 @@ export class CodexAppServerClient {
}
/**
* Always answer an inbound ServerRequest so its id is settled. Approval prompts
* are auto-APPROVED (OmniRoute is a router; the harness that consumes it owns
* execution policy, so codex's own approval must not block the turn). Anything
* we cannot service gets a JSON-RPC error so the id is still settled.
* Always answer an inbound ServerRequest so its id is settled. Approval
* prompts are auto-DENIED unless the operator opted into auto-approval
* (hardening after the #11205 security review): they gate codex's OWN host
* command/file execution, not the harness's tools. Anything we cannot
* service gets a JSON-RPC error so the id is still settled.
*/
private answerServerRequest(id: number, method: string): void {
if (!this.ws || this.closed) return;
if (APPROVAL_REQUEST_METHODS.has(method)) {
// ReviewDecision "approved" — let the model's agentic action proceed. The
// harness downstream of OmniRoute is the real gate. Note the note field is
// advisory; the decision string is what codex acts on.
// ReviewDecision — "denied" by default; "approved" only with the explicit
// operator opt-in. The note field is advisory; the decision string is
// what codex acts on.
const approved = this.autoApproveApprovals;
this.ws.send(
JSON.stringify({
jsonrpc: "2.0",
id,
result: { decision: "approved", note: ROUTER_APPROVAL_NOTE },
result: {
decision: approved ? "approved" : "denied",
note: approved ? ROUTER_APPROVAL_NOTE : ROUTER_DENIAL_NOTE,
},
})
);
return;

View File

@@ -22,15 +22,19 @@ export interface CodexAppServerConfig {
*/
approvalPolicy?: string;
/**
* Optional codex sandbox override (SandboxMode). Defaults to "danger-full-access"
* in the executor so codex's own sandbox does not gate the model; the harness is
* the real gate. Callers may tighten this per request via providerSpecificData.
* Optional codex sandbox override (SandboxMode). Defaults to "workspace-write"
* in the executor (hardened after the #11205 security review; WAS
* "danger-full-access") so codex's own command/file execution is confined to
* the turn's cwd tree. Widen per connection via providerSpecificData or env.
*/
sandbox?: string;
}
type ProviderSpecificData = Record<string, unknown> | null | undefined;
/** Where a resolved value came from — the SSRF binding below keys off this. */
type ConfigSource = "psd" | "env";
function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
@@ -38,23 +42,47 @@ function firstString(...values: unknown[]): string | null {
return null;
}
function firstStringWithSource(
psdValue: unknown,
envValue: unknown
): { value: string; source: ConfigSource } | null {
if (typeof psdValue === "string" && psdValue.trim().length > 0) {
return { value: psdValue.trim(), source: "psd" };
}
if (typeof envValue === "string" && envValue.trim().length > 0) {
return { value: envValue.trim(), source: "env" };
}
return null;
}
/**
* Read the capability token, preferring an inline token, then a token FILE path.
* The token file (produced by `codex app-server --ws-token-file <path>`) holds the
* same hex string that is presented as the bearer token.
* same hex string that is presented as the bearer token. The source of the value
* (psd vs env) is tracked for the credential/URL binding rule.
*/
function resolveToken(psd: ProviderSpecificData): string | null {
const inline = firstString(
psd?.codexAppServerToken,
process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN
);
if (inline) return inline;
function resolveTokenWithSource(
psd: ProviderSpecificData
): { value: string; source: ConfigSource } | null {
const inlinePsd = firstString(psd?.codexAppServerToken);
if (inlinePsd) return { value: inlinePsd, source: "psd" };
const inlineEnv = firstString(process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN);
if (inlineEnv) return { value: inlineEnv, source: "env" };
const tokenFile = firstString(
psd?.codexAppServerTokenFile,
process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE
);
if (!tokenFile) return null;
const filePsd = firstString(psd?.codexAppServerTokenFile);
if (filePsd) {
const contents = readTokenFile(filePsd);
if (contents) return { value: contents, source: "psd" };
}
const fileEnv = firstString(process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE);
if (fileEnv) {
const contents = readTokenFile(fileEnv);
if (contents) return { value: contents, source: "env" };
}
return null;
}
function readTokenFile(tokenFile: string): string | null {
try {
const contents = readFileSync(tokenFile, "utf8").trim();
return contents.length > 0 ? contents : null;
@@ -67,18 +95,80 @@ function isWebSocketUrl(url: string): boolean {
return url.startsWith("ws://") || url.startsWith("wss://");
}
function urlHostname(url: string): string | null {
try {
return new URL(url).hostname || null;
} catch {
return null;
}
}
/**
* Is this hostname inside the operator's own network? Used by the
* credential/URL binding rule: an ENV-sourced capability token (the operator's
* shared secret, not visible to whoever wrote a connection's
* providerSpecificData) may only be sent to env-configured URLs or to
* operator-local hosts. Literal addresses only — no DNS resolution, so a
* public hostname can never smuggle an env token out via DNS. Single-label
* names (`ts-egress`) resolve via the operator's own hosts/mDNS and count as
* local; dotted names must carry a known-local suffix.
*/
export function isLocalAppServerHost(hostname: string): boolean {
const h = hostname
.trim()
.toLowerCase()
.replace(/^\[|\]$/g, "");
if (!h) return false;
if (h === "localhost" || h.endsWith(".localhost")) return true;
if (h.endsWith(".local") || h.endsWith(".ts.net") || h.endsWith(".internal")) return true;
if (h.includes(":")) {
// IPv6: loopback, ULA (fc00::/7), link-local (fe80::/10)
if (h === "::1") return true;
return /^f[cd]/.test(h) || /^fe[89ab]/.test(h);
}
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
if (m) {
const a = Number(m[1]);
const b = Number(m[2]);
if (a === 10 || a === 127) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
return false;
}
// single-label hostname (no dots): LAN/hosts-file/mDNS name
if (!h.includes(".")) return true;
return false;
}
/**
* Resolve the app-server connection config from providerSpecificData with env
* fallbacks. Returns `null` when not fully configured (URL + token both required)
* so the gating predicate `isCodexAppServerRequired` stays false and Codex falls
* back to its other transports.
*
* CREDENTIAL/URL BINDING (hardening after the #11205 security review): an
* env-sourced token is the operator's shared secret. It is only ever paired
* with (a) an env-sourced URL, or (b) an operator-local host
* (isLocalAppServerHost). A providerSpecificData URL pointing at an outside
* host combined with an env token is refused (returns null) — otherwise anyone
* able to write a connection could exfiltrate the env credential. A
* psd-sourced token may go anywhere: whoever wrote the psd already knows it.
*/
export function resolveAppServerConfig(psd: ProviderSpecificData): CodexAppServerConfig | null {
const url = firstString(psd?.codexAppServerUrl, process.env.OMNIROUTE_CODEX_APPSERVER_WS);
if (!url || !isWebSocketUrl(url)) return null;
const urlRes = firstStringWithSource(psd?.codexAppServerUrl, process.env.OMNIROUTE_CODEX_APPSERVER_WS);
if (!urlRes || !isWebSocketUrl(urlRes.value)) return null;
const token = resolveToken(psd);
if (!token) return null;
const tokenRes = resolveTokenWithSource(psd);
if (!tokenRes) return null;
if (tokenRes.source === "env" && urlRes.source === "psd") {
const host = urlHostname(urlRes.value);
if (!host || !isLocalAppServerHost(host)) return null;
}
const url = urlRes.value;
const token = tokenRes.value;
const cwd =
firstString(psd?.codexAppServerCwd, process.env.OMNIROUTE_CODEX_APPSERVER_CWD) ?? "/tmp";
@@ -92,3 +182,35 @@ export function resolveAppServerConfig(psd: ProviderSpecificData): CodexAppServe
return { url, token, cwd, ...(approvalPolicy ? { approvalPolicy } : {}), ...(sandbox ? { sandbox } : {}) };
}
/**
* The turn/start policy triple for a resolved config (hardening after the
* #11205 security review):
* - approvalPolicy defaults to "never": codex must not block a router turn on
* its own interactive approval (unchanged).
* - sandbox defaults to "workspace-write" (WAS "danger-full-access"): codex's
* own command/file execution is confined to the turn's cwd tree unless the
* operator explicitly widens it (providerSpecificData.codexAppServerSandbox /
* OMNIROUTE_CODEX_APPSERVER_SANDBOX). With "never" + a permissive sandbox,
* codex would run model-decided commands on the host with no gate at all.
* - autoApprove defaults to false: server→client approval prompts are answered
* "denied" unless the operator opts in via
* providerSpecificData.codexAppServerAutoApprove ("true"/"1"/"yes") or
* OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE. Harness tool calls are unaffected —
* they travel the separate item/tool/call passthrough.
*/
export function resolveThreadStartPolicy(
config: CodexAppServerConfig,
psd: ProviderSpecificData
): { approvalPolicy: string; sandbox: string; autoApprove: boolean } {
const raw = firstString(
psd?.codexAppServerAutoApprove,
process.env.OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE
);
const autoApprove = raw === "true" || raw === "1" || raw === "yes";
return {
approvalPolicy: config.approvalPolicy ?? "never",
sandbox: config.sandbox ?? "workspace-write",
autoApprove,
};
}

View File

@@ -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)

View File

@@ -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<string, string> | 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

View File

@@ -1218,7 +1218,12 @@ export async function handleChatCore({
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
onIncompatibleReasoning: resolveIncompatibleReasoningAction({
reasoningTransportFallback,
isComboStep: Boolean(comboStepId || comboExecutionKey),
// #11178 regressed combo steps whose combo record carries no explicit
// stepId/executionKey (plain model-list combos): their explicit
// `reasoningTransportFallback: "skip"` config was silently degraded to
// "drop". `isCombo` is the combo marker; step ids are optional
// finer-grained metadata that plain combos never set.
isComboStep: Boolean(isCombo) || Boolean(comboStepId || comboExecutionKey),
headers: clientRawRequest?.headers ?? null,
}),
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string>(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<string>(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));

View File

@@ -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]")

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 };

View File

@@ -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,

View File

@@ -61,7 +61,11 @@ export async function testCodexAppServerConnection(
);
const config = resolveAppServerConfig(psd);
if (!config) {
const error = "Codex app-server transport is not configured (missing url or token)";
// Also reached when the credential/URL binding refused (env token + remote
// psd URL) — the resolve deliberately returns null there so the token can
// never leave the operator's network (see appServerConfig.ts).
const error =
"Codex app-server transport is not configured (missing url/token, or the env-token/remote-URL binding was refused)";
return {
valid: false,
error,
@@ -81,6 +85,10 @@ export async function testCodexAppServerConnection(
method: "GET",
headers: { Authorization: `Bearer ${config.token}` },
signal: controller.signal,
// Never follow redirects carrying the bearer token (SSRF hardening after
// the #11205 security review): a 30x to an outside host would exfiltrate
// the capability token. A redirect response is simply "not ready".
redirect: "manual",
});
if (res.status !== 200) {
const error = `Codex app-server not ready (${readyzUrl} → HTTP ${res.status})`;

View File

@@ -1221,12 +1221,6 @@
"consoleLogsSubtitle": "Console output",
"logsActivitySubtitle": "User activity log",
"healthSubtitle": "System health check",
"healthVerdictReady": "OmniRoute is ready",
"healthVerdictActionRequired": "Action required to restore full operation",
"healthVerdictCoolingDown": "Cooling down after recent changes",
"advancedDiagnosticsTitle": "Advanced diagnostics",
"hide": "Hide",
"show": "Show",
"costsPricingSubtitle": "Per-model pricing rules",
"costsBudgetSubtitle": "Budget limits",
"costsQuotaShareSubtitle": "Share provider quotas across keys",
@@ -2918,6 +2912,7 @@
"interpreter": "Open Interpreter autonomous coding agent CLI",
"omp": "Oh My Pi terminal coding agent",
"letta": "Letta CLI agent with persistent memory and tool use",
"prime-agent": "Prime Agent — self-improving RLM coding harness with OpenAI-compatible provider support",
"warp": "Warp AI terminal with custom provider support",
"agent-deck": "Agent Deck multi-agent orchestrator"
},
@@ -4627,6 +4622,13 @@
"retry": "Retry",
"allOperational": "All systems operational",
"issuesDetected": "System issues detected",
"healthVerdictReady": "OmniRoute is ready",
"healthVerdictActionRequired": "Action required to restore full operation",
"healthVerdictCoolingDown": "Cooling down after recent changes",
"healthSubtitle": "System health check",
"advancedDiagnosticsTitle": "Advanced diagnostics",
"hide": "Hide",
"show": "Show",
"updatedAt": "Updated {time}",
"latency": "Latency",
"latencyP50": "p50",

View File

@@ -970,6 +970,13 @@
"batchTimelineCancelled": "Cancelado",
"batchTokenUsage": "Uso de Token",
"batchMetadata": "Metadados",
"batchHeaderSubtitle": "Execute muitas requisições como um único job",
"batchStep1": "1 · Enviar JSONL",
"batchStep1Desc": "Adicionar requisições",
"batchStep2": "2 · Criar lote",
"batchStep2Desc": "Executar job",
"batchStep3": "3 · Obter resultados",
"batchStep3Desc": "Baixar saída",
"batchFileContents": "Conteúdo do Arquivo",
"batchFileUsedByCount": "Usado por {count, plural, one {# lote} other {# lotes}}",
"batchFilePreview": "Prévia",
@@ -2905,6 +2912,7 @@
"interpreter": "CLI do agente de codificação autônomo Open Interpreter",
"omp": "Agente de codificação de terminal Oh My Pi",
"letta": "Agente CLI Letta com memória persistente e uso de ferramentas",
"prime-agent": "Prime Agent — harness de codificação RLM autoevolutivo com suporte a API compatível com OpenAI",
"warp": "Terminal de IA Warp com suporte a provedor personalizado",
"agent-deck": "Orquestrador multi-agente Agent Deck"
},
@@ -3831,6 +3839,9 @@
},
"endpoint": {
"title": "Endpoint da API",
"subtitle": "Use o endpoint compatível com OpenAI na maioria dos SDKs e ferramentas.",
"testEndpoint": "Testar endpoint →",
"advancedProtocols": "Protocolos avançados",
"available": "Endpoints Disponíveis",
"cloudProxy": "Proxy na Nuvem",
"disableConfirm": "Tem certeza que deseja desativar o proxy na nuvem?",
@@ -4611,6 +4622,13 @@
"retry": "Tentar Novamente",
"allOperational": "Todos os sistemas operacionais",
"issuesDetected": "Problemas detectados no sistema",
"healthVerdictReady": "O OmniRoute está pronto",
"healthVerdictActionRequired": "Ação necessária para restaurar a operação plena",
"healthVerdictCoolingDown": "Em resfriamento após mudanças recentes",
"healthSubtitle": "Verificação de saúde do sistema",
"advancedDiagnosticsTitle": "Diagnósticos avançados",
"hide": "Ocultar",
"show": "Mostrar",
"updatedAt": "Atualizado {time}",
"latency": "Latência",
"latencyP50": "p50",
@@ -12035,6 +12053,7 @@
"acp": {
"title": "ACP Agents",
"phrase": "CLIs que o OmniRoute spawna como backend de execução (fluxo reverso)",
"warning": "A maioria dos usuários pode ignorar isto — use apenas quando uma integração exigir.",
"flow": "Cliente → OmniRoute → spawn CLI (stdio/ACP) → resposta",
"seeOther": "Ver →"
}
@@ -13342,6 +13361,13 @@
},
"resilienceConnections": {
"title": "Resiliência de Conexão",
"reassuranceTitle": "Suas conexões se recuperam automaticamente",
"reassuranceDetail": "Normalmente nenhuma ação é necessária. O OmniRoute dá uma pausa temporária em uma conexão após falhas e depois a tenta novamente com segurança.",
"plainStates": {
"healthy": "Requisições podem ser enviadas",
"coolingDown": "Tentando novamente em breve",
"lockedOut": "Precisa da sua atenção"
},
"table": {
"status": "Status",
"provider": "Provedor",

View File

@@ -1300,13 +1300,7 @@
"open": "mở",
"close": "đóng"
},
"noResults": "Không có kết quả",
"healthVerdictReady": "OmniRoute đã sẵn sàng",
"healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ",
"healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây",
"advancedDiagnosticsTitle": "Chẩn đoán nâng cao",
"hide": "Ẩn",
"show": "Hiện"
"noResults": "Không có kết quả"
},
"webhooks": {
"title": "Webhook",
@@ -2918,6 +2912,7 @@
"interpreter": "Tác nhân lập trình tự trị Open Interpreter CLI",
"omp": "Tác nhân lập trình Oh My Pi trên terminal",
"letta": "Tác nhân Letta CLI có bộ nhớ lâu dài và khả năng dùng công cụ",
"prime-agent": "Prime Agent — bộ khung lập trình RLM tự cải tiến hỗ trợ API tương thích OpenAI",
"warp": "Terminal Warp AI hỗ trợ nhà cung cấp tùy chỉnh",
"agent-deck": "Trình điều phối đa tác nhân Agent Deck"
},
@@ -4627,6 +4622,13 @@
"retry": "Thử lại",
"allOperational": "Tất cả hệ thống đang hoạt động bình thường",
"issuesDetected": "Phát hiện sự cố hệ thống",
"healthVerdictReady": "OmniRoute đã sẵn sàng",
"healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ",
"healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây",
"healthSubtitle": "Kiểm tra tình trạng hệ thống",
"advancedDiagnosticsTitle": "Chẩn đoán nâng cao",
"hide": "Ẩn",
"show": "Hiện",
"updatedAt": "Đã cập nhật {time}",
"latency": "Độ trễ",
"latencyP50": "p50",

26
src/lib/buildPhase.ts Normal file
View File

@@ -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"
);
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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: <T>(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");

View File

@@ -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)
);
}

View File

@@ -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<string, unknown>;

View File

@@ -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");

View File

@@ -67,4 +67,5 @@ export const EXPECTED_CODE_COUNT = 21;
// +2 (#6318): "omp" (Oh My Pi) and "letta" (Letta CLI) added as agent entries.
// Note: #6318 originally also shipped duplicate "pi"/"jcode"/"codewhale" entries —
// those tools were already delivered by a separate PR, so only omp+letta landed here.
export const EXPECTED_AGENT_COUNT = 8;
// +1 (#11166): "prime-agent" (PrimeIntellect-ai/prime-agent) added as an agent entry.
export const EXPECTED_AGENT_COUNT = 9;

View File

@@ -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;

View File

@@ -307,6 +307,7 @@
"tests/unit/public-client-ids-3493.test.ts",
"tests/unit/publicCreds.test.ts",
"tests/unit/qoder-oauth-config.test.ts",
"tests/unit/quota-exhaustion-cutoff-opencode.test.ts",
"tests/unit/quota-groups-route.test.ts",
"tests/unit/quota-key-models-route.test.ts",
"tests/unit/quota-policy-generalization.test.ts",

View File

@@ -2468,39 +2468,42 @@
"Authorization": "Bearer <TOK>",
"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": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user"
},
"nonStream": {
"Accept": "application/json",
"Authorization": "Bearer <TOK>",
"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": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"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": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user"
}
},
"url": {
@@ -2539,42 +2542,45 @@
"Authorization": "Bearer <TOK>",
"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": "<UUID>",
"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": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user",
"x-request-id": "<UUID>"
},
"nonStream": {
"Accept": "application/json",
"Authorization": "Bearer <TOK>",
"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": "<UUID>",
"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": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user",
"x-request-id": "<UUID>"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"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": "<UUID>",
"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": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user",
"x-request-id": "<UUID>"
}
},
"url": {

View File

@@ -6,8 +6,8 @@ import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
const { getNextFamilyFallback } = await import("../../open-sse/services/modelFamilyFallback.ts");
// Regression for #8134 — GitHub Copilot ("github", alias "gh") T5 family fallback
// returned "claude-opus-4-6" verbatim even though the github registry catalog
// (Opus 4.8 / 4.8-fast / 4.7 / 4.5) has NO 4.6 tier under any dot/hyphen
// returned "claude-opus-4-6" verbatim even though the github registry catalog at
// the time (Opus 4.8 / 4.8-fast / 4.7 / 4.5) had NO 4.6 tier under any dot/hyphen
// notation. getNextFamilyFallback() resolved `supportedIds` from the provider's
// registry but only used it to try notation variants of a candidate, never to
// filter out a candidate that is provably absent from the catalog — so the
@@ -18,35 +18,46 @@ const { getNextFamilyFallback } = await import("../../open-sse/services/modelFam
// skips (continue) any family candidate that has no match in supportedIds
// under ANY notation (hyphen, dot, or a dated-snapshot id with the date
// suffix stripped) instead of returning it unfiltered.
//
// Fixture note: #10952 later added claude-opus-4.6 to the github registry, so
// the provably-absent tier used by the fixture moved to claude-opus-4-6-thinking
// (the ladder's first candidate after 4.6 — still absent from the catalog).
test("#8134: github claude-opus-4.8 fallback chain never returns an unsupported tier (claude-opus-4-6)", () => {
test("#8134: github claude-opus fallback chain never returns an unsupported tier (claude-opus-4-6-thinking)", () => {
const github = getRegistryEntry("github");
assert.ok(github, "expected the github registry entry to resolve");
const githubIds = new Set(github.models.map((m) => m.id));
// Fixture assumption: #10952 added claude-opus-4.6 to the github registry, so
// the original absent-tier role moved to the 4.6-thinking variant, which the
// catalog still does NOT carry under any notation.
assert.ok(
!githubIds.has("claude-opus-4-6") && !githubIds.has("claude-opus-4.6"),
"fixture assumption broken: github registry now has a 4.6 tier"
!githubIds.has("claude-opus-4-6-thinking") && !githubIds.has("claude-opus-4.6-thinking"),
"fixture assumption broken: github registry now has a 4.6-thinking tier"
);
// Ladder reality: 4.8 -> 4.7 -> 4.6 -> [4-6-thinking (absent), 4-5-20251101,
// sonnet-5]. The absent 4-6-thinking must be SKIPPED — the third hop resolves
// to the dated 4.5 snapshot's undated catalog entry, never to 4-6-thinking.
const tried = new Set(["github/claude-opus-4.8"]);
const first = getNextFamilyFallback("github/claude-opus-4.8", tried);
assert.ok(first, "expected a first fallback candidate");
const firstBareId = first.replace(/^github\//, "");
assert.ok(
githubIds.has(firstBareId),
`first fallback "${first}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
);
tried.add(first);
const second = getNextFamilyFallback(first, tried);
assert.ok(second, "expected a second fallback candidate (family must not be silently exhausted)");
const secondBareId = second.replace(/^github\//, "");
assert.ok(
githubIds.has(secondBareId),
`second fallback "${second}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
);
assert.notEqual(secondBareId, "claude-opus-4-6");
assert.notEqual(secondBareId, "claude-opus-4.6");
const hops: string[] = [];
let current = "github/claude-opus-4.8";
for (let hop = 0; hop < 3; hop++) {
const next = getNextFamilyFallback(current, tried);
assert.ok(next, `hop ${hop + 1}: family must not be silently exhausted`);
const bareId = next!.replace(/^github\//, "");
assert.ok(
githubIds.has(bareId),
`hop ${hop + 1}: "${next}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
);
assert.notEqual(bareId, "claude-opus-4-6-thinking");
assert.notEqual(bareId, "claude-opus-4.6-thinking");
tried.add(next!);
hops.push(next!);
current = next!;
}
// The skip specifically fired: the 4.6 -> next hop jumped past the absent
// 4-6-thinking tier straight to a catalogued model.
assert.equal(hops[2].replace(/^github\//, ""), "claude-opus-4.5");
});
test("#8134: getNextFamilyFallback never returns a candidate absent from the resolved provider's catalog", () => {

View File

@@ -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());
});
});

View File

@@ -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",

View File

@@ -61,6 +61,11 @@ function hasImporter(mod: string, roots: string[]): boolean {
new RegExp(`(?:import|require)\\s*\\(\\s*['""][^'"]+/db/${escaped}['"]`),
// dynamic template: import(`…/db/<mod>.ts`) — bin/cli/runtime.mjs uses template literals
new RegExp(`import\\s*\\(\`[^'"\`]+/db/${escaped}\\.ts\`\\)`),
// dynamic via file:// URL helper: import(projectFileUrl("…/db/<mod>.ts")) —
// bin/cli/runtime.mjs since #11238 (Windows-safe file:// dynamic imports).
new RegExp(
`import\\s*\\(\\s*projectFileUrl\\(\\s*['""][^'"]+/db/${escaped}\\.ts['"]\\s*\\)\\s*\\)`
),
// relative import within db/: from "./<mod>" or from "./<mod>"
new RegExp(`from\\s+['"]\\.\\.?/${escaped}['"]`),
];

View File

@@ -41,8 +41,8 @@ test("CLI_TOOLS total code entries (including none) equals 26 (21 visible + 5 no
assert.equal(codeAll.length, 26, `Expected 26 total code entries, got ${codeAll.length}`);
});
test("CLI_TOOLS total (code + agent) = 34", () => {
assert.equal(all.length, 34, `Expected 34 total entries, got ${all.length}`);
test("CLI_TOOLS total (code + agent) = 35", () => {
assert.equal(all.length, 35, `Expected 35 total entries, got ${all.length}`);
});
test("All code-none entries have configType mitm OR are legacy excluded entries", () => {
@@ -99,7 +99,7 @@ test("The 21 visible code entries include Qwen Code's rebuilt integration", () =
}
});
test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () => {
test("The 9 agent entries match D15 list exactly (+ omp + letta #6318, + prime-agent #11166)", () => {
const d15Agents = new Set([
"hermes-agent",
"openclaw",
@@ -109,6 +109,7 @@ test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () =>
"agent-deck",
"omp",
"letta",
"prime-agent",
]);
const agentIds = new Set(agentAll.map((t) => t.id));
for (const id of d15Agents) {

View File

@@ -74,6 +74,9 @@ describe("omniroute setup opencode", () => {
// Commander turns `--base-url` into `baseUrl` — the runner must accept it.
baseUrl: "http://10.0.0.5:20128",
nonInteractive: true,
// These tests exercise the plugin install/merge path, not the container
// guard (#10057) — keep them hermetic on container devboxes/CI.
allowContainerWrite: true,
});
assert.equal(r.exitCode, 0);
@@ -99,6 +102,7 @@ describe("omniroute setup opencode", () => {
configDir: CONFIG_DIR,
baseUrl: "http://10.0.0.9:20128",
nonInteractive: true,
allowContainerWrite: true,
});
assert.equal(r.exitCode, 0);
@@ -127,7 +131,11 @@ describe("omniroute setup opencode", () => {
})
);
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
const r = await runSetupOpenCodeCommand({
configDir: CONFIG_DIR,
nonInteractive: true,
allowContainerWrite: true,
});
assert.equal(r.exitCode, 0);
const cfg = readConfig();
@@ -140,7 +148,11 @@ describe("omniroute setup opencode", () => {
it("fails with a clear error (exit 1) when the bundled plugin dist is missing", async () => {
fs.rmSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true, force: true });
try {
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
const r = await runSetupOpenCodeCommand({
configDir: CONFIG_DIR,
nonInteractive: true,
allowContainerWrite: true,
});
assert.equal(r.exitCode, 1);
} finally {
makeFakePluginDist();

View File

@@ -15,6 +15,10 @@ const originalFetch = globalThis.fetch;
const originalJwtSecret = process.env.JWT_SECRET;
const originalApiKeySecret = process.env.API_KEY_SECRET;
const originalXdg = process.env.XDG_CONFIG_HOME;
const originalAllowContainerWrite = process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
// This test exercises the apply/merge path, not the container guard (#10057) —
// keep it hermetic on container devboxes/CI.
process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "1";
const testRoots = new Set<string>();
async function createAuthCookie(): Promise<string> {
@@ -72,6 +76,9 @@ test.afterEach(async () => {
else process.env.API_KEY_SECRET = originalApiKeySecret;
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXdg;
if (originalAllowContainerWrite === undefined)
delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
else process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = originalAllowContainerWrite;
for (const root of testRoots) await fs.rm(root, { recursive: true, force: true });
testRoots.clear();
});

View File

@@ -11,6 +11,7 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code
// (CodeWhale is the actively-maintained successor to DeepSeek TUI).
// omp + letta added by #6318 (agent-category CLI integrations).
// grok-build added — xAI Grok Build TUI coding agent (ported from upstream decolua/9router#2571).
// prime-agent added by #11166 (PrimeIntellect-ai/prime-agent, agent category).
const expected = [
"claude",
"codex",
@@ -46,6 +47,7 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code
"grok-build",
"qwen",
"zcode",
"prime-agent",
];
for (const id of expected) {
assert.ok(id in CLI_TOOLS, `Missing tool: ${id}`);

View File

@@ -106,7 +106,9 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
{ model: "gpt-4o", messages: [] }
);
assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/0.54.0");
// #10952 bumped GITHUB_COPILOT_CLI_VERSION 0.54.0 -> 1.0.81-6; the fingerprint
// pin tracks the advertised upstream CLI version.
assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/1.0.81-6");
});
test("CLI fingerprint keeps legacy Copilot settings functional without exposing duplicate UI toggles", () => {

View File

@@ -42,6 +42,9 @@ test("setup-qwen writes current V4 settings and only its dedicated env key", asy
configPath: settingsPath,
envPath,
yes: true,
// These tests exercise the merge/write logic, not the container guard
// (#10057) — keep them hermetic on container devboxes/CI.
allowContainerWrite: true,
});
assert.equal(code, 0);
@@ -76,6 +79,8 @@ test("setup-qwen does not overwrite an invalid settings file", async () => {
model: "model-id",
configPath: settingsPath,
yes: true,
// See above — hermetic regardless of container detection (#10057).
allowContainerWrite: true,
});
assert.equal(code, 1);
assert.equal(await fs.readFile(settingsPath, "utf8"), "{ invalid JSON");

View File

@@ -213,9 +213,9 @@ test("mapUsage: converts snake_case token counts", () => {
assert.equal(mapUsage(undefined), undefined);
});
// ── Client: stall-guard auto-approval ───────────────────────────────────────
// ── Client: approval stall-guard (deny-by-default, opt-in approve) ─────────
test("CodexAppServerClient: server approval request is auto-approved", async () => {
test("CodexAppServerClient: server approval request is auto-DENIED by default (#11205 hardening)", async () => {
const ctrl = makeFakeSocket();
const { fn } = fakeTransport(ctrl);
const client = new CodexAppServerClient({ websocketFn: fn });
@@ -234,12 +234,30 @@ test("CodexAppServerClient: server approval request is auto-approved", async ()
const reply = ctrl.sent.find((f) => f.id === 99);
assert.ok(reply, "client must reply to the server approval request");
// OmniRoute is a router: approvals are auto-APPROVED so the model's agentic
// tool calls proceed; the harness downstream is the real execution gate.
assert.equal(
(reply!.result as Record<string, unknown>).decision,
"approved"
);
// Security contract (post-#11205 review): codex's OWN command/file/permission
// executions are denied by default — approval prompts are NOT the harness
// tool-call passthrough (that path is item/tool/call, handled separately), so
// denying never sabotages harness tools. Blanket auto-approve + a permissive
// sandbox is a confused-deputy for prompt-injected turns.
assert.equal((reply!.result as Record<string, unknown>).decision, "denied");
});
test("CodexAppServerClient: approval request is auto-approved only with explicit opt-in", async () => {
const ctrl = makeFakeSocket();
const { fn } = fakeTransport(ctrl);
const client = new CodexAppServerClient({ websocketFn: fn, autoApproveApprovals: true });
await client.connect("ws://x", "tok");
ctrl.emit({
jsonrpc: "2.0",
id: 100,
method: "item/fileChange/requestApproval",
params: { changes: [] },
});
const reply = ctrl.sent.find((f) => f.id === 100);
assert.ok(reply, "client must reply to the server approval request");
assert.equal((reply!.result as Record<string, unknown>).decision, "approved");
});
test("CodexAppServerClient: non-approval server request gets a JSON-RPC error", async () => {
@@ -332,11 +350,13 @@ test("CodexAppServerExecutor: streaming turn emits initialize → thread/start
assert.deepEqual(lifecycle, ["initialize", "thread/start", "turn/start"]);
// thread/start carried the router defaults: approvalPolicy:"never" (codex
// never blocks on its own approval) + sandbox:"danger-full-access" (codex's
// own sandbox does not gate the model; the harness is the real execution gate).
// never blocks on its own approval) + sandbox:"workspace-write" — hardened
// default post-#11205 security review (was "danger-full-access"): codex's own
// sandbox now confines writes to the turn's cwd tree unless the operator
// explicitly opts back into a wider sandbox via providerSpecificData/env.
const threadStart = sent.find((f) => f.method === "thread/start");
assert.equal((threadStart!.params as Record<string, unknown>).approvalPolicy, "never");
assert.equal((threadStart!.params as Record<string, unknown>).sandbox, "danger-full-access");
assert.equal((threadStart!.params as Record<string, unknown>).sandbox, "workspace-write");
// turn/start carried the text input with text_elements:[]
const turnStart = sent.find((f) => f.method === "turn/start");
@@ -700,3 +720,173 @@ test("probeCodexAppServerAuth: no transport → unknown (does not throw)", async
assert.equal(status.state, "unknown");
});
// ── Security hardening (#11205 post-merge review) ───────────────────────────
// Two findings from the automated push review on the original #11205 merge:
// (1) the readyz health probe sent the bearer token to any URL a connection
// config pointed at, following redirects (SSRF / credential exfil);
// (2) env-sourced credentials were happily paired with a
// providerSpecificData-sourced URL, so anyone able to write a connection
// could harvest the operator's env token.
// The binding rule: env-sourced tokens are only sent to env-sourced URLs or to
// operator-local hosts (loopback / RFC1918 / link-local / ULA / localhost /
// single-label LAN names / *.local / *.ts.net / *.internal). A psd-sourced
// token may go anywhere — whoever wrote the psd already knows it.
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
const prev: Record<string, string | undefined> = {};
for (const k of Object.keys(vars)) {
prev[k] = process.env[k];
if (vars[k] === undefined) delete process.env[k];
else process.env[k] = vars[k];
}
try {
return fn();
} finally {
for (const k of Object.keys(vars)) {
if (prev[k] === undefined) delete process.env[k];
else process.env[k] = prev[k];
}
}
}
const BINDING_ENV_KEYS = {
OMNIROUTE_CODEX_APPSERVER_WS: undefined,
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN: "env-token-hex",
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE: undefined,
} as const;
test("resolveAppServerConfig: refuses env token → remote psd URL (SSRF binding)", () => {
withEnv({ ...BINDING_ENV_KEYS }, () => {
// attacker/lower-priv connection config points the URL at an outside host;
// the env token must NOT be attached → unconfigured (null), feature off.
assert.equal(
resolveAppServerConfig({
codexTransport: "app-server",
codexAppServerUrl: "wss://evil.example.com:8443",
}),
null
);
// dotted hostnames are not local even when they look benign
assert.equal(
resolveAppServerConfig({
codexTransport: "app-server",
codexAppServerUrl: "ws://appserver.evil-corp.io:1456",
}),
null
);
});
});
test("resolveAppServerConfig: env token allowed to operator-local psd URLs", () => {
withEnv({ ...BINDING_ENV_KEYS }, () => {
const localUrls = [
"ws://127.0.0.1:1456",
"ws://localhost:1456",
"ws://[::1]:1456",
"ws://10.0.0.5:1456",
"ws://172.16.3.4:1456",
"ws://192.168.0.15:1456",
"ws://169.254.1.1:1456",
"ws://ts-egress:1456", // single-label LAN/hosts-file name
"ws://codex.local:1456",
"ws://node1.ts.net:1456",
"ws://sidecar.internal:1456",
];
for (const url of localUrls) {
const cfg = resolveAppServerConfig({ codexTransport: "app-server", codexAppServerUrl: url });
assert.ok(cfg, `expected env token to bind to local URL ${url}`);
assert.equal(cfg!.token, "env-token-hex");
}
});
});
test("resolveAppServerConfig: psd-sourced token may pair with any psd URL", () => {
withEnv(
{
OMNIROUTE_CODEX_APPSERVER_WS: undefined,
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN: undefined,
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE: undefined,
},
() => {
const cfg = resolveAppServerConfig({
codexTransport: "app-server",
codexAppServerUrl: "wss://codex.remote.example.com:443",
codexAppServerToken: "psd-token",
});
assert.ok(cfg, "psd token + psd URL is self-consistent, allowed");
assert.equal(cfg!.token, "psd-token");
}
);
});
test("resolveAppServerConfig: env URL + env token pairs regardless of host", () => {
withEnv(
{
OMNIROUTE_CODEX_APPSERVER_WS: "wss://codex-remote.example.com:8443",
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN: "env-token-hex",
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE: undefined,
},
() => {
const cfg = resolveAppServerConfig({ codexTransport: "app-server" });
assert.ok(cfg, "operator's own env pair is self-consistent, allowed");
assert.equal(cfg!.url, "wss://codex-remote.example.com:8443");
}
);
});
// ── Health probe: redirect pinning + binding inheritance ────────────────────
test("testCodexAppServerConnection: readyz probe pins redirects (no token leak via 30x)", async () => {
const { testCodexAppServerConnection } = await import(
"../../src/app/api/providers/[id]/test/codexAppServerHealth.ts"
);
const originalFetch = globalThis.fetch;
const seen: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
seen.push({ url: String(url), init });
return new Response("not ready", { status: 503 });
}) as typeof fetch;
try {
const result = await testCodexAppServerConnection({
provider: "codex-app-server",
providerSpecificData: {
codexAppServerUrl: "ws://127.0.0.1:1456",
codexAppServerToken: "deadbeef",
},
});
assert.ok(result, "app-server provider must take the readyz path");
assert.equal(result!.valid, false);
assert.equal(seen.length, 1);
assert.equal(seen[0].url, "http://127.0.0.1:1456/readyz");
assert.equal(seen[0].init?.redirect, "manual", "bearer token must never follow a redirect");
} finally {
globalThis.fetch = originalFetch;
}
});
test("testCodexAppServerConnection: env token + remote psd URL reports unconfigured, no network", async () => {
const { testCodexAppServerConnection } = await import(
"../../src/app/api/providers/[id]/test/codexAppServerHealth.ts"
);
const originalFetch = globalThis.fetch;
let fetched = false;
globalThis.fetch = (async () => {
fetched = true;
return new Response("ok", { status: 200 });
}) as typeof fetch;
try {
await withEnv({ ...BINDING_ENV_KEYS }, async () => {
const result = await testCodexAppServerConnection({
provider: "codex-app-server",
providerSpecificData: { codexAppServerUrl: "wss://evil.example.com:8443" },
});
assert.ok(result);
assert.equal(result!.valid, false);
assert.match(String((result!.diagnosis as { code?: string })?.code), /app_server_unconfigured/);
});
assert.equal(fetched, false, "binding refusal must happen before any network call");
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -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$/);
});

View File

@@ -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;
}

View File

@@ -497,7 +497,13 @@ test(
}
);
test("build phase uses an in-memory database without creating sqlite files", serial, async () => {
test("build phase returns the no-op stub without creating sqlite files", serial, async () => {
// Contract changed by #10060 (via #10952): the build phase no longer opens a
// real in-memory SQLite with migrations — loading the native better-sqlite3
// addon aborts the Next.js build worker on exit (node::
// RemoveEnvironmentCleanupHook). getDbInstance() now returns a no-op stub
// (pinned by tests/unit/build/10060-build-sqlite-stub.test.ts); queries are
// harmless no-ops and no file is touched.
const dataDir = makeTempDir("omniroute-db-build-");
try {
@@ -510,13 +516,15 @@ test("build phase uses an in-memory database without creating sqlite files", ser
const core = await importFresh("src/lib/db/core.ts");
const db = core.getDbInstance();
assert.ok(
assert.notEqual(db.driver, "better-sqlite3");
assert.equal(
db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("provider_connections")
.get("provider_connections"),
undefined,
"the build stub must answer queries with no-ops, never a real table scan"
);
assert.equal(fs.existsSync(path.join(dataDir, "storage.sqlite")), false);
assert.equal(db.pragma("journal_mode", { simple: true }), "memory");
core.resetDbInstance();
}

View File

@@ -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", () => {

View File

@@ -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"

View File

@@ -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");
});

View File

@@ -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<string>(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 () => {

View File

@@ -98,7 +98,11 @@ test("single-target Codex combo advertises a larger model context override", asy
assert.equal(response.status, 200);
assert.equal(direct?.context_length, contextWindow);
assert.equal(combo?.context_length, contextWindow);
assert.equal(combo?.max_input_tokens, 272000);
// #11179 raised the static codex catalog cap to maxInputTokens=872000 (the real
// usable window; the old 272000 was just the first pricing tier). The input cap
// can never exceed the total window, so with the 500K override it clamps to it:
// min(872000, 500000) = 500000.
assert.equal(combo?.max_input_tokens, 500000);
} finally {
contextOverrides.removeModelContextOverride("codex", modelId);
}

View File

@@ -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/<model>') 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);
});

View File

@@ -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");

View File

@@ -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);

View File

@@ -181,10 +181,11 @@ test("provider models route merges live Codex models with the local catalog then
// merge conservatively — the smaller of live vs. pinned wins, never the
// larger, so a stale/inflated live number can never make OmniRoute promise
// more context than the account can actually serve (#7012). Here the pinned
// GPT-5.6 Codex contract (272000/128000, see GPT_5_6_CODEX_CAPABILITIES)
// GPT-5.6 Codex contract (872000/128000, see GPT_5_6_CODEX_CAPABILITIES — raised
// from the old 272K pricing tier to the real usable window by #11179)
// is smaller than the live payload's 999999/999999, so the pinned value wins.
assert.equal(liveModel?.name, "GPT 5.6 Sol Live");
assert.equal(liveModel?.inputTokenLimit, 272000);
assert.equal(liveModel?.inputTokenLimit, 872000);
assert.equal(liveModel?.outputTokenLimit, 128000);
assert.equal(liveModel?.apiFormat, "responses");
assert.deepEqual(liveModel?.supportedEndpoints, ["responses"]);

View File

@@ -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 = {

View File

@@ -420,25 +420,24 @@ test("v1 search POST preserves stored SearXNG baseUrl for authless providers", a
}
});
test("v1 search POST returns 400 when auto-select finds no configured provider (searxng-search is now fallbackOnly)", async () => {
test("v1 search POST falls back to duckduckgo-free when no provider is configured (#11097)", async () => {
// Contract changed by PR #11097 ("fix(search): fall back to duckduckgo-free when
// no search provider is configured"): zero-credential /v1/search no longer returns
// 400 — it promotes the fallback-only duckduckgo-free provider so out-of-the-box
// search works. This test pins the NEW contract.
const originalFetch = globalThis.fetch;
let capturedUrl = "";
// DuckDuckGo lite HTML shape: result link + snippet cell (see
// open-sse/services/freeWebSearch.ts parseDuckDuckGoLite).
const liteHtml = `<html><body>
<a href="https://example.com/auto-result" class='result-link'>Auto-selected DuckDuckGo result</a>
<td class='result-snippet'>Fallback free search snippet</td>
</body></html>`;
globalThis.fetch = async (url) => {
capturedUrl = String(url);
return new Response(
JSON.stringify({
results: [
{
title: "Auto-selected SearXNG result",
url: "https://searx.example/auto",
content: "Auto-selected self-hosted response",
engines: ["duckduckgo"],
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
return new Response(liteHtml, { status: 200, headers: { "content-type": "text/html" } });
};
try {
@@ -454,14 +453,15 @@ test("v1 search POST returns 400 when auto-select finds no configured provider (
);
const body = (await response.json()) as any;
assert.equal(response.status, 400);
assert.equal(capturedUrl, "", "fallback-only SearXNG must not receive an upstream request");
assert.ok(body.error?.message || body.error);
assert.match(
String(body.error?.message ?? body.error),
/provider|configured/i,
"the response must explain that no provider was selected"
assert.equal(response.status, 200);
assert.equal(
capturedUrl,
"https://lite.duckduckgo.com/lite/",
"the fallback must call the DuckDuckGo lite endpoint"
);
assert.equal(body.provider, "duckduckgo-free");
assert.equal(body.results[0].title, "Auto-selected DuckDuckGo result");
assert.equal(body.results[0].url, "https://example.com/auto-result");
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -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",

View File

@@ -128,9 +128,11 @@ test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", asyn
assert.equal(typeof defaultModel.created, "number");
assert.equal(defaultModel.owned_by, "codex");
assert.equal(defaultModel.name, "Codex GPT 5.6 Sol");
assert.equal(defaultModel.context_length, 272000);
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
// not the old 272K pricing tier.
assert.equal(defaultModel.context_length, 872000);
assert.equal(defaultModel.max_output_tokens, 128000);
assert.equal(defaultModel.max_input_tokens, 272000);
assert.equal(defaultModel.max_input_tokens, 872000);
assert.deepEqual(defaultModel.capabilities, {
vision: true,
tool_calling: true,

View File

@@ -255,7 +255,9 @@ test("vscode combos route resolves combo names through Ollama api/show", async (
assert.equal(body.model, "show-combo");
assert.equal(body.modelfile, "FROM show-combo");
assert.equal(body.details.family, "show-combo");
assert.equal(body.model_info.context_length, 272000);
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
// not the old 272K pricing tier.
assert.equal(body.model_info.context_length, 872000);
assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
assert.equal(body.model_info.capabilities.reasoning, true);
});
@@ -290,7 +292,8 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy
assert.equal(response.status, 200);
assert.ok(combo, "expected balanced-load in combo root response");
assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true);
assert.equal(combo.maxInputTokens, 272000);
// #11179: codex static catalog maxInputTokens is now the usable 872K window.
assert.equal(combo.maxInputTokens, 872000);
assert.equal(combo.toolCalling, true);
assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
});
@@ -1073,7 +1076,9 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata
assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "low");
assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)");
assert.equal(body.model_info["general.architecture"], "codex");
assert.equal(body.model_info["codex.context_length"], 272000);
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
// not the old 272K pricing tier.
assert.equal(body.model_info["codex.context_length"], 872000);
assert.deepEqual(body.model_info.supports_reasoning_effort, [
"low",
"medium",