mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 23:52:18 +03:00
Compare commits
8 Commits
fix/codex-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df1781065 | ||
|
|
4f9038f470 | ||
|
|
17f5e4e0e9 | ||
|
|
10276821cd | ||
|
|
bdf63d2171 | ||
|
|
950855e168 | ||
|
|
5c03cfeedc | ||
|
|
c2df757610 |
14
.env.example
14
.env.example
@@ -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
11
.gitattributes
vendored
Normal 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
|
||||
19
Dockerfile
19
Dockerfile
@@ -59,6 +59,12 @@ RUN set -eux; \
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
|
||||
# No telemetry, anywhere. Disable Next.js's anonymous build-time telemetry
|
||||
# (it otherwise pings Vercel during `next build`). Set on the builder stage so
|
||||
# every image build is silent; the runtime never builds, so this covers the
|
||||
# only phase Next telemetry can fire.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build tools for native module compilation
|
||||
# apt-get update needed here because base's rm -rf clears the shared cache
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -166,9 +172,20 @@ ENV OMNIROUTE_MITM_STUB=1
|
||||
# child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env).
|
||||
# Build-only; the runtime heap is set separately on the runner stage
|
||||
# (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
# Default raised 4096 → 6144 (#10060): the Next 16 production pass on a codebase
|
||||
# this size intermittently OOMs a build worker at 4 GB on memory-tight hosts.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=6144
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
# Cap Next.js build worker pools. Next 16 defaults to `os.cpus().length - 1`
|
||||
# workers for page-data collection (31 on a 32-core builder); on memory-tight
|
||||
# hosts 31 workers + webpack's multi-GB heap blow past RAM and a worker dies
|
||||
# with SIGSEGV at teardown ("worker exited with code: null and signal: SIGSEGV"),
|
||||
# silently leaving no standalone bundle. Next derives the default worker count
|
||||
# from CIRCLE_NODE_TOTAL (workers = N-1), so N=8 → 7 workers: fast enough while
|
||||
# fitting comfortably in RAM on any host. (#10060)
|
||||
ENV CIRCLE_NODE_TOTAL=8
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data \
|
||||
|
||||
@@ -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.
|
||||
1
changelog.d/fixes/codex-appserver-hardening.md
Normal file
1
changelog.d/fixes/codex-appserver-hardening.md
Normal 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.
|
||||
1
changelog.d/fixes/secret-leak-error-surface-hardening.md
Normal file
1
changelog.d/fixes/secret-leak-error-surface-hardening.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
26
src/lib/buildPhase.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
src/lib/db/better-sqlite3.stub.js
Normal file
31
src/lib/db/better-sqlite3.stub.js
Normal 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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
69
tests/unit/build/10060-build-sqlite-stub.test.ts
Normal file
69
tests/unit/build/10060-build-sqlite-stub.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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}['"]`),
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
71
tests/unit/copilot-claude-always-v1-messages.test.ts
Normal file
71
tests/unit/copilot-claude-always-v1-messages.test.ts
Normal 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$/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
77
tests/unit/github-copilot-discovery-token.test.ts
Normal file
77
tests/unit/github-copilot-discovery-token.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
98
tests/unit/noauth-sibling-compat-override-7620.test.ts
Normal file
98
tests/unit/noauth-sibling-compat-override-7620.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user