Compare commits

..

6 Commits

Author SHA1 Message Date
diegosouzapw
2505a5b5c9 fix(dashboard): read the combos usage-guide dismissal from an external store (base-red #12581)
`release/v3.8.51` is red on `ESLint errors: 1 error(s)`:

  src/app/(dashboard)/dashboard/combos/page.tsx:774
  error react-hooks/set-state-in-effect — Calling setState synchronously
  within an effect can trigger cascading renders

The pattern was deliberate and the comment above it explains why: the
dismissal lives in localStorage, SSR cannot read it, and a lazy useState
initializer would hydrate with a mismatch. The effect fixed the mismatch
at the cost of an extra commit of the whole page tree on every load —
which is exactly what the rule (new in eslint-plugin-react-hooks 7.1.1,
the version this branch pins) now rejects.

`useSyncExternalStore` is the sanctioned shape for this: getServerSnapshot
supplies the SSR-safe default, getSnapshot reads localStorage after
hydration, and the two persistence handlers notify subscribers instead of
setting state. Subscribing to `storage` keeps other tabs in sync for free.

Behavior is preserved exactly, including the distinction between the two
dismissals: "hide forever" persists, while plain "hide" stays per-mount
and is kept as local state rather than folded into the store.

Validated: the rule reproduces locally with the pinned 7.1.1 plugin
(1 error) and is clean after the change; `typecheck:core` 0 errors.
Note `tests/unit/ui/combos-page-smoke.test.tsx` is quarantined in
vitest.config.ts — run under a non-excluded name it times out at 5000ms
importing the module, identically on the unmodified base file, so that
failure is pre-existing and unrelated.
2026-09-03 23:24:15 -03:00
Diego Rodrigues de Sa e Souza
2265ce761f fix(security): harden public error boundaries (#12506)
Validado sobre o tip de `release/v3.8.51` depois de reconciliar com o #12620, que entrou primeiro nesta mesma sessão e ataca a mesma classe de problema por outra arquitetura.

**A colisão e como foi resolvida.** O #12620 consertou o GHSA-qv45-56jc-4wmj adicionando `RAW_CREDENTIAL_PATTERNS` a `error.ts` e importando-os em `upstreamErrorPassthrough.ts`. Este PR resolve o mesmo problema quebrando `error.ts` em `errorSanitization.ts` + `errorPathRedaction.ts`. Mantive a divisão em módulos deste PR, porque ao comparar os dois vocabulários o dele já era mais amplo: o `STRONG_CREDENTIAL_TOKEN` daqui cobre `sk-`/`sk_` **com lookbehind e uma variante para a forma embutida** (que pega `sk-proj-…`), mais Slack `xox-`, AWS `AKIA`/`ASIA`, `github_pat_`/`ghp_`/`glpat-` e JWT de três segmentos.

A única forma que o #12620 carregava e este conjunto não tinha era a chave do Google (`AIza…`) — adicionada aqui, com o mesmo quantificador limitado que os irmãos usam (AGENTS.md → PII §1, já que isso roda sobre corpos upstream não confiáveis).

**A verificação não foi por inspeção.** Rodei as suítes do próprio #12620 contra esta estrutura: **48/48** em `error-sanitizer-sk-key-qv45`, `bifrost-relay-response-leak-9m72`, `search-baseurl-client-override-3f8g` e `search-baseurl-ssrf-guard` — incluindo a asserção anti-drift daquela suíte, que é o oráculo certo aqui: *para todo corpo que a camada de passthrough recusa como vazante, o sanitizador de fallback não pode devolvê-lo intacto*. Ela passa, então a propriedade de segurança dos três GHSAs sobrevive à troca de arquitetura.

Os 21 arquivos de teste deste PR: **259/259**. `typecheck:core` limpo.
2026-09-03 21:31:13 -03:00
Bob.Hou
109cf0f26c fix(providers): reclassify Cerebras as a one-time $5 signup credit (#12591)
Validado sobre o tip de release/v3.8.51 — e o PR ficou completo depois que o autor adicionou a tabela de preços.

O que fazia o teste falhar antes não era a lista free (que o PR já tinha corrigido em `LEGACY_FREE_PROVIDERS` e `tierDefaults.json`), e sim que `classifyTier` cai no ramo cost-based e todos os modelos Cerebras estavam declarados com `input: 0, output: 0` — $0/M ≤ threshold devolve 'free' de qualquer jeito. A tabela de preços resolve isso na raiz.

Confirmei o `gpt-oss-120b` a $0,35/$0,75 de forma independente contra a fonte pública, o que corrobora o resto da tabela. **4/4** no teste-guarda e **25/25** somando free-tier-catalog e free-models; typecheck:core limpo. Obrigado, @HouMinXi.
2026-09-03 21:30:32 -03:00
Diego Rodrigues de Sa e Souza
5ba4247670 chore(quality): rebaseline file-size caps the error-boundary campaign grew past (#12654)
Rebaseline medido no tip com os 14 PRs da campanha mergeados. A anotação registra que 4 das 6 linhas do codex.ts são drift anterior à campanha, não crescimento dela. Não toca stream.ts.
2026-09-03 21:17:17 -03:00
Diego Rodrigues de Sa e Souza
350ac8c12d fix(sse): preserve 1min.ai partial output before stream errors (#12466)
Validado após reconciliar com o #12465, que entrou primeiro e criou o mesmo arquivo novo `open-sse/utils/streamReadiness.ts` com desenho divergente de cancelamento.

Mantive a versão desta branch, que defere o release do lock para quando a leitura em voo termina e faz `reader.cancel()` fire-and-forget — assim uma promise de provider que nunca resolve não torna o cancelamento ilimitado. A escolha não foi por preferência: rodei as suítes dos **dois** PRs contra ela, 21/21 no readiness compartilhado e **22/22** incluindo o boundary do Perplexity do próprio #12465. typecheck:core limpo.
2026-09-03 21:06:28 -03:00
Diego Rodrigues de Sa e Souza
7ae8bf4e05 fix(db): harden migration recovery snapshots (#12435)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 21:01:09 -03:00
127 changed files with 10764 additions and 1695 deletions

View File

@@ -55,10 +55,11 @@ INITIAL_PASSWORD=CHANGEME
# loader (bin/cli/plugins.mjs) at a package tree — this one drives the server-side scanner.
# OMNIROUTE_PLUGINS_DIR=/opt/omniroute/plugins
# Escape hatch for the test-context DATA_DIR guard (#10428). A test run that never
# chose a DATA_DIR is redirected to a throwaway temp dir so it cannot open the
# operator's real database. Set to 1 only for a deliberate run against the real
# DATA_DIR — never for CI. Used by: src/lib/dataPaths.ts
# Escape hatch for the test/eval DATA_DIR guard (#10428). A test or node eval/print
# probe (-e/--eval/-p/--print, including --eval=/--print=) that never chose a DATA_DIR
# is redirected to a throwaway temp dir so it cannot open the operator's real database.
# Set to 1 only for a deliberate run against the real DATA_DIR — never for CI.
# Used by: src/lib/dataPaths.ts
# OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1
# Build provenance (#10427). OMNIROUTE_BUILD_SHA lets a container inject the artifact's git
@@ -96,9 +97,11 @@ STORAGE_ENCRYPTION_KEY=
# Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY.
STORAGE_ENCRYPTION_KEY_VERSION=v1
# Automatic SQLite backup on startup.
# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations.
# Default: false (backups enabled) | Set true to skip backup on every restart.
# Routine/pre-write SQLite backups.
# Used by: src/lib/db/backup.ts. Set true only when those backups are managed externally.
# This never disables the migration runner's mandatory, content-addressed safety snapshot
# or its mass-migration guard for an existing persistent database.
# Default: false (routine backups enabled).
DISABLE_SQLITE_AUTO_BACKUP=false
# ── Redis (Rate Limiting) ──

View File

@@ -7,19 +7,19 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.48B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
<div align="center">
## 💰 ~1.51B Free Tokens / Month
## 💰 ~1.48B Free Tokens / Month
</div>
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **437 free-tier entries across 38 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **437 free-tier entries across 37 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 38 documented recurring pool keys covering 437 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.48B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 37 documented recurring pool keys covering 437 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
>
@@ -209,7 +209,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<br/>
<br/>
@@ -518,9 +518,9 @@ Pix copia-e-cola:
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.51B tokens/month** from the documented,
The main free-tier headline remains **~1.48B tokens/month** from the documented,
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.13B**. Radar is an optional, signed catalog overlay for people who want fresher
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher
free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free.
@@ -648,7 +648,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
</div>
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **437 per-model rows**, **38 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **437 per-model rows**, **37 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
<div align="center">
@@ -1307,7 +1307,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 38 documented recurring pools / 437 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 37 documented recurring pools / 437 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
</table>

View File

@@ -0,0 +1 @@
- **fix(security):** Sanitize provider and runtime failures before public API, SSE and MCP responses and before persistent request, proxy and usage logs, preventing credentials, stack traces and host filesystem paths from crossing those boundaries while preserving stable error codes and useful diagnostics.

View File

@@ -0,0 +1 @@
- **fix(providers):** reclassify Cerebras as a one-time $5 signup credit (payment method required, 30-day validity), not a recurring no-card 1M tokens/day trial ([#11773](https://github.com/diegosouzapw/OmniRoute/issues/11773))

View File

@@ -0,0 +1 @@
- Harden SQLite upgrades around the historical migration-074 version collision: missing discovery and inspector tables are replayed atomically, pre-existing databases (including setup-created skeletons) receive reusable content-addressed safety snapshots, and Node test/eval probes without `DATA_DIR` are isolated from the operator database.

View File

@@ -0,0 +1 @@
- **fix(providers):** keep 1min.ai HTTP 200 stream errors out of assistant content, preserve partial output, and expose sanitized terminal errors so pre-content failures can fall back.

View File

@@ -0,0 +1 @@
- **fix(dashboard):** The Combos page usage guide now reads its dismissal through `useSyncExternalStore` instead of correcting SSR state inside an effect, removing an extra commit of the page tree on every load (and the `react-hooks/set-state-in-effect` error it raised).

View File

@@ -0,0 +1 @@
- **chore(quality):** rebaseline the file-size caps the error-boundary campaign grew past (`open-sse/executors/codex.ts`, `open-sse/vendor/codex-chatgpt-web/bridge.ts`, both via [#12444](https://github.com/diegosouzapw/OmniRoute/pull/12444))

View File

@@ -412,7 +412,7 @@
"open-sse/executors/antigravity.ts": 1665,
"open-sse/executors/base.ts": 1751,
"open-sse/executors/chatgpt-web.ts": 5056,
"open-sse/executors/codex.ts": 1499,
"open-sse/executors/codex.ts": 1505,
"open-sse/executors/cursor.ts": 1759,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 5984,
@@ -428,7 +428,7 @@
"open-sse/utils/proxyFetch.ts": 1271,
"open-sse/utils/stream.ts": 3072,
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5018,
@@ -636,5 +636,6 @@
"_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves.",
"_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base).",
"_rebaseline_2026_09_03_12604_claude_code_2_1_258": "PR #12604 (bump da wire identity do Claude Code 2.1.220->2.1.258, commits do @ggiak vindos do #12402) crescimento proprio: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1606->1607 (+1, a linha do seletor que acompanha a nova versao de identidade). Uma linha num painel de settings ja existente; nao ha o que extrair. Coberto por client-identity-profiles e claude-codex-identity-version-sync (138/138 focados).",
"_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base)."
"_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base).",
"_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente."
}

View File

@@ -34,7 +34,7 @@ inside GitHub's `<img>` sandbox:
| [combo-always-on.svg](./combo-always-on.svg) | style reference | Animated priority-combo fallback (4 layers, 16s loop). Edit the SVG directly — there is no `.mmd` source. |
| [cli-terminal.svg](./cli-terminal.svg) | README.md (root) | Compact half-height animated terminal (1200×350): 3 real CLI commands cycling with typewriter + scrolling subcommand ticker; first frame = completed providers screen. Edit the SVG directly — there is no `.mmd` source. |
| [compression-pipeline.svg](./compression-pipeline.svg) | README.md (root) | Animated 12-engine compression funnel (8s loop). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.51B/mo quantified headline, 20-pool budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.48B/mo quantified headline, 20-pool budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [readme-hero.svg](./readme-hero.svg) | README.md (root) | Animated hero card (tagline, live provider/free-access headline, full-width compression bar demo, 6 stat chips). Edit the SVG directly — there is no `.mmd` source. |
| [promise-pillars.svg](./promise-pillars.svg) | README.md (root) | Animated "The Promise" 6-pillar card (12s border-highlight sweep). Edit the SVG directly — there is no `.mmd` source. |
| [why-pain-fix.svg](./why-pain-fix.svg) | README.md (root) | Animated "Why OmniRoute" 10-row pain-vs-fix ledger (15s green row sweep). Edit the SVG directly — there is no `.mmd` source. |

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.51 billion free tokens per month steady, up to about 2.13 billion in the first month with signup credits. The catalog contains 437 rows, 430 active and 7 discontinued, grouped into 38 recurring pool keys; 20 pools have a published positive monthly token budget and 18 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 13 providers carry a terms-of-service avoid flag. The 20 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, Gemini 60 million, Cerebras 30 million, Cloudflare AI 30 million, API Airforce 24 million, Ollama Cloud 20 million, Groq 15 million, Bluesminds 7.2 million, SambaNova 6 million, Arcee 4.8 million, Navy 4.5 million, BazaarLink 3.6 million, OpenRouter 1.2 million, Cohere 800 thousand, HuggingChat 500 thousand, Morph 400 thousand, Hugging Face 200 thousand, and Kiro 25 thousand. One-time signup credits add about 626 million. Uncapped providers and the OpenRouter top-up boost are shown separately so they do not inflate the headline. Live usage remains available at /dashboard/free-tiers.">
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.51 billion free tokens per month steady, up to about 2.13 billion in the first month with signup credits. The catalog contains 437 rows, 430 active and 7 discontinued, grouped into 37 recurring pool keys; 20 pools have a published positive monthly token budget and 18 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 13 providers carry a terms-of-service avoid flag. The 20 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, Gemini 60 million, Cerebras 30 million, Cloudflare AI 30 million, API Airforce 24 million, Ollama Cloud 20 million, Groq 15 million, Bluesminds 7.2 million, SambaNova 6 million, Arcee 4.8 million, Navy 4.5 million, BazaarLink 3.6 million, OpenRouter 1.2 million, Cohere 800 thousand, HuggingChat 500 thousand, Morph 400 thousand, Hugging Face 200 thousand, and Kiro 25 thousand. One-time signup credits add about 626 million. Uncapped providers and the OpenRouter top-up boost are shown separately so they do not inflate the headline. Live usage remains available at /dashboard/free-tiers.">
<desc>Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately.</desc>
<defs>
<pattern id="gridPaperF" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -61,10 +61,10 @@
<animate attributeName="opacity" values="0;1;1;0" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
</circle>
</g>
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.51B</text>
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.48B</text>
<text x="62" y="266" font-family="Consolas, 'Courier New', monospace" font-size="15" letter-spacing="3" font-weight="700" fill="#a1a1aa">FREE TOKENS / MONTH &#183; <tspan fill="#22c55e">STEADY</tspan></text>
<text x="62" y="298" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16" fill="#F7F6FC">up to <tspan font-weight="800" fill="#22c55e">~2.13B</tspan> in your first month &#8212; signup credits</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">38 recurring pools</tspan> &#183; <tspan fill="#8b5cf6">437 catalog entries</tspan> &#183; one endpoint</text>
<text x="62" y="298" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16" fill="#F7F6FC">up to <tspan font-weight="800" fill="#22c55e">~2.10B</tspan> in your first month &#8212; signup credits</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">37 recurring pools</tspan> &#183; <tspan fill="#8b5cf6">437 catalog entries</tspan> &#183; one endpoint</text>
<!-- ═══ Panel · The honest math ═══ -->
<rect x="680" y="84" width="460" height="216" rx="14" fill="#161b22" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
@@ -75,7 +75,7 @@
</line>
<text x="836" y="156" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">every rate limit &#183; 24/7</text>
<text x="836" y="176" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#ef4444" opacity="0.85">we don't publish that</text>
<text x="704" y="240" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="800" fill="#22c55e">~1.51B</text>
<text x="704" y="240" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="800" fill="#22c55e">~1.48B</text>
<text x="836" y="224" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">each shared free pool</text>
<text x="836" y="244" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#22c55e">counted once &#10003;</text>
<text x="704" y="280" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#f59e0b"><tspan font-weight="800">13 providers</tspan> ToS-flagged <tspan fill="#71717a">&#8212; we flag it &#183; you decide</tspan></text>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint and 356 providers. Six pillars. Resilient fallback: automatic routing continues while another healthy target is available. Save up to 95 percent of eligible tokens: RTK plus Caveman stacked compression averages about 89 percent on tool-heavy sessions. Zero dollars to start: 150+ providers with a free tier and 53 recurring or keyless free-forever providers. Every tool works: 36 CLI and agent integration records, including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity, through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation at /v1. Production controls: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals, and 39,000+ static test declarations across 5,100+ tracked test files.">
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint and 356 providers. Six pillars. Resilient fallback: automatic routing continues while another healthy target is available. Save up to 95 percent of eligible tokens: RTK plus Caveman stacked compression averages about 89 percent on tool-heavy sessions. Zero dollars to start: 150+ providers with a free tier and 52 recurring or keyless free-forever providers. Every tool works: 36 CLI and agent integration records, including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity, through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation at /v1. Production controls: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals, and 39,000+ static test declarations across 5,100+ tracked test files.">
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
<defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -73,7 +73,7 @@
<circle cx="6.6" cy="6.6" r="1.4" fill="#fdcb6e" stroke="none"/>
</g>
<text x="862" y="170" font-size="18" font-weight="800" fill="#fdcb6e">$0 to start</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">150+ providers with a free tier, 53 free</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">150+ providers with a free tier, 52 free</text>
<text x="826" y="226" font-size="13.5" fill="#a1a1aa">forever — Qoder, Pollinations, Cloudflare,</text>
<text x="826" y="248" font-size="13.5" fill="#a1a1aa">SiliconFlow… No card needed.</text>
</g>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 356 AI providers, 150+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 356 AI providers, 150+ free tiers, about 1.48B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<desc>Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame.</desc>
<defs>
<pattern id="gridPaperH" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -72,7 +72,7 @@
<text x="320" y="471" font-size="17" font-weight="800" fill="#7ee787">90+</text>
<text x="320" y="490" font-size="11" fill="#a1a1aa">FREE TIERS</text>
<rect x="420" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#22c55e" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="506" y="471" font-size="17" font-weight="800" fill="#7ee787">~1.51B</text>
<text x="506" y="471" font-size="17" font-weight="800" fill="#7ee787">~1.48B</text>
<text x="506" y="490" font-size="11" fill="#a1a1aa">FREE TOKENS / MO</text>
<rect x="606" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#e17055" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="692" y="471" font-size="17" font-weight="800" fill="#e17055">1595%</text>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -161,8 +161,8 @@ The live, pool-deduplicated catalog currently reports:
| Metric | Current audited value | Interpretation |
| ---------------------------------------------------- | -----------------------------------------------: | ----------------------------------------------------------------------------------------- |
| Recurring quantified grant | **~1.51B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.13B tokens** | Recurring total plus one-time and recurring credits |
| Recurring quantified grant | **~1.48B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.10B tokens** | Recurring total plus one-time and recurring credits |
| Audited free-model inventory | **39 recurring pool keys / 445 catalog entries** | 438 active + 7 discontinued; distinct from the 351-provider catalog |
| Recurring/keyless free-forever providers represented | **55** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types |
| Provider catalog entries marked `hasFree` | **152 / 351** | Broader provider metadata; not all have a quantifiable recurring quota |

View File

@@ -183,7 +183,7 @@ These providers offer **free access** with no credit card:
| **LongCat** | 10M one-time | LongCat-2.0 | API key + KYC |
| **Cloudflare AI** | 10K neurons/day | 50+ models | No auth needed |
| **NVIDIA NIM** | ~40 RPM | 129 models | API key needed |
| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | API key needed |
| **Cerebras** | $5 signup credit | GLM 4.7, GPT-OSS 120B | API key + card |
| **Qoder** | Unlimited | Kimi-K2, DeepSeek-R1, Qwen3-coder | No auth needed |
**Tip**: Connect multiple free providers for **unlimited free AI** with automatic fallback!

View File

@@ -613,7 +613,7 @@ In-process density (compression off the HTTP isolate) is [#11023](https://github
## Important Notes
- **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`.
- **`DISABLE_SQLITE_AUTO_BACKUP`:** Set to `true` if backups are managed externally.
- **`DISABLE_SQLITE_AUTO_BACKUP`:** Set to `true` if routine/pre-write backups are managed externally. Existing-database migrations still require their own durable safety snapshot and mass-migration guard.
- **Data Persistence:** Always mount a volume to `/app/data` to persist your database, keys, and configurations across container restarts.
- **Port Configuration:** Override `PORT` environment variable to change the default `20128` port.

View File

@@ -86,7 +86,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| Variable | Default | Source File | Description |
| -------------------------------------- | -------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | Root directory for SQLite DB, backups, and data files. Override for Docker volumes or custom paths. |
| `OMNIROUTE_ALLOW_DEFAULT_DATA_DIR` | _(unset)_ | `src/lib/dataPaths.ts` | Escape hatch for the test-context DATA_DIR guard (#10428). Test runs with no `DATA_DIR` are redirected to a throwaway temp dir so they cannot open the operator's real database; set to `1` to opt back in to the real directory. |
| `OMNIROUTE_ALLOW_DEFAULT_DATA_DIR` | _(unset)_ | `src/lib/dataPaths.ts` | Escape hatch for the test/eval DATA_DIR guard (#10428). Tests and Node eval/print probes (`-e`/`--eval`/`-p`/`--print`, including `--eval=`/`--print=` forms) with no `DATA_DIR` are redirected to a throwaway temp dir so they cannot open the operator's real database; set to `1` to opt back in to the real directory. |
| `OMNIROUTE_BUILD_SHA` | _(unset)_ | `src/lib/monitoring/buildSha.ts` | Git SHA of the running artifact. Stamped by `npm run build:release`; injectable in containers that ship without the `dist/BUILD_SHA` sentinel. Surfaced as `system.buildSha` on `/api/monitoring/health`. |
| `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). |
@@ -97,7 +97,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_PLUGINS_DIR` | _(unset)_ | `src/lib/plugins/scanner.ts` | Directory the **runtime plugin scanner** reads — and the root the plugin manager installs into — overriding the home-derived default (#11827). Point it at the bind-mounted plugin tree in Docker/K8s instead of moving HOME just to relocate the scan path (HOME governs every other home-relative behaviour too). Unset = `~/.omniroute/plugins`, or `/tmp/.omniroute/plugins` when the process exports no home at all — the silent non-discovery this variable removes. The resolved directory is logged once at startup as `scanner.dir_resolved` with the input that won. Server-side only: CLI command plugins keep their own `OMNIROUTE_PLUGIN_PATH` (section 9). |
| `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. |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips automatic + pre-write SQLite file backups (startup, models.dev pricing save/clear, settings writes). Manual and pre-restore backups still run. Non-manual backups are also **throttled to at most once per 60 minutes** so hourly models.dev sync does not copy the whole DB on every pricing write. Dashboard **Settings → Storage** can disable auto-backup independently. |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips routine/pre-write SQLite file backups (models.dev pricing save/clear, settings writes). Manual and pre-restore backups still run. It does **not** disable the migration runner's mandatory durable safety snapshot or mass-migration guard for an existing persistent DB. Non-manual backups are throttled to at most once per 60 minutes. Dashboard **Settings → Storage** can disable routine auto-backup independently. |
| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. |
| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. |
| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. |
@@ -121,6 +121,16 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `BATCH_BACKOFF_MAX_MS` | `3600000` (1h) | `open-sse/services/batchProcessor.ts` | Cap (ms) for exponential backoff between batch item retries. |
| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | Maximum number of batches processed concurrently. Raise to increase throughput; keep low to avoid rate-limit storms. |
> [!IMPORTANT]
> Before changing an existing persistent database, the migration runner publishes a complete,
> content-addressed snapshot under `DATA_DIR/db_backups/`. Publication requires a filesystem
> that supports same-filesystem, no-overwrite hard links plus durable file sync. POSIX hosts also
> require directory sync; on Windows, Node may reject directory handles, so OmniRoute flushes the
> published file and treats directory-entry sync as best effort.
> If the mounted `DATA_DIR` cannot provide those guarantees, startup fails closed before applying
> a migration. Move `DATA_DIR` to a volume with those primitives; do not use
> `DISABLE_SQLITE_AUTO_BACKUP` to bypass migration safety.
### Scenarios
| Scenario | Configuration |
@@ -1321,8 +1331,8 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. |
| `TAILSCALE_AUTHKEY` | _(unset)_ | `src/lib/tailscaleTunnel.ts` | Pre-shared Tailscale auth key for non-interactive / headless `tailscale up` (passed via `--auth-key=`). When unset, login falls back to the interactive browser auth URL. |
| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. |
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum SQLite backup files retained on disk. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained by manual/scheduled backup cleanup. Migration snapshots are content-addressed and reused for an identical DB state; they are not pruned inside the concurrent migration window. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) retained by manual/scheduled backup cleanup. `0` disables age-based pruning. Migration snapshots are not pruned inside the concurrent migration window. Overrides the value saved from Settings → Database backup retention. |
| `OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS` | `30000` | `src/lib/jobs/backupScheduleJob.ts` | Tick interval (ms) of the server-side job that executes `backup-schedule.json`. Must stay well under the 1-minute cron granularity; values below `5000` or unparseable fall back to `30000`. |
| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` for any Podman topology. Because the container cannot determine whether the engine is local or reached through Podman Machine, the warning stays topology-neutral and points to `contrib/podman/README.md`. |
| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. |

View File

@@ -15,21 +15,23 @@ lastUpdated: 2026-08-31
| Metric | Tokens / month | Meaning |
| ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Documented recurring grant (steady)** | **~1.51B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** |
| **+ first month with signup credits** | **~2.13B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. |
| **Documented recurring grant (steady)** | **~1.48B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** |
| **+ first month with signup credits** | **~2.10B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. |
| **+ permanently free, no published cap** | _un-quantifiable_ | `siliconflow`, `glm-cn` (GLM-4-Flash), `tencent`, `baidu`, `kilo-gateway`, `opencode-zen` — real recurring access, rate/concurrency-limited, **no token cap to count**. Listed, never summed (counting them at `RPM×24/7` is the inflation we reject). |
| **+ deposit-unlock boost** | **+~24M** | A one-time **$10** OpenRouter top-up raises its free pool from 50 → 1000 req/day. Reported separately so it never inflates the steady number. |
| Theoretical ceiling (all rate limits, 24/7) | ~10B | Sum of every provider rate limit extrapolated to non-stop use. **Not a guarantee** — do not headline this. |
**Honest headline:** _OmniRoute aggregates **~1.51B documented free tokens per month** (up to ~2.13B in your first month with signup credits) across 38 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (1595% token savings) stretches that further._
**Honest headline:** _OmniRoute aggregates **~1.48B documented free tokens per month** (up to ~2.10B in your first month with signup credits) across 37 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (1595% token savings) stretches that further._
> **Why this dropped from the previous ~1.94B.** The 2026-06-17 refresh is an honesty correction, not a loss: `gemini` is now pool-deduped (was inflated by counting each Flash variant separately, 462M → 60M), `cloudflare-ai` corrected to its real 10k-Neurons/day (122M → 30M), `doubao` reclassified as a one-time signup credit (not recurring), and shut-down tiers removed (`chutes`/`phind`/`kluster` discontinued). Partly offset by `llm7` (correct 5M/day → 150M) and new free providers (Kilo, OpenCode Zen, Z.AI GLM-Flash).
>
> **Further corrected to ~1.37B in v3.8.42:** `longcat` was reclassified from a 150M/mo recurring grant to a one-time 10M signup credit after its free preview ended. Same honesty rule — no provider was dropped by mistake.
>
> **Updated on 2026-08-26 after retiring Felo Web:** the source now reports 38 recurring pool keys. Felo Web is excluded while its GPL-derived provenance/licensing remains on HOLD. This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`).
> **Corrected to ~1.48B on 2026-09-03 (#11773):** `cerebras` was reclassified from a 30M/mo recurring grant (old no-card 1M tokens/day trial) to a one-time $5 signup credit that requires a payment method. Same honesty rule as LongCat.
>
> **Updated on 2026-08-26 after retiring Felo Web:** the source now reports 37 recurring pool keys. Felo Web is excluded while its GPL-derived provenance/licensing remains on HOLD. This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`).
Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `nara` 150M, `gemini` 60M, `cerebras` 30M, `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.)
Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `nara` 150M, `gemini` 60M, `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.)
> ⚠️ The theoretical ceiling (~10B) is inflated by rate-limit-only providers with **no published token cap** (`tencent`, `siliconflow`, `nvidia`, `baidu`, `glm-cn`, `sparkdesk`) whose figures would be `RPM/TPM × 24/7 × 30d` — a theoretical maximum no single account will sustain. They are **excluded** from the defensible number (shown in the "permanently free, no cap" row instead). This is the same inflation that makes competitors' multi-billion claims unreliable.
@@ -69,7 +71,7 @@ purpose.
## Methodology & caveats
- Numbers are **upper-bound estimates** from each provider's documented free-tier limits as of **2026-06-17**, gathered by web research. Free tiers change constantly — re-verify before relying on a figure.
- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 7 entries carry an independently documented hard stop, and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you".
- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 5 entries carry an independently documented hard stop, and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you".
- `estMonthlyFreeTokens` = recurring monthly tokens only. **One-time signup credits do not recur** and count as 0. Discontinued tiers are also 0.
- Daily token cap → `monthly = daily × 30`. Only RPD documented → `RPD × ~800 output tokens × 30`. Only RPM/TPM (no daily cap) → **uncapped** (see below).
- **Permanently free, but no published token cap** (`siliconflow`, `glm-cn`, `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`): these are real recurring free access, rate/concurrency-limited. We classify them `recurring-uncapped` and **never sum them** — multiplying `RPM × 24/7 × 30d` would produce a fantasy ceiling (the inflation we reject). They are listed so you know they exist.
@@ -193,7 +195,7 @@ purpose.
| `llm7` | recurring | ~150M | — | caution | 4 |
| `longcat` | one-time | — | 10M | caution | 1 |
| `gemini` | recurring | ~60M | — | caution | 4 |
| `cerebras` | recurring | ~30M | | caution | 2 |
| `cerebras` | one-time | — | $5 credit | caution | 2 |
| `cloudflare-ai` | recurring | ~30M | — | caution | 9 |
| `api-airforce` | recurring | ~24M | — | caution | 7 |
| `ollama-cloud` | recurring | ~20M | — | ambiguous | 8 |
@@ -276,7 +278,7 @@ purpose.
- **`bluesminds`** — Our shipped freeNote was "(none)" — but BluesMinds does have a documented free tier: 500 pi credits, 20 RPM, 300 RPD, permanent free plan. The catalog significantly understates the offering.
- **`brave-search`** — The catalog notes "(none)" suggesting no free tier was tracked, but in reality there was a free 5,000 queries/month tier (no card) until February 12, 2026, which has since been replaced by a $5/month…
- **`byteplus`** — Our catalog shipped "(none)" but BytePlus ModelArk does have a free tier: a one-time trial credit of 500k tokens per LLM model for new accounts. The catalog underreports this.
- **`cerebras`** — TPM appears tightened from 60K to 30K on current documented models (gpt-oss-120b, zai-glm-4.7). RPM of 5 is now explicitly documented (was not in our shipped note). Daily token cap of 1M/day is uncha…
- **`cerebras`** — The no-card 1M tokens/day trial is gone. Live cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit, payment method required, 30-day validity. Reclassified as `one-time-initial` (LongCat-shaped); dropped from `LEGACY_FREE_PROVIDERS` and the recurring budget.
- **`chutes`** — The shipped freeNote says "Free tier available" but as of March 15, 2026, the free tier has been officially discontinued. The catalog note is stale and should be updated to reflect that there is no r…
- **`coze`** — The shipped note "Free ByteDance agent platform" is directionally accurate but omits that the free tier is now tightly credit-capped (10 credits/day ≈ 5100 messages depending on model), a constraint…
- **`deepinfra`** — Our shipped freeNote says "Free signup credits for API testing" — this appears stale. The official pricing page now requires card/prepayment with no documented general free signup credit. The free ti…

View File

@@ -151,7 +151,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 |
| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — |
| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks |
| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. |
| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier. |
| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup |
| `chat-oripe` | `chat-oripe` | Chat Oripe | API key, aggregator | [link](https://api.oriper.com) | Official metadata advertises 2M tokens/month, but the public site and documentation were blocked during audit; treat the quota and brand mapping as unconfirmed. |
| `chatanywhere` | `chatanywhere` | ChatAnywhere | API key, aggregator | [link](https://chatanywhere.tech) | Personal, educational or research use only: public documentation cites 10,000 points/day and 200 requests/day per IP/key; do not use for commercial traffic. |

View File

@@ -5,9 +5,9 @@
<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">20 free pools · 446 models · one endpoint</text>
<text x="32" y="84" fill="#7d8590" font-size="11.5">Steady / month</text>
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.51B</text>
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.48B</text>
<text x="330" y="84" fill="#7d8590" font-size="11.5">First month (+ signup credits)</text>
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.13B</text>
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.10B</text>
<text x="700" y="84" fill="#7d8590" font-size="11.5">ToS-flagged (you decide)</text>
<text x="700" y="114" fill="#d29922" font-size="27" font-weight="800">13 providers</text>
<clipPath id="bar"><rect x="32" y="132" width="836" height="16" rx="8"/></clipPath>

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,14 +1,16 @@
---
title: "Error Message Sanitization"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.51
lastUpdated: 2026-09-02
---
# Error Message Sanitization
> **Source of truth:** `open-sse/utils/error.ts` — `sanitizeErrorMessage`, `buildErrorBody`, `createErrorResult`
> **Tests:** `tests/unit/error-message-sanitization.test.ts`
> **Last updated:** 2026-06-28 — v3.8.40
> **Source of truth:** `open-sse/utils/errorSanitization.ts`,
> `open-sse/utils/errorPathRedaction.ts`, and the public builders in `open-sse/utils/error.ts`
> **Tests:** `tests/unit/error-message-sanitization.test.ts`,
> `tests/unit/error-public-boundaries-hardening.test.ts`
> **Last updated:** 2026-09-02 — v3.8.51
> **Audience:** Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers).
> **Status:** **MANDATORY** for every code path that returns an error message to a client.
@@ -20,10 +22,18 @@ CodeQL rule `js/stack-trace-exposure` (CWE-209) flags any code path where an err
- Library / framework versions inferred from stack frames → targeted exploit selection.
- Sensitive runtime values that may be string-interpolated into errors (DB queries, config values).
The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both classes of leakage:
The `sanitizeErrorMessage` helper exported by `open-sse/utils/error.ts` strips these classes of
leakage:
1. Multi-line stack traces — only the first line (the actual error message) is kept.
2. Absolute paths (`/...*.{ts,js,tsx,jsx,mjs,cjs}[:line[:col]]` and `C:\...`) — replaced with `<path>`.
1. Physical, serialized, and unambiguously inline JavaScript stack-frame tails.
2. Absolute POSIX, Windows, UNC, and `file://` filesystem paths, while preserving safe HTTPS URLs
and explicitly marked API routes.
3. Credential assignments, common provider token formats, private-key PEM blocks, and base64 data
URLs.
The sanitizer caps input length and fails closed when a thrown value rejects string coercion.
Recursive upstream JSON sanitization also drops unsafe credential/path keys, session aliases, and
prototype-control keys before a response is serialized.
## The mandatory pattern
@@ -59,7 +69,10 @@ import {
} from "@omniroute/open-sse/utils/error.ts";
```
All of these route through `buildErrorBody` and therefore through `sanitizeErrorMessage`. **You never need to call `sanitizeErrorMessage` manually** when using these helpers.
All of these apply the canonical public-error boundary. `errorResponse`, `writeStreamError`, and
`createErrorResult` route through `buildErrorBody`; the three specialized retry/circuit helpers
project and sanitize their public context directly. **You never need to call
`sanitizeErrorMessage` manually** when using these helpers.
### 2. Custom error envelopes (rare)
@@ -81,17 +94,25 @@ This is the only sanctioned way to assemble a custom error body. See `open-sse/e
### 3. Logging vs. responding
`sanitizeErrorMessage` should **only** wrap the value that crosses the network boundary. Internal logs (`pino`, `console`) should keep the full message, including stack, so operators can debug. Pattern:
Trusted internal exceptions may keep their full message and stack so operators can debug. Values
originating at provider, validation, browser-session, or credential-adjacent boundaries must be
sanitized before they enter console output, audit metadata, or persistent call logs. Pattern:
```ts
try {
// ...
} catch (err) {
log.error({ err }, "handler failed"); // full err with stack — internal log
log.error({ err }, "handler failed"); // trusted internal exception only
return errorResponse(500, getErrorMessage(err)); // sanitized — sent to client
}
```
For provider-controlled failures, project the logged value too:
```ts
log.error({ message: sanitizeErrorMessage(err) || "Provider request failed" });
```
### 4. Forbidden patterns
**Never** put raw exception output in a Response body:
@@ -112,7 +133,9 @@ const safe = String(err).split("\n")[0];
**Never** sanitize in the route and forget the SSE path. Anything that writes to a stream goes through `writeStreamError` (or its underlying `buildErrorBody`).
**Never** include `process.cwd()`, `__filename`, `__dirname`, env-derived paths in error messages — they bypass the path regex and reveal the deployment topology.
**Never** intentionally include `process.cwd()`, `__filename`, `__dirname`, or env-derived paths
in error messages. The sanitizer covers absolute paths as defense in depth, but callers must not
construct topology-bearing messages in the first place.
## Coverage in CI
@@ -129,7 +152,9 @@ When adding a new route or executor, copy the assertion pattern from this file.
## Related controls
- `js/stack-trace-exposure` CodeQL alerts in `.github/security` should always be **either** fixed via these helpers **or** dismissed with a comment citing this doc.
- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles structured log redaction separately. This doc covers only the response-message surface.
- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles trusted structured logs
separately. This document covers public response messages and provider-controlled values that
cross persistent call/proxy-log boundaries.
- Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage — keep both files aligned when adding a new exfiltration concern.
## Upstream details passthrough
@@ -138,27 +163,39 @@ When adding a new route or executor, copy the assertion pattern from this file.
parsed body from the upstream provider). When provided, it is sanitized by
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
An optional fourth argument `classification` (`{ type?: string; code?: string }`)
preserves an explicit error type/code instead of re-deriving both from the
status-code table — used when the caller already classified the failure (e.g.
HTTP 499 → `client_disconnected`).
An optional fourth argument `classification`
(`{ type?: string; code?: string; reason?: string }`) accepts an explicit public classification.
Every field is projected onto the bounded public-identifier vocabulary. Unsafe, credential-shaped,
control-character, or overlong values fall back to the status-derived type/code; an unsafe optional
reason is omitted. Three-digit HTTP status identifiers (`100` through `599`) remain valid for
provider contracts that expose the numeric upstream status as a machine-readable code. The same
bounded range is accepted in the locally generated HTTP-status placeholder form; arbitrary provider
numbers and names remain outside the vocabulary.
Pass every explicit classification in that fourth argument. Never overwrite
`body.error.code`, `body.error.type`, or `body.error.reason` after `buildErrorBody()` returns;
post-builder mutation bypasses the public projection.
Sanitization rules applied to `upstreamDetails`:
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
are removed.
2. Unsafe path, credential, session-alias, and prototype-control keys are removed.
3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`.
4. Arrays are capped at 32 elements.
Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pass
`upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content,
guardrail blocks) do not include `upstream_details`.
Only call sites with a parsed provider error body should pass `upstreamDetails`. Internal OmniRoute
errors (SSE parse failures, empty content, guardrail blocks) must not include it.
Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to
`upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)`
without an upstream body.
Selective upstream 4xx passthrough preserves the provider's safe JSON shape and wording required by
client auto-recovery, but it is not byte-for-byte passthrough: the recursive sanitizer always runs
before serialization. Cyclic, BigInt-bearing, or hostile `toJSON()` bodies fail closed and are not
eligible for passthrough. OCR and moderation apply the same rule; non-JSON, blank, or mislabeled
upstream bodies are converted to the canonical OmniRoute JSON error envelope.
## Known CodeQL limitation: custom sanitizers not recognized
The CodeQL query [`js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) uses a fixed allowlist of sanitizer patterns (e.g. inline `.split("\n")[0]`, `String#replace` with specific regex shapes, access to `.message` on `Error`). It does **not** recognize indirection through a custom helper like our `sanitizeErrorMessage()`.

View File

@@ -106,9 +106,12 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "bytez", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "mistralai/Mistral-7B-Instruct-v0.3", displayName: "mistralai/Mistral-7B-Instruct-v0.3", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
// hardStopGuaranteed: Cerebras pricing page states "Free Trial: 1M tokens/day... no credit card" (open-sse/services/../providers/apikey/inference-hosts.ts:74-84).
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
// #11773: cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit
// gated on a payment method, 30-day expiry — not the old no-card 1M/day
// trial. creditTokens stays 0 because Cerebras publishes dollars, not a
// token grant. hardStopGuaranteed must stay unset: a stored card can bill.
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" },
// #8717: drop dead Workers AI ids (400/403/410). Keep Neurons/day budget on fp8-fast.
{ provider: "cloudflare-ai", modelId: "@cf/mistral/mistral-7b-instruct-v0.2-lora", displayName: "Mistral 7B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
{ provider: "cloudflare-ai", modelId: "@cf/qwen/qwen2.5-coder-32b-instruct", displayName: "Qwen 2.5 Coder 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },

View File

@@ -16,7 +16,6 @@ export const FREE_TIER_BUDGETS: Record<string, number> = {
"cloudflare-ai": 122_000_000,
gemini: 60_000_000,
doubao: 60_000_000,
cerebras: 30_000_000,
"api-airforce": 24_000_000,
"ollama-cloud": 20_000_000,
groq: 15_000_000,

View File

@@ -216,9 +216,10 @@ function makeErrorResponse(
extraHeaders?: Record<string, string>;
}
): Response {
const body = buildErrorBody(status, message, options?.details);
if (options?.type) body.error.type = options.type;
if (options?.code) body.error.code = options.code;
const body = buildErrorBody(status, message, options?.details, {
type: options?.type,
code: options?.code,
});
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (options?.extraHeaders) {
for (const [key, value] of Object.entries(options.extraHeaders)) {

View File

@@ -453,9 +453,10 @@ function makeChunk(
}
function protocolErrorBody(): Record<string, unknown> {
const body = buildErrorBody(502, "Claude Web stream protocol error");
body.error.type = "upstream_protocol_error";
body.error.code = "claude_web_protocol_error";
const body = buildErrorBody(502, "Claude Web stream protocol error", undefined, {
type: "upstream_protocol_error",
code: "claude_web_protocol_error",
});
return body as unknown as Record<string, unknown>;
}

View File

@@ -72,8 +72,7 @@ export class NineRouterExecutor extends BaseExecutor {
* Message goes through buildErrorBody to satisfy hard rule #12 (no raw err.message).
*/
private buildServiceUnavailableResponse(message: string): Response {
const body = buildErrorBody(503, message);
body.error.code = "service_not_running";
const body = buildErrorBody(503, message, undefined, { code: "service_not_running" });
return new Response(JSON.stringify(body), {
status: 503,
headers: {

View File

@@ -16,6 +16,8 @@ type OpenAIMessage = {
};
const CHAT_URL = "https://api.1min.ai/api/chat-with-ai";
const MAX_STREAM_ERROR_DATA_CHARS = 64 * 1024;
const STREAM_ERROR_FALLBACK = "1min.ai upstream stream failed";
const ROLE_LABELS: Record<string, string> = {
system: "System",
developer: "System",
@@ -69,7 +71,35 @@ function buildSseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
function buildOpenAiJsonCompletion(content: string, model: string, id: string, created: number): Response {
function parseStreamErrorMessage(data: string): string {
if (!data || data.length > MAX_STREAM_ERROR_DATA_CHARS) return STREAM_ERROR_FALLBACK;
try {
const parsed = asRecord(JSON.parse(data));
const directMessage = typeof parsed.message === "string" ? parsed.message.trim() : "";
if (directMessage) return directMessage;
if (typeof parsed.error === "string") {
const errorMessage = parsed.error.trim();
if (errorMessage) return errorMessage;
}
const nestedError = asRecord(parsed.error);
const nestedMessage = typeof nestedError.message === "string" ? nestedError.message.trim() : "";
if (nestedMessage) return nestedMessage;
} catch {
// Malformed and over-complex payloads use the fixed public fallback below.
}
return STREAM_ERROR_FALLBACK;
}
function buildOpenAiJsonCompletion(
content: string,
model: string,
id: string,
created: number
): Response {
return new Response(
JSON.stringify({
id,
@@ -84,7 +114,11 @@ function buildOpenAiJsonCompletion(content: string, model: string, id: string, c
);
}
function toOpenAiErrorResponse(status: number, message: string, upstreamDetails?: unknown): Response {
function toOpenAiErrorResponse(
status: number,
message: string,
upstreamDetails?: unknown
): Response {
return new Response(JSON.stringify(buildErrorBody(status, message, upstreamDetails)), {
status,
headers: { "Content-Type": "application/json" },
@@ -96,109 +130,214 @@ function toOpenAiErrorResponse(status: number, message: string, upstreamDetails?
* data: {...}) from the upstream Response body and re-emit them as standard
* OpenAI chat.completion.chunk SSE.
*/
function translateSseStream(upstreamBody: ReadableStream<Uint8Array>, model: string, id: string, created: number): ReadableStream<Uint8Array> {
function translateSseStream(
upstreamBody: ReadableStream<Uint8Array>,
model: string,
id: string,
created: number
): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const reader = upstreamBody.getReader();
const pendingChunks: Uint8Array[] = [];
let buffer = "";
let finished = false;
let roleEmitted = false;
let terminalError: Error | null = null;
let upstreamCancelRequested = false;
let downstreamCancelled = false;
let readInFlight = false;
let readerReleased = false;
const releaseReader = () => {
if (readerReleased) return;
readerReleased = true;
reader.releaseLock();
};
const cancelUpstream = (reason: unknown) => {
if (upstreamCancelRequested) return;
upstreamCancelRequested = true;
try {
// Upstream cleanup is provider-controlled and may never settle. The
// translated stream owns the reader lock and releases it independently.
void reader.cancel(reason).catch(() => {});
} catch {
// Cancellation is cleanup-only; the terminal state is already fixed.
}
};
const queueChunk = (text: string) => {
pendingChunks.push(encoder.encode(text));
};
const emitRole = () => {
if (roleEmitted) return;
roleEmitted = true;
queueChunk(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})
);
};
const finish = () => {
if (finished) return;
finished = true;
queueChunk(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})
);
queueChunk("data: [DONE]\n\n");
};
const emitContent = (text: string) => {
if (!text) return;
emitRole();
queueChunk(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
})
);
};
const emitError = (data: string) => {
if (finished) return;
finished = true;
cancelUpstream("1min.ai upstream stream error");
if (!roleEmitted) {
const message = parseStreamErrorMessage(data);
queueChunk(buildSseChunk(buildErrorBody(502, message)));
queueChunk("data: [DONE]\n\n");
return;
}
// A bare `{ error }` frame is dropped by the OpenAI passthrough sanitizer.
// Preserve every content delta already queued, then error the source with
// a fixed public message. pipeWithDisconnect() converts it into a native
// terminal error frame and drives usage, call-log, and fallback finalizers.
terminalError = Object.assign(new Error(STREAM_ERROR_FALLBACK), {
statusCode: 502,
});
};
// SSE event framing: "event:"/"data:" lines, blank-line separated records.
const processEvent = (eventText: string) => {
let eventType = "message";
const dataLines: string[] = [];
for (const rawLine of eventText.split("\n")) {
if (rawLine.startsWith("event:")) {
eventType = rawLine.slice(6).trim();
} else if (rawLine.startsWith("data:")) {
dataLines.push(rawLine.slice(5).trim());
}
}
const data = dataLines.join("\n");
if (eventType === "content") {
try {
const parsed = asRecord(JSON.parse(data));
if (typeof parsed.content === "string") emitContent(parsed.content);
} catch {
// Ignore malformed content events rather than surfacing partial JSON.
}
} else if (eventType === "error") {
emitError(data);
} else if (eventType === "done") {
finish();
}
// "result" carries the final full aiRecord, redundant with the content
// events already streamed — intentionally ignored.
};
const processBufferedEvents = () => {
let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex !== -1 && !finished) {
processEvent(buffer.slice(0, separatorIndex));
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf("\n\n");
}
};
return new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})
)
);
async pull(controller) {
if (downstreamCancelled) return;
const reader = upstreamBody.getReader();
let buffer = "";
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
const emitContent = (text: string) => {
if (!text) return;
controller.enqueue(
encoder.encode(
buildSseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
})
)
);
};
// SSE event framing: "event:"/"data:" lines, blank-line separated records.
const processEvent = (eventText: string) => {
let eventType = "message";
const dataLines: string[] = [];
for (const rawLine of eventText.split("\n")) {
if (rawLine.startsWith("event:")) {
eventType = rawLine.slice(6).trim();
} else if (rawLine.startsWith("data:")) {
dataLines.push(rawLine.slice(5).trim());
}
}
const data = dataLines.join("\n");
if (eventType === "content") {
try {
const parsed = asRecord(JSON.parse(data));
if (typeof parsed.content === "string") emitContent(parsed.content);
} catch {
// Ignore malformed content events rather than surfacing partial JSON.
}
} else if (eventType === "error") {
emitContent(`\n[1min.ai error: ${data}]`);
finish();
} else if (eventType === "done") {
finish();
}
// "result" carries the final full aiRecord, redundant with the content
// events already streamed — intentionally ignored.
};
try {
while (!finished) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex !== -1) {
processEvent(buffer.slice(0, separatorIndex));
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf("\n\n");
}
}
if (!finished && buffer.trim()) processEvent(buffer);
finish();
} catch (error) {
controller.error(error);
} finally {
reader.releaseLock();
if (pendingChunks.length > 0) {
controller.enqueue(pendingChunks.shift()!);
return;
}
if (terminalError) {
releaseReader();
controller.error(terminalError);
return;
}
if (finished) {
releaseReader();
controller.close();
return;
}
readInFlight = true;
try {
while (pendingChunks.length === 0 && !finished && !downstreamCancelled) {
const { done, value } = await reader.read();
if (downstreamCancelled) return;
if (done) {
buffer += decoder.decode();
if (buffer.trim()) processEvent(buffer);
finish();
break;
}
buffer += decoder.decode(value, { stream: true });
// Process the complete upstream chunk, even after it queues output.
// One network read may contain multiple content events followed by
// an error; the internal queue preserves all of them in order.
processBufferedEvents();
}
if (downstreamCancelled) return;
if (pendingChunks.length > 0) {
controller.enqueue(pendingChunks.shift()!);
} else if (terminalError) {
releaseReader();
controller.error(terminalError);
} else if (finished) {
releaseReader();
controller.close();
}
} catch (error) {
releaseReader();
if (!downstreamCancelled) controller.error(error);
} finally {
readInFlight = false;
if (downstreamCancelled) releaseReader();
}
},
cancel(reason) {
downstreamCancelled = true;
pendingChunks.length = 0;
// A client disconnect must release the upstream reader even when its
// next pull never settles. Do not await provider cleanup here: the
// downstream cancellation contract must remain bounded.
cancelUpstream(reason ?? "1min.ai downstream cancelled");
if (!readInFlight) releaseReader();
},
});
}
@@ -290,7 +429,9 @@ export class OneMinAiExecutor extends BaseExecutor {
const aiRecord = asRecord(json.aiRecord);
const detail = asRecord(aiRecord.aiRecordDetail);
const resultObject = Array.isArray(detail.resultObject) ? detail.resultObject : [];
const content = resultObject.filter((part): part is string => typeof part === "string").join("");
const content = resultObject
.filter((part): part is string => typeof part === "string")
.join("");
return {
response: buildOpenAiJsonCompletion(content, model, id, created),

View File

@@ -5,7 +5,8 @@ import {
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
import { buildFailureUsageRecord, projectFailureUsageErrorCode } from "./chatCore/failureUsage.ts";
import { createTranslationFailureResult } from "./chatCore/translationFailure.ts";
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
import {
extractSystemRoleMessages,
@@ -2513,35 +2514,11 @@ export async function handleChatCore({
: HTTP_STATUS.SERVER_ERROR;
const message = error?.message || "Invalid request";
const errorType = typeof error?.errorType === "string" ? error.errorType : null;
log?.warn?.("TRANSLATE", `Request translation failed: ${message}`);
if (errorType) {
trackPendingRequest(model, provider, connectionId, false);
return {
success: false,
status: statusCode,
error: message,
response: new Response(
JSON.stringify({
error: {
message,
type: errorType,
code: errorType,
},
}),
{
status: statusCode,
headers: {
"Content-Type": "application/json",
},
}
),
};
}
const result = createTranslationFailureResult(statusCode, message, errorType);
log?.warn?.("TRANSLATE", `Request translation failed: ${result.error}`);
trackPendingRequest(model, provider, connectionId, false);
return createErrorResult(statusCode, message);
return result;
}
// The latest OmniGlyph release has protocol-native OpenAI transforms. Run
@@ -3924,10 +3901,14 @@ export async function handleChatCore({
streamController.handleError(error);
return createErrorResult(499, "Request aborted");
}
persistFailureUsage(
failureStatus,
upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error")
);
const persistentErrorCode = projectFailureUsageErrorCode({
statusCode: failureStatus,
message: failureMessage,
errorCode:
upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error"),
errorType: upstreamErrorType,
});
persistFailureUsage(failureStatus, persistentErrorCode);
console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
if (stream && upstreamErrorCode) {
const result = createStreamingErrorResult(
@@ -4253,6 +4234,9 @@ export async function handleChatCore({
`${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})`
);
}
// Classifiers and recovery paths above consume the raw provider wording.
// Project a separate value only at persistent connection-state boundaries.
const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed";
const errorConnectionId = getCurrentConnectionId();
if (errorConnectionId && errorType) {
try {
@@ -4264,7 +4248,7 @@ export async function handleChatCore({
{
testStatus: "banned",
isActive: false,
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4295,7 +4279,7 @@ export async function handleChatCore({
) {
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4308,7 +4292,7 @@ export async function handleChatCore({
{
testStatus: "deactivated",
isActive: false,
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4332,7 +4316,7 @@ export async function handleChatCore({
errorConnectionId,
{
testStatus: "credits_exhausted",
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4418,7 +4402,7 @@ export async function handleChatCore({
rateLimitedUntil: kimiRateLimitResetAt,
backoffLevel: 0,
lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4447,7 +4431,7 @@ export async function handleChatCore({
errorConnectionId,
{
testStatus: "credits_exhausted",
lastError: message,
lastError: persistentMessage,
lastErrorType: errorType,
errorCode: String(statusCode),
},
@@ -4463,14 +4447,14 @@ export async function handleChatCore({
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
} else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) {
// OAuth 401 with invalid credentials - token refresh can recover
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4480,7 +4464,7 @@ export async function handleChatCore({
// Cloud Code 403 with stale project: not a ban, keep account active.
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
console.warn(
@@ -4496,7 +4480,7 @@ export async function handleChatCore({
const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000;
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
// T-PROBE: the 24h exclusion is a routing mutation — a probe must
@@ -4521,7 +4505,7 @@ export async function handleChatCore({
const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000;
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
lastError: persistentMessage,
errorCode: statusCode,
});
try {
@@ -5305,9 +5289,12 @@ export async function handleChatCore({
}).catch(() => {});
const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason);
const malformedMessage = `[${provider}/${model}] ${malformed.message}`;
const malformedClientBody = buildErrorBody(HTTP_STATUS.BAD_GATEWAY, malformedMessage);
malformedClientBody.error.code = malformed.code;
malformedClientBody.error.type = malformed.type;
const malformedClientBody = buildErrorBody(
HTTP_STATUS.BAD_GATEWAY,
malformedMessage,
undefined,
{ code: malformed.code, type: malformed.type }
);
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
tokens: usage,

View File

@@ -8,6 +8,21 @@
* `latencyMs` (Date.now() - startTime) and fires the fire-and-forget saveRequestUsage(...).catch().
*/
import { buildErrorBody } from "../../utils/error.ts";
export function projectFailureUsageErrorCode(opts: {
statusCode: number;
message: string;
errorCode?: string | null;
errorType?: string | null;
}): string {
const errorBody = buildErrorBody(opts.statusCode, opts.message, undefined, {
code: opts.errorCode || undefined,
type: opts.errorType || undefined,
});
return errorBody.error.code || String(opts.statusCode);
}
export function buildFailureUsageRecord(opts: {
provider: string | null | undefined;
model: string | null | undefined;

View File

@@ -25,13 +25,7 @@ export function createStreamingErrorResult(
code?: string,
type?: string
) {
const errorBody = buildErrorBody(statusCode, message);
if (code) {
errorBody.error.code = code;
}
if (type) {
errorBody.error.type = type;
}
const errorBody = buildErrorBody(statusCode, message, undefined, { code, type });
const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`;

View File

@@ -0,0 +1,24 @@
import { buildErrorBody, createErrorResult } from "../../utils/error.ts";
export function createTranslationFailureResult(
status: number,
message: string,
errorType: string | null
) {
if (!errorType) return createErrorResult(status, message);
const body = buildErrorBody(
status,
message,
undefined,
{ type: errorType, code: errorType }
);
return {
success: false as const,
status,
error: body.error.message,
response: new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
};
}

View File

@@ -6,7 +6,8 @@ import { CORS_HEADERS } from "../utils/cors.ts";
*/
import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.ts";
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
@@ -57,14 +58,11 @@ export async function handleModeration({ body, credentials }) {
if (!res.ok) {
const errText = await res.text();
// 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), {
return buildSanitizedUpstreamErrorResponse({
status: res.status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
rawBody: errText,
fallbackMessage: `Moderation provider returned HTTP ${res.status}`,
headers: CORS_HEADERS,
});
}
@@ -79,6 +77,10 @@ export async function handleModeration({ body, credentials }) {
});
return new Response(JSON.stringify(data), { status: 200, headers });
} catch (err) {
return errorResponse(500, `Moderation request failed: ${err.message}`);
const safeDetail =
sanitizeErrorMessage(err)
.replace(/^[A-Za-z]*Error:\s*/, "")
.trim() || "unknown upstream failure";
return errorResponse(500, `Moderation request failed: ${safeDetail}`);
}
}

View File

@@ -11,7 +11,8 @@ import {
parseOcrModel,
OCR_PROVIDERS,
} from "../config/ocrRegistry.ts";
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import {
@@ -151,15 +152,11 @@ export async function handleOcr({
if (!res.ok) {
const errText = await res.text();
// 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), {
return buildSanitizedUpstreamErrorResponse({
status: res.status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
rawBody: errText,
fallbackMessage: `OCR provider returned HTTP ${res.status}`,
headers: CORS_HEADERS,
});
}
@@ -184,7 +181,8 @@ export async function handleOcr({
});
return new Response(JSON.stringify(parsed), { status: 200, headers });
} catch (err) {
console.error("[OCR]", err);
const safeErrorMessage = sanitizeErrorMessage(err).trim() || "OCR request failed";
console.error("[OCR]", safeErrorMessage);
return errorResponse(500, "OCR request failed");
}
}

View File

@@ -0,0 +1,13 @@
import { sanitizeErrorMessage } from "../utils/error.ts";
export function toSafeMcpErrorMessage(
value: unknown,
fallback = "MCP tool execution failed"
): string {
try {
const raw = value instanceof Error ? value.message : value;
return sanitizeErrorMessage(raw) || fallback;
} catch {
return fallback;
}
}

View File

@@ -93,7 +93,7 @@ import {
import { getDbInstance, ensureDbInitialized } from "../../src/lib/db/core.ts";
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { toSafeMcpErrorMessage } from "./errorMessage.ts";
import { mcpFetchTimeoutSignal } from "./fetchTimeout.ts";
import { getMcpModelsCatalog } from "./catalog.ts";
import { registerRadarCatalogTool } from "./radarCatalog.ts";
@@ -328,9 +328,7 @@ async function handleGetHealth() {
.filter(({ settled }) => settled.status === "rejected")
.map(({ source, settled }) => ({
source,
error: sanitizeErrorMessage(
settled.status === "rejected" ? (settled as PromiseRejectedResult).reason : undefined
),
error: toSafeMcpErrorMessage((settled as PromiseRejectedResult).reason, ""),
}));
const result = {
@@ -378,7 +376,7 @@ async function handleGetHealth() {
await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_get_health", {}, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -420,7 +418,7 @@ async function handleListCombos(args: { includeMetrics?: boolean }) {
await logToolCall("omniroute_list_combos", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_list_combos", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -435,7 +433,7 @@ async function handleGetComboMetrics(args: { comboId: string }) {
await logToolCall("omniroute_get_combo_metrics", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_get_combo_metrics", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -451,7 +449,7 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) {
await logToolCall("omniroute_switch_combo", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_switch_combo", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -472,7 +470,7 @@ async function handleCreateCombo(args: {
await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -493,7 +491,7 @@ async function handleCheckQuota(args: { provider?: string; connectionId?: string
await logToolCall("omniroute_check_quota", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_check_quota", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -562,7 +560,7 @@ async function handleRouteRequest(args: {
);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall(
"omniroute_route_request",
{ model: args.model },
@@ -611,7 +609,7 @@ async function handleCostReport(args: { period?: string }) {
await logToolCall("omniroute_cost_report", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_cost_report", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -631,7 +629,7 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_list_models_catalog", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -660,7 +658,7 @@ async function handleWebSearch(args: {
await logToolCall("omniroute_web_search", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_web_search", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -686,7 +684,7 @@ async function handleXSearch(args: {
await logToolCall("omniroute_x_search", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_x_search", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -726,7 +724,7 @@ async function handleWebFetch(args: {
await logToolCall("omniroute_web_fetch", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err);
await logToolCall("omniroute_web_fetch", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
@@ -1182,7 +1180,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Memory tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1209,7 +1207,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Skill tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1234,7 +1232,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Agent skill tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
})
@@ -1259,7 +1257,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "GitHub skill tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1286,7 +1284,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Plugin tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1313,7 +1311,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Compression tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1350,7 +1348,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Pool tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1378,7 +1376,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Gamification tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1405,7 +1403,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Notion tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1432,8 +1430,9 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (error) {
const msg = toSafeMcpErrorMessage(error, "Local corpus tool execution failed");
return {
content: [{ type: "text" as const, text: `Error: ${sanitizeErrorMessage(error)}` }],
content: [{ type: "text" as const, text: `Error: ${msg}` }],
isError: true,
};
}
@@ -1461,7 +1460,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
const result = await toolDef.handler(parsedArgs, extra);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Obsidian tool execution failed");
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
},
@@ -1502,7 +1501,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
],
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = toSafeMcpErrorMessage(err, "Skill execution failed");
return {
content: [{ type: "text" as const, text: `Error: ${msg}` }],
isError: true,

View File

@@ -60,10 +60,10 @@ describe("TierResolver", () => {
expect(result.hasFreeTier).toBe(true);
});
it("classifies Cerebras as free", () => {
it("classifies Cerebras as not free after the no-card trial ended (#11773)", () => {
const result = classifyTier("cerebras", "llama-3.1-70b");
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
expect(result.tier).not.toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(false);
});
it("classifies Groq as free", () => {
@@ -228,7 +228,6 @@ describe("TierResolver", () => {
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq",
]) {
expect(LEGACY_FREE_PROVIDERS.includes(id), `expected ${id} in LEGACY_FREE_PROVIDERS`).toBe(

View File

@@ -52,7 +52,6 @@ export const LEGACY_FREE_PROVIDERS: readonly string[] = [
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq",
];

View File

@@ -19,7 +19,6 @@
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq"
]
}

View File

@@ -5,6 +5,7 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { projectCompletedStreamError } from "../../utils/streamErrorFormat.ts";
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
import { getReadableReasoningValue } from "../../utils/reasoningFields.ts";
@@ -746,6 +747,7 @@ function sendCompleted(state, emit) {
// translator or the OpenAI-Responses translator itself when the upstream
// SSE stream emits a JSON error object after partial content.
const upstreamErr = state.upstreamError;
const publicUpstreamError = projectCompletedStreamError(upstreamErr);
const response: Record<string, unknown> = {
id: state.responseId,
@@ -753,9 +755,7 @@ function sendCompleted(state, emit) {
created_at: state.created,
status: upstreamErr ? "failed" : "completed",
background: false,
error: upstreamErr
? { code: String(upstreamErr.status ?? ""), message: upstreamErr.message ?? "" }
: null,
error: publicUpstreamError,
output,
};

View File

@@ -0,0 +1,79 @@
/** Pure credential signatures shared by guardrails and public error sanitization. */
export interface CredentialPattern {
name: string;
regex: RegExp;
replacement: string;
}
export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
{ name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" },
{ name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" },
{
name: "anthropic",
regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{
name: "anthropic_alt",
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{ name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" },
{ name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" },
{ name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" },
{ name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" },
{ name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" },
{ name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" },
{ name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" },
{ name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" },
{
name: "postman",
regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g,
replacement: "[REDACTED:postman]",
},
{
name: "discord",
regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g,
replacement: "[REDACTED:discord]",
},
{
name: "stripe",
regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g,
replacement: "[REDACTED:stripe]",
},
{
name: "square",
regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g,
replacement: "[REDACTED:square]",
},
{ name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" },
{ name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" },
{
name: "sendgrid",
regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
replacement: "[REDACTED:sendgrid]",
},
{ name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" },
{
name: "private_key",
regex:
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
replacement: "[REDACTED:private_key]",
},
{
name: "jwt",
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
replacement: "[REDACTED:jwt]",
},
{
name: "connection_string",
regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g,
replacement: "[REDACTED:connection_string]",
},
{
name: "auth_header",
regex:
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
replacement: "$1[REDACTED:auth_header]",
},
];

View File

@@ -1,15 +1,18 @@
import { CORS_HEADERS } from "./cors.ts";
import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
import {
redactSensitiveErrorText,
sanitizeErrorMessage,
sanitizeUpstreamDetails,
} from "./errorSanitization.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts";
/**
* Sanitize an error message to prevent stack trace exposure in API responses.
* Strips stack traces, file paths, and absolute Windows/POSIX paths from
* error messages before they reach the client.
*/
export { redactSensitiveErrorText, sanitizeErrorMessage, sanitizeUpstreamDetails };
/** Client-visible error shape; dynamic fields are projected through canonical boundaries. */
interface ErrorResponseBody {
error: {
message: string;
@@ -20,119 +23,6 @@ interface ErrorResponseBody {
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
}
// Length cap protects against pathological inputs even before tokenization.
const MAX_ERROR_LEN = 4096;
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const;
function looksLikeAbsolutePath(tok: string): boolean {
// POSIX: "/<...>.ts" (optionally followed by :line[:col]).
// Windows: "C:\<...>.ts" or "C:/<...>.ts".
if (tok.length < 4 || tok.length > 2048) return false;
const isPosix = tok.charCodeAt(0) === 0x2f; // '/'
const isWindows = tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]);
if (!isPosix && !isWindows) return false;
const dot = tok.lastIndexOf(".");
if (dot <= 0 || dot === tok.length - 1) return false;
const ext = tok
.slice(dot + 1)
.split(":", 1)[0]
.toLowerCase();
return (SOURCE_EXT as readonly string[]).includes(ext);
}
/**
* Raw credential shapes that carry no `key=` label to key off — the token IS the
* whole match, so the only way to redact them is to recognize the shape.
*
* GHSA-qv45-56jc-4wmj: `upstreamErrorPassthrough.ts` already recognized `sk-`
* and refused verbatim passthrough for bodies containing it, then handed those
* bodies to THIS sanitizer — which had no such pattern, so the key came back to
* the caller anyway. The passthrough file's comment claimed to "mirror the
* vocabulary of redactSensitiveErrorText"; the mirror had drifted. It now
* imports this array instead of keeping a second copy, so the two cannot drift
* again.
*
* Quantifiers are upper-bounded (AGENTS.md → PII learnings §1, ReDoS): these run
* over untrusted upstream error bodies.
*/
export const RAW_CREDENTIAL_PATTERNS: ReadonlyArray<RegExp> = [
// OpenAI/Anthropic/Stripe-style secret keys: sk-…, sk-ant-…, sk_live_…
/\bsk[-_][A-Za-z0-9._-]{8,200}/g,
// Google API keys
/\bAIza[A-Za-z0-9_-]{20,200}/g,
// JWTs (three base64url segments)
/\beyJ[A-Za-z0-9_-]{8,400}\.[A-Za-z0-9_-]{8,800}\.[A-Za-z0-9_-]{8,800}/g,
];
export function redactSensitiveErrorText(value: string): string {
let out = value;
for (const pattern of RAW_CREDENTIAL_PATTERNS) {
out = out.replace(pattern, "[REDACTED_CREDENTIAL]");
}
return out
.replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["'])[^"']*(["'])/gi,
"$1[REDACTED]$2"
)
.replace(
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*)[^"',\s}]+/gi,
"$1[REDACTED]"
);
}
/**
* Strip stack-trace tail and absolute source paths from error messages.
*
* Implemented via simple whitespace tokenization (linear time) instead of a
* single complex regex, so CodeQL `js/polynomial-redos` stays clean even when
* the runtime error message is attacker-controlled.
*/
export function sanitizeErrorMessage(message: unknown): string {
let str = typeof message === "string" ? message : String(message ?? "");
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
const nl = str.indexOf("\n");
const firstLine = nl >= 0 ? str.slice(0, nl) : str;
// Preserve original whitespace by splitting on captured separator.
const parts = firstLine.split(/(\s+)/);
for (let i = 0; i < parts.length; i++) {
if (looksLikeAbsolutePath(parts[i])) parts[i] = "<path>";
}
return redactSensitiveErrorText(parts.join(""));
}
const BLOCKED_KEYS =
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie/i;
const MAX_DEPTH = 4;
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths).
* - Keys matching BLOCKED_KEYS are dropped (credential/path guards).
* - Depth capped at MAX_DEPTH to prevent pathological nesting.
* - Arrays capped at 32 elements.
* - Returns null for null/undefined/non-JSON-serializable values.
*/
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") return sanitizeErrorMessage(value);
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (BLOCKED_KEYS.test(k)) continue;
out[k] = sanitizeUpstreamDetails(v, depth + 1);
}
return out;
}
return null;
}
/** Optional caller classification; when set, wins over status-derived defaults. */
export type ErrorBodyClassification = {
type?: string;
@@ -140,6 +30,279 @@ export type ErrorBodyClassification = {
reason?: string;
};
const PUBLIC_ERROR_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const SAFE_PUBLIC_ERROR_IDENTIFIERS = new Set([
"abort",
"aborted",
"account_semaphore_capacity",
"acp_cancelled",
"acp_early_exit",
"acp_error",
"acp_output_too_large",
"acp_session_mismatch",
"acp_timeout",
"admission_aborted",
"admission_deadline",
"admission_lane_evicted",
"admission_oversized",
"admission_queue_full",
"admission_shutdown",
"admission_unavailable",
"all_accounts_inactive",
"all_targets_skipped",
"antigravity_pre_response_timeout",
"api_error",
"authentication_error",
"authentication_required",
"auth_error",
"bad_gateway",
"bad_request",
"bedrock_stream_error",
"billing_error",
"blackbox_auth_required",
"blackbox_rate_limit",
"blackbox_subscription_required",
"body_exceeds_budget",
"browser_stream_inconsistent",
"capability_mismatch",
"cf_mitigated_challenge",
"chat_admission_busy",
"chat_history_too_large",
"chatgpt_web_codex_error",
"chatgpt_web_codex_turn_failed",
"chatgpt_session_expired",
"chatgpt_submission_ambiguous",
"chatgpt_submitted_turn_failed",
"chatgpt_subscription_unavailable",
"client_cancelled",
"client_closed_request",
"client_disconnected",
"cli_not_found",
"cloudflare_challenge",
"cloudflare_or_bot",
"codex_app_server_unconfigured",
"codex_app_server_turn_failed",
"combo_target_timeout",
"combo_timeout",
"compaction_control_unavailable",
"compaction_handoff_failed",
"connector_error",
"connector_not_found",
"connection_error",
"context_length_exceeded",
"context_window",
"chipotle_error",
"devin_agentic_error",
"devin_cli_error",
"devin_desktop_error",
"devin_internal_tool_execution",
"duplicate_tool_use_id",
"direct_response_start_timeout",
"eai_again",
"econnrefused",
"econnreset",
"empty_acp_output",
"empty_content",
"empty_messages",
"empty_response",
"executor_contract_violation",
"error",
"etimedout",
"executor_error",
"feature_disabled",
"gateway_timeout",
"gemini_tpm_exhausted",
"gcp_project_required",
"grok_error",
"insufficient_quota",
"incompatible_reasoning_effort",
"internal_server_error",
"invalid_acp_frame",
"invalid_acp_upstream",
"invalid_api_key",
"invalid_kiro_tool_call",
"invalid_request",
"invalid_request_error",
"invalid_previous_response_binding",
"invalid_tool_arguments",
"invalid_tool_choice",
"invalid_tool_json",
"invalid_tool_name",
"invalid_tools",
"invalid_trailer",
"lease_action_invalid",
"lease_api_key_invalid",
"lease_authentication_required",
"lease_authorization_mismatch",
"lease_capacity_unavailable",
"lease_connection_mismatch",
"lease_content_type_required",
"lease_context_invalid",
"lease_context_required",
"lease_error",
"lease_fence_stale",
"lease_key_configuration_invalid",
"lease_key_policy_invalid",
"lease_model_invalid",
"lease_no_eligible_connection",
"lmarena_error",
"lease_required",
"lease_scope_required",
"lease_service_unavailable",
"lease_eligibility_unavailable",
"lease_unsupported_route",
"lease_unsupported_transport",
"message_limit",
"missing_credits",
"meta_ai_empty_response",
"meta_ai_mode_switch_failed",
"meta_ai_warmup_failed",
"meta_ai_ws_error",
"missing_tool_name",
"missing_tool_use_id",
"mixed_tool_narrative",
"missing_authorization",
"missing_cookie",
"missing_project_id",
"missing_credentials",
"missing_session_id",
"model_not_found",
"model_not_supported",
"model_shutdown",
"multipart_protocol_violation",
"multiple_tool_requests",
"native_codex_pinned_model_unavailable",
"network_error",
"no_free_eligible_connection",
"not_found",
"oauth_missing_project_id",
"orphan_tool_result",
"payload_too_large",
"payment_required",
"permission_error",
"premium_model_requires_key",
"prompt_attachment_integrity",
"provider_error",
"provider_retired",
"provider_unavailable",
"pplx_error",
"proxy_unavailable",
"proxy_family_unavailable",
"proxy_request_failed",
"proxy_unreachable",
"quota_exhausted",
"quota_not_allocated",
"quota_only",
"rate_limit_error",
"rate_limit_execution_timeout",
"rate_limit_exceeded",
"rate_limit_queue_full",
"rate_limit_queue_timeout",
"rate_limit_queue_wedged",
"rate_limit_longer_reached",
"rate_limit_reached",
"rate_limited",
"reached_limit",
"relay_timeout",
"resource_pressure",
"resource_exhausted",
"request_failed",
"risk_session_stale",
"server_error",
"semaphore_queue_full",
"semaphore_timeout",
"service_unavailable",
"service_not_running",
"session_expired",
"session_pool_exhausted",
"spawn_failed",
"stream_error",
"stream_disconnected",
"stream_early_eof",
"stream_idle_timeout",
"stream_pipeline_error",
"stream_readiness_timeout",
"stream_terminated",
"stream_timeout",
"storage_encryption_stale",
"structure_limit",
"structured_output",
"structured_output_validation_failed",
"timeout_error",
"timeout",
"token_limit_exceeded",
"token_required",
"tls_client_unavailable",
"tls_circuit_open",
"tls_fingerprint_failed",
"tls_session_capacity",
"tool_calling_not_supported",
"tools",
"undeclared_historical_tool",
"und_err_body_timeout",
"und_err_connect_timeout",
"und_err_headers_timeout",
"und_err_socket",
"unexpected_acp_response",
"unexecuted_tool_intent",
"unavailable",
"unknown_devin_model",
"unknown_tool",
"unverified_codex_client",
"unsafe_devin_home",
"unsupported_acp_version",
"unsupported_content_block",
"unsupported_control_for_provider",
"unsupported_endpoint",
"unsupported_image_block",
"unsupported_role",
"unsupported_system_block",
"upstream_error",
"upstream_access_denied",
"upstream_auth_error",
"upstream_empty_response",
"upstream_response_failed",
"upstream_response_error",
"upstream_server_error",
"upstream_protocol_error",
"upstream_timeout",
"upstream_websocket_connect_failed",
"upstream_websocket_error",
"usage_limit_reached",
"unsupported_feature",
"unsupported_runtime",
"video_artifact_content_type_invalid",
"video_artifact_download_failed",
"video_artifact_not_ready",
"video_artifact_signature_invalid",
"video_artifact_too_large",
"video_artifact_unavailable",
"video_artifact_url_blocked",
"video_artifact_url_invalid",
"vision",
"claude_web_protocol_error",
"wreq_unavailable",
]);
function isSafePublicErrorIdentifier(value: string): boolean {
if (!PUBLIC_ERROR_IDENTIFIER.test(value)) return false;
if (/^[1-5]\d{2}$/.test(value)) return true;
if (/^HTTP_[1-5]\d{2}$/i.test(value)) return true;
return SAFE_PUBLIC_ERROR_IDENTIFIERS.has(value.toLowerCase());
}
/** Project an internal classification onto the bounded client-visible identifier vocabulary. */
export function projectPublicErrorIdentifier(value: unknown, fallback: unknown): string {
const safeFallback =
fallback === ""
? ""
: typeof fallback === "string" && isSafePublicErrorIdentifier(fallback)
? fallback
: "error";
if (typeof value !== "string") return safeFallback;
return isSafePublicErrorIdentifier(value) ? value : safeFallback;
}
/**
* Build OpenAI-compatible error response body. Message is always sanitized
* so callers do not need to remember to strip stack traces themselves.
@@ -156,13 +319,17 @@ export function buildErrorBody(
): ErrorResponseBody {
const errorInfo = getErrorInfo(statusCode);
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
const safeReason =
typeof classification?.reason === "string" && isSafePublicErrorIdentifier(classification.reason)
? classification.reason
: undefined;
const body: ErrorResponseBody = {
error: {
message: safeMessage,
type: classification?.type ?? errorInfo.type,
code: classification?.code ?? errorInfo.code,
reason: classification?.reason,
type: projectPublicErrorIdentifier(classification?.type, errorInfo.type),
code: projectPublicErrorIdentifier(classification?.code, errorInfo.code),
reason: safeReason,
},
};
@@ -211,7 +378,7 @@ export interface ComboRecoveryHint {
action: ComboRecoveryAction;
/** Seconds the client should wait before retrying. Only meaningful when action="wait". */
retry_after_seconds?: number;
/** Human-readable next step — included verbatim in the error body for non-MCP clients. */
/** Human-readable next step — sanitized and length-capped for non-MCP clients. */
next_step: string;
}
@@ -231,21 +398,36 @@ export interface ComboDiagnostics {
}
function clampDiagStr(v: unknown, max = 128): string {
return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : "";
return typeof v === "string" ? sanitizeErrorMessage(v).slice(0, max) : "";
}
const RECOVERY_ROUTE_PLACEHOLDERS = [
["/dashboard/providers", "OMNIROUTE_SAFE_DASHBOARD_PROVIDERS_ROUTE"],
] as const;
function clampRecoveryStr(value: unknown, max: number): string {
if (typeof value !== "string") return "";
let projected = value;
for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) {
projected = projected.replaceAll(route, placeholder);
}
projected = sanitizeErrorMessage(projected);
for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) {
projected = projected.replaceAll(placeholder, route);
}
return projected.slice(0, max);
}
/**
* HTTP header values must be Latin1/ByteString (undici throws a TypeError
* otherwise — see #6612). Replace any codepoint outside the Latin1 range
* (0-255) with "?" so header construction never throws. Only used for the
* literal header value; the JSON body keeps the original, unsanitized
* readable text via `sanitizeComboDiagnostics`.
* HTTP header values must exclude controls and remain ByteString-compatible
* (undici throws a TypeError otherwise — see #6612). Replace every codepoint
* outside printable ASCII with "?" so header construction never throws.
*/
function toHeaderSafeAscii(v: string): string {
let out = "";
for (let i = 0; i < v.length; i++) {
const code = v.charCodeAt(i);
out += code > 255 ? "?" : v[i];
out += code < 0x20 || code > 0x7e ? "?" : v[i];
}
return out;
}
@@ -270,7 +452,7 @@ export function sanitizeRecoveryHint(
if (!action || !RECOVERY_ACTIONS.has(action)) return undefined;
// Reject empty OR whitespace-only next_step — the value must render usefully as a
// header and as a body field. A whitespace-only string would print as a blank hint.
const next_step = clampDiagStr(r.next_step, 200).trim();
const next_step = clampRecoveryStr(r.next_step, 200).trim();
if (!next_step) return undefined;
const hint: ComboRecoveryHint = { action, next_step };
if (typeof r.retry_after_seconds === "number" && Number.isFinite(r.retry_after_seconds)) {
@@ -321,12 +503,10 @@ export function errorResponseWithComboDiagnostics(
opts: { code?: string; type?: string } = {}
): Response {
const safe = sanitizeComboDiagnostics(diagnostics);
const body = buildErrorBody(statusCode, message) as ErrorResponseBody & {
const body = buildErrorBody(statusCode, message, undefined, opts) as ErrorResponseBody & {
diagnostics?: ComboDiagnostics;
recovery_hint?: ComboRecoveryHint;
};
if (opts.code) body.error.code = opts.code;
if (opts.type) body.error.type = opts.type;
body.diagnostics = safe;
if (safe.recovery) body.recovery_hint = safe.recovery;
const excludedHeader = toHeaderSafeAscii(
@@ -427,6 +607,29 @@ function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null):
return 1;
}
const MAX_PUBLIC_CONTEXT_LABEL_LENGTH = 256;
function projectPublicContextLabel(value: unknown): string | null {
if (typeof value !== "string") return null;
const label = value.trim();
if (
label.length === 0 ||
label.length > MAX_PUBLIC_CONTEXT_LABEL_LENGTH ||
/[\u0000-\u001f\u007f]/.test(label)
) {
return null;
}
return sanitizeErrorMessage(label) === label ? label : null;
}
function projectPublicRetryTimestamp(value: unknown): string | null {
if (typeof value !== "string") return null;
const timestamp = value.trim();
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp)) return null;
const parsed = Date.parse(timestamp);
return Number.isFinite(parsed) && new Date(parsed).toISOString() === timestamp ? timestamp : null;
}
/**
* Parse Antigravity error message to extract retry time
* Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s."
@@ -470,7 +673,7 @@ export function parseAntigravityRetryTime(message: unknown): number | null {
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>}
*/
export async function parseUpstreamError(response: Response, provider: string | null = null) {
let message: unknown = "";
let message = "";
let retryAfterMs: number | null = null;
let responseBody: unknown = null;
let errorCode: unknown = undefined;
@@ -490,9 +693,15 @@ export async function parseUpstreamError(response: Response, provider: string |
// stack) — still routed through sanitizeErrorMessage/buildErrorBody by
// every consumer below (Rule #12).
const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider);
message = clinepassEnvError
const extractedMessage = clinepassEnvError
? clinepassEnvError.message
: json.error?.message || json.message || json.error || text;
: json.error?.message ||
json.message ||
(typeof json.error === "string" ? json.error : null);
message =
typeof extractedMessage === "string"
? extractedMessage
: `Upstream error: ${response.status}`;
errorCode = json.error?.code || json.code;
errorType = json.error?.type || json.type;
} catch {
@@ -503,7 +712,7 @@ export async function parseUpstreamError(response: Response, provider: string |
responseBody = { _rawText: message };
}
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
const messageStr = message;
const retryAfterHeader = response.headers?.get?.("retry-after");
if (retryAfterHeader && !retryAfterMs) {
@@ -573,13 +782,10 @@ export function createErrorResult(
upstreamDetails?: unknown,
opts?: { passthrough?: boolean }
) {
const body = buildErrorBody(statusCode, message, upstreamDetails);
if (errorCode) {
body.error.code = errorCode;
}
if (errorType) {
body.error.type = errorType;
}
const body = buildErrorBody(statusCode, message, upstreamDetails, {
code: errorCode,
type: errorType,
});
const result: {
success: false;
@@ -619,8 +825,8 @@ export function createErrorResult(
result.retryAfterMs = retryAfterMs;
}
// Opt-in relay of the verbatim upstream error body (Claude Code auto-recover
// contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
// Opt-in relay of the recursively sanitized upstream JSON shape (Claude Code
// auto-recover contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
// `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so
// server-side classification (checkFallbackError, combo retry logic, etc.)
// never sees a different value depending on this flag.
@@ -653,7 +859,9 @@ export function unavailableResponse(
retryAfterHuman?: string
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message;
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : "";
const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage;
return new Response(JSON.stringify({ error: { message: msg } }), {
status: statusCode,
headers: {
@@ -668,13 +876,14 @@ export function providerCircuitOpenResponse(
retryAfter?: string | number | Date | null
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
const safeProvider = projectPublicContextLabel(provider) ?? "unknown";
return new Response(
JSON.stringify({
error: {
message: `Provider ${provider} circuit breaker is open`,
message: `Provider ${safeProvider} circuit breaker is open`,
type: "server_error",
code: "provider_circuit_open",
provider,
provider: safeProvider,
retry_after: retryAfterSec,
},
}),
@@ -700,9 +909,10 @@ export function buildModelCooldownBody({
retryAfterAt?: string | null;
credentialsCoolingCount?: number | null;
}): ModelCooldownErrorPayload {
const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
const resolvedRetryAfterAt =
typeof retryAfterAt === "string" && retryAfterAt.length > 0 ? retryAfterAt : null;
const resolvedModel = projectPublicContextLabel(model);
const resolvedRetryAfterAt = projectPublicRetryTimestamp(retryAfterAt);
const resolvedResetSeconds =
Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? Math.max(Math.ceil(retryAfterSec), 1) : 1;
const resolvedCoolingCount =
typeof credentialsCoolingCount === "number" &&
Number.isFinite(credentialsCoolingCount) &&
@@ -718,7 +928,7 @@ export function buildModelCooldownBody({
type: "rate_limit_error",
code: "model_cooldown",
...(resolvedModel ? { model: resolvedModel } : {}),
reset_seconds: Math.max(Math.ceil(retryAfterSec), 1),
reset_seconds: resolvedResetSeconds,
...(resolvedRetryAfterAt ? { retry_after: resolvedRetryAfterAt } : {}),
...(resolvedCoolingCount ? { credentials_cooling: resolvedCoolingCount } : {}),
},

View File

@@ -0,0 +1,905 @@
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"] as const;
const NATIVE_EXT = ["node", "so", "dylib", "dll"] as const;
const LEADING_PATH_PUNCTUATION = "'\"`([{<";
const TRAILING_PATH_PUNCTUATION = "'\"`)]}>.,;:!?";
const PATH_SPAN_END_PUNCTUATION = "'\"`)]}>.,;:!?";
const FILE_URI_PREFIX = "file://";
const HTTP_METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
"CONNECT",
"TRACE",
] as const;
const CLEAR_PROSE_BOUNDARIES = [
"after",
"because",
"before",
"but",
"crashed",
"denied",
"eacces",
"enoent",
"expired",
"failed",
"rejected",
"retry",
"then",
"when",
"while",
] as const;
const POSIX_FILESYSTEM_ROOTS = [
"/Users",
"/app",
"/boot",
"/data",
"/dev",
"/etc",
"/home",
"/media",
"/mnt",
"/nix",
"/opt",
"/private",
"/proc",
"/root",
"/run",
"/srv",
"/sys",
"/tmp",
"/usr",
"/var",
"/workspace",
] as const;
const WINDOWS_ROOT_RELATIVE_ROOTS = new Set([
"program files",
"programdata",
"temp",
"users",
"windows",
]);
function isWindowsAbsolutePathAt(value: string, start: number): boolean {
const remaining = value.length - start;
if (remaining > 2) {
const first = value.charCodeAt(start);
const second = value.charCodeAt(start + 1);
if ((first === 0x5c && second === 0x5c) || (first === 0x2f && second === 0x2f)) {
return true;
}
}
if (remaining < 3 || value.charCodeAt(start + 1) !== 0x3a) return false;
const driveLetter = value.charCodeAt(start);
const isAsciiLetter =
(driveLetter >= 0x41 && driveLetter <= 0x5a) || (driveLetter >= 0x61 && driveLetter <= 0x7a);
return (
isAsciiLetter && (value.charCodeAt(start + 2) === 0x2f || value.charCodeAt(start + 2) === 0x5c)
);
}
function isWindowsAbsolutePath(value: string): boolean {
return isWindowsAbsolutePathAt(value, 0);
}
function isWindowsRootRelativePathAt(value: string, start: number): boolean {
if (
value.charCodeAt(start) !== 0x5c ||
value.charCodeAt(start + 1) === 0x5c ||
isWhitespace(value[start + 1])
) {
return false;
}
const tokenEnd = findTokenEnd(value, start);
let firstSeparator = start + 1;
while (firstSeparator < tokenEnd && value.charCodeAt(firstSeparator) !== 0x5c) {
firstSeparator++;
}
const root = value.slice(start + 1, firstSeparator).toLowerCase();
if (WINDOWS_ROOT_RELATIVE_ROOTS.has(root)) return true;
return (
firstSeparator < tokenEnd - 1 || tokenContainsPathExtensionEvidence(value, start + 1, tokenEnd)
);
}
function hasAbsoluteFileUriAt(value: string, start: number): boolean {
const prefixEnd = start + FILE_URI_PREFIX.length;
return (
value.length > prefixEnd &&
value.slice(start, prefixEnd).toLowerCase() === FILE_URI_PREFIX &&
!isWhitespace(value[prefixEnd])
);
}
function hasAbsoluteFileUri(value: string): boolean {
return hasAbsoluteFileUriAt(value, 0);
}
function isSyntacticallyAbsolutePathAt(value: string, start: number): boolean {
return (
value.charCodeAt(start) === 0x2f ||
isWindowsAbsolutePathAt(value, start) ||
isWindowsRootRelativePathAt(value, start) ||
hasAbsoluteFileUriAt(value, start)
);
}
function isAsciiDigit(code: number): boolean {
return code >= 0x30 && code <= 0x39;
}
function isAsciiLetter(code: number): boolean {
return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
}
function isAsciiAlphaNumeric(code: number): boolean {
return isAsciiDigit(code) || isAsciiLetter(code);
}
function hasHttpUrlSchemeBefore(value: string, slashIndex: number): boolean {
for (const scheme of ["http:", "https:"]) {
const schemeStart = slashIndex - scheme.length;
if (schemeStart < 0 || value.slice(schemeStart, slashIndex).toLowerCase() !== scheme) continue;
if (schemeStart === 0 || !isAsciiAlphaNumeric(value.charCodeAt(schemeStart - 1))) return true;
}
return false;
}
function isWhitespace(value: string): boolean {
return /\s/.test(value);
}
function isRouteContextWord(value: string): boolean {
return value === "Route" || (HTTP_METHODS as readonly string[]).includes(value);
}
function hasRouteContextBefore(value: string, candidateIndex: number): boolean {
let index = candidateIndex - 1;
while (
index >= 0 &&
(isWhitespace(value[index]) ||
value.charCodeAt(index) === 0x28 ||
value.charCodeAt(index) === 0x3a)
) {
index--;
}
const contextEnd = index + 1;
while (index >= 0 && isAsciiAlphaNumeric(value.charCodeAt(index))) index--;
return isRouteContextWord(value.slice(index + 1, contextEnd));
}
function isRouteContextToken(value: string): boolean {
let end = value.length;
while (end > 0 && !isAsciiAlphaNumeric(value.charCodeAt(end - 1))) end--;
let start = end;
while (start > 0 && isAsciiAlphaNumeric(value.charCodeAt(start - 1))) start--;
return isRouteContextWord(value.slice(start, end));
}
function matchesPosixFilesystemRootAt(value: string, start: number, root: string): boolean {
if (!value.startsWith(root, start)) return false;
const rootEnd = start + root.length;
return (
rootEnd === value.length ||
value.charCodeAt(rootEnd) === 0x2f ||
PATH_SPAN_END_PUNCTUATION.includes(value[rootEnd])
);
}
function isKnownPosixFilesystemPathAt(value: string, start: number): boolean {
return POSIX_FILESYSTEM_ROOTS.some((root) => matchesPosixFilesystemRootAt(value, start, root));
}
function isKnownPosixFilesystemPath(value: string): boolean {
return isKnownPosixFilesystemPathAt(value, 0);
}
function looksLikeAbsolutePath(token: string): boolean {
// POSIX: common filesystem roots, with or without a source extension.
// Windows: drive-letter, UNC, or extended-length absolute paths.
// Source-file paths rooted elsewhere remain covered by SOURCE_EXT below.
if (token.length < 4 || token.length > 2048) return false;
const isPosix = token.charCodeAt(0) === 0x2f;
const isWindows = isWindowsAbsolutePath(token) || isWindowsRootRelativePathAt(token, 0);
if (!isPosix && !isWindows) return false;
if (isWindows) return true;
if (isKnownPosixFilesystemPath(token)) return true;
const dot = token.lastIndexOf(".");
if (dot <= 0 || dot === token.length - 1) return false;
const extension = token
.slice(dot + 1)
.split(":", 1)[0]
.toLowerCase();
return (
(SOURCE_EXT as readonly string[]).includes(extension) ||
(NATIVE_EXT as readonly string[]).includes(extension)
);
}
function redactAbsolutePathToken(token: string, followsRouteContext: boolean): string {
let start = 0;
let end = token.length;
while (start < end && LEADING_PATH_PUNCTUATION.includes(token[start])) start++;
while (end > start && TRAILING_PATH_PUNCTUATION.includes(token[end - 1])) end--;
const candidate = token.slice(start, end);
const isFileUri = hasAbsoluteFileUri(candidate);
const pathCandidate = isFileUri ? candidate.slice(FILE_URI_PREFIX.length) : candidate;
if (
!isFileUri &&
!isWindowsAbsolutePath(pathCandidate) &&
!isWindowsRootRelativePathAt(pathCandidate, 0) &&
pathCandidate.charCodeAt(0) === 0x2f &&
followsRouteContext
) {
return token;
}
if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token;
return `${token.slice(0, start)}<path>${token.slice(end)}`;
}
function findPathQuote(value: string, start: number, quote: string, takeFirst: boolean): number {
let candidate = value.indexOf(quote, start);
if (takeFirst || candidate < 0) return candidate < 0 ? value.length : candidate;
while (candidate < value.length) {
const nextQuote = value.indexOf(quote, candidate + 1);
if (nextQuote < 0) return candidate;
// Two separately quoted absolute paths are unambiguous. Close the first
// candidate so the second one is scanned on its own; otherwise keep
// consuming quotes fail-closed because POSIX filenames may contain them.
if (isSyntacticallyAbsolutePathAt(value, nextQuote + 1)) return candidate;
candidate = nextQuote;
}
return value.length;
}
function redactQuotedAbsolutePaths(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const quote = value[index];
if (quote !== "'" && quote !== '"' && quote !== "`") {
index++;
continue;
}
const candidateStart = index + 1;
if (!isSyntacticallyAbsolutePathAt(value, candidateStart)) {
index++;
continue;
}
const isShieldedRoute =
value.charCodeAt(candidateStart) === 0x2f &&
!isWindowsAbsolutePathAt(value, candidateStart) &&
hasRouteContextBefore(value, index);
// Route/API contexts use their first closing quote so a later quoted
// filesystem path is still scanned independently. Filesystem candidates
// take the last matching quote on the line: POSIX filenames may themselves
// contain quote characters, whitespace, and punctuation, so earlier
// matches are ambiguous and must fail closed rather than expose a suffix.
const closingQuote = findPathQuote(value, candidateStart, quote, isShieldedRoute);
if (isShieldedRoute) {
if (closingQuote >= value.length) break;
index = closingQuote + 1;
continue;
}
parts.push(value.slice(copyStart, candidateStart), "<path>");
copyStart = closingQuote;
if (closingQuote >= value.length) break;
index = closingQuote + 1;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function findPathExtensionEnd(value: string, dot: number): number {
let end = dot + 1;
const maxExtensionEnd = Math.min(value.length, end + 16);
while (end < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end))) end++;
if (end === dot + 1 || (end === maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end)))) {
return -1;
}
let hasLetter = false;
for (let index = dot + 1; index < end; index++) {
if (isAsciiLetter(value.charCodeAt(index))) hasLetter = true;
}
if (!hasLetter) return -1;
while (value.charCodeAt(end) === 0x3a) {
let coordinateEnd = end + 1;
if (!isAsciiDigit(value.charCodeAt(coordinateEnd))) break;
while (coordinateEnd < value.length && isAsciiDigit(value.charCodeAt(coordinateEnd))) {
coordinateEnd++;
}
end = coordinateEnd;
}
if (
end === value.length ||
isWhitespace(value[end]) ||
PATH_SPAN_END_PUNCTUATION.includes(value[end])
) {
return end;
}
return -1;
}
function findTokenEnd(value: string, start: number): number {
let end = start;
while (end < value.length && !isWhitespace(value[end])) end++;
return end;
}
function findExtensionEndInToken(value: string, start: number, end: number): number {
let lastExtensionEnd = -1;
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) {
lastExtensionEnd = -1;
continue;
}
if (code !== 0x2e) continue;
const extensionEnd = findPathExtensionEnd(value, index);
if (extensionEnd >= 0 && extensionEnd <= end) lastExtensionEnd = extensionEnd;
}
return lastExtensionEnd;
}
function tokenContainsPathExtensionEvidence(value: string, start: number, end: number): boolean {
for (let dot = start; dot < end; dot++) {
if (value.charCodeAt(dot) !== 0x2e) continue;
let extensionEnd = dot + 1;
const maxExtensionEnd = Math.min(end, extensionEnd + 16);
let hasLetter = false;
while (extensionEnd < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) {
if (isAsciiLetter(value.charCodeAt(extensionEnd))) hasLetter = true;
extensionEnd++;
}
if (
extensionEnd === dot + 1 ||
!hasLetter ||
(extensionEnd === maxExtensionEnd &&
extensionEnd < end &&
isAsciiAlphaNumeric(value.charCodeAt(extensionEnd)))
) {
continue;
}
if (
extensionEnd === end ||
value.charCodeAt(extensionEnd) === 0x2f ||
value.charCodeAt(extensionEnd) === 0x5c ||
PATH_SPAN_END_PUNCTUATION.includes(value[extensionEnd])
) {
return true;
}
}
return false;
}
function tokenContainsPathSeparator(value: string, start: number, end: number): boolean {
for (let index = start; index < end; index++) {
const code = value.charCodeAt(index);
if (code === 0x2f || code === 0x5c) return true;
}
return false;
}
function remainderContainsFilesystemSeparator(value: string, start: number): boolean {
let tokenStart = start;
let previousToken = "";
while (tokenStart < value.length) {
while (tokenStart < value.length && isWhitespace(value[tokenStart])) tokenStart++;
if (tokenStart >= value.length) return false;
const tokenEnd = findTokenEnd(value, tokenStart);
const token = value.slice(tokenStart, tokenEnd).toLowerCase();
const isHttpUrl = token.includes("http://") || token.includes("https://");
let separatorIndex = tokenStart;
while (
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) !== 0x2f &&
value.charCodeAt(separatorIndex) !== 0x5c
) {
separatorIndex++;
}
const precedingSeparatorCode =
separatorIndex > tokenStart ? value.charCodeAt(separatorIndex - 1) : -1;
const contextIndex =
precedingSeparatorCode === 0x27 ||
precedingSeparatorCode === 0x22 ||
precedingSeparatorCode === 0x60
? separatorIndex - 1
: separatorIndex;
const isShieldedRoute =
separatorIndex < tokenEnd &&
value.charCodeAt(separatorIndex) === 0x2f &&
!isWindowsAbsolutePathAt(value, separatorIndex) &&
(isRouteContextToken(previousToken) || hasRouteContextBefore(value, contextIndex));
if (!isHttpUrl && separatorIndex < tokenEnd && !isShieldedRoute) return true;
previousToken = value.slice(tokenStart, tokenEnd);
tokenStart = tokenEnd;
}
return false;
}
function trimPathSpanEnd(value: string, start: number, end: number): number {
while (end > start && PATH_SPAN_END_PUNCTUATION.includes(value[end - 1])) end--;
return end;
}
function isClearProseBoundaryToken(value: string, start: number, end: number): boolean {
while (start < end && LEADING_PATH_PUNCTUATION.includes(value[start])) start++;
end = trimPathSpanEnd(value, start, end);
return (CLEAR_PROSE_BOUNDARIES as readonly string[]).includes(
value.slice(start, end).toLowerCase()
);
}
function findUnquotedPathEnd(
value: string,
start: number,
acceptFirstTokenPunctuation: boolean,
acceptEndpointBeforeAnotherAbsolute: boolean,
failClosedAmbiguity: boolean
): number {
let tokenStart = start;
let isFirstToken = true;
let firstTokenEnd = -1;
let firstTrimmedTokenEnd = -1;
let lastPathTokenEnd = -1;
let resolvedExtensionEnd = -1;
let hasFilesystemEvidence = false;
let hasUnresolvedFragments = false;
const resolveEndpoint = (): number => {
if (hasUnresolvedFragments) {
return failClosedAmbiguity || hasFilesystemEvidence ? value.length : -1;
}
if (resolvedExtensionEnd >= 0) return resolvedExtensionEnd;
if (hasFilesystemEvidence && lastPathTokenEnd >= 0) return lastPathTokenEnd;
if (
acceptFirstTokenPunctuation &&
firstTrimmedTokenEnd >= 0 &&
firstTrimmedTokenEnd < firstTokenEnd
) {
return firstTrimmedTokenEnd;
}
return -1;
};
while (tokenStart < value.length) {
const tokenEnd = findTokenEnd(value, tokenStart);
const extensionEnd = findExtensionEndInToken(value, tokenStart, tokenEnd);
const trimmedTokenEnd = trimPathSpanEnd(value, tokenStart, tokenEnd);
if (isFirstToken) {
firstTokenEnd = tokenEnd;
firstTrimmedTokenEnd = trimmedTokenEnd;
lastPathTokenEnd = trimmedTokenEnd;
// A prose-looking token may itself be a directory name. It is a safe
// boundary only when no later token carries path-separator evidence;
// otherwise keep scanning so a filesystem suffix cannot survive.
} else if (
isClearProseBoundaryToken(value, tokenStart, tokenEnd) &&
(!remainderContainsFilesystemSeparator(value, tokenEnd) ||
(!failClosedAmbiguity && !hasFilesystemEvidence))
) {
return resolveEndpoint();
}
const containsSeparator = tokenContainsPathSeparator(value, tokenStart, tokenEnd);
const containsExtensionEvidence = tokenContainsPathExtensionEvidence(
value,
tokenStart,
tokenEnd
);
if (containsSeparator) {
lastPathTokenEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
resolvedExtensionEnd = extensionEnd >= 0 ? extensionEnd : -1;
if (extensionEnd < 0 && containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
}
} else if (extensionEnd >= 0) {
resolvedExtensionEnd = extensionEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (containsExtensionEvidence) {
resolvedExtensionEnd = trimmedTokenEnd;
hasFilesystemEvidence = true;
hasUnresolvedFragments = false;
} else if (!isFirstToken) {
hasUnresolvedFragments = true;
}
let nextTokenStart = tokenEnd;
while (nextTokenStart < value.length && isWhitespace(value[nextTokenStart])) nextTokenStart++;
if (nextTokenStart >= value.length) return resolveEndpoint();
if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) {
const endpoint = resolveEndpoint();
if (endpoint >= 0) return endpoint;
return acceptEndpointBeforeAnotherAbsolute ? lastPathTokenEnd : -1;
}
tokenStart = nextTokenStart;
isFirstToken = false;
}
return resolveEndpoint();
}
function isUnquotedPosixSpanCandidateAt(value: string, start: number): boolean {
const tokenEnd = findTokenEnd(value, start);
const token = value.slice(start, tokenEnd);
if (isKnownPosixFilesystemPath(token)) return true;
if (
findExtensionEndInToken(value, start, tokenEnd) >= 0 ||
tokenContainsPathExtensionEvidence(value, start, tokenEnd)
) {
return true;
}
let slashCount = 0;
for (let index = start; index < tokenEnd; index++) {
if (value.charCodeAt(index) === 0x2f) slashCount++;
}
// Any boundary-delimited absolute POSIX token is filesystem-sensitive by
// default. Explicit Route/HTTP context is shielded by the caller before this
// candidate check, so `/vault` is redacted while `Route /vault` is retained.
return slashCount >= 1 && token.length > 1;
}
function redactUnquotedAbsolutePathSpans(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const previous = index > 0 ? value[index - 1] : "";
const followsQuote = previous === "'" || previous === '"' || previous === "`";
const hasCommonBoundary =
index === 0 ||
isWhitespace(previous) ||
LEADING_PATH_PUNCTUATION.includes(previous) ||
previous === "=" ||
previous === ":" ||
previous === "," ||
previous === ";" ||
previous === "." ||
previous === ">" ||
previous === "|";
const startsForwardSlashUnc =
value.charCodeAt(index) === 0x2f && value.charCodeAt(index + 1) === 0x2f;
const startsHttpUrl =
startsForwardSlashUnc && previous === ":" && hasHttpUrlSchemeBefore(value, index);
const isWindowsPath =
!followsQuote &&
(isWindowsAbsolutePathAt(value, index) || isWindowsRootRelativePathAt(value, index)) &&
!startsHttpUrl;
const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index);
const isPosixPath =
!followsQuote &&
value.charCodeAt(index) === 0x2f &&
value.charCodeAt(index + 1) !== 0x2f &&
!hasRouteContextBefore(value, index) &&
isUnquotedPosixSpanCandidateAt(value, index);
const hasBoundary = hasCommonBoundary || (isWindowsPath && previous === ":");
if (!hasBoundary || (!isWindowsPath && !isFileUriPath && !isPosixPath)) {
index++;
continue;
}
// Whitespace makes an unquoted path ambiguous. Extend through adjacent
// separator-bearing tokens or to a deterministic filename extension.
// Unequivocal Windows, file-URI, and known-root candidates fail closed;
// arbitrary extensionless POSIX text falls back to token-level handling so
// ordinary `/x/y` route text is not redacted indiscriminately.
const isKnownPosixPath = isKnownPosixFilesystemPathAt(value, index);
const pathEnd = findUnquotedPathEnd(
value,
index,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath,
isWindowsPath || isFileUriPath || isKnownPosixPath
);
if (pathEnd < 0) {
const mustFailClosed = isWindowsPath || isFileUriPath || isKnownPosixPath;
if (mustFailClosed) {
// An unequivocal filesystem prefix with an unknowable endpoint must
// fail closed over the rest of the first line rather than expose a
// suffix such as `Files\\secret` or `My Project`.
parts.push(value.slice(copyStart, index), "<path>");
copyStart = value.length;
index = value.length;
break;
}
index++;
continue;
}
parts.push(value.slice(copyStart, index), "<path>");
copyStart = pathEnd;
index = pathEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function isPhysicalLineSeparator(code: number): boolean {
return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029;
}
function serializedLineSeparatorLengthAt(value: string, start: number): number {
if (value.charCodeAt(start) !== 0x5c) return 0;
const marker = value[start + 1]?.toLowerCase();
if (marker === "n" || marker === "r") return 2;
const unicodeMarker = value.slice(start + 1, start + 6).toLowerCase();
return unicodeMarker === "u000a" ||
unicodeMarker === "u000d" ||
unicodeMarker === "u2028" ||
unicodeMarker === "u2029"
? 6
: 0;
}
function looksLikeRelativeStackLocation(token: string): boolean {
if (token.length < 6 || token.length > 2048) return false;
const lastForwardSlash = token.lastIndexOf("/");
const lastBackslash = token.lastIndexOf("\\");
const lastSeparator = Math.max(lastForwardSlash, lastBackslash);
if (lastSeparator === token.length - 1) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator < 0 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
const queryIndex = token.indexOf("?", lastSeparator + 1);
const fragmentIndex = token.indexOf("#", lastSeparator + 1);
const metadataIndexes = [queryIndex, fragmentIndex].filter(
(index) => index >= 0 && index < lineSeparator
);
const extensionEnd = metadataIndexes.length > 0 ? Math.min(...metadataIndexes) : lineSeparator;
const dot = token.lastIndexOf(".", extensionEnd - 1);
if (dot <= lastSeparator || dot === extensionEnd - 1) return false;
const extension = token.slice(dot + 1, extensionEnd).toLowerCase();
if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false;
return true;
}
function looksLikeUrlStackLocation(token: string): boolean {
if (token.length < 12 || token.length > 2048) return false;
const lower = token.toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
return lineSeparator > 0 && hasNumericLineColumnSuffix(token, lineSeparator);
}
function hasNumericLineColumnSuffix(value: string, separator: number): boolean {
if (value.charCodeAt(separator) !== 0x3a) return false;
let index = separator + 1;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
if (value.charCodeAt(index) !== 0x3a) return false;
index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
return index === value.length;
}
function isNodeModulePathCode(code: number): boolean {
return (
isAsciiAlphaNumeric(code) || code === 0x2e || code === 0x2f || code === 0x5f || code === 0x2d
);
}
function looksLikeNodeStackLocation(token: string): boolean {
if (token.length < 10 || token.length > 2048 || !token.startsWith("node:")) return false;
const columnSeparator = token.lastIndexOf(":");
const lineSeparator = token.lastIndexOf(":", columnSeparator - 1);
if (lineSeparator <= 5 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false;
for (let index = 5; index < lineSeparator; index++) {
if (!isNodeModulePathCode(token.charCodeAt(index))) return false;
}
return true;
}
function looksLikeEvalStackLocation(token: string): boolean {
return token.length <= 64 && token.startsWith("[eval]") && hasNumericLineColumnSuffix(token, 6);
}
function isRecognizedStackPathAt(value: string, start: number): boolean {
if (hasAbsoluteFileUriAt(value, start)) return true;
const tokenEnd = trimPathSpanEnd(value, start, findTokenEnd(value, start));
const token = value.slice(start, tokenEnd);
return (
looksLikeAbsolutePath(token) ||
looksLikeRelativeStackLocation(token) ||
looksLikeUrlStackLocation(token) ||
looksLikeNodeStackLocation(token) ||
looksLikeEvalStackLocation(token)
);
}
function isStackFrameLabel(value: string, start: number, end: number): boolean {
const label = value.slice(start, end).trim();
if (label.length === 0 || label.length > 256) return false;
if (!/^[A-Za-z_$<]/.test(label) || /[^A-Za-z0-9_$.[\]<>:/ -]/.test(label)) return false;
if (!/\s/.test(label)) return true;
return /^(?:async|new)\s+\S+$/.test(label) || /^\S+\s+\[as\s+\S+\]$/.test(label);
}
function skipAsyncStackPrefix(value: string, start: number): number {
if (value.slice(start, start + 5) !== "async" || !isWhitespace(value[start + 5])) return start;
let locationStart = start + 6;
while (locationStart < value.length && isWhitespace(value[locationStart])) locationStart++;
return locationStart;
}
function isAggregateIndexLocationAt(value: string, start: number): boolean {
if (value.slice(start, start + 5) !== "index" || !isWhitespace(value[start + 5])) return false;
let index = start + 6;
while (index < value.length && isWhitespace(value[index])) index++;
if (!isAsciiDigit(value.charCodeAt(index))) return false;
while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++;
while (index < value.length && isWhitespace(value[index])) index++;
return value.charCodeAt(index) === 0x29;
}
function looksLikeStackFrameAt(value: string, atIndex: number, allowDirectPath: boolean): boolean {
if (value.slice(atIndex, atIndex + 2).toLowerCase() !== "at") return false;
let labelStart = atIndex + 2;
if (!isWhitespace(value[labelStart])) return false;
while (labelStart < value.length && isWhitespace(value[labelStart])) labelStart++;
labelStart = skipAsyncStackPrefix(value, labelStart);
if (allowDirectPath && isRecognizedStackPathAt(value, labelStart)) return true;
const openParen = value.indexOf("(", labelStart);
if (openParen < 0 || openParen - labelStart > 256) return false;
let pathStart = openParen + 1;
while (pathStart < value.length && isWhitespace(value[pathStart])) pathStart++;
return (
isStackFrameLabel(value, labelStart, openParen) &&
(isRecognizedStackPathAt(value, pathStart) ||
(allowDirectPath && isAggregateIndexLocationAt(value, pathStart)))
);
}
function looksLikeAtSignStackFrameAt(value: string, frameStart: number): boolean {
const tokenEnd = trimPathSpanEnd(value, frameStart, findTokenEnd(value, frameStart));
const atSign = value.indexOf("@", frameStart);
if (atSign <= frameStart || atSign >= tokenEnd || atSign - frameStart > 256) return false;
return isStackFrameLabel(value, frameStart, atSign) && isRecognizedStackPathAt(value, atSign + 1);
}
function findSerializedStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = serializedLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length) {
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
const adjacentSeparatorLength = serializedLineSeparatorLengthAt(value, frameStart);
if (adjacentSeparatorLength === 0) break;
frameStart += adjacentSeparatorLength;
}
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
let separatorStart = index;
while (separatorStart > 0 && value.charCodeAt(separatorStart - 1) === 0x5c) {
separatorStart--;
}
return separatorStart;
}
}
return -1;
}
function findInlineStackFrameStart(value: string): number {
let marker = value.indexOf(" at ");
while (marker >= 0) {
if (looksLikeStackFrameAt(value, marker + 1, false)) return marker;
marker = value.indexOf(" at ", marker + 4);
}
return -1;
}
function findInlineAtSignStackFrameStart(value: string): number {
let frameStart = 0;
while (frameStart < value.length) {
if (looksLikeAtSignStackFrameAt(value, frameStart)) {
return frameStart > 0 && isWhitespace(value[frameStart - 1]) ? frameStart - 1 : frameStart;
}
const tokenEnd = findTokenEnd(value, frameStart);
frameStart = tokenEnd;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
}
return -1;
}
function physicalLineSeparatorLengthAt(value: string, start: number): number {
const code = value.charCodeAt(start);
if (!isPhysicalLineSeparator(code)) return 0;
return code === 0x0d && value.charCodeAt(start + 1) === 0x0a ? 2 : 1;
}
function findPhysicalStackFrameStart(value: string): number {
for (let index = 0; index < value.length; index++) {
const separatorLength = physicalLineSeparatorLengthAt(value, index);
if (separatorLength === 0) continue;
let frameStart = index + separatorLength;
while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++;
if (
looksLikeStackFrameAt(value, frameStart, true) ||
looksLikeAtSignStackFrameAt(value, frameStart)
) {
return index;
}
index += separatorLength - 1;
}
return -1;
}
/** Strip only recognized physical, serialized, and inline JavaScript stack-frame tails. */
export function stripRecognizedErrorStackTail(value: string): string {
const candidates = [
findPhysicalStackFrameStart(value),
findSerializedStackFrameStart(value),
findInlineStackFrameStart(value),
findInlineAtSignStackFrameStart(value),
].filter((candidate) => candidate >= 0);
if (candidates.length === 0) return value;
return value.slice(0, Math.min(...candidates));
}
/**
* Public exception messages remain fail-closed at the first physical line.
* Provider passthroughs that require multiline capability wording use the
* narrower recognized-frame helper above instead.
*/
export function stripErrorStackTail(value: string): string {
let firstLineEnd = value.length;
for (let index = 0; index < value.length; index++) {
if (isPhysicalLineSeparator(value.charCodeAt(index))) {
firstLineEnd = index;
break;
}
}
return stripRecognizedErrorStackTail(value.slice(0, firstLineEnd));
}
/**
* Redact absolute filesystem paths while preserving URLs, explicitly marked
* API routes, and punctuation around determinable endpoints. Unequivocal
* filesystem prefixes fail closed when an unquoted endpoint is ambiguous.
*/
export function redactErrorPaths(value: string): string {
const quotedPathsRedacted = redactQuotedAbsolutePaths(value);
const pathSpansRedacted = redactUnquotedAbsolutePathSpans(quotedPathsRedacted);
const parts = pathSpansRedacted.split(/(\s+)/);
let previousToken = "";
for (let index = 0; index < parts.length; index++) {
const token = parts[index];
if (isWhitespace(token)) continue;
parts[index] = redactAbsolutePathToken(token, isRouteContextToken(previousToken));
previousToken = token;
}
return parts.join("");
}

View File

@@ -0,0 +1,895 @@
import {
redactErrorPaths,
stripErrorStackTail,
stripRecognizedErrorStackTail,
} from "./errorPathRedaction.ts";
import { CREDENTIAL_PATTERNS } from "./credentialPatterns.ts";
// Length cap protects against pathological inputs even before tokenization.
const MAX_ERROR_LEN = 4096;
const MAX_ERROR_SCAN_HEADROOM = 512;
const MAX_SECURITY_ESCAPE_LAYERS = 3;
const STRONG_CREDENTIAL_TOKEN_SOURCE =
"(?:eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|" +
"github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|" +
"xox[a-z]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|" +
"(?<![A-Za-z0-9])sk[-_][A-Za-z0-9._~+/=-]{8,}|" +
"[A-Za-z0-9]{3,}sk[-_][A-Za-z0-9._~+/=-]{8,})";
const STRONG_CREDENTIAL_TOKEN = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "i");
const STRONG_CREDENTIAL_TOKEN_GLOBAL = new RegExp(STRONG_CREDENTIAL_TOKEN_SOURCE, "gi");
export function containsStrongCredentialToken(value: string): boolean {
return STRONG_CREDENTIAL_TOKEN.test(value);
}
const CREDENTIAL_LABELS = [
["__secure-next-auth.session-token", true],
["arena-auth-prod-v1", true],
["__cf_bm", true],
["_cfuvid", true],
["_puid", true],
["access_token_v2", true],
["token_v2", true],
["tokenv2", true],
["cf_clearance", true],
["credentials", true],
["credential", true],
["session id", true],
["session-id", true],
["session_id", true],
["sessionid", true],
["encryption key", true],
["encryption-key", true],
["encryption_key", true],
["encryptionkey", true],
["private key", true],
["private-key", true],
["private_key", true],
["privatekey", true],
["session key", true],
["session-key", true],
["session_key", true],
["sessionkey", true],
["secret key", true],
["secret-key", true],
["secret_key", true],
["secretkey", true],
["signing key", true],
["signing-key", true],
["signing_key", true],
["signingkey", true],
["refresh token", false],
["refresh-token", false],
["refresh_token", false],
["refreshtoken", false],
["access token", false],
["access-token", false],
["access_token", false],
["accesstoken", false],
["authorization", true],
["sso-rw", true],
["session", true],
["sso", true],
["api key", false],
["api-key", false],
["api_key", false],
["apikey", false],
["password", true],
["cookie", true],
["secret", true],
["token", false],
] as const;
type CredentialAssignment = {
valueStart: number;
failClosed: boolean;
};
function isAsciiAlphaNumericCode(code: number): boolean {
return (
(code >= 0x30 && code <= 0x39) ||
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a)
);
}
function asciiHexValue(code: number): number {
if (code >= 0x30 && code <= 0x39) return code - 0x30;
if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10;
if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10;
return -1;
}
function unicodeEscapeCodeAt(value: string, start: number): number | null {
if (
value.charCodeAt(start) !== 0x5c ||
(value[start + 1] !== "u" && value[start + 1] !== "U") ||
start + 5 >= value.length
) {
return null;
}
let decoded = 0;
for (let digit = start + 2; digit <= start + 5; digit++) {
const nibble = asciiHexValue(value.charCodeAt(digit));
if (nibble < 0) return null;
decoded = decoded * 16 + nibble;
}
return decoded;
}
function isPrintableAscii(code: number | null): code is number {
return code !== null && code >= 0x20 && code <= 0x7e;
}
function isSecurityWhitespaceCode(code: number | null): boolean {
return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
}
function isEscapeTokenBoundary(code: number): boolean {
return !isAsciiAlphaNumericCode(code) && code !== 0x2e && code !== 0x5f && code !== 0x2d;
}
function shouldPreserveUnicodeUncEvidence(
value: string,
runStart: number,
runEnd: number,
decoded: number
): boolean {
if (
runEnd - runStart < 2 ||
decoded === 0x2f ||
decoded === 0x5c ||
decoded === 0x3a ||
(runStart > 0 && !isEscapeTokenBoundary(value.charCodeAt(runStart - 1)))
) {
return false;
}
const afterEscape = runEnd + 5;
let tokenEnd = afterEscape;
while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
if (value.slice(afterEscape, tokenEnd).includes("=")) return false;
return afterEscape < tokenEnd;
}
function decodeSecurityEscapesOnce(
value: string,
decodeQuotes: boolean,
maxLength: number
): string {
const output: string[] = [];
let changed = false;
for (let index = 0; index < value.length; index++) {
if (value.charCodeAt(index) !== 0x5c) {
output.push(value[index]);
continue;
}
const runStart = index;
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
const runEnd = index;
if (runEnd >= value.length) {
output.push(value.slice(runStart));
break;
}
const escaped = value[runEnd];
if (escaped === "u" || escaped === "U") {
const decoded = unicodeEscapeCodeAt(value, runEnd - 1);
const isQuote = decoded === 0x22 || decoded === 0x27;
if (isSecurityWhitespaceCode(decoded)) {
output.push(" ");
index = runEnd + 4;
changed = true;
continue;
}
if (
isPrintableAscii(decoded) &&
(decodeQuotes || !isQuote) &&
!shouldPreserveUnicodeUncEvidence(value, runStart, runEnd, decoded)
) {
output.push(String.fromCharCode(decoded));
index = runEnd + 4;
changed = true;
continue;
}
output.push(value.slice(runStart, runEnd + 5));
index = runEnd + 4;
continue;
}
if (
escaped === "b" ||
escaped === "f" ||
escaped === "n" ||
escaped === "r" ||
escaped === "t"
) {
output.push(" ");
index = runEnd;
changed = true;
continue;
}
if (escaped === "/" || (decodeQuotes && (escaped === '"' || escaped === "'"))) {
output.push(escaped);
index = runEnd;
changed = true;
continue;
}
output.push(value.slice(runStart, runEnd));
index = runEnd - 1;
}
return changed ? output.join("").slice(0, maxLength) : value;
}
function hasResidualSecurityEscape(value: string): boolean {
for (let index = 0; index < value.length; index++) {
if (value.charCodeAt(index) !== 0x5c) continue;
while (index < value.length && value.charCodeAt(index) === 0x5c) index++;
if (index >= value.length) return false;
const escaped = value[index];
if (
escaped === "b" ||
escaped === "f" ||
escaped === "n" ||
escaped === "r" ||
escaped === "t"
) {
return true;
}
if (escaped === "/" || escaped === '"' || escaped === "'") return true;
if (escaped === "u" || escaped === "U") {
const decoded = unicodeEscapeCodeAt(value, index - 1);
if (isPrintableAscii(decoded) || isSecurityWhitespaceCode(decoded)) return true;
}
}
return false;
}
/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */
function normalizeSecurityEscapes(
value: string,
decodeQuotes: boolean,
maxLength = MAX_ERROR_LEN
): string {
let normalized = value.slice(0, maxLength);
for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) {
const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes, maxLength);
if (decoded === normalized) break;
normalized = decoded.slice(0, maxLength);
}
return normalized;
}
function isCredentialLabelBoundary(code: number): boolean {
return !isAsciiAlphaNumericCode(code) && code !== 0x5f && code !== 0x2d;
}
function matchCredentialAssignmentAt(value: string, start: number): CredentialAssignment | null {
const keyQuote = value[start] === '"' || value[start] === "'" ? value[start] : "";
const labelStart = start + (keyQuote ? 1 : 0);
const cliFlag =
!keyQuote &&
labelStart >= 2 &&
value.slice(labelStart - 2, labelStart) === "--" &&
(labelStart === 2 || isCredentialLabelBoundary(value.charCodeAt(labelStart - 3)));
for (const [label, failClosed] of CREDENTIAL_LABELS) {
const labelEnd = labelStart + label.length;
if (value.slice(labelStart, labelEnd).toLowerCase() !== label) continue;
let index = labelEnd;
if (
(label === "arena-auth-prod-v1" || label === "__secure-next-auth.session-token") &&
value[index] === "."
) {
const chunkStart = ++index;
while (index < value.length && /\d/.test(value[index])) index++;
if (index === chunkStart) continue;
}
if (keyQuote) {
if (value[index] !== keyQuote) continue;
index++;
} else if (!isCredentialLabelBoundary(value.charCodeAt(index))) {
continue;
} else if (value[index] === '"' || value[index] === "'") {
index++;
}
const separatorStart = index;
while (/\s/.test(value[index])) index++;
if (value[index] === ":" || value[index] === "=") {
index++;
while (/\s/.test(value[index])) index++;
} else if (!(cliFlag && index > separatorStart)) {
continue;
}
return { valueStart: index, failClosed };
}
return null;
}
function findQuotedCredentialEnd(value: string, start: number, quote: string): number {
let index = start + 1;
while (index < value.length) {
if (value.charCodeAt(index) === 0x5c) {
index += 2;
continue;
}
if (value[index] === quote) return index;
index++;
}
return -1;
}
function findUnquotedCredentialEnd(value: string, start: number): number {
let end = start;
while (end < value.length) {
const char = value[end];
if (/\s/.test(char) || char === '"' || char === "'" || char === "," || char === "}") break;
end++;
}
return end;
}
function redactLabeledCredentialAssignments(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
const assignment = matchCredentialAssignmentAt(value, index);
if (!assignment) {
index++;
continue;
}
const { valueStart, failClosed } = assignment;
const quote = value[valueStart] === '"' || value[valueStart] === "'" ? value[valueStart] : "";
if (quote) {
const closingQuote = findQuotedCredentialEnd(value, valueStart, quote);
parts.push(value.slice(copyStart, valueStart + 1), "[REDACTED]");
if (closingQuote < 0) {
copyStart = value.length;
index = value.length;
} else {
parts.push(quote);
copyStart = closingQuote + 1;
index = copyStart;
}
continue;
}
// A leading backslash may be a serialized quote or another encoded
// delimiter. Do not redact only that prefix and leave the value behind.
const valueEnd =
failClosed || value.charCodeAt(valueStart) === 0x5c
? value.length
: findUnquotedCredentialEnd(value, valueStart);
parts.push(value.slice(copyStart, valueStart), "[REDACTED]");
copyStart = valueEnd;
index = Math.max(valueEnd, valueStart + 1);
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function redactPrivateKeyPemBlocks(value: string): string {
// ASCII-only fold keeps offsets aligned even when the surrounding message
// contains Unicode characters whose full uppercase form expands in length.
const upperValue = value.replace(/[a-z]/g, (char) => char.toUpperCase());
const beginPrefix = "-----BEGIN ";
const parts: string[] = [];
let copyStart = 0;
let searchStart = 0;
while (searchStart < value.length) {
const blockStart = upperValue.indexOf(beginPrefix, searchStart);
if (blockStart < 0) break;
const labelStart = blockStart + beginPrefix.length;
const headerEnd = upperValue.indexOf("-----", labelStart);
if (headerEnd < 0) break;
const label = upperValue.slice(labelStart, headerEnd).trim();
if (!/^(?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?$/.test(label)) {
searchStart = headerEnd + 5;
continue;
}
const endMarker = `-----END ${label}-----`;
const closingStart = upperValue.indexOf(endMarker, headerEnd + 5);
const blockEnd = closingStart < 0 ? value.length : closingStart + endMarker.length;
parts.push(value.slice(copyStart, blockStart), "[REDACTED]");
copyStart = blockEnd;
searchStart = blockEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
const DATA_URL_PREFIX = "data:";
const BASE64_DATA_URL_MARKER = ";base64";
const REDACTED_DATA_URL = "[REDACTED_DATA_URL]";
function matchesAsciiCaseInsensitiveAt(value: string, start: number, expected: string): boolean {
if (start < 0 || start + expected.length > value.length) return false;
for (let offset = 0; offset < expected.length; offset++) {
const code = value.charCodeAt(start + offset);
const foldedCode = code >= 0x41 && code <= 0x5a ? code + 0x20 : code;
if (foldedCode !== expected.charCodeAt(offset)) return false;
}
return true;
}
function isBase64DataUrlPayloadCode(code: number): boolean {
return (
isAsciiAlphaNumericCode(code) ||
code === 0x2b ||
code === 0x2f ||
code === 0x3d ||
code === 0x5f ||
code === 0x2d
);
}
function isEcmaScriptWhitespaceCode(code: number): boolean {
return (
(code >= 0x09 && code <= 0x0d) ||
code === 0x20 ||
code === 0xa0 ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) ||
code === 0x2028 ||
code === 0x2029 ||
code === 0x202f ||
code === 0x205f ||
code === 0x3000 ||
code === 0xfeff
);
}
/** Redact base64 data URLs in one pass, including input with many repeated `data:` prefixes. */
function redactBase64DataUrls(value: string): string {
const parts: string[] = [];
let copyStart = 0;
let index = 0;
while (index < value.length) {
if (!matchesAsciiCaseInsensitiveAt(value, index, DATA_URL_PREFIX)) {
index++;
continue;
}
const dataUrlStart = index;
const mediaTypeStart = dataUrlStart + DATA_URL_PREFIX.length;
let delimiter = mediaTypeStart;
while (
delimiter < value.length &&
value[delimiter] !== "," &&
!isEcmaScriptWhitespaceCode(value.charCodeAt(delimiter))
) {
delimiter++;
}
const markerStart = delimiter - BASE64_DATA_URL_MARKER.length;
const hasBase64Marker =
delimiter < value.length &&
value[delimiter] === "," &&
markerStart >= mediaTypeStart &&
matchesAsciiCaseInsensitiveAt(value, markerStart, BASE64_DATA_URL_MARKER);
if (!hasBase64Marker) {
index = delimiter < value.length ? delimiter + 1 : value.length;
continue;
}
let payloadEnd = delimiter + 1;
while (payloadEnd < value.length && isBase64DataUrlPayloadCode(value.charCodeAt(payloadEnd))) {
payloadEnd++;
}
if (payloadEnd === delimiter + 1) {
index = delimiter + 1;
continue;
}
parts.push(value.slice(copyStart, dataUrlStart), REDACTED_DATA_URL);
copyStart = payloadEnd;
index = payloadEnd;
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
const HTTP_URL_RE = /https?:\/\//gi;
const URL_QUERY_PARAM_RE = /([?&])([^=&#]+)=([^&#]*)/g;
function isUrlTerminator(char: string): boolean {
return (
/\s/.test(char) ||
char === '"' ||
char === "'" ||
char === "`" ||
char === "<" ||
char === ">" ||
char === ")" ||
char === "]" ||
char === "}" ||
char === "," ||
char === ";"
);
}
function normalizeUrlQueryKey(key: string): string {
let decoded = key.replace(/\+/g, " ");
try {
decoded = decodeURIComponent(decoded);
} catch {
// Malformed percent escapes stay visible to the conservative ASCII fold.
}
return decoded.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
}
function isSensitiveUrlQueryKey(key: string): boolean {
const normalized = normalizeUrlQueryKey(key);
return (
normalized === "sig" ||
normalized === "signature" ||
normalized === "key" ||
normalized === "apikey" ||
normalized === "token" ||
normalized === "accesstoken" ||
normalized === "refreshtoken" ||
normalized === "credential" ||
normalized === "password" ||
normalized === "secret" ||
normalized === "awsaccesskeyid" ||
normalized === "googleaccessid" ||
normalized === "xamzcredential" ||
normalized === "xamzsignature" ||
normalized === "xamzsecuritytoken" ||
normalized === "xgoogcredential" ||
normalized === "xgoogsignature"
);
}
function redactUrlSegment(segment: string): string {
const schemeEnd = segment.indexOf("//") + 2;
let authorityEnd = segment.length;
for (const delimiter of ["/", "?", "#"]) {
const candidate = segment.indexOf(delimiter, schemeEnd);
if (candidate >= 0) authorityEnd = Math.min(authorityEnd, candidate);
}
let redacted = segment;
const userInfoEnd = segment.lastIndexOf("@", authorityEnd);
if (userInfoEnd >= schemeEnd) {
redacted = `${segment.slice(0, schemeEnd)}[REDACTED]@${segment.slice(userInfoEnd + 1)}`;
}
URL_QUERY_PARAM_RE.lastIndex = 0;
return redacted.replace(URL_QUERY_PARAM_RE, (match, separator: string, key: string) =>
isSensitiveUrlQueryKey(key) ? `${separator}redacted=[REDACTED]` : match
);
}
function redactSensitiveUrlCredentials(value: string): string {
HTTP_URL_RE.lastIndex = 0;
const parts: string[] = [];
let copyStart = 0;
let match = HTTP_URL_RE.exec(value);
while (match) {
const start = match.index;
let end = HTTP_URL_RE.lastIndex;
while (end < value.length && !isUrlTerminator(value[end])) end++;
const segment = value.slice(start, end);
const redacted = redactUrlSegment(segment);
if (redacted !== segment) {
parts.push(value.slice(copyStart, start), redacted);
copyStart = end;
}
HTTP_URL_RE.lastIndex = Math.max(end, HTTP_URL_RE.lastIndex);
match = HTTP_URL_RE.exec(value);
}
if (parts.length === 0) return value;
parts.push(value.slice(copyStart));
return parts.join("");
}
function redactKnownCredentialPatterns(value: string): string {
let redacted = value;
for (const pattern of CREDENTIAL_PATTERNS) {
if (pattern.name === "auth_header") continue;
pattern.regex.lastIndex = 0;
redacted = redacted.replace(pattern.regex, "[REDACTED]");
}
return redacted;
}
export function redactSensitiveErrorText(value: string): string {
const normalized = normalizeSecurityEscapes(
value,
false,
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
);
const catalogRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized));
const commonCredentialsRedacted = redactBase64DataUrls(redactPrivateKeyPemBlocks(catalogRedacted))
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
return redactLabeledCredentialAssignments(commonCredentialsRedacted);
}
export function containsSensitiveErrorCredential(value: string): boolean {
const normalized = normalizeSecurityEscapes(
value,
false,
MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM
);
const directRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized))
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
if (directRedacted !== normalized) return true;
if (
/(?:^|\s)--(?:api[-_]?key|token|password|secret)\s+(?:"[^"]*"|'[^']*'|\S+)/i.test(normalized)
) {
return true;
}
return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["']?[^"'\\,\s}]{6,}/i.test(
normalized
);
}
function coerceErrorText(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return String(value);
} catch {
// Fail closed when an attacker-controlled toString/valueOf accessor throws.
return "";
}
}
function truncateSanitizedErrorText(value: string): string {
if (value.length <= MAX_ERROR_LEN) return value;
const markerStart = value.lastIndexOf("[REDACTED", MAX_ERROR_LEN);
const markerEnd = markerStart >= 0 ? value.indexOf("]", markerStart) : -1;
if (
markerStart >= 0 &&
markerStart < MAX_ERROR_LEN &&
markerEnd >= MAX_ERROR_LEN &&
markerEnd - markerStart <= 128
) {
const marker = value.slice(markerStart, markerEnd + 1);
return `${value.slice(0, MAX_ERROR_LEN - marker.length)}${marker}`;
}
return value.slice(0, MAX_ERROR_LEN);
}
/**
* Strip stack-trace tails, credentials, and absolute source paths from a
* client-visible error message.
*/
function sanitizeErrorMessageWithStackPolicy(
message: unknown,
stripStackTail: (value: string) => string
): string {
let str = coerceErrorText(message);
if (str.length > MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM) {
str = str.slice(0, MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM);
}
// Preserve quote provenance until hidden labels/delimiters have been
// exposed and redacted, then decode safe quote escapes in the clean text.
// Raw URI credentials must be projected before the path tokenizer consumes
// the URI tail; Windows path evidence still stays intact until after this
// credential-only pass and is redacted before escape normalization.
str = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(stripStackTail(str)));
str = redactErrorPaths(str);
str = redactSensitiveErrorText(str);
str = truncateSanitizedErrorText(str);
str = normalizeSecurityEscapes(str, false);
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
str = normalizeSecurityEscapes(str, true);
str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str)));
return hasResidualSecurityEscape(str) ? "[REDACTED]" : str.trimEnd();
}
export function sanitizeErrorMessage(message: unknown): string {
return sanitizeErrorMessageWithStackPolicy(message, stripErrorStackTail);
}
function sanitizePassthroughErrorMessage(message: unknown): string {
return sanitizeErrorMessageWithStackPolicy(message, stripRecognizedErrorStackTail);
}
const BLOCKED_KEYS =
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie|credential|session(?!_?(?:count|status)$)/i;
const BLOCKED_CREDENTIAL_ALIAS_KEYS =
/^(?:cf_clearance|__cf_bm|_cfuvid|_puid|sso|sso-rw|arena-auth-prod-v1(?:\.\d+)?)$/i;
const PROTOTYPE_CONTROL_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 4;
const MAX_UPSTREAM_KEY_LEN = 256;
type UpstreamClassificationKey = "code" | "reason" | "status" | "type";
const SAFE_UPSTREAM_STATUS_IDENTIFIERS = new Set([
"ABORTED",
"ALREADY_EXISTS",
"CANCELLED",
"DATA_LOSS",
"DEADLINE_EXCEEDED",
"FAILED_PRECONDITION",
"INTERNAL",
"INVALID_ARGUMENT",
"NOT_FOUND",
"OK",
"OUT_OF_RANGE",
"PERMISSION_DENIED",
"RESOURCE_EXHAUSTED",
"UNAUTHENTICATED",
"UNAVAILABLE",
"UNIMPLEMENTED",
"UNKNOWN",
]);
const SAFE_UPSTREAM_ERROR_IDENTIFIERS = new Set([
"api_error",
"auth_error",
"authentication_error",
"bad_gateway",
"bad_request",
"billing_error",
"context_length_exceeded",
"error",
"gateway_timeout",
"insufficient_quota",
"invalid_api_key",
"invalid_request",
"invalid_request_error",
"model_not_found",
"not_found",
"payment_required",
"permission_error",
"provider_error",
"quota_exhausted",
"rate_limit_error",
"rate_limit_exceeded",
"server_error",
"upstream_error",
"upstream_timeout",
]);
function describeOpaqueBinaryDetail(value: ArrayBuffer | ArrayBufferView): string {
return `[binary ${value.byteLength} bytes]`;
}
function normalizeUpstreamClassificationKey(key: string): UpstreamClassificationKey | null {
const normalized = key.replace(/[-_]/g, "").toLowerCase();
if (normalized === "code" || normalized === "errorcode") return "code";
if (normalized === "reason" || normalized === "errorreason") return "reason";
if (
normalized === "status" ||
normalized === "statuscode" ||
normalized === "errorstatus" ||
normalized === "errorstatuscode"
) {
return "status";
}
if (normalized === "type" || normalized === "errortype" || normalized === "subtype") {
return "type";
}
return null;
}
function projectUpstreamErrorIdentifier(key: UpstreamClassificationKey, value: unknown): unknown {
if (typeof value === "number") {
if (!Number.isInteger(value)) return undefined;
if (key === "code" && value >= 0 && value <= 16) return value;
return (key === "code" || key === "status") && value >= 100 && value <= 599 ? value : undefined;
}
if (typeof value !== "string") return undefined;
if (key === "status" && SAFE_UPSTREAM_STATUS_IDENTIFIERS.has(value.toUpperCase())) {
return value;
}
if (
/^[1-5]\d{2}$/.test(value) ||
/^HTTP_[1-5]\d{2}$/i.test(value) ||
SAFE_UPSTREAM_ERROR_IDENTIFIERS.has(value.toLowerCase())
) {
return value;
}
if (key === "type") return "upstream_error";
if (key === "code") return "";
return undefined;
}
function isSafeUpstreamDetailKey(key: string): boolean {
if (
key.length === 0 ||
key.length > MAX_UPSTREAM_KEY_LEN ||
BLOCKED_KEYS.test(key) ||
BLOCKED_CREDENTIAL_ALIAS_KEYS.test(key) ||
PROTOTYPE_CONTROL_KEYS.has(key.toLowerCase())
) {
return false;
}
return sanitizeErrorMessage(key) === key;
}
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* Unsafe keys are dropped rather than renamed so sanitized-key collisions
* cannot restore a secret under a public placeholder.
*/
function sanitizeUpstreamDetailsInternal(
value: unknown,
depth: number,
preserveSafeMultiline: boolean,
projectClassification: boolean
): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") {
return preserveSafeMultiline
? sanitizePassthroughErrorMessage(value)
: sanitizeErrorMessage(value);
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "object") {
try {
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
return describeOpaqueBinaryDetail(value);
}
if (Array.isArray(value)) {
return value
.slice(0, 32)
.map((entry) =>
sanitizeUpstreamDetailsInternal(
entry,
depth + 1,
preserveSafeMultiline,
projectClassification
)
);
}
const out = Object.create(null) as Record<string, unknown>;
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
if (!isSafeUpstreamDetailKey(key)) continue;
const normalizedKey = key.toLowerCase();
const classificationKey = normalizeUpstreamClassificationKey(normalizedKey);
if (projectClassification && classificationKey) {
const projected = projectUpstreamErrorIdentifier(classificationKey, entryValue);
if (projected !== undefined) out[key] = projected;
continue;
}
const childProjectsClassification =
normalizedKey === "error" ||
normalizedKey === "errors" ||
normalizedKey === "warning" ||
normalizedKey === "warnings";
out[key] = sanitizeUpstreamDetailsInternal(
entryValue,
depth + 1,
preserveSafeMultiline,
childProjectsClassification
);
}
return out;
} catch {
return null;
}
}
return null;
}
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
return sanitizeUpstreamDetailsInternal(value, depth, false, depth === 0);
}
/** Provider-only projection that preserves safe multiline capability wording. */
export function sanitizePassthroughUpstreamDetails(value: unknown, depth = 0): unknown {
return sanitizeUpstreamDetailsInternal(value, depth, true, depth === 0);
}

View File

@@ -9,6 +9,7 @@ import {
stripResponsesLifecycleEcho,
} from "./responsesStreamHelpers.ts";
import { getAnyReasoningValue } from "./reasoningFields.ts";
import { projectStreamFailureEvent, type StreamFailurePayload } from "./streamErrorFormat.ts";
type JsonRecord = Record<string, unknown>;
@@ -47,6 +48,7 @@ export type PassthroughTailProcessorContext = {
hasPassthroughToolCalls: () => boolean;
toResponsesCompletedWithToolCalls: (parsed: JsonRecord) => JsonRecord;
restoreOpenAIToolNames: (parsed: JsonRecord) => boolean;
abortFailure: (failure: StreamFailurePayload, publicMessage: string) => void;
};
function asRecord(value: unknown): JsonRecord {
@@ -284,7 +286,13 @@ export function processBufferedPassthroughLine(
context.updateClaudeEmptyResponseLifecycle(parsedPassthroughData);
}
const parsed = parsedPassthroughData as JsonRecord;
const projectedFailure = projectStreamFailureEvent(parsedPassthroughData);
const parsed = projectedFailure
? projectedFailure.publicPayload
: (parsedPassthroughData as JsonRecord);
if (projectedFailure) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
}
if (context.sanitizeUsagePayload(parsed)) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
}
@@ -301,6 +309,14 @@ export function processBufferedPassthroughLine(
}
context.pushClientPayload(parsed);
output = context.passthroughEventPrefix.prefixData(output, line);
context.emitConvertedOutput(output);
if (projectedFailure) {
context.abortFailure(projectedFailure.internalFailure, projectedFailure.publicMessage);
return true;
}
return false;
}
output = context.passthroughEventPrefix.prefixData(output, line);

View File

@@ -0,0 +1,70 @@
type JsonRecord = Record<string, unknown>;
export type ResponsesFailureOutputStringField = "id" | "text" | "refusal";
export type ResponsesFailureOutputStringProjector = (
field: ResponsesFailureOutputStringField,
value: string
) => string;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
/**
* Retain only public assistant text/refusal output from a failed Responses payload.
* Failure envelopes may contain reasoning, tool arguments, annotations, commentary,
* or provider diagnostics, so every retained field is reconstructed explicitly.
*/
export function projectResponsesFailureOutput(
value: unknown,
projectString: ResponsesFailureOutputStringProjector
): JsonRecord[] {
if (!Array.isArray(value)) return [];
const output: JsonRecord[] = [];
for (const item of value) {
const record = asRecord(item);
if (record.type !== "message" || record.role !== "assistant" || record.phase === "commentary") {
continue;
}
const content: JsonRecord[] = [];
if (Array.isArray(record.content)) {
for (const part of record.content) {
const contentPart = asRecord(part);
if (contentPart.phase === "commentary") continue;
if (contentPart.type === "output_text" && typeof contentPart.text === "string") {
content.push({
type: "output_text",
text: projectString("text", contentPart.text),
// Preserve the required Responses schema without forwarding any
// untrusted citation/file metadata supplied by the provider.
annotations: [],
});
} else if (contentPart.type === "refusal" && typeof contentPart.refusal === "string") {
content.push({
type: "refusal",
refusal: projectString("refusal", contentPart.refusal),
});
}
}
}
const projected: JsonRecord = {
type: "message",
role: "assistant",
content,
};
if (typeof record.id === "string") projected.id = projectString("id", record.id);
if (
record.status === "in_progress" ||
record.status === "completed" ||
record.status === "incomplete"
) {
projected.status = record.status;
}
output.push(projected);
}
return output;
}

View File

@@ -50,9 +50,11 @@ import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./te
import { stripObfuscationZeroWidth } from "./zeroWidth.ts";
import {
formatTranslatedStreamError,
normalizeStreamFailurePayload,
prepareTranslatedStreamFailure,
projectStreamFailureEvent,
type StreamFailurePayload,
} from "./streamErrorFormat.ts";
import { createStreamFailureAborter } from "./streamFailureBoundary.ts";
import { recordToolLatency } from "../services/toolLatencyTracker.ts";
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
import {
@@ -178,8 +180,6 @@ type StreamOptions = {
* codex-compatible `namespace` + `name` fields.
*/
requestToolIdentityMap?: Map<string, { namespace: string; name: string }> | null;
/** High water mark for the TransformStream internal buffer (default: 16384) */
highWaterMark?: number;
};
type TranslateState = ReturnType<typeof initState> & {
@@ -1175,7 +1175,39 @@ export function createSSEStream(options: StreamOptions = {}) {
}
};
const highWaterMark = options.highWaterMark ?? 16384;
const abortStreamFailure = createStreamFailureAborter({
onFailure,
onComplete,
getUsage: () => state?.usage,
timing,
buildProviderPayload: () =>
providerPayloadCollector.build(providerPayloadCollector.getSummary(), {
includeEvents: false,
}),
buildClientPayload: (body) => clientPayloadCollector.build(body, { includeEvents: false }),
clearIdleTimer,
clearPendingRequest: clearPendingRequestFromStream,
markPendingRequestCleared,
model,
});
const emitTranslatedFailureAndAbort = (
controller: TransformStreamDefaultController<Uint8Array>,
payload: unknown
): boolean => {
const failure = prepareTranslatedStreamFailure(payload);
if (!failure) return false;
providerPayloadCollector.push(failure.providerPayload);
const output = formatTranslatedStreamError(failure.record, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
upstreamErrorForwarded = true;
doneSent = true;
abortStreamFailure(controller, failure.internalFailure, failure.publicMessage, {
notifyComplete: true,
});
return true;
};
return new TransformStream(
{
@@ -1241,6 +1273,7 @@ export function createSSEStream(options: StreamOptions = {}) {
let injectedUsage = false;
let clientPayload: unknown = null;
let failurePayload: StreamFailurePayload | null = null;
let publicFailureMessage: string | null = null;
if (skipPassthroughEvent) {
if (!trimmed) {
@@ -1328,6 +1361,14 @@ export function createSSEStream(options: StreamOptions = {}) {
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
let parsed = parsedPassthroughData ?? JSON.parse(trimmed.slice(5).trim());
const projectedFailure = projectStreamFailureEvent(parsed);
if (projectedFailure) {
parsed = projectedFailure.publicPayload;
failurePayload = projectedFailure.internalFailure;
publicFailureMessage = projectedFailure.publicMessage;
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
}
// Some upstream Responses-compatible providers leak an initial Chat Completions
// bootstrap chunk (assistant role + empty content) before emitting proper
@@ -1484,9 +1525,6 @@ export function createSSEStream(options: StreamOptions = {}) {
);
}
}
if (parsed.type === "response.failed") {
failurePayload = normalizeStreamFailurePayload(parsed);
}
if (
parsed.type === "response.reasoning_summary_text.delta" ||
parsed.type === "response.reasoning_summary_text.done" ||
@@ -1810,20 +1848,22 @@ export function createSSEStream(options: StreamOptions = {}) {
const rawDelta = parsed.choices?.[0]?.delta;
const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta);
parsed = sanitizeStreamingChunk(parsed);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
(parsed as Record<string, unknown>)[OMIT_STREAMING_CHUNK_MARKER] === true
) {
continue;
if (!projectedFailure) {
parsed = sanitizeStreamingChunk(parsed);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
(parsed as Record<string, unknown>)[OMIT_STREAMING_CHUNK_MARKER] === true
) {
continue;
}
}
const restoredOpenAIToolName = restoreOpenAIToolNames(parsed, toolNameMap);
const idFixed = hadNonStringTopLevelId ? false : fixInvalidId(parsed);
if (!hasValuableContent(parsed, FORMATS.OPENAI)) {
if (!projectedFailure && !hasValuableContent(parsed, FORMATS.OPENAI)) {
continue;
}
@@ -2052,20 +2092,10 @@ export function createSSEStream(options: StreamOptions = {}) {
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
if (failurePayload) {
let failureHandled = false;
if (onFailure) {
try {
failureHandled = onFailure(failurePayload) === true;
} catch (e) {
console.debug(`[STREAM] onFailure callback error:`, e);
}
}
clearIdleTimer();
if (!failureHandled) {
clearPendingRequestFromStream();
}
controller.error(
markPendingRequestCleared(new Error(failurePayload.message || "Upstream failure"))
abortStreamFailure(
controller,
failurePayload,
publicFailureMessage || "Upstream failure"
);
return;
}
@@ -2087,14 +2117,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (upstreamErrorForwarded) continue;
if (parsed.error) {
const output = formatTranslatedStreamError(parsed, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
upstreamErrorForwarded = true;
doneSent = true;
continue;
}
if (emitTranslatedFailureAndAbort(controller, parsed)) return;
// #5786 — drop replayed Responses-API events (identical/lower sequence_number
// re-sent on an upstream reconnect) so their deltas are not glued twice into
@@ -2356,6 +2379,8 @@ export function createSSEStream(options: StreamOptions = {}) {
]) as JsonRecord,
restoreOpenAIToolNames: (parsed: JsonRecord) =>
restoreOpenAIToolNames(parsed, toolNameMap),
abortFailure: (failure: StreamFailurePayload, publicMessage: string) =>
abortStreamFailure(controller, failure, publicMessage),
};
for (const line of normalizedTailLines) {
@@ -2369,12 +2394,18 @@ export function createSSEStream(options: StreamOptions = {}) {
clearPendingPassthroughEvent();
} else if (buffer) {
let output = buffer;
let bufferedProjectedFailure: ReturnType<typeof projectStreamFailureEvent> = null;
if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) {
output = "data: " + buffer.slice(5);
}
const bufferedPayload = parseSSELine(bufferedLine);
let bufferedPayload = parseSSELine(bufferedLine);
if (bufferedPayload) {
providerPayloadCollector.push(bufferedPayload);
bufferedProjectedFailure = projectStreamFailureEvent(bufferedPayload);
if (bufferedProjectedFailure) {
bufferedPayload = bufferedProjectedFailure.publicPayload;
output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
}
if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat))
output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
if (
@@ -2423,6 +2454,14 @@ export function createSSEStream(options: StreamOptions = {}) {
}
reqLogger?.appendConvertedChunk?.(output);
forward(controller, encoder.encode(output));
if (bufferedProjectedFailure) {
abortStreamFailure(
controller,
bufferedProjectedFailure.internalFailure,
bufferedProjectedFailure.publicMessage
);
return;
}
}
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
@@ -2673,6 +2712,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
if (emitTranslatedFailureAndAbort(controller, parsed)) return;
providerPayloadCollector.push(parsed);
// Extract usage from remaining buffer — if the usage-bearing event
// (e.g. response.completed) is the last SSE line, it ends up here
@@ -2737,58 +2777,9 @@ export function createSSEStream(options: StreamOptions = {}) {
// terminal signal for the client.
}
let failureHandled = false;
if (onFailure) {
try {
timing.markInterrupted();
failureHandled =
onFailure({
status: err.status,
message: err.message,
code: err.code,
type: err.type,
}) === true;
} catch (e) {
console.debug(`[STREAM] onFailure callback error (${model || "unknown"}):`, e);
}
}
const errorBody = buildErrorBody(err.status, err.message);
if (onComplete) {
try {
onComplete({
status: err.status,
usage: state?.usage,
responseBody: errorBody,
ttft: timing.ttftMs(),
itlMs: timing.avgItlMs(),
interrupted: timing.interrupted,
error: err.message,
errorCode: err.code,
providerPayload: providerPayloadCollector.build(
providerPayloadCollector.getSummary(),
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(errorBody, {
includeEvents: false,
}),
});
failureHandled = true;
} catch (e) {
console.debug(
`[STREAM] onComplete callback error in error path (${model || "unknown"}):`,
e
);
}
}
clearIdleTimer();
if (!failureHandled) {
clearPendingRequestFromStream();
}
controller.error(
markPendingRequestCleared(new Error(err.message || "Upstream failure"))
);
const publicErrorMessage = errorBody.error.message;
abortStreamFailure(controller, err, publicErrorMessage, { notifyComplete: true });
return;
}
@@ -2996,8 +2987,8 @@ export function createSSEStream(options: StreamOptions = {}) {
clearIdleTimer();
},
},
{ highWaterMark },
{ highWaterMark }
{ highWaterMark: 16384 },
{ highWaterMark: 16384 }
);
}
@@ -3019,8 +3010,7 @@ export function createSSETransformStreamWithLogger(
copilotCompatibleReasoning = false,
suppressThinkClose = false,
customToolNames: ReadonlySet<string> = new Set(),
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null,
highWaterMark?: number
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null
) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
@@ -3039,7 +3029,6 @@ export function createSSETransformStreamWithLogger(
suppressThinkClose,
customToolNames,
requestToolIdentityMap,
highWaterMark,
});
}
@@ -3054,8 +3043,7 @@ export function createPassthroughStreamWithLogger(
apiKeyInfo: unknown = null,
onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise<void>) | null = null,
clientResponseFormat: string | null = null,
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null,
highWaterMark?: number
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null
) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
@@ -3070,7 +3058,6 @@ export function createPassthroughStreamWithLogger(
onFailure,
clientResponseFormat,
requestToolIdentityMap,
highWaterMark,
});
}

View File

@@ -1,5 +1,6 @@
import { FORMATS } from "../translator/formats.ts";
import { buildErrorBody } from "./error.ts";
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
import { projectResponsesFailureOutput } from "./responsesFailureOutput.ts";
/**
* Upstream stream-failure normalization + client-format error framing.
@@ -17,10 +18,125 @@ export type StreamFailurePayload = {
type?: string;
};
export type ProjectedStreamFailureEvent = {
internalFailure: StreamFailurePayload;
publicMessage: string;
publicPayload: JsonRecord;
};
export type PreparedTranslatedStreamFailure = {
record: JsonRecord;
providerPayload: JsonRecord;
internalFailure: StreamFailurePayload;
publicMessage: string;
};
export function projectCompletedStreamError(
failure: StreamFailurePayload | null | undefined
): JsonRecord | null {
if (!failure) return null;
const status = Number.isInteger(failure.status) ? failure.status : 502;
return buildErrorBody(status, failure.message, undefined, {
type: failure.type ?? "server_error",
code: String(failure.status ?? 502),
}).error;
}
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
const RESPONSES_FAILURE_SCALAR_FIELDS = [
"id",
"object",
"created_at",
"completed_at",
"background",
"model",
"max_output_tokens",
"max_tool_calls",
"parallel_tool_calls",
"previous_response_id",
"service_tier",
"store",
"temperature",
"top_p",
"truncation",
] as const;
const ABSOLUTE_PATH_SEGMENT =
/(?:^|[\\/])(?:Users|app|etc|home|opt|private|root|srv|tmp|usr|var|workspace)[\\/]/i;
function projectResponsesFailureString(key: string, value: string): string {
const sanitized = sanitizeErrorMessage(value);
if (sanitized !== value || ABSOLUTE_PATH_SEGMENT.test(value)) return "[REDACTED]";
if (
(key === "id" || key === "previous_response_id") &&
!/^[A-Za-z0-9][\w.:-]{0,511}$/.test(value)
) {
return "[REDACTED]";
}
if (key === "model" && !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) {
return "[REDACTED]";
}
return sanitized;
}
function projectResponsesFailureUsage(value: unknown): JsonRecord | null {
const usage = asRecord(value);
const projected: JsonRecord = {};
for (const key of ["input_tokens", "output_tokens", "total_tokens"] as const) {
if (typeof usage[key] === "number" && Number.isFinite(usage[key])) {
projected[key] = usage[key];
}
}
const allowedDetailFields = {
input_tokens_details: new Set(["cached_tokens"]),
output_tokens_details: new Set([
"reasoning_tokens",
"accepted_prediction_tokens",
"rejected_prediction_tokens",
]),
} as const;
for (const key of ["input_tokens_details", "output_tokens_details"] as const) {
const details = asRecord(usage[key]);
const projectedDetails = Object.fromEntries(
Object.entries(details).filter(
([detailKey, detail]) =>
allowedDetailFields[key].has(detailKey) &&
typeof detail === "number" &&
Number.isFinite(detail)
)
);
if (Object.keys(projectedDetails).length > 0) projected[key] = projectedDetails;
}
return Object.keys(projected).length > 0 ? projected : null;
}
function projectResponsesFailureObject(response: JsonRecord, publicError: JsonRecord): JsonRecord {
const projected: JsonRecord = { status: "failed", error: publicError };
// A failed Responses event is an error boundary, so copy only documented protocol
// fields with their scalar shapes. Spreading the upstream object would also publish
// provider-only siblings such as diagnostics, settings, raw messages, or stack traces.
for (const key of RESPONSES_FAILURE_SCALAR_FIELDS) {
const value = response[key];
if (typeof value === "string") projected[key] = projectResponsesFailureString(key, value);
else if (value === null || typeof value === "number" || typeof value === "boolean")
projected[key] = value;
}
if (Array.isArray(response.output)) {
projected.output = projectResponsesFailureOutput(
response.output,
projectResponsesFailureString
);
}
const usage = projectResponsesFailureUsage(response.usage);
if (usage) projected.usage = usage;
if ("last_error" in response) projected.last_error = publicError;
return projected;
}
function toStreamFailureStatus(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599) {
return value;
@@ -48,19 +164,30 @@ function looksLikeStreamRateLimit(code: string, type: string, message: string):
export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePayload | null {
const record = payload && typeof payload === "object" ? (payload as JsonRecord) : {};
const response = asRecord(record.response);
const error = Object.keys(asRecord(response.error)).length
? asRecord(response.error)
: Object.keys(asRecord(record.error)).length
? asRecord(record.error)
: record;
const responseError = response.error;
const responseLastError = response.last_error;
const rootError = record.error;
const error = Object.keys(asRecord(responseError)).length
? asRecord(responseError)
: Object.keys(asRecord(responseLastError)).length
? asRecord(responseLastError)
: Object.keys(asRecord(rootError)).length
? asRecord(rootError)
: record;
const code = typeof error.code === "string" ? error.code : "upstream_error";
const type = typeof error.type === "string" ? error.type : undefined;
const message =
typeof error.message === "string" && error.message.trim()
? error.message
: typeof record.message === "string" && record.message.trim()
? record.message
: "Upstream failure";
: typeof responseError === "string" && responseError.trim()
? responseError
: typeof responseLastError === "string" && responseLastError.trim()
? responseLastError
: typeof rootError === "string" && rootError.trim()
? rootError
: typeof record.message === "string" && record.message.trim()
? record.message
: "Upstream failure";
const status =
toStreamFailureStatus(error.status_code) ??
toStreamFailureStatus(error.status) ??
@@ -78,6 +205,80 @@ export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePa
};
}
export function prepareTranslatedStreamFailure(
payload: unknown
): PreparedTranslatedStreamFailure | null {
const record = asRecord(payload);
const projected = projectStreamFailureEvent(record);
if (!projected && !record.error) return null;
return {
record,
providerPayload: projected?.publicPayload ?? record,
internalFailure: projected?.internalFailure ??
normalizeStreamFailurePayload(record) ?? {
status: 502,
message: "Upstream failure",
code: "stream_error",
type: "server_error",
},
publicMessage: projected?.publicMessage || "Upstream failure",
};
}
/**
* Project same-format upstream failure events before they cross the client/log boundary.
*
* `internalFailure` intentionally retains the raw provider wording: account fallback uses it
* to classify quota/reset hints before the persistence seam sanitizes the stored message.
* `publicPayload` is a separate protocol-preserving object whose failure subtrees are rebuilt by
* the canonical public boundary. Callers must never forward the raw payload for these events.
*/
export function projectStreamFailureEvent(payload: unknown): ProjectedStreamFailureEvent | null {
const record = asRecord(payload);
const response = asRecord(record.response);
const hasRootError =
Object.keys(asRecord(record.error)).length > 0 ||
(typeof record.error === "string" && record.error.trim().length > 0);
const isResponsesFailure =
record.type === "response.failed" ||
(record.type === "response.completed" && response.status === "failed");
const isClaudeFailure = record.type === "error";
if (!isResponsesFailure && !isClaudeFailure && !hasRootError) return null;
const internalFailure = normalizeStreamFailurePayload(record);
if (!internalFailure) return null;
const publicError = buildErrorBody(internalFailure.status, internalFailure.message, undefined, {
type: internalFailure.type ?? "server_error",
code: internalFailure.code ?? "stream_error",
}).error;
let publicPayload: JsonRecord;
if (isResponsesFailure) {
// Preserve protocol metadata and partial `output[].content[]` without passing output
// through a bounded-depth details sanitizer, while excluding arbitrary diagnostic siblings.
const publicResponse = projectResponsesFailureObject(response, publicError);
publicPayload = {
type: record.type,
response: publicResponse,
...(typeof record.sequence_number === "number"
? { sequence_number: record.sequence_number }
: {}),
};
} else if (isClaudeFailure) {
publicPayload = { type: "error", error: publicError };
} else {
// OpenAI-compatible HTTP-200 streams commonly emit a bare `{ error: ... }` frame.
// Rebuild the complete public envelope so provider-only fields cannot cross the wire.
publicPayload = { error: publicError };
}
return {
internalFailure,
publicMessage: publicError.message,
publicPayload,
};
}
export function formatTranslatedStreamError(payload: unknown, sourceFormat?: string): string {
const failure = normalizeStreamFailurePayload(payload) ?? {
status: 502,

View File

@@ -0,0 +1,76 @@
import { buildErrorBody } from "./error.ts";
import type { StreamFailurePayload } from "./streamErrorFormat.ts";
import type { StreamTiming } from "./streamTiming.ts";
type CompletePayload = {
status: number;
usage: unknown;
responseBody: unknown;
providerPayload: unknown;
clientPayload: unknown;
error: string;
errorCode?: string;
ttft: number | null;
itlMs: number | null;
interrupted: boolean;
};
type AborterContext = {
onFailure?: ((payload: StreamFailurePayload) => boolean | void | Promise<void>) | null;
onComplete?: ((payload: CompletePayload) => void) | null;
getUsage: () => unknown;
timing: StreamTiming;
buildProviderPayload: () => unknown;
buildClientPayload: (body: unknown) => unknown;
clearIdleTimer: () => void;
clearPendingRequest: () => void;
markPendingRequestCleared: (error: Error) => Error;
model?: string | null;
};
export function createStreamFailureAborter(context: AborterContext) {
return (
controller: TransformStreamDefaultController<Uint8Array>,
failure: StreamFailurePayload,
publicMessage: string,
options: { notifyComplete?: boolean } = {}
): void => {
let handled = false;
context.timing.markInterrupted();
if (context.onFailure) {
try {
handled = context.onFailure(failure) === true;
} catch (error) {
console.debug("[STREAM] onFailure callback error:", error);
}
}
let safeMessage = publicMessage || "Upstream failure";
if (options.notifyComplete && context.onComplete) {
const body = buildErrorBody(failure.status, failure.message);
safeMessage = body.error.message;
try {
context.onComplete({
status: failure.status,
usage: context.getUsage(),
responseBody: body,
ttft: context.timing.ttftMs(),
itlMs: context.timing.avgItlMs(),
interrupted: context.timing.interrupted,
error: safeMessage,
errorCode: failure.code,
providerPayload: context.buildProviderPayload(),
clientPayload: context.buildClientPayload(body),
});
handled = true;
} catch (error) {
console.debug(
`[STREAM] onComplete callback error in error path (${context.model || "unknown"}):`,
error
);
}
}
context.clearIdleTimer();
if (!handled) context.clearPendingRequest();
controller.error(context.markPendingRequestCleared(new Error(safeMessage)));
};
}

View File

@@ -5,6 +5,7 @@ import {
import { HTTP_STATUS } from "../config/constants.ts";
import { buildErrorBody } from "./error.ts";
import { sanitizeErrorMessage } from "./errorSanitization.ts";
export type StreamCompletionPayload = {
status: number;
@@ -129,9 +130,7 @@ export function finalizeStreamRequestLog({
} else {
console.warn(
"finalizeMostRecentPendingRequest failed:",
error && typeof error === "object" && "message" in error
? (error as { message?: unknown }).message
: error
sanitizeErrorMessage(error) || "Stream request finalization failed"
);
}
} catch {}
@@ -158,12 +157,12 @@ export function createStreamFailureFinalizers({
const status = failure.status || HTTP_STATUS.BAD_GATEWAY;
const message = failure.message || "Upstream stream error";
const code = failure.code || failure.type || String(status);
const classification =
failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined;
const errorBody = buildErrorBody(status, message, undefined, classification);
const projectedCode = errorBody.error.code || String(status);
if (!isFailureCompletionRecorded()) {
const errorBody = buildErrorBody(status, message, undefined, classification);
onStreamComplete({
status,
usage: null,
@@ -171,12 +170,12 @@ export function createStreamFailureFinalizers({
providerPayload: errorBody,
clientPayload: errorBody,
error: message,
errorCode: code,
errorCode: projectedCode,
ttft: 0,
});
}
persistFailureUsage(status, code);
persistFailureUsage(status, projectedCode);
try {
onStreamFailure?.(failure);
} catch {

View File

@@ -422,56 +422,66 @@ function prependBufferedChunks(
reader: ReadableStreamDefaultReader<Uint8Array>
): ReadableStream<Uint8Array> {
let bufferedIndex = 0;
let cancelled = false;
let readInFlight = false;
let cancelRequested = false;
let readerReleased = false;
const releaseReader = () => {
if (readerReleased) return;
readerReleased = true;
reader.releaseLock();
};
const cancelReader = (reason: unknown) => {
if (cancelRequested) return;
cancelRequested = true;
try {
reader.releaseLock();
// The provider controls this promise and may never settle. Cancellation
// of the replay stream must remain bounded, so cleanup is deliberately
// fire-and-forget while the in-flight read releases the lock in `pull`.
void reader.cancel(reason).catch(() => {});
} catch {
// A hostile source can keep a read/cancel pending forever. The public stream must
// remain cancellable even when its abandoned source cannot release immediately.
// A synchronous cancellation failure is cleanup-only; the downstream
// stream has already been cancelled by its consumer.
}
if (!readInFlight) releaseReader();
};
return new ReadableStream<Uint8Array>({
async pull(controller) {
if (cancelled) return;
if (cancelRequested) return;
// Replay exactly one readiness chunk per pull. Keeping the first buffered chunk at
// the stream's default high-water mark prevents an eager read of a later upstream
// failure from discarding that legitimate prefix before the caller attaches.
// Replay exactly one readiness chunk per demand. Reading the source
// eagerly here would let a subsequent source error clear this queue
// before the consumer has observed the buffered prefix.
if (bufferedIndex < chunks.length) {
controller.enqueue(chunks[bufferedIndex]);
bufferedIndex += 1;
return;
}
readInFlight = true;
try {
const { done, value } = await reader.read();
if (cancelled) return;
if (cancelRequested) return;
if (done) {
releaseReader();
controller.close();
return;
} else if (value) {
controller.enqueue(value);
}
if (value) controller.enqueue(value);
} catch (error) {
releaseReader();
if (!cancelled) controller.error(error);
if (!cancelRequested) controller.error(error);
} finally {
readInFlight = false;
if (cancelRequested) releaseReader();
}
},
cancel(reason) {
if (cancelled) return;
cancelled = true;
// Do not await a provider's cancel hook: a hostile or stalled source must not make
// downstream cancellation hang. Release the lock once cancellation actually settles.
void reader
.cancel(reason)
.catch(() => {})
.finally(releaseReader);
cancelReader(reason);
},
});
}

View File

@@ -1,13 +1,16 @@
import { RAW_CREDENTIAL_PATTERNS } from "./error.ts";
import {
containsSensitiveErrorCredential,
sanitizePassthroughUpstreamDetails,
} from "./errorSanitization.ts";
/**
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
*
* Claude Code matches the upstream error WORDING to auto-disable capabilities
* (thinking / output_config) for the rest of the conversation. Wrapping the body
* via buildErrorBody() truncates the message and breaks that recovery. For
* upstream-originated 4xx errors the body is the provider's public API message —
* not our internals — so it is safe and required to relay it verbatim.
* OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12).
* Claude Code matches upstream error wording to auto-disable capabilities
* (thinking / output_config) for the rest of the conversation. This path keeps
* the wording and JSON shape required for that recovery after applying the
* canonical recursive sanitizer. OmniRoute-generated errors MUST keep using
* buildErrorBody() (Hard Rule #12).
*/
const PASSTHROUGH_MIN = 400;
const PASSTHROUGH_MAX = 499;
@@ -18,39 +21,28 @@ 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 LABELLED_CREDENTIAL_RE =
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
/**
* The raw-token shapes (sk-…, AIza…, JWT) come from error.ts's
* RAW_CREDENTIAL_PATTERNS rather than a second local copy. The previous local
* copy carried `sk-` while the sanitizer this file falls back to did NOT, so a
* body recognized as leaky here was returned unredacted there
* (GHSA-qv45-56jc-4wmj). One source, no drift.
*/
function containsCredential(text: string): boolean {
if (LABELLED_CREDENTIAL_RE.test(text)) return true;
return RAW_CREDENTIAL_PATTERNS.some((pattern) => {
pattern.lastIndex = 0; // the shared patterns are /g — reset before .test()
return pattern.test(text);
});
}
// validation body. If the body carries a credential pattern, REFUSE passthrough
// before the recursive sanitizer so the caller falls back to buildErrorBody.
// Eligible JSON retains its safe shape and capability/quota wording after the
// recursive projection. Mirrors redactSensitiveErrorText in errorSanitization.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;
if (EXCLUDED_STATUSES.has(statusCode)) return false;
if (!upstreamBody || typeof upstreamBody !== "object") return false;
const text = JSON.stringify(upstreamBody);
let text: string | undefined;
try {
text = JSON.stringify(upstreamBody);
} catch {
// Relay only JSON-stable objects; cyclic/BigInt/hostile toJSON bodies fail closed.
return false;
}
if (typeof text !== "string") return false;
if (INTERNAL_LEAK_RE.test(text)) return false;
// Refuse passthrough when the provider echoed a credential back to us.
if (containsCredential(text)) return false;
if (CREDENTIAL_LEAK_RE.test(text) || containsSensitiveErrorCredential(text)) return false;
return true;
}
@@ -60,8 +52,18 @@ export function buildPassthroughErrorResponse(
headers?: Record<string, string>
): Response | null {
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
return new Response(JSON.stringify(upstreamBody), {
status: statusCode,
headers: { "Content-Type": "application/json", ...(headers || {}) },
});
try {
const sanitizedBody = sanitizePassthroughUpstreamDetails(upstreamBody);
const publicBody =
sanitizedBody && typeof sanitizedBody === "object"
? sanitizedBody
: { error: { message: "Upstream error" } };
return new Response(JSON.stringify(publicBody), {
status: statusCode,
headers: { "Content-Type": "application/json", ...(headers || {}) },
});
} catch {
// A proxy/getter may behave differently between eligibility and projection.
return null;
}
}

View File

@@ -0,0 +1,46 @@
import { buildErrorBody, sanitizeUpstreamDetails } from "./error.ts";
interface SanitizedUpstreamErrorResponseOptions {
status: number;
rawBody: string;
fallbackMessage: string;
headers?: Record<string, string>;
}
/**
* Preserve a provider's JSON error shape while applying the canonical recursive sanitizer.
* Providers sometimes label plain text as JSON; those bodies use OmniRoute's canonical error
* envelope so the advertised content type always matches the response bytes.
*/
export function buildSanitizedUpstreamErrorResponse({
status,
rawBody,
fallbackMessage,
headers,
}: SanitizedUpstreamErrorResponseOptions): Response {
const trimmedBody = rawBody.trim();
if (trimmedBody) {
try {
const parsedBody: unknown = JSON.parse(trimmedBody);
const serializedBody = JSON.stringify(sanitizeUpstreamDetails(parsedBody));
if (serializedBody !== undefined) {
return new Response(serializedBody, {
status,
headers: { ...headers, "Content-Type": "application/json" },
});
}
} catch {
// Upstreams commonly return text or HTML despite an application/json response header.
// Treat it as an opaque message and use the canonical JSON envelope below.
}
}
// Non-JSON is an opaque upstream body. Do not echo even sanitized fragments:
// provider HTML/plaintext can contain credentials or implementation details
// outside the patterns the canonical sanitizer knows about.
return new Response(JSON.stringify(buildErrorBody(status, fallbackMessage)), {
status,
headers: { ...headers, "Content-Type": "application/json" },
});
}

View File

@@ -106,19 +106,16 @@ function parsePatterns(name) {
const LOCAL_ONLY_PREFIXES = parsePrefixes("LOCAL_ONLY_API_PREFIXES");
const LOCAL_ONLY_PATTERNS = parsePatterns("LOCAL_ONLY_API_PATTERNS");
const ALWAYS_PROTECTED_PATHS = parsePrefixes("ALWAYS_PROTECTED_API_PATHS");
const ALWAYS_PROTECTED_PATTERNS = parsePatterns("ALWAYS_PROTECTED_API_PATTERNS");
if (
LOCAL_ONLY_PREFIXES.length === 0 ||
LOCAL_ONLY_PATTERNS.length === 0 ||
ALWAYS_PROTECTED_PATHS.length === 0 ||
ALWAYS_PROTECTED_PATTERNS.length === 0
ALWAYS_PROTECTED_PATHS.length === 0
) {
console.error(
`[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants ` +
`(prefixes=${LOCAL_ONLY_PREFIXES.length}, patterns=${LOCAL_ONLY_PATTERNS.length}, ` +
`alwaysProtected=${ALWAYS_PROTECTED_PATHS.length}, ` +
`alwaysProtectedPatterns=${ALWAYS_PROTECTED_PATTERNS.length})`
`alwaysProtected=${ALWAYS_PROTECTED_PATHS.length})`
);
process.exit(1);
}
@@ -138,19 +135,6 @@ function coveredByLocalOnly(pathStr) {
return matchesPrefix(concrete) || LOCAL_ONLY_PATTERNS.some((re) => re.test(concrete));
}
// Same two-array shape as the LOCAL_ONLY tier: routeGuard protects a path when
// EITHER list matches (`isAlwaysProtectedPath` ORs them), so reading only the
// prefix array reports every regex-covered route as an annotation mismatch.
// That is what happened to the four `{claude,codex}-auth/{export,apply-local}`
// routes, which ALWAYS_PROTECTED_API_PATTERNS has always covered.
function coveredByAlwaysProtected(pathStr) {
const concrete = concretize(pathStr);
return (
ALWAYS_PROTECTED_PATHS.some((p) => concrete === p || concrete.startsWith(`${p}/`)) ||
ALWAYS_PROTECTED_PATTERNS.some((re) => re.test(concrete))
);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};
const errors = [];
@@ -167,11 +151,16 @@ for (const [pathStr, methods] of Object.entries(paths)) {
);
}
if (spec["x-always-protected"] === true && !coveredByAlwaysProtected(pathStr)) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT covered by ` +
`ALWAYS_PROTECTED_API_PATHS or ALWAYS_PROTECTED_API_PATTERNS`
if (spec["x-always-protected"] === true) {
const matchesPath = ALWAYS_PROTECTED_PATHS.some(
(p) => pathStr === p || pathStr.startsWith(`${p}/`)
);
if (!matchesPath) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
`ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
);
}
}
}
}

View File

@@ -1,6 +1,15 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react";
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useSyncExternalStore,
memo,
Suspense,
} from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
@@ -388,6 +397,42 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
// The dismissal lives in localStorage, which SSR cannot read: a lazy useState
// initializer would render "not dismissed" on the server and the real value on
// the client, and correcting that in an effect is a synchronous setState inside
// an effect (react-hooks/set-state-in-effect) that costs an extra commit of this
// whole tree. useSyncExternalStore is the sanctioned shape for exactly this —
// getServerSnapshot supplies the SSR-safe default, getSnapshot reads the store
// after hydration, and the two handlers below notify subscribers instead of
// setting state. The `storage` listener keeps other tabs in sync for free.
const usageGuideListeners = new Set<() => void>();
function subscribeUsageGuide(onStoreChange: () => void): () => void {
usageGuideListeners.add(onStoreChange);
globalThis.addEventListener?.("storage", onStoreChange);
return () => {
usageGuideListeners.delete(onStoreChange);
globalThis.removeEventListener?.("storage", onStoreChange);
};
}
function emitUsageGuideChange(): void {
for (const listener of usageGuideListeners) listener();
}
function getUsageGuideSnapshot(): boolean {
try {
return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1";
} catch {
// Storage access errors (privacy mode / restricted environments) show the guide.
return true;
}
}
function getUsageGuideServerSnapshot(): boolean {
return true;
}
// Pure predicate hoisted out of the page component to keep its cyclomatic budget flat
// (check:complexity new-code mode).
function isStaleIntelligentSelection(
@@ -768,14 +813,15 @@ function CombosPageContent() {
// full client-only re-render of this tree, discarding whatever the fetch
// effects below had already populated. Start with the SSR-safe default on
// both passes and correct it client-only, after hydration, in an effect.
const [showUsageGuide, setShowUsageGuide] = useState(true);
useEffect(() => {
try {
setShowUsageGuide(globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1");
} catch {
// Ignore storage access errors (privacy mode / restricted environments)
}
}, []);
const usageGuideNotDismissed = useSyncExternalStore(
subscribeUsageGuide,
getUsageGuideSnapshot,
getUsageGuideServerSnapshot
);
// "Hide" (as opposed to "hide forever") is intentionally per-mount: it is not
// persisted, and remounting the page brings the guide back — same as before.
const [usageGuideHiddenForNow, setUsageGuideHiddenForNow] = useState(false);
const showUsageGuide = usageGuideNotDismissed && !usageGuideHiddenForNow;
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
const [creatingKimiPreset, setCreatingKimiPreset] = useState(false);
const [comboDragIndex, setComboDragIndex] = useState(null);
@@ -1006,17 +1052,18 @@ function CombosPageContent() {
};
const handleHideUsageGuideForever = () => {
setShowUsageGuide(false);
try {
globalThis.localStorage?.setItem(COMBO_USAGE_GUIDE_STORAGE_KEY, "1");
} catch {}
emitUsageGuideChange();
};
const handleShowUsageGuide = () => {
setShowUsageGuide(true);
try {
globalThis.localStorage?.removeItem(COMBO_USAGE_GUIDE_STORAGE_KEY);
} catch {}
setUsageGuideHiddenForNow(false);
emitUsageGuideChange();
};
const handleFilterChange = (nextFilter) => {
@@ -1149,7 +1196,7 @@ function CombosPageContent() {
{showUsageGuide && (
<ComboUsageGuide
onHide={() => setShowUsageGuide(false)}
onHide={() => setUsageGuideHiddenForNow(true)}
onHideForever={handleHideUsageGuideForever}
onCreateCombo={() => setShowCreateModal(true)}
/>

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorFramesFromLogChunks } from "@/lib/logPayloads";
import { getCallLogById } from "@/lib/usageDb";
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
import {
@@ -18,6 +20,29 @@ import {
// before it's parsed.
const CHUNK_LOG_TIMESTAMP_PREFIX = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
type ManagementStreamChunks = {
provider?: string[];
openai?: string[];
client?: string[];
};
function projectManagementStreamChunks(
streamChunks: ManagementStreamChunks | null | undefined
): ManagementStreamChunks | null {
if (!streamChunks) return null;
return {
...(streamChunks.provider
? { provider: sanitizeErrorFramesFromLogChunks(streamChunks.provider) }
: {}),
...(streamChunks.openai
? { openai: sanitizeErrorFramesFromLogChunks(streamChunks.openai) }
: {}),
...(streamChunks.client
? { client: sanitizeErrorFramesFromLogChunks(streamChunks.client) }
: {}),
};
}
// Best-effort parse of the accumulated SSE `data:` lines captured live for an
// in-flight request (open-sse/utils/requestLogger.ts's appendConvertedChunk
// mutates these arrays in place as chunks arrive, so this reflects "the reply
@@ -77,12 +102,13 @@ export async function GET(
try {
const pendingRequestDetail = getPendingById().get(id);
if (pendingRequestDetail) {
const safeStreamChunks = projectManagementStreamChunks(pendingRequestDetail.streamChunks);
const pipelinePayloads: any = {
clientRequest: pendingRequestDetail.clientRequest ?? null,
providerRequest: pendingRequestDetail.providerRequest ?? null,
providerResponse: pendingRequestDetail.providerResponse ?? null,
clientResponse: pendingRequestDetail.clientResponse ?? null,
streamChunks: pendingRequestDetail.streamChunks ?? null,
streamChunks: safeStreamChunks,
};
const activeEntry = {
@@ -102,7 +128,7 @@ export async function GET(
// The still-generating reply so far — the request's own context
// panel renders this alongside its (already-complete) requestBody
// instead of waiting for the stream to finish.
partialAssistantText: extractPartialAssistantText(pendingRequestDetail.streamChunks),
partialAssistantText: extractPartialAssistantText(safeStreamChunks),
};
return NextResponse.json(activeEntry);
@@ -123,12 +149,13 @@ export async function GET(
const completed = getCompletedDetails();
const inMem = completed.get(id);
if (inMem) {
const safeStreamChunks = projectManagementStreamChunks(inMem.streamChunks);
const pipelinePayloads: any = {
clientRequest: inMem.clientRequest ?? null,
providerRequest: inMem.providerRequest ?? null,
providerResponse: inMem.providerResponse ?? null,
clientResponse: inMem.clientResponse ?? null,
streamChunks: inMem.streamChunks ?? null,
streamChunks: safeStreamChunks,
};
const minimal = {
@@ -142,7 +169,7 @@ export async function GET(
duration: Date.now() - inMem.startedAt,
detailState: "in-memory",
active: false,
error: inMem.error || null,
error: sanitizeErrorMessage(inMem.error) || null,
pipelinePayloads,
hasPipelineDetails: true,
};

View File

@@ -40,9 +40,7 @@ export function buildStaleEncryptionKeyResponse(
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
// buildErrorBody sanitizes the message (Rule #12); override the type so the
// client can key off the specific stale-encryption cause.
const body = buildErrorBody(424, message);
body.error.type = "storage_encryption_stale";
// buildErrorBody sanitizes the message and projects the client-visible classification.
const body = buildErrorBody(424, message, undefined, { type: "storage_encryption_stale" });
return NextResponse.json(body, { status: 424 });
}

View File

@@ -0,0 +1,155 @@
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { makeDiagnosis } from "./codexAppServerHealth";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
export function toSafeMessage(value: unknown, fallback = "Unknown error"): string {
const safeMessage = sanitizeErrorMessage(value).trim();
return safeMessage || fallback;
}
/**
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
* account is deactivated, in which case the API returns 401 — mislabeling that as
* "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the
* account-fallback classifier already trusts.
*/
export function isAccountDeactivatedMessage(text: string): boolean {
const normalized = (text || "").toLowerCase();
return (
normalized.includes("account_deactivated") ||
(normalized.includes("deactivat") && normalized.includes("account"))
);
}
export function classifyFailure({
error,
statusCode = null,
refreshFailed = false,
unsupported = false,
provider,
}: ClassifyFailureArgs) {
const message = toSafeMessage(error, "Connection test failed");
const normalized = message.toLowerCase();
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
if (unsupported) {
return makeDiagnosis("unsupported", "validation", message, "unsupported");
}
if (refreshFailed || normalized.includes("refresh failed")) {
return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed");
}
// #1444: a deactivated account is distinct from a revoked/expired token — surface it
// as account_deactivated (which the dashboard renders as "Account Deactivated") before
// the generic 401/403 branch below would mark it "upstream_auth_error".
if (isAccountDeactivatedMessage(normalized)) {
return makeDiagnosis("account_deactivated", "account", message, "account_deactivated");
}
if (numericStatus === 401 || numericStatus === 403) {
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
}
if (numericStatus === 429) {
return makeDiagnosis("upstream_rate_limited", "upstream", message, "429");
}
if (numericStatus && numericStatus >= 500) {
return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus));
}
if (normalized.includes("token expired") || normalized.includes("expired")) {
return makeDiagnosis("token_expired", "oauth", message, "token_expired");
}
if (
normalized.includes("invalid api key") ||
normalized.includes("token invalid") ||
normalized.includes("revoked") ||
normalized.includes("access denied") ||
normalized.includes("unauthorized") ||
normalized.includes("forbidden")
) {
return makeDiagnosis(
"upstream_auth_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "auth_failed"
);
}
if (
normalized.includes("rate limit") ||
normalized.includes("quota") ||
normalized.includes("too many requests")
) {
return makeDiagnosis(
"upstream_rate_limited",
"upstream",
message,
numericStatus ? String(numericStatus) : "rate_limited"
);
}
if (
normalized.includes("fetch failed") ||
normalized.includes("network") ||
normalized.includes("timeout") ||
normalized.includes("timed out") ||
normalized.includes("econn") ||
normalized.includes("enotfound") ||
normalized.includes("socket")
) {
return makeDiagnosis("network_error", "upstream", message, "network_error");
}
return makeDiagnosis(
"upstream_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "upstream_error"
);
}
/** Allowlist the CLI health fields safe to expose outside the local runtime boundary. */
export function projectProviderRuntimeForPublicResponse(
runtime: unknown
): Record<string, unknown> | null {
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return null;
const record = runtime as Record<string, unknown>;
const projected: Record<string, unknown> = {};
for (const field of ["installed", "runnable", "requiresBinary"] as const) {
if (typeof record[field] === "boolean") projected[field] = record[field];
}
for (const field of ["reason", "runtimeMode", "version", "command"] as const) {
if (typeof record[field] !== "string") continue;
const safeValue = sanitizeErrorMessage(record[field]).trim();
if (safeValue) projected[field] = safeValue.slice(0, 512);
}
return projected;
}
/** Sanitize every connection-test result before health writes, logs, and HTTP responses. */
export function projectConnectionTestResultForPublicResponse<
T extends { error?: unknown; warning?: unknown; diagnosis?: unknown },
>(result: T) {
const projected = projectProviderValidationResultForPublicResponse(result);
if (!projected.diagnosis || typeof projected.diagnosis !== "object") return projected;
const diagnosis = projected.diagnosis as Record<string, unknown>;
return {
...projected,
diagnosis: {
...diagnosis,
message:
diagnosis.message === null || diagnosis.message === undefined
? null
: toSafeMessage(diagnosis.message, "Connection test failed"),
},
};
}

View File

@@ -7,6 +7,7 @@ import { isCloudEnabled, resolveProxyForConnection } from "@/lib/db/settings";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { validateProviderApiKey } from "@/lib/providers/validation";
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
import { getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { buildQoderCliNotFoundHint } from "@omniroute/open-sse/services/qoderCliResolve.ts";
// Use the shared open-sse token refresh with built-in dedup/race-condition cache
@@ -29,11 +30,19 @@ import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHea
import { recoverKeyHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { shouldClearErrorStateOnValidProbe } from "@/lib/usage/providerLimits";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult";
import { classifyOAuthProbeInconclusive, OAUTH_TEST_CONFIG } from "./oauthTestConfig";
import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts";
import * as retirement from "@/lib/providers/chatgptWebRetirementResponse";
import {
classifyFailure,
isAccountDeactivatedMessage,
projectConnectionTestResultForPublicResponse,
projectProviderRuntimeForPublicResponse,
toSafeMessage,
} from "./publicErrorBoundary";
export { classifyFailure, projectProviderRuntimeForPublicResponse } from "./publicErrorBoundary";
// Match the API-key path's 30s timeout so a hung OAuth upstream cannot block the test queue.
const OAUTH_TEST_TIMEOUT_MS = 30_000;
@@ -45,115 +54,6 @@ const providerConnectionTestBodySchema = z.object({
validationModelId: z.string().max(500).optional(),
});
function toSafeMessage(value: any, fallback = "Unknown error"): string {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed || fallback;
}
/**
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
* account is deactivated, in which case the API returns 401 — mislabeling that as
* "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the
* account-fallback classifier already trusts.
*/
function isAccountDeactivatedMessage(text: string): boolean {
const n = (text || "").toLowerCase();
return n.includes("account_deactivated") || (n.includes("deactivat") && n.includes("account"));
}
export function classifyFailure({
error,
statusCode = null,
refreshFailed = false,
unsupported = false,
provider,
}: ClassifyFailureArgs) {
const message = toSafeMessage(error, "Connection test failed");
const normalized = message.toLowerCase();
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
if (unsupported) {
return makeDiagnosis("unsupported", "validation", message, "unsupported");
}
if (refreshFailed || normalized.includes("refresh failed")) {
return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed");
}
// #1444: a deactivated account is distinct from a revoked/expired token — surface it
// as account_deactivated (which the dashboard renders as "Account Deactivated") before
// the generic 401/403 branch below would mark it "upstream_auth_error".
if (isAccountDeactivatedMessage(normalized)) {
return makeDiagnosis("account_deactivated", "account", message, "account_deactivated");
}
if (numericStatus === 401 || numericStatus === 403) {
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
}
if (numericStatus === 429) {
return makeDiagnosis("upstream_rate_limited", "upstream", message, "429");
}
if (numericStatus && numericStatus >= 500) {
return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus));
}
if (normalized.includes("token expired") || normalized.includes("expired")) {
return makeDiagnosis("token_expired", "oauth", message, "token_expired");
}
if (
normalized.includes("invalid api key") ||
normalized.includes("token invalid") ||
normalized.includes("revoked") ||
normalized.includes("access denied") ||
normalized.includes("unauthorized") ||
normalized.includes("forbidden")
) {
return makeDiagnosis(
"upstream_auth_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "auth_failed"
);
}
if (
normalized.includes("rate limit") ||
normalized.includes("quota") ||
normalized.includes("too many requests")
) {
return makeDiagnosis(
"upstream_rate_limited",
"upstream",
message,
numericStatus ? String(numericStatus) : "rate_limited"
);
}
if (
normalized.includes("fetch failed") ||
normalized.includes("network") ||
normalized.includes("timeout") ||
normalized.includes("timed out") ||
normalized.includes("econn") ||
normalized.includes("enotfound") ||
normalized.includes("socket")
) {
return makeDiagnosis("network_error", "upstream", message, "network_error");
}
return makeDiagnosis(
"upstream_error",
"upstream",
message,
numericStatus ? String(numericStatus) : "upstream_error"
);
}
function hasQoderToken(connection: any): boolean {
if (typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0) return true;
const psd = connection?.providerSpecificData;
@@ -218,7 +118,10 @@ async function getProviderRuntimeStatus(connection: any) {
error: runtimeMessage,
};
} catch (error) {
const runtimeMessage = `Failed to check local CLI runtime: ${(error as any)?.message || "runtime_check_failed"}`;
const runtimeMessage = `Failed to check local CLI runtime: ${toSafeMessage(
error,
"runtime_check_failed"
)}`;
return {
installed: false,
runnable: false,
@@ -302,7 +205,10 @@ async function refreshOAuthToken(connection: any) {
});
return result; // { accessToken, expiresIn, refreshToken } or null
} catch (err) {
console.error(`Error refreshing ${provider} token:`, (err as any).message);
console.error(
`Error refreshing ${provider} token:`,
toSafeMessage(err, "Token refresh failed")
);
return null;
}
}
@@ -376,7 +282,10 @@ async function syncToCloudIfEnabled() {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud after token refresh:", error);
console.log(
"Error syncing to cloud after token refresh:",
toSafeMessage(error, "Cloud sync failed")
);
}
}
@@ -934,11 +843,13 @@ async function testApiKeyConnection(connection: any) {
};
}
const result = await validateProviderApiKey({
provider: connection.provider,
apiKey: connection.apiKey,
providerSpecificData: connection.providerSpecificData,
});
const result = projectProviderValidationResultForPublicResponse(
await validateProviderApiKey({
provider: connection.provider,
apiKey: connection.apiKey,
providerSpecificData: connection.providerSpecificData,
})
);
if (result.unsupported) {
const error = "Provider test not supported";
@@ -1001,8 +912,11 @@ export async function testSingleConnection(connectionId: string, validationModel
let proxyInfo: any = null;
try {
proxyInfo = await resolveProxyForConnection(connectionId);
} catch (proxyErr: any) {
console.log(`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, proxyErr?.message);
} catch (proxyErr: unknown) {
console.log(
`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`,
toSafeMessage(proxyErr, "Proxy resolution failed")
);
}
let result;
@@ -1046,6 +960,12 @@ export async function testSingleConnection(connectionId: string, validationModel
);
}
// Every runtime path converges here before any health-state write, diagnosis,
// persistent log, or public response. API-key validation is projected at its
// own seam above as well so future refactors cannot move it past this boundary.
result = projectConnectionTestResultForPublicResponse(result);
const publicRuntime = projectProviderRuntimeForPublicResponse(runtime);
const latencyMs = Date.now() - startTime;
// Unsupported validation capability is neutral: the probe established that
@@ -1063,14 +983,14 @@ export async function testSingleConnection(connectionId: string, validationModel
} catch (activateError) {
console.log(
`[ConnectionTest] Failed to activate unverifiable connection ${connectionId}:`,
(activateError as any)?.message || activateError
toSafeMessage(activateError, "Connection activation failed")
);
}
}
return {
...result,
latencyMs,
runtime: runtime || null,
runtime: publicRuntime,
testedAt: null,
};
}
@@ -1214,7 +1134,7 @@ export async function testSingleConnection(connectionId: string, validationModel
diagnosis,
latencyMs,
statusCode: result.statusCode || null,
runtime: runtime || null,
runtime: publicRuntime,
testedAt: now,
};
}
@@ -1245,7 +1165,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
} catch (error) {
const retired = retirement.responseForError(error);
if (retired) return retired;
console.log("Error testing connection:", error);
console.log("Error testing connection:", toSafeMessage(error, "Connection test failed"));
return NextResponse.json({ error: "Test failed" }, { status: 500 });
}
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderNodeById } from "@/models";
@@ -8,6 +9,7 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { validateProviderApiKey } from "@/lib/providers/validation";
import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport";
import { getProxyForLevel } from "@/lib/db/settings";
import { resolveProxyForProvider } from "@/lib/db/proxies";
import { validateProviderApiKeySchema } from "@/shared/validation/schemas";
@@ -123,12 +125,14 @@ export async function POST(request) {
proxyToUse = providerProxy || globalProxy || null;
}
const result = await runWithProxyContextOrDirect(proxyToUse || null, () =>
validateProviderApiKey({
provider,
apiKey,
providerSpecificData,
})
const result = projectProviderValidationResultForPublicResponse(
await runWithProxyContextOrDirect(proxyToUse || null, () =>
validateProviderApiKey({
provider,
apiKey,
providerSpecificData,
})
)
);
if (result.unsupported) {
@@ -174,7 +178,7 @@ export async function POST(request) {
providerSpecificData: result.providerSpecificData || null,
});
} catch (error) {
console.log("Error validating API key:", error);
console.log("Error validating API key:", sanitizeErrorMessage(error) || "Validation failed");
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
}
}

View File

@@ -6139,7 +6139,7 @@
"bluesminds": "Get your API key at https://www.bluesminds.com — OpenAI-compatible endpoint at https://api.bluesminds.com/v1 with free daily credits. VIP models (Claude Opus 4.5, Gemini 2.5 Pro) consume pi credits.",
"byteplus": "Connect BytePlus ModelArk with an API key.",
"bytez": "$1 free credits, refreshes every 4 weeks",
"cerebras": "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.",
"cerebras": "One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.",
"charm-hyper": "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.",
"chutes": "Bearer API key for the Chutes OpenAI-compatible gateway.",
"clarifai": "Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.",

View File

@@ -6136,7 +6136,7 @@
"bluesminds": "Get your API key at https://www.bluesminds.com — OpenAI-compatible endpoint at https://api.bluesminds.com/v1 with free daily credits. VIP models (Claude Opus 4.5, Gemini 2.5 Pro) consume pi credits.",
"byteplus": "Connect BytePlus ModelArk with an API key.",
"bytez": "$1 free credits, refreshes every 4 weeks",
"cerebras": "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.",
"cerebras": "One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.",
"charm-hyper": "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.",
"chutes": "Bearer API key for the Chutes OpenAI-compatible gateway.",
"clarifai": "Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.",

View File

@@ -101,30 +101,70 @@ export function isTestContext(): boolean {
);
}
/**
* `node --eval` / `node -e` (and their print variants) are common shapes used by
* one-off import probes.
* Such a process has no application entry point from which to establish storage intent,
* so defaulting it to the operator's durable database is unsafe. A deliberate production
* inspection can still opt in with an explicit DATA_DIR (preferred) or
* OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1.
*/
function isEvalProbeContext(): boolean {
return process.execArgv.some(
(arg) =>
arg === "--eval" ||
arg === "-e" ||
arg === "-pe" ||
arg === "-ep" ||
arg.startsWith("--eval=") ||
arg === "--print" ||
arg === "-p" ||
arg.startsWith("--print=")
);
}
/** Process-wide redirect target, so repeated calls share one DB instead of one per call. */
let testContextDataDir: string | null = null;
let testContextCleanupRegistered = false;
export function resolveWritableDataDir({ isCloud = false }: { isCloud?: boolean } = {}): string {
const resolved = resolveDataDir({ isCloud });
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
// Cloud/serverless never owns a writable home dir; leave its sentinel alone.
if (isCloud) return resolved;
// #10428: a test/ad-hoc run that never chose a DATA_DIR would otherwise open the
// #10428: a test/eval-probe run that never chose a DATA_DIR would otherwise open the
// OPERATOR'S REAL database (~/.omniroute/storage.sqlite — live provider credentials).
// Redirect to a throwaway dir instead of throwing: the documented single-file command
// (`node --import tsx/esm --test tests/unit/x.test.ts`) does not load the isolation
// setup, and a hard failure there would only teach people to disable the guard.
// `OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1` opts back in, so the intent is recorded.
if (
!process.env.DATA_DIR &&
isTestContext() &&
!configured &&
(isTestContext() || isEvalProbeContext()) &&
process.env.OMNIROUTE_ALLOW_DEFAULT_DATA_DIR !== "1"
) {
if (!testContextDataDir) {
testContextDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `${APP_NAME}-testctx-`));
if (!testContextCleanupRegistered) {
testContextCleanupRegistered = true;
process.once("exit", () => {
if (!testContextDataDir) return;
try {
fs.rmSync(testContextDataDir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 25,
});
} catch {
// An unclean exit is left to the operating system's temp-directory policy.
}
});
}
console.warn(
`[DATA_DIR] test context without DATA_DIR → using '${testContextDataDir}' instead of ` +
`[DATA_DIR] test/eval context without DATA_DIR → using '${testContextDataDir}' instead of ` +
`'${resolved}'. Set DATA_DIR explicitly (or load tests/_setup/isolateDataDir.ts) to silence this.`
);
}
@@ -132,7 +172,6 @@ export function resolveWritableDataDir({ isCloud = false }: { isCloud?: boolean
}
// No explicit override → already the default user dir; nothing to fall back to.
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
if (!configured) return resolved;
try {

View File

@@ -101,6 +101,24 @@ function getBackupDir() {
return DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
}
function listBackupFilesNewestFirst(backupDir: string) {
return fs
.readdirSync(backupDir)
.filter((filename) => filename.startsWith("db_") && filename.endsWith(".sqlite"))
.flatMap((filename) => {
try {
return [{ filename, stat: fs.statSync(path.join(backupDir, filename)) }];
} catch {
// A concurrent retention pass may remove an entry after readdir.
return [];
}
})
.sort(
(left, right) =>
right.stat.mtimeMs - left.stat.mtimeMs || right.filename.localeCompare(left.filename)
);
}
export function cleanupDbBackups(options?: {
maxFiles?: number;
retentionDays?: number;
@@ -272,16 +290,26 @@ export function backupDbFile(reason = "auto") {
if (reason !== "manual" && reason !== "pre-restore") {
// Shrink detection is useful for automatic safety backups, but it should
// never block an explicit operator action like manual backup or pre-restore.
// Only timestamp-named automatic/manual backups are shrink baselines. The
// content-addressed migration snapshots are restore points, not periodic size
// samples; excluding them also keeps this lookup to names only with a single stat
// even in legacy directories containing tens of thousands of timestamp backups.
const existingBackups = fs
.readdirSync(backupDir)
.filter((f) => f.startsWith("db_") && f.endsWith(".sqlite"))
.filter((filename) => /^db_\d{4}-.*\.sqlite$/.test(filename))
.sort();
if (existingBackups.length > 0) {
const latestBackup = existingBackups[existingBackups.length - 1];
const latestStat = fs.statSync(path.join(backupDir, latestBackup));
if (latestStat.size > 4096 && stat.size < latestStat.size * 0.5) {
console.warn(`[DB] Backup SKIPPED — DB shrank from ${latestStat.size}B to ${stat.size}B`);
return null;
const latestBackup = existingBackups.at(-1)!;
try {
const latestStat = fs.statSync(path.join(backupDir, latestBackup));
if (latestStat.size > 4096 && stat.size < latestStat.size * 0.5) {
console.warn(
`[DB] Backup SKIPPED — DB shrank from ${latestStat.size}B to ${stat.size}B`
);
return null;
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException | null)?.code !== "ENOENT") throw error;
}
}
}
@@ -316,16 +344,11 @@ export async function listDbBackups() {
try {
if (!fs.existsSync(backupDir)) return [];
const entries = fs
.readdirSync(backupDir)
.filter((f) => f.startsWith("db_") && f.endsWith(".sqlite"))
.sort()
.reverse();
const entries = listBackupFilesNewestFirst(backupDir);
const { tryOpenSync } = await import("@/lib/db/adapters/driverFactory");
return entries.map((filename) => {
return entries.map(({ filename, stat }) => {
const filePath = path.join(backupDir, filename);
const stat = fs.statSync(filePath);
const match = filename.match(/^db_(.+?)_([^.]+)\.sqlite$/);
const reason = match ? match[2] : "unknown";

View File

@@ -1,17 +1,12 @@
/**
* Backup retention primitives — pure filesystem work, no `core.ts` dependency.
*
* This module exists so BOTH backup call sites can share one retention policy:
*
* - `backup.ts` (manual/API/auto backups) — resolves the operator's settings from the
* database and delegates here.
* - `migrationRunner.ts` (pre-migration snapshots) — cannot import `backup.ts`, because
* `core.ts` already imports `migrationRunner.ts` and `backup.ts` imports `core.ts`;
* that edge would close a cycle. Keeping the policy here, free of `core`, lets the
* migration path prune without one.
*
* Before #10421 the migration path had no retention at all and `db_backups/` grew
* without bound (observed: 48.999 files / 204 GB against a 5,3 MB live database).
* `backup.ts` (manual/API/auto backups) resolves the operator's settings from the
* database and delegates pure family pruning here. The migration runner deliberately
* does not prune during its concurrent safety window: its snapshots are content-addressed
* and reused for an identical DB state, while manual/scheduled cleanup remains the single
* retention boundary. Before #10421, repeated failed starts created distinct timestamped
* snapshots and `db_backups/` grew without bound (observed: 48,999 files / 204 GB).
*/
import fs from "fs";

View File

@@ -1118,10 +1118,10 @@ export function getDbInstance(): SqliteDatabase {
// This is needed so the migration runner skips the mass-migration safety abort
// that would otherwise trigger because heuristic seeding marks some migrations
// as applied, making the fresh DB look like a wiped existing DB (#1328).
// #9934: also classify as fresh a file that `omniroute setup` created with
// only the clipped skeleton schema (see the probe below) — even though the
// file exists, it has never had migrations run.
let isNewDb = !fs.existsSync(sqliteFile);
// #9934: also classify a setup-created skeleton as logically fresh for the mass guard,
// while tracking its pre-existing file independently for mandatory snapshot safety.
const databaseExistedBeforeInitialization = fs.existsSync(sqliteFile);
let isNewDb = !databaseExistedBeforeInitialization;
// Detect and handle old schema format — preserve data when possible (#146)
// Uses a single probe connection that becomes the real connection when possible.
@@ -1310,7 +1310,7 @@ export function getDbInstance(): SqliteDatabase {
VALUES ('001', 'initial_schema');
`);
runMigrations(db, { isNewDb });
runMigrations(db, { isNewDb, databaseExistedBeforeInitialization });
// Fresh installs need the same post-migration index guarantee as upgraded
// databases, including recovery from an interrupted migration 127 attempt.
ensureUsageHistoryAccountIndex(db);

View File

@@ -21,37 +21,29 @@ import type { SqliteAdapter } from "./adapters/types";
import { DEFAULT_DATABASE_SETTINGS } from "@/types/databaseSettings";
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
import {
RENAMED_MIGRATION_COMPATIBILITY,
LEGACY_VERSION_SLOT_MIGRATIONS,
SUPERSEDED_DUPLICATE_MIGRATIONS,
PHYSICAL_SCHEMA_SENTINELS,
INITIAL_SCHEMA_SENTINELS,
OPTIONAL_FTS5_MIGRATION_VERSIONS,
RENAMED_MIGRATION_COMPATIBILITY,
SUPERSEDED_DUPLICATE_MIGRATIONS,
} from "./migrationRunner/constants";
import { getExtraMigrationFiles } from "./migrationRunner/extraDirs";
// Retention primitives live in their own `core`-free module: `core.ts` imports this file,
// so importing `backup.ts` (which imports `core.ts`) here would close a dependency cycle.
import { migrationConsole as console } from "./migrationRunner/logger";
import {
MAX_DB_BACKUPS,
DEFAULT_DB_BACKUP_RETENTION_DAYS,
parsePositiveInt,
parseNonNegativeInt,
pruneBackupDirectory,
} from "./backupRetention";
const isNodeTestRunnerChild = typeof process.env.NODE_TEST_CONTEXT === "string";
const console = {
log: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.log(...args);
},
warn: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.warn(...args);
},
error: (...args: unknown[]) => {
globalThis.console.error(...args);
},
};
createPreMigrationBackup,
hashFileSync,
type PreMigrationBackupReceipt,
} from "./migrationRunner/preMigrationBackup";
import {
detectNameMismatches,
getPlausiblePendingCount,
hasColumn,
hasLedgerRepairCandidates,
hasPhysicalTable,
hasTable,
inferPhysicalSchemaBaseline,
reconcileRenumberedMigrations,
rehomeLegacyVersionSlotMigrations,
} from "./migrationRunner/schemaState";
/**
* Resolve the migrations directory path safely across platforms.
@@ -336,16 +328,96 @@ function getAppliedRecords(db: SqliteAdapter): Array<{ version: string; name: st
}>;
}
function hasTable(db: SqliteAdapter, tableName: string): boolean {
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?")
.get(tableName) as { name?: string } | undefined;
return Boolean(row?.name);
/**
* Reopen a narrowly selected migration when the table it creates is physically absent.
*
* Historical databases can carry `074_discovery_results` or the rehomed
* `081_inspector_custom_hosts` in the ledger without the table itself (for example after a
* version-slot collision or an incomplete manual recovery). Treating either marker as
* authoritative leaves an incomplete schema. A same-named view does not count as the table;
* replaying the owning migration fails closed instead of silently advancing.
*
* This intentionally detects table absence only. It is not a general schema-healing layer:
* column/rebuild migrations continue to use targeted idempotency checks elsewhere.
*/
const REQUIRED_PHYSICAL_MIGRATIONS = [
{ version: "074", name: "discovery_results", tableName: "discovery_results" },
{ version: "081", name: "inspector_custom_hosts", tableName: "inspector_custom_hosts" },
] as const;
function validateRequiredPhysicalMigrationProvenance(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): void {
for (const required of REQUIRED_PHYSICAL_MIGRATIONS) {
if (hasPhysicalTable(db, required.tableName)) continue;
const migrationExists = files.some(
(file) => file.version === required.version && file.name === required.name
);
if (!migrationExists) continue;
const occupied = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(required.version) as { version: string; name: string } | undefined;
if (!occupied || occupied.name === required.name) continue;
const knownRenumberedCollision = RENAMED_MIGRATION_COMPATIBILITY.some(
(compatibility) =>
compatibility.fromVersion === occupied.version &&
compatibility.fromName === occupied.name &&
files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
) &&
files.some(
(file) =>
file.version === compatibility.fromVersion && file.name !== compatibility.fromName
)
);
const knownLegacySlotCollision = LEGACY_VERSION_SLOT_MIGRATIONS.some(
(legacy) =>
legacy.version === occupied.version &&
legacy.name === occupied.name &&
files.some((file) => file.version === legacy.version && file.name !== legacy.name)
);
const knownRepairableCollision = knownRenumberedCollision || knownLegacySlotCollision;
if (knownRepairableCollision) continue;
throw new Error(
`[Migration] Required table "${required.tableName}" is missing, but version ` +
`${required.version} is recorded as unknown migration "${occupied.name}" instead of ` +
`"${required.name}". Refusing to treat this database as current.`
);
}
}
function hasColumn(db: SqliteAdapter, tableName: string, columnName: string): boolean {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return columns.some((column) => column.name === columnName);
function findAtomicPhysicalReplays(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): Set<string> {
const replayVersions = new Set<string>();
for (const required of REQUIRED_PHYSICAL_MIGRATIONS) {
if (hasPhysicalTable(db, required.tableName)) continue;
const migrationExists = files.some(
(file) => file.version === required.version && file.name === required.name
);
if (!migrationExists) continue;
const applied = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(required.version, required.name) as { version: string; name: string } | undefined;
if (!applied) continue;
replayVersions.add(required.version);
console.warn(
`[Migration] Will atomically replay ${required.version}_${required.name}: ledger recorded ` +
`"${applied.name}" but required table "${required.tableName}" is missing.`
);
}
return replayVersions;
}
function ensureColumn(db: SqliteAdapter, tableName: string, columnName: string, ddl: string): void {
@@ -651,276 +723,31 @@ function applyCompressionCombosMigration(db: SqliteAdapter, migrationPath: strin
`);
}
function inferPhysicalSchemaBaseline(db: SqliteAdapter): {
version: string;
description: string;
} | null {
for (const sentinel of PHYSICAL_SCHEMA_SENTINELS) {
if (hasTable(db, sentinel.tableName)) {
return {
version: sentinel.version,
description: sentinel.description,
};
}
}
const hasInitialSchema = INITIAL_SCHEMA_SENTINELS.every((tableName) => hasTable(db, tableName));
if (hasInitialSchema) {
return {
version: "001",
description: "initial schema tables",
};
}
return null;
}
function getPlausiblePendingCount(
files: Array<{ version: string; name: string; path: string }>,
baselineVersion: string
): number {
const baseline = Number.parseInt(baselineVersion, 10);
return files.filter((file) => Number.parseInt(file.version, 10) > baseline).length;
}
/**
* Detect migration name mismatches — when a migration version number
* has been reused/renumbered with a different name. This is a strong signal
* that the migration tracking is corrupted or migrations were renumbered.
*/
function detectNameMismatches(
appliedRecords: Array<{ version: string; name: string }>,
files: Array<{ version: string; name: string; path: string }>
): Array<{ version: string; appliedName: string; diskName: string }> {
const appliedByName = new Map(appliedRecords.map((r) => [r.version, r.name]));
const mismatches: Array<{ version: string; appliedName: string; diskName: string }> = [];
for (const file of files) {
const appliedName = appliedByName.get(file.version);
if (appliedName && appliedName !== file.name) {
mismatches.push({
version: file.version,
appliedName,
diskName: file.name,
});
}
}
return mismatches;
}
function reconcileRenumberedMigrations(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): boolean {
let repaired = false;
for (const compatibility of RENAMED_MIGRATION_COMPATIBILITY) {
const hasTargetFile = files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
);
const hasSourceFile = files.some(
(file) => file.version === compatibility.fromVersion && file.name !== compatibility.fromName
);
if (!hasTargetFile || !hasSourceFile) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(compatibility.fromVersion, compatibility.fromName) as
{ version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const targetRow = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.toVersion) as { version: string } | undefined;
const applyRepair = db.transaction(() => {
if (targetRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
compatibility.fromVersion,
compatibility.fromName
);
} else {
db.prepare(
"UPDATE _omniroute_migrations SET version = ?, name = ? WHERE version = ? AND name = ?"
).run(
compatibility.toVersion,
compatibility.toName,
compatibility.fromVersion,
compatibility.fromName
);
}
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Reconciled renamed migration ${compatibility.fromVersion}_${compatibility.fromName} ` +
`to ${compatibility.toVersion}_${compatibility.toName} to preserve pending migrations.`
);
// After the compat rewrite, verify the old version slot is now free.
// A residual row (from a failed prior run, manual intervention, or edge-case
// UPDATE conflict) at the old version would shadow a NEW migration file
// placed at that version number — e.g. 028_create_files_and_batches.sql
// would be skipped because getAppliedVersions() still sees version "028".
const residualRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.fromVersion) as { version: string; name: string } | undefined;
if (residualRow) {
console.warn(
`[Migration] ⚠️ Residual row at version ${compatibility.fromVersion} ` +
`(name: "${residualRow.name}") still present after compat rewrite — ` +
`removing to unblock new migration at this version slot.`
);
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ?").run(
compatibility.fromVersion
);
}
}
return repaired;
}
function rehomeLegacyVersionSlotMigrations(
db: SqliteAdapter,
files: Array<{ version: string; name: string; path: string }>
): boolean {
let repaired = false;
const diskNamesByVersion = new Map(files.map((file) => [file.version, file.name]));
for (const legacy of LEGACY_VERSION_SLOT_MIGRATIONS) {
const diskName = diskNamesByVersion.get(legacy.version);
if (!diskName || diskName === legacy.name) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(legacy.version, legacy.name) as { version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const legacyVersion = `legacy-${legacy.version}-${legacy.name}`;
const applyRepair = db.transaction(() => {
const existingLegacyRow = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?")
.get(legacyVersion) as { version: string } | undefined;
if (existingLegacyRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
legacy.version,
legacy.name
);
return;
}
db.prepare("UPDATE _omniroute_migrations SET version = ? WHERE version = ? AND name = ?").run(
legacyVersion,
legacy.version,
legacy.name
);
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Rehomed legacy migration ${legacy.version}_${legacy.name} ` +
`to ${legacyVersion} so current ${legacy.version}_${diskName} can apply.`
);
}
return repaired;
}
/**
* Read a persisted `dbBackup` retention setting through the adapter that is ALREADY open
* for this migration run.
* Run a callback while holding SQLite's IMMEDIATE writer transaction.
*
* `backup.ts`'s equivalent goes through `getDbInstance()`, which is unsafe here: this
* code runs from inside database initialization, so asking for the singleton would
* re-enter it. Reading off `db` keeps the same stored values without that risk. A DB too
* old to have `key_value` yet simply falls back to the default.
* Production adapters expose `immediate()` directly. A small number of long-standing
* migration tests and external callers still pass a raw better-sqlite3 Database, whose
* transaction wrapper exposes `.immediate()` instead. Supporting both shapes here keeps
* the safety transaction real: this must never degrade to a plain callback invocation.
*/
function readStoredBackupSetting(db: SqliteAdapter, key: string, min: number): number | undefined {
try {
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get("dbBackup", key) as { value?: string } | undefined;
if (!row?.value) return undefined;
const parsed = JSON.parse(row.value);
return Number.isInteger(parsed) && parsed >= min ? parsed : undefined;
} catch {
return undefined;
function runImmediateTransaction<T>(db: SqliteAdapter, fn: () => T): T {
const adapterImmediate = (db as Partial<SqliteAdapter>).immediate;
if (typeof adapterImmediate === "function") {
let result!: T;
adapterImmediate.call(db, () => {
result = fn();
});
return result;
}
}
/**
* Enforce the backup retention budget after a pre-migration snapshot (#10421).
*
* Precedence matches `backup.ts`: env override → persisted operator setting → default.
* Never throws: a migration must not fail because housekeeping did.
*/
function pruneMigrationBackups(db: SqliteAdapter, backupDir: string): void {
try {
const maxFiles = process.env.DB_BACKUP_MAX_FILES
? parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS)
: (readStoredBackupSetting(db, "maxFiles", 1) ?? MAX_DB_BACKUPS);
const retentionDays = process.env.DB_BACKUP_RETENTION_DAYS
? parseNonNegativeInt(process.env.DB_BACKUP_RETENTION_DAYS, DEFAULT_DB_BACKUP_RETENTION_DAYS)
: (readStoredBackupSetting(db, "retentionDays", 0) ?? DEFAULT_DB_BACKUP_RETENTION_DAYS);
const result = pruneBackupDirectory({ backupDir, maxFiles, retentionDays });
if (result.deletedFiles > 0) {
console.log(
`[Migration] Pruned ${result.deletedFiles} old backup file(s) ` +
`(${result.keptBackupFamilies} kept, maxFiles=${maxFiles}, retentionDays=${retentionDays}).`
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[Migration] Failed to prune old backups: ${message}`);
}
}
/**
* Create a pre-migration backup of the SQLite database using VACUUM INTO.
* Returns the backup path on success, null on failure.
*/
function createPreMigrationBackup(db: SqliteAdapter): string | null {
try {
const sqliteFile = db.name;
if (!sqliteFile || sqliteFile === ":memory:") return null;
const backupDir = path.join(path.dirname(sqliteFile), "db_backups");
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(backupDir, `db_${timestamp}_pre-migration.sqlite`);
const escapedBackupPath = backupPath.replace(/'/g, "''");
db.exec(`VACUUM INTO '${escapedBackupPath}'`);
console.log(`[Migration] Pre-migration backup created: ${backupPath}`);
// #10421: apply the operator's retention budget right here. Without this the
// migration path was the one backup producer that never pruned, so every process
// start with a pending migration added ~5 MB forever (observed: 49k files / 204 GB).
pruneMigrationBackups(db, backupDir);
return backupPath;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[Migration] Failed to create pre-migration backup: ${message}`);
return null;
const rawTransaction = db.transaction(fn) as ReturnType<SqliteAdapter["transaction"]> & {
immediate?: () => T;
};
if (typeof rawTransaction.immediate !== "function") {
throw new Error("[Migration] Database adapter does not support IMMEDIATE transactions.");
}
return rawTransaction.immediate();
}
/**
@@ -932,15 +759,243 @@ function createPreMigrationBackup(db: SqliteAdapter): string | null {
* 2. Aborts if too many pending migrations on an existing DB (likely wipe)
* 3. Creates automatic backup before running any migrations
*/
export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }): number {
export function runMigrations(
db: SqliteAdapter,
options?: { isNewDb?: boolean; databaseExistedBeforeInitialization?: boolean }
): number {
const isNewDb = options?.isNewDb === true;
// `isNewDb` also covers a setup-created skeleton so it can bypass the mass-migration
// false positive. Snapshot eligibility must use the independent physical-file fact:
// that skeleton can already contain provider credentials and other operator state.
const databaseExistedBeforeInitialization =
options?.databaseExistedBeforeInitialization ?? !isNewDb;
ensureMigrationsTable(db);
const files = filterSupersededDuplicateMigrations(getMigrationFiles());
rehomeLegacyVersionSlotMigrations(db, files);
reconcileRenumberedMigrations(db, files);
const applied = getAppliedVersions(db);
const appliedRecords = getAppliedRecords(db);
validateRequiredPhysicalMigrationProvenance(db, files);
let preMigrationBackup: PreMigrationBackupReceipt | null = null;
let plan!: {
atomicPhysicalReplays: Set<string>;
appliedRecords: Array<{ version: string; name: string }>;
pending: typeof files;
deferredUnsupported: typeof files;
highestAppliedBeforeMigrations: number;
};
let count = 0;
const preliminaryApplied = getAppliedVersions(db);
const preliminaryAtomicReplays = findAtomicPhysicalReplays(db, files);
const preliminaryPending = files.filter(
(file) => !preliminaryApplied.has(file.version) || preliminaryAtomicReplays.has(file.version)
);
const preliminaryDeferred = preliminaryPending.filter((migration) =>
isDeferredUnsupportedMigration(db, migration)
);
const preliminaryActionable = preliminaryPending.filter(
(migration) => !preliminaryDeferred.some((deferred) => deferred.version === migration.version)
);
const preliminaryHasRepairCandidates = hasLedgerRepairCandidates(db, files);
// Preserve the historical read-only/no-op path. Merely checking an already-current
// database must not acquire a writer lock (or fail SQLITE_BUSY because another supported
// host currently owns one). Safety state is recomputed under IMMEDIATE whenever work exists.
if (preliminaryActionable.length === 0 && !preliminaryHasRepairCandidates) {
const numericApplied = Array.from(preliminaryApplied)
.map((version) => Number.parseInt(version, 10))
.filter((version) => !Number.isNaN(version));
plan = {
atomicPhysicalReplays: preliminaryAtomicReplays,
appliedRecords: getAppliedRecords(db),
pending: preliminaryPending,
deferredUnsupported: preliminaryDeferred,
highestAppliedBeforeMigrations: numericApplied.length > 0 ? Math.max(...numericApplied) : 0,
};
}
// sql.js export() finalizes its active SAVEPOINT, so exporting from inside
// `db.immediate()` would make a later safety throw unable to roll repairs back.
// Its adapter is synchronous and in-memory, so no JavaScript writer can interleave
// between this preflight/export and the immediately following savepoint.
if (
!plan &&
db.driver === "sql.js" &&
(preliminaryActionable.length > 0 || preliminaryHasRepairCandidates)
) {
const needsSnapshot =
(preliminaryActionable.length > 0 || preliminaryHasRepairCandidates) &&
db.name !== ":memory:" &&
databaseExistedBeforeInitialization;
if (needsSnapshot) {
preMigrationBackup = createPreMigrationBackup(db);
if (!preMigrationBackup) {
throw new Error(
"[Migration] Refusing to migrate an existing database without a durable snapshot. " +
"The DATA_DIR filesystem must support atomic hard-link publication."
);
}
}
}
// Hold SQLite's native writer lock through snapshot selection, compatibility repairs,
// and the mass-safety decision. Native adapters open a separate read-only connection
// for VACUUM INTO while competing writers remain blocked. The outer transaction then
// commits before migrations so the repository's one-transaction-per-file contract stays
// intact: an earlier successful migration remains committed if a later file fails.
if (!plan)
runImmediateTransaction(db, () => {
const appliedBeforeRepair = getAppliedVersions(db);
const hadAppliedBeforeRepair = appliedBeforeRepair.size > 0;
const preliminaryAtomicReplays = findAtomicPhysicalReplays(db, files);
const preliminaryPending = files.filter(
(file) =>
!appliedBeforeRepair.has(file.version) || preliminaryAtomicReplays.has(file.version)
);
const preliminaryActionable = preliminaryPending.filter(
(migration) => !isDeferredUnsupportedMigration(db, migration)
);
const mayWriteExistingDatabase =
preliminaryActionable.length > 0 || hasLedgerRepairCandidates(db, files);
const needsSnapshot =
mayWriteExistingDatabase && db.name !== ":memory:" && databaseExistedBeforeInitialization;
if (needsSnapshot && !preMigrationBackup) {
if (db.driver === "sql.js") {
throw new Error(
"[Migration] sql.js safety state changed after its pre-transaction snapshot preflight; " +
"refusing to export from inside the rollback savepoint."
);
}
preMigrationBackup = createPreMigrationBackup(db);
if (!preMigrationBackup) {
throw new Error(
"[Migration] Refusing to migrate an existing database without a durable snapshot. " +
"The DATA_DIR filesystem must support atomic hard-link publication."
);
}
}
rehomeLegacyVersionSlotMigrations(db, files);
reconcileRenumberedMigrations(db, files);
const atomicPhysicalReplays = findAtomicPhysicalReplays(db, files);
const applied = getAppliedVersions(db);
const appliedRecords = getAppliedRecords(db);
const pending = files.filter(
(file) => !applied.has(file.version) || atomicPhysicalReplays.has(file.version)
);
const deferredUnsupported = pending.filter((migration) =>
isDeferredUnsupportedMigration(db, migration)
);
const actionablePending = pending.filter(
(migration) =>
!deferredUnsupported.some((deferred) => deferred.version === migration.version)
);
const isFreshSeedOnly =
applied.size === 1 &&
applied.has("001") &&
inferPhysicalSchemaBaseline(db) === null &&
hasTable(db, "provider_connections");
const requiresDurableBackup =
actionablePending.length > 0 &&
db.name !== ":memory:" &&
databaseExistedBeforeInitialization;
// Recompute under the same writer transaction as repairs and fail before any
// ledger mutation can commit if the durable-snapshot requirement is not met.
if (requiresDurableBackup && !preMigrationBackup) {
throw new Error(
"[Migration] Refusing to migrate an existing database without a durable snapshot. " +
"The DATA_DIR filesystem must support atomic hard-link publication."
);
}
const isTestEnvironment = isAutomatedTestProcess();
const maxPendingMigrations = resolveMaxPendingMigrations();
if (
actionablePending.length > 0 &&
!isTestEnvironment &&
!isNewDb &&
!isFreshSeedOnly &&
maxPendingMigrations > 0 &&
(applied.size > 0 || hadAppliedBeforeRepair) &&
actionablePending.length > maxPendingMigrations
) {
const physicalBaseline = inferPhysicalSchemaBaseline(db);
const plausiblePendingCount = physicalBaseline
? getPlausiblePendingCount(files, physicalBaseline.version)
: null;
if (plausiblePendingCount !== null && actionablePending.length <= plausiblePendingCount) {
console.warn(
`[Migration] Allowing ${actionablePending.length} pending migrations on an existing database ` +
`because the physical schema only proves ${physicalBaseline?.version} ` +
`(${physicalBaseline?.description}).`
);
} else {
const schemaHint =
physicalBaseline && plausiblePendingCount !== null
? ` Physical schema already shows ${physicalBaseline.version} ` +
`(${physicalBaseline.description}), so at most ${plausiblePendingCount} pending ` +
`migration(s) are expected from a legitimate upgrade.`
: "";
const bypassHint =
` To bypass this check (e.g. after restoring a backup where the migration ` +
`tracking table was wiped), set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 in your ` +
`server.env or DATA_DIR/.env and restart.`;
const msg =
`[Migration] 🛑 ABORT: Detected ${actionablePending.length} pending migrations on an existing database ` +
`(threshold is ${maxPendingMigrations}). ` +
`This usually means the migration tracking table was accidentally wiped. ` +
`Running all migrations from scratch will cause data loss or schema errors.` +
schemaHint +
bypassHint;
if (memoizedSafetyAbort && memoizedSafetyAbort.message === msg) {
console.error(
`[Migration] 🛑 ABORT (repeat — see earlier detail): ` +
`${actionablePending.length} pending > threshold ${maxPendingMigrations}. ` +
`Set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 to bypass.`
);
throw memoizedSafetyAbort;
}
console.error(msg);
memoizedSafetyAbort = new MigrationSafetyAbortError(msg);
throw memoizedSafetyAbort;
}
}
if (
preMigrationBackup &&
hashFileSync(preMigrationBackup.path) !== preMigrationBackup.sha256
) {
throw new Error(
"[Migration] Refusing to migrate because the pre-migration snapshot changed before use."
);
}
const numericApplied = Array.from(applied)
.map((version) => Number.parseInt(version, 10))
.filter((version) => !Number.isNaN(version));
const highestAppliedBeforeMigrations =
numericApplied.length > 0 ? Math.max(...numericApplied) : 0;
plan = {
atomicPhysicalReplays,
appliedRecords,
pending,
deferredUnsupported,
highestAppliedBeforeMigrations,
};
});
const {
atomicPhysicalReplays,
appliedRecords,
pending,
deferredUnsupported,
highestAppliedBeforeMigrations,
} = plan;
// ── Safety Check 1: Detect migration name mismatches (renumbering) ──
const mismatches = detectNameMismatches(appliedRecords, files);
@@ -963,34 +1018,15 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
);
}
// ── Gap Reconciliation: Identify non-contiguous missing migrations ──
// Do not rely on any highest-version-applied heuristic. We must explicitly
// iterate through all missing files on disk and apply them if they are missing
// from the _omniroute_migrations table.
const numericApplied = Array.from(applied)
.map((v) => Number.parseInt(v, 10))
.filter((n) => !Number.isNaN(n));
const highestApplied = numericApplied.length > 0 ? Math.max(...numericApplied) : 0;
const pending = files.filter((f) => {
const isMissing = !applied.has(f.version);
if (isMissing && Number(f.version) < highestApplied) {
for (const migration of pending) {
if (Number(migration.version) < highestAppliedBeforeMigrations) {
console.warn(
`[Migration] 🔄 RECONCILIATION: Found missing intermediate migration ` +
`${f.version}_${f.name} (highest applied is ${highestApplied}). ` +
`${migration.version}_${migration.name} ` +
`(highest applied is ${highestAppliedBeforeMigrations}). ` +
`This gap will be back-filled to ensure schema integrity.`
);
}
return isMissing;
});
const deferredUnsupported = pending.filter((migration) =>
isDeferredUnsupportedMigration(db, migration)
);
const actionablePending = pending.filter(
(migration) => !deferredUnsupported.some((deferred) => deferred.version === migration.version)
);
if (pending.length === 0) {
return 0; // Nothing to do
}
if (deferredUnsupported.length > 0) {
@@ -1003,101 +1039,28 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
);
}
// ── Safety Check 2: Mass-migration detection (abort if existing DB + many migrations) ──
// Skip in test environments where fresh DBs legitimately have many pending migrations.
const isTestEnvironment = isAutomatedTestProcess();
// #3416: resolve the threshold at call time so OMNIROUTE_MAX_PENDING_MIGRATIONS
// can override the default (0 disables the check). The abort message below
// interpolates this resolved value, so it auto-reflects any override.
const maxPendingMigrations = resolveMaxPendingMigrations();
// #9934: `omniroute setup`'s openOmniRouteDb writes a partial skeleton file
// (provider_connections + key_value) that has never had migrations run. When
// the first `serve` opens it and auto-seeds only the 001 marker, the applied
// set is exactly {001} — which would otherwise look like a wiped existing DB
// and trip this abort on a brand-new install. This is distinct from a real
// wiped/backup-restored database: that case has a non-trivial physical schema
// (baseline inference is non-null) and full data tables, so it still aborts.
// The 001-marker-only state on a provider_connections skeleton is the fresh
// auto-seed — let it through. A genuinely empty table is already exempt via
// `applied.size > 0`, and an upgraded DB has a non-trivial applied set.
const isFreshSeedOnly =
applied.size === 1 &&
applied.has("001") &&
inferPhysicalSchemaBaseline(db) === null &&
hasTable(db, "provider_connections");
if (
!isTestEnvironment &&
!isNewDb &&
!isFreshSeedOnly &&
process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" &&
maxPendingMigrations > 0 &&
applied.size > 0 &&
actionablePending.length > maxPendingMigrations
) {
const physicalBaseline = inferPhysicalSchemaBaseline(db);
const plausiblePendingCount = physicalBaseline
? getPlausiblePendingCount(files, physicalBaseline.version)
: null;
if (plausiblePendingCount !== null && actionablePending.length <= plausiblePendingCount) {
console.warn(
`[Migration] Allowing ${actionablePending.length} pending migrations on an existing database ` +
`because the physical schema only proves ${physicalBaseline?.version} ` +
`(${physicalBaseline?.description}).`
);
} else {
const schemaHint =
physicalBaseline && plausiblePendingCount !== null
? ` Physical schema already shows ${physicalBaseline.version} ` +
`(${physicalBaseline.description}), so at most ${plausiblePendingCount} pending ` +
`migration(s) are expected from a legitimate upgrade.`
: "";
const bypassHint =
` To bypass this check (e.g. after restoring a backup where the migration ` +
`tracking table was wiped), set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 in your ` +
`server.env or DATA_DIR/.env and restart.`;
const msg =
`[Migration] 🛑 ABORT: Detected ${actionablePending.length} pending migrations on an existing database ` +
`(threshold is ${maxPendingMigrations}). ` +
`This usually means the migration tracking table was accidentally wiped. ` +
`Running all migrations from scratch will cause data loss or schema errors.` +
schemaHint +
bypassHint;
// #6260: memoize so the cascade of downstream ensureDbInitialized() calls
// that re-open the DB throw the SAME instance and only log once.
if (memoizedSafetyAbort && memoizedSafetyAbort.message === msg) {
console.error(
`[Migration] 🛑 ABORT (repeat — see earlier detail): ` +
`${actionablePending.length} pending > threshold ${maxPendingMigrations}. ` +
`Set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 to bypass.`
);
throw memoizedSafetyAbort;
}
console.error(msg);
memoizedSafetyAbort = new MigrationSafetyAbortError(msg);
throw memoizedSafetyAbort;
}
if (preMigrationBackup && hashFileSync(preMigrationBackup.path) !== preMigrationBackup.sha256) {
throw new Error(
"[Migration] Refusing to migrate because the pre-migration snapshot changed before use."
);
}
// ── Safety Check 3: Pre-migration backup ──
// Skip backup if it's a completely fresh database (0 applied and all pending)
// or if running in tests (where AUTO_BACKUP might be disabled)
if (applied.size > 0 && process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true") {
createPreMigrationBackup(db);
}
let count = 0;
for (const migration of pending) {
if (isDeferredUnsupportedMigration(db, migration)) {
continue;
}
if (isDeferredUnsupportedMigration(db, migration)) continue;
const applyMigration = db.transaction(() => {
if (atomicPhysicalReplays.has(migration.version)) {
const removed = db
.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?")
.run(migration.version, migration.name);
if (removed.changes !== 1) {
throw new Error(
`[Migration] Atomic replay lost its expected ledger marker for ` +
`${migration.version}_${migration.name}.`
);
}
}
if (isSchemaAlreadyApplied(db, migration)) {
console.warn(
`[Migration] Skipped executing ${migration.version}_${migration.name} as schema changes are already present (Idempotency check).`
@@ -1120,29 +1083,36 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
try {
applyMigration();
count++;
count += 1;
console.log(`[Migration] Applied: ${migration.version}_${migration.name}`);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
// "duplicate column name" means the column already exists — end state achieved, mark applied.
if (message.includes("duplicate column name")) {
if (
message.includes("duplicate column name") &&
!atomicPhysicalReplays.has(migration.version)
) {
const applyMarkerOnly = db.transaction(() => {
db.prepare(
"INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)"
).run(migration.version, migration.name);
});
applyMarkerOnly();
count++;
count += 1;
console.log(
`[Migration] Applied (column pre-exists): ${migration.version}_${migration.name}`
);
} else {
console.error(`[Migration] FAILED: ${migration.version}_${migration.name}${message}`);
throw err; // Re-throw to prevent DB from starting in inconsistent state
throw err;
}
}
}
// Retention intentionally does not run inside the migration window. Another process
// may still be using a different snapshot as its in-flight restore point. Manual and
// scheduled backup paths continue to enforce the operator's retention policy; retries
// here are bounded by the deterministic content address instead of destructive pruning.
if (count > 0) {
console.log(`[Migration] ${count} migration(s) applied successfully.`);
}
@@ -1175,7 +1145,7 @@ function insertDefaultDatabaseSettings(db: SqliteAdapter) {
// Run in an immediate transaction to avoid nested transactions
try {
db.immediate(() => {
runImmediateTransaction(db, () => {
tx();
});
} catch (error) {

View File

@@ -158,6 +158,14 @@ export const RENAMED_MIGRATION_COMPATIBILITY = [
toVersion: "151",
toName: "windsurf_to_devin_desktop",
},
{
// inspector_custom_hosts was once published in slot 074, now occupied by
// discovery_results. Its canonical idempotent migration lives at 081.
fromVersion: "074",
fromName: "inspector_custom_hosts",
toVersion: "081",
toName: "inspector_custom_hosts",
},
{
fromVersion: "134",
fromName: "ccr_blocks",

View File

@@ -0,0 +1,13 @@
const isNodeTestRunnerChild = typeof process.env.NODE_TEST_CONTEXT === "string";
export const migrationConsole = {
log: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.log(...args);
},
warn: (...args: unknown[]) => {
if (!isNodeTestRunnerChild) globalThis.console.warn(...args);
},
error: (...args: unknown[]) => {
globalThis.console.error(...args);
},
};

View File

@@ -0,0 +1,293 @@
import { createHash } from "crypto";
import fs from "fs";
import path from "path";
import type { SqliteAdapter } from "../adapters/types";
import { tryOpenSync } from "../adapters/driverFactory";
import { migrationConsole as console } from "./logger";
export type PreMigrationBackupReceipt = {
path: string;
sha256: string;
};
function fsyncDirectoryEntry(directory: string): void {
let fd: number | null = null;
try {
fd = fs.openSync(directory, "r");
fs.fsyncSync(fd);
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | null)?.code;
const windowsDirectoryHandleUnsupported =
process.platform === "win32" &&
(code === "EACCES" || code === "EPERM" || code === "EISDIR" || code === "EINVAL");
if (!windowsDirectoryHandleUnsupported) throw error;
} finally {
if (fd !== null) fs.closeSync(fd);
}
}
export function hashFileSync(filePath: string): string {
const hash = createHash("sha256");
const fd = fs.openSync(filePath, "r");
const buffer = Buffer.allocUnsafe(1024 * 1024);
let position = 0;
try {
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, position);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
} finally {
fs.closeSync(fd);
}
return hash.digest("hex");
}
function getReusablePreMigrationBackup(
candidatePath: string,
expectedSha256: string
): PreMigrationBackupReceipt | null {
if (!fs.existsSync(candidatePath)) return null;
const before = fs.lstatSync(candidatePath);
if (!before.isFile() || hashFileSync(candidatePath) !== expectedSha256) {
throw new Error(
`[Migration] Content-addressed snapshot path exists with unexpected content: ${candidatePath}`
);
}
const after = fs.lstatSync(candidatePath);
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeMs !== after.mtimeMs
) {
throw new Error(
`[Migration] Content-addressed snapshot changed while it was being validated: ${candidatePath}`
);
}
return { path: candidatePath, sha256: expectedSha256 };
}
function publishSnapshotWithoutOverwrite(tempPath: string, destination: string): void {
// link() publishes a complete same-filesystem image atomically and, unlike rename(),
// fails with EEXIST instead of overwriting a path created by another process. There is
// deliberately no copy/rename fallback: filesystems without this primitive fail closed
// instead of exposing a partial canonical `.sqlite` file after a crash.
fs.linkSync(tempPath, destination);
const publishedFd = fs.openSync(destination, "r+");
try {
// Flush through the published name as well as the already-fsynced temp handle.
// On Windows this maps to FlushFileBuffers and is the strongest file-level
// durability proof available when directory handles are unsupported by Node.
fs.fsyncSync(publishedFd);
} finally {
fs.closeSync(publishedFd);
}
fsyncDirectoryEntry(path.dirname(destination));
}
function fsyncReusableSnapshot(snapshotPath: string): void {
const fd = fs.openSync(snapshotPath, "r+");
try {
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
}
type SqlJsSnapshotClone = {
run(sql: string): void;
export(): Uint8Array;
close(): void;
};
const SQLITE_HEADER_MIN_BYTES = 100;
const SQLITE_HEADER_MAGIC = "SQLite format 3\0";
const SQLITE_CHANGE_COUNTER_OFFSET = 24;
const SQLITE_VERSION_VALID_FOR_OFFSET = 92;
const SQLITE_STANDALONE_CHANGE_COUNTER = 1;
function exportCanonicalSqlJsSnapshot(raw: { export: () => Uint8Array }): Buffer {
const RawDatabase = (
raw as unknown as { constructor: new (data: Uint8Array) => SqlJsSnapshotClone }
).constructor;
let clone: SqlJsSnapshotClone | null = null;
try {
// A rolled-back sql.js SAVEPOINT can leave SQLite's physical change counter advanced
// even though every logical row/schema change was undone. Canonicalize only a detached
// clone: VACUUM removes rollback-only page artifacts without touching the live database.
clone = new RawDatabase(raw.export());
clone.run("VACUUM");
const canonical = Buffer.from(clone.export());
if (
canonical.length < SQLITE_HEADER_MIN_BYTES ||
canonical.subarray(0, SQLITE_HEADER_MAGIC.length).toString("binary") !== SQLITE_HEADER_MAGIC
) {
throw new Error("sql.js export did not produce a valid SQLite file header");
}
// SQLite file-header offsets 24 and 92 are the change counter and
// version-valid-for number. VACUUM keeps the two equal, but seeds them from the
// source image, so an otherwise identical rolled-back retry still gets a different
// byte hash. A standalone snapshot has no open readers to invalidate; assigning the
// same stable value to both fields preserves a valid/restorable header while making
// the complete canonical image deterministic.
canonical.writeUInt32BE(SQLITE_STANDALONE_CHANGE_COUNTER, SQLITE_CHANGE_COUNTER_OFFSET);
canonical.writeUInt32BE(SQLITE_STANDALONE_CHANGE_COUNTER, SQLITE_VERSION_VALID_FOR_OFFSET);
return canonical;
} finally {
clone?.close();
}
}
function writeSqlJsSnapshot(raw: { export: () => Uint8Array }, tempPath: string): void {
let fd: number | null = null;
try {
fd = fs.openSync(tempPath, "wx");
fs.writeFileSync(fd, exportCanonicalSqlJsSnapshot(raw));
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = null;
} catch (error: unknown) {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
// The original snapshot error remains authoritative.
}
}
throw error;
}
}
function cleanupOwnedSnapshotTemp(tempDir: string | null, tempPath: string | null): void {
if (!tempDir || !fs.existsSync(tempDir)) return;
try {
// `tempDir` comes only from mkdtempSync below. Removing that exact owned directory
// lets Node retry Windows/AV EBUSY and EPERM failures without touching canonical backups.
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[Migration] Failed to remove owned snapshot temp directory` +
`${tempPath ? ` (${tempPath})` : ""}: ${message}`
);
}
}
/**
* Create a synchronous pre-migration snapshot.
*
* Native SQLite drivers use VACUUM INTO. sql.js has an in-memory VFS, so a host
* path passed to VACUUM INTO is not writable; export its current database image
* directly instead. The SHA-256 content address lives in the first portion of the
* canonical `db_<snapshot-id>_<reason>.sqlite` shape, preserving reason parsing while
* making unchanged retries an O(1) lookup even with tens of thousands of old backups.
* Work happens inside an exclusively-created
* temp directory, so failure cleanup has exact ownership. Publication uses an atomic,
* no-overwrite hard link. If the filesystem cannot provide that primitive, the caller
* fails closed instead of exposing a partial canonical `.sqlite` file. A content hash
* reuses an identical prior snapshot, so repeated zero-progress startups retain one
* restore point for that database state without ever deleting a published backup.
*/
export function createPreMigrationBackup(db: SqliteAdapter): PreMigrationBackupReceipt | null {
let backupPath: string | null = null;
let tempPath: string | null = null;
let tempDir: string | null = null;
try {
const sqliteFile = db.name;
if (!sqliteFile || sqliteFile === ":memory:") return null;
const backupDir = path.join(path.dirname(sqliteFile), "db_backups");
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
fsyncDirectoryEntry(path.dirname(backupDir));
}
tempDir = fs.mkdtempSync(path.join(backupDir, ".migration-snapshot-"));
tempPath = path.join(tempDir, "snapshot.sqlite");
if (db.driver === "sql.js") {
const raw = db.raw as { export?: () => Uint8Array } | null;
if (!raw || typeof raw.export !== "function") {
throw new Error("sql.js adapter does not expose database export()");
}
writeSqlJsSnapshot(raw as { export: () => Uint8Array }, tempPath);
} else {
const escapedTempPath = tempPath.replace(/'/g, "''");
const snapshotDb = tryOpenSync(sqliteFile, { readonly: true, fileMustExist: true });
if (!snapshotDb) {
throw new Error("no synchronous read-only SQLite driver is available for snapshotting");
}
try {
snapshotDb.exec(`VACUUM INTO '${escapedTempPath}'`);
} finally {
snapshotDb.close();
}
const fd = fs.openSync(tempPath, "r+");
try {
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
}
const sha256 = hashFileSync(tempPath);
backupPath = path.join(backupDir, `db_state-${sha256}_pre-migration.sqlite`);
const reusable = getReusablePreMigrationBackup(backupPath, sha256);
if (reusable) {
fsyncReusableSnapshot(reusable.path);
fsyncDirectoryEntry(backupDir);
cleanupOwnedSnapshotTemp(tempDir, tempPath);
tempDir = null;
tempPath = null;
console.log(`[Migration] Reusing identical pre-migration backup: ${reusable.path}`);
return reusable;
}
try {
publishSnapshotWithoutOverwrite(tempPath, backupPath);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException | null)?.code !== "EEXIST") throw error;
const racedReusable = getReusablePreMigrationBackup(backupPath, sha256);
if (!racedReusable) throw error;
fsyncReusableSnapshot(racedReusable.path);
fsyncDirectoryEntry(backupDir);
cleanupOwnedSnapshotTemp(tempDir, tempPath);
tempDir = null;
tempPath = null;
console.log(`[Migration] Reusing concurrently published backup: ${racedReusable.path}`);
return racedReusable;
}
cleanupOwnedSnapshotTemp(tempDir, tempPath);
tempDir = null;
tempPath = null;
console.log(`[Migration] Pre-migration backup created: ${backupPath}`);
return { path: backupPath, sha256 };
} catch (error: unknown) {
// Never unlink a canonical backup here: publication may have failed because another
// actor created it first. The exclusive temp directory is the only cleanup authority.
cleanupOwnedSnapshotTemp(tempDir, tempPath);
const message = error instanceof Error ? error.message : String(error);
console.warn(`[Migration] Failed to create pre-migration backup: ${message}`);
throw new Error(
`[Migration] Refusing to migrate an existing database without a durable snapshot. ` +
`Snapshot creation failed: ${message}. The DATA_DIR filesystem must support atomic ` +
`no-overwrite hard links, durable file synchronization, and directory synchronization ` +
`where the platform exposes it.`,
{ cause: error instanceof Error ? error : undefined }
);
}
}

View File

@@ -0,0 +1,248 @@
import type { SqliteAdapter } from "../adapters/types";
import {
INITIAL_SCHEMA_SENTINELS,
LEGACY_VERSION_SLOT_MIGRATIONS,
PHYSICAL_SCHEMA_SENTINELS,
RENAMED_MIGRATION_COMPATIBILITY,
} from "./constants";
import { migrationConsole as console } from "./logger";
type MigrationFile = { version: string; name: string; path: string };
export function hasTable(db: SqliteAdapter, tableName: string): boolean {
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?")
.get(tableName) as { name?: string } | undefined;
return Boolean(row?.name);
}
export function hasPhysicalTable(db: SqliteAdapter, tableName: string): boolean {
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { name?: string } | undefined;
return Boolean(row?.name);
}
export function hasColumn(db: SqliteAdapter, tableName: string, columnName: string): boolean {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return columns.some((column) => column.name === columnName);
}
export function inferPhysicalSchemaBaseline(db: SqliteAdapter): {
version: string;
description: string;
} | null {
for (const sentinel of PHYSICAL_SCHEMA_SENTINELS) {
if (hasTable(db, sentinel.tableName)) {
return {
version: sentinel.version,
description: sentinel.description,
};
}
}
const hasInitialSchema = INITIAL_SCHEMA_SENTINELS.every((tableName) => hasTable(db, tableName));
if (hasInitialSchema) {
return {
version: "001",
description: "initial schema tables",
};
}
return null;
}
export function getPlausiblePendingCount(files: MigrationFile[], baselineVersion: string): number {
const baseline = Number.parseInt(baselineVersion, 10);
return files.filter((file) => Number.parseInt(file.version, 10) > baseline).length;
}
/**
* Detect migration name mismatches — when a migration version number
* has been reused/renumbered with a different name. This is a strong signal
* that the migration tracking is corrupted or migrations were renumbered.
*/
export function detectNameMismatches(
appliedRecords: Array<{ version: string; name: string }>,
files: MigrationFile[]
): Array<{ version: string; appliedName: string; diskName: string }> {
const appliedByName = new Map(appliedRecords.map((record) => [record.version, record.name]));
const mismatches: Array<{ version: string; appliedName: string; diskName: string }> = [];
for (const file of files) {
const appliedName = appliedByName.get(file.version);
if (appliedName && appliedName !== file.name) {
mismatches.push({
version: file.version,
appliedName,
diskName: file.name,
});
}
}
return mismatches;
}
export function reconcileRenumberedMigrations(db: SqliteAdapter, files: MigrationFile[]): boolean {
let repaired = false;
for (const compatibility of RENAMED_MIGRATION_COMPATIBILITY) {
const hasTargetFile = files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
);
const hasSourceFile = files.some(
(file) => file.version === compatibility.fromVersion && file.name !== compatibility.fromName
);
if (!hasTargetFile || !hasSourceFile) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(compatibility.fromVersion, compatibility.fromName) as
{ version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const targetRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.toVersion) as { version: string; name: string } | undefined;
const isSameSlotReplacement = compatibility.fromVersion === compatibility.toVersion;
if (targetRow && !isSameSlotReplacement && targetRow.name !== compatibility.toName) {
throw new Error(
`[Migration] Cannot reconcile ${compatibility.fromVersion}_${compatibility.fromName}: ` +
`target version ${compatibility.toVersion} is occupied by unknown migration ` +
`"${targetRow.name}" (expected "${compatibility.toName}").`
);
}
const applyRepair = db.transaction(() => {
if (targetRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
compatibility.fromVersion,
compatibility.fromName
);
} else {
db.prepare(
"UPDATE _omniroute_migrations SET version = ?, name = ? WHERE version = ? AND name = ?"
).run(
compatibility.toVersion,
compatibility.toName,
compatibility.fromVersion,
compatibility.fromName
);
}
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Reconciled renamed migration ${compatibility.fromVersion}_${compatibility.fromName} ` +
`to ${compatibility.toVersion}_${compatibility.toName} to preserve pending migrations.`
);
// After the compat rewrite, verify the old version slot is now free.
// A residual row (from a failed prior run, manual intervention, or edge-case
// UPDATE conflict) at the old version would shadow a NEW migration file
// placed at that version number — e.g. 028_create_files_and_batches.sql
// would be skipped because getAppliedVersions() still sees version "028".
const residualRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?")
.get(compatibility.fromVersion) as { version: string; name: string } | undefined;
if (residualRow) {
console.warn(
`[Migration] ⚠️ Residual row at version ${compatibility.fromVersion} ` +
`(name: "${residualRow.name}") still present after compat rewrite — ` +
`removing to unblock new migration at this version slot.`
);
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ?").run(
compatibility.fromVersion
);
}
}
return repaired;
}
export function rehomeLegacyVersionSlotMigrations(
db: SqliteAdapter,
files: MigrationFile[]
): boolean {
let repaired = false;
const diskNamesByVersion = new Map(files.map((file) => [file.version, file.name]));
for (const legacy of LEGACY_VERSION_SLOT_MIGRATIONS) {
const diskName = diskNamesByVersion.get(legacy.version);
if (!diskName || diskName === legacy.name) {
continue;
}
const legacyRow = db
.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(legacy.version, legacy.name) as { version: string; name: string } | undefined;
if (!legacyRow) {
continue;
}
const legacyVersion = `legacy-${legacy.version}-${legacy.name}`;
const applyRepair = db.transaction(() => {
const existingLegacyRow = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?")
.get(legacyVersion) as { version: string } | undefined;
if (existingLegacyRow) {
db.prepare("DELETE FROM _omniroute_migrations WHERE version = ? AND name = ?").run(
legacy.version,
legacy.name
);
return;
}
db.prepare("UPDATE _omniroute_migrations SET version = ? WHERE version = ? AND name = ?").run(
legacyVersion,
legacy.version,
legacy.name
);
});
applyRepair();
repaired = true;
console.warn(
`[Migration] Rehomed legacy migration ${legacy.version}_${legacy.name} ` +
`to ${legacyVersion} so current ${legacy.version}_${diskName} can apply.`
);
}
return repaired;
}
export function hasLedgerRepairCandidates(db: SqliteAdapter, files: MigrationFile[]): boolean {
const diskNamesByVersion = new Map(files.map((file) => [file.version, file.name]));
for (const legacy of LEGACY_VERSION_SLOT_MIGRATIONS) {
const diskName = diskNamesByVersion.get(legacy.version);
if (!diskName || diskName === legacy.name) continue;
const row = db
.prepare("SELECT 1 FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(legacy.version, legacy.name);
if (row) return true;
}
for (const compatibility of RENAMED_MIGRATION_COMPATIBILITY) {
const hasTargetFile = files.some(
(file) => file.version === compatibility.toVersion && file.name === compatibility.toName
);
const hasSourceFile = files.some(
(file) => file.version === compatibility.fromVersion && file.name !== compatibility.fromName
);
if (!hasTargetFile || !hasSourceFile) continue;
const row = db
.prepare("SELECT 1 FROM _omniroute_migrations WHERE version = ? AND name = ?")
.get(compatibility.fromVersion, compatibility.fromName);
if (row) return true;
}
return false;
}

View File

@@ -1,5 +1,9 @@
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
import { CREDENTIAL_PATTERNS } from "@omniroute/open-sse/utils/credentialPatterns.ts";
import { getSettings } from "@/lib/db/settings";
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
export { CREDENTIAL_PATTERNS };
export type { CredentialPattern } from "@omniroute/open-sse/utils/credentialPatterns.ts";
/**
* CredentialMaskerGuardrail — redacts well-known API-key / secret-token patterns
@@ -11,88 +15,6 @@ import { getSettings } from "@/lib/db/settings";
* Future: per-pipeline / per-provider scoping via GuardrailContext.
*/
export interface CredentialPattern {
name: string;
regex: RegExp;
replacement: string;
}
export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
// ── LLM provider keys ──────────────────────────────────────────────────
{ name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" },
{ name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" },
{
name: "anthropic",
regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{
name: "anthropic_alt",
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{ name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" },
{ name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" },
{ name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" },
// ── VCS / SaaS tokens ──────────────────────────────────────────────────
{ name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" },
{ name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" },
{ name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" },
{ name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" },
{ name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" },
{ name: "postman", regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g, replacement: "[REDACTED:postman]" },
{
name: "discord",
regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g,
replacement: "[REDACTED:discord]",
},
// ── Payments ───────────────────────────────────────────────────────────
{
name: "stripe",
regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g,
replacement: "[REDACTED:stripe]",
},
{
name: "square",
regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g,
replacement: "[REDACTED:square]",
},
// ── Cloud / infra ──────────────────────────────────────────────────────
{ name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" },
{ name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" },
{
name: "sendgrid",
regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
replacement: "[REDACTED:sendgrid]",
},
{ name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" },
// ── Crypto / identity ──────────────────────────────────────────────────
{
name: "private_key",
regex:
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
replacement: "[REDACTED:private_key]",
},
{
name: "jwt",
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
replacement: "[REDACTED:jwt]",
},
// ── Connection strings (creds embedded in URI) ─────────────────────────
{
name: "connection_string",
regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g,
replacement: "[REDACTED:connection_string]",
},
// ── Header-style secrets ───────────────────────────────────────────────
{
name: "auth_header",
regex:
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
replacement: "$1[REDACTED:auth_header]",
},
];
export interface CredentialRedactionResult {
text: string;
detections: Array<{ type: string; count: number }>;

View File

@@ -1,3 +1,8 @@
import {
sanitizeErrorMessage,
sanitizeUpstreamDetails,
} from "@omniroute/open-sse/utils/errorSanitization.ts";
import { projectResponsesFailureOutput } from "@omniroute/open-sse/utils/responsesFailureOutput.ts";
import { sanitizePII } from "./piiSanitizer";
const SENSITIVE_KEYS = new Set([
@@ -35,6 +40,21 @@ const SENSITIVE_KEYS = new Set([
"runtimeKey",
]);
const SENSITIVE_CHALLENGE_KEYS = new Set([
"recaptchav3token",
"recaptchatoken",
"turnstiletoken",
"prooftoken",
"resumetoken",
"preparetoken",
]);
function isSensitivePayloadKey(key: string): boolean {
if (SENSITIVE_KEYS.has(key)) return true;
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
return SENSITIVE_CHALLENGE_KEYS.has(normalizedKey);
}
type JsonRecord = Record<string, unknown>;
const ENCRYPTED_REASONING_KEY = "encrypted_content";
@@ -60,6 +80,283 @@ export function omitEncryptedReasoningFromLogChunks(chunks: string[]): string[]
return found ? [omitted] : chunks;
}
const ERROR_SUBTREE_KEYS = new Set([
"error",
"errors",
"warning",
"warnings",
"errormessage",
"warningmessage",
"errordescription",
"warningdescription",
"lasterror",
]);
function isErrorSubtreeKey(key: string): boolean {
return ERROR_SUBTREE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase());
}
function sanitizeErrorSubtreeValue(value: unknown): unknown {
if (typeof value === "string") return sanitizeErrorMessage(value);
try {
if (value instanceof Error) {
return {
name: sanitizeErrorMessage(value.name) || "Error",
message: sanitizeErrorMessage(value.message),
};
}
return sanitizeUpstreamDetails(value);
} catch {
return "[REDACTED]";
}
}
type ErrorSubtreeProjection = { value: unknown; found: boolean };
function projectErrorSubtreesForLog(
value: unknown,
seen = new WeakSet<object>(),
forceResponsesFailure = false,
protocolResponseObject = false
): ErrorSubtreeProjection {
if (forceResponsesFailure && typeof value === "string") {
return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true };
}
if (typeof value === "string") {
const trimmed = value.trim();
if (
(trimmed.startsWith("{") || trimmed.startsWith("[")) &&
STREAM_ERROR_ENVELOPE_RE.test(trimmed)
) {
try {
const parsed: unknown = JSON.parse(trimmed);
const projected = isDiscriminatedStreamError(parsed)
? { value: sanitizeErrorSubtreeValue(parsed), found: true }
: projectErrorSubtreesForLog(parsed, seen);
if (projected.found) {
const serialized = JSON.stringify(projected.value);
if (typeof serialized === "string") return { value: serialized, found: true };
}
} catch {
return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true };
}
}
return { value, found: false };
}
if (value === null || value === undefined || typeof value !== "object") {
return { value, found: false };
}
if (isOpaqueBinary(value)) return { value, found: false };
if (isDiscriminatedStreamError(value)) {
return { value: sanitizeErrorSubtreeValue(value), found: true };
}
const declaresResponsesFailure = isResponsesFailureEvent(value);
const responsesFailure = forceResponsesFailure || declaresResponsesFailure;
if (seen.has(value)) return { value: "[circular]", found: false };
seen.add(value);
if (Array.isArray(value)) {
try {
let found = false;
const projected = value.map((entry) => {
const result = projectErrorSubtreesForLog(entry, seen, responsesFailure, false);
found ||= result.found;
return result.value;
});
return { value: projected, found };
} finally {
seen.delete(value);
}
}
try {
let found = responsesFailure;
const projected: JsonRecord = {};
for (const [key, entryValue] of Object.entries(value)) {
if (isErrorSubtreeKey(key) || (responsesFailure && isResponseFailureMessageKey(key))) {
projected[key] = sanitizeErrorSubtreeValue(entryValue);
found = true;
continue;
}
// Responses failures may attach diagnostics under neutral key names. Keep
// projecting through that envelope, while preserving partial model output
// as content rather than treating it as an error message.
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
const preservePartialOutput =
responsesFailure &&
normalizedKey === "output" &&
(protocolResponseObject || declaresResponsesFailure);
if (preservePartialOutput) {
projected[key] = projectResponsesFailureOutput(
entryValue,
(_field, stringValue) => sanitizeErrorMessage(stringValue) || "[REDACTED]"
);
found = true;
continue;
}
const childIsProtocolResponse =
normalizedKey === "response" &&
(declaresResponsesFailure || (forceResponsesFailure && !protocolResponseObject));
const result = projectErrorSubtreesForLog(
entryValue,
seen,
responsesFailure,
childIsProtocolResponse
);
projected[key] = result.value;
found ||= result.found;
}
return { value: projected, found };
} catch {
return { value: "[REDACTED]", found: false };
} finally {
seen.delete(value);
}
}
const STREAM_ERROR_DISCRIMINATOR_KEYS = ["type", "event", "kind", "status"] as const;
const STREAM_ERROR_DISCRIMINATORS = new Set(["error", "warning"]);
const RESPONSES_FAILURE_DISCRIMINATORS = new Set(["response.failed"]);
const RESPONSE_FAILURE_MESSAGE_KEYS = new Set(["message", "detail", "details", "description"]);
const STREAM_ERROR_ENVELOPE_RE =
/["'](?:error|errors|warning|warnings|last_error|lastError|errorMessage|warningMessage)["']\s*:|["'](?:type|event|kind)["']\s*:\s*["'](?:error|warning|response\.(?:failed|completed))["']|["']status["']\s*:\s*["']failed["']/i;
function isResponseFailureMessageKey(key: string): boolean {
return RESPONSE_FAILURE_MESSAGE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase());
}
function isResponsesFailureEvent(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
try {
const record = value as JsonRecord;
const directFailure = STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => {
const discriminator = record[key];
return (
typeof discriminator === "string" &&
RESPONSES_FAILURE_DISCRIMINATORS.has(discriminator.trim().toLowerCase())
);
});
if (directFailure) return true;
const status = record.status;
if (typeof status === "string" && status.trim().toLowerCase() === "failed") return true;
const nestedResponse = record.response;
if (!nestedResponse || typeof nestedResponse !== "object" || Array.isArray(nestedResponse)) {
return false;
}
const nestedStatus = (nestedResponse as JsonRecord).status;
return typeof nestedStatus === "string" && nestedStatus.trim().toLowerCase() === "failed";
} catch {
return true;
}
}
function isDiscriminatedStreamError(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
try {
const record = value as JsonRecord;
return STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => {
const discriminator = record[key];
return (
typeof discriminator === "string" &&
STREAM_ERROR_DISCRIMINATORS.has(discriminator.trim().toLowerCase())
);
});
} catch {
return true;
}
}
function sanitizeStreamErrorPayload(
rawPayload: string,
forceError: boolean,
forceResponsesFailure = false
): { found: boolean; value: string } {
try {
const parsed: unknown = JSON.parse(rawPayload);
if (forceError || isDiscriminatedStreamError(parsed)) {
const projected = sanitizeErrorSubtreeValue(parsed);
const serialized = JSON.stringify(projected);
return {
found: true,
value: typeof serialized === "string" ? serialized : "[REDACTED]",
};
}
const projected = projectErrorSubtreesForLog(
parsed,
new WeakSet<object>(),
forceResponsesFailure
);
if (!projected.found) return { found: false, value: rawPayload };
return { found: true, value: JSON.stringify(projected.value) };
} catch {
if (!forceError && !forceResponsesFailure && !STREAM_ERROR_ENVELOPE_RE.test(rawPayload)) {
return { found: false, value: rawPayload };
}
return {
found: true,
value: sanitizeErrorMessage(rawPayload) || "[REDACTED]",
};
}
}
/**
* Sanitize error/warning records captured as fragmented SSE or NDJSON text.
* Prefixes are matched at the start of a line so unrelated `metadata:` fields
* cannot be mistaken for SSE `data:` frames.
*/
export function sanitizeErrorFramesFromLogChunks(chunks: string[]): string[] {
const combined = chunks.map((chunk) => chunk.replace(STREAM_CHUNK_TIMESTAMP_RE, "")).join("");
let found = false;
let errorEventActive = false;
let responsesFailureEventActive = false;
const projectedLines = combined.split("\n").map((line) => {
if (line.trim().length === 0) {
errorEventActive = false;
responsesFailureEventActive = false;
return line;
}
const eventMatch = line.match(/^\s*event:\s*([^\s]+)\s*$/i);
if (eventMatch) {
const eventName = eventMatch[1].toLowerCase();
errorEventActive = STREAM_ERROR_DISCRIMINATORS.has(eventName);
responsesFailureEventActive = RESPONSES_FAILURE_DISCRIMINATORS.has(eventName);
return line;
}
const dataMatch = line.match(/^(\s*data:)([ \t]?)(.*)$/);
if (dataMatch) {
const rawPayload = dataMatch[3].trim();
if (!rawPayload || rawPayload === "[DONE]") return line;
const projected = sanitizeStreamErrorPayload(
rawPayload,
errorEventActive,
responsesFailureEventActive
);
if (!projected.found) return line;
found = true;
return `${dataMatch[1]}${dataMatch[2]}${projected.value}`;
}
if (errorEventActive || responsesFailureEventActive) {
found = true;
return sanitizeErrorMessage(line) || "[REDACTED]";
}
const trimmed = line.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return line;
const projected = sanitizeStreamErrorPayload(trimmed, false);
if (!projected.found) return line;
found = true;
return `${line.slice(0, line.length - line.trimStart().length)}${projected.value}`;
});
return found ? [projectedLines.join("\n")] : chunks;
}
/**
* True for any binary/opaque byte view (Uint8Array, Buffer, DataView, other
* typed arrays). `Array.isArray()` returns false for these, so callers that
@@ -125,7 +422,7 @@ export function redactPayload(payload: unknown): unknown {
const redacted: JsonRecord = {};
for (const [key, value] of Object.entries(payload)) {
if (SENSITIVE_KEYS.has(key)) {
if (isSensitivePayloadKey(key)) {
redacted[key] = "[REDACTED]";
} else if (typeof value === "string" && value.startsWith("Bearer ")) {
redacted[key] = "Bearer [REDACTED]";
@@ -162,7 +459,19 @@ export function sanitizePayloadPII(payload: unknown): unknown {
export function protectPayloadForLog(payload: unknown): unknown {
if (payload === null || payload === undefined) return null;
const normalized = normalizePayloadForLog(payload);
const reasoningOmitted = omitEncryptedReasoningForLog(normalized);
const errorProjected = projectErrorSubtreesForLog(normalized).value;
const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected);
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
return redactPayload(piiSanitized);
}
/** Project every string leaf because the payload is known to represent a failed response. */
export function protectErrorPayloadForLog(payload: unknown): unknown {
if (payload === null || payload === undefined) return null;
const normalized = normalizePayloadForLog(payload);
if (isOpaqueBinary(normalized)) return describeOpaqueBinary(normalized);
const errorProjected = sanitizeErrorSubtreeValue(normalized);
const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected);
const piiSanitized = sanitizePayloadPII(reasoningOmitted);
return redactPayload(piiSanitized);
}

View File

@@ -1,6 +1,7 @@
// Outbound fetch wrappers for provider validation: proxy-fallback, SSRF-aware proxy targeting, and
// error→result mapping. Extracted from validation.ts (god-file decomposition). Behavior is
// byte-identical to the original inline defs.
// error→result mapping. Extracted from validation.ts (god-file decomposition) and kept as the
// common boundary for sanitizing validation failures.
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import {
SAFE_OUTBOUND_FETCH_PRESETS,
SafeOutboundFetchError,
@@ -11,6 +12,28 @@ import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
export type ProjectedProviderValidationResult<T> = {
[K in keyof T]: K extends "error" | "warning" ? string | null : T[K];
} & {
error?: string | null;
warning?: string | null;
};
export function projectProviderValidationResultForPublicResponse<
T extends { error?: unknown; warning?: unknown },
>(result: T): ProjectedProviderValidationResult<T>;
export function projectProviderValidationResultForPublicResponse(
result: Record<string, unknown>
): Record<string, unknown> {
const projected: Record<string, unknown> = { ...result };
for (const field of ["error", "warning"] as const) {
if (!Object.prototype.hasOwnProperty.call(result, field)) continue;
const value = result[field];
projected[field] = value === null || value === undefined ? null : sanitizeErrorMessage(value);
}
return projected;
}
/**
* Wrapped fetch call that auto-retries with a proxy when the direct connection
* fails. This happens transparently so individual validators don't need to
@@ -156,17 +179,30 @@ export function toWebCookieValidationErrorResult(provider: string, error: unknow
}
export function toValidationErrorResult(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "Validation failed");
const statusCode = getSafeOutboundFetchErrorStatus(error);
let rawMessage: unknown = error || "Validation failed";
try {
if (error instanceof Error) rawMessage = error.message;
} catch {
rawMessage = "Validation failed";
}
const message = sanitizeErrorMessage(rawMessage);
let statusCode: number | null = null;
let timeout = false;
let securityBlocked = false;
try {
statusCode = getSafeOutboundFetchErrorStatus(error);
timeout = error instanceof SafeOutboundFetchError && error.code === "TIMEOUT";
securityBlocked = isSecurityBlockError(error);
} catch {
// Classification is advisory; hostile accessors must not escape the safe error boundary.
}
return {
valid: false,
error: message || "Validation failed",
unsupported: false as const,
...(statusCode ? { statusCode } : {}),
...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"
? { timeout: true }
: {}),
...(isSecurityBlockError(error) ? { securityBlocked: true } : {}),
...(timeout ? { timeout: true } : {}),
...(securityBlocked ? { securityBlocked: true } : {}),
};
}

View File

@@ -7,6 +7,7 @@
* Pattern follows callLogs.js (T-15 decomposition).
*/
import { v4 as uuidv4 } from "uuid";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { getDbInstance, isCloud, isBuildPhase } from "./db/core";
import { ensureProxyLogsColumns } from "./db/schemaColumns";
@@ -99,7 +100,10 @@ function loadFromDb() {
console.log(`[proxyLogger] Loaded ${proxyLogs.length} proxy logs from SQLite`);
}
} catch (err: any) {
console.warn("[proxyLogger] Failed to load from DB:", err.message);
console.warn(
"[proxyLogger] Failed to load from DB:",
sanitizeErrorMessage(err) || "Proxy log hydration failed"
);
}
}
@@ -113,10 +117,7 @@ loadFromDb();
/** Read at call time so tests can toggle it between imports. */
export function isProxyLogIncludeIps(): boolean {
return (
process.env.PROXY_LOG_INCLUDE_IPS === "true" ||
process.env.PROXY_LOG_INCLUDE_IPS === "1"
);
return process.env.PROXY_LOG_INCLUDE_IPS === "true" || process.env.PROXY_LOG_INCLUDE_IPS === "1";
}
/**
@@ -152,6 +153,10 @@ export function formatProxyEgressConsoleLine(params: {
// ──────────────── Log a proxy event ────────────────
export function logProxyEvent(entry: ProxyLogInput) {
const safeError =
entry.error === null || entry.error === undefined || entry.error === ""
? null
: sanitizeErrorMessage(entry.error) || "Proxy request failed";
const log: ProxyLogEntry = {
id: uuidv4(),
timestamp: new Date().toISOString(),
@@ -164,7 +169,7 @@ export function logProxyEvent(entry: ProxyLogInput) {
clientIp: entry.clientIp ?? entry.publicIp ?? null,
egressIp: entry.egressIp ?? null,
latencyMs: entry.latencyMs || 0,
error: entry.error || null,
error: safeError,
connectionId: entry.connectionId || null,
comboId: entry.comboId || null,
account: entry.account || null,
@@ -236,15 +241,17 @@ export function flushProxyLogsSync() {
// 1. If Redis driver is active, asynchronously publish batch to Redis Stream/Channel
if (process.env.QUOTA_STORE_DRIVER === "redis" || process.env.QUOTA_STORE_REDIS_URL) {
try {
import("@/lib/quota/redisQuotaStore").then(({ getRedisQuotaStore }) => {
const store = getRedisQuotaStore(process.env.QUOTA_STORE_REDIS_URL || "");
const client = (store as any)?.client;
if (client && typeof client.publish === "function") {
for (const entry of batch) {
client.publish("omniroute:proxy_logs", JSON.stringify(entry)).catch(() => {});
import("@/lib/quota/redisQuotaStore")
.then(({ getRedisQuotaStore }) => {
const store = getRedisQuotaStore(process.env.QUOTA_STORE_REDIS_URL || "");
const client = (store as any)?.client;
if (client && typeof client.publish === "function") {
for (const entry of batch) {
client.publish("omniroute:proxy_logs", JSON.stringify(entry)).catch(() => {});
}
}
}
}).catch(() => {});
})
.catch(() => {});
} catch {
/* ignore redis pub errors */
}
@@ -289,7 +296,10 @@ export function flushProxyLogsSync() {
transaction(batch);
} catch (err: any) {
console.warn("[proxyLogger] Failed to write proxy log batch to disk:", err?.message || err);
console.warn(
"[proxyLogger] Failed to write proxy log batch to disk:",
sanitizeErrorMessage(err) || "Proxy log persistence failed"
);
}
}
@@ -351,7 +361,10 @@ export function clearProxyLogs() {
const db = getDbInstance();
db.prepare("DELETE FROM proxy_logs").run();
} catch (err: any) {
console.warn("[proxyLogger] Failed to clear DB:", err.message);
console.warn(
"[proxyLogger] Failed to clear DB:",
sanitizeErrorMessage(err) || "Proxy log cleanup failed"
);
}
}
}

View File

@@ -1,3 +1,8 @@
import {
sanitizeErrorMessage,
sanitizeUpstreamDetails,
} from "@omniroute/open-sse/utils/errorSanitization.ts";
import { skillRegistry } from "./registry";
import { SkillExecution, SkillStatus, SkillHandler } from "./types";
import { builtinSkills } from "./builtins";
@@ -8,6 +13,169 @@ import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("SKILLS_EXECUTOR");
function toSafeSkillErrorMessage(value: unknown): string {
try {
const raw = value instanceof Error ? value.message : value;
return sanitizeErrorMessage(raw) || "Skill execution failed";
} catch {
return "Skill execution failed";
}
}
const SKILL_FAILURE_DISCRIMINATORS = new Set(["error", "failed", "failure"]);
function isSkillErrorKey(key: string): boolean {
const normalizedKey = key.replace(/[-_]/g, "").toLowerCase();
return (
normalizedKey === "error" ||
normalizedKey === "errors" ||
normalizedKey === "warning" ||
normalizedKey === "warnings"
);
}
function isFailureDiscriminator(value: unknown): boolean {
return typeof value === "string" && SKILL_FAILURE_DISCRIMINATORS.has(value.trim().toLowerCase());
}
function isSkillFailureOutput(output: Record<string, unknown>): boolean {
try {
const status = output.status;
return (
output.success === false ||
(typeof status === "number" && Number.isFinite(status) && status >= 400) ||
isFailureDiscriminator(status) ||
isFailureDiscriminator(output.type) ||
isFailureDiscriminator(output.event) ||
isFailureDiscriminator(output.kind)
);
} catch {
return true;
}
}
type SensitiveSkillReferences = {
objects: WeakSet<object>;
strings: Set<string>;
};
function markSensitiveSkillReference(value: unknown, sensitive: SensitiveSkillReferences): void {
if (typeof value === "string") {
sensitive.strings.add(value);
return;
}
if (!value || typeof value !== "object" || sensitive.objects.has(value)) return;
sensitive.objects.add(value);
try {
for (const entry of Object.values(value as Record<string, unknown>)) {
markSensitiveSkillReference(entry, sensitive);
}
} catch {
// A revoked proxy or throwing getter is unsafe to expose at the boundary.
}
}
function collectSensitiveSkillReferences(
value: unknown,
sensitive: SensitiveSkillReferences,
visited: WeakSet<object>
): void {
if (!value || typeof value !== "object" || visited.has(value)) return;
visited.add(value);
try {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (isSkillErrorKey(key)) {
markSensitiveSkillReference(entry, sensitive);
} else {
collectSensitiveSkillReferences(entry, sensitive, visited);
}
}
} catch {
markSensitiveSkillReference(value, sensitive);
}
}
type SkillProjectionContext = {
active: WeakSet<object>;
projected: WeakMap<object, unknown>;
sensitive: SensitiveSkillReferences;
};
function projectNestedSkillErrorSubtrees(value: unknown, context: SkillProjectionContext): unknown {
if (typeof value === "string") {
return context.sensitive.strings.has(value) ? sanitizeErrorMessage(value) : value;
}
if (!value || typeof value !== "object") return value;
if (context.active.has(value)) return "[circular]";
if (context.projected.has(value)) return context.projected.get(value);
if (context.sensitive.objects.has(value)) {
const safeValue = sanitizeUpstreamDetails(value);
context.projected.set(value, safeValue);
return safeValue;
}
context.active.add(value);
if (Array.isArray(value)) {
const projected: unknown[] = [];
context.projected.set(value, projected);
for (const entry of value) projected.push(projectNestedSkillErrorSubtrees(entry, context));
context.active.delete(value);
return projected;
}
const projected: Record<string, unknown> = {};
context.projected.set(value, projected);
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
projected[key] = isSkillErrorKey(key)
? sanitizeUpstreamDetails(entry)
: projectNestedSkillErrorSubtrees(entry, context);
}
context.active.delete(value);
return projected;
}
function skillFailureMessage(output: Record<string, unknown>): string {
try {
for (const candidate of [output.message, output.reason, output.statusText, output.error]) {
if (typeof candidate === "string" || candidate instanceof Error) {
return toSafeSkillErrorMessage(candidate);
}
}
} catch {
// Fall through to the stable public message.
}
return "Skill execution failed";
}
export function projectSkillOutputForBoundary(
output: Record<string, unknown>
): Record<string, unknown> {
try {
if (isSkillFailureOutput(output)) {
const projected = sanitizeUpstreamDetails(output);
return projected && typeof projected === "object" && !Array.isArray(projected)
? (projected as Record<string, unknown>)
: { success: false, error: "Skill execution failed" };
}
const sensitive: SensitiveSkillReferences = {
objects: new WeakSet<object>(),
strings: new Set<string>(),
};
collectSensitiveSkillReferences(output, sensitive, new WeakSet<object>());
return projectNestedSkillErrorSubtrees(output, {
active: new WeakSet<object>(),
projected: new WeakMap<object, unknown>(),
sensitive,
}) as Record<string, unknown>;
} catch {
return { success: false, error: "Skill execution failed" };
}
}
class SkillExecutor {
private static instance: SkillExecutor;
private handlers: Map<string, SkillHandler> = new Map();
@@ -99,9 +267,14 @@ class SkillExecutor {
const result = await this.executeWithTimeout(
handler(input, { apiKeyId: context.apiKeyId, sessionId: context.sessionId || "" })
);
output = result;
const resultIsFailure = isSkillFailureOutput(result);
output = projectSkillOutputForBoundary(result);
if (resultIsFailure) {
errorMessage = skillFailureMessage(result);
status = SkillStatus.ERROR;
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : String(err);
errorMessage = toSafeSkillErrorMessage(err);
status = SkillStatus.ERROR;
}
@@ -131,7 +304,7 @@ class SkillExecutor {
};
} catch (err) {
const durationMs = Date.now() - startTime;
const errorMessage = err instanceof Error ? err.message : String(err);
const errorMessage = toSafeSkillErrorMessage(err);
db.prepare(
`UPDATE skill_executions SET status = ?, error_message = ?, duration_ms = ? WHERE id = ?`

View File

@@ -1,14 +1,29 @@
import { skillExecutor } from "./executor";
import { projectSkillOutputForBoundary, skillExecutor } from "./executor";
import { skillRegistry } from "./registry";
import { builtinSkills } from "./builtins";
import { memoryBuiltinHandlers, MEMORY_BUILTIN_TOOL_NAMES } from "./memoryBuiltins";
import { detectProvider, decodeSkillToolName } from "./injection";
import { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webSearchFallback.ts";
import { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webFetchInterception.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("SKILLS_INTERCEPTION");
function toSafeSkillErrorMessage(value: unknown): string {
try {
const raw = value instanceof Error ? value.message : value;
return sanitizeErrorMessage(raw) || "Skill execution failed";
} catch {
return "Skill execution failed";
}
}
function projectSkillResultForPublicResponse(result: unknown): unknown {
if (!result || typeof result !== "object" || Array.isArray(result)) return result;
return projectSkillOutputForBoundary(result as Record<string, unknown>);
}
interface ToolCall {
id: string;
name: string;
@@ -130,7 +145,7 @@ export async function interceptToolCalls(
return {
id: call.id,
result,
result: projectSkillResultForPublicResponse(result),
};
}
@@ -151,11 +166,12 @@ export async function interceptToolCalls(
sessionId: context.sessionId,
});
const result =
const result = projectSkillResultForPublicResponse(
execution.output ??
(execution.errorMessage
? { error: execution.errorMessage }
: { error: "Skill execution returned no output" });
(execution.errorMessage
? { error: toSafeSkillErrorMessage(execution.errorMessage) }
: { error: "Skill execution returned no output" })
);
log.info("skills.interception.execution_complete", {
toolName: call.name,
@@ -167,14 +183,15 @@ export async function interceptToolCalls(
result,
};
} catch (err) {
const safeError = toSafeSkillErrorMessage(err);
log.error("skills.interception.execution_failed", {
toolName: call.name,
callId: call.id,
err: err instanceof Error ? err.message : String(err),
err: safeError,
});
return {
id: call.id,
result: { error: err instanceof Error ? err.message : String(err) },
result: { error: safeError },
};
}
})

View File

@@ -8,6 +8,7 @@
import fs from "node:fs";
import path from "node:path";
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import { getDbInstance } from "../db/core";
import { getRequestDetailLogByCallLogId } from "../db/detailedLogs";
import { shouldPersistToDisk } from "./migrations";
@@ -21,7 +22,11 @@ import {
getObservedReasoning,
} from "./tokenAccounting";
import { isNoLog } from "../compliance/noLog";
import { protectPayloadForLog, parseStoredPayload } from "../logPayloads";
import {
parseStoredPayload,
protectErrorPayloadForLog,
protectPayloadForLog,
} from "../logPayloads";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import {
CALL_LOGS_DIR,
@@ -335,7 +340,10 @@ function readLegacyLogFromDisk(entry: {
return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
}
} catch (error) {
console.error("[callLogs] Failed to read legacy disk log:", (error as Error).message);
console.error(
"[callLogs] Failed to read legacy disk log:",
sanitizeErrorMessage(error) || "Legacy call log read failed"
);
}
return null;
@@ -447,10 +455,19 @@ async function saveCallLogOperation(entry: any): Promise<void> {
const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false);
const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody);
const protectedResponseBody = noLogEnabled ? null : protectPayloadForLog(entry.responseBody);
const responseStatus = Number(entry.status);
const failedResponse = Number.isFinite(responseStatus) && responseStatus >= 400;
const protectedResponseBody = noLogEnabled
? null
: failedResponse
? protectErrorPayloadForLog(entry.responseBody)
: protectPayloadForLog(entry.responseBody);
const protectedPipelinePayloads = noLogEnabled
? null
: protectPipelinePayloads(entry.pipelinePayloads ?? entry.pipeline ?? null);
: protectPipelinePayloads(
entry.pipelinePayloads ?? entry.pipeline ?? null,
failedResponse ? responseStatus : undefined
);
const protectedError = sanitizeErrorForLog(entry.error);
const account = await resolveAccountName(entry.connectionId || null);
@@ -582,7 +599,10 @@ async function saveCallLogOperation(entry: any): Promise<void> {
scheduleCallLogRotation();
} catch (error) {
console.error("[callLogs] Failed to save call log:", (error as Error).message);
console.error(
"[callLogs] Failed to save call log:",
sanitizeErrorMessage(error) || "Call log persistence failed"
);
}
}

View File

@@ -1,7 +1,16 @@
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
import { classifyProviderError } from "@omniroute/open-sse/services/errorClassifier.ts";
import {
sanitizeErrorMessage,
sanitizeUpstreamDetails,
} from "@omniroute/open-sse/utils/errorSanitization.ts";
import { sanitizePII } from "../../piiSanitizer";
import { omitEncryptedReasoningFromLogChunks, protectPayloadForLog } from "../../logPayloads";
import {
omitEncryptedReasoningFromLogChunks,
protectErrorPayloadForLog,
protectPayloadForLog,
sanitizeErrorFramesFromLogChunks,
} from "../../logPayloads";
import type { CallLogDetailState } from "../callLogArtifacts";
// #7879: re-export the canonical helper so existing consumers of this module
// keep importing `toNumber` from here unchanged.
@@ -44,15 +53,24 @@ export function normalizeDetailState(value: unknown): CallLogDetailState {
export function sanitizeErrorForLog(error: unknown): unknown {
if (error === null || error === undefined) return null;
if (typeof error === "string") return sanitizePII(error).text;
if (error instanceof Error) {
return {
message: sanitizePII(error.message).text,
stack: sanitizePII(error.stack || "").text || undefined,
name: error.name,
};
if (typeof error === "string") {
return sanitizePII(sanitizeErrorMessage(error)).text;
}
try {
if (error instanceof Error) {
const message = sanitizePII(sanitizeErrorMessage(error.message)).text;
const stack = sanitizePII(sanitizeErrorMessage(error.stack || "")).text;
const name = sanitizeErrorMessage(error.name) || "Error";
return {
message,
...(stack ? { stack } : {}),
name,
};
}
return protectPayloadForLog(sanitizeUpstreamDetails(error));
} catch {
return "[REDACTED]";
}
return protectPayloadForLog(error);
}
export function toStoredErrorSummary(error: unknown): string | null {
@@ -70,7 +88,10 @@ export function toStoredErrorSummary(error: unknown): string | null {
}
}
export function protectPipelinePayloads(payloads: unknown): RequestPipelinePayloads | null {
export function protectPipelinePayloads(
payloads: unknown,
responseStatus?: unknown
): RequestPipelinePayloads | null {
if (!payloads || typeof payloads !== "object") return null;
const protectedPayloads: RequestPipelinePayloads = {};
@@ -84,7 +105,9 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo
.filter(([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0)
.map(([stage, chunkValue]) => [
stage,
omitEncryptedReasoningFromLogChunks(chunkValue as string[]),
sanitizeErrorFramesFromLogChunks(
omitEncryptedReasoningFromLogChunks(chunkValue as string[])
),
])
);
if (Object.keys(compacted).length > 0) {
@@ -95,6 +118,21 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo
continue;
}
if (key === "providerResponse" || key === "clientResponse") {
const response = asRecord(value);
const status = Number(response.status ?? responseStatus);
if (Number.isFinite(status) && status >= 400 && status <= 599) {
const projectedResponse =
"body" in response
? { ...response, body: protectErrorPayloadForLog(response.body) }
: protectErrorPayloadForLog(value);
protectedPayloads[key as "providerResponse" | "clientResponse"] = protectPayloadForLog(
projectedResponse
) as RequestPipelinePayloads["providerResponse"];
continue;
}
}
protectedPayloads[key as keyof RequestPipelinePayloads] = protectPayloadForLog(value) as never;
}

View File

@@ -9,6 +9,7 @@
import { getDbInstance } from "../db/core";
import { protectPayloadForLog } from "../logPayloads";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import {
resolveOrphanedUsageAccountIdentity,
resolveUsageAccountIdentity,
@@ -128,7 +129,7 @@ function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingReq
normalized.status = Number.isFinite(status) ? status : null;
}
if (metadata.error !== undefined) {
normalized.error = toStringOrNull(metadata.error) || null;
normalized.error = sanitizeErrorMessage(toStringOrNull(metadata.error)) || null;
}
if (metadata.errorCode !== undefined) {
normalized.errorCode = toStringOrNull(metadata.errorCode) || null;

View File

@@ -318,6 +318,10 @@ export async function getUsageStats() {
}
const pendingRequests = getPendingRequests();
const publicPendingRequests = {
byModel: pendingRequests.byModel,
byAccount: pendingRequests.byAccount,
};
const stats: {
totalRequests: number;
@@ -329,7 +333,7 @@ export async function getUsageStats() {
byAccount: Record<string, UsageBreakdown>;
byApiKey: Record<string, UsageBreakdown>;
last10Minutes: UsageBucket[];
pending: ReturnType<typeof getPendingRequests>;
pending: Pick<ReturnType<typeof getPendingRequests>, "byModel" | "byAccount">;
activeRequests: ActiveRequest[];
} = {
totalRequests: 0,
@@ -341,7 +345,7 @@ export async function getUsageStats() {
byAccount: {},
byApiKey: {},
last10Minutes: [],
pending: pendingRequests,
pending: publicPendingRequests,
activeRequests: [],
};

View File

@@ -340,26 +340,29 @@ export const DEFAULT_PRICING_INFERENCE = {
cache_creation: 0,
},
},
// #11773: Developer-tier $/1M from cerebras.ai/pricing (2026-09-03).
// Signup is a one-time $5 credit, not a $0 token grant — keep paid rates
// so classifyTier cannot treat Cerebras as the free routing tier.
cerebras: {
"gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
"gemma-4-31b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
"zai-glm-4.7": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
"llama-3.3-70b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
"gpt-oss-120b": { input: 0.35, output: 0.75, cached: 0, reasoning: 0, cache_creation: 0 },
"gemma-4-31b": { input: 0.4, output: 0.8, cached: 0, reasoning: 0, cache_creation: 0 },
"zai-glm-4.7": { input: 2.25, output: 2.75, cached: 0, reasoning: 0, cache_creation: 0 },
"llama-3.3-70b": { input: 0.85, output: 1.2, cached: 0, reasoning: 0, cache_creation: 0 },
"llama-4-scout-17b-16e-instruct": {
input: 0,
output: 0,
input: 0.2,
output: 0.2,
cached: 0,
reasoning: 0,
cache_creation: 0,
},
"qwen-3-235b-a22b-instruct-2507": {
input: 0,
output: 0,
input: 0.6,
output: 1.2,
cached: 0,
reasoning: 0,
cache_creation: 0,
},
"qwen-3-32b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
"qwen-3-32b": { input: 0.4, output: 0.8, cached: 0, reasoning: 0, cache_creation: 0 },
},
nvidia: {
"nvidia/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },

View File

@@ -86,7 +86,12 @@ export const APIKEY_PROVIDERS_INFERENCE = {
textIcon: "CB",
website: "https://inference.cerebras.ai",
hasFree: true,
freeNote: "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.",
// #11773: Cerebras retired the no-card 1M tokens/day trial. Live
// cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit that
// requires a payment method and expires after 30 days — LongCat-shaped
// (hasFree stays true; not a recurring grant).
freeNote:
"One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.",
},
nvidia: {
id: "nvidia",

View File

@@ -254,8 +254,7 @@ async function isComboAllowedForKey(
}
function quotaPolicyResponse(message: string, code: string): Response {
const body = buildErrorBody(HTTP_STATUS.FORBIDDEN, message);
body.error.code = code;
const body = buildErrorBody(HTTP_STATUS.FORBIDDEN, message, undefined, { code });
return new Response(JSON.stringify(body), {
status: HTTP_STATUS.FORBIDDEN,
headers: { "Content-Type": "application/json" },

View File

@@ -1,17 +1,33 @@
import { updateProviderConnection } from "@/lib/db/providers";
import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
type Patch = { testStatus: string; isActive?: boolean; lastError?: string | null; errorCode?: string | null; lastErrorType?: string | null; lastErrorAt?: string | null };
const TERMINAL = new Set(["banned","expired","deactivated","credits_exhausted"]);
type Patch = {
testStatus: string;
isActive?: boolean;
lastError?: string | null;
errorCode?: string | null;
lastErrorType?: string | null;
lastErrorAt?: string | null;
};
const TERMINAL = new Set(["banned", "expired", "deactivated", "credits_exhausted"]);
export async function writeTerminalStatus(connectionId: string, patch: Patch, origin: "probe" | "production"): Promise<void> {
export async function writeTerminalStatus(
connectionId: string,
patch: Patch,
origin: "probe" | "production"
): Promise<void> {
const isTerminal = TERMINAL.has(patch.testStatus.toLowerCase());
const persistedLastError =
patch.lastError == null
? null
: sanitizeErrorMessage(patch.lastError) || "Provider request failed";
// Double gate: AsyncLocalStorage probe + explicit origin "probe" — fail-safe ON
const probeIsolated = await shouldIsolateProbeFailures();
if ((origin === "probe" || probeIsolated) && isTerminal) {
// record-only: never remove from pool
await updateProviderConnection(connectionId, {
lastError: patch.lastError ?? null,
lastError: persistedLastError,
lastErrorAt: new Date().toISOString(),
lastErrorType: patch.lastErrorType ?? null,
errorCode: patch.errorCode ?? null,
@@ -21,7 +37,7 @@ export async function writeTerminalStatus(connectionId: string, patch: Patch, or
await updateProviderConnection(connectionId, {
isActive: patch.isActive ?? (isTerminal ? false : undefined),
testStatus: patch.testStatus,
lastError: patch.lastError ?? null,
lastError: persistedLastError,
lastErrorAt: new Date().toISOString(),
lastErrorType: patch.lastErrorType ?? null,
errorCode: patch.errorCode ?? null,

View File

@@ -73,6 +73,7 @@ import {
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
import {
honorsRuleLockScope,
isEgressBucketedLockScope,
@@ -2717,14 +2718,13 @@ export async function markAccountUnavailable(
// the opt-in setting probeCanDisable restores the historical behavior.
if (await shouldIsolateProbeFailures()) {
await updateProviderConnection(connectionId, {
// lastError kept RAW (full text) — maximal probe visibility; the
// divergence vs the normal path's slice(0,100) is intentional.
// Persist safe wording only after classification has consumed the raw provider text.
// backoffLevel is deliberately NOT written: a positive backoff
// triggers the selection-time auto-decay (resetConnectionBackoff,
// auth.ts getProviderCredentials) which wipes lastError back to
// NULL on the next attempt — silently destroying the probe record.
// The backoff is also routing state a probe must not touch (#9817).
lastError: errorText,
lastError: sanitizeErrorMessage(errorText) || "Provider request failed",
lastErrorType: fallbackResult.reason || null,
errorCode: status,
lastErrorAt: new Date().toISOString(),
@@ -3140,8 +3140,8 @@ export async function markAccountUnavailable(
);
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
}
const errorMsg = describeUpstreamFailure(errorText);
const errorMsg =
sanitizeErrorMessage(describeUpstreamFailure(errorText)) || "Provider request failed";
// T09: Codex per-scope lockout (do not block the whole account globally).
if (

View File

@@ -0,0 +1,620 @@
import assert from "node:assert/strict";
import test from "node:test";
assert.ok(process.env.DATA_DIR, "the parent harness must provide an isolated DATA_DIR");
assert.ok(
process.env.OMNIROUTE_PLUGINS_DIR,
"the parent harness must provide an isolated OMNIROUTE_PLUGINS_DIR"
);
const [
{ OneMinAiExecutor },
{ ensureStreamReadiness },
dbCore,
settingsDb,
callLogs,
usageHistory,
accountSemaphore,
readCache,
{ handleChatCore },
] = await Promise.all([
import("../../open-sse/executors/oneminai.ts"),
import("../../open-sse/utils/streamReadiness.ts"),
import("../../src/lib/db/core.ts"),
import("../../src/lib/db/settings.ts"),
import("../../src/lib/usage/callLogs.ts"),
import("../../src/lib/usage/usageHistory.ts"),
import("../../open-sse/services/accountSemaphore.ts"),
import("../../src/lib/db/readCache.ts"),
import("../../open-sse/handlers/chatCore.ts"),
]);
const originalFetch = globalThis.fetch;
const encoder = new TextEncoder();
const STREAM_URL = "https://api.1min.ai/api/chat-with-ai?isStreaming=true";
type PersistenceIdentity = {
model: string;
connectionId: string;
};
const PRE_CONTENT_IDENTITY: PersistenceIdentity = {
model: "gpt-4o-mini-onemin-pre-content-boundary",
connectionId: "onemin-stream-pre-content-boundary",
};
const BATCHED_IDENTITY: PersistenceIdentity = {
model: "gpt-4o-mini-onemin-batched-boundary",
connectionId: "onemin-stream-batched-boundary",
};
const PARTIAL_IDENTITY: PersistenceIdentity = {
model: "gpt-4o-mini-onemin-partial-boundary",
connectionId: "onemin-stream-partial-boundary",
};
function installFetchFactory(responseFactory: () => Response): () => number {
let calls = 0;
globalThis.fetch = async (input, init = {}) => {
calls += 1;
assert.equal(String(input), STREAM_URL, "the test must never permit another network target");
assert.equal(init.method, "POST");
assert.equal((init.headers as Record<string, string>)["API-KEY"], "unit-test-key");
return responseFactory();
};
return () => calls;
}
function createStreamingResponse(events: string[]): Response {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) controller.enqueue(encoder.encode(event));
controller.close();
},
}),
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
);
}
function installStreamingFetch(events: string[]): () => number {
return installFetchFactory(() => createStreamingResponse(events));
}
async function executeStreaming(events: string[]): Promise<Response> {
const getCalls = installStreamingFetch(events);
const result = await new OneMinAiExecutor().execute({
model: "gpt-4o-mini",
body: { messages: [{ role: "user", content: "hello" }] },
stream: true,
credentials: { apiKey: "unit-test-key" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(getCalls(), 1);
return result.response;
}
function noopLog() {
return { debug() {}, info() {}, warn() {}, error() {} };
}
async function invokeStreamingChatCore(
identity: PersistenceIdentity,
onStreamFailure?: (failure: {
status: number;
message: string;
code?: string;
type?: string;
}) => void,
onRequestSuccess?: () => Promise<void> | void
) {
await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
readCache.invalidateDbCache("settings");
const body = {
model: identity.model,
stream: true,
messages: [{ role: "user", content: "hello" }],
};
return handleChatCore({
body: structuredClone(body),
modelInfo: { provider: "oneminai", model: identity.model, extendedContext: false },
credentials: {
apiKey: "unit-test-key",
connectionId: identity.connectionId,
providerSpecificData: {},
},
connectionId: identity.connectionId,
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/chat/completions",
body: structuredClone(body),
headers: new Headers({
accept: "text/event-stream",
"x-omniroute-session-id": identity.connectionId,
}),
},
userAgent: identity.connectionId,
onRequestSuccess,
onStreamFailure,
} as never);
}
async function waitFor<T>(read: () => Promise<T | null>, timeoutMs = 5_000): Promise<T | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await read();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return null;
}
async function getOneMinCallLog(identity: PersistenceIdentity) {
assert.equal(
await callLogs.waitForCallLogSaves(5_000),
true,
"call-log persistence must drain before inspection"
);
const rows = await callLogs.getCallLogs({
provider: "oneminai",
model: identity.model,
limit: 20,
});
const row = Array.isArray(rows)
? rows.find(
(candidate) =>
candidate.connectionId === identity.connectionId &&
(candidate.model === identity.model || candidate.requestedModel === identity.model)
)
: null;
return row ? callLogs.getCallLogById(row.id) : null;
}
async function getOneMinUsage(identity: PersistenceIdentity) {
const rows = await usageHistory.getUsageHistory({
provider: "oneminai",
model: identity.model,
});
return rows.find((row) => row.connectionId === identity.connectionId) ?? null;
}
async function assertUnusedPersistenceIdentity(identity: PersistenceIdentity) {
assert.equal(
await getOneMinCallLog(identity),
null,
`call-log identity must be unused before scenario: ${identity.connectionId}`
);
assert.equal(
await getOneMinUsage(identity),
null,
`usage identity must be unused before scenario: ${identity.connectionId}`
);
}
async function readUntil(
reader: ReadableStreamDefaultReader<Uint8Array>,
marker: string
): Promise<string> {
const decoder = new TextDecoder();
let text = "";
while (!text.includes(marker)) {
const { done, value } = await reader.read();
assert.equal(done, false, `stream ended before ${marker}`);
if (value) text += decoder.decode(value, { stream: true });
}
return text;
}
async function readRemaining(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
const decoder = new TextDecoder();
let text = "";
for (;;) {
const { done, value } = await reader.read();
if (done) return text + decoder.decode();
if (value) text += decoder.decode(value, { stream: true });
}
}
test.afterEach(async () => {
const drained = await callLogs.waitForCallLogSaves(5_000);
globalThis.fetch = originalFetch;
usageHistory.clearPendingRequests();
accountSemaphore.resetAll();
assert.equal(drained, true, "all call-log saves must drain before the next test");
});
test.after(async () => {
const drained = await callLogs.waitForCallLogSaves(5_000);
try {
await callLogs.closeCallLogSaves(5_000);
} finally {
globalThis.fetch = originalFetch;
usageHistory.clearPendingRequests();
accountSemaphore.resetAll();
dbCore.resetDbInstance();
}
assert.equal(drained, true, "all call-log saves must drain before teardown");
});
test("1min.ai pre-content stream errors stay errors and permit readiness fallback", async () => {
const rawMessage =
"quota lookup failed at /srv/omniroute/open-sse/executors/oneminai.ts:170\n" +
" at translateSseStream (/srv/omniroute/open-sse/executors/oneminai.ts:99:5)";
const response = await executeStreaming([
`event: error\ndata: ${JSON.stringify({ error: { message: rawMessage } })}\n\n`,
]);
const clientCopy = response.clone();
const readiness = await ensureStreamReadiness(response, {
timeoutMs: 2_000,
provider: "oneminai",
model: "gpt-4o-mini",
});
assert.equal(readiness.ok, false);
if (readiness.ok) assert.fail("an error-only stream must not become ready");
assert.equal(readiness.response.status, 502);
const fallbackBody = await readiness.response.text();
assert.match(fallbackBody, /STREAM_EARLY_EOF/);
assert.doesNotMatch(fallbackBody, /\/srv\/omniroute/);
assert.doesNotMatch(fallbackBody, /translateSseStream/);
const clientText = await clientCopy.text();
assert.match(clientText, /^data: \{"error":/);
assert.match(clientText, /quota lookup failed at <path>/);
assert.match(clientText, /data: \[DONE\]/);
assert.doesNotMatch(clientText, /"role":"assistant"/);
assert.doesNotMatch(clientText, /"finish_reason":"stop"/);
assert.doesNotMatch(clientText, /\/srv\/omniroute/);
assert.doesNotMatch(clientText, /translateSseStream/);
});
test("chatCore turns a pre-content 1min.ai stream error into persisted HTTP 502", async () => {
await assertUnusedPersistenceIdentity(PRE_CONTENT_IDENTITY);
installStreamingFetch([
`event: error\ndata: ${JSON.stringify({
error: {
message:
"quota lookup failed at /srv/omniroute/open-sse/executors/oneminai.ts:230 api_key=pre-content-secret\nstack tail",
},
})}\n\n`,
]);
const result = await invokeStreamingChatCore(PRE_CONTENT_IDENTITY);
assert.equal(result.success, false);
if (result.success) assert.fail("a pre-content error must not commit HTTP 200");
assert.equal(result.status, 502);
assert.equal(result.response.status, 502);
const clientBody = await result.response.text();
assert.match(clientBody, /STREAM_EARLY_EOF/);
assert.doesNotMatch(clientBody, /pre-content-secret/);
assert.doesNotMatch(clientBody, /\/srv\/omniroute/);
assert.doesNotMatch(clientBody, /stack tail/);
const detail = await waitFor(() => getOneMinCallLog(PRE_CONTENT_IDENTITY));
assert.ok(detail, "the failed pre-content attempt must be persisted");
assert.equal(detail.status, 502);
const persisted = JSON.stringify(detail);
assert.doesNotMatch(persisted, /pre-content-secret/);
assert.doesNotMatch(persisted, /\/srv\/omniroute/);
assert.doesNotMatch(persisted, /stack tail/);
const usage = await waitFor(() => getOneMinUsage(PRE_CONTENT_IDENTITY));
assert.ok(usage, "the failed pre-content usage record must be persisted");
assert.equal(usage.success, false);
assert.equal(usage.status, "502");
assert.equal(usage.errorCode, "STREAM_EARLY_EOF");
});
test("chatCore preserves batched 1min.ai content before its terminal stream error", async () => {
await assertUnusedPersistenceIdentity(BATCHED_IDENTITY);
installStreamingFetch([
'event: content\ndata: {"content":"batched partial one"}\n\n' +
'event: content\ndata: {"content":"batched partial two"}\n\n' +
`event: error\ndata: ${JSON.stringify({
message:
"provider failed at /srv/omniroute/open-sse/executors/oneminai.ts:230 api_key=batched-secret",
})}\n\n`,
]);
const failures: Array<{
status: number;
message: string;
code?: string;
type?: string;
}> = [];
const requestSuccessPhases: string[] = [];
const result = await invokeStreamingChatCore(
BATCHED_IDENTITY,
(failure) => failures.push(failure),
async () => {
requestSuccessPhases.push("started");
await new Promise((resolve) => setTimeout(resolve, 30));
requestSuccessPhases.push("finished");
}
);
assert.equal(result.success, true, "batched real content must cross the readiness boundary");
assert.deepEqual(requestSuccessPhases, ["started", "finished"]);
assert.ok(result.response.body);
const clientText = await result.response.text();
const firstContentIndex = clientText.indexOf("batched partial one");
const secondContentIndex = clientText.indexOf("batched partial two");
const errorIndex = clientText.indexOf('"error":');
const doneIndex = clientText.indexOf("data: [DONE]");
assert.ok(firstContentIndex >= 0, "the first queued content delta must not be discarded");
assert.ok(secondContentIndex >= 0, "the second queued content delta must not be discarded");
assert.ok(firstContentIndex < secondContentIndex, "batched content must retain upstream order");
assert.ok(secondContentIndex < errorIndex, "all batched content must precede its terminal error");
assert.ok(
errorIndex < doneIndex,
`the terminal error must precede [DONE]: ${JSON.stringify(clientText)}`
);
assert.match(clientText, /"finish_reason":"error"/);
assert.doesNotMatch(clientText, /"finish_reason":"stop"/);
assert.doesNotMatch(clientText, /response\.failed/);
assert.doesNotMatch(clientText, /batched-secret/);
assert.doesNotMatch(clientText, /\/srv\/omniroute/);
assert.deepEqual(failures, [
{
status: 502,
message: "1min.ai upstream stream failed",
code: "stream_pipeline_error",
type: "stream_error",
},
]);
const pending = usageHistory.getPendingRequests();
assert.deepEqual(Object.keys(pending.byModel), []);
assert.deepEqual(Object.keys(pending.byAccount), []);
const completed = [...usageHistory.getCompletedDetails().values()];
assert.equal(completed.length, 1);
assert.equal(completed[0].status, 502);
assert.equal(completed[0].error, "1min.ai upstream stream failed");
assert.equal(completed[0].errorCode, "stream_pipeline_error");
const detail = await waitFor(() => getOneMinCallLog(BATCHED_IDENTITY));
assert.ok(detail, "the batched terminal stream failure must be persisted");
assert.equal(detail.status, 502);
assert.equal(detail.error, "1min.ai upstream stream failed");
const persisted = JSON.stringify(detail);
assert.doesNotMatch(persisted, /batched-secret/);
assert.doesNotMatch(persisted, /\/srv\/omniroute/);
const usage = await waitFor(() => getOneMinUsage(BATCHED_IDENTITY));
assert.ok(usage, "the batched terminal failure usage record must be persisted");
assert.equal(usage.success, false);
assert.equal(usage.status, "502");
assert.equal(usage.errorCode, "stream_pipeline_error");
});
test("chatCore preserves partial 1min.ai content then finalizes and persists a stream failure", async () => {
await assertUnusedPersistenceIdentity(PARTIAL_IDENTITY);
let upstreamController: ReadableStreamDefaultController<Uint8Array> | null = null;
let cancelCalls = 0;
const getCalls = installFetchFactory(
() =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
upstreamController = controller;
controller.enqueue(
encoder.encode('event: content\ndata: {"content":"partial answer"}\n\n')
);
},
cancel() {
cancelCalls += 1;
},
}),
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
)
);
const failures: Array<{
status: number;
message: string;
code?: string;
type?: string;
}> = [];
const result = await invokeStreamingChatCore(PARTIAL_IDENTITY, (failure) =>
failures.push(failure)
);
assert.equal(getCalls(), 1);
assert.equal(result.success, true, "real content must cross the readiness boundary");
assert.ok(result.response.body);
const reader = result.response.body.getReader();
let clientText = await readUntil(reader, "partial answer");
assert.ok(upstreamController);
upstreamController.enqueue(
encoder.encode(
`event: error\ndata: ${JSON.stringify({
message:
"provider failed at /srv/omniroute/open-sse/executors/oneminai.ts:230 api_key=post-content-secret\nstack tail",
})}\n\n`
)
);
clientText += await readRemaining(reader);
const roleIndex = clientText.indexOf('"role":"assistant"');
const contentIndex = clientText.indexOf("partial answer");
const errorIndex = clientText.indexOf('"error":');
const doneIndex = clientText.indexOf("data: [DONE]");
assert.ok(roleIndex >= 0 && roleIndex < contentIndex, "the role must precede real content");
assert.ok(contentIndex < errorIndex, "partial content must remain before the terminal error");
assert.ok(errorIndex < doneIndex, "the pipeline error must precede [DONE]");
assert.equal(clientText.match(/"role":"assistant"/g)?.length, 1);
assert.match(clientText, /"finish_reason":"error"/);
assert.match(clientText, /1min\.ai upstream stream failed/);
assert.doesNotMatch(clientText, /"finish_reason":"stop"/);
assert.doesNotMatch(clientText, /response\.failed/);
assert.doesNotMatch(clientText, /post-content-secret/);
assert.doesNotMatch(clientText, /\/srv\/omniroute/);
assert.doesNotMatch(clientText, /stack tail/);
assert.equal(cancelCalls, 1, "the upstream source must be cancelled after its terminal error");
assert.equal(failures.length, 1);
assert.deepEqual(failures[0], {
status: 502,
message: "1min.ai upstream stream failed",
code: "stream_pipeline_error",
type: "stream_error",
});
const pending = usageHistory.getPendingRequests();
assert.deepEqual(Object.keys(pending.byModel), []);
assert.deepEqual(Object.keys(pending.byAccount), []);
const completed = [...usageHistory.getCompletedDetails().values()];
assert.equal(completed.length, 1);
assert.equal(completed[0].status, 502);
assert.equal(completed[0].error, "1min.ai upstream stream failed");
assert.equal(completed[0].errorCode, "stream_pipeline_error");
const detail = await waitFor(() => getOneMinCallLog(PARTIAL_IDENTITY));
assert.ok(detail, "the post-content stream failure must be persisted");
assert.equal(detail.status, 502);
assert.equal(detail.error, "1min.ai upstream stream failed");
const persisted = JSON.stringify(detail);
assert.match(persisted, /1min\.ai upstream stream failed/);
assert.doesNotMatch(persisted, /post-content-secret/);
assert.doesNotMatch(persisted, /\/srv\/omniroute/);
assert.doesNotMatch(persisted, /stack tail/);
const usage = await waitFor(() => getOneMinUsage(PARTIAL_IDENTITY));
assert.ok(usage, "the post-content failure usage record must be persisted");
assert.equal(usage.success, false);
assert.equal(usage.status, "502");
assert.equal(usage.errorCode, "stream_pipeline_error");
});
test("1min.ai error completion does not wait for an upstream cancel promise", async () => {
let cancelCalls = 0;
const getCalls = installFetchFactory(
() =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode('event: error\ndata: {"message":"capacity unavailable"}\n\n')
);
},
cancel() {
cancelCalls += 1;
return new Promise<void>(() => {});
},
}),
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
)
);
const result = await new OneMinAiExecutor().execute({
model: "gpt-4o-mini",
body: { messages: [{ role: "user", content: "hello" }] },
stream: true,
credentials: { apiKey: "unit-test-key" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const clientText = await Promise.race([
result.response.text(),
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error("translated stream stayed pending on cancel")), 500)
),
]);
assert.equal(getCalls(), 1);
assert.equal(cancelCalls, 1);
assert.match(clientText, /capacity unavailable/);
assert.match(clientText, /data: \[DONE\]/);
});
test("1min.ai propagates downstream cancellation without awaiting upstream cleanup", async () => {
let upstreamController: ReadableStreamDefaultController<Uint8Array> | null = null;
let cancelCalls = 0;
let markPullStarted: (() => void) | null = null;
const pullStarted = new Promise<void>((resolve) => {
markPullStarted = resolve;
});
const getCalls = installFetchFactory(
() =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
upstreamController = controller;
controller.enqueue(
encoder.encode('event: content\ndata: {"content":"partial answer"}\n\n')
);
},
pull() {
markPullStarted?.();
return new Promise<void>(() => {});
},
cancel() {
cancelCalls += 1;
return new Promise<void>(() => {});
},
}),
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
)
);
const result = await new OneMinAiExecutor().execute({
model: "gpt-4o-mini",
body: { messages: [{ role: "user", content: "hello" }] },
stream: true,
credentials: { apiKey: "unit-test-key" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.ok(result.response.body);
const reader = result.response.body.getReader();
try {
const clientText = await readUntil(reader, "partial answer");
assert.match(clientText, /"role":"assistant"/);
await pullStarted;
await Promise.race([
reader.cancel("client disconnected"),
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error("downstream cancellation stayed pending")), 500)
),
]);
assert.equal(getCalls(), 1);
assert.equal(cancelCalls, 1, "downstream cancellation must reach the upstream reader once");
assert.deepEqual(await reader.read(), { value: undefined, done: true });
} finally {
try {
upstreamController?.close();
} catch {
// The fixed path has already cancelled and closed the upstream stream.
}
}
});
test("1min.ai accepts the bounded error-string shape without exposing a success chunk", async () => {
const response = await executeStreaming([
'event: error\ndata: {"error":"billing temporarily unavailable"}\n\n',
]);
const clientText = await response.text();
assert.match(clientText, /"error":\{"message":"billing temporarily unavailable"/);
assert.doesNotMatch(clientText, /"role":"assistant"/);
assert.doesNotMatch(clientText, /"finish_reason":"stop"/);
});
test("1min.ai replaces oversized stream-error payloads with a fixed public fallback", async () => {
const oversizedMessage = `private-prefix-${"x".repeat(70 * 1024)}`;
const response = await executeStreaming([
`event: error\ndata: ${JSON.stringify({ message: oversizedMessage })}\n\n`,
]);
const clientText = await response.text();
assert.match(clientText, /1min\.ai upstream stream failed/);
assert.ok(clientText.length < 1_024, "the oversized upstream payload must not be reflected");
assert.doesNotMatch(clientText, /private-prefix/);
assert.doesNotMatch(clientText, /"role":"assistant"/);
assert.doesNotMatch(clientText, /"finish_reason":"stop"/);
});

View File

@@ -17,7 +17,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
asRecord,
toNumber,
@@ -96,6 +95,15 @@ describe("callLogs/format — toStoredErrorSummary", () => {
assert.ok(out.includes("kaboom"));
assert.ok(out.includes("message"));
});
it("removes credentials, filesystem paths, and stack frames before persistence", () => {
const out = toStoredErrorSummary(
"Provider failed access_token=persisted-secret at /srv/private/provider.json\n" +
" at dispatch (/srv/private/dispatcher.ts:42:7)"
);
assert.equal(typeof out, "string");
assert.doesNotMatch(out, /persisted-secret|srv\/private|dispatcher\.ts|\bat dispatch\b/i);
});
});
describe("callLogs/format — buildRequestSummary", () => {

View File

@@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import { APIKEY_PROVIDERS } from "../../src/shared/constants/providers/apikey/index.ts";
import { getProviderById } from "../../src/shared/constants/providers.ts";
import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.ts";
import { FREE_TIER_BUDGETS } from "../../open-sse/config/freeTierCatalog.ts";
import { LEGACY_FREE_PROVIDERS } from "../../open-sse/services/tierConfig.ts";
import { classifyTier, clearTierCache } from "../../open-sse/services/tierResolver.ts";
import { PROVIDER_TIER } from "../../open-sse/services/tierTypes.ts";
const CEREBRAS_MODELS = ["zai-glm-4.7", "gpt-oss-120b"] as const;
test("#11773 cerebras stays catalogued, but not as a recurring zero-cost tier", () => {
const entry = APIKEY_PROVIDERS.cerebras;
assert.ok(entry, "APIKEY_PROVIDERS.cerebras must remain registered");
assert.equal(entry.hasFree, true);
assert.equal(Object.hasOwn(FREE_TIER_BUDGETS, "cerebras"), false);
assert.equal(LEGACY_FREE_PROVIDERS.includes("cerebras"), false);
});
test("#11773 cerebras freeNote describes the $5 card-gated signup credit", () => {
const note = getProviderById("cerebras")?.freeNote ?? "";
assert.match(note, /\$5/);
assert.match(note, /30.?day|30 days/i);
assert.match(note, /payment method|credit card/i);
assert.equal(/1M tokens\/day|30K TPM/.test(note), false);
});
test("#11773 cerebras catalog rows are one-time signup credits, not a hard-stop free trial", () => {
const rows = FREE_MODEL_BUDGETS.filter((row) => row.provider === "cerebras");
assert.ok(rows.length >= CEREBRAS_MODELS.length, "catalog must keep the live Cerebras models");
for (const modelId of CEREBRAS_MODELS) {
const row = rows.find((entry) => entry.modelId === modelId);
assert.ok(row, `missing catalog row for ${modelId}`);
assert.equal(row.freeType, "one-time-initial");
assert.equal(row.monthlyTokens, 0);
assert.notEqual(row.hardStopGuaranteed, true);
}
});
test("#11773 cerebras is not classified as the free routing tier", () => {
clearTierCache();
const result = classifyTier("cerebras", "zai-glm-4.7");
assert.notEqual(result.tier, PROVIDER_TIER.FREE);
});

View File

@@ -43,6 +43,23 @@ test("createStreamingErrorResult attaches optional code and type", async () => {
assert.equal(json.error.type, "rate_limit_error");
});
test("createStreamingErrorResult sanitizes code and type at the SSE boundary", async () => {
const result = createStreamingErrorResult(
502,
"upstream failed",
"sk-live-secret-value",
"server_error\nX-Leak: yes"
);
const body = await result.response.text();
const json = JSON.parse(body.slice("data: ".length, body.indexOf("\n\n"))) as {
error: { code: string; type: string };
};
assert.equal(json.error.code, "bad_gateway");
assert.equal(json.error.type, "server_error");
assert.doesNotMatch(body, /sk-live-secret-value|X-Leak/);
});
test("getUpstreamErrorIdentifier returns a non-empty string code or undefined", () => {
assert.equal(getUpstreamErrorIdentifier({ code: "ECONNRESET" }), "ECONNRESET");
assert.equal(getUpstreamErrorIdentifier({ code: "" }), undefined);

View File

@@ -4,8 +4,15 @@ 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-chatcore-translation-"));
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatcore-translation-"));
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
@@ -448,7 +455,11 @@ test.after(async () => {
resetAccountSemaphores();
await flushAsyncSideEffects();
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("chatCore times out upstream execution before provider response headers", async () => {
// This test asserts pendingDetail.providerRequest — only attached when the
@@ -1938,35 +1949,12 @@ test("chatCore surfaces translation errors with explicit status codes", async ()
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI,
() => {
const error = new Error("responses translator rejected the payload");
error.statusCode = 409;
throw error;
},
null
);
const { result } = await invokeChatCore({
provider: "openai",
model: "gpt-4o-mini",
endpoint: "/v1/responses",
body: {
model: "gpt-4o-mini",
input: "hello",
},
});
assert.equal(result.success, false);
assert.equal(result.status, 409);
assert.equal(result.error, "responses translator rejected the payload");
});
test("chatCore surfaces typed translation errors with the declared error type", async () => {
register(
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI,
() => {
const error = new Error("typed translator failure");
const error = new Error(
"translator rejected access_token=translation-secret at /srv/private/translator.ts\n" +
" at translate (/srv/private/translator.ts:41:8)"
);
error.statusCode = 422;
error.errorType = "unsupported_feature";
error.errorType = "unsupported_feature access_token=type-secret /srv/private/type.ts";
throw error;
},
null
@@ -1984,10 +1972,16 @@ test("chatCore surfaces typed translation errors with the declared error type",
assert.equal(result.success, false);
assert.equal(result.status, 422);
const payload = (await result.response.json()) as any;
assert.equal(payload.error.type, "unsupported_feature");
assert.equal(payload.error.code, "unsupported_feature");
const payload = (await result.response.json()) as {
error: { message: string; type: string; code: string };
};
assert.equal(payload.error.type, "invalid_request_error");
assert.equal(payload.error.code, "");
assert.match(payload.error.message, /translator rejected/);
assert.doesNotMatch(
JSON.stringify({ payload, internalError: result.error }),
/translation-secret|type-secret|srv\/private|translator\.ts|type\.ts|\bat translate\b/i
);
});
test("chatCore returns 500 when translation throws a generic error", async () => {
register(

View File

@@ -485,8 +485,8 @@ const TRAINING_CLAIM = {
};
test("the hard-stop claim passes on the real sentence and fails on a stale count", () => {
const v = makeValidator(7, HARD_STOP_CLAIM);
assert.equal(v("7 entries carry an independently documented hard stop, and").ok, true);
const v = makeValidator(5, HARD_STOP_CLAIM);
assert.equal(v("5 entries carry an independently documented hard stop, and").ok, true);
assert.equal(v("99 entries carry an independently documented hard stop, and").ok, false);
});
@@ -500,10 +500,10 @@ test("the training claim passes on the real sentence and fails on a stale count"
test("a reworded or deleted sentence fails, instead of passing as absent", () => {
// The gate's real failure mode is not a stale number, it is silence: reword the
// sentence past the pattern and "no claim in this file" used to read green.
const required = makeValidator(7, { ...HARD_STOP_CLAIM, requireClaim: true });
assert.equal(required("7 entries have a provider-documented hard-stop guarantee.").ok, false);
const required = makeValidator(5, { ...HARD_STOP_CLAIM, requireClaim: true });
assert.equal(required("5 entries have a provider-documented hard-stop guarantee.").ok, false);
assert.equal(required("the page no longer mentions it at all").ok, false);
assert.equal(required("7 entries carry an independently documented hard stop.").ok, true);
assert.equal(required("5 entries carry an independently documented hard stop.").ok, true);
const trainingRequired = makeValidator(13, { ...TRAINING_CLAIM, requireClaim: true });
assert.equal(trainingRequired("13 entries disclose training use.").ok, false);
@@ -514,7 +514,7 @@ test("the live page actually satisfies both required gates", () => {
// A unit test on synthetic strings proves the validator; this one proves the
// document. Without it, the two could drift apart and both stay green.
const page = readFileSync(path.resolve(here, "../../docs/reference/FREE_TIERS.md"), "utf8");
assert.equal(makeValidator(7, { ...HARD_STOP_CLAIM, requireClaim: true })(page).ok, true);
assert.equal(makeValidator(5, { ...HARD_STOP_CLAIM, requireClaim: true })(page).ok, true);
assert.equal(makeValidator(13, { ...TRAINING_CLAIM, requireClaim: true })(page).ok, true);
});

View File

@@ -9,9 +9,9 @@
import test from "node:test";
import assert from "node:assert/strict";
const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } = await import(
"../../open-sse/utils/error.ts"
);
const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } =
await import("../../open-sse/utils/error.ts");
const { buildRecoveryHint } = await import("../../open-sse/services/combo/pinRecovery.ts");
test("combo diagnostics: headers + body carry the sanitized trace (code override preserved)", async () => {
const res = errorResponseWithComboDiagnostics(
@@ -89,7 +89,9 @@ test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must no
{
poolSize: 4,
attempted: 1,
excluded: [{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }],
excluded: [
{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" },
],
attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }],
terminalReason,
}
@@ -112,8 +114,43 @@ test("combo diagnostics: JSON body keeps the original non-Latin1 text even thoug
}
);
// Header value must be a valid Latin1 ByteString — em dash (U+2014) replaced.
assert.equal(res.headers.get("x-omniroute-combo-terminal-reason"), terminalReason.replace("—", "?"));
assert.equal(
res.headers.get("x-omniroute-combo-terminal-reason"),
terminalReason.replace("—", "?")
);
const body = await res.json();
// JSON body keeps the original, readable (unsanitized) em dash.
assert.equal(body.diagnostics.terminalReason, terminalReason);
});
test("combo diagnostics preserve every canonical recovery hint up to the existing cap", async () => {
const reasons = [
"reasoning_budget_exhausted",
"max_attempts_exceeded",
"all_accounts_inactive",
"quota_exhausted",
"all_models_failed",
"no_executable_targets",
"context_requirements_exhausted",
"all_targets_skipped",
"unknown_reason",
];
for (const reason of reasons) {
const recovery = buildRecoveryHint(reason, 30);
const response = errorResponseWithComboDiagnostics(503, "combo failed", {
poolSize: 1,
attempted: 1,
excluded: [],
attemptOrder: [],
terminalReason: reason,
recovery,
});
const body = (await response.json()) as {
recovery_hint?: { action: string; next_step: string };
};
assert.equal(body.recovery_hint?.action, recovery.action, reason);
assert.equal(body.recovery_hint?.next_step, recovery.next_step.slice(0, 200), reason);
}
});

View File

@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
@@ -21,6 +22,30 @@ import fs from "node:fs";
*/
const { resolveWritableDataDir, getDefaultDataDir } = await import("../../src/lib/dataPaths.ts");
const redirectedDirs = new Set<string>();
function assertOwnedRedirectDir(candidate: string): string {
const resolved = path.resolve(candidate);
const tempRoot = path.resolve(os.tmpdir());
assert.ok(
resolved.startsWith(`${tempRoot}${path.sep}`) &&
path.basename(resolved).startsWith("omniroute-testctx-"),
`refusing to treat a non-owned path as a test redirect: ${resolved}`
);
return resolved;
}
function rememberRedirectDir(candidate: string): string {
const resolved = assertOwnedRedirectDir(candidate);
redirectedDirs.add(resolved);
return resolved;
}
test.after(() => {
for (const redirected of redirectedDirs) {
fs.rmSync(redirected, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
function withEnv(overrides: Record<string, string | undefined>, run: () => void) {
const saved: Record<string, string | undefined> = {};
@@ -39,11 +64,58 @@ function withEnv(overrides: Record<string, string | undefined>, run: () => void)
}
}
const EVAL_PROBE_SCRIPT =
"import('./src/lib/dataPaths.ts').then(({ resolveWritableDataDir }) => " +
"console.log('OMNIROUTE_TEST_DATA_DIR=' + resolveWritableDataDir()))";
function assertEvalProbeIsIsolated(evalArgs: string[], configuredDataDir = "") {
const result = spawnSync(process.execPath, ["--import", "tsx/esm", ...evalArgs], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
DATA_DIR: configuredDataDir,
XDG_CONFIG_HOME: "",
NODE_ENV: "production",
NODE_TEST_CONTEXT: "",
VITEST: "",
OMNIROUTE_ALLOW_DEFAULT_DATA_DIR: "",
},
});
assert.equal(result.status, 0, result.stderr);
const outputLine = result.stdout
.trim()
.split("\n")
.find((line) => line.startsWith("OMNIROUTE_TEST_DATA_DIR="));
const resolved = outputLine?.slice("OMNIROUTE_TEST_DATA_DIR=".length) ?? "";
const ownedRedirect = assertOwnedRedirectDir(resolved);
try {
assert.notEqual(
ownedRedirect,
path.join(os.homedir(), ".omniroute"),
"an eval/import probe must not inherit the normal server's default database"
);
assert.equal(
fs.existsSync(ownedRedirect),
false,
"the child exit handler must remove its exact redirected DATA_DIR"
);
} finally {
fs.rmSync(ownedRedirect, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
}
}
test("G1: a test context with no DATA_DIR never resolves to the operator's real data dir", () => {
withEnv(
{ DATA_DIR: undefined, NODE_ENV: "test", OMNIROUTE_ALLOW_DEFAULT_DATA_DIR: undefined },
() => {
const resolved = resolveWritableDataDir();
const resolved = rememberRedirectDir(resolveWritableDataDir());
assert.notEqual(
resolved,
getDefaultDataDir(),
@@ -101,7 +173,7 @@ test("G5: node:test subprocesses are detected through NODE_TEST_CONTEXT too", ()
OMNIROUTE_ALLOW_DEFAULT_DATA_DIR: undefined,
},
() => {
const resolved = resolveWritableDataDir();
const resolved = rememberRedirectDir(resolveWritableDataDir());
assert.notEqual(resolved, getDefaultDataDir());
assert.ok(resolved.startsWith(os.tmpdir()));
}
@@ -110,8 +182,24 @@ test("G5: node:test subprocesses are detected through NODE_TEST_CONTEXT too", ()
test("G6: the redirect is stable within a process (same dir on repeated calls)", () => {
withEnv({ DATA_DIR: undefined, NODE_ENV: "test" }, () => {
const first = resolveWritableDataDir();
const second = resolveWritableDataDir();
const first = rememberRedirectDir(resolveWritableDataDir());
const second = rememberRedirectDir(resolveWritableDataDir());
assert.equal(first, second, "a per-call temp dir would split the DB across handles");
});
});
test("G7: a node --eval probe without DATA_DIR is isolated from the operator home", () => {
assertEvalProbeIsIsolated(["--eval", EVAL_PROBE_SCRIPT]);
});
test("G8: the single-argument --eval= form is isolated too", () => {
assertEvalProbeIsIsolated([`--eval=${EVAL_PROBE_SCRIPT}`]);
});
test("G9: whitespace DATA_DIR is absent for a node -e probe", () => {
assertEvalProbeIsIsolated(["-e", EVAL_PROBE_SCRIPT], " ");
});
test("G10: a combined node -pe probe is isolated too", () => {
assertEvalProbeIsIsolated(["-pe", EVAL_PROBE_SCRIPT]);
});

View File

@@ -97,6 +97,32 @@ test("backupDbFile creates manual backups and listDbBackups returns metadata", a
assert.equal(fs.existsSync(backupPath), true);
});
test("listDbBackups orders mixed timestamp and content-addressed names by mtime", async () => {
seedConnections(2);
fs.mkdirSync(core.DB_BACKUPS_DIR, { recursive: true });
const lexicallyFutureButOld = "db_2099-01-01T00-00-00-000Z_manual.sqlite";
const timestampMiddle = "db_2026-09-02T00-00-00-000Z_manual.sqlite";
const contentAddressedNewest = `db_state-${"a".repeat(64)}_pre-migration.sqlite`;
for (const filename of [lexicallyFutureButOld, timestampMiddle, contentAddressedNewest]) {
await core.getDbInstance().backup(path.join(core.DB_BACKUPS_DIR, filename));
}
const now = Date.now() / 1000;
fs.utimesSync(path.join(core.DB_BACKUPS_DIR, lexicallyFutureButOld), now - 120, now - 120);
fs.utimesSync(path.join(core.DB_BACKUPS_DIR, timestampMiddle), now - 60, now - 60);
fs.utimesSync(path.join(core.DB_BACKUPS_DIR, contentAddressedNewest), now, now);
const backups = await backupDb.listDbBackups();
assert.deepEqual(
backups.map((backup) => backup.id),
[contentAddressedNewest, timestampMiddle, lexicallyFutureButOld],
"content-addressed migration snapshots must not make filename order masquerade as recency"
);
assert.equal(backups[0]?.reason, "pre-migration");
assert.equal(backups[0]?.connectionCount, 2);
});
test("listDbBackups returns an empty list when the backup directory is missing", async () => {
fs.rmSync(core.DB_BACKUPS_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
const backups = await backupDb.listDbBackups();

View File

@@ -101,6 +101,13 @@ test(
const cli = await importFresh("bin/cli/sqlite.mjs");
const setup = await cli.openOmniRouteDb();
assert.ok(fs.existsSync(setup.dbPath), "setup created storage.sqlite");
setup.db
.prepare(
`INSERT INTO provider_connections
(id, provider, created_at, updated_at)
VALUES (?, ?, ?, ?)`
)
.run("setup-provider", "openai", "2026-09-02T00:00:00.000Z", "2026-09-02T00:00:00.000Z");
setup.db.close();
const onDisk = new Database(setup.dbPath, { readonly: true });
@@ -139,6 +146,27 @@ test(
(maxRow?.maxV ?? 0) > 1,
`expected migrations beyond 001 to run, got max=${maxRow?.maxV}`
);
const backupDir = path.join(dataDir, "db_backups");
const snapshots = fs
.readdirSync(backupDir)
.filter((name) => /^db_state-[a-f0-9]{64}_pre-migration\.sqlite$/.test(name));
assert.equal(
snapshots.length,
1,
"a setup-created file is logically fresh for the mass guard but physically existing for snapshot safety"
);
const snapshot = new Database(path.join(backupDir, snapshots[0]!), { readonly: true });
try {
assert.deepEqual(
snapshot.prepare("SELECT id, provider FROM provider_connections").get(),
{ id: "setup-provider", provider: "openai" },
"the mandatory snapshot must preserve setup-created provider state"
);
} finally {
snapshot.close();
}
} finally {
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;

View File

@@ -0,0 +1,839 @@
// ENVIRONMENT NOTE (sandbox better-sqlite3 / glibc limitation, not a code defect):
// This test constructs a real better-sqlite3 database. Production and CI load the
// native addon normally; see tests/unit/_helpers/betterSqlite3Availability.ts for
// the documented fallback context on older sandboxes.
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
const isIsolatedChild = process.env.OMNIROUTE_DB_MIGRATION_SAFETY_CHILD === "1";
if (!isIsolatedChild) {
test("historical migration repair scenarios pass in an isolated process", () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-schema-repair-data-"));
const migrationsDir = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-schema-repair-migrations-")
);
try {
const childEnv = {
...process.env,
DATA_DIR: dataDir,
OMNIROUTE_DB_MIGRATION_SAFETY_CHILD: "1",
OMNIROUTE_MAX_PENDING_MIGRATIONS: "",
OMNIROUTE_MIGRATIONS_DIR: migrationsDir,
};
// Node's test runner exports this only to the current test worker. Passing it into
// another `node --test` process makes Node classify the nested file as recursive and
// skip every subtest while returning exit 0 — a dangerous false green.
delete childEnv.NODE_TEST_CONTEXT;
const result = spawnSync(
process.execPath,
["--import", "tsx/esm", "--test", fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
encoding: "utf8",
env: childEnv,
}
);
assert.equal(
result.status,
0,
`isolated migration regressions failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
);
assert.match(result.stdout, /\btests 13\b/, "the isolated child must execute all subtests");
assert.match(result.stdout, /\bpass 13\b/, "the isolated child must pass all subtests");
} finally {
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(migrationsDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
} else {
const dataDir = process.env.DATA_DIR;
const migrationsDir = process.env.OMNIROUTE_MIGRATIONS_DIR;
assert.ok(dataDir, "isolated child requires an explicit DATA_DIR");
assert.ok(migrationsDir, "isolated child requires an explicit migrations directory");
const discoveryMigrationSql = fs.readFileSync(
path.resolve("src/lib/db/migrations/074_discovery_results.sql"),
"utf8"
);
fs.writeFileSync(
path.join(migrationsDir, "074_discovery_results.sql"),
discoveryMigrationSql,
"utf8"
);
fs.writeFileSync(
path.join(migrationsDir, "081_inspector_custom_hosts.sql"),
`
CREATE TABLE IF NOT EXISTS inspector_custom_hosts (
host TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_inspector_custom_hosts_enabled
ON inspector_custom_hosts(enabled);
`,
"utf8"
);
fs.writeFileSync(
path.join(migrationsDir, "151_windsurf_to_devin_desktop.sql"),
"UPDATE discovery_results SET provider_id = 'devin-desktop' WHERE provider_id = 'windsurf';",
"utf8"
);
fs.writeFileSync(
path.join(migrationsDir, "152_remove_puter_provider.sql"),
"DELETE FROM discovery_results WHERE provider_id = 'puter';",
"utf8"
);
const { runMigrations } = await import("../../src/lib/db/migrationRunner.ts");
function listPreMigrationBackups(): string[] {
const backupDir = path.join(dataDir, "db_backups");
if (!fs.existsSync(backupDir)) return [];
return fs
.readdirSync(backupDir)
.filter((name) => name.endsWith("_pre-migration.sqlite"))
.sort();
}
function withNonTestEnvironment<T>(fn: () => T): T {
const previousNodeEnv = process.env.NODE_ENV;
const previousVitest = process.env.VITEST;
const previousArgv = [...process.argv];
const previousExecArgv = [...process.execArgv];
delete process.env.NODE_ENV;
delete process.env.VITEST;
process.argv = process.argv.filter((arg) => !arg.includes("test"));
process.execArgv = process.execArgv.filter((arg) => !arg.includes("test"));
try {
return fn();
} finally {
process.argv = previousArgv;
process.execArgv = previousExecArgv;
if (previousNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = previousNodeEnv;
if (previousVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = previousVitest;
}
}
test.after(() => {
// The parent owns both explicit temp directories and removes them after this
// process exits. Keeping ownership there also covers child startup failures.
});
test("runner repairs the 074 inspector collision before migrations 151 and 152", () => {
const db = new Database(":memory:");
try {
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE inspector_custom_hosts (
host TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
INSERT INTO inspector_custom_hosts (host, enabled)
VALUES ('api.example.test', 1);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'inspector_custom_hosts');
`);
assert.equal(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'discovery_results'"
)
.get(),
undefined,
"precondition: the collided 074 marker hides the missing discovery_results table"
);
assert.equal(runMigrations(db as never), 3);
assert.ok(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'discovery_results'"
)
.get(),
"074 must be replayed before migrations 151 and 152 reference discovery_results"
);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
[
{ version: "074", name: "discovery_results" },
{ version: "081", name: "inspector_custom_hosts" },
{ version: "151", name: "windsurf_to_devin_desktop" },
{ version: "152", name: "remove_puter_provider" },
]
);
assert.deepEqual(
db.prepare("SELECT host, enabled FROM inspector_custom_hosts").get(),
{ host: "api.example.test", enabled: 1 },
"re-homing the inspector marker to 081 must preserve the existing table data"
);
assert.equal(runMigrations(db as never), 0, "the repaired state must be idempotent");
} finally {
db.close();
}
});
test("runner rehomes a collided 074 inspector marker even when both tables exist", () => {
const db = new Database(":memory:");
try {
db.exec(discoveryMigrationSql);
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE inspector_custom_hosts (
host TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
INSERT INTO inspector_custom_hosts (host, enabled)
VALUES ('api.example.test', 1);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'inspector_custom_hosts');
`);
assert.equal(runMigrations(db as never), 3);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
[
{ version: "074", name: "discovery_results" },
{ version: "081", name: "inspector_custom_hosts" },
{ version: "151", name: "windsurf_to_devin_desktop" },
{ version: "152", name: "remove_puter_provider" },
],
"the old 074 name must not remain as a permanent CRITICAL mismatch"
);
assert.deepEqual(db.prepare("SELECT host FROM inspector_custom_hosts").get(), {
host: "api.example.test",
});
} finally {
db.close();
}
});
test("runner rebuilds both collided tables when neither physical table survived", () => {
const db = new Database(":memory:");
try {
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'inspector_custom_hosts');
`);
assert.equal(runMigrations(db as never), 4);
assert.ok(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'discovery_results'"
)
.get(),
"the canonical 074 table must be restored"
);
assert.ok(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'inspector_custom_hosts'"
)
.get(),
"the rehomed 081 marker must not hide a missing inspector table"
);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
[
{ version: "074", name: "discovery_results" },
{ version: "081", name: "inspector_custom_hosts" },
{ version: "151", name: "windsurf_to_devin_desktop" },
{ version: "152", name: "remove_puter_provider" },
]
);
} finally {
db.close();
}
});
test("runner atomically replays 081 when its marker exists without the inspector table", () => {
const db = new Database(":memory:");
try {
db.exec(discoveryMigrationSql);
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'discovery_results');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('081', 'inspector_custom_hosts');
`);
assert.equal(runMigrations(db as never), 3);
assert.ok(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'inspector_custom_hosts'"
)
.get(),
"a valid 081 marker must be replayed when its physical table is absent"
);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
[
{ version: "074", name: "discovery_results" },
{ version: "081", name: "inspector_custom_hosts" },
{ version: "151", name: "windsurf_to_devin_desktop" },
{ version: "152", name: "remove_puter_provider" },
]
);
} finally {
db.close();
}
});
test("runner fails closed when target 081 has unknown provenance", () => {
const db = new Database(":memory:");
try {
db.exec(`
CREATE TABLE inspector_custom_hosts (
host TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'inspector_custom_hosts');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('081', 'unknown_historical_migration');
`);
assert.throws(
() => runMigrations(db as never),
/target version 081 is occupied by unknown migration/i
);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
[
{ version: "074", name: "inspector_custom_hosts" },
{ version: "081", name: "unknown_historical_migration" },
],
"a target collision must preserve both provenance records"
);
} finally {
db.close();
}
});
test("runner rejects an unknown 074 marker even when all later migrations are marked", () => {
const db = new Database(":memory:");
try {
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'unknown_historical_migration');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('081', 'inspector_custom_hosts');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('151', 'windsurf_to_devin_desktop');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('152', 'remove_puter_provider');
`);
assert.throws(
() => runMigrations(db as never),
/required table "discovery_results" is missing.*unknown migration/i,
"unknown provenance must fail closed instead of being silently rewritten"
);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
[
{ version: "074", name: "unknown_historical_migration" },
{ version: "081", name: "inspector_custom_hosts" },
{ version: "151", name: "windsurf_to_devin_desktop" },
{ version: "152", name: "remove_puter_provider" },
]
);
} finally {
db.close();
}
});
test("runner backs up an existing DB before reopening its only applied marker", () => {
const sqlitePath = path.join(dataDir, "only-marker.sqlite");
const db = new Database(sqlitePath);
const previousDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
try {
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
db.exec(`
CREATE TABLE provider_connections (id TEXT PRIMARY KEY);
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO provider_connections (id) VALUES ('existing-data');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'discovery_results');
`);
assert.equal(runMigrations(db as never), 4);
const backupDir = path.join(dataDir, "db_backups");
const backups = fs
.readdirSync(backupDir)
.filter((name) => name.endsWith("_pre-migration.sqlite"));
assert.equal(
backups.length,
1,
"removing the only marker must not make an existing DB look fresh and skip its snapshot"
);
} finally {
db.close();
if (previousDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = previousDisableBackup;
}
});
test("snapshot publication never deletes a raced final path", () => {
const sqlitePath = path.join(dataDir, "snapshot-publish-race.sqlite");
const db = new Database(sqlitePath);
const previousDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
const originalLinkSync = fs.linkSync;
let racedFinalPath: string | null = null;
try {
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'discovery_results');
`);
fs.linkSync = ((_existingPath: fs.PathLike, newPath: fs.PathLike) => {
racedFinalPath = String(newPath);
fs.writeFileSync(racedFinalPath, "third-party-sentinel");
throw Object.assign(new Error("destination already exists"), { code: "EEXIST" });
}) as typeof fs.linkSync;
assert.throws(
() => runMigrations(db as never),
/without a durable snapshot/,
"a raced final name must fail closed before atomic replay"
);
assert.ok(racedFinalPath);
assert.equal(
fs.readFileSync(racedFinalPath, "utf8"),
"third-party-sentinel",
"snapshot failure cleanup must never unlink another actor's final path"
);
assert.deepEqual(db.prepare("SELECT version, name FROM _omniroute_migrations").all(), [
{ version: "074", name: "discovery_results" },
]);
} finally {
fs.linkSync = originalLinkSync;
if (racedFinalPath && fs.existsSync(racedFinalPath)) fs.unlinkSync(racedFinalPath);
db.close();
if (previousDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = previousDisableBackup;
}
});
test("snapshot publication fails closed when hard links are unsupported", () => {
const sqlitePath = path.join(dataDir, "snapshot-publish-fallback.sqlite");
const db = new Database(sqlitePath);
const previousDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
const originalLinkSync = fs.linkSync;
const backupsBefore = listPreMigrationBackups();
try {
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'discovery_results');
`);
fs.linkSync = (() => {
throw Object.assign(new Error("hard links unsupported"), { code: "ENOTSUP" });
}) as typeof fs.linkSync;
assert.throws(
() => runMigrations(db as never),
/durable snapshot.*hard links unsupported.*hard links.*synchronization/is
);
assert.deepEqual(db.prepare("SELECT version, name FROM _omniroute_migrations").all(), [
{ version: "074", name: "discovery_results" },
]);
assert.equal(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'discovery_results'"
)
.get(),
undefined
);
assert.deepEqual(listPreMigrationBackups(), backupsBefore);
} finally {
fs.linkSync = originalLinkSync;
db.close();
if (previousDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = previousDisableBackup;
}
});
test("an only-marker repair cannot disarm the mass-migration barrier on retry", () => {
const sqlitePath = path.join(dataDir, "only-marker-mass-safety.sqlite");
const db = new Database(sqlitePath);
const previousDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
const previousMaxPending = process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS;
try {
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS = "1";
db.exec(`
CREATE TABLE provider_connections (id TEXT PRIMARY KEY);
CREATE TABLE inspector_custom_hosts (
host TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO provider_connections (id) VALUES ('existing-data');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'inspector_custom_hosts');
`);
const runOnce = () => withNonTestEnvironment(() => runMigrations(db as never));
const backupsBefore = listPreMigrationBackups();
assert.throws(runOnce, /threshold is 1/i);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations").all(),
[{ version: "074", name: "inspector_custom_hosts" }],
"an abort must restore the marker that was rehomed to calculate the real pending set"
);
const afterFirstAbort = listPreMigrationBackups();
const created = afterFirstAbort.filter((name) => !backupsBefore.includes(name));
assert.equal(created.length, 1, "the first abort must retain one restore point");
assert.match(created[0]!, /^db_state-[a-f0-9]{64}_pre-migration\.sqlite$/);
assert.throws(runOnce, /threshold is 1/i);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations").all(),
[{ version: "074", name: "inspector_custom_hosts" }],
"the second startup must hit the same barrier instead of treating the DB as fresh"
);
assert.deepEqual(
listPreMigrationBackups(),
afterFirstAbort,
"the identical retry must reuse the first content-addressed snapshot"
);
} finally {
db.close();
if (previousDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = previousDisableBackup;
if (previousMaxPending === undefined) delete process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS;
else process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS = previousMaxPending;
}
});
test("a failed atomic 074 replay restores its marker and does not churn snapshots", () => {
const sqlitePath = path.join(dataDir, "failed-atomic-replay.sqlite");
const db = new Database(sqlitePath);
const previousDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
try {
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'discovery_results');
CREATE TRIGGER block_migration_ledger_replay
BEFORE INSERT ON _omniroute_migrations
WHEN NEW.version = '074'
BEGIN
SELECT RAISE(ABORT, 'ledger replay blocked');
END;
`);
const runOnce = () => runMigrations(db as never);
const backupsBefore = listPreMigrationBackups();
assert.throws(runOnce, /ledger replay blocked/);
assert.deepEqual(db.prepare("SELECT version, name FROM _omniroute_migrations").all(), [
{ version: "074", name: "discovery_results" },
]);
assert.equal(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'discovery_results'"
)
.get(),
undefined,
"the table creation and marker replacement must roll back together"
);
const afterFirstFailure = listPreMigrationBackups();
const created = afterFirstFailure.filter((name) => !backupsBefore.includes(name));
assert.equal(created.length, 1, "the first failed replay must retain one restore point");
assert.match(created[0]!, /^db_state-[a-f0-9]{64}_pre-migration\.sqlite$/);
assert.throws(runOnce, /ledger replay blocked/);
assert.deepEqual(
listPreMigrationBackups(),
afterFirstFailure,
"the identical failed replay must reuse its content-addressed restore point"
);
} finally {
db.close();
if (previousDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = previousDisableBackup;
}
});
test("sql.js rolls ledger repairs back when the mass-migration barrier aborts", async () => {
const sqlitePath = path.join(dataDir, "sqljs-mass-safety.sqlite");
const { createSqlJsAdapter } = await import("../../src/lib/db/adapters/sqljsAdapter.ts");
const db = await createSqlJsAdapter(sqlitePath);
const previousMaxPending = process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS;
const backupsBefore = listPreMigrationBackups();
try {
process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS = "1";
db.exec(`
CREATE TABLE provider_connections (id TEXT PRIMARY KEY);
CREATE TABLE inspector_custom_hosts (
host TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO provider_connections (id) VALUES ('existing-data');
INSERT INTO inspector_custom_hosts (host) VALUES ('api.example.test');
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'inspector_custom_hosts');
`);
const runOnce = () => withNonTestEnvironment(() => runMigrations(db));
const expectedLedger = [{ version: "074", name: "inspector_custom_hosts" }];
assert.throws(runOnce, /threshold is 1/i);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
expectedLedger,
"sql.js must roll the compatibility repair back with the safety savepoint"
);
const afterFirstAbort = listPreMigrationBackups();
const created = afterFirstAbort.filter((name) => !backupsBefore.includes(name));
assert.equal(created.length, 1, "the first sql.js abort must retain one host snapshot");
assert.match(created[0]!, /^db_state-[a-f0-9]{64}_pre-migration\.sqlite$/);
assert.throws(runOnce, /threshold is 1/i);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
expectedLedger,
"a retry must see the same original ledger rather than committed repair residue"
);
assert.deepEqual(
listPreMigrationBackups(),
afterFirstAbort,
"the identical sql.js abort must reuse its content-addressed snapshot"
);
} finally {
db.close();
if (previousMaxPending === undefined) delete process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS;
else process.env.OMNIROUTE_MAX_PENDING_MIGRATIONS = previousMaxPending;
}
});
test("sql.js exports a real host snapshot before replaying 074", async () => {
const sqlitePath = path.join(dataDir, "sqljs-physical-replay.sqlite");
const { createSqlJsAdapter } = await import("../../src/lib/db/adapters/sqljsAdapter.ts");
const db = await createSqlJsAdapter(sqlitePath);
const previousDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
const backupsBefore = listPreMigrationBackups();
try {
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
db.exec(`
PRAGMA user_version = 42;
PRAGMA application_id = 1337;
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _omniroute_migrations (version, name)
VALUES ('074', 'discovery_results');
CREATE TRIGGER block_sqljs_ledger_replay
BEFORE INSERT ON _omniroute_migrations
WHEN NEW.version = '074'
BEGIN
SELECT RAISE(ABORT, 'sqljs ledger replay blocked');
END;
`);
assert.throws(() => runMigrations(db), /sqljs ledger replay blocked/);
assert.deepEqual(db.prepare("SELECT version, name FROM _omniroute_migrations").all(), [
{ version: "074", name: "discovery_results" },
]);
assert.equal(
db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'discovery_results'"
)
.get(),
undefined,
"sql.js must roll back the table and marker replacement together"
);
const afterFirstFailure = listPreMigrationBackups();
const firstCreated = afterFirstFailure.filter((name) => !backupsBefore.includes(name));
assert.equal(firstCreated.length, 1, "sql.js must retain one host restore point");
assert.throws(() => runMigrations(db), /sqljs ledger replay blocked/);
assert.deepEqual(
listPreMigrationBackups(),
afterFirstFailure,
"the identical sql.js failure must reuse its content-addressed snapshot"
);
db.exec("DROP TRIGGER block_sqljs_ledger_replay");
assert.equal(runMigrations(db), 4);
const created = listPreMigrationBackups().filter((name) => !backupsBefore.includes(name));
assert.equal(
created.length,
2,
`dropping the trigger changes the DB state and must create a second snapshot: ${created}`
);
const snapshot = new Database(path.join(dataDir, "db_backups", created[0]!), {
readonly: true,
});
try {
assert.equal(snapshot.pragma("integrity_check", { simple: true }), "ok");
assert.equal(snapshot.pragma("user_version", { simple: true }), 42);
assert.equal(snapshot.pragma("application_id", { simple: true }), 1337);
const snapshotBytes = fs.readFileSync(path.join(dataDir, "db_backups", created[0]!));
assert.equal(snapshotBytes.readUInt32BE(24), 1);
assert.equal(
snapshotBytes.readUInt32BE(92),
snapshotBytes.readUInt32BE(24),
"the normalized SQLite change counter and version-valid-for fields must agree"
);
assert.deepEqual(
snapshot.prepare("SELECT version, name FROM _omniroute_migrations").all(),
[{ version: "074", name: "discovery_results" }]
);
assert.equal(
snapshot
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'discovery_results'"
)
.get(),
undefined,
"the snapshot must contain the complete pre-replay image"
);
} finally {
snapshot.close();
}
const { listDbBackups } = await import("../../src/lib/db/backup.ts");
const listed = await listDbBackups();
assert.equal(
listed.find((backup) => backup.id === created[0])?.reason,
"pre-migration",
"the content address must not change the public backup reason"
);
db.close();
const reopened = await createSqlJsAdapter(sqlitePath);
try {
assert.deepEqual(
reopened
.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version")
.all(),
[
{ version: "074", name: "discovery_results" },
{ version: "081", name: "inspector_custom_hosts" },
{ version: "151", name: "windsurf_to_devin_desktop" },
{ version: "152", name: "remove_puter_provider" },
]
);
assert.ok(
reopened
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_discovery_results_provider'"
)
.get()
);
assert.ok(
reopened
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_discovery_results_status'"
)
.get()
);
} finally {
reopened.close();
}
} finally {
if (db.open) db.close();
if (previousDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = previousDisableBackup;
}
});
}

View File

@@ -70,8 +70,8 @@ describe("migrationRunner/constants — exact small-table snapshots", () => {
// ── large tables — count + shape + spot-checks (corruption guard) ─────────────
describe("migrationRunner/constants — large-table integrity", () => {
it("RENAMED_MIGRATION_COMPATIBILITY has 31 well-formed entries", () => {
assert.equal(RENAMED_MIGRATION_COMPATIBILITY.length, 31);
it("RENAMED_MIGRATION_COMPATIBILITY has 32 well-formed entries", () => {
assert.equal(RENAMED_MIGRATION_COMPATIBILITY.length, 32);
for (const e of RENAMED_MIGRATION_COMPATIBILITY) {
assert.equal(typeof e.fromVersion, "string");
assert.equal(typeof e.fromName, "string");
@@ -113,6 +113,17 @@ describe("migrationRunner/constants — large-table integrity", () => {
"144",
]
);
assert.deepEqual(
RENAMED_MIGRATION_COMPATIBILITY.find(
(e) => e.fromVersion === "074" && e.fromName === "inspector_custom_hosts"
),
{
fromVersion: "074",
fromName: "inspector_custom_hosts",
toVersion: "081",
toName: "inspector_custom_hosts",
}
);
assert.deepEqual(RENAMED_MIGRATION_COMPATIBILITY.at(-7), {
fromVersion: "134",
fromName: "ccr_blocks",

View File

@@ -1,27 +1,24 @@
// ENVIRONMENT NOTE (sandbox better-sqlite3 / glibc limitation, not a code defect):
// This test constructs or exercises a real better-sqlite3-backed SQLite database.
// better-sqlite3 is a native addon; production and CI load it normally, but some
// sandboxes/dev boxes ship a system glibc older than the prebuilt binary requires
// ("GLIBC_2.29 not found"), so the native module fails to dlopen and any test that
// reaches better-sqlite3 directly (or asserts stdout that the load-failure warning
// would pollute) fails HERE while passing in CI. This is a known environment
// limitation, not a defect in the code under test: the OmniRoute runtime itself
// cascades to node:sqlite/sql.js when better-sqlite3 is unavailable. See
// tests/unit/_helpers/betterSqlite3Availability.ts for a guard helper.
// #10421 — pre-migration backups were created on every migration run and never pruned,
// so `db_backups/` grew without bound (observed: 48.999 files / 204 GB against a 5,3 MB
// live database). The pruning logic already existed in `cleanupDbBackups()` but nothing
// on the migration path ever reached it. These tests pin the retention step to the
// backup call site so the operator's maxFiles/retentionDays budget is honored there too.
// This suite uses a real on-disk better-sqlite3 database because migration snapshots
// must exercise SQLite's native read-only VACUUM path. Production and CI load the native
// addon normally; see tests/unit/_helpers/betterSqlite3Availability.ts for older sandboxes.
//
// #10421 — repeated failed startups once created a fresh timestamped snapshot every time
// and pruned unrelated restore points. Migration safety now publishes a content-addressed
// snapshot once per database state, never deletes a published snapshot, and leaves retention
// to the manual/scheduled backup paths outside the migration window.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import { createBetterSqliteAdapter } from "../../src/lib/db/adapters/betterSqliteAdapter.ts";
const serial = { concurrency: false };
async function importFresh(modulePath: string) {
@@ -29,27 +26,23 @@ async function importFresh(modulePath: string) {
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
function withMockedMigrationFs(files: Record<string, string>, fn: () => void) {
function withMockedMigrationFs<T>(files: Record<string, string>, fn: () => T): T {
const originalExistsSync = fs.existsSync;
const originalReaddirSync = fs.readdirSync;
const originalReadFileSync = fs.readFileSync;
const isMigrationDir = (target: unknown) =>
String(target).replaceAll("\\", "/").endsWith("/src/lib/db/migrations") ||
String(target).replaceAll("\\", "/").endsWith("/migrations");
fs.existsSync = ((target: unknown) => {
if (isMigrationDir(target)) return true;
const fileName = path.basename(String(target));
if (Object.hasOwn(files, fileName)) return true;
if (Object.hasOwn(files, path.basename(String(target)))) return true;
return originalExistsSync(target as string);
}) as typeof fs.existsSync;
fs.readdirSync = ((target: string, options?: unknown) => {
if (isMigrationDir(target)) return Object.keys(files);
return originalReaddirSync(target, options as never);
}) as typeof fs.readdirSync;
fs.readFileSync = ((target: unknown, options?: unknown) => {
const fileName = path.basename(String(target));
if (Object.hasOwn(files, fileName)) return files[fileName];
@@ -65,149 +58,264 @@ function withMockedMigrationFs(files: Record<string, string>, fn: () => void) {
}
}
/** Minimal SqliteAdapter over a real on-disk file (VACUUM INTO needs a file, not :memory:). */
function createFileDb(sqlitePath: string) {
const db = new Database(sqlitePath);
return {
driver: "better-sqlite3",
get open() {
return db.open;
},
get name() {
return db.name;
},
prepare: (sql: string) => db.prepare(sql),
exec: (sql: string) => db.exec(sql),
pragma: (str: string, options?: unknown) => db.pragma(str, options as never),
transaction: (fn: (...args: unknown[]) => unknown) => {
const tx = db.transaction((...args: unknown[]) => fn(...args));
return (...args: unknown[]) => tx(...args);
},
immediate: (fn: () => void) => fn(),
async backup() {},
checkpoint() {},
close: () => db.close(),
get raw() {
return db;
},
};
return createBetterSqliteAdapter(new Database(sqlitePath));
}
/**
* Build a DB that already has migrations applied (so the pre-migration backup path is
* reached: it requires `applied.size > 0`) plus one pending migration to trigger a run.
*/
function seedAppliedDb(db: ReturnType<typeof createFileDb>) {
function seedExistingDb(db: ReturnType<typeof createFileDb>): void {
db.exec(`
CREATE TABLE provider_connections (id TEXT PRIMARY KEY);
CREATE TABLE combos (id TEXT PRIMARY KEY);
CREATE TABLE call_logs (id TEXT PRIMARY KEY);
`);
}
/**
* Record 001 as applied in the runner's own ledger table. `runMigrations` only takes a
* pre-migration backup when `applied.size > 0`, so this is what puts the test on the
* code path under exercise.
*/
function seedAppliedMigration(db: ReturnType<typeof createFileDb>) {
db.exec(`
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO provider_connections (id) VALUES ('existing-data');
INSERT INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema');
`);
db.prepare(
"INSERT OR REPLACE INTO _omniroute_migrations (version, name, applied_at) VALUES (?, ?, ?)"
).run("001", "initial_schema", new Date().toISOString());
}
function makeTempDataDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-backup-retention-"));
function seedSetupSkeleton(db: ReturnType<typeof createFileDb>): void {
db.exec(`
CREATE TABLE provider_connections (id TEXT PRIMARY KEY);
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO provider_connections (id) VALUES ('setup-preserved-data');
INSERT INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema');
`);
}
function makeTempDataDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-snapshot-"));
fs.mkdirSync(path.join(dir, "db_backups"), { recursive: true });
return dir;
}
/** Pre-existing backups, oldest first, with distinct mtimes so retention ordering is stable. */
function seedBackups(backupDir: string, count: number) {
function seedTraditionalBackups(backupDir: string, count: number): string[] {
const names: string[] = [];
for (let i = 0; i < count; i++) {
const name = `db_2026-08-${String(i + 1).padStart(2, "0")}T00-00-00-000Z_pre-migration.sqlite`;
const filePath = path.join(backupDir, name);
fs.writeFileSync(filePath, "x");
const t = new Date(2026, 7, i + 1).getTime() / 1000;
fs.utimesSync(filePath, t, t);
for (let index = 0; index < count; index += 1) {
const name =
`db_2026-08-${String(index + 1).padStart(2, "0")}` + "T00-00-00-000Z_pre-migration.sqlite";
fs.writeFileSync(path.join(backupDir, name), `seed-${index}`);
names.push(name);
}
return names;
}
function countBackups(backupDir: string) {
return fs.readdirSync(backupDir).filter((n) => n.startsWith("db_")).length;
function listCanonicalBackups(backupDir: string): string[] {
if (!fs.existsSync(backupDir)) return [];
return fs
.readdirSync(backupDir)
.filter((name) => name.startsWith("db_") && name.endsWith(".sqlite"))
.sort();
}
function withEnv(vars: Record<string, string | undefined>, fn: () => void) {
const saved: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(vars)) {
saved[k] = process.env[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
function listOwnedTempDirs(backupDir: string): string[] {
if (!fs.existsSync(backupDir)) return [];
return fs.readdirSync(backupDir).filter((name) => name.startsWith(".migration-snapshot-"));
}
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
const saved = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(vars)) {
saved.set(key, process.env[key]);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
return fn();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
test(
"#10421 runMigrations prunes pre-migration backups to the configured maxFiles",
"repeated zero-progress failures reuse one content-addressed snapshot without pruning",
serial,
async () => {
const dataDir = makeTempDataDir();
const backupDir = path.join(dataDir, "db_backups");
const sqlitePath = path.join(dataDir, "storage.sqlite");
const db = createFileDb(sqlitePath);
const db = createFileDb(path.join(dataDir, "storage.sqlite"));
try {
seedAppliedDb(db);
seedBackups(backupDir, 30);
assert.equal(countBackups(backupDir), 30, "precondition: 30 stale backups on disk");
seedExistingDb(db);
const seeded = seedTraditionalBackups(backupDir, 6);
const { runMigrations } = await importFresh("src/lib/db/migrationRunner.ts");
const files = {
"001_initial_schema.sql": "SELECT 1;",
"002_broken_probe.sql": "INSERT INTO table_that_does_not_exist VALUES (1);",
};
const fail = () => withMockedMigrationFs(files, () => runMigrations(db));
withEnv(
{
DB_BACKUP_MAX_FILES: "5",
DB_BACKUP_RETENTION_DAYS: "0",
DISABLE_SQLITE_AUTO_BACKUP: undefined,
},
() => {
assert.throws(fail, /table_that_does_not_exist/);
const afterFirst = listCanonicalBackups(backupDir);
const contentAddressed = afterFirst.filter((name) => name.startsWith("db_state-"));
assert.equal(contentAddressed.length, 1);
assert.match(contentAddressed[0]!, /^db_state-[a-f0-9]{64}_pre-migration\.sqlite$/);
assert.equal(
seeded.every((name) => afterFirst.includes(name)),
true,
"migration failure must not prune pre-existing restore points"
);
assert.throws(fail, /table_that_does_not_exist/);
assert.deepEqual(
listCanonicalBackups(backupDir),
afterFirst,
"an unchanged failed startup must reuse the exact content-addressed snapshot"
);
assert.deepEqual(listOwnedTempDirs(backupDir), []);
} finally {
db.close();
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
);
test(
"an existing DB fails closed when hard-link publication is unavailable even with auto backup disabled",
serial,
async () => {
const dataDir = makeTempDataDir();
const backupDir = path.join(dataDir, "db_backups");
const db = createFileDb(path.join(dataDir, "storage.sqlite"));
const originalLinkSync = fs.linkSync;
try {
seedExistingDb(db);
const { runMigrations } = await importFresh("src/lib/db/migrationRunner.ts");
fs.linkSync = (() => {
throw Object.assign(new Error("hard links unsupported by this filesystem"), {
code: "ENOTSUP",
});
}) as typeof fs.linkSync;
assert.throws(
() =>
withEnv({ DISABLE_SQLITE_AUTO_BACKUP: "true" }, () =>
withMockedMigrationFs(
{
"001_initial_schema.sql": "SELECT 1;",
"002_ordinary_pending.sql": "CREATE TABLE must_not_apply (id INTEGER);",
},
() => runMigrations(db)
)
),
/durable snapshot.*hard links unsupported.*hard links.*synchronization/is
);
assert.equal(
db.prepare("SELECT name FROM sqlite_master WHERE name = 'must_not_apply'").get(),
undefined,
"an ordinary pending migration must not run without its mandatory snapshot"
);
assert.deepEqual(
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
[{ version: "001", name: "initial_schema" }]
);
assert.deepEqual(listCanonicalBackups(backupDir), []);
assert.deepEqual(listOwnedTempDirs(backupDir), []);
} finally {
fs.linkSync = originalLinkSync;
db.close();
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
);
test(
"a pre-existing setup skeleton requires a snapshot even when mass-migration safety treats it as fresh",
serial,
async () => {
const dataDir = makeTempDataDir();
const backupDir = path.join(dataDir, "db_backups");
const db = createFileDb(path.join(dataDir, "storage.sqlite"));
const originalLinkSync = fs.linkSync;
try {
seedSetupSkeleton(db);
const { runMigrations } = await importFresh("src/lib/db/migrationRunner.ts");
fs.linkSync = (() => {
throw Object.assign(new Error("hard links unsupported by this filesystem"), {
code: "ENOTSUP",
});
}) as typeof fs.linkSync;
assert.throws(
() =>
withMockedMigrationFs(
{
"001_initial_schema.sql": "SELECT 1;",
"002_retention_probe.sql": "CREATE TABLE retention_probe_10421 (id INTEGER);",
"002_ordinary_pending.sql": "CREATE TABLE must_not_apply (id INTEGER);",
},
() => {
// Mark 001 as applied so `applied.size > 0` and the backup path is reached.
seedAppliedMigration(db);
() =>
runMigrations(db, {
isNewDb: true,
databaseExistedBeforeInitialization: true,
})
),
/durable snapshot.*hard links unsupported.*hard links.*synchronization/is
);
assert.equal(
db.prepare("SELECT name FROM sqlite_master WHERE name = 'must_not_apply'").get(),
undefined,
"a setup-created persistent DB must not change when its safety snapshot cannot publish"
);
assert.deepEqual(
db.prepare("SELECT id FROM provider_connections").all(),
[{ id: "setup-preserved-data" }],
"the setup-created provider state must remain untouched"
);
assert.deepEqual(listCanonicalBackups(backupDir), []);
assert.deepEqual(listOwnedTempDirs(backupDir), []);
} finally {
fs.linkSync = originalLinkSync;
db.close();
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
);
runMigrations(db);
}
);
}
test(
"successful migrations retain existing backups and do not prune inside the migration window",
serial,
async () => {
const dataDir = makeTempDataDir();
const backupDir = path.join(dataDir, "db_backups");
const db = createFileDb(path.join(dataDir, "storage.sqlite"));
try {
seedExistingDb(db);
const seeded = seedTraditionalBackups(backupDir, 6);
const { runMigrations } = await importFresh("src/lib/db/migrationRunner.ts");
const count = withEnv({ DB_BACKUP_MAX_FILES: "1", DB_BACKUP_RETENTION_DAYS: "0" }, () =>
withMockedMigrationFs(
{
"001_initial_schema.sql": "SELECT 1;",
"002_success.sql": "CREATE TABLE migration_success (id INTEGER);",
},
() => runMigrations(db)
)
);
const remaining = countBackups(backupDir);
assert.equal(count, 1);
assert.ok(
remaining <= 5,
`expected retention to cap db_backups at 5 files, found ${remaining}` +
`pre-migration backups are accumulating unbounded (#10421)`
db.prepare("SELECT name FROM sqlite_master WHERE name = 'migration_success'").get()
);
const after = listCanonicalBackups(backupDir);
assert.equal(after.filter((name) => name.startsWith("db_state-")).length, 1);
assert.equal(
seeded.every((name) => after.includes(name)),
true,
"retention must remain outside the concurrent migration window"
);
} finally {
db.close();
@@ -216,51 +324,26 @@ test(
}
);
test("#10421 the newest pre-migration backup survives pruning", serial, async () => {
test("an already-current DB does not acquire an IMMEDIATE writer lock", serial, async () => {
const dataDir = makeTempDataDir();
const backupDir = path.join(dataDir, "db_backups");
const sqlitePath = path.join(dataDir, "storage.sqlite");
const db = createFileDb(sqlitePath);
const db = createFileDb(path.join(dataDir, "storage.sqlite"));
try {
seedAppliedDb(db);
seedBackups(backupDir, 10);
seedExistingDb(db);
const { runMigrations } = await importFresh("src/lib/db/migrationRunner.ts");
withEnv(
{
DB_BACKUP_MAX_FILES: "3",
DB_BACKUP_RETENTION_DAYS: "0",
DISABLE_SQLITE_AUTO_BACKUP: undefined,
const noWriterAdapter = {
...db,
immediate: () => {
throw new Error("unexpected IMMEDIATE writer lock");
},
() => {
withMockedMigrationFs(
{
"001_initial_schema.sql": "SELECT 1;",
"002_retention_probe.sql": "CREATE TABLE retention_probe_10421b (id INTEGER);",
},
() => {
seedAppliedMigration(db);
};
runMigrations(db);
}
);
}
assert.equal(
withMockedMigrationFs({ "001_initial_schema.sql": "SELECT 1;" }, () =>
runMigrations(noWriterAdapter)
),
0
);
const remaining = fs.readdirSync(backupDir).filter((n) => n.startsWith("db_"));
assert.ok(remaining.length <= 3, `expected <=3 backups, found ${remaining.length}`);
// The backup written by THIS run must be among the survivors — pruning must never
// discard the snapshot that protects the migration it was taken for.
const seededNames = new Set(
Array.from({ length: 10 }, (_, i) => {
return `db_2026-08-${String(i + 1).padStart(2, "0")}T00-00-00-000Z_pre-migration.sqlite`;
})
);
const fresh = remaining.filter((n) => !seededNames.has(n));
assert.equal(fresh.length, 1, `expected the run's own backup to survive, got ${fresh.length}`);
} finally {
db.close();
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });

View File

@@ -8,8 +8,16 @@ 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-err-sanitize-"));
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-err-sanitize-"));
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET;
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
process.env.API_KEY_SECRET = "test-api-key-secret-32chars-long!!";
const core = await import("../../src/lib/db/core.ts");
@@ -42,7 +50,13 @@ test.beforeEach(async () => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
if (ORIGINAL_API_KEY_SECRET === undefined) delete process.env.API_KEY_SECRET;
else process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET;
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
async function createCombo(name: string, model: string) {
@@ -338,7 +352,8 @@ test("buildErrorBody — upstream details with stack key are stripped", async ()
!("stack" in (body.upstream_details as any)),
"stack must be stripped from upstream_details"
);
assert.equal((body.upstream_details as any).code, "internal");
assert.equal((body.upstream_details as any).code, "");
assert.doesNotMatch(JSON.stringify(body.upstream_details), /internal/);
});
// ── createErrorResult with upstreamDetails ───────────────────────────────────

View File

@@ -0,0 +1,11 @@
import test from "node:test";
import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts";
test("public error boundaries pass in an isolated child process", () => {
runIsolatedBoundaryFixture({
fixtureUrl: new URL("./fixtures/error-public-boundaries-hardening.fixture.ts", import.meta.url),
expectedTests: 23,
label: "public error boundaries",
});
});

Some files were not shown because too many files have changed in this diff Show More