From b40a9fc3daa567a485540263f5ddc54648cba6db Mon Sep 17 00:00:00 2001
From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:48:13 -0300
Subject: [PATCH] security(runtime): harden TLS and public error boundaries
---
.env.example | 12 +
.github/workflows/electron-release.yml | 5 +
Dockerfile | 4 +-
Dockerfile.bun | 20 +-
README.md | 6 +-
THIRD_PARTY_NOTICES.md | 4 +-
.../11742-tls-client-license-provenance.md | 4 +-
config/quality/.license-allowlist.json | 8 +-
config/quality/eslint-suppressions.json | 5 -
docs/diagrams/comparison-table.svg | 8 +-
docs/diagrams/privacy-local.svg | 6 +-
docs/i18n/it/README.md | 10 +-
docs/i18n/ru/README.md | 8 +-
docs/i18n/tr/README.md | 10 +-
docs/reference/ENVIRONMENT.md | 3 +
docs/security/ERROR_SANITIZATION.md | 111 +-
open-sse/executors/chatgpt-web.ts | 220 ++--
open-sse/executors/chatgpt-web/handoff.ts | 34 +-
open-sse/executors/chatgptWebTools.ts | 1 +
open-sse/executors/claude-web.ts | 30 +-
open-sse/executors/grok-web.ts | 43 +-
open-sse/executors/lmarena.ts | 20 +-
open-sse/executors/lmarena/error.ts | 17 +
open-sse/executors/lmarena/models.ts | 5 +-
open-sse/executors/lmarena/response.ts | 75 +-
open-sse/executors/notion-web.ts | 69 +-
open-sse/executors/perplexity-web.ts | 525 ++++++---
open-sse/executors/perplexity-web/protocol.ts | 13 +-
open-sse/handlers/chatCore.ts | 402 ++++---
.../handlers/chatCore/streamErrorResult.ts | 76 +-
open-sse/handlers/moderations.ts | 20 +-
open-sse/handlers/ocr.ts | 18 +-
open-sse/services/tlsClientBase.ts | 776 +++++--------
open-sse/services/tlsClientDownloadDir.ts | 596 +++++++++-
.../services/tlsClientLifecycleRegistry.ts | 273 +++++
open-sse/services/tlsClientStream.ts | 416 +++++++
open-sse/services/tlsClientTimeout.ts | 70 ++
open-sse/utils/error.ts | 287 +++--
open-sse/utils/errorPathRedaction.ts | 830 ++++++++++++++
open-sse/utils/errorSanitization.ts | 440 ++++++++
open-sse/utils/upstreamErrorPassthrough.ts | 39 +-
open-sse/utils/upstreamErrorResponse.ts | 47 +
package.json | 10 +-
scripts/build/assembleStandalone.mjs | 246 ++--
scripts/build/build-next-isolated.mjs | 94 +-
scripts/build/fixTlsClientNodeBinary.mjs | 839 ++++++++++++--
scripts/build/pack-artifact-policy.ts | 38 +
scripts/build/prepare-electron-standalone.mjs | 27 +
scripts/build/prepublish.ts | 35 +-
scripts/build/standaloneSidecarCopy.mjs | 121 ++
scripts/build/tlsClientAssetCopy.mjs | 446 ++++++++
scripts/check/check-licenses.mjs | 112 +-
src/app/api/providers/validate/route.ts | 8 +-
src/lib/logPayloads.ts | 17 +-
src/lib/providers/validation/transport.ts | 99 +-
src/lib/providers/validation/webProvidersA.ts | 145 ++-
src/lib/providers/validation/webProvidersB.ts | 86 +-
...next-isolated-assembly-fail-closed.test.ts | 185 +++
tests/unit/build/check-licenses.test.ts | 299 ++++-
.../build/electron-tls-client-seed.test.ts | 165 +++
.../build/tls-client-assembly-digest.test.ts | 445 ++++++++
.../tls-client-license-provenance.test.ts | 225 +++-
tests/unit/build/tls-client-pack-seed.test.ts | 184 +++
.../unit/chatcore-stream-error-result.test.ts | 86 ++
tests/unit/chatcore-translation-paths.test.ts | 587 +++++++---
tests/unit/chatgpt-web-handoff-resume.test.ts | 99 ++
tests/unit/chatgpt-web-tools-5240.test.ts | 89 +-
tests/unit/chatgpt-web.test.ts | 1004 ++++++++++++++++-
tests/unit/combo-diagnostics-trace.test.ts | 87 +-
...r-message-sanitization-credentials.test.ts | 989 ++++++++++++++++
tests/unit/error-message-sanitization.test.ts | 539 +++++++++
tests/unit/executor-notion-web.test.ts | 174 ++-
.../fix-tls-client-node-binary-7802.test.ts | 741 +++++++++++-
...ix-tls-client-node-binary-security.test.ts | 493 ++++++++
tests/unit/grok-web.test.ts | 315 +++++-
tests/unit/lmarena-provider.test.ts | 291 ++++-
...marena-stream-readiness-repro-9306.test.ts | 162 ++-
tests/unit/moderations-handler.test.ts | 134 ++-
tests/unit/ocr-handler-dispatch.test.ts | 189 ++++
tests/unit/pack-artifact-policy.test.ts | 46 +
tests/unit/perplexity-web.test.ts | 527 +++++++--
...ider-validation-error-sanitization.test.ts | 156 +++
.../provider-validation-specialty.test.ts | 483 +++++++-
.../repro-9406-claude-web-429-valid.test.ts | 77 +-
tests/unit/request-log-payloads.test.ts | 28 +
.../stream-failure-499-classification.test.ts | 51 +-
.../unit/tls-client-download-dir-8579.test.ts | 846 +++++++++++++-
.../unit/tls-client-install-lock-race.test.ts | 220 ++++
tests/unit/tls-client-lifecycle.test.ts | 873 ++++++++++++++
...tls-client-node-docker-binary-7802.test.ts | 107 +-
tests/unit/tls-client-timeout.test.ts | 88 ++
.../unit/types-barrel-model-cooldown.test.ts | 20 +
tests/unit/upstream-error-passthrough.test.ts | 60 +-
93 files changed, 16963 insertions(+), 1990 deletions(-)
create mode 100644 open-sse/executors/lmarena/error.ts
create mode 100644 open-sse/services/tlsClientLifecycleRegistry.ts
create mode 100644 open-sse/services/tlsClientStream.ts
create mode 100644 open-sse/services/tlsClientTimeout.ts
create mode 100644 open-sse/utils/errorPathRedaction.ts
create mode 100644 open-sse/utils/errorSanitization.ts
create mode 100644 open-sse/utils/upstreamErrorResponse.ts
create mode 100644 scripts/build/standaloneSidecarCopy.mjs
create mode 100644 scripts/build/tlsClientAssetCopy.mjs
create mode 100644 tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts
create mode 100644 tests/unit/build/electron-tls-client-seed.test.ts
create mode 100644 tests/unit/build/tls-client-assembly-digest.test.ts
create mode 100644 tests/unit/build/tls-client-pack-seed.test.ts
create mode 100644 tests/unit/error-message-sanitization-credentials.test.ts
create mode 100644 tests/unit/fix-tls-client-node-binary-security.test.ts
create mode 100644 tests/unit/provider-validation-error-sanitization.test.ts
create mode 100644 tests/unit/tls-client-install-lock-race.test.ts
create mode 100644 tests/unit/tls-client-lifecycle.test.ts
create mode 100644 tests/unit/tls-client-timeout.test.ts
diff --git a/.env.example b/.env.example
index c1a1f06e67..76fb26df3a 100644
--- a/.env.example
+++ b/.env.example
@@ -2530,6 +2530,11 @@ APP_LOG_TO_FILE=true
# should leave this unset; the sidecar is auto-managed.
# OMNIROUTE_TLS_PROXY_URL=
+# Optional read-only seed directory for the pinned tls-client-node native binary.
+# The file name and SHA-256 must match the bundled manifest; symlinks are rejected.
+# Used by: open-sse/services/tlsClientDownloadDir.ts
+# OMNIROUTE_TLS_CLIENT_SEED_DIR=
+
# ── Skills sandbox (experimental) ──
# Used by: src/lib/skills/builtins.ts. All values support comma lists where
# noted in the source.
@@ -2637,6 +2642,13 @@ APP_LOG_TO_FILE=true
# Default (when unset): 1 (tarballs emitted). Set to 0 to disable.
# OMNIROUTE_OPTIONAL_PACK_TAR=1
+# Electron packaging target passed to prepare-electron-standalone.mjs. The root
+# electron:build:* scripts and release workflow set these automatically; leave
+# them unset for host-native local preparation. Platform accepts win32, darwin,
+# or linux. Arches is a comma-separated list such as x64 or x64,arm64.
+# OMNIROUTE_ELECTRON_TARGET_PLATFORM=
+# OMNIROUTE_ELECTRON_TARGET_ARCHES=
+
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
# ELECTRON_SMOKE_TIMEOUT_MS=45000
diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml
index e899a664ea..cb2684ed47 100644
--- a/.github/workflows/electron-release.yml
+++ b/.github/workflows/electron-release.yml
@@ -95,6 +95,9 @@ jobs:
OMNIROUTE_USE_TURBOPACK: "0"
run: npm run build
+ - name: Verify TLS client runtime seed
+ run: node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone
+
- name: Pack standalone bundle
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
# archive's own sha256 so artifact-transfer corruption is caught before
@@ -258,6 +261,8 @@ jobs:
working-directory: electron
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OMNIROUTE_ELECTRON_TARGET_PLATFORM: ${{ matrix.os }}
+ OMNIROUTE_ELECTRON_TARGET_ARCHES: ${{ matrix.arch }}
run: npm run build:${{ matrix.target }}
- name: Smoke packaged Electron app
diff --git a/Dockerfile b/Dockerfile
index c0e4cf45f3..1e500e70ce 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -199,6 +199,7 @@ COPY . ./
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
mkdir -p /app/data \
&& npm run build \
+ && node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone \
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
# ── Runner base ────────────────────────────────────────────────────────────
@@ -207,8 +208,7 @@ FROM base AS runner-base
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \
org.opencontainers.image.url="https://omniroute.online" \
- org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
- org.opencontainers.image.licenses="MIT"
+ org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute"
ENV NODE_ENV=production
ENV PORT=20128
diff --git a/Dockerfile.bun b/Dockerfile.bun
index bb547ce210..71b4b3aaf3 100644
--- a/Dockerfile.bun
+++ b/Dockerfile.bun
@@ -29,10 +29,8 @@ RUN if [ -d "node_modules/better-sqlite3" ]; then \
(cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \
fi
-# Fetch tls-client-node native binary if script exists
-RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \
- bun node_modules/tls-client-node/scripts/postinstall.js || true; \
- fi
+# Pin and checksum-verify the tls-client-node native binary; fail closed if unavailable.
+RUN bun scripts/build/fixTlsClientNodeBinary.mjs --strict
# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
ENV OMNIROUTE_USE_TURBOPACK=0
@@ -47,7 +45,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Bun native Next.js build execution
-RUN bun run --quiet build
+RUN bun run --quiet build \
+ && bun scripts/build/fixTlsClientNodeBinary.mjs --strict \
+ --standalone-dir .build/next/standalone
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
FROM oven/bun:1.3.14-slim AS runner-base
@@ -55,8 +55,7 @@ FROM oven/bun:1.3.14-slim AS runner-base
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
org.opencontainers.image.url="https://omniroute.online" \
- org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
- org.opencontainers.image.licenses="MIT"
+ org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute"
WORKDIR /app
@@ -73,14 +72,17 @@ ENV HOSTNAME=0.0.0.0
ENV OMNIROUTE_MEMORY_MB=1024
ENV DATA_DIR=/app/data
-RUN mkdir -p /app/data
+RUN mkdir -p /app/data \
+ && chown -R bun:bun /app/data
-COPY --from=builder /app/.build/next/standalone ./
+COPY --from=builder --chown=bun:bun /app/.build/next/standalone ./
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
+USER bun
+
EXPOSE 20128
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
diff --git a/README.md b/README.md
index aca28600b8..eba33a294b 100644
--- a/README.md
+++ b/README.md
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
-
+
📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)
@@ -469,7 +469,7 @@ All **19** strategies — mix & match per combo step:
## 💚 Support OmniRoute
-OmniRoute is MIT-licensed and maintained in the open. If it saves you time or money, here's how to keep it independent — pick whatever fits you. Sponsorship never affects routing priority; it buys visibility, not ranking.
+OmniRoute's own code is MIT-licensed and maintained in the open; bundled and optional third-party components retain their own licenses. If it saves you time or money, here's how to keep it independent — pick whatever fits you. Sponsorship never affects routing priority; it buys visibility, not ranking.
⭐ Star the repo Free — genuinely helps visibility Star OmniRoute
@@ -769,7 +769,7 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
-
+
📖 [Authorization](docs/architecture/AUTHZ_GUIDE.md) · [Guardrails](docs/security/GUARDRAILS.md) · [Compliance](docs/security/COMPLIANCE.md)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 5e62c56b83..d262d12c67 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -28,8 +28,8 @@ OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWA
## fatihkabakk/tls-client-node 0.2.0
OmniRoute includes [`tls-client-node@0.2.0`](https://github.com/fatihkabakk/tls-client-node/tree/v0.2.0)
-as an optional runtime dependency. The package is source-available under Apache License 2.0
-with the Commons Clause License Condition v1.0. The following license and NOTICE blocks are
+as an optional runtime dependency. The package is source-available, not OSI-approved, under
+Apache License 2.0 with the Commons Clause License Condition v1.0. The following license and NOTICE blocks are
reproduced verbatim from the tagged primary sources.
### tls-client-node license
diff --git a/changelog.d/maintenance/11742-tls-client-license-provenance.md b/changelog.d/maintenance/11742-tls-client-license-provenance.md
index b0fcaf68df..3ec2878626 100644
--- a/changelog.d/maintenance/11742-tls-client-license-provenance.md
+++ b/changelog.d/maintenance/11742-tls-client-license-provenance.md
@@ -1 +1,3 @@
-- **security(deps):** pin `tls-client-node@0.2.0`, ship its exact Commons Clause/Apache and upstream BSD-4 notices, pin `bogdanfinn/tls-client` to v1.15.1, and verify every native binary against GitHub's official SHA-256 before loading it ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)).
+- **security(deps):** pin `tls-client-node@0.2.0`, ship its exact Commons Clause/Apache and upstream BSD-4 notices, pin `bogdanfinn/tls-client` to v1.15.1, and verify every supported native binary against GitHub's official SHA-256 before loading it ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)).
+- **fix(runtime):** serialize native TLS installation and client ownership, bound caller waits for startup/requests, and coordinate stream and lease cleanup across completion, error, cancellation, and process-exit hooks ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)).
+- **security(runtime):** fail closed on hostile thrown values and redact credentials, recognized filesystem paths, and stack frames from upstream HTTP/SSE errors and diagnostic logs in chat, provider validation, moderation, and OCR boundaries ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)).
diff --git a/config/quality/.license-allowlist.json b/config/quality/.license-allowlist.json
index 0f314dfa15..a4582965f4 100644
--- a/config/quality/.license-allowlist.json
+++ b/config/quality/.license-allowlist.json
@@ -76,13 +76,15 @@
"reviewAt": "v4.0.0"
},
"tls-client-node": {
- "license": "Custom: LICENSE (Apache-2.0 + Commons Clause)",
- "justification": "TEMPORARY bridge for a restrictive, source-available dependency: tls-client-node uses Apache-2.0 with the Commons Clause, which removes the right to Sell the software when a paid product or service derives entirely or substantially from its functionality. The shared native TLS transport serves six providers: chatgpt-web, claude-web, perplexity-web, grok-web, notion-web, and lmarena. Shipping the required notices does not grant commercial rights. Legal review is required before commercial deployment; replace with a permissive transport such as wreq-js or obtain separate permission before this exception expires.",
+ "version": "0.2.0",
+ "license": "Custom: LICENSE",
+ "justification": "TEMPORARY bridge for a restrictive, source-available dependency: tls-client-node uses Apache-2.0 with the Commons Clause, which removes the right to Sell the software when a paid product or service derives entirely or substantially from its functionality. The shared native TLS transport serves six providers: chatgpt-web, claude-web, perplexity-web, grok-web, notion-web, and lmarena. Shipping the required notices does not grant commercial rights. Legal review is required before commercial deployment; replace with a permissive transport such as wreq-js or obtain separate permission before this exception expires. Tracked by PR #11742.",
"risk": "medium",
"temporary": true,
"owner": "@diegosouzapw",
"reviewBy": "2026-09-30",
- "reviewAt": "v3.9.0"
+ "reviewAt": "v3.9.0",
+ "classification": "Apache-2.0 with Commons Clause; non-OSI source-available"
}
}
}
diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json
index ff58151ba2..55aa0f9d3c 100644
--- a/config/quality/eslint-suppressions.json
+++ b/config/quality/eslint-suppressions.json
@@ -706,11 +706,6 @@
"count": 1
}
},
- "open-sse/services/tlsClientBase.ts": {
- "@typescript-eslint/no-unused-vars": {
- "count": 1
- }
- },
"open-sse/services/tokenLimitCounter.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg
index d75b8c2db1..577714bb38 100644
--- a/docs/diagrams/comparison-table.svg
+++ b/docs/diagrams/comparison-table.svg
@@ -1,4 +1,4 @@
-
+
Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses.
@@ -124,8 +124,8 @@
- Self-hosted · 100% MIT
- MIT
+ Self-hosted · own code license
+ MIT*
MIT
MIT
@@ -133,6 +133,6 @@
- full • partial • none · *OpenRouter counts models & is a hosted SaaS, not self-hosted.
+ full • partial • none · *Own code MIT; optional dependencies retain their licenses. OpenRouter counts models.
verified from each project's docs
diff --git a/docs/diagrams/privacy-local.svg b/docs/diagrams/privacy-local.svg
index b571eb7936..3f8673c3a2 100644
--- a/docs/diagrams/privacy-local.svg
+++ b/docs/diagrams/privacy-local.svg
@@ -1,4 +1,4 @@
-
+
Animated privacy ledger: eleven fully readable rows on the first frame; a soft green highlight sweeps down the rows in a continuous cycle.
@@ -15,11 +15,11 @@
PRIVATE & LOCAL-FIRST
- Your keys, your machine, your data. OmniRoute is a local proxy — it never phones home .
+ Your keys, your machine, your data. No OmniRoute-hosted prompt hop ; telemetry off by default .
- Runs 100% on your hardware — npm, Docker, desktop, or your phone — no OmniRoute cloud in the request path0 CLOUD HOPS Zero telemetry by default — your prompts go only to the providers you choose, nowhere elseDEFAULT Credentials encrypted at rest — API keys & OAuth tokens sealed on your own diskAES-256-GCM No account, no sign-up — a local password guards the dashboard — OmniRoute never asks who you areLOCAL AUTH Hardened gateway — API-key scoping, IP filtering, rate limits, prompt-injection guardAUTHZ TIERS Process routes are loopback-only — a token leaked through a tunnel can’t spawn processes127.0.0.1 Upstream header scrubbing — deny-listed headers stripped before every provider callDENY-LIST PII redaction & response sanitization — built in, strictly opt-in — payloads are never mutated by defaultOPT-IN Sanitized errors — responses never leak stack traces, paths or internalsNO LEAKS Local audit trail — MCP tool calls & admin actions logged in your SQLite, not oursYOUR DB MIT licensed & fully open-source — audit every line, self-host foreverMIT
+ Request routing runs on your hardware — npm, Docker, desktop, or your phoneSELF-HOSTED Telemetry disabled by default — activates only when explicitly configuredDEFAULT Credentials encrypted at rest — API keys & OAuth tokens sealed on your own diskAES-256-GCM No OmniRoute-hosted account service — operator controls dashboard identity: local password or optional OIDCLOCAL AUTH Hardened gateway — API-key scoping, IP filtering, rate limits, prompt-injection guardAUTHZ TIERS Process routes are loopback-only — a token leaked through a tunnel can’t spawn processes127.0.0.1 Upstream header scrubbing — deny-listed headers stripped before every provider callDENY-LIST PII redaction & response sanitization — built in, strictly opt-in — These redactions run only when enabledOPT-IN Sanitized errors — redact credentials, stack traces & recognized file pathsREDACTED Local audit trail — MCP tool calls & admin actions logged in your SQLite, not oursYOUR DB OmniRoute own code: MIT — bundled third-party components retain their own licensesMIT
diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md
index cbb7f49c4c..f9d0c70ff8 100644
--- a/docs/i18n/it/README.md
+++ b/docs/i18n/it/README.md
@@ -397,7 +397,7 @@ Tutte e **19** le strategie — combinabili liberamente per ogni passaggio della
-
+
📊 Metodologia completa e dettaglio per funzionalità rispetto a 9router, OpenRouter, CLIProxyAPI e LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](../../comparison/OMNIROUTE_VS_ALTERNATIVES.md)
@@ -406,7 +406,11 @@ Tutte e **19** le strategie — combinabili liberamente per ogni passaggio della
## 💚 Supporta OmniRoute
-OmniRoute è distribuito con licenza MIT e mantenuto apertamente. Se ti fa risparmiare tempo o denaro, ecco come aiutarlo a restare indipendente — scegli ciò che preferisci. Le sponsorizzazioni non influenzano mai la priorità del routing: acquistano visibilità, non posizionamento.
+Il codice proprio di OmniRoute è distribuito con licenza MIT e mantenuto apertamente; i
+componenti di terze parti inclusi e opzionali mantengono le rispettive licenze. Se ti fa
+risparmiare tempo o denaro, ecco come aiutarlo a restare indipendente — scegli ciò che preferisci.
+Le sponsorizzazioni non influenzano mai la priorità del routing: acquistano visibilità, non
+posizionamento.
⭐ Metti una stella alla repo Gratis — aiuta davvero la visibilità Dai una stella a OmniRoute
@@ -701,7 +705,7 @@ Dall'editor: apri la vista **Extensions**, cerca **"OmniRoute"**, fai clic su **
-
+
📖 [Autorizzazione](../../architecture/AUTHZ_GUIDE.md) · [Guardrail](../../security/GUARDRAILS.md) · [Conformità](../../security/COMPLIANCE.md)
diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md
index 54664d68d3..a3fccc452b 100644
--- a/docs/i18n/ru/README.md
+++ b/docs/i18n/ru/README.md
@@ -354,13 +354,13 @@ Combo: "always-on" strategy: priority
-> Ваши ключи, ваша машина, ваши данные. OmniRoute — **локальный прокси**, без «звонков домой».
+> Ваши ключи, ваша машина, ваши данные. OmniRoute не добавляет этап обработки промптов, размещённый на инфраструктуре OmniRoute; телеметрия по умолчанию отключена.
-- 🏠 **100% на вашем железе** — npm, Docker, desktop или телефон. Нет cloud-hop OmniRoute.
+- 🏠 **Роутинг выполняется локально** — npm, Docker, desktop или телефон; выбранные провайдеры остаются внешними upstream-сервисами.
- 🔐 **Credentials at rest** — API keys и OAuth в **AES-256-GCM**.
-- 🚫 **Zero telemetry по умолчанию** — промпты уходят только выбранным провайдерам.
+- 🚫 **Телеметрия по умолчанию отключена** — включается только при явной настройке.
- 🛡️ **Жёсткий gateway** — scoping ключей, IP filter, rate limits, prompt-injection guard, loopback-only process routes.
-- 📜 **MIT, fully open-source** — аудируйте построчно, self-host навсегда.
+- 📜 **Собственный код OmniRoute — MIT** — проект открыт; включённые сторонние компоненты сохраняют свои лицензии. Смотрите `THIRD_PARTY_NOTICES.md`.
📖 [Authorization](../../architecture/AUTHZ_GUIDE.md) · [Guardrails](../../security/GUARDRAILS.md) · [Compliance](../../security/COMPLIANCE.md)
diff --git a/docs/i18n/tr/README.md b/docs/i18n/tr/README.md
index 463c70caaa..e7977dcee6 100644
--- a/docs/i18n/tr/README.md
+++ b/docs/i18n/tr/README.md
@@ -461,7 +461,7 @@ Tüm **19** strateji — kombo adımı başına karıştırın ve eşleştirin:
-
+
📊 9router, OpenRouter, CLIProxyAPI ve LiteLLM'e karşı tam metodoloji ve özellik bazında detaylar → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)
@@ -469,7 +469,11 @@ Tüm **19** strateji — kombo adımı başına karıştırın ve eşleştirin:
## 💚 OmniRoute'u Destekleyin
-OmniRoute, MIT lisanslıdır ve açık olarak sürdürülmektedir. Size zaman veya para tasarrufu sağlıyorsa, bağımsız kalmasını nasıl sağlayabileceğinizi buradan görebilirsiniz — size en uygun yöntemi seçin. Sponsorluk yönlendirme önceliğini asla etkilemez; sıralamayı değil, görünürlüğü sağlar.
+OmniRoute'un kendi kodu MIT lisanslıdır ve açık olarak sürdürülmektedir; birlikte gelen ve
+isteğe bağlı üçüncü taraf bileşenler kendi lisanslarını korur. Size zaman veya para tasarrufu
+sağlıyorsa, bağımsız kalmasını nasıl sağlayabileceğinizi buradan görebilirsiniz — size en uygun
+yöntemi seçin. Sponsorluk yönlendirme önceliğini asla etkilemez; sıralamayı değil, görünürlüğü
+sağlar.
⭐ Depoya yıldız verin Ücretsizdir — görünürlüğe gerçekten yardımcı olur OmniRoute'a Yıldız Verin
@@ -753,7 +757,7 @@ Düzenleyicinin içinden: **Uzantılar (Extensions)** görünümünü açın, **
-
+
📖 [Yetkilendirme](docs/architecture/AUTHZ_GUIDE.md) · [Güvenlik Önlemleri](docs/security/GUARDRAILS.md) · [Uyumluluk](docs/security/COMPLIANCE.md)
diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md
index b383b5696f..f2fbdb3b14 100644
--- a/docs/reference/ENVIRONMENT.md
+++ b/docs/reference/ENVIRONMENT.md
@@ -1311,6 +1311,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `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. |
| `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`. |
| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. |
+| `OMNIROUTE_TLS_CLIENT_SEED_DIR` | _(unset)_ | `open-sse/services/tlsClientDownloadDir.ts` | Optional read-only directory containing the pinned `tls-client-node` native binary. An absent file or SHA-256 mismatch falls through to the bundled seeds and verified download; a symlink, non-regular file, or file above 64 MiB is treated as an unsafe entry and aborts resolution. |
| `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`. |
| `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). |
@@ -1601,6 +1602,8 @@ These settings were introduced after the previous environment-contract snapshot.
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |
| `OMNIROUTE_OPTIONAL_PACK_TAR` | `1` (enabled) | `scripts/build/optionalPackStaging.mjs` | Set `0` to skip emitting `.tar.gz` tarballs while staging optional ML/browser packs for the Electron standalone tree (pack directories and `optional-packs.index.json` are still produced). Used by the desktop release workflow to trim artifact upload size. |
+| `OMNIROUTE_ELECTRON_TARGET_PLATFORM` | host platform | `scripts/build/prepare-electron-standalone.mjs` | Build-only Electron packaging target (`win32`, `darwin`, or `linux`). Root `electron:build:*` scripts and the release workflow set it automatically; leave unset for a host-native local preparation. |
+| `OMNIROUTE_ELECTRON_TARGET_ARCHES` | `x64,arm64` on Linux; host arch otherwise | `scripts/build/prepare-electron-standalone.mjs` | Comma-separated Electron packaging architectures whose pinned TLS native seeds must be staged and verified. Root `electron:build:*` scripts and the release workflow set it automatically. |
### ChatGPT Web (Codex)
Globale Defaults für den headless Browser und den ausgehenden Tool-Tunnel. Im Dashboard gesetzte Connection-Werte haben Vorrang.
diff --git a/docs/security/ERROR_SANITIZATION.md b/docs/security/ERROR_SANITIZATION.md
index 898ca209d9..d8fa088d9d 100644
--- a/docs/security/ERROR_SANITIZATION.md
+++ b/docs/security/ERROR_SANITIZATION.md
@@ -1,16 +1,16 @@
---
title: "Error Message Sanitization"
-version: 3.8.40
-lastUpdated: 2026-06-28
+version: 3.8.50
+lastUpdated: 2026-09-01
---
# 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
-> **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.
+> **Source of truth:** `open-sse/utils/errorSanitization.ts`, which composes `errorPathRedaction.ts` and is re-exported by `open-sse/utils/error.ts`
+> **Tests:** `tests/unit/error-message-sanitization.test.ts` and `error-message-sanitization-credentials.test.ts`
+> **Last updated:** 2026-09-01 — v3.8.50
+> **Audience:** Any engineer touching error responses or error log sinks (HTTP routes, SSE streams, executors, MCP handlers).
+> **Status:** **MANDATORY** for every client-visible error and every untrusted or upstream-derived error value sent to a log sink.
## Why this exists
@@ -20,7 +20,8 @@ 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 implemented in `open-sse/utils/errorSanitization.ts` and
+re-exported by `open-sse/utils/error.ts` strips both 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 ``.
@@ -32,13 +33,14 @@ The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both class
Use `buildErrorBody()` — sanitization is built-in:
```ts
-import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
+import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
export async function POST(req: Request) {
try {
// ... handler logic ...
} catch (err) {
- return new Response(JSON.stringify(buildErrorBody(500, String(err))), {
+ const safeMessage = sanitizeErrorMessage(err) || "Internal server error";
+ return new Response(JSON.stringify(buildErrorBody(500, safeMessage)), {
status: 500,
headers: { "Content-Type": "application/json" },
});
@@ -59,7 +61,11 @@ 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 enforce the canonical sanitization/projection boundary. Helpers backed by
+`buildErrorBody` sanitize automatically; helpers with protocol-specific envelopes apply equivalent
+safe projections. Pass an already typed string directly. At a `catch (err)` boundary where the
+value is `unknown`, normalize it with `sanitizeErrorMessage(err)` as in the example above; never
+pre-coerce an unknown value with `String(err)` because a hostile coercion hook can throw.
### 2. Custom error envelopes (rare)
@@ -79,17 +85,49 @@ const body = JSON.stringify({
This is the only sanctioned way to assemble a custom error body. See `open-sse/executors/cursor.ts::buildErrorResponse` for the reference implementation.
+When a provider error must retain its upstream JSON shape, use
+`buildSanitizedUpstreamErrorResponse()` from `open-sse/utils/upstreamErrorResponse.ts`. It parses
+and recursively sanitizes valid JSON; mislabeled text or HTML becomes OmniRoute's canonical JSON
+error envelope, so the response bytes always match `Content-Type: application/json`.
+
### 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:
+Responses and logs are separate boundaries, but neither may receive untrusted upstream material
+verbatim:
+
+- **Responses:** every dynamic message or detail goes through `buildErrorBody`, one of its wrappers,
+ or `sanitizeErrorMessage`. A response never receives a raw exception message, stack, or upstream
+ body.
+- **Logs:** trusted local fields such as a provider identifier, numeric HTTP status, enumerated
+ classification, request ID, or validated domain identifier may be logged directly. Any error
+ message, error detail, or classification text derived from an upstream response, external
+ exception, request, plugin, or other untrusted source must go through `sanitizeErrorMessage`
+ (with a safe fallback) before reaching `pino`, `console`, or another sink. Request transcripts
+ and other non-error observability fields follow their own data-minimization policy; this error
+ sanitizer is not a universal transcript encoder. Do not pass a raw upstream `Error` object to the
+ logger: serializers may include its message and stack.
+- **Classification:** code may inspect raw material in memory to classify the failure. Log only the
+ trusted classification and sanitized projection; do not attach the raw input as structured
+ context.
+
+A locally generated `Error` may retain its stack in access-controlled internal observability when
+the application proves that neither its message nor stack contains upstream or otherwise untrusted
+data. This policy does not claim that every locally generated internal stack is removed.
+
+Pattern for an upstream-derived failure:
```ts
-try {
- // ...
-} catch (err) {
- log.error({ err }, "handler failed"); // full err with stack — internal log
- return errorResponse(500, getErrorMessage(err)); // sanitized — sent to client
-}
+const upstreamStatus = response.status;
+const rawUpstreamText = await response.text();
+const classification = upstreamStatus === 429 ? "rate_limited" : "upstream_error";
+const safeDetail =
+ sanitizeErrorMessage(rawUpstreamText.trim()) || `Provider returned HTTP ${upstreamStatus}`;
+
+log.warn(
+ { providerId, status: upstreamStatus, classification, detail: safeDetail },
+ "upstream request failed"
+);
+return errorResponse(upstreamStatus, safeDetail);
```
### 4. Forbidden patterns
@@ -112,11 +150,14 @@ 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** include `process.cwd()`, `__filename`, `__dirname`, or env-derived paths in error
+messages. Path detection is deliberately bounded defense in depth; callers must not rely on the
+redactor to make an avoidable disclosure safe.
## Coverage in CI
-`tests/unit/error-message-sanitization.test.ts` enforces:
+`tests/unit/error-message-sanitization.test.ts` and
+`tests/unit/error-message-sanitization-credentials.test.ts` enforce:
- Every route under `/api/model-combo-mappings/*` returns sanitized bodies on 4xx/5xx.
- `sanitizeErrorMessage` strips multi-line stack traces.
@@ -129,7 +170,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`) is defense in depth for known
+ structured fields. It does not make arbitrary upstream strings or raw `Error` objects safe to
+ log, and it does not replace the untrusted-to-log boundary documented above.
- 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,16 +181,20 @@ 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 }`) accepts a
+caller's explicit error type/code and projects it onto the public identifier policy. Runtime guards
+also reject non-string values received from untyped JavaScript or upstream parsing. Unsafe,
+non-string, or empty values fall back to the status-code table rather than being reflected
+verbatim — for example, HTTP 499 falls back to `client_disconnected` unless the supplied identifier
+is safe.
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.
+1. String leaves: run through `sanitizeErrorMessage` (strips stacks, absolute paths, labeled or
+ strongly identifiable credentials, and JWT-shaped secrets).
+2. Key blocklist: stack/path/file/directory fields, credential/key material, authorization/cookie
+ fields, and opaque credential or session identifiers are removed. Explicit aggregate fields
+ such as `session_count` and `session_status` remain eligible after normal sanitization.
3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`.
4. Arrays are capped at 32 elements.
@@ -155,6 +202,14 @@ Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pa
`upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content,
guardrail blocks) do not include `upstream_details`.
+Those call sites may also opt into `createErrorResult(..., { passthrough: true })`. Passthrough is
+limited to eligible upstream 4xx object bodies and excludes authentication-adjacent 401, 403, and
+407 responses. The selected body keeps its upstream JSON shape only after recursive sanitization;
+otherwise the normal OmniRoute envelope remains in place. The option replaces only the public
+`Response`: internal classification fields and retry logic continue using their original values.
+The tests prove this response-shape contract with synthetic payloads; they do not by themselves
+prove a client's end-to-end recovery behavior.
+
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.
diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts
index 438565b45c..99c99792dd 100644
--- a/open-sse/executors/chatgpt-web.ts
+++ b/open-sse/executors/chatgpt-web.ts
@@ -21,6 +21,7 @@ import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
import { createHash, randomUUID, randomBytes } from "node:crypto";
import { sha3_512Hex } from "../utils/sha3-512.ts";
+import { sanitizeErrorMessage } from "../utils/error.ts";
import {
tlsFetchChatGpt,
TlsClientUnavailableError,
@@ -403,7 +404,7 @@ async function runSessionWarmup(
} catch (err) {
log?.debug?.(
"CGPT-WEB",
- `warmup ${url} failed: ${err instanceof Error ? err.message : String(err)}`
+ `warmup ${sanitizeUpstreamLogDetail(url, "ChatGPT warmup endpoint")} failed: ${sanitizeUpstreamLogDetail(err)}`
);
}
}
@@ -678,9 +679,10 @@ async function solvePow(opts: PowOptions): Promise {
return `${opts.prefix}${b64}`;
}
}
+ const safeTarget = opts.target ? "" : "";
opts.log?.warn?.(
"CGPT-WEB",
- `PoW (${opts.label}) exhausted ${opts.maxIter} iterations against target=${opts.target || ""}; submitting unsolved token (Sentinel may reject)`
+ `PoW (${opts.label}) exhausted ${opts.maxIter} iterations against target=${safeTarget}; submitting unsolved token (Sentinel may reject)`
);
const b64 = Buffer.from(JSON.stringify(cfg)).toString("base64");
return `${opts.prefix}${b64}`;
@@ -1439,7 +1441,9 @@ async function fetchConversationDetail(
if (response.status >= 400) {
ctx.log?.warn?.(
"CGPT-WEB",
- `conversation poll ${response.status}: ${(response.text || "").slice(0, 300)}`
+ `conversation poll ${response.status}: ${sanitizeUpstreamLogDetail(
+ (response.text || "").slice(0, 300)
+ )}`
);
return { detail: null, terminal: [401, 403, 404].includes(response.status) };
}
@@ -1449,10 +1453,7 @@ async function fetchConversationDetail(
terminal: false,
};
} catch (err) {
- ctx.log?.warn?.(
- "CGPT-WEB",
- `conversation poll failed: ${err instanceof Error ? err.message : String(err)}`
- );
+ ctx.log?.warn?.("CGPT-WEB", `conversation poll failed: ${sanitizeUpstreamLogDetail(err)}`);
return { detail: null, terminal: false };
}
}
@@ -1485,19 +1486,20 @@ async function pollForFinalAssistantAnswer(
await delayWithAbort(Math.min(intervalMs, remaining), ctx.signal);
}
+ const safeConversationId = sanitizeOpaqueLogId(conversationId);
if (last) {
ctx.log?.warn?.(
"CGPT-WEB",
terminalPollFailure
- ? `conversation poll stopped before finished_successfully; returning latest assistant text for ${conversationId}`
- : `conversation poll timed out before finished_successfully; returning latest assistant text for ${conversationId}`
+ ? `conversation poll stopped before finished_successfully; returning latest assistant text for ${safeConversationId}`
+ : `conversation poll timed out before finished_successfully; returning latest assistant text for ${safeConversationId}`
);
} else {
ctx.log?.warn?.(
"CGPT-WEB",
terminalPollFailure
- ? `conversation poll stopped without assistant text for ${conversationId}`
- : `conversation poll timed out without assistant text for ${conversationId}`
+ ? `conversation poll stopped without assistant text for ${safeConversationId}`
+ : `conversation poll timed out without assistant text for ${safeConversationId}`
);
}
return last;
@@ -1558,9 +1560,10 @@ async function resolveImagePointers(
);
if (url) urls.push(url);
} catch (err) {
+ const safePointerScheme = safeAssetPointerScheme(ref.pointer);
log?.warn?.(
"CGPT-WEB",
- `Image resolve failed (${ref.pointer}): ${err instanceof Error ? err.message : String(err)}`
+ `Image resolve failed (${safePointerScheme}): ${sanitizeUpstreamLogDetail(err)}`
);
}
}
@@ -1712,7 +1715,7 @@ function buildStreamingResponse(
choices: [
{
index: 0,
- delta: { content: `[Error: ${chunk.error}]` },
+ delta: { content: `[Error: ${sanitizeUpstreamLogDetail(chunk.error)}]` },
finish_reason: null,
logprobs: null,
},
@@ -1803,10 +1806,7 @@ function buildStreamingResponse(
const polled = await pollAsyncImage(conversationId);
if (polled.length > 0) imagePointers = polled;
} catch (err) {
- log?.warn?.(
- "CGPT-WEB",
- `Async image poll failed: ${err instanceof Error ? err.message : String(err)}`
- );
+ log?.warn?.("CGPT-WEB", `Async image poll failed: ${sanitizeUpstreamLogDetail(err)}`);
} finally {
stopHb();
}
@@ -1902,7 +1902,7 @@ function buildStreamingResponse(
{
index: 0,
delta: {
- content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`,
+ content: `[Stream error: ${sanitizeCaughtError(err)}]`,
},
finish_reason: "stop",
logprobs: null,
@@ -1954,7 +1954,11 @@ async function buildNonStreamingResponse(
if (chunk.error) {
return new Response(
JSON.stringify({
- error: { message: chunk.error, type: "upstream_error", code: "CHATGPT_ERROR" },
+ error: {
+ message: sanitizeUpstreamLogDetail(chunk.error),
+ type: "upstream_error",
+ code: "CHATGPT_ERROR",
+ },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
@@ -2013,10 +2017,7 @@ async function buildNonStreamingResponse(
const polled = await pollAsyncImage(conversationId);
if (polled.length > 0) imagePointers = polled;
} catch (err) {
- log?.warn?.(
- "CGPT-WEB",
- `Async image poll failed: ${err instanceof Error ? err.message : String(err)}`
- );
+ log?.warn?.("CGPT-WEB", `Async image poll failed: ${sanitizeUpstreamLogDetail(err)}`);
}
}
@@ -2037,7 +2038,9 @@ async function buildNonStreamingResponse(
);
if (imageResolutionFailed && log?.warn) {
const schemes = (imagePointers ?? [])
- .map((p) => p.pointer.split("://")[0] || p.pointer.slice(0, 24))
+ .map((p) => {
+ return safeAssetPointerScheme(p.pointer);
+ })
.join(", ");
log.warn(
"CGPT-WEB",
@@ -2078,11 +2081,76 @@ async function buildNonStreamingResponse(
function errorResponse(status: number, message: string, code?: string): Response {
return new Response(
- JSON.stringify({ error: { message, type: "upstream_error", ...(code ? { code } : {}) } }),
+ JSON.stringify({
+ error: {
+ message: sanitizeErrorMessage(message),
+ type: "upstream_error",
+ ...(code ? { code } : {}),
+ },
+ }),
{ status, headers: { "Content-Type": "application/json" } }
);
}
+function sanitizeCaughtError(err: unknown): string {
+ return sanitizeUpstreamLogDetail(err);
+}
+
+const UPSTREAM_LOG_DETAIL_MAX_CHARS = 300;
+const UPSTREAM_LOG_URL_PATTERN = /(?:https?|wss?):\/\/[^\s"'<>]+/giu;
+
+function sanitizeUpstreamLogDetail(
+ value: unknown,
+ fallback = "upstream error unavailable"
+): string {
+ let raw = value;
+ try {
+ if (value instanceof Error) raw = value.message;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ }
+ const sanitized = sanitizeErrorMessage(raw).replace(UPSTREAM_LOG_URL_PATTERN, "").trim();
+ return sanitized ? sanitized.slice(0, UPSTREAM_LOG_DETAIL_MAX_CHARS) : fallback;
+}
+
+function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError {
+ try {
+ return error instanceof TlsClientUnavailableError;
+ } catch {
+ // Hostile prototype access must degrade to the generic connection error.
+ return false;
+ }
+}
+
+function isSessionAuthError(error: unknown): error is SessionAuthError {
+ try {
+ return error instanceof SessionAuthError;
+ } catch {
+ // Hostile prototype access must degrade to the generic session error.
+ return false;
+ }
+}
+
+function isSentinelBlockedError(error: unknown): error is SentinelBlockedError {
+ try {
+ return error instanceof SentinelBlockedError;
+ } catch {
+ // Hostile prototype access must degrade to the generic Sentinel error.
+ return false;
+ }
+}
+
+function sanitizeOpaqueLogId(value: string | null | undefined): string {
+ return value?.trim() ? "" : "unknown";
+}
+
+function safeAssetPointerScheme(pointer: string): string {
+ const separator = pointer.indexOf("://");
+ if (separator <= 0) return "unknown";
+ const scheme = pointer.slice(0, separator).toLowerCase();
+ return /^[a-z][a-z0-9+.-]{0,31}$/.test(scheme) ? scheme : "unknown";
+}
+
function normalizePublicBaseUrl(value?: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
@@ -2148,10 +2216,14 @@ function derivePublicBaseUrl(
const configuredBase =
normalizePublicBaseUrl(process.env.OMNIROUTE_BASE_URL) ||
normalizePublicBaseUrl(process.env.NEXT_PUBLIC_BASE_URL);
+ const safeConfiguredBase = configuredBase
+ ? sanitizeUpstreamLogDetail(configuredBase, "unavailable")
+ : "-";
+ const safeHeaderBase = headerBase ? sanitizeUpstreamLogDetail(headerBase, "unavailable") : "-";
log?.debug?.(
"CGPT-WEB",
- `derivePublicBaseUrl: configured=${configuredBase ?? "-"} header=${headerBase ?? "-"}`
+ `derivePublicBaseUrl: configured=${safeConfiguredBase} header=${safeHeaderBase}`
);
if (configuredBase && (!headerBase || !isLocalBaseUrl(configuredBase))) return configuredBase;
@@ -2210,10 +2282,7 @@ async function fetchDownloadUrl(endpoint: string, ctx: ResolverContext): Promise
signal: ctx.signal,
});
if (response.status !== 200) {
- ctx.log?.warn?.(
- "CGPT-WEB",
- `Image download URL fetch failed (${response.status}) for ${endpoint}`
- );
+ ctx.log?.warn?.("CGPT-WEB", `Image download URL fetch failed (${response.status})`);
return null;
}
let parsed: { download_url?: string } = {};
@@ -2263,17 +2332,16 @@ async function imageUrlToCachedImageUrl(
byteResponse: true,
});
} catch (err) {
- ctx.log?.warn?.(
- "CGPT-WEB",
- `Image fetch failed: ${err instanceof Error ? err.message : String(err)}`
- );
+ ctx.log?.warn?.("CGPT-WEB", `Image fetch failed: ${sanitizeUpstreamLogDetail(err)}`);
return null;
}
if (response.status !== 200) {
ctx.log?.warn?.(
"CGPT-WEB",
- `Image fetch returned HTTP ${response.status} (${(response.text || "").slice(0, 120)})`
+ `Image fetch returned HTTP ${response.status} (${sanitizeUpstreamLogDetail(
+ (response.text || "").slice(0, 120)
+ )})`
);
return null;
}
@@ -2370,9 +2438,10 @@ async function registerWebSocket(ctx: ResolverContext): Promise {
signal: ctx.signal,
});
} catch (err) {
+ const safeUrl = sanitizeUpstreamLogDetail(url, "ChatGPT WebSocket endpoint");
ctx.log?.warn?.(
"CGPT-WEB",
- `register-websocket fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`
+ `register-websocket fetch failed for ${safeUrl}: ${sanitizeUpstreamLogDetail(err)}`
);
continue;
}
@@ -2384,7 +2453,10 @@ async function registerWebSocket(ctx: ResolverContext): Promise {
};
const ws = data.websocket_url ?? data.wss_url;
if (ws) {
- ctx.log?.debug?.("CGPT-WEB", `Got WebSocket URL via ${url}`);
+ ctx.log?.debug?.(
+ "CGPT-WEB",
+ `Got WebSocket URL via ${sanitizeUpstreamLogDetail(url, "ChatGPT WebSocket endpoint")}`
+ );
return ws;
}
} catch {
@@ -2394,7 +2466,10 @@ async function registerWebSocket(ctx: ResolverContext): Promise {
}
ctx.log?.warn?.(
"CGPT-WEB",
- `register-websocket via ${url} → ${r.status}: ${(r.text || "").slice(0, 200)}`
+ `register-websocket via ${sanitizeUpstreamLogDetail(
+ url,
+ "ChatGPT WebSocket endpoint"
+ )} → ${r.status}: ${sanitizeUpstreamLogDetail((r.text || "").slice(0, 200))}`
);
}
return null;
@@ -2453,7 +2528,13 @@ async function waitForImageViaWebSocket(
};
ws.onerror = (e) => {
errored = true;
- ctx.log?.warn?.("CGPT-WEB", `WebSocket error: ${(e as ErrorEvent).message ?? "unknown"}`);
+ ctx.log?.warn?.(
+ "CGPT-WEB",
+ `WebSocket error: ${sanitizeUpstreamLogDetail(
+ (e as ErrorEvent).message,
+ "upstream error unavailable"
+ )}`
+ );
};
ws.onclose = () => {
clearTimeout(timer);
@@ -2653,7 +2734,10 @@ function makeImageResolver(ctx: ResolverContext): ImageResolver {
} else if (assetPointer.startsWith(SEDIMENT_PREFIX)) {
fileId = assetPointer.slice(SEDIMENT_PREFIX.length);
} else {
- ctx.log?.warn?.("CGPT-WEB", `Unknown asset_pointer scheme: ${assetPointer}`);
+ ctx.log?.warn?.(
+ "CGPT-WEB",
+ `Unknown asset_pointer scheme: ${safeAssetPointerScheme(assetPointer)}`
+ );
}
let signedUrl: string | null = null;
@@ -2703,7 +2787,10 @@ function makeImageResolver(ctx: ResolverContext): ImageResolver {
const preview = finalUrl.startsWith("data:")
? `data:... (${finalUrl.length} chars)`
: finalUrl.slice(0, 80) + "...";
- ctx.log?.debug?.("CGPT-WEB", `Resolved ${assetPointer} → ${preview}`);
+ ctx.log?.debug?.(
+ "CGPT-WEB",
+ `Resolved ${safeAssetPointerScheme(assetPointer)} asset → ${sanitizeUpstreamLogDetail(preview, "local image URL unavailable")}`
+ );
}
return finalUrl;
};
@@ -2768,8 +2855,8 @@ export class ChatGptWebExecutor extends BaseExecutor {
try {
tokenEntry = await exchangeSession(cookie, signal);
} catch (err) {
- if (err instanceof SessionAuthError) {
- log?.warn?.("CGPT-WEB", err.message);
+ if (isSessionAuthError(err)) {
+ log?.warn?.("CGPT-WEB", sanitizeCaughtError(err));
return {
response: errorResponse(
401,
@@ -2781,15 +2868,10 @@ export class ChatGptWebExecutor extends BaseExecutor {
transformedBody: body,
};
}
- log?.error?.(
- "CGPT-WEB",
- `Session exchange failed: ${err instanceof Error ? err.message : String(err)}`
- );
+ const safeErrorMessage = sanitizeCaughtError(err);
+ log?.error?.("CGPT-WEB", `Session exchange failed: ${safeErrorMessage}`);
return {
- response: errorResponse(
- 502,
- `ChatGPT session exchange failed: ${err instanceof Error ? err.message : String(err)}`
- ),
+ response: errorResponse(502, `ChatGPT session exchange failed: ${safeErrorMessage}`),
url: SESSION_URL,
headers: {},
transformedBody: body,
@@ -2802,10 +2884,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
try {
await onCredentialsRefreshed?.(updated);
} catch (err) {
- log?.warn?.(
- "CGPT-WEB",
- `Failed to persist refreshed cookie: ${err instanceof Error ? err.message : String(err)}`
- );
+ log?.warn?.("CGPT-WEB", `Failed to persist refreshed cookie: ${sanitizeCaughtError(err)}`);
}
}
@@ -2816,7 +2895,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
} catch (err) {
log?.warn?.(
"CGPT-WEB",
- `DPL warmup failed (continuing with fallback): ${err instanceof Error ? err.message : String(err)}`
+ `DPL warmup failed (continuing with fallback): ${sanitizeCaughtError(err)}`
);
dplInfo = {
dpl: `dpl=${OAI_CLIENT_VERSION.replace(/^prod-/, "")}`,
@@ -2855,8 +2934,8 @@ export class ChatGptWebExecutor extends BaseExecutor {
log
);
} catch (err) {
- if (err instanceof SentinelBlockedError) {
- log?.warn?.("CGPT-WEB", err.message);
+ if (isSentinelBlockedError(err)) {
+ log?.warn?.("CGPT-WEB", sanitizeCaughtError(err));
return {
response: errorResponse(
403,
@@ -2868,15 +2947,10 @@ export class ChatGptWebExecutor extends BaseExecutor {
transformedBody: body,
};
}
- log?.error?.(
- "CGPT-WEB",
- `Sentinel failed: ${err instanceof Error ? err.message : String(err)}`
- );
+ const safeErrorMessage = sanitizeCaughtError(err);
+ log?.error?.("CGPT-WEB", `Sentinel failed: ${safeErrorMessage}`);
return {
- response: errorResponse(
- 502,
- `ChatGPT sentinel failed: ${err instanceof Error ? err.message : String(err)}`
- ),
+ response: errorResponse(502, `ChatGPT sentinel failed: ${safeErrorMessage}`),
url: SENTINEL_PREPARE_URL,
headers: {},
transformedBody: body,
@@ -2980,14 +3054,11 @@ export class ChatGptWebExecutor extends BaseExecutor {
stream,
});
} catch (err) {
- log?.error?.("CGPT-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`);
- const code = err instanceof TlsClientUnavailableError ? "TLS_UNAVAILABLE" : undefined;
+ const safeErrorMessage = sanitizeCaughtError(err);
+ log?.error?.("CGPT-WEB", `Fetch failed: ${safeErrorMessage}`);
+ const code = isTlsClientUnavailableError(err) ? "TLS_UNAVAILABLE" : undefined;
return {
- response: errorResponse(
- 502,
- `ChatGPT connection failed: ${err instanceof Error ? err.message : String(err)}`,
- code
- ),
+ response: errorResponse(502, `ChatGPT connection failed: ${safeErrorMessage}`, code),
url: CONV_URL,
headers,
transformedBody: cgptBody,
@@ -2999,7 +3070,8 @@ export class ChatGptWebExecutor extends BaseExecutor {
// Log the upstream body on 4xx/5xx — error responses are small and the
// upstream message is much more useful than our wrapper. Goes through
// the executor logger so it respects the application's log config.
- log?.warn?.("CGPT-WEB", `conv ${status}: ${(response.text || "").slice(0, 400)}`);
+ const safeUpstreamSnippet = sanitizeUpstreamLogDetail((response.text || "").slice(0, 400));
+ log?.warn?.("CGPT-WEB", `conv ${status}: ${safeUpstreamSnippet}`);
const errMsg = describeChatGptWebHttpError(status);
if (status === 401 || status === 403) {
tokenCache.delete(cookieKey(cookie));
diff --git a/open-sse/executors/chatgpt-web/handoff.ts b/open-sse/executors/chatgpt-web/handoff.ts
index c0f66b5092..334fb1c7a6 100644
--- a/open-sse/executors/chatgpt-web/handoff.ts
+++ b/open-sse/executors/chatgpt-web/handoff.ts
@@ -1,7 +1,27 @@
import { tlsFetchChatGpt } from "../../services/chatgptTlsClient.ts";
+import { sanitizeErrorMessage } from "../../utils/error.ts";
const CONVERSATION_RESUME_URL = "https://chatgpt.com/backend-api/f/conversation/resume";
const RESUME_OFFSETS = [0, 1, 2] as const;
+const UPSTREAM_LOG_DETAIL_MAX_CHARS = 300;
+const UPSTREAM_LOG_URL_PATTERN = /(?:https?|wss?):\/\/[^\s"'<>]+/giu;
+
+function sanitizeUpstreamLogDetail(value: unknown): string {
+ let raw = value;
+ try {
+ if (value instanceof Error) raw = value.message;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ }
+ const sanitized = sanitizeErrorMessage(raw).replace(UPSTREAM_LOG_URL_PATTERN, "").trim();
+ return sanitized
+ ? sanitized.slice(0, UPSTREAM_LOG_DETAIL_MAX_CHARS)
+ : "upstream error unavailable";
+}
+
+function sanitizeOpaqueLogId(value: string | null | undefined): string {
+ return value?.trim() ? "" : "unknown";
+}
export interface FinalAssistantAnswer {
text: string;
@@ -98,7 +118,9 @@ async function attemptResumeOffset({
if (response.status >= 400) {
log?.warn?.(
"CGPT-WEB",
- `conversation resume ${response.status}: ${(response.text || "").slice(0, 300)}`
+ `conversation resume ${response.status}: ${sanitizeUpstreamLogDetail(
+ (response.text || "").slice(0, 300)
+ )}`
);
return { answer: null, shouldRetry: false };
}
@@ -109,10 +131,7 @@ async function attemptResumeOffset({
const answer = await readFinalAssistantAnswer(eventStream, signal, readContent);
return { answer, shouldRetry: !answer };
} catch (error) {
- log?.warn?.(
- "CGPT-WEB",
- `conversation resume failed: ${error instanceof Error ? error.message : String(error)}`
- );
+ log?.warn?.("CGPT-WEB", `conversation resume failed: ${sanitizeUpstreamLogDetail(error)}`);
return { answer: null, shouldRetry: false };
}
}
@@ -149,6 +168,9 @@ export async function resumeChatGptHandoff({
if (!attempt.shouldRetry) return null;
}
- log?.warn?.("CGPT-WEB", `conversation resume returned no assistant text for ${conversationId}`);
+ log?.warn?.(
+ "CGPT-WEB",
+ `conversation resume returned no assistant text for ${sanitizeOpaqueLogId(conversationId)}`
+ );
return null;
}
diff --git a/open-sse/executors/chatgptWebTools.ts b/open-sse/executors/chatgptWebTools.ts
index 55a1c5be91..ba4848994e 100644
--- a/open-sse/executors/chatgptWebTools.ts
+++ b/open-sse/executors/chatgptWebTools.ts
@@ -113,6 +113,7 @@ export async function buildToolModeResponse(
stream: boolean,
meta: { cid: string; created: number; model: string; idSeed?: string }
): Promise {
+ if (!bufferedJson.ok) return bufferedJson;
const jsonResponse = await applyToolCallsToJsonResponse(
bufferedJson,
requestedTools,
diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts
index 5034b0da6c..12d7a46d5e 100644
--- a/open-sse/executors/claude-web.ts
+++ b/open-sse/executors/claude-web.ts
@@ -39,6 +39,9 @@ const CLAUDE_WEB_API_BASE = "https://claude.ai/api";
const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`;
const CLAUDE_SESSION_COOKIE_NAME = "sessionKey";
const MAX_ERROR_BODY_BYTES = 64 * 1024;
+const MAX_FORWARDED_RETRY_AFTER_SECONDS = 24 * 60 * 60;
+const HTTP_DATE_PATTERN =
+ /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT.userAgent;
type SendClaudeWebTransport = (
@@ -290,7 +293,9 @@ async function readTransportErrorText(result: ClaudeWebTransportResult): Promise
output += decoder.decode();
return output;
} finally {
- await reader.cancel().catch(() => {});
+ await reader.cancel().catch(() => {
+ // The bounded error body is already captured; cancel can race an upstream close.
+ });
try {
reader.releaseLock();
} catch {
@@ -299,6 +304,26 @@ async function readTransportErrorText(result: ClaudeWebTransportResult): Promise
}
}
+function normalizeForwardedRetryAfter(value: string | null): string | null {
+ const trimmed = value?.trim();
+ if (!trimmed) return null;
+
+ if (/^\d{1,9}$/.test(trimmed)) {
+ const seconds = Number(trimmed);
+ return Number.isSafeInteger(seconds) && seconds <= MAX_FORWARDED_RETRY_AFTER_SECONDS
+ ? String(seconds)
+ : null;
+ }
+
+ if (!HTTP_DATE_PATTERN.test(trimmed)) return null;
+ const timestamp = Date.parse(trimmed);
+ if (!Number.isFinite(timestamp)) return null;
+ const normalized = new Date(timestamp).toUTCString();
+ if (normalized !== trimmed) return null;
+ if (Math.abs(timestamp - Date.now()) > MAX_FORWARDED_RETRY_AFTER_SECONDS * 1000) return null;
+ return normalized;
+}
+
async function errorResponseForTransport(
result: ClaudeWebTransportResult,
turn: PreparedClaudeWebTurn
@@ -310,7 +335,7 @@ async function errorResponseForTransport(
}
if (result.status === 429) {
const extraHeaders: Record = {};
- const upstreamRetryAfter = result.headers.get("retry-after");
+ const upstreamRetryAfter = normalizeForwardedRetryAfter(result.headers.get("retry-after"));
if (upstreamRetryAfter) {
extraHeaders["Retry-After"] = upstreamRetryAfter;
}
@@ -348,6 +373,7 @@ export class ClaudeWebExecutor extends BaseExecutor {
const cookieHeader = normalizeClaudeSessionCookie(rawCookie);
return verifyCookieValidity(cookieHeader, readClaudeWebDeviceId(credentials), signal);
} catch {
+ // Connection checks deliberately collapse malformed credentials and transport failures to false.
return false;
}
}
diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts
index a99bc2590a..6caef0afb0 100644
--- a/open-sse/executors/grok-web.ts
+++ b/open-sse/executors/grok-web.ts
@@ -54,6 +54,23 @@ import {
const GROK_CHAT_API = "https://grok.com/rest/app-chat/conversations/new";
const GROK_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+const GROK_PUBLIC_UPSTREAM_ERROR = "Grok upstream error";
+
+function sanitizeGrokUpstreamError(message: unknown): string {
+ const sanitized = sanitizeErrorMessage(message);
+ return sanitized.trim() && !/^(?:[A-Za-z_$][\w$]*)?Error:\s*$/.test(sanitized)
+ ? sanitized
+ : GROK_PUBLIC_UPSTREAM_ERROR;
+}
+
+function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError {
+ try {
+ return error instanceof TlsClientUnavailableError;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ return false;
+ }
+}
// ─── Model mappings ─────────────────────────────────────────────────────────
// Grok Web exposes UI modes, not stable public model IDs. Keep OmniRoute model
@@ -383,6 +400,7 @@ function buildStreamingResponse(
if (chunk.fingerprint) fp = chunk.fingerprint;
if (chunk.error) {
+ const publicError = sanitizeGrokUpstreamError(chunk.error);
controller.enqueue(
encoder.encode(
sseChunk({
@@ -394,7 +412,7 @@ function buildStreamingResponse(
choices: [
{
index: 0,
- delta: { content: `[Error: ${chunk.error}]` },
+ delta: { content: `[Error: ${publicError}]` },
finish_reason: null,
logprobs: null,
},
@@ -518,9 +536,7 @@ function buildStreamingResponse(
{
index: 0,
delta: {
- content: sanitizeErrorMessage(
- `[Stream error: ${err instanceof Error ? err.message : String(err)}]`
- ),
+ content: `[Stream error: ${sanitizeGrokUpstreamError(err)}]`,
},
finish_reason: "stop",
logprobs: null,
@@ -560,7 +576,11 @@ async function buildNonStreamingResponse(
if (chunk.error) {
return new Response(
JSON.stringify({
- error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" },
+ error: {
+ message: sanitizeGrokUpstreamError(chunk.error),
+ type: "upstream_error",
+ code: "GROK_ERROR",
+ },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
@@ -953,12 +973,13 @@ export class GrokWebExecutor extends BaseExecutor {
streamEofSymbol: "[DONE]",
});
} catch (err) {
- if (err instanceof TlsClientUnavailableError) {
- log?.error?.("GROK-WEB", `TLS client unavailable: ${err.message}`);
+ const publicError = sanitizeGrokUpstreamError(err);
+ if (isTlsClientUnavailableError(err)) {
+ log?.error?.("GROK-WEB", `TLS client unavailable: ${publicError}`);
const errResp = new Response(
JSON.stringify({
error: {
- message: sanitizeErrorMessage(`Grok TLS client unavailable: ${err.message}`),
+ message: `Grok TLS client unavailable: ${publicError}`,
type: "upstream_error",
code: "TLS_CLIENT_UNAVAILABLE",
},
@@ -967,13 +988,11 @@ export class GrokWebExecutor extends BaseExecutor {
);
return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload };
}
- log?.error?.("GROK-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`);
+ log?.error?.("GROK-WEB", `Fetch failed: ${publicError}`);
const errResp = new Response(
JSON.stringify({
error: {
- message: sanitizeErrorMessage(
- `Grok connection failed: ${err instanceof Error ? err.message : String(err)}`
- ),
+ message: `Grok connection failed: ${publicError}`,
type: "upstream_error",
},
}),
diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts
index 42bcd9cf7e..77772b4907 100644
--- a/open-sse/executors/lmarena.ts
+++ b/open-sse/executors/lmarena.ts
@@ -11,6 +11,7 @@ import { v7 as uuidv7 } from "uuid";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { tlsFetchLMArena, TlsClientUnavailableError } from "../services/lmarenaTlsClient.ts";
import { readLMArenaCookie, reconstructLMArenaCookie } from "./lmarena/cookie.ts";
+import { sanitizeLMArenaError } from "./lmarena/error.ts";
import {
LMARENA_STREAM_URL,
LMARENA_USER_AGENT,
@@ -50,6 +51,15 @@ interface OpenAIMessage {
content?: unknown;
}
+function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError {
+ try {
+ return error instanceof TlsClientUnavailableError;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ return false;
+ }
+}
+
/** Optional browser-issued reCAPTCHA v3 token (operator-supplied). */
function readRecaptchaToken(credentials: unknown, body: unknown): string | null {
const fromObj = (v: unknown): string | null => {
@@ -146,13 +156,13 @@ export class LMArenaExecutor extends BaseExecutor {
log,
});
} catch (error) {
- if (error instanceof TlsClientUnavailableError) {
- log?.error?.("LMArenaExecutor", `TLS client unavailable: ${error.message}`);
+ if (isTlsClientUnavailableError(error)) {
+ log?.error?.("LMArenaExecutor", `TLS client unavailable: ${sanitizeLMArenaError(error)}`);
return mapTlsUnavailable(error, url, headers, transformedBody);
}
- const message = error instanceof Error ? error.message : String(error);
- log?.error?.("LMArenaExecutor", `Request failed: ${message}`);
- return mapNetworkError(message, url, headers, transformedBody);
+ const publicMessage = sanitizeLMArenaError(error);
+ log?.error?.("LMArenaExecutor", `Request failed: ${publicMessage}`);
+ return mapNetworkError(error, url, headers, transformedBody);
}
}
diff --git a/open-sse/executors/lmarena/error.ts b/open-sse/executors/lmarena/error.ts
new file mode 100644
index 0000000000..be0e404699
--- /dev/null
+++ b/open-sse/executors/lmarena/error.ts
@@ -0,0 +1,17 @@
+import { sanitizeErrorMessage } from "../../utils/error.ts";
+
+const ERROR_NAME_ONLY_RE = /^[A-Za-z]*Error:?$/;
+
+/** Convert an unknown Arena failure into a stable, public-safe message. */
+export function sanitizeLMArenaError(value: unknown, fallback = "Arena upstream error"): string {
+ let candidate = value;
+ try {
+ if (value instanceof Error) candidate = value.message;
+ } catch {
+ // Hostile thrown values can expose coercing prototype/message accessors.
+ }
+
+ const sanitized = sanitizeErrorMessage(candidate).trim();
+ if (!sanitized || ERROR_NAME_ONLY_RE.test(sanitized)) return fallback;
+ return sanitized;
+}
diff --git a/open-sse/executors/lmarena/models.ts b/open-sse/executors/lmarena/models.ts
index fbfe277809..8883ba09f4 100644
--- a/open-sse/executors/lmarena/models.ts
+++ b/open-sse/executors/lmarena/models.ts
@@ -2,6 +2,8 @@
* LMArena live model list parsing, catalog normalization, and name→UUID resolution.
*/
+import { sanitizeLMArenaError } from "./error.ts";
+
export const LMARENA_API_BASE = "https://arena.ai";
export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`;
/**
@@ -297,10 +299,9 @@ export async function resolveLMArenaModelId(model: string, log?: LogFn): Promise
if (fromSeed) return fromSeed;
return pickLMArenaModelId(requested, await getLMArenaModels(log));
} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
log?.warn?.(
"LMArenaExecutor",
- `Using raw model id after static catalog lookup failed: ${message}`
+ `Using raw model id after static catalog lookup failed: ${sanitizeLMArenaError(error, "Arena catalog lookup error")}`
);
return requested;
}
diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts
index acc86f915a..f99218fd47 100644
--- a/open-sse/executors/lmarena/response.ts
+++ b/open-sse/executors/lmarena/response.ts
@@ -2,12 +2,38 @@
* Response mapping helpers for the Arena (lmarena) executor — kept small so
* the executor methods stay under complexity / max-lines gates.
*/
-import { sanitizeErrorMessage } from "../../utils/error.ts";
import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts";
+import { sanitizeLMArenaError } from "./error.ts";
import { markLMArenaCatalogModelDead } from "./models.ts";
import { parseArenaSSE } from "./stream.ts";
const encoder = new TextEncoder();
+const SAFE_ARENA_STREAM_ERROR_NAMES = new Set([
+ "AbortError",
+ "ResponseAborted",
+ "TimeoutError",
+ "BodyTimeoutError",
+]);
+
+function projectArenaStreamError(error: unknown, publicMessage: string): Error {
+ const projected = new Error(publicMessage) as Error & { statusCode?: number };
+ projected.stack = undefined;
+ if (!error || typeof error !== "object") return projected;
+
+ try {
+ const name = (error as { name?: unknown }).name;
+ if (typeof name === "string" && SAFE_ARENA_STREAM_ERROR_NAMES.has(name)) {
+ projected.name = name;
+ }
+ const statusCode = Number((error as { statusCode?: unknown }).statusCode);
+ if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
+ projected.statusCode = statusCode;
+ }
+ } catch {
+ // Hostile thrown values must not escape through coercing metadata accessors.
+ }
+ return projected;
+}
export function errorResponse(
status: number,
@@ -15,9 +41,10 @@ export function errorResponse(
type: string,
code: string
): Response {
+ const publicMessage = sanitizeLMArenaError(message);
return new Response(
JSON.stringify({
- error: { message: sanitizeErrorMessage(message), type, code },
+ error: { message: publicMessage, type, code },
}),
{ status, headers: { "Content-Type": "application/json" } }
);
@@ -114,7 +141,7 @@ export function mapTlsUnavailable(
return {
response: errorResponse(
502,
- `Arena TLS impersonation unavailable: ${error.message}. Install/repair tls-client-node native binary.`,
+ `Arena TLS impersonation unavailable: ${sanitizeLMArenaError(error)}. Install/repair tls-client-node native binary.`,
"upstream_error",
"TLS_CLIENT_UNAVAILABLE"
),
@@ -125,13 +152,13 @@ export function mapTlsUnavailable(
}
export function mapNetworkError(
- message: string,
+ message: unknown,
url: string,
headers: Record,
transformedBody: unknown
) {
return {
- response: errorResponse(502, message, "network_error", "request_failed"),
+ response: errorResponse(502, sanitizeLMArenaError(message), "network_error", "request_failed"),
url,
headers,
transformedBody,
@@ -199,7 +226,7 @@ function handleArenaEventLine(
enqueueSse(controller, {
...baseChunk(model),
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
- error: { message: sanitizeErrorMessage(event.content || "Unknown error") },
+ error: { message: sanitizeLMArenaError(event.content) },
});
controller.close();
return true;
@@ -220,9 +247,28 @@ export function createOpenAIArenaStream(opts: {
const { reader, model, signal, log } = opts;
const decoder = new TextDecoder();
let buffer = "";
+ let readerCleanup: Promise | null = null;
+
+ const cleanupReader = (): Promise => {
+ if (readerCleanup) return readerCleanup;
+ readerCleanup = (async () => {
+ try {
+ await reader.cancel();
+ } catch {
+ // The upstream may already be closed or errored; still release its lock below.
+ }
+ try {
+ reader.releaseLock();
+ } catch {
+ // A concurrent cleanup may already have released this reader.
+ }
+ })();
+ return readerCleanup;
+ };
const onAbort = () => {
- void reader.cancel().catch(() => undefined);
+ // The upstream reader may already be closed; cleanup failure must not replace the abort outcome.
+ void cleanupReader();
};
if (signal) {
if (signal.aborted) onAbort();
@@ -234,7 +280,8 @@ export function createOpenAIArenaStream(opts: {
try {
while (true) {
if (signal?.aborted) {
- await reader.cancel().catch(() => undefined);
+ // Cancellation is best-effort cleanup; the already-observed abort remains authoritative.
+ await cleanupReader();
controller.close();
return;
}
@@ -252,15 +299,17 @@ export function createOpenAIArenaStream(opts: {
}
emitStopAndDone(controller, model);
} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- log?.error?.("LMArenaExecutor", `Streaming error: ${message}`);
- controller.error(error);
+ const publicMessage = sanitizeLMArenaError(error, "Arena upstream stream error");
+ log?.error?.("LMArenaExecutor", `Streaming error: ${publicMessage}`);
+ controller.error(projectArenaStreamError(error, publicMessage));
} finally {
+ await cleanupReader();
if (signal) signal.removeEventListener("abort", onAbort);
}
},
- cancel() {
- void reader.cancel().catch(() => undefined);
+ async cancel() {
+ // The consumer may cancel after the upstream reader closed; cleanup must not mask that outcome.
+ await cleanupReader();
if (signal) signal.removeEventListener("abort", onAbort);
},
});
diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts
index b53bd69669..c38e822cb6 100644
--- a/open-sse/executors/notion-web.ts
+++ b/open-sse/executors/notion-web.ts
@@ -29,7 +29,10 @@
*/
import { randomUUID } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
-import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
+import {
+ makeExecutorErrorResult as makeErrorResult,
+ sanitizeErrorMessage,
+} from "../utils/error.ts";
import {
BROWSER_HEADERS,
extractNotionUserIdFromCookie,
@@ -57,13 +60,9 @@ import {
} from "../services/notionStreamParser.ts";
import {
buildNotionTranscript,
- messagesForNotionTranscript,
type NotionAgentOptions,
} from "../services/notionTranscriptBuilder.ts";
-import {
- tlsFetchNotion,
- TlsClientUnavailableError,
-} from "../services/notionTlsClient.ts";
+import { tlsFetchNotion, TlsClientUnavailableError } from "../services/notionTlsClient.ts";
// Re-exported for unit tests that destructure `mod.` on this module.
export {
@@ -225,7 +224,6 @@ function extractUserIdFromCookie(cookie: string): string {
return extractNotionUserIdFromCookie(cookie);
}
-
/**
* Notion's undocumented inference API does not return token usage.
* Emit a cheap char-based estimate so clients don't see a constant
@@ -236,9 +234,7 @@ export function estimateNotionUsage(
messages: NotionMessage[] | undefined,
content: string
): { prompt_tokens: number; completion_tokens: number; total_tokens: number; estimated: true } {
- const promptText = (messages || [])
- .map((m) => extractNotionMessageText(m?.content))
- .join("\n");
+ const promptText = (messages || []).map((m) => extractNotionMessageText(m?.content)).join("\n");
// ~4 chars/token (English-ish); at least 1 when there is any text.
const prompt_tokens = promptText ? Math.max(1, Math.ceil(promptText.length / 4)) : 0;
const completion_tokens = content ? Math.max(1, Math.ceil(content.length / 4)) : 0;
@@ -317,6 +313,25 @@ function clientFacingModelId(model: unknown): string {
return clientFacingModel;
}
+function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError {
+ try {
+ return error instanceof TlsClientUnavailableError;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ return false;
+ }
+}
+
+function sanitizeNotionTransportError(error: unknown): string {
+ let candidate = error;
+ try {
+ if (error instanceof Error) candidate = error.message;
+ } catch {
+ // Keep the unknown value for the canonical fail-closed sanitizer.
+ }
+ return sanitizeErrorMessage(candidate).trim() || "unknown error";
+}
+
/** Resolves workspace + user (cached). Required for createThread payloads. */
async function resolveExecuteWorkspace(
cookie: string,
@@ -393,9 +408,8 @@ function buildNotionExecuteHeaders(opts: {
const isCustom = Boolean(opts.agent?.workflowId);
// Browser uses /agent/?wfv=chat for custom agents.
const agentPathId = (opts.agent?.workflowId || "").replace(/-/g, "");
- const referer = isCustom && agentPathId
- ? `${BASE_URL}/agent/${agentPathId}?wfv=chat`
- : `${BASE_URL}/ai`;
+ const referer =
+ isCustom && agentPathId ? `${BASE_URL}/agent/${agentPathId}?wfv=chat` : `${BASE_URL}/ai`;
const reqHeaders: Record = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
@@ -453,11 +467,8 @@ export function resolveNotionAgentOptions(
"agent_id",
]) || "";
const pageFromPs =
- readProviderSpecificString(ps, [
- "contextPageId",
- "context_page_id",
- "notionContextPageId",
- ]) || "";
+ readProviderSpecificString(ps, ["contextPageId", "context_page_id", "notionContextPageId"]) ||
+ "";
const readCookie = (name: string): string => {
const m = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`, "i"));
@@ -477,10 +488,7 @@ export function resolveNotionAgentOptions(
readCookie("agent_id")
);
const contextPageId =
- pageFromPs ||
- readCookie("context_page_id") ||
- readCookie("notion_context_page_id") ||
- "";
+ pageFromPs || readCookie("context_page_id") || readCookie("notion_context_page_id") || "";
return {
workflowId: workflowId || undefined,
@@ -510,13 +518,12 @@ async function sendNotionInferenceRequest(opts: {
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
// Inference can take a while (tool-autoload + LLM first token).
- timeoutMs:
- Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
+ timeoutMs: Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
});
status = tlsRes.status;
rawText = tlsRes.text ?? "";
} catch (err) {
- if (err instanceof TlsClientUnavailableError) {
+ if (isTlsClientUnavailableError(err)) {
// Fall back to plain fetch only when the native TLS sidecar is missing —
// better a degraded path than a hard crash on platforms without the binary.
try {
@@ -532,7 +539,7 @@ async function sendNotionInferenceRequest(opts: {
return {
errorResult: makeErrorResult(
502,
- `Notion fetch failed: ${fallbackErr instanceof Error ? fallbackErr.message : "unknown error"}`,
+ `Notion fetch failed: ${sanitizeNotionTransportError(fallbackErr)}`,
reqBody,
NOTION_URL
),
@@ -542,7 +549,7 @@ async function sendNotionInferenceRequest(opts: {
return {
errorResult: makeErrorResult(
502,
- `Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
+ `Notion fetch failed: ${sanitizeNotionTransportError(err)}`,
reqBody,
NOTION_URL
),
@@ -634,8 +641,7 @@ export class NotionWebExecutor extends BaseExecutor {
const inboundHeaders =
(input.clientHeaders as Record | null | undefined) ??
((input as { headers?: Record }).headers as
- | Record
- | undefined);
+ Record | undefined);
const clientThreadId = readClientThreadId(requestBody, inboundHeaders ?? undefined);
// Namespace the thread cache PER CALLER (hash of the caller's cookie) AND by custom
// agent, so (a) two users of the same Notion space never share a cached thread
@@ -738,7 +744,10 @@ export class NotionWebExecutor extends BaseExecutor {
// One automatic retry for transient Notion faults — same threadId, never create again
if (isFailedAttempt(attempt) && attempt.retryable) {
- const delayMs = process.env.NODE_ENV === "test" || process.env.VITEST ? 20 : 700 + Math.floor(Math.random() * 400);
+ const delayMs =
+ process.env.NODE_ENV === "test" || process.env.VITEST
+ ? 20
+ : 700 + Math.floor(Math.random() * 400);
await new Promise((r) => setTimeout(r, delayMs));
attempt = await runOnce({ createThread: false, threadId });
}
diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts
index fa1a0f0258..62f13a357f 100644
--- a/open-sse/executors/perplexity-web.ts
+++ b/open-sse/executors/perplexity-web.ts
@@ -15,11 +15,8 @@ import {
} from "../services/perplexityTlsClient.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
-import { sanitizeErrorMessage } from "../utils/error.ts";
-import {
- buildSessionCookieHeader,
- mergeRefreshedCookie,
-} from "../utils/nextAuthCookie.ts";
+import { projectPublicErrorIdentifier, sanitizeErrorMessage } from "../utils/error.ts";
+import { buildSessionCookieHeader, mergeRefreshedCookie } from "../utils/nextAuthCookie.ts";
import {
PPLX_SSE_ENDPOINT,
PPLX_USER_AGENT,
@@ -31,13 +28,38 @@ import {
buildPplxRequestBody,
buildQuery,
extractContent,
+ PPLX_ADVANCED_QUOTA_DEFAULT_RESET_SECONDS,
sseChunk,
+ type ContentChunk,
} from "./perplexity-web/protocol.ts";
// ─── Session continuity ─────────────────────────────────────────────────────
const SESSION_MAX_AGE_MS = 3600_000;
const SESSION_MAX_ENTRIES = 200;
+const PPLX_PUBLIC_UPSTREAM_ERROR = "Perplexity upstream error";
+
+function sanitizePerplexityUpstreamError(message: unknown): string {
+ const sanitized = sanitizeErrorMessage(message);
+ return sanitized.trim() && !/^(?:[A-Za-z_$][\w$]*)?Error:\s*$/.test(sanitized)
+ ? sanitized
+ : PPLX_PUBLIC_UPSTREAM_ERROR;
+}
+
+function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError {
+ try {
+ return error instanceof TlsClientUnavailableError;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ return false;
+ }
+}
+
+export function toPublicPerplexityErrorCode(errorCode: unknown, isQuota: boolean): string {
+ if (isQuota) return "quota_exhausted";
+ if (typeof errorCode !== "string" || errorCode.length > 64) return "PPLX_ERROR";
+ return projectPublicErrorIdentifier(errorCode, "PPLX_ERROR");
+}
interface SessionEntry {
backendUuid: string;
@@ -95,134 +117,131 @@ function sessionStore(
}
}
+const PPLX_STREAM_PREFLIGHT_MAX_CHUNKS = 32;
+const PPLX_STREAM_PREFLIGHT_TIMEOUT_MS = 250;
+const PPLX_STREAM_PREFLIGHT_TIMED_OUT = Symbol("pplx-stream-preflight-timeout");
+
+async function waitForPreflightChunk(
+ pending: Promise>,
+ timeoutMs: number
+): Promise | typeof PPLX_STREAM_PREFLIGHT_TIMED_OUT> {
+ let timer: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ pending,
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(PPLX_STREAM_PREFLIGHT_TIMED_OUT), timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
+async function* replayContentChunks(
+ buffered: ContentChunk[],
+ pending: Promise> | null,
+ remaining: AsyncGenerator
+): AsyncGenerator {
+ try {
+ yield* buffered;
+ if (pending) {
+ const nextChunk = await pending;
+ if (!nextChunk.done) yield nextChunk.value;
+ }
+ yield* remaining;
+ } finally {
+ // Releasing the iterator unlocks the upstream reader; cleanup cannot replace the SSE outcome.
+ await remaining.return(undefined).catch(() => {});
+ }
+}
+
+interface ContentPreflightResult {
+ quotaError: ContentChunk | null;
+ contentChunks: AsyncIterable | null;
+}
+
+async function preflightContentChunks(
+ source: AsyncGenerator
+): Promise {
+ const buffered: ContentChunk[] = [];
+ const deadline = Date.now() + PPLX_STREAM_PREFLIGHT_TIMEOUT_MS;
+
+ for (let index = 0; index < PPLX_STREAM_PREFLIGHT_MAX_CHUNKS; index += 1) {
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) {
+ return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) };
+ }
+
+ const pending = source.next();
+ const nextChunk = await waitForPreflightChunk(pending, remainingMs);
+ if (nextChunk === PPLX_STREAM_PREFLIGHT_TIMED_OUT) {
+ return { quotaError: null, contentChunks: replayContentChunks(buffered, pending, source) };
+ }
+ if (nextChunk.done) {
+ return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) };
+ }
+
+ const chunk = nextChunk.value;
+ buffered.push(chunk);
+ if (chunk.error && isPerplexityQuotaError(chunk)) {
+ // Closing the primed generator releases its reader; cleanup must not mask the quota response.
+ await source.return(undefined).catch(() => {});
+ return { quotaError: chunk, contentChunks: null };
+ }
+ if (chunk.error || chunk.delta || chunk.answer || chunk.done) {
+ return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) };
+ }
+ }
+
+ // Once either bound is exhausted, preserve the original SSE 200 behavior for later errors.
+ return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) };
+}
+
+async function* throwContentError(error: unknown): AsyncGenerator {
+ throw error;
+}
+
function buildStreamingResponse(
- eventStream: ReadableStream,
+ contentChunks: AsyncIterable,
model: string,
cid: string,
created: number,
history: Array<{ role: string; content: string }>,
currentMsg: string,
- signal?: AbortSignal | null
-): ReadableStream {
+ onCancel?: (reason: unknown) => void
+): Response {
const encoder = new TextEncoder();
+ const contentIterator = contentChunks[Symbol.asyncIterator]();
+ const ownedChunks = { [Symbol.asyncIterator]: () => contentIterator };
+ let cancelled = false;
- return new ReadableStream(
- {
- async start(controller) {
- try {
- // Initial role chunk
- controller.enqueue(
- encoder.encode(
- sseChunk({
- id: cid,
- object: "chat.completion.chunk",
- created,
- model,
- system_fingerprint: null,
- choices: [
- { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null },
- ],
- })
- )
- );
+ const pump = async (controller: ReadableStreamDefaultController) => {
+ try {
+ // Initial role chunk
+ controller.enqueue(
+ encoder.encode(
+ sseChunk({
+ id: cid,
+ object: "chat.completion.chunk",
+ created,
+ model,
+ system_fingerprint: null,
+ choices: [
+ { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null },
+ ],
+ })
+ )
+ );
- let fullAnswer = "";
- let respBackendUuid: string | null = null;
+ let fullAnswer = "";
+ let respBackendUuid: string | null = null;
- for await (const chunk of extractContent(eventStream, signal)) {
- if (chunk.backendUuid) respBackendUuid = chunk.backendUuid;
+ for await (const chunk of ownedChunks) {
+ if (chunk.backendUuid) respBackendUuid = chunk.backendUuid;
- if (chunk.error) {
- controller.enqueue(
- encoder.encode(
- sseChunk({
- id: cid,
- object: "chat.completion.chunk",
- created,
- model,
- system_fingerprint: null,
- choices: [
- {
- index: 0,
- delta: { content: `[Error: ${chunk.error}]` },
- finish_reason: null,
- logprobs: null,
- },
- ],
- })
- )
- );
- break;
- }
-
- if (chunk.thinking) {
- controller.enqueue(
- encoder.encode(
- sseChunk({
- id: cid,
- object: "chat.completion.chunk",
- created,
- model,
- system_fingerprint: null,
- choices: [
- {
- index: 0,
- delta: { reasoning_content: chunk.thinking + "\n" },
- finish_reason: null,
- logprobs: null,
- },
- ],
- })
- )
- );
- continue;
- }
-
- if (chunk.done) {
- fullAnswer = chunk.answer || fullAnswer;
- break;
- }
-
- let dt = chunk.delta || "";
- if (dt) {
- dt = cleanResponse(dt, false);
- if (dt) {
- controller.enqueue(
- encoder.encode(
- sseChunk({
- id: cid,
- object: "chat.completion.chunk",
- created,
- model,
- system_fingerprint: null,
- choices: [
- { index: 0, delta: { content: dt }, finish_reason: null, logprobs: null },
- ],
- })
- )
- );
- }
- }
- if (chunk.answer) fullAnswer = chunk.answer;
- }
-
- // Stop chunk
- controller.enqueue(
- encoder.encode(
- sseChunk({
- id: cid,
- object: "chat.completion.chunk",
- created,
- model,
- system_fingerprint: null,
- choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
- })
- )
- );
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
-
- sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid);
- } catch (err) {
+ if (chunk.error) {
+ const publicError = sanitizePerplexityUpstreamError(chunk.error);
controller.enqueue(
encoder.encode(
sseChunk({
@@ -234,26 +253,173 @@ function buildStreamingResponse(
choices: [
{
index: 0,
- delta: {
- content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`,
- },
- finish_reason: "stop",
+ delta: { content: `[Error: ${publicError}]` },
+ finish_reason: null,
logprobs: null,
},
],
})
)
);
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
- } finally {
- try {
- controller.close();
- } catch {}
+ break;
+ }
+
+ if (chunk.thinking) {
+ controller.enqueue(
+ encoder.encode(
+ sseChunk({
+ id: cid,
+ object: "chat.completion.chunk",
+ created,
+ model,
+ system_fingerprint: null,
+ choices: [
+ {
+ index: 0,
+ delta: { reasoning_content: chunk.thinking + "\n" },
+ finish_reason: null,
+ logprobs: null,
+ },
+ ],
+ })
+ )
+ );
+ continue;
+ }
+
+ if (chunk.done) {
+ fullAnswer = chunk.answer || fullAnswer;
+ break;
+ }
+
+ let dt = chunk.delta || "";
+ if (dt) {
+ dt = cleanResponse(dt, false);
+ if (dt) {
+ controller.enqueue(
+ encoder.encode(
+ sseChunk({
+ id: cid,
+ object: "chat.completion.chunk",
+ created,
+ model,
+ system_fingerprint: null,
+ choices: [
+ { index: 0, delta: { content: dt }, finish_reason: null, logprobs: null },
+ ],
+ })
+ )
+ );
+ }
+ }
+ if (chunk.answer) fullAnswer = chunk.answer;
+ }
+ if (cancelled) return;
+
+ // Stop chunk
+ controller.enqueue(
+ encoder.encode(
+ sseChunk({
+ id: cid,
+ object: "chat.completion.chunk",
+ created,
+ model,
+ system_fingerprint: null,
+ choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
+ })
+ )
+ );
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+
+ sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid);
+ } catch (err) {
+ if (cancelled) return;
+ controller.enqueue(
+ encoder.encode(
+ sseChunk({
+ id: cid,
+ object: "chat.completion.chunk",
+ created,
+ model,
+ system_fingerprint: null,
+ choices: [
+ {
+ index: 0,
+ delta: {
+ content: `[Stream error: ${sanitizePerplexityUpstreamError(err)}]`,
+ },
+ finish_reason: "stop",
+ logprobs: null,
+ },
+ ],
+ })
+ )
+ );
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ } finally {
+ try {
+ controller.close();
+ } catch {
+ // Consumer cancellation or an already-closed stream must not replace the terminal outcome.
+ }
+ }
+ };
+
+ const stream = new ReadableStream(
+ {
+ start(controller) {
+ // The pump must not own start(): cancellation is unavailable until start() settles.
+ void pump(controller);
+ },
+ async cancel(reason) {
+ cancelled = true;
+ onCancel?.(reason);
+ try {
+ await contentIterator.return?.(undefined);
+ } catch {
+ // Upstream cleanup cannot replace the caller's already-selected cancellation outcome.
}
},
},
{ highWaterMark: 16384 }
);
+ return new Response(stream, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ },
+ });
+}
+
+function buildUpstreamErrorResponse(chunk: ContentChunk): Response {
+ // Quota exhaustion → 429 + reset_seconds so OmniRoute marks rate_limited_until
+ // and VibeProxy limit badges / rotation skip parse the same shape as model_cooldown.
+ const isQuota = isPerplexityQuotaError(chunk);
+ const resetSeconds =
+ typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0
+ ? chunk.resetSeconds
+ : isQuota
+ ? PPLX_ADVANCED_QUOTA_DEFAULT_RESET_SECONDS
+ : undefined;
+ const error: Record = {
+ message: sanitizePerplexityUpstreamError(chunk.error),
+ type: isQuota ? "quota_exhausted" : "upstream_error",
+ code: toPublicPerplexityErrorCode(chunk.errorCode, isQuota),
+ };
+ if (resetSeconds !== undefined) error.reset_seconds = resetSeconds;
+ const headers: Record = { "Content-Type": "application/json" };
+ if (resetSeconds !== undefined) headers["Retry-After"] = String(resetSeconds);
+ return new Response(JSON.stringify({ error }), { status: isQuota ? 429 : 502, headers });
+}
+
+function isPerplexityQuotaError(chunk: ContentChunk): boolean {
+ return (
+ chunk.errorCode === "quota_exhausted" ||
+ /quota exhausted/i.test(chunk.error || "") ||
+ (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0)
+ );
}
async function buildNonStreamingResponse(
@@ -272,28 +438,7 @@ async function buildNonStreamingResponse(
for await (const chunk of extractContent(eventStream, signal)) {
if (chunk.backendUuid) respBackendUuid = chunk.backendUuid;
if (chunk.error) {
- // Quota exhaustion → 429 + reset_seconds so OmniRoute marks rate_limited_until
- // and VibeProxy limit badges / rotation skip parse the same shape as model_cooldown.
- const isQuota =
- chunk.errorCode === "quota_exhausted" ||
- /quota exhausted/i.test(chunk.error) ||
- (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0);
- const status = isQuota ? 429 : 502;
- const code = chunk.errorCode || (isQuota ? "quota_exhausted" : "PPLX_ERROR");
- const type = isQuota ? "quota_exhausted" : "upstream_error";
- const errBody: Record = {
- message: chunk.error,
- type,
- code,
- };
- if (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) {
- errBody.reset_seconds = chunk.resetSeconds;
- }
- const respHeaders: Record = { "Content-Type": "application/json" };
- if (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) {
- respHeaders["Retry-After"] = String(chunk.resetSeconds);
- }
- return new Response(JSON.stringify({ error: errBody }), { status, headers: respHeaders });
+ return buildUpstreamErrorResponse(chunk);
}
if (chunk.thinking) {
thinkingParts.push(chunk.thinking);
@@ -348,10 +493,8 @@ async function persistRotatedSessionCookie(
await onCredentialsRefreshed({ ...credentials, apiKey: refreshed });
}
} catch (err) {
- log?.warn?.(
- "PPLX-WEB",
- `Failed to persist refreshed cookie: ${err instanceof Error ? err.message : String(err)}`
- );
+ const publicError = sanitizePerplexityUpstreamError(err);
+ log?.warn?.("PPLX-WEB", `Failed to persist refreshed cookie: ${publicError}`);
}
}
@@ -362,7 +505,15 @@ export class PerplexityWebExecutor extends BaseExecutor {
super("perplexity-web", { id: "perplexity-web", baseUrl: PPLX_SSE_ENDPOINT });
}
- async execute({ model, body, stream, credentials, signal, log, onCredentialsRefreshed }: ExecuteInput) {
+ async execute({
+ model,
+ body,
+ stream,
+ credentials,
+ signal,
+ log,
+ onCredentialsRefreshed,
+ }: ExecuteInput) {
const bodyObj = (body || {}) as Record;
const rawMessages = bodyObj.messages as Array> | undefined;
if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) {
@@ -406,7 +557,7 @@ export class PerplexityWebExecutor extends BaseExecutor {
const parsed = parseOpenAIMessages(effectiveMessages);
const followUpUuid = sessionLookup(parsed.history);
if (followUpUuid) {
- log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`);
+ log?.info?.("PPLX-WEB", "Continuing existing session");
}
const query = buildQuery(parsed, followUpUuid);
@@ -473,14 +624,15 @@ export class PerplexityWebExecutor extends BaseExecutor {
streamEofSymbol: PPLX_STREAM_EOF_SYMBOL,
});
} catch (err) {
- const isTlsUnavail = err instanceof TlsClientUnavailableError;
- log?.error?.("PPLX-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`);
+ const isTlsUnavail = isTlsClientUnavailableError(err);
+ const publicError = sanitizePerplexityUpstreamError(err);
+ log?.error?.("PPLX-WEB", `Fetch failed: ${publicError}`);
const errResp = new Response(
JSON.stringify({
error: {
message: isTlsUnavail
- ? `Perplexity TLS client unavailable: ${sanitizeErrorMessage((err as Error).message)}`
- : `Perplexity connection failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`,
+ ? `Perplexity TLS client unavailable: ${publicError}`
+ : `Perplexity connection failed: ${publicError}`,
type: "upstream_error",
},
}),
@@ -596,23 +748,36 @@ export class PerplexityWebExecutor extends BaseExecutor {
idSeed: "pplx",
});
} else if (stream) {
- const sseStream = buildStreamingResponse(
- response.body,
- model,
- cid,
- created,
- parsed.history,
- parsed.currentMsg,
- signal
- );
- finalResponse = new Response(sseStream, {
- status: 200,
- headers: {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache",
- "X-Accel-Buffering": "no",
- },
- });
+ const contentAbortController = new AbortController();
+ const contentSignal = signal
+ ? AbortSignal.any([signal, contentAbortController.signal])
+ : contentAbortController.signal;
+ const contentChunks = extractContent(response.body, contentSignal);
+ try {
+ const preflight = await preflightContentChunks(contentChunks);
+ if (preflight.quotaError) {
+ finalResponse = buildUpstreamErrorResponse(preflight.quotaError);
+ } else {
+ finalResponse = buildStreamingResponse(
+ preflight.contentChunks as AsyncIterable,
+ model,
+ cid,
+ created,
+ parsed.history,
+ parsed.currentMsg,
+ (reason) => contentAbortController.abort(reason)
+ );
+ }
+ } catch (err) {
+ finalResponse = buildStreamingResponse(
+ throwContentError(err),
+ model,
+ cid,
+ created,
+ parsed.history,
+ parsed.currentMsg
+ );
+ }
} else {
finalResponse = await buildNonStreamingResponse(
response.body,
diff --git a/open-sse/executors/perplexity-web/protocol.ts b/open-sse/executors/perplexity-web/protocol.ts
index 12e98ccdc4..0ea35f994f 100644
--- a/open-sse/executors/perplexity-web/protocol.ts
+++ b/open-sse/executors/perplexity-web/protocol.ts
@@ -213,6 +213,12 @@ export async function* readPplxSseEvents(
const decoder = new TextDecoder();
let buffer = "";
let dataLines: string[] = [];
+ const cancelPendingRead = () => {
+ // Abort only releases pending upstream I/O; the executor retains the public stream outcome.
+ void reader.cancel(signal?.reason).catch(() => {});
+ };
+ if (signal?.aborted) cancelPendingRead();
+ else signal?.addEventListener("abort", cancelPendingRead, { once: true });
function flush(): PplxStreamEvent | null | "done" {
if (dataLines.length === 0) return null;
@@ -263,6 +269,7 @@ export async function* readPplxSseEvents(
const tail = flush();
if (tail && tail !== "done") yield tail;
} finally {
+ signal?.removeEventListener("abort", cancelPendingRead);
reader.releaseLock();
}
}
@@ -417,9 +424,8 @@ export interface ContentChunk {
/** Structured error code for quota / rate-limit surfaces (e.g. quota_exhausted). */
errorCode?: string;
/**
- * Suggested client/account cooldown in seconds when the stream failed due to
- * advanced-model weekly quota (or similar). Downstream marks the connection
- * rate_limited_until and VibeProxy limit badges parse this + "reset after Xs".
+ * Suggested cooldown when quota is classified before the HTTP stream is committed.
+ * Once SSE 200 starts, a late error cannot retroactively add status or Retry-After metadata.
*/
resetSeconds?: number;
done?: boolean;
@@ -775,6 +781,7 @@ export async function* extractContent(
if (event.error_code || event.error_message) {
yield {
error: event.error_message || `Perplexity error: ${event.error_code}`,
+ errorCode: event.error_code,
done: true,
};
return;
diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index 3c240717ad..d294b115cc 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -224,7 +224,9 @@ import {
createErrorResult,
parseUpstreamError,
formatProviderError,
+ projectPublicErrorIdentifier,
sanitizeErrorMessage,
+ sanitizeUpstreamDetails,
} from "../utils/error.ts";
import {
reportMalformed200,
@@ -266,8 +268,10 @@ import { recordKeyHealthStatus as recordKeyHealthStatusFor } from "./chatCore/ke
import { getSkillsModelIdForFormat } from "./chatCore/skillsFormat.ts";
import { readNonStreamingResponseBody } from "./chatCore/nonStreamingResponseBody.ts";
import {
- isSemaphoreCapacityError,
+ createSafeAbortError,
createStreamingErrorResult,
+ formatStreamRecoveryRetryWarning,
+ getSafeErrorMetadata,
getUpstreamErrorIdentifier,
} from "./chatCore/streamErrorResult.ts";
import { wrapReadableStreamWithFinalize } from "./chatCore/streamFinalize.ts";
@@ -596,12 +600,11 @@ export async function handleChatCore({
status: 409,
});
};
- const isManagedLeaseFenceError = (error: unknown): boolean =>
- managedLease !== null &&
- typeof (error as { code?: unknown })?.code === "string" &&
- String((error as { code: string }).code).startsWith("LEASE_");
- const managedLeaseFenceErrorResult = (error: unknown) => {
- const code = (error as { code: string }).code;
+ const getManagedLeaseFenceErrorCode = (code: string | undefined): string | undefined => {
+ if (managedLease === null) return undefined;
+ return code?.startsWith("LEASE_") ? code : undefined;
+ };
+ const managedLeaseFenceErrorResult = (code: string) => {
return {
...createErrorResult(409, "Managed lease request fence rejected the dispatch", null, code),
errorType: "lease_error",
@@ -2433,48 +2436,47 @@ export async function handleChatCore({
error instanceof Error ? error : new Error(String(error))
);
} catch (pluginErr) {
- log?.debug?.(
- "PLUGIN",
- `onError hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}`
- );
+ const pluginErrorMessage = sanitizeErrorMessage(pluginErr) || "Plugin onError hook failed";
+ log?.debug?.("PLUGIN", `onError hook error (non-fatal): ${pluginErrorMessage}`);
}
- const parsedStatus = Number(error?.statusCode);
+ let parsedStatus = Number.NaN;
+ try {
+ parsedStatus = Number(error?.statusCode);
+ } catch {
+ // Hostile thrown values may expose Symbols or throwing status accessors.
+ }
const statusCode =
Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599
? parsedStatus
: 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",
- },
- }
- ),
- };
+ let message = "Invalid request";
+ try {
+ const candidate = error?.message;
+ message =
+ (typeof candidate === "string" ? candidate : sanitizeErrorMessage(candidate)) || message;
+ } catch {
+ // Hostile thrown values may expose throwing property accessors.
+ }
+ let errorType: string | null = null;
+ try {
+ const candidate = error?.errorType;
+ errorType = typeof candidate === "string" ? candidate : null;
+ } catch {
+ // Hostile thrown values may expose throwing classification accessors.
}
+ const result = createErrorResult(
+ statusCode,
+ message,
+ null,
+ errorType ?? undefined,
+ errorType ?? undefined
+ );
+ 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
@@ -3458,9 +3460,11 @@ export async function handleChatCore({
onRetry: (attempt, err) =>
log?.warn?.(
"STREAM_RECOVERY",
- `transparent early-retry ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX} after ${
- (err as { name?: string })?.name || "truncation"
- }`
+ formatStreamRecoveryRetryWarning(
+ attempt,
+ STREAM_RECOVERY.EARLY_RETRY_MAX,
+ err
+ )
),
continueStream,
onContinue: (attempt) =>
@@ -3717,15 +3721,21 @@ export async function handleChatCore({
}
} catch (error) {
trackPendingRequest(model, provider, connectionId, false);
- if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error);
- if (isSemaphoreCapacityError(error)) {
+ const errorMetadata = getSafeErrorMetadata(error);
+ const managedLeaseFenceCode = getManagedLeaseFenceErrorCode(errorMetadata.code);
+ if (managedLeaseFenceCode) return managedLeaseFenceErrorResult(managedLeaseFenceCode);
+ if (
+ errorMetadata.code === "SEMAPHORE_TIMEOUT" ||
+ errorMetadata.code === "SEMAPHORE_QUEUE_FULL"
+ ) {
+ const semaphoreCode = errorMetadata.code as "SEMAPHORE_TIMEOUT" | "SEMAPHORE_QUEUE_FULL";
appendRequestLog({
model,
provider,
connectionId,
- status: `FAILED ${error.code}`,
+ status: `FAILED ${semaphoreCode}`,
}).catch(() => {});
- const failureMessage = error.message || "Semaphore timeout";
+ const failureMessage = sanitizeErrorMessage(errorMetadata.message) || "Semaphore timeout";
persistAttemptLogs({
status: HTTP_STATUS.RATE_LIMITED,
error: failureMessage,
@@ -3734,25 +3744,32 @@ export async function handleChatCore({
claudeCacheMeta: claudePromptCacheLogMeta,
cacheSource: "upstream",
});
- persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code);
+ persistFailureUsage(HTTP_STATUS.RATE_LIMITED, semaphoreCode);
const result = stream
- ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code)
+ ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, semaphoreCode)
: createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage);
return {
...result,
errorType: "account_semaphore_capacity",
- errorCode: error.code,
+ errorCode: semaphoreCode,
};
}
// abort(reason) can reject with a raw string lacking `name`/`status`; classify
// it through isLocalStreamLifecycleError so it maps to 499 rather than the
// 502 provider-failure default.
- const isRequestAborted = isLocalStreamLifecycleError(error);
+ let isRequestAborted = errorMetadata.name === "AbortError";
+ if (!isRequestAborted) {
+ try {
+ isRequestAborted = isLocalStreamLifecycleError(error);
+ } catch {
+ // A hostile Proxy must not escape the provider-error boundary during abort classification.
+ }
+ }
// #8376: proxyFetch tags unreachable transport failures so they remain
// distinguishable from ordinary provider 5xx responses.
const isProxyUnreachableFailure =
- !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable";
- const errorCode = getUpstreamErrorIdentifier(error);
+ !isRequestAborted && errorMetadata.errorCode === "proxy_unreachable";
+ const errorCode = errorMetadata.code;
const localRateLimitFailure = localLimiterErrors.getClientSafeLocalRateLimitError(error);
const failureStatus = isRequestAborted
? 499
@@ -3760,14 +3777,27 @@ export async function handleChatCore({
? HTTP_STATUS.BAD_GATEWAY
: localRateLimitFailure
? localRateLimitFailure.status
- : error.name === "TimeoutError" || error.name === "BodyTimeoutError"
+ : errorMetadata.name === "TimeoutError" || errorMetadata.name === "BodyTimeoutError"
? HTTP_STATUS.GATEWAY_TIMEOUT
- : error.status && typeof error.status === "number"
- ? error.status
+ : errorMetadata.status
+ ? errorMetadata.status
: HTTP_STATUS.BAD_GATEWAY;
const failureMessage = isRequestAborted
? "Request aborted"
- : formatProviderError(localRateLimitFailure ?? error, provider, model, failureStatus);
+ : (() => {
+ try {
+ return formatProviderError(
+ localRateLimitFailure ?? error,
+ provider,
+ model,
+ failureStatus
+ );
+ } catch {
+ // Formatting is diagnostic only; hostile rejection metadata falls back safely.
+ return errorMetadata.message || "Upstream provider error";
+ }
+ })();
+ const safeFailureMessage = sanitizeErrorMessage(failureMessage) || "Upstream provider error";
const upstreamErrorCode =
localRateLimitFailure?.code ?? (isProxyUnreachableFailure ? "proxy_unreachable" : errorCode);
// Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError,
@@ -3776,7 +3806,7 @@ export async function handleChatCore({
// tags its pre-response timeout via the code below.)
const isOwnDeadlineTimeout =
failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT &&
- (error.name === "TimeoutError" || error.name === "BodyTimeoutError");
+ (errorMetadata.name === "TimeoutError" || errorMetadata.name === "BodyTimeoutError");
const upstreamErrorType =
upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout
? "upstream_timeout"
@@ -3791,7 +3821,7 @@ export async function handleChatCore({
}).catch(() => {});
persistAttemptLogs({
status: failureStatus,
- error: failureMessage,
+ error: safeFailureMessage,
providerRequest: finalBody || translatedBody,
// On a client-abort (AbortError), the client already disconnected before
// we ever got here — this body is what we WOULD have sent, not what was
@@ -3799,19 +3829,22 @@ export async function handleChatCore({
// dashboard reads that field as "what the client received"), so omit it
// for this case; `error` above already records the failure reason.
clientResponse:
- error.name === "AbortError" ? undefined : buildErrorBody(failureStatus, failureMessage),
+ errorMetadata.name === "AbortError"
+ ? undefined
+ : buildErrorBody(failureStatus, failureMessage),
claudeCacheMeta: claudePromptCacheLogMeta,
cacheSource: "upstream",
});
if (isRequestAborted) {
- streamController.handleError(error);
+ streamController.handleError(createSafeAbortError());
return createErrorResult(499, "Request aborted");
}
- persistFailureUsage(
- failureStatus,
- upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error")
+ const failureUsageCode = projectPublicErrorIdentifier(
+ upstreamErrorCode || errorMetadata.name,
+ "upstream_error"
);
- console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
+ persistFailureUsage(failureStatus, failureUsageCode);
+ console.log(`${COLORS.red}[ERROR] ${safeFailureMessage}${COLORS.reset}`);
if (stream && upstreamErrorCode) {
const result = createStreamingErrorResult(
failureStatus,
@@ -3972,7 +4005,10 @@ export async function handleChatCore({
upstreamErrorParsed = false; // Let it be parsed downstream
}
} catch (retryErr) {
- if (isManagedLeaseFenceError(retryErr)) return managedLeaseFenceErrorResult(retryErr);
+ const retryLeaseFenceCode = getManagedLeaseFenceErrorCode(
+ getUpstreamErrorIdentifier(retryErr)
+ );
+ if (retryLeaseFenceCode) return managedLeaseFenceErrorResult(retryLeaseFenceCode);
// Refresh succeeded but the retry leg failed (network blip, AbortError,
// executor throw). Don't swallow — the operator-visible signal "the user
// saw 401 even though auth was actually fixed" is much more confusing
@@ -4032,8 +4068,8 @@ export async function handleChatCore({
message = details.message;
retryAfterMs = details.retryAfterMs;
upstreamErrorBody = details.responseBody;
- upstreamErrorCode = details.errorCode as string | undefined;
- upstreamErrorType = details.errorType as string | undefined;
+ upstreamErrorCode = typeof details.errorCode === "string" ? details.errorCode : undefined;
+ upstreamErrorType = typeof details.errorType === "string" ? details.errorType : undefined;
}
// Gateways like agentrouter misstate temporary quota exhaustion as 403/400,
@@ -4089,8 +4125,14 @@ export async function handleChatCore({
message = signatureRecovery.error.message;
retryAfterMs = signatureRecovery.error.retryAfterMs;
upstreamErrorBody = signatureRecovery.error.responseBody;
- upstreamErrorCode = signatureRecovery.error.errorCode as string | undefined;
- upstreamErrorType = signatureRecovery.error.errorType as string | undefined;
+ upstreamErrorCode =
+ typeof signatureRecovery.error.errorCode === "string"
+ ? signatureRecovery.error.errorCode
+ : undefined;
+ upstreamErrorType =
+ typeof signatureRecovery.error.errorType === "string"
+ ? signatureRecovery.error.errorType
+ : undefined;
}
}
@@ -4225,79 +4267,84 @@ export async function handleChatCore({
`[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
);
} else {
- // Kimi's 403 says "billing cycle" for both an exhausted subscription and a
- // temporary request window. Read its official usage endpoint before making
- // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit
- // window must recover automatically at the reported reset time.
- let kimiRateLimitResetAt: string | null = null;
- if (provider === "kimi-coding") {
- try {
- const { fetchAndPersistProviderLimits } =
- await import("@/lib/usage/providerLimits");
- const { usage } = await fetchAndPersistProviderLimits(errorConnectionId, "manual");
- kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage);
- } catch {
- // Preserve the existing quota handling when Kimi's usage endpoint is unavailable.
+ // Kimi's 403 says "billing cycle" for both an exhausted subscription and a
+ // temporary request window. Read its official usage endpoint before making
+ // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit
+ // window must recover automatically at the reported reset time.
+ let kimiRateLimitResetAt: string | null = null;
+ if (provider === "kimi-coding") {
+ try {
+ const { fetchAndPersistProviderLimits } =
+ await import("@/lib/usage/providerLimits");
+ const { usage } = await fetchAndPersistProviderLimits(
+ errorConnectionId,
+ "manual"
+ );
+ kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage);
+ } catch {
+ // Preserve the existing quota handling when Kimi's usage endpoint is unavailable.
+ }
}
- }
- // Providers with per-model quotas — lock the model only, not the connection
- const quotaCooldownMs = kimiRateLimitResetAt
- ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
- : retryAfterMs || COOLDOWN_MS.rateLimit;
- const accountSemaphoreKey = resolveAccountSemaphoreKey({
- provider,
- model: currentModel,
- connectionId: errorConnectionId,
- credentials,
- });
- if (accountSemaphoreKey) {
- markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
- }
- if (kimiRateLimitResetAt) {
- await updateProviderConnection(errorConnectionId, {
- testStatus: "unavailable",
- rateLimitedUntil: kimiRateLimitResetAt,
- backoffLevel: 0,
- lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
- lastError: message,
- errorCode: statusCode,
- });
- console.warn(
- `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}`
- );
- } else if (isModelScope() && errorConnectionId) {
- const lockFn = provider === "antigravity" ? lockExactModel : lockModel;
- lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
- console.warn(
- `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
- );
- } else if (
- lockModelIfPerModelQuota(
+ // Providers with per-model quotas — lock the model only, not the connection
+ const quotaCooldownMs = kimiRateLimitResetAt
+ ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
+ : retryAfterMs || COOLDOWN_MS.rateLimit;
+ const accountSemaphoreKey = resolveAccountSemaphoreKey({
provider,
- errorConnectionId,
- model,
- "quota_exhausted",
- quotaCooldownMs
- )
- ) {
- const quotaScope = getQuotaScopeLabelForProvider(provider, model);
- console.warn(
- `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)`
- );
- } else {
- await writeTerminalStatus(
- errorConnectionId,
- {
- testStatus: "credits_exhausted",
+ model: currentModel,
+ connectionId: errorConnectionId,
+ credentials,
+ });
+ if (accountSemaphoreKey) {
+ markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
+ }
+ if (kimiRateLimitResetAt) {
+ await updateProviderConnection(errorConnectionId, {
+ testStatus: "unavailable",
+ rateLimitedUntil: kimiRateLimitResetAt,
+ backoffLevel: 0,
+ lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
lastError: message,
- lastErrorType: errorType,
- errorCode: String(statusCode),
- },
- "production"
- );
- console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`);
- }
+ errorCode: statusCode,
+ });
+ console.warn(
+ `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}`
+ );
+ } else if (isModelScope() && errorConnectionId) {
+ const lockFn = provider === "antigravity" ? lockExactModel : lockModel;
+ lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
+ console.warn(
+ `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
+ );
+ } else if (
+ lockModelIfPerModelQuota(
+ provider,
+ errorConnectionId,
+ model,
+ "quota_exhausted",
+ quotaCooldownMs
+ )
+ ) {
+ const quotaScope = getQuotaScopeLabelForProvider(provider, model);
+ console.warn(
+ `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)`
+ );
+ } else {
+ await writeTerminalStatus(
+ errorConnectionId,
+ {
+ testStatus: "credits_exhausted",
+ lastError: message,
+ lastErrorType: errorType,
+ errorCode: String(statusCode),
+ },
+ "production"
+ );
+ console.warn(
+ `[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`
+ );
+ }
} // close probeIsolated3 else
}
} else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
@@ -4408,7 +4455,9 @@ export async function handleChatCore({
}).catch(() => {});
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
- console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
+ const safeErrMsg = sanitizeErrorMessage(errMsg) || "Upstream provider error";
+ const safeUpstreamErrorBody = sanitizeUpstreamDetails(upstreamErrorBody);
+ console.log(`${COLORS.red}[ERROR] ${safeErrMsg}${COLORS.reset}`);
// Log Antigravity retry time if available
if (retryAfterMs && provider === "antigravity") {
@@ -4422,7 +4471,7 @@ export async function handleChatCore({
providerResponse.status,
providerResponse.statusText,
providerResponse.headers,
- upstreamErrorBody
+ safeUpstreamErrorBody
);
// Update rate limiter from error response headers
@@ -4464,9 +4513,9 @@ export async function handleChatCore({
// Fallback also failed — return original error
persistAttemptLogs({
status: statusCode,
- error: errMsg,
+ error: safeErrMsg,
providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
+ providerResponse: safeUpstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
@@ -4484,9 +4533,9 @@ export async function handleChatCore({
} catch {
persistAttemptLogs({
status: statusCode,
- error: errMsg,
+ error: safeErrMsg,
providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
+ providerResponse: safeUpstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
@@ -4504,9 +4553,9 @@ export async function handleChatCore({
} else {
persistAttemptLogs({
status: statusCode,
- error: errMsg,
+ error: safeErrMsg,
providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
+ providerResponse: safeUpstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
@@ -4553,9 +4602,9 @@ export async function handleChatCore({
} else {
persistAttemptLogs({
status: statusCode,
- error: errMsg,
+ error: safeErrMsg,
providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
+ providerResponse: safeUpstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
@@ -4573,9 +4622,9 @@ export async function handleChatCore({
} catch {
persistAttemptLogs({
status: statusCode,
- error: errMsg,
+ error: safeErrMsg,
providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
+ providerResponse: safeUpstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
@@ -4593,9 +4642,9 @@ export async function handleChatCore({
} else {
persistAttemptLogs({
status: statusCode,
- error: errMsg,
+ error: safeErrMsg,
providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
+ providerResponse: safeUpstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
@@ -4613,9 +4662,9 @@ export async function handleChatCore({
} else {
persistAttemptLogs({
status: statusCode,
- error: errMsg,
+ error: safeErrMsg,
providerRequest: finalBody || translatedBody,
- providerResponse: upstreamErrorBody,
+ providerResponse: safeUpstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
cacheSource: "upstream",
});
@@ -4662,11 +4711,13 @@ export async function handleChatCore({
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
const invalidSseMessage = parsed.message;
+ const safeInvalidSseMessage =
+ sanitizeErrorMessage(invalidSseMessage) || "Invalid SSE response for non-streaming request";
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
- error: invalidSseMessage,
+ error: safeInvalidSseMessage,
providerRequest: finalBody || translatedBody,
- providerResponse: normalizedProviderPayload,
+ providerResponse: sanitizeUpstreamDetails(normalizedProviderPayload),
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage),
cacheSource: "upstream",
});
@@ -4684,11 +4735,12 @@ export async function handleChatCore({
}).catch(() => {});
const detailedError = parsed.detailedError;
const invalidJsonMessage = parsed.message;
+ const safeDetailedError = sanitizeErrorMessage(detailedError) || invalidJsonMessage;
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
- error: detailedError,
+ error: safeDetailedError,
providerRequest: finalBody || translatedBody,
- providerResponse: normalizedProviderPayload,
+ providerResponse: sanitizeUpstreamDetails(normalizedProviderPayload),
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage),
cacheSource: "upstream",
});
@@ -4735,12 +4787,10 @@ export async function handleChatCore({
}
}
} catch (retryErr) {
- log?.warn?.(
- "RETRY",
- `clinepass retry failed: ${
- retryErr instanceof Error ? retryErr.message : String(retryErr)
- }`
- );
+ const retryMessage =
+ sanitizeErrorMessage(getSafeErrorMetadata(retryErr).message ?? retryErr) ||
+ "Upstream provider error";
+ log?.warn?.("RETRY", `clinepass retry failed: ${retryMessage}`);
}
}
if (envError) {
@@ -4777,7 +4827,7 @@ export async function handleChatCore({
status: HTTP_STATUS.BAD_GATEWAY,
error: emptyContentMessage,
providerRequest: finalBody || translatedBody,
- providerResponse: normalizedProviderPayload,
+ providerResponse: sanitizeUpstreamDetails(normalizedProviderPayload),
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage),
cacheSource: "upstream",
});
@@ -5144,14 +5194,26 @@ export async function handleChatCore({
const malformedClientBody = buildErrorBody(HTTP_STATUS.BAD_GATEWAY, malformedMessage);
malformedClientBody.error.code = malformed.code;
malformedClientBody.error.type = malformed.type;
+ const sanitizedMalformedResponse = sanitizeUpstreamDetails(responseBody);
+ const sanitizedMalformedProviderResponse = looksLikeSSE
+ ? {
+ _streamed: true,
+ _format: "sse-json",
+ summary: sanitizedMalformedResponse,
+ }
+ : sanitizedMalformedResponse;
+ reqLogger.logProviderResponse(
+ providerResponse.status,
+ providerResponse.statusText,
+ providerResponse.headers,
+ sanitizedMalformedProviderResponse
+ );
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
tokens: usage,
- responseBody,
+ responseBody: sanitizedMalformedResponse,
providerRequest: finalBody || translatedBody,
- providerResponse: looksLikeSSE
- ? { _streamed: true, _format: "sse-json", summary: responseBody }
- : responseBody,
+ providerResponse: sanitizedMalformedProviderResponse,
clientResponse: malformedClientBody,
claudeCacheMeta: claudePromptCacheLogMeta,
claudeCacheUsageMeta: cacheUsageLogMeta,
diff --git a/open-sse/handlers/chatCore/streamErrorResult.ts b/open-sse/handlers/chatCore/streamErrorResult.ts
index 77244b611d..7bc0dbaaaf 100644
--- a/open-sse/handlers/chatCore/streamErrorResult.ts
+++ b/open-sse/handlers/chatCore/streamErrorResult.ts
@@ -4,19 +4,65 @@
*
* Extracted from chatCore: identify semaphore capacity errors, build a sanitized SSE error result
* (an `data: {...}\n\ndata: [DONE]\n\n` body wrapped in an event-stream Response), and pull a string
- * error code off an unknown error. Side-effect-free; behaviour is byte-identical to the previous
- * module-level functions.
+ * error code off an unknown error. The status and SSE envelope remain stable while every public
+ * message/code/type crosses the canonical sanitizer; raw internal fields stay outside this body.
*/
-import { buildErrorBody } from "../../utils/error.ts";
+import { buildErrorBody, sanitizeErrorMessage } from "../../utils/error.ts";
+
+export interface SafeErrorMetadata {
+ code?: string;
+ errorCode?: string;
+ message?: string;
+ name?: string;
+ status?: number;
+}
+
+function readErrorProperty(error: object, property: string): unknown {
+ try {
+ return Reflect.get(error, property);
+ } catch {
+ // Provider rejections may be hostile Proxies; public error handling fails closed per field.
+ return undefined;
+ }
+}
+
+export function getSafeErrorMetadata(error: unknown): SafeErrorMetadata {
+ if (error === null || (typeof error !== "object" && typeof error !== "function")) {
+ return {};
+ }
+ const code = readErrorProperty(error, "code");
+ const errorCode = readErrorProperty(error, "errorCode");
+ const message = readErrorProperty(error, "message");
+ const name = readErrorProperty(error, "name");
+ const status = readErrorProperty(error, "status");
+ return {
+ code: typeof code === "string" && code.length > 0 ? code : undefined,
+ errorCode: typeof errorCode === "string" && errorCode.length > 0 ? errorCode : undefined,
+ message: typeof message === "string" && message.length > 0 ? message : undefined,
+ name: typeof name === "string" && name.length > 0 ? name : undefined,
+ status: typeof status === "number" && Number.isFinite(status) ? status : undefined,
+ };
+}
+
+export function createSafeAbortError(): Error {
+ const error = new Error("Request aborted");
+ error.name = "AbortError";
+ return error;
+}
+
+export function formatStreamRecoveryRetryWarning(
+ attempt: number,
+ maxAttempts: number,
+ error: unknown
+): string {
+ const safeName = sanitizeErrorMessage(getSafeErrorMetadata(error).name) || "truncation";
+ return `transparent early-retry ${attempt}/${maxAttempts} after ${safeName}`;
+}
export function isSemaphoreCapacityError(error: unknown): error is Error & { code: string } {
- return (
- !!error &&
- typeof error === "object" &&
- ((error as { code?: unknown }).code === "SEMAPHORE_TIMEOUT" ||
- (error as { code?: unknown }).code === "SEMAPHORE_QUEUE_FULL")
- );
+ const code = getSafeErrorMetadata(error).code;
+ return code === "SEMAPHORE_TIMEOUT" || code === "SEMAPHORE_QUEUE_FULL";
}
export function createStreamingErrorResult(
@@ -25,13 +71,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`;
@@ -52,7 +92,5 @@ export function createStreamingErrorResult(
}
export function getUpstreamErrorIdentifier(error: unknown): string | undefined {
- if (!error || typeof error !== "object") return undefined;
- const value = (error as { code?: unknown }).code;
- return typeof value === "string" && value.length > 0 ? value : undefined;
+ return getSafeErrorMetadata(error).code;
}
diff --git a/open-sse/handlers/moderations.ts b/open-sse/handlers/moderations.ts
index c153e10eea..fa5cb72a16 100644
--- a/open-sse/handlers/moderations.ts
+++ b/open-sse/handlers/moderations.ts
@@ -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}`);
}
}
diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts
index f5d52f0106..16538e08cc 100644
--- a/open-sse/handlers/ocr.ts
+++ b/open-sse/handlers/ocr.ts
@@ -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");
}
}
diff --git a/open-sse/services/tlsClientBase.ts b/open-sse/services/tlsClientBase.ts
index 2b3fef63d4..386b78b865 100644
--- a/open-sse/services/tlsClientBase.ts
+++ b/open-sse/services/tlsClientBase.ts
@@ -25,18 +25,41 @@
// ---------------------------------------------------------------------------
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
-import { join, dirname } from "node:path";
-import { open, unlink, rmdir, readFile, mkdtemp, stat } from "node:fs/promises";
+import { join } from "node:path";
+import { open, readFile, mkdtemp, stat } from "node:fs/promises";
// ---------------------------------------------------------------------------
// Proxy resolution — every provider file imports both of these
// ---------------------------------------------------------------------------
import { resolveProxyForRequest } from "../utils/proxyFetch.ts";
+import { sanitizeErrorMessage } from "../utils/error.ts";
+import { logger } from "../utils/logger.ts";
import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts";
import {
buildNativeTlsClientOptions,
resolveVerifiedTlsClientNativeLibrary,
} from "./tlsClientDownloadDir.ts";
+import {
+ acquireNativeTlsClientLease,
+ activateNativeTlsClientLease,
+ installNativeTlsClientExitHook,
+ releaseNativeTlsClientLease,
+ type NativeTlsClientLease,
+} from "./tlsClientLifecycleRegistry.ts";
+import {
+ cleanupTlsClientStreamPath,
+ createSanitizedTlsStreamError,
+ createTlsClientTailStream,
+ sanitizeTlsClientErrorMessage,
+ type TlsClientTailVariant,
+} from "./tlsClientStream.ts";
+import { makeAbortError, raceWithTimeout, TlsClientHangError } from "./tlsClientTimeout.ts";
+
+export { makeAbortError, raceWithTimeout, TlsClientHangError };
+
+const tlsClientLogger = logger("TLSClient");
+const DEFAULT_CLIENT_START_TIMEOUT_MS = 30_000;
+const DEFAULT_PARTIAL_CLIENT_CLEANUP_TIMEOUT_MS = 5_000;
// ---------------------------------------------------------------------------
// Types
@@ -94,7 +117,7 @@ export interface TlsClientConfig {
* "B1" — Buffer.from enqueue, excludes EOF, inline drainRemaining
* "B2" — Buffer.from enqueue, excludes EOF, extracted helpers
*/
- tailFileVariant: "A" | "B1" | "B2";
+ tailFileVariant: TlsClientTailVariant;
/**
* Response validation mode:
* "sse" — check looksLikeSse → fall back to buffered
@@ -125,8 +148,13 @@ export class TlsClientUnavailableError extends Error {
override name = "TlsClientUnavailableError";
}
-export class TlsClientHangError extends Error {
- override name = "TlsClientHangError";
+function isTlsClientHangError(error: unknown): error is TlsClientHangError {
+ try {
+ return error instanceof TlsClientHangError;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ return false;
+ }
}
// ---------------------------------------------------------------------------
@@ -137,14 +165,6 @@ export function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
-export function makeAbortError(signal: AbortSignal): Error {
- const reason = signal.reason;
- if (reason instanceof Error) return reason;
- const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
- err.name = "AbortError";
- return err;
-}
-
export function toHeaders(raw: Record | null | undefined): Headers {
const h = new Headers();
for (const [k, vs] of Object.entries(raw || {})) {
@@ -153,65 +173,6 @@ export function toHeaders(raw: Record | null | undefined): Hea
return h;
}
-export async function raceWithTimeout(
- promise: Promise,
- timeoutMs: number,
- signal: AbortSignal | null | undefined
-): Promise {
- // If no signal, just race with a simple timeout.
- if (!signal) {
- return await Promise.race([
- promise,
- new Promise((_, reject) => {
- setTimeout(() => reject(new TlsClientHangError()), timeoutMs);
- }),
- ]);
- }
-
- // With signal, race against both timeout and abort.
- return await new Promise((resolve, reject) => {
- let settled = false;
-
- const done = (fn: () => void) => {
- if (!settled) {
- settled = true;
- fn();
- }
- };
-
- const timer = setTimeout(() => {
- done(() => reject(new TlsClientHangError()));
- }, timeoutMs);
-
- const onAbort = () => {
- done(() => reject(makeAbortError(signal)));
- };
-
- if (signal.aborted) {
- onAbort();
- } else {
- signal.addEventListener("abort", onAbort, { once: true });
- }
-
- promise.then(
- (v) => {
- done(() => {
- clearTimeout(timer);
- signal.removeEventListener("abort", onAbort);
- resolve(v);
- });
- },
- (e) => {
- done(() => {
- clearTimeout(timer);
- signal.removeEventListener("abort", onAbort);
- reject(e);
- });
- }
- );
- });
-}
-
/** Read up to N bytes from a file, returning the utf-8 decoded text. */
export async function readFirstBytes(path: string, n: number): Promise {
const fd = await open(path, "r");
@@ -280,23 +241,6 @@ export function isCloudflareChallenge(text: string | null | undefined): boolean
);
}
-// ---------------------------------------------------------------------------
-// Temp-path cleanup — two variants
-// ---------------------------------------------------------------------------
-
-/** Variant A: substring-based parent dir extraction (ChatGPT, Claude, Perplexity, Notion) */
-async function cleanupTempPathSubstring(path: string): Promise {
- await unlink(path).catch(() => {});
- const dir = path.substring(0, path.lastIndexOf("/"));
- await rmdir(dir).catch(() => {});
-}
-
-/** Variant B: dirname-based parent dir extraction (Grok, LMArena) */
-async function cleanupTempPathDirname(path: string): Promise {
- await unlink(path).catch(() => {});
- await rmdir(dirname(path)).catch(() => {});
-}
-
async function readTextFileIfExists(path: string): Promise {
try {
return await readFile(path, "utf8");
@@ -305,302 +249,12 @@ async function readTextFileIfExists(path: string): Promise {
}
}
-// ---------------------------------------------------------------------------
-// TailFile — Variant A
-// Uint8Array enqueue, includes EOF symbol, substring cleanup
-// Used by: ChatGPT, Claude, Perplexity, Notion
-// ---------------------------------------------------------------------------
-
-function tailFileVariantA(
- path: string,
- eofSymbol: string,
- done: Promise,
- signal: AbortSignal | null = null,
- cleanupPath: string
-): ReadableStream {
- return new ReadableStream({
- async start(controller) {
- const fd = await open(path, "r");
- const buf = Buffer.alloc(64 * 1024);
- let offset = 0;
- let finished = false;
- let aborted = false;
- let upstreamError: Error | null = null;
-
- done.then(
- () => {
- finished = true;
- },
- (err) => {
- upstreamError = err instanceof Error ? err : new Error(String(err));
- finished = true;
- }
- );
-
- const onAbort = () => {
- aborted = true;
- };
- if (signal) {
- if (signal.aborted) aborted = true;
- else signal.addEventListener("abort", onAbort, { once: true });
- }
-
- let errored = false;
- try {
- while (!aborted) {
- const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
- if (bytesRead > 0) {
- const chunk = buf.subarray(0, bytesRead);
- offset += bytesRead;
- const text = chunk.toString("utf8");
- if (text.includes(eofSymbol)) {
- const cutAt = text.indexOf(eofSymbol) + eofSymbol.length;
- controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt)));
- break;
- }
- controller.enqueue(new Uint8Array(chunk));
- } else if (finished) {
- if (upstreamError) {
- controller.error(upstreamError);
- errored = true;
- }
- break;
- } else {
- await sleep(25);
- }
- }
- } catch (err) {
- controller.error(err);
- errored = true;
- } finally {
- if (signal) signal.removeEventListener("abort", onAbort);
- await fd.close().catch(() => {});
- await cleanupTempPathSubstring(cleanupPath);
- if (!errored) controller.close();
- }
- },
- });
-}
-
-// ---------------------------------------------------------------------------
-// TailFile — Variant B1
-// Buffer.from enqueue, excludes EOF symbol, inline drainRemaining loop
-// Used by: Grok
-// ---------------------------------------------------------------------------
-
-function tailFileVariantB1(
- path: string,
- eofSymbol: string,
- done: Promise,
- signal: AbortSignal | null = null,
- cleanupPath: string
-): ReadableStream {
- return new ReadableStream({
- async start(controller) {
- const fd = await open(path, "r");
- const buf = Buffer.alloc(64 * 1024);
- let offset = 0;
- let finished = false;
- let aborted = false;
- let upstreamError: Error | null = null;
-
- done.then(
- () => {
- finished = true;
- },
- (err) => {
- upstreamError = err instanceof Error ? err : new Error(String(err));
- finished = true;
- }
- );
-
- const onAbort = () => {
- aborted = true;
- };
- if (signal) {
- if (signal.aborted) aborted = true;
- else signal.addEventListener("abort", onAbort, { once: true });
- }
-
- let errored = false;
- try {
- while (!aborted) {
- const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
- if (bytesRead > 0) {
- const chunk = buf.subarray(0, bytesRead);
- offset += bytesRead;
- const text = chunk.toString("utf8");
-
- if (text.includes(eofSymbol)) {
- const beforeEof = text.substring(0, text.indexOf(eofSymbol));
- if (beforeEof) {
- controller.enqueue(Buffer.from(beforeEof, "utf8"));
- }
- controller.close();
- return;
- }
-
- controller.enqueue(Buffer.from(chunk));
- }
-
- if (finished) {
- // Request finished — drain any remaining bytes then close.
- while (true) {
- const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
- if (bytesRead === 0) break;
- const chunk = buf.subarray(0, bytesRead);
- offset += bytesRead;
- const text = chunk.toString("utf8");
-
- if (text.includes(eofSymbol)) {
- const beforeEof = text.substring(0, text.indexOf(eofSymbol));
- if (beforeEof) {
- controller.enqueue(Buffer.from(beforeEof, "utf8"));
- }
- controller.close();
- return;
- }
-
- controller.enqueue(Buffer.from(chunk));
- }
-
- if (upstreamError && !errored) {
- errored = true;
- controller.error(upstreamError);
- return;
- }
-
- controller.close();
- return;
- }
-
- await sleep(25);
- }
- } catch (err) {
- if (!errored) {
- errored = true;
- controller.error(err instanceof Error ? err : new Error(String(err)));
- }
- } finally {
- await fd.close().catch(() => {});
- await cleanupTempPathDirname(cleanupPath);
- if (signal) signal.removeEventListener("abort", onAbort);
- }
- },
- });
-}
-
-// ---------------------------------------------------------------------------
-// TailFile — Variant B2
-// Buffer.from enqueue, excludes EOF symbol, extracted helpers
-// Used by: LMArena
-// ---------------------------------------------------------------------------
-
-type FileHandle = Awaited>;
-
-function enqueueChunkMaybeEof(
- controller: ReadableStreamDefaultController,
- chunk: Buffer,
- eofSymbol: string
-): boolean {
- const text = chunk.toString("utf8");
- if (!text.includes(eofSymbol)) {
- controller.enqueue(Buffer.from(chunk));
- return false;
- }
- const beforeEof = text.substring(0, text.indexOf(eofSymbol));
- if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8"));
- controller.close();
- return true;
-}
-
-async function drainRemaining(
- fd: FileHandle,
- buf: Buffer,
- offsetRef: { offset: number },
- controller: ReadableStreamDefaultController,
- eofSymbol: string
-): Promise<"closed" | "drained"> {
- while (true) {
- const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset);
- if (bytesRead === 0) return "drained";
- const chunk = buf.subarray(0, bytesRead);
- offsetRef.offset += bytesRead;
- if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed";
- }
-}
-
-function tailFileVariantB2(
- path: string,
- eofSymbol: string,
- done: Promise,
- signal: AbortSignal | null = null,
- cleanupPath: string
-): ReadableStream {
- return new ReadableStream({
- async start(controller) {
- const fd = await open(path, "r");
- const buf = Buffer.alloc(64 * 1024);
- const offsetRef = { offset: 0 };
- let finished = false;
- let aborted = false;
- let upstreamError: Error | null = null;
- let errored = false;
-
- done.then(
- () => {
- finished = true;
- },
- (err) => {
- upstreamError = err instanceof Error ? err : new Error(String(err));
- finished = true;
- }
- );
-
- const onAbort = () => {
- aborted = true;
- };
- if (signal) {
- if (signal.aborted) aborted = true;
- else signal.addEventListener("abort", onAbort, { once: true });
- }
-
- try {
- while (!aborted) {
- const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset);
- if (bytesRead > 0) {
- const chunk = buf.subarray(0, bytesRead);
- offsetRef.offset += bytesRead;
- if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return;
- }
-
- if (!finished) {
- await sleep(25);
- continue;
- }
-
- const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol);
- if (drained === "closed") return;
- if (upstreamError && !errored) {
- errored = true;
- controller.error(upstreamError);
- return;
- }
- controller.close();
- return;
- }
- } catch (err) {
- if (!errored) {
- errored = true;
- controller.error(err instanceof Error ? err : new Error(String(err)));
- }
- } finally {
- await fd.close().catch(() => {});
- await cleanupTempPathDirname(cleanupPath);
- if (signal) signal.removeEventListener("abort", onAbort);
- }
- },
- });
+function sanitizeTlsFetchRejection(error: unknown): TlsResponseLike {
+ return {
+ status: 502,
+ headers: {},
+ body: sanitizeTlsClientErrorMessage(error),
+ };
}
// ---------------------------------------------------------------------------
@@ -612,43 +266,66 @@ function tailFileVariantB2(
* Uses dynamic `import("tls-client-node")` with `{ runtimeMode: "native" }`
* and `client.start()`, matching the original per-provider lifecycle.
*/
-export function createGetClient(config: {
- providerName: string;
- tlsProfile?: string;
-}): () => Promise<{
+type TlsClientConstructor = new (config: Record) => {
+ start: () => Promise;
request: (url: string, opts: Record) => Promise;
-}> {
- let clientPromise: Promise<{
- request: (url: string, opts: Record) => Promise;
- }> | null = null;
- let exitHookInstalled = false;
+ stop: () => Promise;
+};
- const installExitHook = (client: { stop: () => Promise }): void => {
- if (!exitHookInstalled) {
- exitHookInstalled = true;
- process.on("exit", () => {
- void client.stop();
+type TlsClientInstance = InstanceType;
+type TlsClientRequestClient = Pick;
+type ManagedTlsClientGetter = {
+ (): Promise;
+ invalidate: (expectedClient: TlsClientRequestClient) => Promise;
+};
+
+export function createGetClient(
+ config: {
+ providerName: string;
+ tlsProfile?: string;
+ },
+ dependencies: {
+ loadTlsClient?: () => Promise<{ TLSClient: TlsClientConstructor }>;
+ resolveNativeLibrary?: () => Promise;
+ startTimeoutMs?: number;
+ cleanupTimeoutMs?: number;
+ installExitHook?: (hook: () => void) => void;
+ } = {}
+): ManagedTlsClientGetter {
+ let clientPromise: Promise | null = null;
+ let activeClient: TlsClientInstance | null = null;
+ let invalidationPromise: Promise | null = null;
+ let invalidatingClient: TlsClientRequestClient | null = null;
+ const clientLeases = new WeakMap();
+ const cleanupTimeoutMs =
+ dependencies.cleanupTimeoutMs ?? DEFAULT_PARTIAL_CLIENT_CLEANUP_TIMEOUT_MS;
+
+ const releaseClientLeaseBounded = async (
+ lease: NativeTlsClientLease,
+ warning: string
+ ): Promise => {
+ try {
+ await releaseNativeTlsClientLease(lease, cleanupTimeoutMs);
+ } catch (stopErr) {
+ tlsClientLogger.warn(warning, {
+ provider: config.providerName,
+ error: sanitizeErrorMessage(stopErr),
});
}
};
- return async function getClient(): Promise<{
- request: (url: string, opts: Record) => Promise;
- }> {
+ const getClient = async function getClient(): Promise {
+ if (invalidationPromise) await invalidationPromise;
if (!clientPromise) {
clientPromise = (async () => {
- let TLSClientCtor: {
- new (config: Record): {
- start: () => Promise;
- request: (url: string, opts: Record) => Promise;
- stop: () => Promise;
- };
- };
+ let TLSClientCtor: TlsClientConstructor;
try {
// tls-client-node uses a native binary loaded at runtime.
// The dynamic import delays the binary load until first use — no
// point crashing startup on machines where it's not installed.
- const mod = await import("tls-client-node");
+ const mod = dependencies.loadTlsClient
+ ? await dependencies.loadTlsClient()
+ : ((await import("tls-client-node")) as { TLSClient: TlsClientConstructor });
TLSClientCtor = mod.TLSClient;
} catch {
throw new TlsClientUnavailableError(
@@ -657,11 +334,16 @@ export function createGetClient(config: {
}
let nativeLibraryPath: string;
try {
- nativeLibraryPath = await resolveVerifiedTlsClientNativeLibrary();
+ nativeLibraryPath = await (
+ dependencies.resolveNativeLibrary ?? resolveVerifiedTlsClientNativeLibrary
+ )();
} catch (err) {
- const detail = err instanceof Error ? err.message : String(err);
+ tlsClientLogger.warn("Native binary verification failed", {
+ provider: config.providerName,
+ error: sanitizeErrorMessage(err),
+ });
throw new TlsClientUnavailableError(
- `tls-client native binary verification failed for ${config.providerName}: ${detail}`
+ `tls-client native binary verification failed for ${config.providerName}`
);
}
const tlsOptions: Record = {
@@ -670,16 +352,115 @@ export function createGetClient(config: {
if (config.tlsProfile) {
tlsOptions.clientIdentifier = config.tlsProfile;
}
- const client = new TLSClientCtor(tlsOptions);
- // Start the native TLS client binding
- await client.start();
- installExitHook(client);
+ let client: InstanceType | undefined;
+ let clientLease: NativeTlsClientLease | null = null;
+ let rawStartPromise: Promise | null = null;
+ try {
+ client = new TLSClientCtor(tlsOptions);
+ const constructedClient = client;
+ clientLease = await acquireNativeTlsClientLease(
+ nativeLibraryPath,
+ () => constructedClient.stop(),
+ cleanupTimeoutMs
+ );
+ clientLeases.set(client, clientLease);
+ // Start the native TLS client binding.
+ rawStartPromise = client.start();
+ await raceWithTimeout(
+ rawStartPromise,
+ dependencies.startTimeoutMs ?? DEFAULT_CLIENT_START_TIMEOUT_MS,
+ null
+ );
+ activateNativeTlsClientLease(clientLease);
+ } catch (err) {
+ tlsClientLogger.warn("Native TLS client initialization failed", {
+ provider: config.providerName,
+ error: sanitizeErrorMessage(err),
+ });
+ if (client && clientLease) {
+ if (rawStartPromise && isTlsClientHangError(err)) {
+ const discardedLease = clientLease;
+ const settledLateStart = rawStartPromise.then(
+ () => {
+ activateNativeTlsClientLease(discardedLease);
+ },
+ (lateStartErr: unknown) => {
+ tlsClientLogger.warn("Timed-out native TLS client start later rejected", {
+ provider: config.providerName,
+ error: sanitizeErrorMessage(lateStartErr),
+ });
+ }
+ );
+ void settledLateStart.then(() =>
+ releaseClientLeaseBounded(
+ discardedLease,
+ "Late native TLS client initialization cleanup failed"
+ )
+ );
+ } else {
+ await releaseClientLeaseBounded(
+ clientLease,
+ "Partial native TLS client cleanup failed"
+ );
+ }
+ }
+ throw new TlsClientUnavailableError(
+ `tls-client native initialization failed for ${config.providerName}`
+ );
+ }
+ activeClient = client;
+ installNativeTlsClientExitHook(dependencies.installExitHook);
return client;
})();
}
- return clientPromise;
+ const pending = clientPromise;
+ try {
+ return await pending;
+ } catch (err) {
+ // A transient download/start failure must not poison this provider until
+ // process restart. Concurrent callers still share the same pending attempt;
+ // the next call creates a fresh one only after that attempt rejects.
+ if (clientPromise === pending) clientPromise = null;
+ throw err;
+ }
};
+
+ getClient.invalidate = async (expectedClient: TlsClientRequestClient): Promise => {
+ if (invalidationPromise && invalidatingClient === expectedClient) {
+ await invalidationPromise;
+ return;
+ }
+ if (activeClient !== expectedClient) return;
+
+ const pendingClient = clientPromise;
+ activeClient = null;
+ clientPromise = null;
+ invalidatingClient = expectedClient;
+
+ const cleanupPromise = (async () => {
+ let client: TlsClientInstance | null = null;
+ try {
+ client = pendingClient ? await pendingClient : null;
+ } catch {
+ // A rejected pending client never became active, so no client lease exists to release here.
+ return;
+ }
+ if (client !== expectedClient) return;
+ const lease = clientLeases.get(client);
+ if (!lease) return;
+
+ await releaseClientLeaseBounded(lease, "Native TLS client invalidation cleanup failed");
+ })();
+
+ invalidationPromise = cleanupPromise.finally(() => {
+ invalidationPromise = null;
+ invalidatingClient = null;
+ });
+ await invalidationPromise;
+ };
+
+ return getClient;
}
/**
@@ -694,17 +475,6 @@ export function resolveProxyUrl(domain: string, perCall: string | undefined): st
// Factory — creates provider-specific tlsFetch + helpers
// ---------------------------------------------------------------------------
-const CLEANUP_VARIANTS = {
- A: cleanupTempPathSubstring,
- B: cleanupTempPathDirname,
-} as const;
-
-const TAIL_FILE_VARIANTS = {
- A: tailFileVariantA,
- B1: tailFileVariantB1,
- B2: tailFileVariantB2,
-} as const;
-
export interface TlsClientModule {
tlsFetch: (url: string, options: TlsFetchOptions) => Promise;
__setTlsFetchOverrideForTesting: (
@@ -720,6 +490,13 @@ export interface TlsClientModule {
hardTimeoutMs?: number,
firstByteTimeoutMs?: number
) => Promise;
+ __tlsFetchNonStreamingForTesting?: (
+ client: { request: (url: string, opts: Record) => Promise },
+ url: string,
+ requestOptions: Record,
+ signal?: AbortSignal | null,
+ hardTimeoutMs?: number
+ ) => Promise;
}
/**
@@ -747,29 +524,15 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
const getClient = createGetClient({ providerName, tlsProfile });
- function resetClientCache(): void {
- // The getClient closure holds clientPromise — by design the only
- // reference is inside getClient's closure. After a hang we need
- // the next call to spawn a fresh binding. We achieve this by
- // clearing the local reference; the module-level tlsFetch will
- // re-read via getClient which recreates it.
- // Since getClient's clientPromise is a closure variable, we
- // re-create getClient itself:
- Object.assign(localState, {
- getClient: createGetClient({ providerName, tlsProfile }),
- });
- // Note: this is safe because only tlsFetch calls getClient.
- // A concurrent in-flight call holds its own reference.
+ async function resetClientCache(client: TlsClientRequestClient): Promise {
+ await getClient.invalidate(client);
}
- const localState: { getClient: typeof getClient } = { getClient };
-
let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null =
null;
- const tailFileFn = TAIL_FILE_VARIANTS[tailFileVariant];
-
- const cleanupFn = tailFileVariant === "A" ? cleanupTempPathSubstring : cleanupTempPathDirname;
+ const cleanupFn = (path: string): Promise =>
+ cleanupTlsClientStreamPath(tailFileVariant, path);
async function tlsFetchStreaming(
client: { request: (url: string, opts: Record) => Promise },
@@ -790,25 +553,29 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
streamOutputEOFSymbol: eofSymbol,
};
+ let nativeRequest: Promise;
+ try {
+ nativeRequest = client.request(url, streamOpts);
+ } catch (err) {
+ await cleanupFn(path);
+ throw createSanitizedTlsStreamError(err);
+ }
+
let resetOnHang = true;
- const requestPromise = raceWithTimeout(
- client.request(url, streamOpts),
- hardTimeoutMs,
- signal
- ).catch((err: unknown) => {
- if (resetOnHang && err instanceof TlsClientHangError) {
- resetClientCache();
- resetOnHang = false;
+ const requestPromise = raceWithTimeout(nativeRequest, hardTimeoutMs, signal).catch(
+ async (err: unknown) => {
+ if (resetOnHang && isTlsClientHangError(err)) {
+ resetOnHang = false;
+ await resetClientCache(client);
+ }
+ throw err;
}
- throw err;
- });
+ );
// Wait for the file to exist AND have at least one byte.
const ready = await waitForContent(path, firstByteMs, requestPromise);
if (!ready) {
- const r = await requestPromise.catch(
- (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike
- );
+ const r = await requestPromise.catch(sanitizeTlsFetchRejection);
const fileText = await readTextFileIfExists(path);
await cleanupFn(path);
return {
@@ -819,7 +586,13 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
};
}
- const peek = await readFirstBytes(path, 256);
+ let peek: string;
+ try {
+ peek = await readFirstBytes(path, 256);
+ } catch (err) {
+ await cleanupFn(path);
+ throw createSanitizedTlsStreamError(err);
+ }
if (responseValidation === "cf") {
// Cloudflare challenge check
@@ -845,9 +618,7 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
} else {
// SSE validation — if it doesn't look like SSE, return buffered
if (!looksLikeSse(peek)) {
- const r = await requestPromise.catch(
- (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike
- );
+ const r = await requestPromise.catch(sanitizeTlsFetchRejection);
const fileText = await readTextFileIfExists(path);
await cleanupFn(path);
return {
@@ -860,7 +631,13 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
}
// Looks valid — create streaming response.
- const stream = tailFileFn(path, eofSymbol, requestPromise, signal, path);
+ const stream = createTlsClientTailStream({
+ variant: tailFileVariant,
+ path,
+ eofSymbol,
+ done: requestPromise,
+ signal,
+ });
const contentType = responseValidation === "cf" ? "application/x-ndjson" : "text/event-stream";
@@ -871,6 +648,35 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
return { status: 200, headers, text: null, body: stream };
}
+ async function tlsFetchNonStreaming(
+ client: { request: (url: string, opts: Record) => Promise },
+ url: string,
+ requestOptions: Record,
+ signal: AbortSignal | null,
+ hardTimeoutMs: number
+ ): Promise {
+ let tlsResponse: TlsResponseLike;
+ try {
+ tlsResponse = await raceWithTimeout(
+ client.request(url, requestOptions),
+ hardTimeoutMs,
+ signal
+ );
+ } catch (err) {
+ if (isTlsClientHangError(err)) {
+ await resetClientCache(client);
+ }
+ throw err;
+ }
+ if (signal?.aborted) throw makeAbortError(signal);
+ return {
+ status: tlsResponse.status,
+ headers: toHeaders(tlsResponse.headers),
+ text: tlsResponse.body,
+ body: null,
+ };
+ }
+
async function tlsFetch(url: string, options: TlsFetchOptions = {}): Promise {
// Resolve proxyUrl early so test overrides and the real path both see it.
const resolvedProxyUrl = resolveProxyUrl(proxyDomainOverride ?? domain, options.proxyUrl);
@@ -879,7 +685,7 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
if (options.signal?.aborted) {
throw makeAbortError(options.signal);
}
- const client = await localState.getClient();
+ const client = await getClient();
if (options.signal?.aborted) {
throw makeAbortError(options.signal);
}
@@ -909,28 +715,13 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
);
}
- let tlsResponse: TlsResponseLike;
- try {
- tlsResponse = await raceWithTimeout(
- client.request(url, requestOptions),
- (options.timeoutMs ?? defaultTimeoutMs) + hardTimeoutGraceMs,
- options.signal ?? null
- );
- } catch (err) {
- if (err instanceof TlsClientHangError) {
- resetClientCache();
- }
- throw err;
- }
- if (options.signal?.aborted) {
- throw makeAbortError(options.signal);
- }
- return {
- status: tlsResponse.status,
- headers: toHeaders(tlsResponse.headers),
- text: tlsResponse.body,
- body: null,
- };
+ return await tlsFetchNonStreaming(
+ client,
+ url,
+ requestOptions,
+ options.signal ?? null,
+ (options.timeoutMs ?? defaultTimeoutMs) + hardTimeoutGraceMs
+ );
}
const module: TlsClientModule = {
@@ -964,6 +755,15 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule
firstByteMs
);
};
+ module.__tlsFetchNonStreamingForTesting = (
+ client,
+ url,
+ requestOptions,
+ signal = null,
+ hardTimeoutMs = defaultTimeoutMs + hardTimeoutGraceMs
+ ): Promise => {
+ return tlsFetchNonStreaming(client, url, requestOptions, signal, hardTimeoutMs);
+ };
}
return module;
diff --git a/open-sse/services/tlsClientDownloadDir.ts b/open-sse/services/tlsClientDownloadDir.ts
index 9daa9ea639..04d277e301 100644
--- a/open-sse/services/tlsClientDownloadDir.ts
+++ b/open-sse/services/tlsClientDownloadDir.ts
@@ -1,6 +1,9 @@
import { createHash, randomUUID } from "node:crypto";
-import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
-import { join } from "node:path";
+import { constants as fsConstants } from "node:fs";
+import { chmod, lstat, mkdir, open, realpath, rename, rm, rmdir } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
+import { setTimeout as delay } from "node:timers/promises";
import { resolveDataDir } from "@/lib/dataPaths";
import tlsClientNativeManifest from "../config/tlsClientNativeManifest.json";
@@ -10,19 +13,500 @@ type TlsClientNativeAsset = {
};
type FetchLike = (input: string | URL, init?: RequestInit) => Promise;
+type NativeResolverTestHooks = {
+ afterOpenedFileStat?: (filePath: string) => void | Promise;
+ afterInstallLockCreated?: (lockPath: string) => void | Promise;
+ afterInstallLockExists?: (lockPath: string) => void | Promise;
+ resolveTlsClientPackageJson?: () => string;
+};
const TLS_CLIENT_NATIVE_ASSETS = tlsClientNativeManifest.assets as Record<
string,
TlsClientNativeAsset
>;
+const INSTALL_LOCK_TIMEOUT_MS = 75_000;
+const STALE_INSTALL_LOCK_MS = 60_000;
+const MAX_NATIVE_ASSET_BYTES = 64 * 1024 * 1024;
+const NATIVE_ASSET_READ_CHUNK_BYTES = 64 * 1024;
+const MAX_INSTALL_LOCK_TOKEN_BYTES = 128;
+const moduleRequire = createRequire(import.meta.url);
-async function fileMatchesSha256(filePath: string, expectedSha256: string): Promise {
- try {
- const bytes = await readFile(filePath);
- return createHash("sha256").update(bytes).digest("hex") === expectedSha256;
- } catch {
- return false;
+function validateNativeAsset(asset: TlsClientNativeAsset): void {
+ if (
+ !asset.file ||
+ asset.file === "." ||
+ asset.file === ".." ||
+ basename(asset.file) !== asset.file ||
+ asset.file.includes("/") ||
+ asset.file.includes("\\") ||
+ asset.file.includes("\0")
+ ) {
+ throw new Error(`Invalid tls-client native asset path: ${JSON.stringify(asset.file)}`);
}
+ if (!/^[a-f0-9]{64}$/.test(asset.sha256)) {
+ throw new Error(`Invalid SHA-256 in tls-client native manifest for ${asset.file}`);
+ }
+}
+
+async function resolveSafeDirectory(
+ directoryPath: string,
+ trustedRoot: string = directoryPath
+): Promise {
+ const absolutePath = resolve(directoryPath);
+ const absoluteRoot = resolve(trustedRoot);
+ const relativePath = relative(absoluteRoot, absolutePath);
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
+ throw new Error(`Unsafe tls-client native directory outside trusted root: ${absolutePath}`);
+ }
+
+ // The configured DATA_DIR itself is an operator-controlled trust anchor and
+ // may legitimately be a symlink (for example to a mounted volume). Every
+ // component created below it must be a real directory, never another link.
+ await mkdir(absoluteRoot, { recursive: true, mode: 0o700 });
+ const canonicalRoot = await realpath(absoluteRoot);
+ const rootStats = await lstat(canonicalRoot);
+ if (!rootStats.isDirectory()) {
+ throw new Error(`Unsafe tls-client trusted directory: ${absoluteRoot}`);
+ }
+
+ let currentPath = absoluteRoot;
+ const components = relativePath ? relativePath.split(sep).filter(Boolean) : [];
+ for (const component of components) {
+ currentPath = join(currentPath, component);
+ try {
+ await mkdir(currentPath, { mode: 0o700 });
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
+ }
+ const stats = await lstat(currentPath);
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
+ throw new Error(`Unsafe tls-client native directory component: ${currentPath}`);
+ }
+ }
+
+ const canonicalPath = await realpath(absolutePath);
+ const expectedCanonicalPath = resolve(canonicalRoot, relativePath);
+ if (canonicalPath !== expectedCanonicalPath) {
+ throw new Error(`Unsafe tls-client native directory redirection: ${absolutePath}`);
+ }
+ await chmod(canonicalPath, 0o700);
+ return canonicalPath;
+}
+
+function sameFileIdentity(
+ left: { dev: number | bigint; ino: number | bigint },
+ right: { dev: number | bigint; ino: number | bigint }
+): boolean {
+ return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
+}
+
+async function readInstallLockClaim(lockPath: string) {
+ let handle;
+ try {
+ handle = await open(
+ lockPath,
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0)
+ );
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT" || code === "ELOOP" || code === "EISDIR") return undefined;
+ throw err;
+ }
+
+ try {
+ const openedStats = await handle.stat({ bigint: true });
+ if (
+ !openedStats.isFile() ||
+ openedStats.size <= 0n ||
+ openedStats.size > BigInt(MAX_INSTALL_LOCK_TOKEN_BYTES)
+ ) {
+ return undefined;
+ }
+
+ const bytes = Buffer.alloc(Number(openedStats.size));
+ let bytesReadTotal = 0;
+ while (bytesReadTotal < bytes.length) {
+ const { bytesRead } = await handle.read(
+ bytes,
+ bytesReadTotal,
+ bytes.length - bytesReadTotal,
+ bytesReadTotal
+ );
+ if (bytesRead === 0) break;
+ bytesReadTotal += bytesRead;
+ }
+
+ const verifiedStats = await handle.stat({ bigint: true });
+ if (
+ bytesReadTotal !== bytes.length ||
+ verifiedStats.size !== openedStats.size ||
+ !sameFileIdentity(openedStats, verifiedStats)
+ ) {
+ return undefined;
+ }
+
+ let pathStats;
+ try {
+ pathStats = await lstat(lockPath, { bigint: true });
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
+ throw err;
+ }
+ if (
+ pathStats.isSymbolicLink() ||
+ !pathStats.isFile() ||
+ !sameFileIdentity(openedStats, pathStats)
+ ) {
+ return undefined;
+ }
+
+ return { stats: openedStats, token: bytes.toString("utf8") };
+ } finally {
+ await handle.close();
+ }
+}
+
+/**
+ * Read and verify one regular file without following a final-component symlink.
+ * The second lstat closes the ordinary check/read path-swap window: callers only
+ * receive bytes when the pathname still identifies the inode that was opened.
+ */
+async function readVerifiedRegularFile(
+ filePath: string,
+ expectedSha256: string,
+ {
+ normalizeMode = false,
+ afterOpenedFileStat,
+ }: {
+ normalizeMode?: boolean;
+ afterOpenedFileStat?: NativeResolverTestHooks["afterOpenedFileStat"];
+ } = {}
+): Promise {
+ let pathStats;
+ try {
+ pathStats = await lstat(filePath);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
+ throw err;
+ }
+ if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
+ throw new Error(`Unsafe tls-client native cache entry (symlink/non-regular file): ${filePath}`);
+ }
+
+ let handle;
+ try {
+ handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ELOOP") {
+ throw new Error(`Unsafe tls-client native cache entry (symlink): ${filePath}`);
+ }
+ throw err;
+ }
+
+ try {
+ const openedStats = await handle.stat();
+ if (!openedStats.isFile()) {
+ throw new Error(`Unsafe tls-client native cache entry (not a regular file): ${filePath}`);
+ }
+ if (openedStats.size > MAX_NATIVE_ASSET_BYTES) {
+ throw new Error(`Local tls-client native asset exceeds the 64 MiB limit: ${filePath}`);
+ }
+ await afterOpenedFileStat?.(filePath);
+
+ const readBuffer = Buffer.alloc(openedStats.size);
+ let bytesReadTotal = 0;
+ while (bytesReadTotal < readBuffer.length) {
+ const bytesToRead = Math.min(
+ NATIVE_ASSET_READ_CHUNK_BYTES,
+ readBuffer.length - bytesReadTotal
+ );
+ const { bytesRead } = await handle.read(
+ readBuffer,
+ bytesReadTotal,
+ bytesToRead,
+ bytesReadTotal
+ );
+ if (bytesRead === 0) break;
+ bytesReadTotal += bytesRead;
+ }
+ const bytes = readBuffer.subarray(0, bytesReadTotal);
+ const verifiedStats = await handle.stat();
+ if (verifiedStats.size > MAX_NATIVE_ASSET_BYTES) {
+ throw new Error(`Local tls-client native asset exceeds the 64 MiB limit: ${filePath}`);
+ }
+ if (
+ !verifiedStats.isFile() ||
+ !sameFileIdentity(openedStats, verifiedStats) ||
+ verifiedStats.size !== openedStats.size ||
+ bytes.length !== verifiedStats.size
+ ) {
+ throw new Error(
+ `Unsafe tls-client native cache entry changed during verification: ${filePath}`
+ );
+ }
+ const currentStats = await lstat(filePath);
+ if (
+ currentStats.isSymbolicLink() ||
+ !currentStats.isFile() ||
+ !sameFileIdentity(openedStats, currentStats)
+ ) {
+ throw new Error(
+ `Unsafe tls-client native cache entry changed during verification: ${filePath}`
+ );
+ }
+ if (createHash("sha256").update(bytes).digest("hex") !== expectedSha256) return undefined;
+ if (normalizeMode && process.platform !== "win32") await handle.chmod(0o500);
+ return bytes;
+ } finally {
+ await handle.close();
+ }
+}
+
+async function installVerifiedBytes(
+ downloadDir: string,
+ asset: TlsClientNativeAsset,
+ bytes: Buffer
+): Promise {
+ const actualSha256 = createHash("sha256").update(bytes).digest("hex");
+ if (actualSha256 !== asset.sha256) {
+ throw new Error(
+ `SHA-256 mismatch for tls-client v${tlsClientNativeManifest.version} native asset ` +
+ `${asset.file}: expected ${asset.sha256}, received ${actualSha256}`
+ );
+ }
+
+ const safeDir = await resolveSafeDirectory(downloadDir);
+ const destinationPath = join(safeDir, asset.file);
+ if (dirname(destinationPath) !== safeDir) {
+ throw new Error(`Invalid tls-client native asset path: ${asset.file}`);
+ }
+
+ const existingBytes = await readVerifiedRegularFile(destinationPath, asset.sha256, {
+ normalizeMode: true,
+ });
+ if (existingBytes) return destinationPath;
+
+ const temporaryPath = join(safeDir, `.${asset.file}.${process.pid}.${randomUUID()}.tmp`);
+ let handle;
+ try {
+ handle = await open(
+ temporaryPath,
+ fsConstants.O_WRONLY |
+ fsConstants.O_CREAT |
+ fsConstants.O_EXCL |
+ (fsConstants.O_NOFOLLOW ?? 0),
+ 0o500
+ );
+ await handle.writeFile(bytes);
+ await handle.sync();
+ await handle.close();
+ handle = undefined;
+
+ if (!(await readVerifiedRegularFile(temporaryPath, asset.sha256, { normalizeMode: true }))) {
+ throw new Error(`SHA-256 mismatch after writing ${asset.file}`);
+ }
+
+ // POSIX rename replaces atomically. No rm-before-rename window means six
+ // provider clients can initialize together without deleting each other's
+ // verified result. On Windows, accept a concurrently installed valid file.
+ try {
+ await rename(temporaryPath, destinationPath);
+ } catch (err) {
+ if (!(await readVerifiedRegularFile(destinationPath, asset.sha256, { normalizeMode: true })))
+ throw err;
+ }
+
+ if (!(await readVerifiedRegularFile(destinationPath, asset.sha256, { normalizeMode: true }))) {
+ throw new Error(`SHA-256 mismatch after installing ${asset.file}`);
+ }
+ return destinationPath;
+ } finally {
+ await handle?.close();
+ await rm(temporaryPath, { force: true });
+ }
+}
+
+/**
+ * Local-filesystem lease for one native-asset install. O_EXCL + a bounded nonce
+ * fences ordinary owner replacement while the handle is live. This is not a
+ * distributed NFS/CIFS lock: after the 60-second stale lease, Node has no
+ * portable compare-and-unlink primitive, so digest checks and atomic install
+ * remain the final safety boundary if an event-loop stall allows overlap.
+ */
+async function withNativeAssetInstallLock(
+ destinationPath: string,
+ expectedSha256: string,
+ operation: () => Promise,
+ testHooks?: NativeResolverTestHooks
+): Promise {
+ const lockPath = `${destinationPath}.lock`;
+ const startedAt = Date.now();
+ let acquiredLockHandle: Awaited> | undefined;
+ let acquiredLockToken: string | undefined;
+ let afterInstallLockCreated = testHooks?.afterInstallLockCreated;
+
+ while (!acquiredLockHandle) {
+ let createdLockHandle: Awaited> | undefined;
+ try {
+ createdLockHandle = await open(
+ lockPath,
+ fsConstants.O_RDWR |
+ fsConstants.O_CREAT |
+ fsConstants.O_EXCL |
+ (fsConstants.O_NOFOLLOW ?? 0),
+ 0o600
+ );
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
+ }
+
+ if (createdLockHandle) {
+ try {
+ const createdToken = randomUUID();
+ await createdLockHandle.writeFile(createdToken);
+ await createdLockHandle.sync();
+ const createdStats = await createdLockHandle.stat({ bigint: true });
+ if (!createdStats.isFile()) {
+ throw new Error(`Unsafe tls-client install lock: ${lockPath}`);
+ }
+
+ // Capture ownership from the O_EXCL handle before exposing the test
+ // scheduling seam. A pathname lstat alone could capture a replacement
+ // lock created by another process after this owner was descheduled.
+ const afterCreated = afterInstallLockCreated;
+ afterInstallLockCreated = undefined;
+ if (afterCreated) await afterCreated(lockPath);
+
+ const currentClaim = await readInstallLockClaim(lockPath);
+ if (
+ currentClaim &&
+ currentClaim.token === createdToken &&
+ sameFileIdentity(createdStats, currentClaim.stats)
+ ) {
+ acquiredLockHandle = createdLockHandle;
+ acquiredLockToken = createdToken;
+ createdLockHandle = undefined;
+ }
+ } finally {
+ await createdLockHandle?.close();
+ }
+ if (acquiredLockHandle) break;
+ continue;
+ }
+
+ const afterExists = testHooks?.afterInstallLockExists;
+ if (afterExists) await afterExists(lockPath);
+ let lockStats;
+ try {
+ lockStats = await lstat(lockPath);
+ } catch (lockErr) {
+ if ((lockErr as NodeJS.ErrnoException).code === "ENOENT") continue;
+ throw lockErr;
+ }
+ if (lockStats.isSymbolicLink() || (!lockStats.isDirectory() && !lockStats.isFile())) {
+ throw new Error(`Unsafe tls-client install lock: ${lockPath}`);
+ }
+ if (await readVerifiedRegularFile(destinationPath, expectedSha256, { normalizeMode: true })) {
+ return destinationPath;
+ }
+ if (Date.now() - lockStats.mtimeMs > STALE_INSTALL_LOCK_MS) {
+ const removed = await (lockStats.isDirectory() ? rmdir(lockPath) : rm(lockPath)).then(
+ () => true,
+ () => false
+ );
+ if (removed) continue;
+ }
+ if (Date.now() - startedAt >= INSTALL_LOCK_TIMEOUT_MS) {
+ if (await readVerifiedRegularFile(destinationPath, expectedSha256, { normalizeMode: true })) {
+ return destinationPath;
+ }
+ throw new Error(`Timed out waiting for tls-client native install lock: ${lockPath}`);
+ }
+ await delay(25);
+ }
+
+ const ownerHandle = acquiredLockHandle;
+ const ownerToken = acquiredLockToken;
+ if (!ownerHandle || !ownerToken) {
+ await ownerHandle?.close();
+ throw new Error(`Invalid tls-client install lock ownership: ${lockPath}`);
+ }
+ try {
+ if (await readVerifiedRegularFile(destinationPath, expectedSha256, { normalizeMode: true })) {
+ return destinationPath;
+ }
+ return await operation();
+ } finally {
+ try {
+ const ownerStats = await ownerHandle.stat({ bigint: true });
+ const currentClaim = await readInstallLockClaim(lockPath);
+ if (
+ currentClaim &&
+ currentClaim.token === ownerToken &&
+ sameFileIdentity(ownerStats, currentClaim.stats)
+ ) {
+ await rm(lockPath);
+ }
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
+ } finally {
+ await ownerHandle.close();
+ }
+ }
+}
+
+async function readBoundedNativeAssetResponse(response: Response): Promise {
+ const declaredLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_NATIVE_ASSET_BYTES) {
+ throw new Error("Pinned tls-client native asset exceeds the 64 MiB download limit");
+ }
+ if (!response.body) return Buffer.alloc(0);
+
+ const reader = response.body.getReader();
+ const chunks: Buffer[] = [];
+ let totalBytes = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ totalBytes += value.byteLength;
+ if (totalBytes > MAX_NATIVE_ASSET_BYTES) {
+ // Cancellation is best-effort cleanup; its failure must not mask the size-limit error.
+ await reader.cancel("native asset exceeds download limit").catch(() => {});
+ throw new Error("Pinned tls-client native asset exceeds the 64 MiB download limit");
+ }
+ chunks.push(Buffer.from(value));
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ return Buffer.concat(chunks, totalBytes);
+}
+
+function resolveInstalledTlsClientSeedDir(testHooks?: NativeResolverTestHooks): string | undefined {
+ try {
+ const packageJsonPath =
+ testHooks?.resolveTlsClientPackageJson?.() ??
+ moduleRequire.resolve("tls-client-node/package.json");
+ return join(dirname(packageJsonPath), "bin");
+ } catch {
+ // The seed package is optional; configured/runtime paths are still scanned and verified below.
+ return undefined;
+ }
+}
+
+function resolveBundledSeedDirs(testHooks?: NativeResolverTestHooks): string[] {
+ const configured = process.env.OMNIROUTE_TLS_CLIENT_SEED_DIR?.trim();
+ const installedPackageSeedDir = resolveInstalledTlsClientSeedDir(testHooks);
+ return [
+ ...new Set([
+ ...(configured ? [configured] : []),
+ join(process.cwd(), "runtime-assets", "tls-client", "bin"),
+ join(process.cwd(), "data", "tls-client", "bin"),
+ ...(installedPackageSeedDir ? [installedPackageSeedDir] : []),
+ join(process.cwd(), "node_modules", "tls-client-node", "bin"),
+ ]),
+ ];
}
/**
@@ -45,75 +529,67 @@ export async function resolveVerifiedTlsClientNativeLibrary({
platform = process.platform,
arch = process.arch,
asset,
- downloadDir = resolveTlsClientDownloadDir(),
+ downloadDir,
+ seedDirs,
fetchImpl = globalThis.fetch,
+ testHooks,
}: {
platform?: NodeJS.Platform;
arch?: string;
asset?: TlsClientNativeAsset;
downloadDir?: string;
+ seedDirs?: string[];
fetchImpl?: FetchLike;
+ /** Deterministic filesystem-race seam used only by native resolver tests. */
+ testHooks?: NativeResolverTestHooks;
} = {}): Promise {
const expectedAsset = asset ?? TLS_CLIENT_NATIVE_ASSETS[`${platform}-${arch}`];
if (!expectedAsset) {
throw new Error(`Unsupported platform for tls-client native asset: ${platform}/${arch}`);
}
+ validateNativeAsset(expectedAsset);
- const destinationPath = join(downloadDir, expectedAsset.file);
- if (await fileMatchesSha256(destinationPath, expectedAsset.sha256)) {
- return destinationPath;
- }
-
- const assetUrl =
- `https://github.com/bogdanfinn/tls-client/releases/download/v${tlsClientNativeManifest.version}/` +
- expectedAsset.file;
- const response = await fetchImpl(assetUrl, {
- redirect: "follow",
- signal: AbortSignal.timeout(30_000),
+ const requestedDownloadDir = downloadDir ?? resolveTlsClientDownloadDir();
+ const trustedDownloadRoot = downloadDir === undefined ? resolveDataDir() : requestedDownloadDir;
+ const safeDownloadDir = await resolveSafeDirectory(requestedDownloadDir, trustedDownloadRoot);
+ const resolvedSeedDirs = seedDirs ?? resolveBundledSeedDirs(testHooks);
+ const destinationPath = join(safeDownloadDir, expectedAsset.file);
+ const cachedBytes = await readVerifiedRegularFile(destinationPath, expectedAsset.sha256, {
+ normalizeMode: true,
+ afterOpenedFileStat: testHooks?.afterOpenedFileStat,
});
- if (!response.ok) {
- throw new Error(
- `Failed to download pinned tls-client v${tlsClientNativeManifest.version} native asset: ` +
- `${response.status} ${response.statusText}`
- );
- }
+ if (cachedBytes) return destinationPath;
- const bytes = Buffer.from(await response.arrayBuffer());
- const actualSha256 = createHash("sha256").update(bytes).digest("hex");
- if (actualSha256 !== expectedAsset.sha256) {
- throw new Error(
- `SHA-256 mismatch for tls-client v${tlsClientNativeManifest.version} native asset ` +
- `${expectedAsset.file}: ` +
- `expected ${expectedAsset.sha256}, received ${actualSha256}`
- );
- }
+ return withNativeAssetInstallLock(
+ destinationPath,
+ expectedAsset.sha256,
+ async () => {
+ for (const seedDir of resolvedSeedDirs) {
+ const seedPath = join(resolve(seedDir), expectedAsset.file);
+ if (seedPath === destinationPath) continue;
+ const seedBytes = await readVerifiedRegularFile(seedPath, expectedAsset.sha256);
+ if (seedBytes) return installVerifiedBytes(safeDownloadDir, expectedAsset, seedBytes);
+ }
- await mkdir(downloadDir, { recursive: true });
- const temporaryPath = join(
- downloadDir,
- `.${expectedAsset.file}.${process.pid}.${randomUUID()}.tmp`
+ const assetUrl =
+ `https://github.com/bogdanfinn/tls-client/releases/download/v${tlsClientNativeManifest.version}/` +
+ expectedAsset.file;
+ const response = await fetchImpl(assetUrl, {
+ redirect: "follow",
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (!response.ok) {
+ throw new Error(
+ `Failed to download pinned tls-client v${tlsClientNativeManifest.version} native asset: ` +
+ `${response.status}`
+ );
+ }
+
+ const bytes = await readBoundedNativeAssetResponse(response);
+ return installVerifiedBytes(safeDownloadDir, expectedAsset, bytes);
+ },
+ testHooks
);
- try {
- await writeFile(temporaryPath, bytes, { mode: 0o755 });
- if (!(await fileMatchesSha256(temporaryPath, expectedAsset.sha256))) {
- throw new Error(`SHA-256 mismatch after writing ${expectedAsset.file}`);
- }
-
- await rm(destinationPath, { force: true });
- try {
- await rename(temporaryPath, destinationPath);
- } catch (err) {
- // A concurrent process may have installed the same verified asset first.
- if (!(await fileMatchesSha256(destinationPath, expectedAsset.sha256))) throw err;
- }
- if (!(await fileMatchesSha256(destinationPath, expectedAsset.sha256))) {
- await rm(destinationPath, { force: true });
- throw new Error(`SHA-256 mismatch after installing ${expectedAsset.file}`);
- }
- return destinationPath;
- } finally {
- await rm(temporaryPath, { force: true });
- }
}
export function buildNativeTlsClientOptions(nativeLibraryPath?: string): {
diff --git a/open-sse/services/tlsClientLifecycleRegistry.ts b/open-sse/services/tlsClientLifecycleRegistry.ts
new file mode 100644
index 0000000000..1f94a89f55
--- /dev/null
+++ b/open-sse/services/tlsClientLifecycleRegistry.ts
@@ -0,0 +1,273 @@
+/**
+ * Process-wide ownership for tls-client-node's native backend.
+ *
+ * The dependency caches one binding per native library path and implements
+ * `TLSClient.stop()` as binding-wide `destroyAll()`. A per-provider singleton
+ * therefore cannot decide independently when stop is safe. This registry keeps
+ * that global fact behind a process-wide lease interface.
+ */
+
+import { realpath } from "node:fs/promises";
+import { resolve } from "node:path";
+
+declare const nativeTlsLeaseBrand: unique symbol;
+
+export type NativeTlsClientLease = {
+ readonly [nativeTlsLeaseBrand]: true;
+};
+
+type InternalLease = NativeTlsClientLease & {
+ cleanup: () => Promise;
+ cleanupTimeoutMs: number;
+ completion: Promise | null;
+ state: NativeLibraryState;
+ status: "pending" | "active" | "released";
+};
+
+type NativeLibraryState = {
+ cleanupBarrier: Promise | null;
+ cleanupCandidate: (() => Promise) | null;
+ cleanupFailed: boolean;
+ cleanupToken: symbol | null;
+ owners: Set;
+};
+
+type NativeLibraryPathCanonicalizer = (nativeLibraryPath: string) => Promise;
+type BeforeOwnerReservationHook = () => Promise;
+
+const nativeLibraryStates = new Map();
+const RESOLVED_VOID = Promise.resolve();
+let exitCleanupStarted = false;
+let exitHookInstalled = false;
+
+async function canonicalizeNativeLibraryPath(nativeLibraryPath: string): Promise {
+ const absolutePath = resolve(nativeLibraryPath);
+ try {
+ return await realpath(absolutePath);
+ } catch {
+ // Tests and early failures may use a path that cannot be resolved yet. The
+ // absolute spelling is still a stable key for all callers using that path.
+ return absolutePath;
+ }
+}
+
+function getNativeLibraryState(
+ states: Map,
+ canonicalPath: string
+): NativeLibraryState {
+ let state = states.get(canonicalPath);
+ if (!state) {
+ state = {
+ cleanupBarrier: null,
+ cleanupCandidate: null,
+ cleanupFailed: false,
+ cleanupToken: null,
+ owners: new Set(),
+ };
+ states.set(canonicalPath, state);
+ }
+ return state;
+}
+
+function waitBounded(promise: Promise, timeoutMs: number): Promise {
+ return new Promise((resolvePromise, rejectPromise) => {
+ const timer = setTimeout(
+ () => rejectPromise(new Error("Native TLS cleanup did not finish within its safety budget")),
+ Math.max(1, timeoutMs)
+ );
+ timer.unref?.();
+ void promise.then(
+ () => {
+ clearTimeout(timer);
+ resolvePromise();
+ },
+ (error: unknown) => {
+ clearTimeout(timer);
+ rejectPromise(error);
+ }
+ );
+ });
+}
+
+function startCleanup(
+ state: NativeLibraryState,
+ cleanup: () => Promise,
+ timeoutMs: number
+): Promise {
+ let rawCleanup: Promise;
+ try {
+ rawCleanup = Promise.resolve(cleanup());
+ } catch (error) {
+ rawCleanup = Promise.reject(error);
+ }
+
+ const cleanupToken = Symbol("native-tls-cleanup");
+ state.cleanupBarrier = rawCleanup;
+ state.cleanupToken = cleanupToken;
+ void rawCleanup.then(
+ () => {
+ if (state.cleanupToken !== cleanupToken) return;
+ state.cleanupBarrier = null;
+ state.cleanupCandidate = null;
+ state.cleanupFailed = false;
+ state.cleanupToken = null;
+ },
+ () => {
+ if (state.cleanupToken !== cleanupToken) return;
+ state.cleanupBarrier = null;
+ state.cleanupFailed = true;
+ state.cleanupToken = null;
+ }
+ );
+
+ return waitBounded(rawCleanup, timeoutMs);
+}
+
+/**
+ * Acquire ownership before starting a native client.
+ *
+ * A cleanup that already became the last-owner cleanup is an ordering barrier:
+ * no successor may start until its raw Promise settles. Callers wait only for
+ * their bounded safety budget and fail closed if the barrier is still pending.
+ */
+async function acquireNativeTlsClientLeaseFromRegistry(
+ states: Map,
+ canonicalizePath: NativeLibraryPathCanonicalizer,
+ nativeLibraryPath: string,
+ cleanup: () => Promise,
+ cleanupTimeoutMs: number,
+ beforeOwnerReservation?: BeforeOwnerReservationHook
+): Promise {
+ const canonicalPath = await canonicalizePath(nativeLibraryPath);
+ const state = getNativeLibraryState(states, canonicalPath);
+ const lease = {
+ cleanup,
+ cleanupTimeoutMs,
+ completion: null,
+ state,
+ status: "pending",
+ } as InternalLease;
+ let reservationHookInvoked = false;
+
+ while (true) {
+ if (state.cleanupFailed) {
+ throw new Error("Native TLS cleanup failed; refusing to start a new owner");
+ }
+ const cleanupBarrier = state.cleanupBarrier;
+ if (cleanupBarrier) {
+ try {
+ await waitBounded(cleanupBarrier, cleanupTimeoutMs);
+ } catch {
+ throw new Error("Native TLS cleanup is incomplete; refusing to start a new owner");
+ }
+ continue;
+ }
+
+ if (!reservationHookInvoked && beforeOwnerReservation) {
+ reservationHookInvoked = true;
+ await beforeOwnerReservation();
+ continue;
+ }
+
+ // No await is allowed between this final state check and the reservation.
+ // A last-owner release is synchronous too, so exactly one side wins: the
+ // new owner is counted first, or its acquire observes the raw cleanup.
+ if (state.cleanupFailed || state.cleanupBarrier) continue;
+ state.owners.add(lease);
+ return lease;
+ }
+}
+
+export async function acquireNativeTlsClientLease(
+ nativeLibraryPath: string,
+ cleanup: () => Promise,
+ cleanupTimeoutMs: number
+): Promise {
+ return acquireNativeTlsClientLeaseFromRegistry(
+ nativeLibraryStates,
+ canonicalizeNativeLibraryPath,
+ nativeLibraryPath,
+ cleanup,
+ cleanupTimeoutMs
+ );
+}
+
+/** Create an isolated acquirer for deterministic registry race tests. */
+export function createNativeTlsClientLeaseAcquirerForTesting(
+ beforeOwnerReservation: BeforeOwnerReservationHook
+): (
+ nativeLibraryPath: string,
+ cleanup: () => Promise,
+ cleanupTimeoutMs: number
+) => Promise {
+ const states = new Map();
+ return (nativeLibraryPath, cleanup, cleanupTimeoutMs) =>
+ acquireNativeTlsClientLeaseFromRegistry(
+ states,
+ async (path) => resolve(path),
+ nativeLibraryPath,
+ cleanup,
+ cleanupTimeoutMs,
+ beforeOwnerReservation
+ );
+}
+
+export function activateNativeTlsClientLease(lease: NativeTlsClientLease): void {
+ const internalLease = lease as InternalLease;
+ if (internalLease.status !== "pending") return;
+ internalLease.status = "active";
+ internalLease.state.cleanupCandidate = internalLease.cleanup;
+}
+
+/** Install one coordinated process-exit hook for every native library path. */
+export function installNativeTlsClientExitHook(
+ installExitHook: (hook: () => void) => void = (hook) => process.on("exit", hook)
+): void {
+ if (exitHookInstalled) return;
+
+ const hook = () => {
+ if (exitCleanupStarted) return;
+ exitCleanupStarted = true;
+ for (const state of nativeLibraryStates.values()) {
+ for (const lease of [...state.owners]) {
+ // `exit` cannot await Promises; release already invokes the final native stop synchronously.
+ void releaseNativeTlsClientLease(lease, lease.cleanupTimeoutMs).catch(() => {});
+ }
+ }
+ };
+
+ installExitHook(hook);
+ exitHookInstalled = true;
+}
+
+/**
+ * Release one owner. The preserved active cleanup candidate is invoked only
+ * for the final owner. A timed-out start remains pending until its raw start
+ * settles; there is deliberately no TTL or pending-owner cap in this wrapper.
+ *
+ * Invocation is synchronous so process-exit hooks can at least begin native
+ * cleanup; Promise completion remains best-effort during exit.
+ */
+export function releaseNativeTlsClientLease(
+ lease: NativeTlsClientLease,
+ cleanupTimeoutMs: number
+): Promise {
+ const internalLease = lease as InternalLease;
+ if (internalLease.status === "released") {
+ return internalLease.completion ?? RESOLVED_VOID;
+ }
+
+ const wasActive = internalLease.status === "active";
+ internalLease.status = "released";
+ internalLease.state.owners.delete(internalLease);
+ if (wasActive) internalLease.state.cleanupCandidate = internalLease.cleanup;
+ if (internalLease.state.owners.size > 0) {
+ internalLease.completion = RESOLVED_VOID;
+ return RESOLVED_VOID;
+ }
+
+ const cleanup = internalLease.state.cleanupCandidate ?? internalLease.cleanup;
+ const completion = startCleanup(internalLease.state, cleanup, cleanupTimeoutMs);
+ internalLease.completion = completion;
+ return completion;
+}
diff --git a/open-sse/services/tlsClientStream.ts b/open-sse/services/tlsClientStream.ts
new file mode 100644
index 0000000000..f8444d8060
--- /dev/null
+++ b/open-sse/services/tlsClientStream.ts
@@ -0,0 +1,416 @@
+/**
+ * File-backed stream adapter shared by the native TLS client wrappers.
+ *
+ * The native binding writes response bytes to a temporary file. This module
+ * tails that file, applies each provider family's EOF convention, owns cleanup,
+ * and guarantees that every terminal stream error is safe for public sinks.
+ */
+
+import { open, rm, rmdir } from "node:fs/promises";
+import { dirname } from "node:path";
+
+import { sanitizeErrorMessage } from "../utils/error.ts";
+
+export type TlsClientTailVariant = "A" | "B1" | "B2";
+
+type FileHandle = Awaited>;
+
+const TLS_CLIENT_REQUEST_FAILED = "TLS client request failed";
+const SAFE_TLS_ERROR_NAMES = new Set([
+ "Error",
+ "AbortError",
+ "TimeoutError",
+ "BodyTimeoutError",
+ "TlsClientHangError",
+ "NativeTlsError",
+]);
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function cleanupTempPathSubstring(path: string): Promise {
+ // This request-owned scratch path is never reused; cleanup cannot replace the stream outcome.
+ await rm(path, { force: true, recursive: true }).catch(() => {});
+ const dir = path.substring(0, path.lastIndexOf("/"));
+ // This request-owned directory is never reused; removal failure cannot alter response semantics.
+ await rmdir(dir).catch(() => {});
+}
+
+async function cleanupTempPathDirname(path: string): Promise {
+ // This request-owned scratch path is never reused; cleanup cannot replace the stream outcome.
+ await rm(path, { force: true, recursive: true }).catch(() => {});
+ // This request-owned directory is never reused; removal failure cannot alter response semantics.
+ await rmdir(dirname(path)).catch(() => {});
+}
+
+export async function cleanupTlsClientStreamPath(
+ variant: TlsClientTailVariant,
+ path: string
+): Promise {
+ if (variant === "A") await cleanupTempPathSubstring(path);
+ else await cleanupTempPathDirname(path);
+}
+
+function projectTlsClientError(error: unknown): { message: string; name: string } {
+ let rawMessage: unknown = error;
+ let name = "Error";
+ try {
+ if (error instanceof Error) {
+ try {
+ rawMessage = error.message;
+ } catch {
+ rawMessage = undefined;
+ }
+ try {
+ const rawName: unknown = error.name;
+ if (typeof rawName === "string" && SAFE_TLS_ERROR_NAMES.has(rawName)) {
+ name = rawName;
+ }
+ } catch {
+ name = "Error";
+ }
+ }
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ rawMessage = undefined;
+ }
+ const sanitized = rawMessage == null ? "" : sanitizeErrorMessage(rawMessage);
+ const message =
+ sanitized.trim() && !/^(?:[A-Za-z_$][\w$]*)?Error:\s*$/.test(sanitized)
+ ? sanitized
+ : TLS_CLIENT_REQUEST_FAILED;
+ return { message, name };
+}
+
+export function sanitizeTlsClientErrorMessage(error: unknown): string {
+ return projectTlsClientError(error).message;
+}
+
+export function createSanitizedTlsStreamError(error: unknown): Error {
+ const projection = projectTlsClientError(error);
+ const sanitizedError = new Error(projection.message);
+ sanitizedError.name = projection.name;
+ // Do not retain the native error as `cause`: it can contain credentials and
+ // absolute paths. Keep a safe single-line stack for consumers that inspect it.
+ sanitizedError.stack = `${sanitizedError.name}: ${sanitizedError.message}`;
+ return sanitizedError;
+}
+
+function tailFileVariantA(
+ path: string,
+ eofSymbol: string,
+ done: Promise,
+ signal: AbortSignal | null
+): ReadableStream {
+ return new ReadableStream({
+ async start(controller) {
+ let fd: FileHandle;
+ try {
+ fd = await open(path, "r");
+ } catch (err) {
+ await cleanupTempPathSubstring(path);
+ controller.error(createSanitizedTlsStreamError(err));
+ return;
+ }
+ const buf = Buffer.alloc(64 * 1024);
+ let offset = 0;
+ let finished = false;
+ let aborted = false;
+ let upstreamError: Error | null = null;
+
+ done.then(
+ () => {
+ finished = true;
+ },
+ (err) => {
+ upstreamError = createSanitizedTlsStreamError(err);
+ finished = true;
+ }
+ );
+
+ const onAbort = () => {
+ aborted = true;
+ };
+ if (signal) {
+ if (signal.aborted) aborted = true;
+ else signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ let errored = false;
+ try {
+ while (!aborted) {
+ const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
+ if (bytesRead > 0) {
+ const chunk = buf.subarray(0, bytesRead);
+ offset += bytesRead;
+ const text = chunk.toString("utf8");
+ if (text.includes(eofSymbol)) {
+ const cutAt = text.indexOf(eofSymbol) + eofSymbol.length;
+ controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt)));
+ break;
+ }
+ controller.enqueue(new Uint8Array(chunk));
+ } else if (finished) {
+ if (upstreamError) {
+ errored = true;
+ controller.error(upstreamError);
+ }
+ break;
+ } else {
+ await sleep(25);
+ }
+ }
+ } catch (err) {
+ if (!errored) {
+ errored = true;
+ controller.error(createSanitizedTlsStreamError(err));
+ }
+ } finally {
+ if (signal) signal.removeEventListener("abort", onAbort);
+ // The handle is no longer used; a close error must not skip temp cleanup or replace EOF.
+ await fd.close().catch(() => {});
+ await cleanupTempPathSubstring(path);
+ if (!errored) controller.close();
+ }
+ },
+ });
+}
+
+function tailFileVariantB1(
+ path: string,
+ eofSymbol: string,
+ done: Promise,
+ signal: AbortSignal | null
+): ReadableStream {
+ return new ReadableStream({
+ async start(controller) {
+ let fd: FileHandle;
+ try {
+ fd = await open(path, "r");
+ } catch (err) {
+ await cleanupTempPathDirname(path);
+ controller.error(createSanitizedTlsStreamError(err));
+ return;
+ }
+ const buf = Buffer.alloc(64 * 1024);
+ let offset = 0;
+ let finished = false;
+ let aborted = false;
+ let upstreamError: Error | null = null;
+
+ done.then(
+ () => {
+ finished = true;
+ },
+ (err) => {
+ upstreamError = createSanitizedTlsStreamError(err);
+ finished = true;
+ }
+ );
+
+ const onAbort = () => {
+ aborted = true;
+ };
+ if (signal) {
+ if (signal.aborted) aborted = true;
+ else signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ let errored = false;
+ try {
+ while (!aborted) {
+ const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
+ if (bytesRead > 0) {
+ const chunk = buf.subarray(0, bytesRead);
+ offset += bytesRead;
+ const text = chunk.toString("utf8");
+
+ if (text.includes(eofSymbol)) {
+ const beforeEof = text.substring(0, text.indexOf(eofSymbol));
+ if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8"));
+ controller.close();
+ return;
+ }
+
+ controller.enqueue(Buffer.from(chunk));
+ }
+
+ if (finished) {
+ while (true) {
+ const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
+ if (bytesRead === 0) break;
+ const chunk = buf.subarray(0, bytesRead);
+ offset += bytesRead;
+ const text = chunk.toString("utf8");
+
+ if (text.includes(eofSymbol)) {
+ const beforeEof = text.substring(0, text.indexOf(eofSymbol));
+ if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8"));
+ controller.close();
+ return;
+ }
+
+ controller.enqueue(Buffer.from(chunk));
+ }
+
+ if (upstreamError && !errored) {
+ errored = true;
+ controller.error(upstreamError);
+ return;
+ }
+
+ controller.close();
+ return;
+ }
+
+ await sleep(25);
+ }
+ } catch (err) {
+ if (!errored) {
+ errored = true;
+ controller.error(createSanitizedTlsStreamError(err));
+ }
+ } finally {
+ // The handle is no longer used; a close error must not skip temp cleanup or replace EOF.
+ await fd.close().catch(() => {});
+ await cleanupTempPathDirname(path);
+ if (signal) signal.removeEventListener("abort", onAbort);
+ }
+ },
+ });
+}
+
+function enqueueChunkMaybeEof(
+ controller: ReadableStreamDefaultController,
+ chunk: Buffer,
+ eofSymbol: string
+): boolean {
+ const text = chunk.toString("utf8");
+ if (!text.includes(eofSymbol)) {
+ controller.enqueue(Buffer.from(chunk));
+ return false;
+ }
+ const beforeEof = text.substring(0, text.indexOf(eofSymbol));
+ if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8"));
+ controller.close();
+ return true;
+}
+
+async function drainRemaining(
+ fd: FileHandle,
+ buf: Buffer,
+ offsetRef: { offset: number },
+ controller: ReadableStreamDefaultController,
+ eofSymbol: string
+): Promise<"closed" | "drained"> {
+ while (true) {
+ const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset);
+ if (bytesRead === 0) return "drained";
+ const chunk = buf.subarray(0, bytesRead);
+ offsetRef.offset += bytesRead;
+ if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed";
+ }
+}
+
+function tailFileVariantB2(
+ path: string,
+ eofSymbol: string,
+ done: Promise,
+ signal: AbortSignal | null
+): ReadableStream {
+ return new ReadableStream({
+ async start(controller) {
+ let fd: FileHandle;
+ try {
+ fd = await open(path, "r");
+ } catch (err) {
+ await cleanupTempPathDirname(path);
+ controller.error(createSanitizedTlsStreamError(err));
+ return;
+ }
+ const buf = Buffer.alloc(64 * 1024);
+ const offsetRef = { offset: 0 };
+ let finished = false;
+ let aborted = false;
+ let upstreamError: Error | null = null;
+ let errored = false;
+
+ done.then(
+ () => {
+ finished = true;
+ },
+ (err) => {
+ upstreamError = createSanitizedTlsStreamError(err);
+ finished = true;
+ }
+ );
+
+ const onAbort = () => {
+ aborted = true;
+ };
+ if (signal) {
+ if (signal.aborted) aborted = true;
+ else signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ try {
+ while (!aborted) {
+ const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset);
+ if (bytesRead > 0) {
+ const chunk = buf.subarray(0, bytesRead);
+ offsetRef.offset += bytesRead;
+ if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return;
+ }
+
+ if (!finished) {
+ await sleep(25);
+ continue;
+ }
+
+ const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol);
+ if (drained === "closed") return;
+ if (upstreamError && !errored) {
+ errored = true;
+ controller.error(upstreamError);
+ return;
+ }
+ controller.close();
+ return;
+ }
+ } catch (err) {
+ if (!errored) {
+ errored = true;
+ controller.error(createSanitizedTlsStreamError(err));
+ }
+ } finally {
+ // The handle is no longer used; a close error must not skip temp cleanup or replace EOF.
+ await fd.close().catch(() => {});
+ await cleanupTempPathDirname(path);
+ if (signal) signal.removeEventListener("abort", onAbort);
+ }
+ },
+ });
+}
+
+const TAIL_FILE_VARIANTS = {
+ A: tailFileVariantA,
+ B1: tailFileVariantB1,
+ B2: tailFileVariantB2,
+} as const;
+
+export function createTlsClientTailStream({
+ variant,
+ path,
+ eofSymbol,
+ done,
+ signal,
+}: {
+ variant: TlsClientTailVariant;
+ path: string;
+ eofSymbol: string;
+ done: Promise;
+ signal: AbortSignal | null;
+}): ReadableStream {
+ return TAIL_FILE_VARIANTS[variant](path, eofSymbol, done, signal);
+}
diff --git a/open-sse/services/tlsClientTimeout.ts b/open-sse/services/tlsClientTimeout.ts
new file mode 100644
index 0000000000..4d077e356c
--- /dev/null
+++ b/open-sse/services/tlsClientTimeout.ts
@@ -0,0 +1,70 @@
+/** Timeout and abort primitives shared by every native TLS client wrapper. */
+
+export class TlsClientHangError extends Error {
+ override name = "TlsClientHangError";
+
+ constructor(message = "TLS client operation timed out") {
+ super(message);
+ }
+}
+
+export function makeAbortError(signal: AbortSignal): Error {
+ const reason = signal.reason;
+ try {
+ if (reason instanceof Error) return reason;
+ } catch {
+ // A hostile Proxy reason must not keep the already-aborted race pending.
+ }
+ const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
+ err.name = "AbortError";
+ return err;
+}
+
+export async function raceWithTimeout(
+ promise: Promise,
+ timeoutMs: number,
+ signal: AbortSignal | null | undefined
+): Promise {
+ return await new Promise((resolve, reject) => {
+ let settled = false;
+ let timer: ReturnType | undefined;
+
+ const cleanup = () => {
+ if (timer !== undefined) clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ };
+
+ const done = (fn: () => void) => {
+ if (!settled) {
+ settled = true;
+ cleanup();
+ fn();
+ }
+ };
+
+ const onAbort = () => {
+ if (signal) done(() => reject(makeAbortError(signal)));
+ };
+
+ timer = setTimeout(() => {
+ done(() => reject(new TlsClientHangError()));
+ }, timeoutMs);
+
+ if (signal) {
+ if (signal.aborted) {
+ onAbort();
+ } else {
+ signal.addEventListener("abort", onAbort, { once: true });
+ }
+ }
+
+ promise.then(
+ (value) => {
+ done(() => resolve(value));
+ },
+ (error) => {
+ done(() => reject(error));
+ }
+ );
+ });
+}
diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts
index f8cff79844..8d08335ea4 100644
--- a/open-sse/utils/error.ts
+++ b/open-sse/utils/error.ts
@@ -1,15 +1,19 @@
import { CORS_HEADERS } from "./cors.ts";
import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
+import {
+ containsStrongCredentialToken,
+ 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;
@@ -19,97 +23,121 @@ interface ErrorResponseBody {
upstream_details?: Record | 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);
-}
-
-export function redactSensitiveErrorText(value: string): string {
- return value
- .replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
- .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
- .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] = "";
- }
- 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 = {};
- for (const [k, v] of Object.entries(value as Record)) {
- 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;
code?: string;
};
+const PUBLIC_ERROR_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
+const SAFE_PUBLIC_CREDENTIAL_ERROR_IDENTIFIERS = new Set([
+ "acp_session_mismatch",
+ "access_token_missing",
+ "access_token_required",
+ "api_key_invalid",
+ "authorization_code",
+ "authorization_code_pkce",
+ "authorization_endpoint",
+ "authorization_failed",
+ "authorization_pending",
+ "bearer_error",
+ "bearer_expired",
+ "bearer_invalid",
+ "bearer_required",
+ "client_secret_missing",
+ "codex_credentials_unavailable",
+ "codex_access_token_missing",
+ "codex_oauth_token_missing",
+ "expired_token",
+ "cursor_session_stale",
+ "github_access_token_invalid",
+ "github_token_expired",
+ "invalid_api_key",
+ "invalid_password",
+ "invalid_token",
+ "invalid_token_response",
+ "lease_api_key_invalid",
+ "lease_authorization_mismatch",
+ "missing_access_token",
+ "missing_api_key",
+ "missing_authorization",
+ "missing_cookie",
+ "missing_id_token",
+ "missing_refresh_token",
+ "missing_credentials",
+ "missing_session_id",
+ "no_refresh_token",
+ "no_access_token",
+ "no_credentials",
+ "oauth_invalid_token",
+ "password_mismatch",
+ "password_required",
+ "provider_bearer_error",
+ "provider_token_expired",
+ "refresh_token_invalid",
+ "refresh_token_invalidated",
+ "refresh_token_reused",
+ "risk_session_stale",
+ "session_expired",
+ "session_pool_exhausted",
+ "token_expired",
+ "token_health_check",
+ "token_limit_exceeded",
+ "token_refresh_failed",
+ "token_refresh_transient",
+ "token_required",
+ "tls_session_capacity",
+ "token_type",
+ "token_usage",
+]);
+const SAFE_PUBLIC_CREDENTIAL_LEXICAL_IDENTIFIERS = new Set([
+ "passwordless",
+ "passwordless_auth_required",
+ "passwordless_error",
+ "tokenization_error",
+ "tokenizer_error",
+]);
+const PUBLIC_ERROR_CREDENTIAL_MARKERS = [
+ "accesstoken",
+ "refreshtoken",
+ "apikey",
+ "privatekey",
+ "sessionkey",
+ "encryptionkey",
+ "secretkey",
+ "signingkey",
+ "sessionid",
+ "cfclearance",
+ "credential",
+ "authorization",
+ "password",
+ "secret",
+ "bearer",
+ "cookie",
+ "token",
+ "session",
+ "ssorw",
+ "sso",
+] as const;
+
+function isSafePublicErrorIdentifier(value: string): boolean {
+ if (!PUBLIC_ERROR_IDENTIFIER.test(value)) return false;
+ const lowerValue = value.toLowerCase();
+ if (SAFE_PUBLIC_CREDENTIAL_ERROR_IDENTIFIERS.has(lowerValue)) return true;
+ if (SAFE_PUBLIC_CREDENTIAL_LEXICAL_IDENTIFIERS.has(lowerValue)) return true;
+ if (containsStrongCredentialToken(value)) return false;
+ const compactValue = lowerValue.replace(/[._-]+/g, "");
+ return !PUBLIC_ERROR_CREDENTIAL_MARKERS.some((marker) => compactValue.includes(marker));
+}
+
+/** Project an internal classification onto the bounded client-visible identifier vocabulary. */
+export function projectPublicErrorIdentifier(value: unknown, fallback: unknown): string {
+ const safeFallback =
+ 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.
@@ -130,8 +158,8 @@ export function buildErrorBody(
const body: ErrorResponseBody = {
error: {
message: safeMessage,
- type: classification?.type ?? errorInfo.type,
- code: classification?.code ?? errorInfo.code,
+ type: projectPublicErrorIdentifier(classification?.type, errorInfo.type),
+ code: projectPublicErrorIdentifier(classification?.code, errorInfo.code),
},
};
@@ -200,21 +228,21 @@ 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) : "";
}
/**
- * 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 (0x20-0x7e) with "?" so construction never throws. Only used for the
+ * literal header value; the JSON body keeps the sanitized readable text via
+ * `sanitizeComboDiagnostics`.
*/
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;
}
@@ -290,12 +318,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(
@@ -387,6 +413,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."
@@ -533,13 +582,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;
@@ -579,8 +625,9 @@ 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 upstream wording/shape needed by Claude Code auto-recovery,
+ // after canonical recursive sanitization (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.
@@ -613,7 +660,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: {
@@ -628,13 +677,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,
},
}),
@@ -660,9 +710,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) &&
@@ -678,7 +729,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 } : {}),
},
diff --git a/open-sse/utils/errorPathRedaction.ts b/open-sse/utils/errorPathRedaction.ts
new file mode 100644
index 0000000000..1131f28c82
--- /dev/null
+++ b/open-sse/utils/errorPathRedaction.ts
@@ -0,0 +1,830 @@
+const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] 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;
+
+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 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) ||
+ 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 isUnambiguousPosixFilesystemPathAt(value: string, start: number): boolean {
+ if (!isKnownPosixFilesystemPathAt(value, start)) return false;
+ // `/app` is also a common application route and is shielded only when an
+ // explicit Route/HTTP context proves that interpretation.
+ return !matchesPosixFilesystemRootAt(value, start, "/app");
+}
+
+function isUnambiguousPosixFilesystemPath(value: string): boolean {
+ return isUnambiguousPosixFilesystemPathAt(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);
+ 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) &&
+ pathCandidate.charCodeAt(0) === 0x2f &&
+ followsRouteContext &&
+ !isUnambiguousPosixFilesystemPath(pathCandidate)
+ ) {
+ return token;
+ }
+ if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token;
+ return `${token.slice(0, start)}${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) &&
+ !isUnambiguousPosixFilesystemPathAt(value, candidateStart);
+ // 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), "");
+ 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)) &&
+ !isUnambiguousPosixFilesystemPathAt(value, separatorIndex);
+ 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 (!isFirstToken && 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++;
+ }
+ return slashCount >= 2;
+}
+
+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) && !startsHttpUrl;
+ const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index);
+ const isUnambiguousPosixFilesystemPathCandidate = isUnambiguousPosixFilesystemPathAt(
+ value,
+ index
+ );
+ const isPosixPath =
+ !followsQuote &&
+ value.charCodeAt(index) === 0x2f &&
+ value.charCodeAt(index + 1) !== 0x2f &&
+ (!hasRouteContextBefore(value, index) || isUnambiguousPosixFilesystemPathCandidate) &&
+ 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), "");
+ copyStart = value.length;
+ index = value.length;
+ break;
+ }
+ index++;
+ continue;
+ }
+ parts.push(value.slice(copyStart, index), "");
+ 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 lowerToken = token.toLowerCase();
+ if (lowerToken.startsWith("http://") || lowerToken.startsWith("https://")) return false;
+
+ const lastForwardSlash = token.lastIndexOf("/");
+ const lastBackslash = token.lastIndexOf("\\");
+ const lastSeparator = Math.max(lastForwardSlash, lastBackslash);
+ if (lastSeparator < 0 || lastSeparator === token.length - 1) return false;
+
+ const dot = token.lastIndexOf(".");
+ if (dot <= lastSeparator || dot === token.length - 1) return false;
+ const lineSeparator = token.indexOf(":", dot + 1);
+ if (lineSeparator < 0) return false;
+ const extension = token.slice(dot + 1, lineSeparator).toLowerCase();
+ if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false;
+
+ let index = lineSeparator + 1;
+ if (!isAsciiDigit(token.charCodeAt(index))) return false;
+ while (index < token.length && isAsciiDigit(token.charCodeAt(index))) index++;
+ if (index === token.length) return true;
+ if (token.charCodeAt(index) !== 0x3a) return false;
+
+ index++;
+ if (!isAsciiDigit(token.charCodeAt(index))) return false;
+ while (index < token.length && isAsciiDigit(token.charCodeAt(index))) index++;
+ return index === token.length;
+}
+
+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) ||
+ 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 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)) {
+ 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;
+}
+
+/** Strip physical, serialized, and unambiguously inline JavaScript stack-frame tails. */
+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;
+ }
+ }
+
+ const firstLine = value.slice(0, firstLineEnd);
+ const serializedFrameStart = findSerializedStackFrameStart(firstLine);
+ const inlineFrameStart = findInlineStackFrameStart(firstLine);
+ const frameStart =
+ serializedFrameStart < 0
+ ? inlineFrameStart
+ : inlineFrameStart < 0
+ ? serializedFrameStart
+ : Math.min(serializedFrameStart, inlineFrameStart);
+ return frameStart < 0 ? firstLine : firstLine.slice(0, frameStart);
+}
+
+/**
+ * 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("");
+}
diff --git a/open-sse/utils/errorSanitization.ts b/open-sse/utils/errorSanitization.ts
new file mode 100644
index 0000000000..5b101e32a1
--- /dev/null
+++ b/open-sse/utils/errorSanitization.ts
@@ -0,0 +1,440 @@
+import { redactErrorPaths, stripErrorStackTail } from "./errorPathRedaction.ts";
+
+// Length cap protects against pathological inputs even before tokenization.
+const MAX_ERROR_LEN = 4096;
+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}|" +
+ "(?= 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 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): 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 (
+ 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 === "/" || (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, MAX_ERROR_LEN) : 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 === "/" || escaped === '"' || escaped === "'") return true;
+ if (escaped === "u" || escaped === "U") {
+ const decoded = unicodeEscapeCodeAt(value, index - 1);
+ if (isPrintableAscii(decoded)) return true;
+ }
+ }
+ return false;
+}
+
+/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */
+function normalizeSecurityEscapes(value: string, decodeQuotes: boolean): string {
+ let normalized = value.slice(0, MAX_ERROR_LEN);
+ for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) {
+ const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes);
+ if (decoded === normalized) break;
+ normalized = decoded.slice(0, MAX_ERROR_LEN);
+ }
+ 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);
+
+ 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++;
+ }
+ while (/\s/.test(value[index])) index++;
+ if (value[index] !== ":" && value[index] !== "=") continue;
+ index++;
+ while (/\s/.test(value[index])) index++;
+ 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$/.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("");
+}
+
+export function redactSensitiveErrorText(value: string): string {
+ const commonCredentialsRedacted = redactPrivateKeyPemBlocks(value)
+ .replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
+ .replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]");
+ return redactLabeledCredentialAssignments(commonCredentialsRedacted);
+}
+
+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 "";
+ }
+}
+
+/**
+ * Strip stack-trace tails, credentials, and absolute source paths from a
+ * client-visible error message.
+ */
+export function sanitizeErrorMessage(message: unknown): string {
+ let str = coerceErrorText(message);
+ if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
+ // Preserve quote provenance until hidden labels/delimiters have been
+ // exposed and redacted, then decode safe quote escapes in the clean text.
+ str = redactSensitiveErrorText(str);
+ str = normalizeSecurityEscapes(str, false);
+ str = redactSensitiveErrorText(redactErrorPaths(stripErrorStackTail(str)));
+ str = normalizeSecurityEscapes(str, true);
+ str = redactSensitiveErrorText(redactErrorPaths(stripErrorStackTail(str)));
+ return hasResidualSecurityEscape(str) ? "[REDACTED]" : str;
+}
+
+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;
+
+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.
+ */
+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 = Object.create(null) as Record;
+ for (const [key, entryValue] of Object.entries(value as Record)) {
+ if (!isSafeUpstreamDetailKey(key)) continue;
+ out[key] = sanitizeUpstreamDetails(entryValue, depth + 1);
+ }
+ return out;
+ }
+ return null;
+}
diff --git a/open-sse/utils/upstreamErrorPassthrough.ts b/open-sse/utils/upstreamErrorPassthrough.ts
index 21d0c6c964..64a247971a 100644
--- a/open-sse/utils/upstreamErrorPassthrough.ts
+++ b/open-sse/utils/upstreamErrorPassthrough.ts
@@ -1,12 +1,13 @@
+import { sanitizeUpstreamDetails } 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;
@@ -17,13 +18,9 @@ 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.
+// validation body. The eligibility filter still refuses obvious credential
+// echoes, and the response builder independently applies the canonical recursive
+// sanitizer. Safe capability/quota wording remains unchanged for Claude Code.
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;
@@ -31,7 +28,14 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody:
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 (CREDENTIAL_LEAK_RE.test(text)) return false;
@@ -44,7 +48,12 @@ export function buildPassthroughErrorResponse(
headers?: Record
): Response | null {
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
- return new Response(JSON.stringify(upstreamBody), {
+ const sanitizedBody = sanitizeUpstreamDetails(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 || {}) },
});
diff --git a/open-sse/utils/upstreamErrorResponse.ts b/open-sse/utils/upstreamErrorResponse.ts
new file mode 100644
index 0000000000..5048dafea1
--- /dev/null
+++ b/open-sse/utils/upstreamErrorResponse.ts
@@ -0,0 +1,47 @@
+import { buildErrorBody, sanitizeErrorMessage, sanitizeUpstreamDetails } from "./error.ts";
+
+interface SanitizedUpstreamErrorResponseOptions {
+ status: number;
+ rawBody: string;
+ fallbackMessage: string;
+ headers?: Record;
+}
+
+/**
+ * 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.
+ }
+ }
+
+ const safeMessage =
+ sanitizeErrorMessage(`Upstream error: ${trimmedBody}`)
+ .replace(/^Upstream error:\s*/, "")
+ .trim() || fallbackMessage;
+ return new Response(JSON.stringify(buildErrorBody(status, safeMessage)), {
+ status,
+ headers: { ...headers, "Content-Type": "application/json" },
+ });
+}
diff --git a/package.json b/package.json
index 38d9043c36..d71544b334 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,8 @@
"scripts/check/check-supported-node-runtime.ts",
"scripts/dev/sync-env.mjs",
"scripts/build/assembleStandalone.mjs",
+ "scripts/build/standaloneSidecarCopy.mjs",
+ "scripts/build/tlsClientAssetCopy.mjs",
"scripts/build/backendOnlyPages.mjs",
"scripts/build/build-tproxy-native.mjs",
"scripts/build/native-binary-compat.mjs",
@@ -102,7 +104,7 @@
"build:backend": "cross-env OMNIROUTE_BUILD_BACKEND_ONLY=1 node scripts/build/build-next-isolated.mjs",
"build:cli": "node --import tsx scripts/build/prepublish.ts",
"omniroute:verify": "node scripts/check/omniroute-verify.mjs",
- "build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
+ "build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone && npm run build:cli && node scripts/build/write-build-sha.mjs",
"build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild",
"start": "node scripts/dev/run-next.mjs start",
"homolog": "node scripts/homolog/run.mjs",
@@ -112,9 +114,9 @@
"lint:prose": "vale docs",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
"electron:build": "npm run build && cd electron && npm run build",
- "electron:build:win": "npm run build && cd electron && npm run build:win",
- "electron:build:mac": "npm run build && cd electron && npm run build:mac",
- "electron:build:linux": "npm run build && cd electron && npm run build:linux",
+ "electron:build:win": "npm run build && cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=win32 OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:win",
+ "electron:build:mac": "npm run build && cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:mac-x64 && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=arm64 npm run build:mac-arm64",
+ "electron:build:linux": "npm run build && cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=linux OMNIROUTE_ELECTRON_TARGET_ARCHES=x64,arm64 npm run build:linux",
"electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs",
"test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\"",
"test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs
index fa46b26c64..77e5e07dc8 100644
--- a/scripts/build/assembleStandalone.mjs
+++ b/scripts/build/assembleStandalone.mjs
@@ -45,10 +45,24 @@
* removeGeneratedElectronArtifacts - - Y UNIQUE (electron)
*/
-import fs from "node:fs/promises";
import fsSync from "node:fs";
+import fs from "node:fs/promises";
import path from "node:path";
import { colocateLlmlinguaOptionals, SEED_PACKAGES } from "./colocateOptionals.mjs";
+import { TLS_CLIENT_NATIVE_ASSETS } from "./fixTlsClientNodeBinary.mjs";
+import {
+ clearStaleDest,
+ copyNativeAssetsAndExtraModules,
+ repairEmptyExternalPackageDirs,
+ resolvesToSamePath,
+} from "./standaloneSidecarCopy.mjs";
+import {
+ auditTlsClientStandaloneBundle,
+ copyVerifiedTlsClientNativeAsset,
+ createTlsClientNativeAssetEntries,
+} from "./tlsClientAssetCopy.mjs";
+
+const TLS_CLIENT_NATIVE_ASSET_ENTRIES = createTlsClientNativeAssetEntries(TLS_CLIENT_NATIVE_ASSETS);
/**
* Check whether a path exists (async).
@@ -73,9 +87,14 @@ async function exists(targetPath) {
*
* Each entry uses path SEGMENT arrays (not pre-joined strings) so the source
* (relative to projectRoot) and destination (relative to outDir) can be joined
- * for either path/platform. @type {{label:string, src:string[], dest:string[]}[]}
+ * for either path/platform.
+ * @type {{label:string, src:string[], dest:string[], tlsClientSha256?:string}[]}
*/
export const NATIVE_ASSET_ENTRIES = [
+ // tls-client-node loads one platform library dynamically, outside Next.js
+ // tracing. Copy only manifest-declared filenames: recursively copying bin/
+ // would distribute unverified upstream residue beside the pinned seeds.
+ ...TLS_CLIENT_NATIVE_ASSET_ENTRIES,
{
label: "better-sqlite3 native binary",
src: ["node_modules", "better-sqlite3", "build"],
@@ -112,6 +131,18 @@ export const NATIVE_ASSET_ENTRIES = [
/** @type {{label:string, src:string[], dest:string[]}[]} */
const EXTRA_MODULE_ENTRIES = [
+ {
+ // The project MIT license must accompany standalone, Docker, and Electron
+ // distributions just like third-party notices do.
+ label: "OmniRoute project license",
+ src: ["LICENSE"],
+ dest: ["LICENSE"],
+ },
+ {
+ label: "tls-client pinned native manifest",
+ src: ["open-sse", "config", "tlsClientNativeManifest.json"],
+ dest: ["open-sse", "config", "tlsClientNativeManifest.json"],
+ },
{
// Legal notices must travel with every standalone bundle. Docker copies the
// complete standalone tree into /app, so this one entry covers both outputs.
@@ -308,6 +339,14 @@ const EXTRA_MODULE_ENTRIES = [
].map((pkg) => ({ label: pkg, src: ["node_modules", pkg], dest: ["node_modules", pkg] })),
];
+function nativeAssetEntriesFor(tlsClientNativeAssets) {
+ if (tlsClientNativeAssets === TLS_CLIENT_NATIVE_ASSETS) return NATIVE_ASSET_ENTRIES;
+ return [
+ ...createTlsClientNativeAssetEntries(tlsClientNativeAssets),
+ ...NATIVE_ASSET_ENTRIES.filter((entry) => !entry.tlsClientSha256),
+ ];
+}
+
/**
* Copy native standalone assets (better-sqlite3 build/prebuilds and TPROXY).
*
@@ -317,12 +356,25 @@ const EXTRA_MODULE_ENTRIES = [
* @param {string} rootDir - project root (node_modules are read from here)
* @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests)
* @param {Console|{log:Function}} [log] - logger
+ * @param {{tlsClientNativeAssets?:Record}} [options]
* @returns {Promise} true if any asset was copied
*/
-export async function syncStandaloneNativeAssets(rootDir, fsImpl = fs, log = console, outDir) {
+export async function syncStandaloneNativeAssets(
+ rootDir,
+ fsImpl = fs,
+ log = console,
+ outDir,
+ { tlsClientNativeAssets = TLS_CLIENT_NATIVE_ASSETS } = {}
+) {
const standaloneRoot =
outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone");
- return syncNativeAssetsToDir(rootDir, standaloneRoot, fsImpl, log);
+ return syncNativeAssetsToDir(
+ rootDir,
+ standaloneRoot,
+ fsImpl,
+ log,
+ nativeAssetEntriesFor(tlsClientNativeAssets)
+ );
}
/**
@@ -349,16 +401,41 @@ export async function syncStandaloneExtraModules(rootDir, fsImpl = fs, log = con
* @param {string} outDir
* @param {typeof fs} fsImpl
* @param {Console|{log:Function}} log
+ * @param {{label:string, src:string[], dest:string[], tlsClientSha256?:string}[]} nativeAssetEntries
* @returns {Promise}
*/
-async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) {
+async function syncNativeAssetsToDir(
+ projectRoot,
+ outDir,
+ fsImpl,
+ log,
+ nativeAssetEntries = NATIVE_ASSET_ENTRIES
+) {
let changed = false;
- for (const entry of NATIVE_ASSET_ENTRIES) {
+ for (const entry of nativeAssetEntries) {
const sourcePath = path.join(projectRoot, ...entry.src);
+ const destinationPath = path.join(outDir, ...entry.dest);
+ if (entry.tlsClientSha256) {
+ const copied = copyVerifiedTlsClientNativeAsset({
+ sourceRoot: projectRoot,
+ sourcePath,
+ destinationPath,
+ expectedSha256: entry.tlsClientSha256,
+ outDir,
+ });
+ if (!copied) continue;
+ log.log(
+ `[assembleStandalone] Copied verified native standalone asset: ${path.relative(
+ projectRoot,
+ destinationPath
+ )}`
+ );
+ changed = true;
+ continue;
+ }
if (!(await exists(sourcePath))) continue;
- const destinationPath = path.join(outDir, ...entry.dest);
// See resolvesToSamePath/clearStaleDest (sync copy path, same module) — the same
// ERR_FS_CP_EINVAL/ERR_FS_CP_DIR_TO_NON_DIR races apply to fsImpl.cp here.
if (resolvesToSamePath(sourcePath, destinationPath)) continue;
@@ -550,145 +627,6 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir
}
}
-/**
- * Two independent copy passes assemble a bundle: the bulk "standalone -> outDir" tree
- * copy (step 1 of assembleStandalone) can already have carried a prior entry's result
- * into `dest` (e.g. an absolute pnpm-store symlink, or a directory) BEFORE this entry's
- * own copy runs. `fs.cpSync`/`fs.cp` refuse to overwrite in two such cases even with
- * `force: true`:
- * - dest already resolves (via symlink chain) to the exact same real path as src ->
- * ERR_FS_CP_EINVAL "src and dest cannot be the same".
- * - dest exists with a different node type than src (file/symlink vs directory) ->
- * ERR_FS_CP_DIR_TO_NON_DIR / ERR_FS_CP_NON_DIR_TO_DIR.
- * Under heavy concurrent build I/O this manifested non-deterministically across
- * different EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES on every retry. Resolve both
- * cases up front: skip entirely when dest is already the right target, otherwise clear
- * whatever stale node occupies dest (via lstat, so it also removes a broken symlink)
- * so the fresh copy always lands cleanly.
- *
- * @param {string} src
- * @param {string} dest
- * @returns {boolean} true when dest already IS src's target and no copy is needed
- */
-function resolvesToSamePath(src, dest) {
- if (path.resolve(src) === path.resolve(dest)) return true;
- if (!fsSync.existsSync(dest)) return false;
- try {
- return fsSync.realpathSync(src) === fsSync.realpathSync(dest);
- } catch {
- return false;
- }
-}
-
-/** @see resolvesToSamePath — clears whatever stale node sits at `dest` before a copy. */
-function clearStaleDest(dest) {
- try {
- fsSync.lstatSync(dest);
- } catch {
- return;
- }
- fsSync.rmSync(dest, { recursive: true, force: true });
-}
-
-/**
- * Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars
- * (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
- * into the assembled bundle. Missing sources are skipped silently.
- *
- * @param {string} projectRoot
- * @param {string} resolvedOutDir
- */
-function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
- for (const asset of NATIVE_ASSET_ENTRIES) {
- const src = path.join(projectRoot, ...asset.src);
- if (!fsSync.existsSync(src)) continue;
- const dest = path.join(resolvedOutDir, ...asset.dest);
- if (resolvesToSamePath(src, dest)) continue;
- clearStaleDest(dest);
- fsSync.mkdirSync(path.dirname(dest), { recursive: true });
- fsSync.cpSync(src, dest, { recursive: true, force: true });
- console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
- }
-
- for (const mod of EXTRA_MODULE_ENTRIES) {
- const src = path.join(projectRoot, ...mod.src);
- if (!fsSync.existsSync(src)) continue;
- const dest = path.join(resolvedOutDir, ...mod.dest);
- if (resolvesToSamePath(src, dest)) continue;
- clearStaleDest(dest);
- fsSync.mkdirSync(path.dirname(dest), { recursive: true });
- fsSync.cpSync(src, dest, { recursive: true, force: true });
- console.log(`[assembleStandalone] Synced module: ${mod.label}`);
- }
-}
-
-/**
- * Next/Turbopack standalone output can leave behind hollow top-level package
- * directories for externalized runtime deps (directory exists, but contains no
- * files). Those empty placeholders shadow the real repo-level install and make
- * runtime ESM externals fail with "Cannot find package '/node_modules//index.js'"
- * even though the dependency is present in the source tree.
- *
- * Repair strategy: for each empty top-level package dir already present in the
- * assembled bundle, if the same package exists in the project root node_modules,
- * replace the hollow directory with a full recursive copy from the source install.
- * This keeps the fix narrowly scoped to packages the standalone already expects.
- *
- * @param {string} projectRoot
- * @param {string} bundleNodeModules
- * @returns {{repaired: number, packages: string[]}}
- */
-function repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules) {
- const summary = { repaired: 0, packages: [] };
- const sourceNodeModules = path.join(projectRoot, "node_modules");
- if (!fsSync.existsSync(bundleNodeModules) || !fsSync.existsSync(sourceNodeModules)) {
- return summary;
- }
-
- for (const name of fsSync.readdirSync(bundleNodeModules)) {
- if (name.startsWith(".") || name.startsWith("@")) continue;
-
- const bundlePkgDir = path.join(bundleNodeModules, name);
- const sourcePkgDir = path.join(sourceNodeModules, name);
-
- let bundleStat;
- try {
- bundleStat = fsSync.statSync(bundlePkgDir);
- } catch {
- continue;
- }
- if (!bundleStat.isDirectory()) continue;
-
- let bundleEntries = [];
- try {
- bundleEntries = fsSync.readdirSync(bundlePkgDir);
- } catch {
- continue;
- }
- if (bundleEntries.length > 0 || !fsSync.existsSync(sourcePkgDir)) continue;
-
- let sourceStat;
- try {
- sourceStat = fsSync.statSync(sourcePkgDir);
- } catch {
- continue;
- }
- if (!sourceStat.isDirectory()) continue;
- // See resolvesToSamePath/clearStaleDest above: bundlePkgDir can itself be a
- // symlink to sourcePkgDir's realpath whose target momentarily read as empty
- // under heavy concurrent build I/O (a transient readdirSync race, not a real
- // hollow placeholder), or a stale non-directory node from an earlier pass.
- if (resolvesToSamePath(sourcePkgDir, bundlePkgDir)) continue;
- clearStaleDest(bundlePkgDir);
-
- fsSync.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true });
- summary.repaired += 1;
- summary.packages.push(name);
- }
-
- return summary;
-}
-
/**
* Materialize Turbopack "hashed external module" symlinks inside a bundled
* node_modules dir into real, self-contained directories.
@@ -840,6 +778,7 @@ export function syncRebuiltNativeModuleIntoHashedEntries(rootModuleDir, nodeModu
* @param {boolean} [opts.patchTurbopackChunks] - strip hashed externals from .next/server js files (default false)
* @param {boolean} [opts.copyNatives] - copy native assets + extra modules (default true)
* @param {boolean} [opts.materializeSymlinks] - dereference Turbopack hashed-module symlinks in node_modules (default false)
+ * @param {Record} [opts.tlsClientNativeAssets] - integrity manifest (default pinned production manifest)
* @returns {void}
*/
export function assembleStandalone({
@@ -850,6 +789,7 @@ export function assembleStandalone({
patchTurbopackChunks: doPatchChunks = false,
copyNatives = true,
materializeSymlinks = false,
+ tlsClientNativeAssets = TLS_CLIENT_NATIVE_ASSETS,
}) {
if (!distDir) throw new Error("[assembleStandalone] distDir is required");
if (!outDir) throw new Error("[assembleStandalone] outDir is required");
@@ -904,7 +844,12 @@ export function assembleStandalone({
// 6. Optionally copy native assets + extra modules (synchronous)
if (copyNatives) {
- copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
+ copyNativeAssetsAndExtraModules({
+ projectRoot,
+ outDir: resolvedOutDir,
+ nativeAssetEntries: nativeAssetEntriesFor(tlsClientNativeAssets),
+ extraModuleEntries: EXTRA_MODULE_ENTRIES,
+ });
// Repair hollow externalized package dirs in BOTH locations Turbopack's standalone
// tracer can populate: the top-level bundle node_modules, and — for projects with a
// custom distDir (see next.config.mjs) — the nested /node_modules mirrored
@@ -955,4 +900,11 @@ export function assembleStandalone({
}
}
}
+
+ auditTlsClientStandaloneBundle({
+ outDir: resolvedOutDir,
+ projectRoot,
+ relativeNextDistDir: relDistDir,
+ nativeAssets: tlsClientNativeAssets,
+ });
}
diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs
index fa58607ff5..429adb3878 100644
--- a/scripts/build/build-next-isolated.mjs
+++ b/scripts/build/build-next-isolated.mjs
@@ -11,6 +11,7 @@ import {
syncStandaloneNativeAssets as _syncNativeAssets,
syncStandaloneExtraModules as _syncExtraModules,
} from "./assembleStandalone.mjs";
+import { fixTlsClientNodeBinary } from "./fixTlsClientNodeBinary.mjs";
import {
isBackendOnlyBuild,
stubDashboardPages,
@@ -259,6 +260,35 @@ export async function syncStandaloneExtraModules(
return _syncExtraModules(rootDir, fsImpl, log);
}
+/**
+ * Assemble the movable standalone runtime, then verify the exact TLS native seed
+ * that consumers will execute. Keeping both operations in one awaited composition
+ * prevents a successful copy/prune from bypassing the pinned-digest gate.
+ */
+export async function assembleAndVerifyStandalone({
+ rootDir = projectRoot,
+ buildDistDir = distDir,
+ standaloneDir = path.join(buildDistDir, "standalone"),
+ assembleImpl = assembleStandalone,
+ verifyImpl = fixTlsClientNodeBinary,
+} = {}) {
+ await assembleImpl({
+ distDir: buildDistDir,
+ outDir: standaloneDir,
+ projectRoot: rootDir,
+ patchTurbopackChunks: true,
+ copyNatives: true,
+ materializeSymlinks: true,
+ });
+
+ await verifyImpl({
+ rootDir,
+ standaloneDir,
+ strict: true,
+ requireStandalone: true,
+ });
+}
+
export async function main() {
const movedPaths = [];
const transientBuildPaths = getTransientBuildPaths();
@@ -299,7 +329,21 @@ export async function main() {
const result = await runNextBuild();
const standaloneDir = path.join(distDir, "standalone");
- if (result.code === 0 && (await exists(standaloneDir))) {
+ if (result.code === 0) {
+ let standaloneStats;
+ try {
+ standaloneStats = await fs.lstat(standaloneDir);
+ } catch (error) {
+ if (error?.code !== "ENOENT") throw error;
+ }
+
+ if (!standaloneStats?.isDirectory() || standaloneStats.isSymbolicLink()) {
+ throw new Error(
+ `Next.js build exited successfully but did not produce a standalone directory at ${standaloneDir}. ` +
+ 'Ensure Next.js output is set to "standalone" and inspect the preceding build-worker logs.'
+ );
+ }
+
try {
await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), {
recursive: true,
@@ -337,34 +381,28 @@ export async function main() {
);
}
- try {
- console.log(
- "[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..."
+ console.log(
+ "[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..."
+ );
+ // Match the hardened packaging path used by Electron builds:
+ // Turbopack can emit hashed external-package references and standalone
+ // symlinks that break after the bundle is moved/copied. The composition
+ // verifies the copied TLS seed before any later build step can succeed.
+ await assembleAndVerifyStandalone({
+ rootDir: projectRoot,
+ buildDistDir: distDir,
+ standaloneDir,
+ });
+ const { spawnSync } = await import("node:child_process");
+ const basePathWrite = spawnSync(
+ process.execPath,
+ [path.join(projectRoot, "scripts", "build", "write-build-base-path.mjs")],
+ { cwd: projectRoot, env: process.env, stdio: "inherit" }
+ );
+ if (basePathWrite.status !== 0) {
+ console.warn(
+ "[build-next-isolated] Non-fatal error writing BUILD_OMNIROUTE_BASE_PATH sentinel"
);
- assembleStandalone({
- distDir,
- outDir: standaloneDir,
- projectRoot,
- // Match the hardened packaging path used by Electron builds:
- // Turbopack can emit hashed external-package references and
- // standalone symlinks that break after the bundle is moved/copied.
- patchTurbopackChunks: true,
- copyNatives: true,
- materializeSymlinks: true,
- });
- const { spawnSync } = await import("node:child_process");
- const basePathWrite = spawnSync(
- process.execPath,
- [path.join(projectRoot, "scripts", "build", "write-build-base-path.mjs")],
- { cwd: projectRoot, env: process.env, stdio: "inherit" }
- );
- if (basePathWrite.status !== 0) {
- console.warn(
- "[build-next-isolated] Non-fatal error writing BUILD_OMNIROUTE_BASE_PATH sentinel"
- );
- }
- } catch (assembleErr) {
- console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr);
}
}
process.exitCode = result.code;
diff --git a/scripts/build/fixTlsClientNodeBinary.mjs b/scripts/build/fixTlsClientNodeBinary.mjs
index 155b3f4b2d..33bac77b58 100644
--- a/scripts/build/fixTlsClientNodeBinary.mjs
+++ b/scripts/build/fixTlsClientNodeBinary.mjs
@@ -20,20 +20,41 @@
* `dist/node_modules/tls-client-node/bin/` bundle (same pattern as
* fixWreqJsBinary), so the published npm package works even though its
* own `files` allowlist never ships the binary.
- * 3. When that verified asset is absent, invokes the module's postinstall
- * with TLS_CLIENT_VERSION pinned and retries with exponential backoff.
+ * 3. When that verified asset is absent, downloads the exact tagged release
+ * asset directly and retries with exponential backoff.
*
* Normal npm postinstall remains best-effort and warns on failure. Docker and
* release callers use --strict, which fails closed instead of shipping an
* absent or unverified binary.
*/
-import { createHash } from "node:crypto";
-import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
-import { join } from "node:path";
+import { createHash, randomUUID } from "node:crypto";
+import {
+ closeSync,
+ constants as fsConstants,
+ existsSync,
+ fchmodSync,
+ fstatSync,
+ lstatSync,
+ mkdirSync,
+ openSync,
+ readFileSync,
+ readdirSync,
+ readSync,
+ realpathSync,
+ renameSync,
+ unlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";
const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000, 8_000];
+const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
+const MAX_NATIVE_ASSET_BYTES = 64 * 1024 * 1024;
+const NATIVE_ASSET_READ_CHUNK_BYTES = 64 * 1024;
+const TLS_CLIENT_RELEASE_DOWNLOAD_BASE =
+ "https://github.com/bogdanfinn/tls-client/releases/download";
const NATIVE_MANIFEST = JSON.parse(
readFileSync(
new URL("../../open-sse/config/tlsClientNativeManifest.json", import.meta.url),
@@ -46,6 +67,24 @@ export const TLS_CLIENT_NATIVE_ASSETS = NATIVE_MANIFEST.assets;
/** @typedef {{ file: string; sha256: string }} NativeAsset */
+/** @param {NativeAsset} asset */
+function validateNativeAsset(asset) {
+ if (
+ !asset?.file ||
+ asset.file === "." ||
+ asset.file === ".." ||
+ basename(asset.file) !== asset.file ||
+ asset.file.includes("/") ||
+ asset.file.includes("\\") ||
+ asset.file.includes("\0")
+ ) {
+ throw new Error(`Invalid tls-client native asset path: ${JSON.stringify(asset?.file)}`);
+ }
+ if (!/^[a-f0-9]{64}$/.test(asset.sha256)) {
+ throw new Error(`Invalid SHA-256 in tls-client native manifest for ${asset.file}`);
+ }
+}
+
/**
* Resolve the exact native asset supported by tls-client-node@0.2.0.
*
@@ -58,136 +97,507 @@ export function resolveTlsClientNativeAsset(platform = process.platform, arch =
if (!asset) {
throw new Error(`Unsupported platform for tls-client-node native asset: ${platform}/${arch}`);
}
+ validateNativeAsset(asset);
return asset;
}
-function sha256File(filePath) {
- return createHash("sha256").update(readFileSync(filePath)).digest("hex");
+function sameFileIdentity(left, right) {
+ return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
+}
+
+/** @param {string} filePath */
+function lstatIfPresent(filePath) {
+ try {
+ return lstatSync(filePath);
+ } catch (err) {
+ if (err?.code === "ENOENT") return undefined;
+ throw err;
+ }
+}
+
+function assertNativeAssetSize(size, label) {
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_NATIVE_ASSET_BYTES) {
+ throw new Error(`${label} exceeds the 64 MiB limit`);
+ }
+}
+
+function pathEscapesRoot(rootPath, candidatePath) {
+ const relativePath = relative(rootPath, candidatePath);
+ return isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${sep}`);
+}
+
+function readSafeDirectoryIdentity(directoryPath) {
+ const initialStats = lstatIfPresent(directoryPath);
+ if (!initialStats) return undefined;
+ if (initialStats.isSymbolicLink() || !initialStats.isDirectory()) {
+ throw new Error(
+ `Unsafe tls-client native destination ancestor (symlink/non-directory): ${directoryPath}`
+ );
+ }
+ const canonicalPath = realpathSync(directoryPath);
+ const finalStats = lstatSync(directoryPath);
+ if (
+ finalStats.isSymbolicLink() ||
+ !finalStats.isDirectory() ||
+ !sameFileIdentity(initialStats, finalStats)
+ ) {
+ throw new Error(
+ `Unsafe tls-client native destination ancestor changed during verification: ${directoryPath}`
+ );
+ }
+ return canonicalPath;
+}
+
+/**
+ * Validate every existing directory component without following symlinks. The
+ * trusted root itself must already be a real directory; descendants may be
+ * absent because the caller creates them only after this check succeeds.
+ */
+function assertSafeDestinationAncestors(trustedRoot, destinationPath) {
+ const resolvedRoot = resolve(trustedRoot);
+ const resolvedDestination = resolve(destinationPath);
+ const destinationRelativePath = relative(resolvedRoot, resolvedDestination);
+ if (
+ destinationRelativePath === "" ||
+ isAbsolute(destinationRelativePath) ||
+ destinationRelativePath === ".." ||
+ destinationRelativePath.startsWith(`..${sep}`)
+ ) {
+ throw new Error(
+ `Unsafe tls-client native destination outside trusted root: ${resolvedDestination}`
+ );
+ }
+
+ const canonicalRoot = readSafeDirectoryIdentity(resolvedRoot);
+ if (!canonicalRoot) {
+ throw new Error(`Trusted tls-client native destination root not found: ${resolvedRoot}`);
+ }
+
+ let currentPath = resolvedRoot;
+ const relativeParent = relative(resolvedRoot, dirname(resolvedDestination));
+ for (const component of relativeParent.split(sep).filter(Boolean)) {
+ currentPath = join(currentPath, component);
+ const canonicalPath = readSafeDirectoryIdentity(currentPath);
+ if (canonicalPath && pathEscapesRoot(canonicalRoot, canonicalPath)) {
+ throw new Error(
+ `Unsafe tls-client native destination ancestor outside trusted root: ${currentPath}`
+ );
+ }
+ }
+
+ return resolvedDestination;
+}
+
+function collectAllowedNativeAssets(nativeAssets, targetAsset) {
+ const assetsByFile = new Map();
+ for (const candidate of Object.values(nativeAssets ?? {})) {
+ validateNativeAsset(candidate);
+ const previous = assetsByFile.get(candidate.file);
+ if (previous && previous.sha256 !== candidate.sha256) {
+ throw new Error(`Ambiguous SHA-256 for tls-client native asset: ${candidate.file}`);
+ }
+ assetsByFile.set(candidate.file, candidate);
+ }
+ if (targetAsset) {
+ validateNativeAsset(targetAsset);
+ // A deterministic test target intentionally overrides the production asset
+ // with the same filename. Production calls do not provide this seam.
+ assetsByFile.set(targetAsset.file, targetAsset);
+ }
+ return assetsByFile;
+}
+
+function assertNativeAssetDirectoryInventory(
+ trustedRoot,
+ binDir,
+ allowedAssets,
+ verifyDigests = false
+) {
+ const auditSentinel = join(binDir, ".tls-client-native-audit");
+ assertSafeDestinationAncestors(trustedRoot, auditSentinel);
+ if (!lstatIfPresent(binDir)) return;
+
+ for (const entryName of readdirSync(binDir)) {
+ const expectedAsset = allowedAssets.get(entryName);
+ if (!expectedAsset) {
+ throw new Error(
+ `Unlisted tls-client native sibling is not in the manifest: ${join(binDir, entryName)}`
+ );
+ }
+ const entryPath = join(binDir, entryName);
+ const entryStats = lstatIfPresent(entryPath);
+ if (!entryStats || entryStats.isSymbolicLink() || !entryStats.isFile()) {
+ throw new Error(`Unsafe tls-client native sibling (symlink/non-regular file): ${entryPath}`);
+ }
+ if (verifyDigests && !isVerifiedBinary(entryPath, expectedAsset)) {
+ throw new Error(
+ `Manifested tls-client native sibling has an unverified SHA-256: ${entryPath}`
+ );
+ }
+ }
+ assertSafeDestinationAncestors(trustedRoot, auditSentinel);
+}
+
+function readFileDescriptorBounded(fd, filePath) {
+ assertNativeAssetSize(fstatSync(fd).size, `Local tls-client native asset: ${filePath}`);
+
+ const chunks = [];
+ let totalBytes = 0;
+ while (true) {
+ const remainingWithSentinel = MAX_NATIVE_ASSET_BYTES - totalBytes + 1;
+ const chunk = Buffer.allocUnsafe(
+ Math.min(NATIVE_ASSET_READ_CHUNK_BYTES, remainingWithSentinel)
+ );
+ const bytesRead = readSync(fd, chunk, 0, chunk.byteLength, null);
+ if (bytesRead === 0) break;
+ totalBytes += bytesRead;
+ assertNativeAssetSize(totalBytes, `Local tls-client native asset: ${filePath}`);
+ chunks.push(chunk.subarray(0, bytesRead));
+ }
+
+ const finalStats = fstatSync(fd);
+ assertNativeAssetSize(finalStats.size, `Local tls-client native asset: ${filePath}`);
+ const bytes = Buffer.concat(chunks, totalBytes);
+ assertNativeAssetSize(bytes.length, `Local tls-client native asset: ${filePath}`);
+ return bytes;
+}
+
+/**
+ * @param {string} filePath
+ * @param {NativeAsset} asset
+ * @param {(filePath: string) => void} [afterInitialStat]
+ */
+function readVerifiedBinary(filePath, asset, afterInitialStat) {
+ const pathStats = lstatIfPresent(filePath);
+ if (!pathStats) return undefined;
+ if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
+ throw new Error(`Unsafe tls-client native path (symlink/non-regular file): ${filePath}`);
+ }
+
+ const fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
+ try {
+ const openedStats = fstatSync(fd);
+ if (!openedStats.isFile()) {
+ throw new Error(`Unsafe tls-client native path (not a regular file): ${filePath}`);
+ }
+ assertNativeAssetSize(openedStats.size, `Local tls-client native asset: ${filePath}`);
+ afterInitialStat?.(filePath);
+ const bytes = readFileDescriptorBounded(fd, filePath);
+ const currentStats = lstatSync(filePath);
+ if (
+ currentStats.isSymbolicLink() ||
+ !currentStats.isFile() ||
+ !sameFileIdentity(openedStats, currentStats)
+ ) {
+ throw new Error(`Unsafe tls-client native path changed during verification: ${filePath}`);
+ }
+ if (createHash("sha256").update(bytes).digest("hex") !== asset.sha256) return undefined;
+ if (process.platform !== "win32") fchmodSync(fd, 0o555);
+ return bytes;
+ } finally {
+ closeSync(fd);
+ }
}
/** @param {string} filePath @param {NativeAsset} asset */
function isVerifiedBinary(filePath, asset) {
- if (!existsSync(filePath)) return false;
try {
- return sha256File(filePath) === asset.sha256;
+ return Boolean(readVerifiedBinary(filePath, asset));
} catch {
return false;
}
}
+/**
+ * @param {string} destinationPath
+ * @param {Uint8Array} bytes
+ * @param {NativeAsset} asset
+ * @param {string} trustedRoot
+ */
+function writeVerifiedBinary(destinationPath, bytes, asset, trustedRoot) {
+ assertNativeAssetSize(bytes.byteLength, `tls-client native asset: ${asset.file}`);
+ if (createHash("sha256").update(bytes).digest("hex") !== asset.sha256) {
+ throw new Error(`SHA-256 mismatch for tls-client native asset: ${asset.file}`);
+ }
+ const resolvedDestination = assertSafeDestinationAncestors(trustedRoot, destinationPath);
+ const destinationStats = lstatIfPresent(resolvedDestination);
+ if (destinationStats?.isSymbolicLink() || (destinationStats && !destinationStats.isFile())) {
+ throw new Error(`Unsafe tls-client native destination path: ${resolvedDestination}`);
+ }
+ mkdirSync(dirname(resolvedDestination), { recursive: true });
+ assertSafeDestinationAncestors(trustedRoot, resolvedDestination);
+ const temporaryPath = join(
+ dirname(resolvedDestination),
+ `.${asset.file}.${process.pid}.${randomUUID()}.tmp`
+ );
+ try {
+ assertSafeDestinationAncestors(trustedRoot, resolvedDestination);
+ writeFileSync(temporaryPath, bytes, { flag: "wx", mode: 0o555 });
+ if (!isVerifiedBinary(temporaryPath, asset)) {
+ throw new Error(`SHA-256 mismatch after writing ${asset.file}`);
+ }
+ assertSafeDestinationAncestors(trustedRoot, resolvedDestination);
+ renameSync(temporaryPath, resolvedDestination);
+ assertSafeDestinationAncestors(trustedRoot, resolvedDestination);
+ if (!isVerifiedBinary(resolvedDestination, asset)) {
+ throw new Error(`SHA-256 mismatch after installing ${asset.file}`);
+ }
+ assertSafeDestinationAncestors(trustedRoot, resolvedDestination);
+ } finally {
+ try {
+ assertSafeDestinationAncestors(trustedRoot, temporaryPath);
+ removeIfPresent(temporaryPath);
+ } catch {
+ // Never follow a destination ancestor that changed while the write was in progress.
+ }
+ }
+}
+
+/**
+ * @param {string} sourcePath
+ * @param {string} destinationPath
+ * @param {NativeAsset} asset
+ * @param {string} trustedRoot
+ */
+function copyVerifiedBinary(sourcePath, destinationPath, asset, trustedRoot) {
+ assertSafeDestinationAncestors(trustedRoot, sourcePath);
+ const bytes = readVerifiedBinary(sourcePath, asset);
+ if (!bytes) throw new Error(`Source native binary is absent or unverified: ${sourcePath}`);
+ assertSafeDestinationAncestors(trustedRoot, sourcePath);
+ writeVerifiedBinary(destinationPath, bytes, asset, trustedRoot);
+}
+
function removeIfPresent(filePath) {
- if (existsSync(filePath)) unlinkSync(filePath);
+ if (lstatIfPresent(filePath)) unlinkSync(filePath);
}
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
-/**
- * Re-run tls-client-node's own postinstall.js in-process, retrying with
- * backoff when the attempt leaves `bin/` empty (covers transient GitHub API
- * rate-limiting — the upstream script itself never throws on failure, it
- * only warns, so "still empty after running it" is the only failure signal
- * available).
- */
-async function downloadWithRetry(rootTlsClientDir, asset, version, retryDelaysMs, log) {
- const postinstallScript = join(rootTlsClientDir, "scripts", "postinstall.js");
- const binDir = join(rootTlsClientDir, "bin");
- const binaryPath = join(binDir, asset.file);
- if (!existsSync(postinstallScript)) return false;
+function pinnedReleaseAssetUrl(version, asset) {
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
+ throw new Error(`Invalid pinned tls-client native version: ${version}`);
+ }
+ return `${TLS_CLIENT_RELEASE_DOWNLOAD_BASE}/v${version}/${encodeURIComponent(asset.file)}`;
+}
- if (existsSync(binaryPath) && !isVerifiedBinary(binaryPath, asset)) {
- removeIfPresent(binaryPath);
- log(` ⚠️ Removed tls-client-node binary with an invalid SHA-256: ${asset.file}`);
+async function readBoundedResponseBytes(response, asset) {
+ const contentLength = response.headers?.get?.("content-length");
+ if (contentLength !== null && contentLength !== undefined) {
+ if (!/^\d+$/.test(contentLength)) {
+ throw new Error(`Invalid Content-Length for tls-client native asset: ${asset.file}`);
+ }
+ assertNativeAssetSize(
+ Number(contentLength),
+ `Downloaded tls-client native asset: ${asset.file}`
+ );
}
+ const reader = response.body?.getReader?.();
+ if (!reader) {
+ throw new Error(`Downloaded tls-client native asset has no readable body: ${asset.file}`);
+ }
+
+ const chunks = [];
+ let totalBytes = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const chunk = Buffer.from(value);
+ totalBytes += chunk.byteLength;
+ try {
+ assertNativeAssetSize(totalBytes, `Downloaded tls-client native asset: ${asset.file}`);
+ } catch (err) {
+ // Cancellation is advisory after the size violation; preserve the actionable size error.
+ await reader.cancel().catch(() => {});
+ throw err;
+ }
+ chunks.push(chunk);
+ }
+ const bytes = Buffer.concat(chunks, totalBytes);
+ assertNativeAssetSize(bytes.length, `Downloaded tls-client native asset: ${asset.file}`);
+ return bytes;
+}
+
+async function downloadPinnedAssetWithRetry(
+ rootTlsClientDir,
+ trustedRoot,
+ asset,
+ version,
+ retryDelaysMs,
+ downloadTimeoutMs,
+ fetchImpl,
+ log
+) {
+ const binaryPath = join(rootTlsClientDir, "bin", asset.file);
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
+ const initialStats = lstatIfPresent(binaryPath);
+ if (initialStats?.isSymbolicLink() || (initialStats && !initialStats.isFile())) {
+ throw new Error(`Unsafe tls-client native path (symlink/non-regular file): ${binaryPath}`);
+ }
+ if (initialStats && !isVerifiedBinary(binaryPath, asset)) {
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
+ removeIfPresent(binaryPath);
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
+ log(` ⚠️ Removed tls-client-node binary with an invalid SHA-256: ${asset.file}`);
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
+ }
+
+ if (typeof fetchImpl !== "function") {
+ throw new Error("Fetch is unavailable; cannot download the pinned tls-client native asset");
+ }
+ if (!Number.isFinite(downloadTimeoutMs) || downloadTimeoutMs <= 0) {
+ throw new Error(`Invalid tls-client native download timeout: ${downloadTimeoutMs}`);
+ }
+
+ const downloadUrl = pinnedReleaseAssetUrl(version, asset);
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) {
log(
- ` ⏳ tls-client-node native binary still missing — retrying download ` +
- `(attempt ${attempt + 1}/${retryDelaysMs.length + 1}) after rate-limit/backoff...`
+ ` ⏳ tls-client-node ${asset.file} still missing — retrying pinned download ` +
+ `(attempt ${attempt + 1}/${retryDelaysMs.length + 1})...`
);
await sleep(retryDelaysMs[attempt - 1]);
}
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
+ timeout.unref?.();
try {
- const { execFileSync } = await import("node:child_process");
- execFileSync(process.execPath, [postinstallScript], {
- cwd: rootTlsClientDir,
- env: {
- ...process.env,
- TLS_CLIENT_SKIP_DOWNLOAD: "0",
- TLS_CLIENT_VERSION: version,
- },
- stdio: "pipe",
- timeout: 30_000,
- });
+ const response = await fetchImpl(downloadUrl, { signal: controller.signal });
+ if (!response?.ok) {
+ throw new Error(`HTTP ${response?.status ?? "unknown"}`);
+ }
+ const bytes = await readBoundedResponseBytes(response, asset);
+ writeVerifiedBinary(binaryPath, bytes, asset, trustedRoot);
} catch (err) {
- log(` ⚠️ tls-client-node postinstall attempt failed: ${err.message.split("\n")[0]}`);
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
+ log(
+ ` ⚠️ Pinned tls-client native download attempt failed for ${asset.file}: ` +
+ `${err.message.split("\n")[0]}`
+ );
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
+ } finally {
+ clearTimeout(timeout);
}
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
if (isVerifiedBinary(binaryPath, asset)) return true;
- if (existsSync(binaryPath)) {
+ const currentStats = lstatIfPresent(binaryPath);
+ if (currentStats?.isSymbolicLink() || (currentStats && !currentStats.isFile())) {
+ throw new Error(`Unsafe tls-client native path after download: ${binaryPath}`);
+ }
+ if (currentStats) {
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
removeIfPresent(binaryPath);
- log(` ⚠️ Rejected tls-client-node binary with an invalid SHA-256: ${asset.file}`);
+ assertSafeDestinationAncestors(trustedRoot, binaryPath);
}
}
return false;
}
-/**
- * @param {object} opts
- * @param {string} opts.rootDir - repo root
- * @param {(msg: string) => void} [opts.log]
- * @param {number[]} [opts.retryDelaysMs] - override for tests (avoid real sleeps)
- * @param {NativeAsset} [opts.asset] - injected only for deterministic tests
- * @param {boolean} [opts.strict] - fail instead of warning (Docker/release builds)
- */
-export async function fixTlsClientNodeBinary({
- rootDir,
- log = (m) => console.log(m),
- retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
- asset,
- strict = false,
-} = {}) {
- const version = TLS_CLIENT_NATIVE_VERSION;
- const rootTlsClientDir = join(rootDir, "node_modules", "tls-client-node");
- const rootBinDir = join(rootTlsClientDir, "bin");
- const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node");
+function normalizeTargetPlatform(platform) {
+ if (typeof platform !== "string" || !/^[a-z0-9]+$/.test(platform)) {
+ throw new Error(`Invalid tls-client target platform: ${JSON.stringify(platform)}`);
+ }
+ return platform;
+}
- if (!existsSync(rootTlsClientDir)) {
- if (strict) throw new Error("tls-client-node is not installed; cannot verify native binary");
+function normalizeTargetArches(arches) {
+ const values = (Array.isArray(arches) ? arches : [arches])
+ .flatMap((arch) => (typeof arch === "string" ? arch.split(",") : []))
+ .map((arch) => arch.trim())
+ .filter(Boolean);
+ if (values.length === 0 || values.some((arch) => !/^[a-z0-9_-]+$/.test(arch))) {
+ throw new Error(`Invalid tls-client target arches: ${JSON.stringify(arches)}`);
+ }
+ return [...new Set(values)];
+}
+
+function resolveTargetNativeAsset(platform, arch, nativeAssets) {
+ const expectedAsset = nativeAssets?.[`${platform}-${arch}`];
+ if (!expectedAsset) {
+ throw new Error(`Unsupported platform for tls-client-node native asset: ${platform}/${arch}`);
+ }
+ validateNativeAsset(expectedAsset);
+ return expectedAsset;
+}
+
+async function fixTlsClientNodeTarget({
+ rootDir,
+ rootTlsClientDir,
+ distTlsClientDir,
+ expectedAsset,
+ targetPlatform,
+ targetArch,
+ version,
+ log,
+ retryDelaysMs,
+ downloadTimeoutMs,
+ fetchImpl,
+ strict,
+ standaloneDir,
+ requireStandalone,
+ afterSourceStat,
+}) {
+ const rootBinDir = join(rootTlsClientDir, "bin");
+ const rootBinaryPath = join(rootBinDir, expectedAsset.file);
+
+ try {
+ assertSafeDestinationAncestors(rootDir, rootBinaryPath);
+ } catch (err) {
+ if (strict) throw err;
+ console.warn(` ⚠️ ${err.message}`);
+ return;
+ }
+ const rootBinaryStats = lstatIfPresent(rootBinaryPath);
+ if (rootBinaryStats?.isSymbolicLink() || (rootBinaryStats && !rootBinaryStats.isFile())) {
+ const message = `Unsafe tls-client native source path: ${rootBinaryPath}`;
+ if (strict) throw new Error(message);
+ console.warn(` ⚠️ ${message}`);
return;
}
- let expectedAsset = asset;
+ let rootBinaryVerified;
try {
- expectedAsset ??= resolveTlsClientNativeAsset();
+ rootBinaryVerified = Boolean(
+ readVerifiedBinary(rootBinaryPath, expectedAsset, afterSourceStat)
+ );
} catch (err) {
if (strict) throw err;
console.warn(` ⚠️ ${err.message}`);
return;
}
- const rootBinaryPath = join(rootBinDir, expectedAsset.file);
-
- if (!isVerifiedBinary(rootBinaryPath, expectedAsset)) {
+ if (!rootBinaryVerified) {
log(
`\n 🔧 tls-client-node native binary missing or unverified — fetching pinned ` +
- `v${version} and checking SHA-256...\n`
- );
- const recovered = await downloadWithRetry(
- rootTlsClientDir,
- expectedAsset,
- version,
- retryDelaysMs,
- log
+ `v${version} for ${targetPlatform}/${targetArch} and checking SHA-256...\n`
);
+ let recovered = false;
+ try {
+ recovered = await downloadPinnedAssetWithRetry(
+ rootTlsClientDir,
+ rootDir,
+ expectedAsset,
+ version,
+ retryDelaysMs,
+ downloadTimeoutMs,
+ fetchImpl,
+ log
+ );
+ } catch (err) {
+ if (strict) throw err;
+ console.warn(` ⚠️ Could not recover tls-client-node binary: ${err.message}`);
+ return;
+ }
if (!recovered) {
const message =
`Could not fetch tls-client-node v${version} verified native binary ` +
- `(${expectedAsset.file}) after retries.`;
+ `(${expectedAsset.file}, ${targetPlatform}/${targetArch}) after retries.`;
if (strict) throw new Error(message);
console.warn(`\n ⚠️ ${message} GitHub may be rate-limited or unreachable.`);
console.warn(
@@ -199,38 +609,281 @@ export async function fixTlsClientNodeBinary({
);
return;
}
+ try {
+ assertSafeDestinationAncestors(rootDir, rootBinaryPath);
+ } catch (err) {
+ if (strict) throw err;
+ console.warn(` ⚠️ ${err.message}`);
+ return;
+ }
log(" ✅ tls-client-node native binary fetched successfully!\n");
+ try {
+ assertSafeDestinationAncestors(rootDir, rootBinaryPath);
+ } catch (err) {
+ if (strict) throw err;
+ console.warn(` ⚠️ ${err.message}`);
+ return;
+ }
}
- if (!existsSync(distTlsClientDir) || !isVerifiedBinary(rootBinaryPath, expectedAsset)) return;
+ if (!isVerifiedBinary(rootBinaryPath, expectedAsset)) {
+ const message =
+ `tls-client-node v${version} root native binary failed post-recovery verification ` +
+ `(${expectedAsset.file}); refusing to copy or seed standalone artifacts.`;
+ if (strict) throw new Error(message);
+ console.warn(` ⚠️ ${message}`);
+ return;
+ }
- const distBinDir = join(distTlsClientDir, "bin");
- const distBinaryPath = join(distBinDir, expectedAsset.file);
- if (isVerifiedBinary(distBinaryPath, expectedAsset)) return;
-
- try {
- removeIfPresent(distBinaryPath);
- mkdirSync(distBinDir, { recursive: true });
- copyFileSync(rootBinaryPath, distBinaryPath);
- if (!isVerifiedBinary(distBinaryPath, expectedAsset)) {
- removeIfPresent(distBinaryPath);
- throw new Error(`SHA-256 mismatch after copying ${expectedAsset.file}`);
+ if (existsSync(distTlsClientDir)) {
+ const distBinaryPath = join(distTlsClientDir, "bin", expectedAsset.file);
+ try {
+ assertSafeDestinationAncestors(rootDir, distBinaryPath);
+ if (!isVerifiedBinary(distBinaryPath, expectedAsset)) {
+ copyVerifiedBinary(rootBinaryPath, distBinaryPath, expectedAsset, rootDir);
+ log(
+ ` ✅ Verified tls-client-node v${version} native binary copied to standalone ` +
+ "dist/node_modules.\n"
+ );
+ }
+ } catch (err) {
+ if (strict) throw err;
+ console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`);
}
- log(
- ` ✅ Verified tls-client-node v${version} native binary copied to standalone ` +
- "dist/node_modules.\n"
+ }
+
+ if (requireStandalone && !standaloneDir) {
+ throw new Error("Final standalone artifact path is required for strict verification");
+ }
+ if (standaloneDir) {
+ const resolvedStandaloneDir = resolve(rootDir, standaloneDir);
+ if (!existsSync(resolvedStandaloneDir)) {
+ const message = `Final standalone artifact not found: ${resolvedStandaloneDir}`;
+ if (requireStandalone || strict) throw new Error(message);
+ console.warn(` ⚠️ ${message}`);
+ return;
+ }
+ const runtimeBinaryPath = join(
+ resolvedStandaloneDir,
+ "runtime-assets",
+ "tls-client",
+ "bin",
+ expectedAsset.file
);
+ try {
+ assertSafeDestinationAncestors(rootDir, runtimeBinaryPath);
+ if (!isVerifiedBinary(runtimeBinaryPath, expectedAsset)) {
+ copyVerifiedBinary(rootBinaryPath, runtimeBinaryPath, expectedAsset, rootDir);
+ }
+ if (!isVerifiedBinary(runtimeBinaryPath, expectedAsset)) {
+ throw new Error(`Final standalone runtime seed is unverified: ${runtimeBinaryPath}`);
+ }
+ log(` ✅ Verified tls-client native runtime seed: ${runtimeBinaryPath}\n`);
+ } catch (err) {
+ if (strict || requireStandalone) throw err;
+ console.warn(` ⚠️ Could not seed standalone TLS runtime binary: ${err.message}`);
+ }
+ }
+}
+
+/**
+ * @param {object} opts
+ * @param {string} opts.rootDir - repo root
+ * @param {(msg: string) => void} [opts.log]
+ * @param {number[]} [opts.retryDelaysMs] - override for tests (avoid real sleeps)
+ * @param {number} [opts.downloadTimeoutMs] - timeout for each pinned fetch attempt
+ * @param {NativeAsset} [opts.asset] - legacy single-target injection for deterministic tests
+ * @param {Record} [opts.nativeAssets] - manifest injection for tests
+ * @param {typeof fetch} [opts.fetchImpl] - pinned download boundary, injectable for tests
+ * @param {string} [opts.platform] - target platform (defaults to the current host)
+ * @param {string|string[]} [opts.arches] - one or more target arches (defaults to host arch)
+ * @param {(filePath: string) => void} [opts.afterSourceStat] - deterministic race hook for tests
+ * @param {boolean} [opts.strict] - fail instead of warning (Docker/release builds)
+ * @param {string} [opts.standaloneDir] - final standalone root to seed and verify
+ * @param {boolean} [opts.requireStandalone] - fail if standaloneDir does not exist
+ */
+export async function fixTlsClientNodeBinary({
+ rootDir,
+ log = (m) => console.log(m),
+ retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
+ downloadTimeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS,
+ asset,
+ nativeAssets = TLS_CLIENT_NATIVE_ASSETS,
+ fetchImpl = globalThis.fetch,
+ platform = process.platform,
+ arches = [process.arch],
+ afterSourceStat,
+ strict = false,
+ standaloneDir,
+ requireStandalone = false,
+} = {}) {
+ const version = TLS_CLIENT_NATIVE_VERSION;
+ const rootTlsClientDir = join(rootDir, "node_modules", "tls-client-node");
+ const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node");
+ const resolvedStandaloneDir = standaloneDir ? resolve(rootDir, standaloneDir) : undefined;
+
+ if (!existsSync(rootTlsClientDir)) {
+ if (strict) throw new Error("tls-client-node is not installed; cannot verify native binary");
+ return;
+ }
+ if (requireStandalone && !standaloneDir) {
+ throw new Error("Final standalone artifact path is required for strict verification");
+ }
+ if (resolvedStandaloneDir) {
+ if (!existsSync(resolvedStandaloneDir)) {
+ const message = `Final standalone artifact not found: ${resolvedStandaloneDir}`;
+ if (requireStandalone || strict) throw new Error(message);
+ console.warn(` ⚠️ ${message}`);
+ return;
+ }
+ }
+
+ let allowedNativeAssets;
+ let strictBinDirs;
+ if (strict) {
+ allowedNativeAssets = collectAllowedNativeAssets(nativeAssets, asset);
+ const resolvedRootDir = resolve(rootDir);
+ const configuredNextDistDir = resolve(
+ resolvedRootDir,
+ process.env.NEXT_DIST_DIR || ".build/next"
+ );
+ if (pathEscapesRoot(resolvedRootDir, configuredNextDistDir)) {
+ throw new Error(
+ `Unsafe NEXT_DIST_DIR outside tls-client trusted root: ${configuredNextDistDir}`
+ );
+ }
+ const relativeNextDistDir = relative(resolvedRootDir, configuredNextDistDir);
+ strictBinDirs = [
+ join(rootTlsClientDir, "bin"),
+ join(distTlsClientDir, "bin"),
+ join(configuredNextDistDir, "node_modules", "tls-client-node", "bin"),
+ ...(resolvedStandaloneDir
+ ? [
+ join(resolvedStandaloneDir, "node_modules", "tls-client-node", "bin"),
+ join(
+ resolvedStandaloneDir,
+ "projects",
+ "OmniRoute",
+ "node_modules",
+ "tls-client-node",
+ "bin"
+ ),
+ join(
+ resolvedStandaloneDir,
+ basename(resolvedRootDir),
+ "node_modules",
+ "tls-client-node",
+ "bin"
+ ),
+ join(
+ resolvedStandaloneDir,
+ relativeNextDistDir,
+ "node_modules",
+ "tls-client-node",
+ "bin"
+ ),
+ join(resolvedStandaloneDir, "runtime-assets", "tls-client", "bin"),
+ ]
+ : []),
+ ];
+ for (const binDir of new Set(strictBinDirs)) {
+ assertNativeAssetDirectoryInventory(rootDir, binDir, allowedNativeAssets);
+ }
+ }
+
+ let targetPlatform;
+ let targetArches;
+ try {
+ targetPlatform = normalizeTargetPlatform(platform);
+ targetArches = normalizeTargetArches(arches);
+ if (asset && targetArches.length !== 1) {
+ throw new Error("A synthetic tls-client asset can only be used with one target arch");
+ }
} catch (err) {
if (strict) throw err;
- console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`);
+ console.warn(` ⚠️ ${err.message}`);
+ return;
}
+
+ for (const targetArch of targetArches) {
+ let expectedAsset;
+ try {
+ expectedAsset = asset ?? resolveTargetNativeAsset(targetPlatform, targetArch, nativeAssets);
+ validateNativeAsset(expectedAsset);
+ } catch (err) {
+ if (strict) throw err;
+ console.warn(` ⚠️ ${err.message}`);
+ continue;
+ }
+
+ await fixTlsClientNodeTarget({
+ rootDir,
+ rootTlsClientDir,
+ distTlsClientDir,
+ expectedAsset,
+ targetPlatform,
+ targetArch,
+ version,
+ log,
+ retryDelaysMs,
+ downloadTimeoutMs,
+ fetchImpl,
+ strict,
+ standaloneDir,
+ requireStandalone,
+ afterSourceStat,
+ });
+ }
+
+ if (strict) {
+ for (const binDir of new Set(strictBinDirs)) {
+ assertNativeAssetDirectoryInventory(rootDir, binDir, allowedNativeAssets, true);
+ }
+ }
+}
+
+function readCliOptionValues(argv, optionNames) {
+ const values = [];
+ for (let index = 0; index < argv.length; index++) {
+ const argument = argv[index];
+ const matchingName = optionNames.find(
+ (optionName) => argument === optionName || argument.startsWith(`${optionName}=`)
+ );
+ if (!matchingName) continue;
+
+ if (argument === matchingName) {
+ const value = argv[index + 1];
+ if (!value || value.startsWith("--")) {
+ throw new Error(`${matchingName} requires a value`);
+ }
+ values.push(value);
+ index += 1;
+ } else {
+ const value = argument.slice(matchingName.length + 1);
+ if (!value) throw new Error(`${matchingName} requires a value`);
+ values.push(value);
+ }
+ }
+ return values;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
+ const cliArgs = process.argv.slice(2);
+ const standaloneValues = readCliOptionValues(cliArgs, ["--standalone-dir"]);
+ const platformValues = readCliOptionValues(cliArgs, ["--platform"]);
+ const archValues = readCliOptionValues(cliArgs, ["--arch", "--arches"]);
+ if (standaloneValues.length > 1) throw new Error("--standalone-dir may only be passed once");
+ if (platformValues.length > 1) throw new Error("--platform may only be passed once");
+
+ const standaloneDir = standaloneValues[0];
await fixTlsClientNodeBinary({
rootDir: process.cwd(),
- strict: process.argv.includes("--strict"),
+ strict: cliArgs.includes("--strict"),
+ platform: platformValues[0],
+ arches: archValues.length > 0 ? archValues : undefined,
+ standaloneDir,
+ requireStandalone: standaloneValues.length > 0,
});
} catch (err) {
console.error(` ❌ ${err.message}`);
diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts
index 79505024a5..141e9ee152 100644
--- a/scripts/build/pack-artifact-policy.ts
+++ b/scripts/build/pack-artifact-policy.ts
@@ -6,6 +6,32 @@
* directories out of the staged dist/ tree and out of the final tarball.
*/
+import {
+ resolveTlsClientNativeAsset,
+ TLS_CLIENT_NATIVE_ASSETS,
+} from "./fixTlsClientNodeBinary.mjs";
+
+export function resolveTlsClientRuntimeSeedPath(
+ platform: NodeJS.Platform = process.platform,
+ arch: string = process.arch
+): string {
+ const asset = resolveTlsClientNativeAsset(platform, arch);
+ return `runtime-assets/tls-client/bin/${asset.file}`;
+}
+
+export const TLS_CLIENT_RUNTIME_SEED_PATHS: string[] = Object.keys(TLS_CLIENT_NATIVE_ASSETS)
+ .sort()
+ .map((target) => {
+ const separatorIndex = target.indexOf("-");
+ if (separatorIndex <= 0 || separatorIndex === target.length - 1) {
+ throw new Error(`Invalid tls-client native manifest target: ${target}`);
+ }
+ return resolveTlsClientRuntimeSeedPath(
+ target.slice(0, separatorIndex) as NodeJS.Platform,
+ target.slice(separatorIndex + 1)
+ );
+ });
+
const STAGING_FORBIDDEN_DIRECTORIES = [
"app.__qa_backup",
"coverage",
@@ -34,6 +60,8 @@ export const APP_STAGING_REMOVAL_PATHS: string[] = [
export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"BUILD_SHA",
+ "LICENSE",
+ "THIRD_PARTY_NOTICES.md",
"docs/openapi.yaml",
// #7065: imported by dist/server-ws.mjs; assembleStandalone copies it but without
// this bare entry the prepublish prune deleted it → every `omniroute` boot of the
@@ -50,6 +78,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
"package.json",
"peer-stamp.mjs",
"main-server-timeouts.mjs",
+ "open-sse/config/tlsClientNativeManifest.json",
// server-ws.mjs import (sd_notify helper) — enforced by the closure test
// tests/unit/pack-artifact-server-ws-closure.test.ts.
"systemd-notify.mjs",
@@ -64,6 +93,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
// this bare entry the prepublish prune (Step 10.7) deletes it → `omniroute serve`
// crashes with ERR_MODULE_NOT_FOUND (regressed in the published 3.8.41 tarball).
"tls-options.mjs",
+ ...TLS_CLIENT_RUNTIME_SEED_PATHS,
"webdav-handler.mjs",
];
@@ -131,6 +161,8 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
"open-sse/utils/setupPolyfill.ts",
"package.json",
"scripts/build/assembleStandalone.mjs",
+ "scripts/build/standaloneSidecarCopy.mjs",
+ "scripts/build/tlsClientAssetCopy.mjs",
"scripts/build/backendOnlyPages.mjs",
"scripts/build/build-tproxy-native.mjs",
"scripts/build/build-next-isolated.mjs",
@@ -179,6 +211,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [
];
export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
+ "dist/LICENSE",
+ "dist/THIRD_PARTY_NOTICES.md",
+ "dist/open-sse/config/tlsClientNativeManifest.json",
"dist/open-sse/services/compression/engines/rtk/filters/generic-output.json",
"dist/src/lib/usage/callLogArtifactWorker.js",
"dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js",
@@ -191,6 +226,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
// server-ws.mjs import (sd_notify helper) — enforced by the closure test.
"dist/systemd-notify.mjs",
"dist/http-method-guard.cjs",
+ ...TLS_CLIENT_RUNTIME_SEED_PATHS.map((seedPath) => `dist/${seedPath}`),
// #5452: regression guard — make check:pack-artifact fail loudly if the TLS
// opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball.
"dist/tls-options.mjs",
@@ -229,6 +265,8 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"scripts/build/colocateOptionals.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/runtime-env.mjs",
+ "scripts/build/standaloneSidecarCopy.mjs",
+ "scripts/build/tlsClientAssetCopy.mjs",
// #10382: runtime imports of bin/cli/commands/packs.mjs (optional packs CLI) —
// listed REQUIRED so their absence from the tarball fails loudly.
"scripts/packs/optionalPackInstaller.mjs",
diff --git a/scripts/build/prepare-electron-standalone.mjs b/scripts/build/prepare-electron-standalone.mjs
index b929a942fb..804e09e3dc 100644
--- a/scripts/build/prepare-electron-standalone.mjs
+++ b/scripts/build/prepare-electron-standalone.mjs
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { assertSqlitePrebuildExists } from "./electronRebuildPlan.mjs";
import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs";
+import { fixTlsClientNodeBinary } from "./fixTlsClientNodeBinary.mjs";
import { stageOptionalPacks } from "./optionalPackStaging.mjs";
import { runBuildTool } from "./buildToolRunner.mjs";
@@ -17,6 +18,14 @@ const NEXT_DIST_DIR = process.env.NEXT_DIST_DIR || ".build/next";
const DIST_DIR = join(ROOT, NEXT_DIST_DIR);
const STANDALONE_DIR = join(DIST_DIR, "standalone");
const ELECTRON_STANDALONE_DIR = join(ROOT, ".build", "electron-standalone");
+const ELECTRON_TARGET_PLATFORM = process.env.OMNIROUTE_ELECTRON_TARGET_PLATFORM ?? process.platform;
+const configuredTargetArches = process.env.OMNIROUTE_ELECTRON_TARGET_ARCHES;
+const ELECTRON_TARGET_ARCHES =
+ configuredTargetArches === undefined
+ ? ELECTRON_TARGET_PLATFORM === "linux"
+ ? ["x64", "arm64"]
+ : [process.arch]
+ : configuredTargetArches.split(",").map((arch) => arch.trim());
// --- Electron-UNIQUE: resolve the nested server.js location ----------------
@@ -152,6 +161,15 @@ process.on("uncaughtException", logContextualError);
const bundleDir = resolveStandaloneBundleDir();
assertBundleIsPackagable(bundleDir);
+await fixTlsClientNodeBinary({
+ rootDir: ROOT,
+ strict: true,
+ platform: ELECTRON_TARGET_PLATFORM,
+ arches: ELECTRON_TARGET_ARCHES,
+ standaloneDir: STANDALONE_DIR,
+ requireStandalone: true,
+});
+
// Clean the stage dir before assembly
rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true });
@@ -236,6 +254,15 @@ await stageOptionalPacks({
log: (msg) => console.log(msg.replace(/^\[optional-packs\]/, "[electron]")),
});
+await fixTlsClientNodeBinary({
+ rootDir: ROOT,
+ strict: true,
+ platform: ELECTRON_TARGET_PLATFORM,
+ arches: ELECTRON_TARGET_ARCHES,
+ standaloneDir: ELECTRON_STANDALONE_DIR,
+ requireStandalone: true,
+});
+
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
);
diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts
index 41d6e867b3..fadb96cc62 100644
--- a/scripts/build/prepublish.ts
+++ b/scripts/build/prepublish.ts
@@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { isNativeExecutable, resolveLocalBinEntry } from "./buildToolRunner.mjs";
+import { fixTlsClientNodeBinary, TLS_CLIENT_NATIVE_ASSETS } from "./fixTlsClientNodeBinary.mjs";
import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts";
import {
APP_STAGING_ALLOWED_EXACT_PATHS,
@@ -88,6 +89,31 @@ function runBuildTool(
const DIST_DIR = join(ROOT, "dist");
const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n';
+const TLS_CLIENT_ARCHES_BY_PLATFORM = Object.keys(TLS_CLIENT_NATIVE_ASSETS).reduce<
+ Record
+>((targets, target) => {
+ const separatorIndex = target.indexOf("-");
+ if (separatorIndex <= 0 || separatorIndex === target.length - 1) {
+ throw new Error(`Invalid tls-client native manifest target: ${target}`);
+ }
+ const platform = target.slice(0, separatorIndex);
+ const arch = target.slice(separatorIndex + 1);
+ (targets[platform] ??= []).push(arch);
+ return targets;
+}, {});
+
+async function verifyAllTlsClientRuntimeSeeds(targetStandaloneDir: string): Promise {
+ for (const [platform, arches] of Object.entries(TLS_CLIENT_ARCHES_BY_PLATFORM)) {
+ await fixTlsClientNodeBinary({
+ rootDir: ROOT,
+ platform,
+ arches,
+ standaloneDir: targetStandaloneDir,
+ strict: true,
+ requireStandalone: true,
+ });
+ }
+}
function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] {
let entries: string[] = [];
@@ -169,7 +195,8 @@ if (existsSync(DIST_DIR)) {
// .build/next/standalone artifact produced by `npm run build` (build-next-isolated.mjs).
// If the artifact is absent we invoke it exactly once.
const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next";
-const standaloneServerJs = join(ROOT, NEXT_DIST, "standalone", "server.js");
+const standaloneDir = join(ROOT, NEXT_DIST, "standalone");
+const standaloneServerJs = join(standaloneDir, "server.js");
if (!existsSync(standaloneServerJs)) {
console.log(" 🏗️ .build/next/standalone not found — running `npm run build` once...");
execFileSync(process.execPath, ["scripts/build/build-next-isolated.mjs"], {
@@ -187,6 +214,9 @@ if (!existsSync(standaloneServerJs)) {
}
console.log(" ✅ Standalone artifact present:", standaloneServerJs);
+console.log(" 🔐 Verifying every pinned TLS client runtime seed...");
+await verifyAllTlsClientRuntimeSeeds(standaloneDir);
+
// ── Step 3–7: Assemble standalone into dist/ ───────────────
// All shared copy/sync/sanitize/chunk-patch operations are delegated to
// assembleStandalone. npm-UNIQUE steps (MITM, MCP, CLI, sidecars) follow.
@@ -707,6 +737,9 @@ if (remainingUnexpectedFiles.length > 0) {
process.exit(1);
}
+console.log(" 🔐 Re-verifying every staged TLS client runtime seed after pruning...");
+await verifyAllTlsClientRuntimeSeeds(DIST_DIR);
+
// ── Done ───────────────────────────────────────────────────
const distPkg = join(DIST_DIR, "package.json");
if (existsSync(distPkg)) {
diff --git a/scripts/build/standaloneSidecarCopy.mjs b/scripts/build/standaloneSidecarCopy.mjs
new file mode 100644
index 0000000000..105dbfe2dd
--- /dev/null
+++ b/scripts/build/standaloneSidecarCopy.mjs
@@ -0,0 +1,121 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import { copyVerifiedTlsClientNativeAsset } from "./tlsClientAssetCopy.mjs";
+
+/**
+ * A bulk standalone copy may already have carried a source symlink or a stale
+ * node with the wrong type into a sidecar destination. Skip an identical real
+ * target; otherwise clear the direct stale node before the explicit copy.
+ */
+export function resolvesToSamePath(src, dest) {
+ if (path.resolve(src) === path.resolve(dest)) return true;
+ if (!fs.existsSync(dest)) return false;
+ try {
+ return fs.realpathSync(src) === fs.realpathSync(dest);
+ } catch {
+ // An unresolved path cannot be proven identical, so use the normal stale-destination copy path.
+ return false;
+ }
+}
+
+export function clearStaleDest(dest) {
+ try {
+ fs.lstatSync(dest);
+ } catch {
+ // A missing destination is clear; later copy operations surface non-ENOENT access failures.
+ return;
+ }
+ fs.rmSync(dest, { recursive: true, force: true });
+}
+
+/**
+ * Copy registered native assets and runtime sidecars into an assembled bundle.
+ * TLS entries take the digest-verified path; ordinary entries retain the
+ * existing recursive-copy behavior.
+ *
+ * @param {{projectRoot:string, outDir:string, nativeAssetEntries:{label:string,src:string[],dest:string[],tlsClientSha256?:string}[], extraModuleEntries:{label:string,src:string[],dest:string[]}[]}} options
+ */
+export function copyNativeAssetsAndExtraModules({
+ projectRoot,
+ outDir,
+ nativeAssetEntries,
+ extraModuleEntries,
+}) {
+ for (const asset of nativeAssetEntries) {
+ const src = path.join(projectRoot, ...asset.src);
+ const dest = path.join(outDir, ...asset.dest);
+ if (asset.tlsClientSha256) {
+ const copied = copyVerifiedTlsClientNativeAsset({
+ sourceRoot: projectRoot,
+ sourcePath: src,
+ destinationPath: dest,
+ expectedSha256: asset.tlsClientSha256,
+ outDir,
+ });
+ if (copied) console.log(`[assembleStandalone] Copied verified native asset: ${asset.label}`);
+ continue;
+ }
+ if (!fs.existsSync(src) || resolvesToSamePath(src, dest)) continue;
+ clearStaleDest(dest);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.cpSync(src, dest, { recursive: true, force: true });
+ console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
+ }
+
+ for (const mod of extraModuleEntries) {
+ const src = path.join(projectRoot, ...mod.src);
+ if (!fs.existsSync(src)) continue;
+ const dest = path.join(outDir, ...mod.dest);
+ if (resolvesToSamePath(src, dest)) continue;
+ clearStaleDest(dest);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.cpSync(src, dest, { recursive: true, force: true });
+ console.log(`[assembleStandalone] Synced module: ${mod.label}`);
+ }
+}
+
+/** Repair hollow top-level external package directories emitted by Next/Turbopack. */
+export function repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules) {
+ const summary = { repaired: 0, packages: [] };
+ const sourceNodeModules = path.join(projectRoot, "node_modules");
+ if (!fs.existsSync(bundleNodeModules) || !fs.existsSync(sourceNodeModules)) return summary;
+
+ for (const name of fs.readdirSync(bundleNodeModules)) {
+ if (name.startsWith(".") || name.startsWith("@")) continue;
+ const bundlePkgDir = path.join(bundleNodeModules, name);
+ const sourcePkgDir = path.join(sourceNodeModules, name);
+
+ let bundleStat;
+ try {
+ bundleStat = fs.statSync(bundlePkgDir);
+ } catch {
+ // An unreadable or vanished entry cannot be classified safely as a hollow repair candidate.
+ continue;
+ }
+ if (!bundleStat.isDirectory()) continue;
+
+ let bundleEntries;
+ try {
+ bundleEntries = fs.readdirSync(bundlePkgDir);
+ } catch {
+ // Without a readable listing we cannot prove the destination is hollow enough to replace.
+ continue;
+ }
+ if (bundleEntries.length > 0 || !fs.existsSync(sourcePkgDir)) continue;
+
+ let sourceStat;
+ try {
+ sourceStat = fs.statSync(sourcePkgDir);
+ } catch {
+ // An unreadable or vanished source cannot safely repair this optional package copy.
+ continue;
+ }
+ if (!sourceStat.isDirectory() || resolvesToSamePath(sourcePkgDir, bundlePkgDir)) continue;
+ clearStaleDest(bundlePkgDir);
+ fs.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true });
+ summary.repaired += 1;
+ summary.packages.push(name);
+ }
+ return summary;
+}
diff --git a/scripts/build/tlsClientAssetCopy.mjs b/scripts/build/tlsClientAssetCopy.mjs
new file mode 100644
index 0000000000..4a7f32f33c
--- /dev/null
+++ b/scripts/build/tlsClientAssetCopy.mjs
@@ -0,0 +1,446 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+
+const MAX_NATIVE_ASSET_BYTES = 64 * 1024 * 1024;
+const READ_CHUNK_BYTES = 64 * 1024;
+
+/**
+ * Convert the pinned manifest into copy entries while validating every name and
+ * digest. Keeping the digest on the entry makes the async and sync assemblers
+ * consume one source of truth.
+ *
+ * @param {Record} nativeAssets
+ * @returns {{label:string, src:string[], dest:string[], tlsClientSha256:string}[]}
+ */
+export function createTlsClientNativeAssetEntries(nativeAssets) {
+ const entriesByFile = new Map();
+ for (const asset of Object.values(nativeAssets)) {
+ const file = asset?.file;
+ if (
+ typeof file !== "string" ||
+ file.length === 0 ||
+ file === "." ||
+ file === ".." ||
+ path.basename(file) !== file ||
+ file.includes("/") ||
+ file.includes("\\") ||
+ file.includes("\0")
+ ) {
+ throw new Error(`Invalid tls-client native asset path: ${JSON.stringify(file)}`);
+ }
+ if (!/^[a-f0-9]{64}$/.test(asset.sha256)) {
+ throw new Error(`Invalid SHA-256 in tls-client native manifest for ${file}`);
+ }
+ const prior = entriesByFile.get(file);
+ if (prior && prior.tlsClientSha256 !== asset.sha256) {
+ throw new Error(`Conflicting SHA-256 values in tls-client native manifest for ${file}`);
+ }
+ entriesByFile.set(file, {
+ label: `manifest-declared tls-client native runtime seed (${file})`,
+ src: ["node_modules", "tls-client-node", "bin", file],
+ // Keep the public bootstrap seed outside DATA_DIR: Docker deployments
+ // commonly mount an empty /app/data volume, which must not hide it.
+ dest: ["runtime-assets", "tls-client", "bin", file],
+ tlsClientSha256: asset.sha256,
+ });
+ }
+ return [...entriesByFile.values()];
+}
+
+function lstatIfPresent(filePath) {
+ try {
+ return fs.lstatSync(filePath);
+ } catch (error) {
+ if (error?.code === "ENOENT") return undefined;
+ throw error;
+ }
+}
+
+function sameFileIdentity(left, right) {
+ return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
+}
+
+function assertNativeAssetSize(size, filePath) {
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_NATIVE_ASSET_BYTES) {
+ throw new Error(
+ `[assembleStandalone] tls-client native asset exceeds the 64 MiB limit: ${filePath}`
+ );
+ }
+}
+
+/**
+ * Read a native seed through a no-follow descriptor and cap the read itself,
+ * not merely the initial stat. This also detects path replacement during the
+ * read before a digest can authorize the bytes.
+ *
+ * @param {string} filePath
+ * @returns {Buffer|undefined}
+ */
+function readBoundedRegularAsset(filePath) {
+ const pathStats = lstatIfPresent(filePath);
+ if (!pathStats) return undefined;
+ if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native asset (symlink/non-regular file): ${filePath}`
+ );
+ }
+
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
+ try {
+ const openedStats = fs.fstatSync(fd);
+ if (!openedStats.isFile() || !sameFileIdentity(pathStats, openedStats)) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native asset changed before read: ${filePath}`
+ );
+ }
+ assertNativeAssetSize(openedStats.size, filePath);
+
+ const chunks = [];
+ let totalBytes = 0;
+ while (true) {
+ const remainingWithSentinel = MAX_NATIVE_ASSET_BYTES - totalBytes + 1;
+ const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remainingWithSentinel));
+ const bytesRead = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
+ if (bytesRead === 0) break;
+ totalBytes += bytesRead;
+ assertNativeAssetSize(totalBytes, filePath);
+ chunks.push(chunk.subarray(0, bytesRead));
+ }
+
+ const finalDescriptorStats = fs.fstatSync(fd);
+ const finalPathStats = fs.lstatSync(filePath);
+ if (
+ !finalDescriptorStats.isFile() ||
+ finalPathStats.isSymbolicLink() ||
+ !finalPathStats.isFile() ||
+ !sameFileIdentity(openedStats, finalDescriptorStats) ||
+ !sameFileIdentity(openedStats, finalPathStats)
+ ) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native asset changed during read: ${filePath}`
+ );
+ }
+ assertNativeAssetSize(finalDescriptorStats.size, filePath);
+ if (finalDescriptorStats.size !== totalBytes) {
+ throw new Error(
+ `[assembleStandalone] tls-client native asset size changed during read: ${filePath}`
+ );
+ }
+ return Buffer.concat(chunks, totalBytes);
+ } finally {
+ fs.closeSync(fd);
+ }
+}
+
+function assertDigest(bytes, expectedSha256, filePath) {
+ const actualSha256 = createHash("sha256").update(bytes).digest("hex");
+ if (actualSha256 !== expectedSha256) {
+ throw new Error(
+ `[assembleStandalone] SHA-256 mismatch for tls-client native asset: ${filePath}`
+ );
+ }
+}
+
+function assertSafeExistingDestinationMode(filePath) {
+ if (process.platform === "win32") return;
+ const mode = fs.lstatSync(filePath).mode & 0o777;
+ if (mode !== 0o555) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native destination mode ` +
+ `(expected 0555, received ${mode.toString(8).padStart(4, "0")}): ${filePath}`
+ );
+ }
+}
+
+function assertContainedPath(rootPath, candidatePath, label) {
+ const root = path.resolve(rootPath);
+ const candidate = path.resolve(candidatePath);
+ const relativeCandidate = path.relative(root, candidate);
+ if (
+ relativeCandidate === "" ||
+ path.isAbsolute(relativeCandidate) ||
+ relativeCandidate === ".." ||
+ relativeCandidate.startsWith(`..${path.sep}`)
+ ) {
+ throw new Error(`[assembleStandalone] unsafe tls-client native ${label}: ${candidate}`);
+ }
+ return { root, candidate, relativeCandidate };
+}
+
+function pathEscapesRoot(rootPath, candidatePath) {
+ const relativePath = path.relative(rootPath, candidatePath);
+ return (
+ path.isAbsolute(relativePath) ||
+ relativePath === ".." ||
+ relativePath.startsWith(`..${path.sep}`)
+ );
+}
+
+function readSafeDirectoryIdentity(directoryPath, label) {
+ const initialStats = lstatIfPresent(directoryPath);
+ if (!initialStats) return undefined;
+ if (initialStats.isSymbolicLink() || !initialStats.isDirectory()) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native ${label} ` +
+ `(symlink/non-directory): ${directoryPath}`
+ );
+ }
+ const canonicalPath = fs.realpathSync(directoryPath);
+ const finalStats = fs.lstatSync(directoryPath);
+ if (
+ finalStats.isSymbolicLink() ||
+ !finalStats.isDirectory() ||
+ !sameFileIdentity(initialStats, finalStats)
+ ) {
+ throw new Error(
+ `[assembleStandalone] tls-client native ${label} changed during verification: ${directoryPath}`
+ );
+ }
+ return canonicalPath;
+}
+
+function assertSafeAncestorChain(
+ rootPath,
+ candidatePath,
+ { label, createMissing = false, missingIsError = true }
+) {
+ const { root, relativeCandidate } = assertContainedPath(
+ rootPath,
+ candidatePath,
+ `${label} outside trusted root`
+ );
+ if (!lstatIfPresent(root) && createMissing) fs.mkdirSync(root, { recursive: true });
+ const canonicalRoot = readSafeDirectoryIdentity(root, `${label} root`);
+ if (!canonicalRoot) {
+ if (!missingIsError) return false;
+ throw new Error(`[assembleStandalone] tls-client native ${label} root missing: ${root}`);
+ }
+
+ let current = root;
+ for (const component of path.dirname(relativeCandidate).split(path.sep).filter(Boolean)) {
+ current = path.join(current, component);
+ if (!lstatIfPresent(current) && createMissing) fs.mkdirSync(current);
+ const canonicalCurrent = readSafeDirectoryIdentity(current, `${label} ancestor`);
+ if (!canonicalCurrent) {
+ if (!missingIsError) return false;
+ throw new Error(
+ `[assembleStandalone] tls-client native ${label} ancestor missing: ${current}`
+ );
+ }
+ if (pathEscapesRoot(canonicalRoot, canonicalCurrent)) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native ${label} ancestor outside root: ${current}`
+ );
+ }
+ }
+ return true;
+}
+
+function assertSafeSourceAncestors(sourceRoot, sourcePath) {
+ assertSafeAncestorChain(sourceRoot, sourcePath, { label: "source" });
+}
+
+function ensureSafeDestinationParent(outDir, destinationPath) {
+ assertSafeAncestorChain(outDir, destinationPath, {
+ label: "destination",
+ createMissing: true,
+ });
+}
+
+function revalidateSafeDestinationParent(outDir, destinationPath) {
+ return assertSafeAncestorChain(outDir, destinationPath, {
+ label: "destination",
+ missingIsError: false,
+ });
+}
+
+function assertStillSafeDestinationParent(outDir, destinationPath) {
+ if (!revalidateSafeDestinationParent(outDir, destinationPath)) {
+ throw new Error(
+ `[assembleStandalone] tls-client native destination ancestor disappeared: ${destinationPath}`
+ );
+ }
+}
+
+function removeDirectDestinationSafely(outDir, destinationPath) {
+ try {
+ if (!revalidateSafeDestinationParent(outDir, destinationPath)) return false;
+ if (!lstatIfPresent(destinationPath)) return true;
+ if (!revalidateSafeDestinationParent(outDir, destinationPath)) return false;
+ fs.rmSync(destinationPath, { recursive: true, force: true });
+ return revalidateSafeDestinationParent(outDir, destinationPath);
+ } catch {
+ // The chain may now resolve through a symlink. Do not traverse it merely
+ // to clean up: preserving an external file is safer than an unsafe rmSync.
+ return false;
+ }
+}
+
+function auditNativeBin(outDir, binDir, assetsByFile) {
+ const auditSentinel = path.join(binDir, ".tls-client-native-audit");
+ if (!revalidateSafeDestinationParent(outDir, auditSentinel)) return;
+
+ for (const entryName of fs.readdirSync(binDir)) {
+ const entryPath = path.join(binDir, entryName);
+ try {
+ const expectedSha256 = assetsByFile.get(entryName);
+ if (!expectedSha256) {
+ throw new Error(`[assembleStandalone] unlisted tls-client native sibling: ${entryPath}`);
+ }
+ assertStillSafeDestinationParent(outDir, entryPath);
+ const bytes = readBoundedRegularAsset(entryPath);
+ assertStillSafeDestinationParent(outDir, entryPath);
+ if (!bytes) {
+ throw new Error(
+ `[assembleStandalone] tls-client native bundle entry disappeared: ${entryPath}`
+ );
+ }
+ assertDigest(bytes, expectedSha256, entryPath);
+ assertSafeExistingDestinationMode(entryPath);
+ assertStillSafeDestinationParent(outDir, entryPath);
+ } catch (error) {
+ removeDirectDestinationSafely(outDir, entryPath);
+ throw error;
+ }
+ }
+ assertStillSafeDestinationParent(outDir, auditSentinel);
+}
+
+/**
+ * Audit every tls-client bin topology that Next.js may bulk-copy into a
+ * standalone output. This prevents a valid manifest filename with unauthorized
+ * bytes from surviving outside runtime-assets merely because a later platform
+ * gate verifies only selected target arches.
+ *
+ * @param {{outDir:string, projectRoot:string, relativeNextDistDir:string, nativeAssets:Record}} options
+ */
+export function auditTlsClientStandaloneBundle({
+ outDir,
+ projectRoot,
+ relativeNextDistDir,
+ nativeAssets,
+}) {
+ const assetsByFile = new Map(
+ createTlsClientNativeAssetEntries(nativeAssets).map((entry) => [
+ entry.src.at(-1),
+ entry.tlsClientSha256,
+ ])
+ );
+ const projectBasename = path.basename(path.resolve(projectRoot));
+ const binDirs = [
+ path.join(outDir, "node_modules", "tls-client-node", "bin"),
+ path.join(outDir, "projects", "OmniRoute", "node_modules", "tls-client-node", "bin"),
+ path.join(outDir, projectBasename, "node_modules", "tls-client-node", "bin"),
+ path.join(outDir, relativeNextDistDir, "node_modules", "tls-client-node", "bin"),
+ path.join(outDir, "runtime-assets", "tls-client", "bin"),
+ ];
+ for (const binDir of new Set(binDirs)) auditNativeBin(outDir, binDir, assetsByFile);
+}
+
+/**
+ * Copy only bytes authorized by the pinned digest, then independently read and
+ * verify the emitted file. Any failed verification removes the direct output so
+ * a failed assembly cannot leave a distributable manifest-named seed behind.
+ *
+ * @param {{sourceRoot:string, sourcePath:string, destinationPath:string, expectedSha256:string, outDir:string}} options
+ * @returns {boolean} true only when source bytes were copied
+ */
+export function copyVerifiedTlsClientNativeAsset({
+ sourceRoot,
+ sourcePath,
+ destinationPath,
+ expectedSha256,
+ outDir,
+}) {
+ ensureSafeDestinationParent(outDir, destinationPath);
+ try {
+ const sourcePathStats = lstatIfPresent(sourcePath);
+ let sourceBytes;
+ if (sourcePathStats) {
+ assertSafeSourceAncestors(sourceRoot, sourcePath);
+ sourceBytes = readBoundedRegularAsset(sourcePath);
+ assertSafeSourceAncestors(sourceRoot, sourcePath);
+ if (!sourceBytes) {
+ throw new Error(
+ `[assembleStandalone] tls-client native source disappeared during verification: ${sourcePath}`
+ );
+ }
+ }
+ if (!sourceBytes) {
+ // assembleStandalone first bulk-copies a prior Next standalone tree.
+ // If the source install is now absent, a manifest-named seed may still
+ // have arrived through that pass; authorize it independently or fail and
+ // remove it instead of silently distributing stale bytes.
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ const existingDestinationBytes = readBoundedRegularAsset(destinationPath);
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ if (!existingDestinationBytes) return false;
+ assertDigest(existingDestinationBytes, expectedSha256, destinationPath);
+ assertSafeExistingDestinationMode(destinationPath);
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ return false;
+ }
+ assertDigest(sourceBytes, expectedSha256, sourcePath);
+
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ const destinationStats = lstatIfPresent(destinationPath);
+ if (destinationStats?.isSymbolicLink() || (destinationStats && !destinationStats.isFile())) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native destination (symlink/non-regular file): ${destinationPath}`
+ );
+ }
+ if (!removeDirectDestinationSafely(outDir, destinationPath)) {
+ throw new Error(
+ `[assembleStandalone] unsafe tls-client native destination changed before removal: ${destinationPath}`
+ );
+ }
+ assertStillSafeDestinationParent(outDir, destinationPath);
+
+ const fd = fs.openSync(
+ destinationPath,
+ fs.constants.O_WRONLY |
+ fs.constants.O_CREAT |
+ fs.constants.O_EXCL |
+ (fs.constants.O_NOFOLLOW ?? 0),
+ 0o555
+ );
+ try {
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ let offset = 0;
+ while (offset < sourceBytes.length) {
+ const bytesWritten = fs.writeSync(fd, sourceBytes, offset, sourceBytes.length - offset);
+ if (bytesWritten <= 0) {
+ throw new Error(
+ `[assembleStandalone] failed to write tls-client native asset: ${destinationPath}`
+ );
+ }
+ offset += bytesWritten;
+ }
+ if (process.platform !== "win32") fs.fchmodSync(fd, 0o555);
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ } finally {
+ fs.closeSync(fd);
+ }
+
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ const destinationBytes = readBoundedRegularAsset(destinationPath);
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ if (!destinationBytes) {
+ throw new Error(
+ `[assembleStandalone] copied tls-client native asset disappeared: ${destinationPath}`
+ );
+ }
+ assertDigest(destinationBytes, expectedSha256, destinationPath);
+ if (!sourceBytes.equals(destinationBytes)) {
+ throw new Error(
+ `[assembleStandalone] copied tls-client native asset differs from source: ${destinationPath}`
+ );
+ }
+ assertStillSafeDestinationParent(outDir, destinationPath);
+ return true;
+ } catch (error) {
+ removeDirectDestinationSafely(outDir, destinationPath);
+ throw error;
+ }
+}
diff --git a/scripts/check/check-licenses.mjs b/scripts/check/check-licenses.mjs
index 3c265144bc..f5ccd57747 100644
--- a/scripts/check/check-licenses.mjs
+++ b/scripts/check/check-licenses.mjs
@@ -35,7 +35,7 @@ const PRINT_JSON = process.argv.includes("--json");
/**
* Loads and returns the license allowlist from .license-allowlist.json.
*
- * @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record }}
+ * @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record }}
*/
export function loadAllowlist() {
if (!fs.existsSync(ALLOWLIST_PATH)) {
@@ -59,34 +59,116 @@ export function loadAllowlist() {
/**
* Classifies a package+license against the allowlist.
*
- * @param {string} packageName - Package name without version, e.g. "lightningcss"
+ * @param {string} packageName - Package key including version when available, e.g. "tls-client-node@0.2.0"
* @param {string} license - License string from license-checker, e.g. "MPL-2.0"
* @param {{ allowed: string[], allowedExpressions: string[], exceptions: Record }} allowlist
+ * @param {{ now?: Date }} [options]
* @returns {{ status: "allowed" | "exception" | "denied", reason: string }}
*/
-export function classifyLicense(packageName, license, allowlist) {
+export function classifyLicense(packageName, license, allowlist, { now = new Date() } = {}) {
const { allowed, allowedExpressions, exceptions } = allowlist;
-
- // 1. Direct SPDX match
- if (allowed.includes(license)) {
- return { status: "allowed", reason: `SPDX match: ${license}` };
- }
-
- // 2. Expression match (e.g. "(MIT OR Apache-2.0)")
- if (allowedExpressions.includes(license)) {
- return { status: "allowed", reason: `allowed expression: ${license}` };
- }
-
- // 3. Per-package exception (strip version suffix for lookup)
const baseName = stripVersion(packageName);
+
+ // 1. A package-specific exception is an overlay on the global policy. It
+ // must be evaluated first so a newly detected globally allowed SPDX id
+ // cannot bypass the exception's exact-license, ownership, or expiry gates.
if (exceptions[baseName]) {
const exc = exceptions[baseName];
+ if (typeof exc.license !== "string" || !exc.license.trim()) {
+ return {
+ status: "denied",
+ reason: `invalid exception license for '${baseName}': expected a non-empty string`,
+ };
+ }
+ if (license !== exc.license) {
+ return {
+ status: "denied",
+ reason: `license '${license}' does not match exception license '${exc.license}' for '${baseName}'`,
+ };
+ }
+ if (Object.prototype.hasOwnProperty.call(exc, "version")) {
+ if (
+ typeof exc.version !== "string" ||
+ !exc.version.trim() ||
+ exc.version !== exc.version.trim() ||
+ /[@\s]/.test(exc.version)
+ ) {
+ return {
+ status: "denied",
+ reason: `invalid exception version for '${baseName}': expected an exact non-empty package version`,
+ };
+ }
+
+ const versionSuffix = packageName.slice(baseName.length);
+ if (!versionSuffix) {
+ return {
+ status: "denied",
+ reason: `version-pinned exception for '${baseName}' requires package key '${baseName}@${exc.version}', but '${packageName}' has no version`,
+ };
+ }
+ const detectedVersion = versionSuffix.startsWith("@") ? versionSuffix.slice(1) : "";
+ if (!detectedVersion || /[@\s]/.test(detectedVersion)) {
+ return {
+ status: "denied",
+ reason: `version-pinned exception for '${baseName}' cannot evaluate malformed package key '${packageName}'`,
+ };
+ }
+ if (detectedVersion !== exc.version) {
+ return {
+ status: "denied",
+ reason: `package version '${detectedVersion}' does not match exception version '${exc.version}' for '${baseName}'`,
+ };
+ }
+ }
+ if (exc.temporary === true) {
+ if (typeof exc.owner !== "string" || !exc.owner.trim()) {
+ return {
+ status: "denied",
+ reason: `temporary exception for '${baseName}' has no owner`,
+ };
+ }
+ const reviewBy = exc.reviewBy;
+ const isDateOnly = typeof reviewBy === "string" && /^\d{4}-\d{2}-\d{2}$/.test(reviewBy);
+ const deadline = isDateOnly ? new Date(`${reviewBy}T23:59:59.999Z`) : new Date(NaN);
+ const isRealCalendarDate =
+ isDateOnly &&
+ !Number.isNaN(deadline.getTime()) &&
+ deadline.toISOString().slice(0, 10) === reviewBy;
+ if (!isRealCalendarDate) {
+ return {
+ status: "denied",
+ reason: `temporary exception for '${baseName}' has invalid reviewBy metadata`,
+ };
+ }
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
+ return {
+ status: "denied",
+ reason: `temporary exception for '${baseName}' cannot be evaluated with an invalid clock`,
+ };
+ }
+ if (now.getTime() > deadline.getTime()) {
+ return {
+ status: "denied",
+ reason: `temporary exception for '${baseName}' expired at reviewBy=${reviewBy}`,
+ };
+ }
+ }
return {
status: "exception",
reason: `exception: ${exc.justification} [risk=${exc.risk}]`,
};
}
+ // 2. Direct SPDX match
+ if (allowed.includes(license)) {
+ return { status: "allowed", reason: `SPDX match: ${license}` };
+ }
+
+ // 3. Expression match (e.g. "(MIT OR Apache-2.0)")
+ if (allowedExpressions.includes(license)) {
+ return { status: "allowed", reason: `allowed expression: ${license}` };
+ }
+
// 4. Denied
return {
status: "denied",
diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts
index 77129ec05b..4392e7b71e 100644
--- a/src/app/api/providers/validate/route.ts
+++ b/src/app/api/providers/validate/route.ts
@@ -1,4 +1,6 @@
import { NextResponse } from "next/server";
+import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
+import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderNodeById } from "@/models";
@@ -8,10 +10,10 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { validateProviderApiKey } from "@/lib/providers/validation";
-import { getProxyForLevel, resolveProxyForProvider } from "@/lib/localDb";
+import { resolveProxyForProvider } from "@/lib/db/proxies";
+import { getProxyForLevel } from "@/lib/db/settings";
import { validateProviderApiKeySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
function sanitizeAuditUrl(url: string | null | undefined) {
if (!url) return null;
@@ -169,7 +171,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 });
}
}
diff --git a/src/lib/logPayloads.ts b/src/lib/logPayloads.ts
index f97e338259..bdc732440b 100644
--- a/src/lib/logPayloads.ts
+++ b/src/lib/logPayloads.ts
@@ -35,6 +35,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;
const ENCRYPTED_REASONING_KEY = "encrypted_content";
@@ -125,7 +140,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]";
diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts
index c486ff41fc..63c35eff4b 100644
--- a/src/lib/providers/validation/transport.ts
+++ b/src/lib/providers/validation/transport.ts
@@ -1,6 +1,8 @@
// 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/error.ts";
+import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
import {
SAFE_OUTBOUND_FETCH_PRESETS,
SafeOutboundFetchError,
@@ -9,7 +11,22 @@ import {
} from "@/shared/network/safeOutboundFetch";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
-import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
+
+export function readProxyFallbackErrorState(error: unknown): {
+ isNetworkIssue: boolean;
+ isRetryable: boolean;
+} {
+ try {
+ const fetchError = error as { code?: unknown; isRetryable?: unknown } | null | undefined;
+ return {
+ isNetworkIssue: fetchError?.code === "NETWORK_ERROR" || fetchError?.code === "TIMEOUT",
+ isRetryable: fetchError?.isRetryable !== false,
+ };
+ } catch {
+ // Hostile accessors are not trustworthy enough to authorize a second outbound attempt.
+ return { isNetworkIssue: false, isRetryable: false };
+ }
+}
/**
* Wrapped fetch call that auto-retries with a proxy when the direct connection
@@ -31,9 +48,7 @@ export async function fetchWithProxyFallback(
} catch (err: unknown) {
// Only attempt proxy fallback for retryable errors (network / timeout)
// and only when the target is not a local / LAN address.
- const fetchErr = err as SafeOutboundFetchError;
- const isNetworkIssue = fetchErr?.code === "NETWORK_ERROR" || fetchErr?.code === "TIMEOUT";
- const isRetryable = fetchErr?.isRetryable !== false;
+ const { isNetworkIssue, isRetryable } = readProxyFallbackErrorState(err);
const isValidTarget = !isLocal && isRetryableProxyTarget(url);
if (isLocal || !isNetworkIssue || !isRetryable) throw err;
@@ -80,15 +95,15 @@ export async function validationWrite(url: string, init: RequestInit, isLocal: b
// (#3288 / #3758). Only treat a blocked redirect as a security event when its target is
// a private/internal host.
export function isSecurityBlockError(error: unknown): boolean {
- if (!(error instanceof SafeOutboundFetchError)) return false;
- if (error.code === "URL_GUARD_BLOCKED" || error.code === "INVALID_URL") return true;
- if (error.code === "REDIRECT_BLOCKED") {
- if (!error.location) return false;
- try {
+ try {
+ if (!isSafeOutboundFetchError(error)) return false;
+ if (error.code === "URL_GUARD_BLOCKED" || error.code === "INVALID_URL") return true;
+ if (error.code === "REDIRECT_BLOCKED") {
+ if (!error.location) return false;
return isPrivateHost(new URL(error.location, error.url).hostname);
- } catch {
- return false;
}
+ } catch {
+ // Hostile error prototypes/accessors are never evidence of an SSRF block.
}
return false;
}
@@ -107,6 +122,15 @@ export function isSecurityBlockError(error: unknown): boolean {
// #7542 plan-file, "Risks").
const WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE = new Set(["lmarena"]);
+export function isSafeOutboundFetchError(error: unknown): error is SafeOutboundFetchError {
+ try {
+ return error instanceof SafeOutboundFetchError;
+ } catch {
+ // A rejected Proxy may throw while instanceof walks its prototype chain.
+ return false;
+ }
+}
+
// #7857 — web-cookie providers whose registry `baseUrl` is a conversation/completion
// endpoint, not a real API root (e.g. huggingchat's baseUrl is
// "https://huggingface.co/chat/conversation", not "https://huggingface.co"). Appending
@@ -130,32 +154,49 @@ export const WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API = new Set([
]);
export function toWebCookieValidationErrorResult(provider: string, error: unknown) {
- if (
- error instanceof SafeOutboundFetchError &&
- error.code === "REDIRECT_BLOCKED" &&
- WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE.has(provider)
- ) {
- return {
- valid: false,
- error: "Provider validation not supported",
- unsupported: true as const,
- };
+ try {
+ if (
+ isSafeOutboundFetchError(error) &&
+ error.code === "REDIRECT_BLOCKED" &&
+ WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE.has(provider)
+ ) {
+ return {
+ valid: false,
+ error: "Provider validation not supported",
+ unsupported: true as const,
+ };
+ }
+ } catch {
+ // Hostile error accessors must degrade to the generic validation result below.
}
return toValidationErrorResult(error);
}
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 = isSafeOutboundFetchError(error) && 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 } : {}),
};
}
diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts
index 52e9aa4c0e..0b30c334c6 100644
--- a/src/lib/providers/validation/webProvidersA.ts
+++ b/src/lib/providers/validation/webProvidersA.ts
@@ -1,9 +1,7 @@
// Web-cookie provider key validators (part A): deepseek-web, qwen-web, grok-web, chatgpt-web,
// perplexity-web, blackbox-web. Extracted from validation.ts (god-file decomposition) — top-level
-// functions with no dispatcher-state captures; behavior is byte-identical to the original inline defs.
-import { addModelsSuffix } from "./urlHelpers";
-import { applyCustomUserAgent } from "./headers";
-import { toValidationErrorResult, validationRead, validationWrite } from "./transport";
+// functions with no dispatcher-state captures; behavior is regression-tested in this module.
+import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "@omniroute/open-sse/utils/error.ts";
import {
buildGrokCookieHeader,
buildQwenCookieHeader,
@@ -12,6 +10,34 @@ import {
extractQwenToken,
normalizeSessionCookieHeader,
} from "@/lib/providers/webCookieAuth";
+import { applyCustomUserAgent } from "./headers";
+import { toValidationErrorResult, validationRead, validationWrite } from "./transport";
+
+interface ErrorInstanceClassifier {
+ [Symbol.hasInstance](value: unknown): boolean;
+}
+
+function isErrorInstance(error: unknown, classifier: ErrorInstanceClassifier): boolean {
+ try {
+ return classifier[Symbol.hasInstance](error);
+ } catch {
+ // A rejected Proxy may throw while the classifier walks its prototype chain.
+ return false;
+ }
+}
+
+function sanitizeValidationThrownError(error: unknown): string {
+ let candidate = error;
+ try {
+ if (isErrorInstance(error, Error)) {
+ const message = (error as { message?: unknown }).message;
+ if (typeof message === "string") candidate = message;
+ }
+ } catch {
+ // Keep the unknown value for the canonical fail-closed sanitizer.
+ }
+ return sanitizeErrorMessage(candidate);
+}
// kimi-web uses the international (west-facing) `www.kimi.ai` Connect-RPC API by
// default. `www.kimi.com` is the China-region endpoint — it serves China users but
@@ -144,7 +170,7 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) {
if (!bizData?.token) {
return {
valid: false,
- error: `DeepSeek did not return an access token: ${json?.msg || "unknown error"}`,
+ error: `DeepSeek did not return an access token: ${sanitizeErrorMessage(json?.msg) || "unknown error"}`,
};
}
return { valid: true, error: null };
@@ -254,7 +280,7 @@ export async function validateQwenWebProvider({ apiKey }: any) {
"Qwen session token is invalid or expired — re-login at https://chat.qwen.ai and paste a fresh full Cookie header",
};
}
- } catch (parseError) {
+ } catch {
return {
valid: false,
error: "Qwen returned invalid JSON response",
@@ -306,6 +332,68 @@ const GROK_IP_REPUTATION_GUIDANCE =
"auth failure. cf_clearance is pinned to the IP + TLS fingerprint + User-Agent that earned " +
"it and cannot be replayed from a different machine/IP. Retry from a residential IP or " +
"configure a proxy for grok-web.";
+const GROK_VALIDATION_RAW_DETAIL_BUDGET = 64 * 1024;
+const GROK_REJECTED_DETAIL_DISPLAY_BUDGET = 160;
+const GROK_GENERIC_DETAIL_DISPLAY_BUDGET = 240;
+
+function decodeGrokValidationUnicodeEscapes(value: string): string {
+ let decoded = value;
+ for (let pass = 0; pass < 2; pass += 1) {
+ const next = decoded
+ .replace(/\\u([0-9a-f]{4})/gi, (_match, codeUnit: string) =>
+ String.fromCharCode(Number.parseInt(codeUnit, 16))
+ )
+ .replace(/\\\//g, "/");
+ if (next === decoded) break;
+ decoded = next;
+ }
+ return decoded;
+}
+
+function normalizeGrokValidationString(value: string): string {
+ return decodeGrokValidationUnicodeEscapes(value)
+ .replace(/\\r\\n|\\n|\\r/g, "\n")
+ .replace(/[\u2028\u2029]/g, "\n")
+ .replace(/\r\n?/g, "\n");
+}
+
+function normalizeGrokValidationJsonValue(value: unknown): unknown {
+ if (typeof value === "string") return normalizeGrokValidationString(value);
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
+
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([key, nestedValue]) => [
+ normalizeGrokValidationString(key),
+ nestedValue,
+ ])
+ );
+}
+
+function sanitizeGrokValidationErrorDetail(errorDetail: string): string {
+ if (!errorDetail) return "";
+
+ try {
+ const parsed = JSON.parse(errorDetail, (_key, value: unknown) =>
+ normalizeGrokValidationJsonValue(value)
+ );
+ const sanitized = sanitizeUpstreamDetails(parsed);
+ return sanitized === null ? "" : (JSON.stringify(sanitized) ?? "");
+ } catch {
+ // Invalid or truncated upstream JSON still needs the bounded text sanitizer fallback.
+ return sanitizeErrorMessage(normalizeGrokValidationString(errorDetail));
+ }
+}
+
+function isGrokAntiBotBlockWithinBudget(errorDetail: string, wasTruncated: boolean): boolean {
+ if (!wasTruncated) return isGrokAntiBotBlock(errorDetail);
+
+ const text = errorDetail.trimStart();
+ if (/anti-bot|forbidden|access denied|blocked|rate.?limit/i.test(text)) return true;
+ // A JSON-shaped body may be incomplete only because our defensive parse budget
+ // cut it. Do not turn that bounded-read condition into a false IP-reputation verdict.
+ if (text.startsWith("{") || text.startsWith("[")) return false;
+ return isGrokAntiBotBlock(text);
+}
export async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: any) {
try {
@@ -389,19 +477,22 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = {
}),
timeoutMs: 15_000,
});
- } catch (err: any) {
- if (err instanceof TlsClientUnavailableError) {
+ } catch (err: unknown) {
+ if (isErrorInstance(err, TlsClientUnavailableError)) {
return {
valid: false,
- error: `TLS impersonation client unavailable: ${err.message}`,
+ error: `TLS impersonation client unavailable: ${sanitizeValidationThrownError(err)}`,
};
}
throw err;
}
let errorDetail = "";
+ let errorDetailWasTruncated = false;
try {
- errorDetail = (response.text || "").slice(0, 240);
+ const rawErrorDetail = response.text || "";
+ errorDetailWasTruncated = rawErrorDetail.length > GROK_VALIDATION_RAW_DETAIL_BUDGET;
+ errorDetail = rawErrorDetail.slice(0, GROK_VALIDATION_RAW_DETAIL_BUDGET);
} catch {}
// Detect Cloudflare challenge pages even with a 200 status from tls-client-node
@@ -457,7 +548,10 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = {
// not code-fixable: the datacenter/VPS IP is flagged. A Cloudflare
// challenge body, Grok's "anti-bot rules" rejection, or a bare/non-JSON
// forbidden body (no structured upstream `error.message`) all map here.
- if (isCloudflareChallenge(errorDetail) || isGrokAntiBotBlock(errorDetail)) {
+ if (
+ isCloudflareChallenge(errorDetail) ||
+ isGrokAntiBotBlockWithinBudget(errorDetail, errorDetailWasTruncated)
+ ) {
return {
valid: false,
error: `Grok returned 403 (anti-bot/Cloudflare block). ${GROK_IP_REPUTATION_GUIDANCE}`,
@@ -465,9 +559,13 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = {
}
// 3. Structured upstream error (e.g. probe model renamed) → surface the body
// so the user/maintainer sees the real cause instead of a wrong verdict.
+ const safeErrorDetail = sanitizeGrokValidationErrorDetail(errorDetail).slice(
+ 0,
+ GROK_REJECTED_DETAIL_DISPLAY_BUDGET
+ );
return {
valid: false,
- error: `Grok rejected validation (403)${errorDetail ? `: ${errorDetail.slice(0, 160)}` : ""}`,
+ error: `Grok rejected validation (403)${safeErrorDetail ? `: ${safeErrorDetail}` : ""}`,
};
}
@@ -479,9 +577,13 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = {
return { valid: false, error: `Grok unavailable (${response.status})` };
}
+ const safeErrorDetail = sanitizeGrokValidationErrorDetail(errorDetail).slice(
+ 0,
+ GROK_GENERIC_DETAIL_DISPLAY_BUDGET
+ );
return {
valid: false,
- error: `Grok validation failed (${response.status})${errorDetail ? `: ${errorDetail}` : ""}`,
+ error: `Grok validation failed (${response.status})${safeErrorDetail ? `: ${safeErrorDetail}` : ""}`,
};
} catch (error: any) {
return toValidationErrorResult(error);
@@ -529,11 +631,11 @@ export async function validateChatGptWebProvider({ apiKey, providerSpecificData
),
timeoutMs: 30_000,
});
- } catch (err: any) {
- if (err instanceof TlsClientUnavailableError) {
+ } catch (err: unknown) {
+ if (isErrorInstance(err, TlsClientUnavailableError)) {
return {
valid: false,
- error: `${err.message} (chatgpt-web requires this — without it, Cloudflare blocks every request)`,
+ error: `${sanitizeValidationThrownError(err)} (chatgpt-web requires this — without it, Cloudflare blocks every request)`,
};
}
throw err;
@@ -568,9 +670,12 @@ export async function validateChatGptWebProvider({ apiKey, providerSpecificData
}
if (!contentType.includes("json")) {
+ const safeContentType = sanitizeErrorMessage(contentType) || "no content-type";
+ const safeCfRay = cfRay ? sanitizeErrorMessage(cfRay) : "";
+ const safeResponseMetadata = `${safeContentType}${safeCfRay ? `, cf-ray=${safeCfRay}` : ""}`;
return {
valid: false,
- error: `ChatGPT returned non-JSON (${contentType || "no content-type"}${cfRay ? `, cf-ray=${cfRay}` : ""}) — paste the FULL Cookie line including cf_clearance, __cf_bm, _cfuvid alongside the session-token chunks.`,
+ error: `ChatGPT returned non-JSON (${safeResponseMetadata}) — paste the FULL Cookie line including cf_clearance, __cf_bm, _cfuvid alongside the session-token chunks.`,
};
}
@@ -664,11 +769,11 @@ export async function validatePerplexityWebProvider({ apiKey, providerSpecificDa
}),
timeoutMs: 30_000,
});
- } catch (err) {
- if (err instanceof TlsClientUnavailableError) {
+ } catch (err: unknown) {
+ if (isErrorInstance(err, TlsClientUnavailableError)) {
return {
valid: false,
- error: `${err.message} perplexity-web requires it — without it Cloudflare blocks every request.`,
+ error: `${sanitizeValidationThrownError(err)} perplexity-web requires it — without it Cloudflare blocks every request.`,
};
}
throw err;
diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts
index 1349a2d11c..c1cdb4ba1f 100644
--- a/src/lib/providers/validation/webProvidersB.ts
+++ b/src/lib/providers/validation/webProvidersB.ts
@@ -1,17 +1,11 @@
// Web-cookie provider key validators (part B): muse-spark-web, adapta-web, claude-web, gemini-web,
// copilot-web, t3-web, jules, devin (cloud-agent), inner-ai. Extracted from validation.ts (god-file
-// decomposition) — top-level functions with no dispatcher-state captures; behavior is byte-identical
-// to the inline defs.
-import { applyCustomUserAgent } from "./headers";
-import {
- isSecurityBlockError,
- toValidationErrorResult,
- validationRead,
- validationWrite,
-} from "./transport";
-import { SafeOutboundFetchError } from "@/shared/network/safeOutboundFetch";
-import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth";
+// decomposition) — top-level functions with no dispatcher-state captures; behavior is
+// regression-tested in this module.
+import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { buildJulesApiUrl } from "@/lib/cloudAgent/julesApi.ts";
+import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth";
+import { applyCustomUserAgent } from "./headers";
import {
META_AI_ASBD_ID,
META_AI_FRIENDLY_NAME,
@@ -19,6 +13,39 @@ import {
META_AI_USER_AGENT,
buildMetaAiValidationBody,
} from "./metaAi";
+import {
+ isSafeOutboundFetchError,
+ isSecurityBlockError,
+ toValidationErrorResult,
+ validationRead,
+ validationWrite,
+} from "./transport";
+
+interface ErrorInstanceClassifier {
+ [Symbol.hasInstance](value: unknown): boolean;
+}
+
+function isErrorInstance(error: unknown, classifier: ErrorInstanceClassifier): boolean {
+ try {
+ return classifier[Symbol.hasInstance](error);
+ } catch {
+ // A rejected Proxy may throw while the classifier walks its prototype chain.
+ return false;
+ }
+}
+
+function sanitizeValidationThrownError(error: unknown): string {
+ let candidate = error;
+ try {
+ if (isErrorInstance(error, Error)) {
+ const message = (error as { message?: unknown }).message;
+ if (typeof message === "string") candidate = message;
+ }
+ } catch {
+ // Keep the unknown value for the canonical fail-closed sanitizer.
+ }
+ return sanitizeErrorMessage(candidate);
+}
export async function validateMuseSparkWebProvider({ apiKey, providerSpecificData = {} }: any) {
try {
@@ -166,11 +193,11 @@ export async function validateClaudeWebProvider({ apiKey, providerSpecificData =
),
timeoutMs: 30_000,
});
- } catch (err: any) {
- if (err instanceof TlsClientUnavailableError) {
+ } catch (err: unknown) {
+ if (isErrorInstance(err, TlsClientUnavailableError)) {
return {
valid: false,
- error: `${err.message} (claude-web requires this — without it, Cloudflare blocks every request)`,
+ error: `${sanitizeValidationThrownError(err)} (claude-web requires this — without it, Cloudflare blocks every request)`,
};
}
throw err;
@@ -261,12 +288,22 @@ export async function validateGeminiWebProvider({ apiKey, providerSpecificData =
// - accounts.google.com/ServiceLogin — expired session → valid:false
// - other accounts.google.com paths — ambiguous, warn but treat as valid
// - non-Google redirects (e.g. gemini.google.com redirect loop) — valid
- if (
- error instanceof SafeOutboundFetchError &&
- error.code === "REDIRECT_BLOCKED" &&
- !isSecurityBlockError(error)
- ) {
- const location = error.location ?? "";
+ let publicRedirect: { location: string } | null = null;
+ try {
+ if (
+ isSafeOutboundFetchError(error) &&
+ error.code === "REDIRECT_BLOCKED" &&
+ !isSecurityBlockError(error)
+ ) {
+ publicRedirect = {
+ location: typeof error.location === "string" ? error.location : "",
+ };
+ }
+ } catch {
+ // Hostile redirect metadata must degrade to the generic validation failure below.
+ }
+ if (publicRedirect) {
+ const { location } = publicRedirect;
if (/accounts\.google\.com\/.*ServiceLogin/i.test(location)) {
return {
valid: false,
@@ -509,7 +546,7 @@ export async function validateJulesProvider({ apiKey }: { apiKey: string }) {
const errorText = await response.text().catch(() => "");
return {
valid: false,
- error: errorText.trim() || `Jules API returned ${response.status}`,
+ error: sanitizeErrorMessage(errorText.trim()) || `Jules API returned ${response.status}`,
};
} catch (error: unknown) {
return toValidationErrorResult(error);
@@ -541,7 +578,7 @@ export async function validateDevinCloudAgentProvider({ apiKey }: { apiKey: stri
const errorText = await response.text().catch(() => "");
return {
valid: false,
- error: errorText.trim() || `Devin API returned ${response.status}`,
+ error: sanitizeErrorMessage(errorText.trim()) || `Devin API returned ${response.status}`,
};
} catch (error: unknown) {
return toValidationErrorResult(error);
@@ -599,7 +636,10 @@ export async function validateNotionWebProvider({ apiKey, providerSpecificData =
}
}
-export async function validateInnerAiProvider({ apiKey, providerSpecificData = {} }: any) {
+export async function validateInnerAiProvider({
+ apiKey,
+ providerSpecificData: _providerData = {},
+}: any) {
try {
const raw = typeof apiKey === "string" ? apiKey.trim() : "";
if (!raw) {
diff --git a/tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts b/tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts
new file mode 100644
index 0000000000..082c17c505
--- /dev/null
+++ b/tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts
@@ -0,0 +1,185 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import test from "node:test";
+import { pathToFileURL } from "node:url";
+
+const BUILD_SCRIPT = resolve("scripts/build/build-next-isolated.mjs");
+
+test("isolated build verifies the assembled TLS seed before continuing", async () => {
+ const projectRoot = mkdtempSync(join(tmpdir(), "omniroute-assembly-tls-gate-"));
+ const standaloneDir = join(projectRoot, ".build", "next", "standalone");
+ const seedPath = join(
+ standaloneDir,
+ "runtime-assets",
+ "tls-client",
+ "bin",
+ "tls-client-linux-x64"
+ );
+ const moduleUrl = `${pathToFileURL(BUILD_SCRIPT).href}?tls-gate=${Date.now()}`;
+ const buildModule = (await import(moduleUrl)) as {
+ assembleAndVerifyStandalone?: (options: {
+ rootDir: string;
+ buildDistDir: string;
+ standaloneDir: string;
+ assembleImpl: (options: Record) => void;
+ verifyImpl: (options: Record) => Promise;
+ }) => Promise;
+ };
+
+ try {
+ assert.equal(
+ typeof buildModule.assembleAndVerifyStandalone,
+ "function",
+ "build-next-isolated must expose its post-assembly TLS verification composition"
+ );
+
+ let assembled = false;
+ await assert.rejects(
+ buildModule.assembleAndVerifyStandalone?.({
+ rootDir: projectRoot,
+ buildDistDir: join(projectRoot, ".build", "next"),
+ standaloneDir,
+ assembleImpl: () => {
+ mkdirSync(join(seedPath, ".."), { recursive: true });
+ writeFileSync(seedPath, "TAMPERED_AFTER_ASSEMBLY");
+ assembled = true;
+ },
+ verifyImpl: async (options) => {
+ assert.equal(assembled, true, "verification must run after standalone assembly");
+ assert.equal(options.rootDir, projectRoot);
+ assert.equal(options.standaloneDir, standaloneDir);
+ assert.equal(options.strict, true);
+ assert.equal(options.requireStandalone, true);
+ assert.equal(readFileSync(seedPath, "utf8"), "TAMPERED_AFTER_ASSEMBLY");
+ throw new Error("TLS seed digest mismatch");
+ },
+ }),
+ /TLS seed digest mismatch/
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+});
+
+test("isolated build fails closed when standalone assembly fails", () => {
+ const projectRoot = mkdtempSync(join(tmpdir(), "omniroute-assembly-fail-closed-"));
+ const nextBin = join(projectRoot, "node_modules", "next", "dist", "bin", "next");
+ const sentinelPath = join(projectRoot, "build-base-path-sentinel-ran");
+
+ try {
+ mkdirSync(join(projectRoot, "node_modules", "next", "dist", "bin"), {
+ recursive: true,
+ });
+ mkdirSync(join(projectRoot, "scripts", "build"), { recursive: true });
+
+ // A successful fake Next build leaves an invalid standalone FILE. The real
+ // assembler must throw when it tries to treat that path as a directory.
+ writeFileSync(
+ nextBin,
+ [
+ 'const fs = require("node:fs");',
+ 'const path = require("node:path");',
+ 'const distDir = path.resolve(process.env.NEXT_DIST_DIR || ".build/next");',
+ "fs.mkdirSync(distDir, { recursive: true });",
+ 'fs.writeFileSync(path.join(distDir, "standalone"), "not-a-directory");',
+ 'console.log("FAKE_NEXT_BUILD_SUCCEEDED");',
+ ].join("\n")
+ );
+ writeFileSync(
+ join(projectRoot, "scripts", "build", "write-build-base-path.mjs"),
+ [
+ 'import { writeFileSync } from "node:fs";',
+ `writeFileSync(${JSON.stringify(sentinelPath)}, "ran");`,
+ ].join("\n")
+ );
+
+ const result = spawnSync(process.execPath, [BUILD_SCRIPT], {
+ cwd: projectRoot,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ NEXT_DIST_DIR: ".build/next",
+ OMNIROUTE_BUILD_BACKEND_ONLY: "0",
+ OMNIROUTE_BUILD_PROFILE: "full",
+ },
+ timeout: 60_000,
+ });
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
+
+ assert.equal(result.error, undefined, output);
+ assert.match(output, /FAKE_NEXT_BUILD_SUCCEEDED/, output);
+ assert.equal(result.signal, null, output);
+ assert.equal(result.status, 1, `assembly failure must be fatal\n${output}`);
+ assert.match(output, /\[build-next-isolated\] Build failed:/, output);
+ assert.doesNotMatch(output, /Non-fatal error assembling standalone/, output);
+ assert.equal(
+ existsSync(sentinelPath),
+ false,
+ "post-assembly steps must not run after the assembler throws"
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+});
+
+test("isolated build fails closed when Next exits successfully without standalone output", () => {
+ const projectRoot = mkdtempSync(join(tmpdir(), "omniroute-standalone-missing-"));
+ const nextBin = join(projectRoot, "node_modules", "next", "dist", "bin", "next");
+ const sentinelPath = join(projectRoot, "build-base-path-sentinel-ran");
+
+ try {
+ mkdirSync(join(projectRoot, "node_modules", "next", "dist", "bin"), {
+ recursive: true,
+ });
+ mkdirSync(join(projectRoot, "scripts", "build"), { recursive: true });
+
+ // Next reports success but produces no standalone directory. This is the
+ // failure mode seen when a worker aborts after Next has already decided its
+ // process exit code; the wrapper must not silently skip assembly/verification.
+ writeFileSync(
+ nextBin,
+ ['console.log("FAKE_NEXT_BUILD_SUCCEEDED_WITHOUT_STANDALONE");'].join("\n")
+ );
+ writeFileSync(
+ join(projectRoot, "scripts", "build", "write-build-base-path.mjs"),
+ [
+ 'import { writeFileSync } from "node:fs";',
+ `writeFileSync(${JSON.stringify(sentinelPath)}, "ran");`,
+ ].join("\n")
+ );
+
+ const result = spawnSync(process.execPath, [BUILD_SCRIPT], {
+ cwd: projectRoot,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ NEXT_DIST_DIR: ".build/next",
+ OMNIROUTE_BUILD_BACKEND_ONLY: "0",
+ OMNIROUTE_BUILD_PROFILE: "full",
+ },
+ timeout: 60_000,
+ });
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
+
+ assert.equal(result.error, undefined, output);
+ assert.match(output, /FAKE_NEXT_BUILD_SUCCEEDED_WITHOUT_STANDALONE/, output);
+ assert.equal(result.signal, null, output);
+ assert.equal(result.status, 1, `missing standalone output must be fatal\n${output}`);
+ assert.match(
+ output,
+ /Next\.js build exited successfully but did not produce a standalone directory/,
+ output
+ );
+ assert.doesNotMatch(output, /Assembling standalone bundle/, output);
+ assert.equal(
+ existsSync(sentinelPath),
+ false,
+ "post-assembly steps must not run when Next omits standalone output"
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+});
diff --git a/tests/unit/build/check-licenses.test.ts b/tests/unit/build/check-licenses.test.ts
index ace523a3b4..0d5eea2ff7 100644
--- a/tests/unit/build/check-licenses.test.ts
+++ b/tests/unit/build/check-licenses.test.ts
@@ -26,7 +26,19 @@ function makeAllowlist(
overrides: Partial<{
allowed: string[];
allowedExpressions: string[];
- exceptions: Record;
+ exceptions: Record<
+ string,
+ {
+ license?: unknown;
+ version?: unknown;
+ justification: string;
+ risk: string;
+ temporary?: boolean;
+ owner?: string;
+ reviewBy?: string;
+ classification?: string;
+ }
+ >;
}> = {}
) {
return {
@@ -205,7 +217,7 @@ test("classifyLicense: exception does not apply to different package", () => {
assert.equal(result.status, "denied", "exception must be per-package, not per-license");
});
-test("classifyLicense: exception with risk=medium still returns 'exception' (not denied)", () => {
+test("classifyLicense: exception applies when detected license exactly matches", () => {
const allowlist = makeAllowlist({
exceptions: {
"tls-client-node": {
@@ -219,6 +231,278 @@ test("classifyLicense: exception with risk=medium still returns 'exception' (not
assert.equal(result.status, "exception");
});
+test("classifyLicense: version-pinned exception denies a different package version", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ version: "0.2.0",
+ justification: "Only the provenance-audited release is temporarily authorized.",
+ risk: "medium",
+ },
+ },
+ });
+
+ const result = classifyLicense("tls-client-node@0.2.1", "Custom: LICENSE", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /0\.2\.1/);
+ assert.match(result.reason, /0\.2\.0/);
+});
+
+test("classifyLicense: version-pinned exception applies to the exact package version", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ version: "0.2.0",
+ justification: "Only the provenance-audited release is temporarily authorized.",
+ risk: "medium",
+ },
+ },
+ });
+
+ const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
+ assert.equal(result.status, "exception");
+});
+
+test("classifyLicense: version-pinned exception denies an unversioned package key", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ version: "0.2.0",
+ justification: "Only the provenance-audited release is temporarily authorized.",
+ risk: "medium",
+ },
+ },
+ });
+
+ const result = classifyLicense("tls-client-node", "Custom: LICENSE", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /no version/i);
+ assert.match(result.reason, /tls-client-node@0\.2\.0/);
+});
+
+test("classifyLicense: version-pinned exception denies malformed package keys", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ version: "0.2.0",
+ justification: "Only the provenance-audited release is temporarily authorized.",
+ risk: "medium",
+ },
+ },
+ });
+
+ for (const packageKey of ["tls-client-node@", "tls-client-node@@0.2.0"] as const) {
+ const result = classifyLicense(packageKey, "Custom: LICENSE", allowlist);
+ assert.equal(result.status, "denied", packageKey);
+ assert.match(result.reason, /malformed package key/i);
+ assert.match(result.reason, new RegExp(packageKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
+ }
+});
+
+test("classifyLicense: malformed exception version metadata fails closed", () => {
+ for (const declaredVersion of [undefined, "", " 0.2.0", ["0.2.0"]]) {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ version: declaredVersion,
+ justification: "Malformed version metadata must never authorize a package.",
+ risk: "medium",
+ },
+ },
+ });
+
+ const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /invalid exception version/i);
+ assert.match(result.reason, /tls-client-node/);
+ }
+});
+
+test("classifyLicense: scoped version-pinned exception uses the version after the package name", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "@scope/native-transport": {
+ license: "Custom: LICENSE",
+ version: "1.2.3",
+ justification: "Only the provenance-audited scoped package release is authorized.",
+ risk: "medium",
+ },
+ },
+ });
+
+ const exact = classifyLicense("@scope/native-transport@1.2.3", "Custom: LICENSE", allowlist);
+ assert.equal(exact.status, "exception");
+
+ const changed = classifyLicense("@scope/native-transport@1.2.4", "Custom: LICENSE", allowlist);
+ assert.equal(changed.status, "denied");
+ assert.match(changed.reason, /1\.2\.4/);
+ assert.match(changed.reason, /1\.2\.3/);
+});
+
+test("classifyLicense: package exception overrides a globally allowed license", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ justification: "A package-specific review must not be bypassed by the global allowlist.",
+ risk: "medium",
+ },
+ },
+ });
+
+ const result = classifyLicense("tls-client-node@0.2.0", "Apache-2.0", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /Apache-2\.0/);
+ assert.match(result.reason, /Custom: LICENSE/);
+ assert.match(result.reason, /match/i);
+});
+
+test("classifyLicense: an expired package exception cannot fall through to the global allowlist", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "reviewed-apache-package": {
+ license: "Apache-2.0",
+ justification: "Temporary package-specific review despite a globally allowed SPDX id.",
+ risk: "medium",
+ temporary: true,
+ owner: "@owner",
+ reviewBy: "2026-09-30",
+ },
+ },
+ });
+
+ const result = classifyLicense("reviewed-apache-package@1.0.0", "Apache-2.0", allowlist, {
+ now: new Date("2026-10-01T00:00:00.000Z"),
+ });
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /expired|reviewBy/i);
+});
+
+test("classifyLicense: exception denies a GPL license that does not match its declaration", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ justification: "Exception applies only to the detected custom license.",
+ risk: "medium",
+ },
+ },
+ });
+ const result = classifyLicense("tls-client-node@0.2.0", "GPL-3.0-only", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /GPL-3\.0-only/);
+ assert.match(result.reason, /Custom: LICENSE/);
+ assert.match(result.reason, /match/i);
+});
+
+test("classifyLicense: exception denies UNKNOWN when its declared license is specific", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ justification: "Exception applies only to the detected custom license.",
+ risk: "medium",
+ },
+ },
+ });
+ const result = classifyLicense("tls-client-node@0.2.0", "UNKNOWN", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /UNKNOWN/);
+ assert.match(result.reason, /Custom: LICENSE/);
+ assert.match(result.reason, /match/i);
+});
+
+test("classifyLicense: exception with a missing or malformed declared license fails closed", () => {
+ for (const declaredLicense of [undefined, ["Custom: LICENSE"]]) {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: declaredLicense,
+ justification: "Malformed test exception must never authorize a detected license.",
+ risk: "medium",
+ },
+ },
+ });
+ const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /invalid exception license/i);
+ assert.match(result.reason, /tls-client-node/);
+ }
+});
+
+test("classifyLicense: temporary exception is denied after its reviewBy date", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ justification: "Temporary non-OSI source-available bridge with an owner.",
+ risk: "medium",
+ temporary: true,
+ owner: "@owner",
+ reviewBy: "2026-09-30",
+ classification: "non-OSI source-available",
+ },
+ },
+ });
+
+ const beforeExpiry = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, {
+ now: new Date("2026-09-30T23:59:59.999Z"),
+ });
+ assert.equal(beforeExpiry.status, "exception");
+
+ const expired = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, {
+ now: new Date("2026-10-01T00:00:00.000Z"),
+ });
+ assert.equal(expired.status, "denied");
+ assert.match(expired.reason, /expired|reviewBy/i);
+});
+
+test("classifyLicense: malformed temporary exception metadata fails closed", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ justification: "Temporary exception whose deadline is intentionally invalid.",
+ risk: "medium",
+ temporary: true,
+ owner: "@owner",
+ reviewBy: "2026-02-30",
+ classification: "non-OSI source-available",
+ },
+ },
+ });
+ const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, {
+ now: new Date("2026-08-27T00:00:00.000Z"),
+ });
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /invalid|reviewBy/i);
+});
+
+test("classifyLicense: ownerless temporary exception fails closed", () => {
+ const allowlist = makeAllowlist({
+ exceptions: {
+ "tls-client-node": {
+ license: "Custom: LICENSE",
+ justification: "Temporary exception deliberately missing an accountable owner.",
+ risk: "medium",
+ temporary: true,
+ reviewBy: "2026-09-30",
+ classification: "non-OSI source-available",
+ },
+ },
+ });
+ const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, {
+ now: new Date("2026-08-27T00:00:00.000Z"),
+ });
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /owner/i);
+});
+
// ---------------------------------------------------------------------------
// classifyLicense — reason field content
// ---------------------------------------------------------------------------
@@ -294,8 +578,11 @@ test("loadAllowlist: tls-client-node exception is temporary, owned, and covers a
assert.equal(exc.owner, "@diegosouzapw");
assert.equal(exc.reviewBy, "2026-09-30");
assert.equal(exc.reviewAt, "v3.9.0");
+ assert.equal(exc.version, "0.2.0", "exception must cover only the provenance-audited release");
+ assert.equal(exc.classification, "Apache-2.0 with Commons Clause; non-OSI source-available");
assert.match(exc.justification, /source-available/i);
assert.match(exc.justification, /commercial deployment/i);
+ assert.match(exc.justification, /PR #11742/, "temporary exception must link its tracker");
for (const provider of [
"chatgpt-web",
"claude-web",
@@ -348,6 +635,14 @@ test("integration: classifyLicense passes tls-client-node as exception against r
assert.equal(result.status, "exception");
});
+test("integration: real tls-client-node exception denies an unaudited future version", () => {
+ const allowlist = loadAllowlist();
+ const result = classifyLicense("tls-client-node@0.2.1", "Custom: LICENSE", allowlist);
+ assert.equal(result.status, "denied");
+ assert.match(result.reason, /0\.2\.1/);
+ assert.match(result.reason, /0\.2\.0/);
+});
+
test("integration: classifyLicense denies GPL-3.0 against real allowlist", () => {
const allowlist = loadAllowlist();
const result = classifyLicense("hypothetical-gpl@1.0.0", "GPL-3.0", allowlist);
diff --git a/tests/unit/build/electron-tls-client-seed.test.ts b/tests/unit/build/electron-tls-client-seed.test.ts
new file mode 100644
index 0000000000..575287f85e
--- /dev/null
+++ b/tests/unit/build/electron-tls-client-seed.test.ts
@@ -0,0 +1,165 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import test from "node:test";
+
+const ROOT = join(import.meta.dirname, "..", "..", "..");
+const prepareScript = readFileSync(
+ join(ROOT, "scripts", "build", "prepare-electron-standalone.mjs"),
+ "utf8"
+);
+const electronReleaseWorkflow = readFileSync(
+ join(ROOT, ".github", "workflows", "electron-release.yml"),
+ "utf8"
+);
+const fixerScript = readFileSync(
+ join(ROOT, "scripts", "build", "fixTlsClientNodeBinary.mjs"),
+ "utf8"
+);
+const rootPackage = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
+ scripts: Record;
+};
+const electronPackage = JSON.parse(
+ readFileSync(join(ROOT, "electron", "package.json"), "utf8")
+) as {
+ scripts: Record;
+ build: {
+ win: { target: Array<{ arch: string[] }> };
+ linux: { target: Array<{ arch: string[] }> };
+ };
+};
+
+function getFixerCalls(source: string): RegExpMatchArray[] {
+ return [...source.matchAll(/await\s+fixTlsClientNodeBinary\(\{([\s\S]*?)\}\);/g)];
+}
+
+function assertStrictStandaloneCall(call: RegExpMatchArray, standaloneDir: string): void {
+ const options = call[1];
+ assert.match(options, /\brootDir:\s*ROOT\b/);
+ assert.match(options, /\bstrict:\s*true\b/);
+ assert.match(options, /\bplatform:\s*ELECTRON_TARGET_PLATFORM\b/);
+ assert.match(options, /\barches:\s*ELECTRON_TARGET_ARCHES\b/);
+ assert.match(options, new RegExp(`\\bstandaloneDir:\\s*${standaloneDir}\\b`));
+ assert.match(options, /\brequireStandalone:\s*true\b/);
+}
+
+test("Electron staging verifies the source and final TLS client runtime seeds in release order", () => {
+ assert.match(
+ prepareScript,
+ /import\s*\{\s*fixTlsClientNodeBinary\s*\}\s*from\s*"\.\/fixTlsClientNodeBinary\.mjs";/
+ );
+ assert.match(prepareScript, /const STANDALONE_DIR = join\(DIST_DIR, "standalone"\);/);
+ assert.match(
+ prepareScript,
+ /const ELECTRON_STANDALONE_DIR = join\(ROOT, "\.build", "electron-standalone"\);/
+ );
+ assert.match(prepareScript, /process\.env\.OMNIROUTE_ELECTRON_TARGET_PLATFORM/);
+ assert.match(prepareScript, /process\.env\.OMNIROUTE_ELECTRON_TARGET_ARCHES/);
+ assert.match(
+ prepareScript,
+ /ELECTRON_TARGET_PLATFORM\s*===\s*"linux"[\s\S]{0,160}\["x64",\s*"arm64"\][\s\S]{0,160}\[process\.arch\]/,
+ "Linux packaging must safely default to both electron-builder target arches"
+ );
+ assert.match(
+ fixerScript,
+ /for\s*\(const targetArch of targetArches\)/,
+ "the fixer must verify every requested target arch"
+ );
+ assert.match(fixerScript, /readCliOptionValues\(cliArgs, \["--platform"\]\)/);
+ assert.match(
+ fixerScript,
+ /readCliOptionValues\(cliArgs, \["--arch", "--arches"\]\)/,
+ "the CLI must accept one or more target arches"
+ );
+
+ const fixerCalls = getFixerCalls(prepareScript);
+ assert.equal(fixerCalls.length, 2, "Electron staging must verify exactly both bundle boundaries");
+ assertStrictStandaloneCall(fixerCalls[0], "STANDALONE_DIR");
+ assertStrictStandaloneCall(fixerCalls[1], "ELECTRON_STANDALONE_DIR");
+
+ const sourceFixIndex = fixerCalls[0].index ?? -1;
+ const assembleIndex = prepareScript.indexOf("assembleStandalone({");
+ const optionalPackIndex = prepareScript.indexOf("await stageOptionalPacks({");
+ const finalFixIndex = fixerCalls[1].index ?? -1;
+ const successIndex = prepareScript.indexOf("[electron] prepared standalone bundle:");
+
+ assert.ok(sourceFixIndex >= 0 && sourceFixIndex < assembleIndex, "verify source before assembly");
+ assert.ok(
+ assembleIndex < optionalPackIndex && optionalPackIndex < finalFixIndex,
+ "verify the final Electron bundle after all staging mutations"
+ );
+ assert.ok(
+ finalFixIndex < successIndex,
+ "verify the final Electron bundle before reporting success"
+ );
+});
+
+test("shared Electron web build seeds the TLS client binary before packing the artifact", () => {
+ const webBuildStart = electronReleaseWorkflow.indexOf("\n web-build:");
+ const buildStart = electronReleaseWorkflow.indexOf("\n build:", webBuildStart + 1);
+ assert.ok(webBuildStart >= 0 && buildStart > webBuildStart, "locate the shared web-build job");
+
+ const webBuildJob = electronReleaseWorkflow.slice(webBuildStart, buildStart);
+ const buildIndex = webBuildJob.indexOf("run: npm run build");
+ const fixerIndex = webBuildJob.indexOf(
+ "run: node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone"
+ );
+ const packIndex = webBuildJob.indexOf(
+ "run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz"
+ );
+
+ assert.ok(buildIndex >= 0, "shared web-build job must build the Next standalone bundle");
+ assert.ok(fixerIndex > buildIndex, "strict TLS client seed verification must follow Next build");
+ assert.ok(packIndex > fixerIndex, "strict TLS client seed verification must precede packing");
+});
+
+test("Electron matrix passes platform and every packaging arch to bundle preparation via env", () => {
+ const buildStepStart = electronReleaseWorkflow.indexOf(
+ "- name: Build Electron for ${{ matrix.platform }}"
+ );
+ const smokeStepStart = electronReleaseWorkflow.indexOf(
+ "- name: Smoke packaged Electron app",
+ buildStepStart + 1
+ );
+ assert.ok(buildStepStart >= 0 && smokeStepStart > buildStepStart, "locate Electron build step");
+
+ const buildStep = electronReleaseWorkflow.slice(buildStepStart, smokeStepStart);
+ assert.match(buildStep, /OMNIROUTE_ELECTRON_TARGET_PLATFORM:\s*\$\{\{ matrix\.os \}\}/);
+ assert.match(buildStep, /OMNIROUTE_ELECTRON_TARGET_ARCHES:\s*\$\{\{ matrix\.arch \}\}/);
+ assert.match(
+ electronReleaseWorkflow,
+ /platform:\s*linux[\s\S]{0,180}os:\s*linux[\s\S]{0,80}arch:\s*x64,arm64/,
+ "the Linux matrix must continue declaring both packaged architectures"
+ );
+});
+
+test("root Electron build scripts pass each packaged platform and arch to bundle preparation", () => {
+ assert.deepEqual(
+ [...new Set(electronPackage.build.win.target.flatMap((target) => target.arch))],
+ ["x64"],
+ "Windows packages x64 artifacts"
+ );
+ assert.deepEqual(
+ [...new Set(electronPackage.build.linux.target.flatMap((target) => target.arch))],
+ ["x64", "arm64"],
+ "Linux packages x64 and arm64 artifacts"
+ );
+ assert.match(electronPackage.scripts["build:mac-x64"], /electron-builder --mac --x64$/);
+ assert.match(electronPackage.scripts["build:mac-arm64"], /electron-builder --mac --arm64$/);
+
+ assert.match(
+ rootPackage.scripts["electron:build:win"],
+ /cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=win32 OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:win$/,
+ "Windows preparation must verify the win32/x64 DLL seed"
+ );
+ assert.match(
+ rootPackage.scripts["electron:build:mac"],
+ /cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:mac-x64 && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=arm64 npm run build:mac-arm64$/,
+ "macOS preparation must verify the exact Intel and Apple Silicon seeds it packages"
+ );
+ assert.match(
+ rootPackage.scripts["electron:build:linux"],
+ /cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=linux OMNIROUTE_ELECTRON_TARGET_ARCHES=x64,arm64 npm run build:linux$/,
+ "Linux preparation must verify both configured electron-builder target arches"
+ );
+});
diff --git a/tests/unit/build/tls-client-assembly-digest.test.ts b/tests/unit/build/tls-client-assembly-digest.test.ts
new file mode 100644
index 0000000000..ace13fcc04
--- /dev/null
+++ b/tests/unit/build/tls-client-assembly-digest.test.ts
@@ -0,0 +1,445 @@
+import assert from "node:assert/strict";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ symlinkSync,
+ truncateSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, dirname, join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+ assembleStandalone,
+ syncStandaloneNativeAssets,
+} from "../../../scripts/build/assembleStandalone.mjs";
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
+
+test("async standalone assembly rejects a manifest-named TLS seed with the wrong digest", async () => {
+ const manifest = JSON.parse(
+ readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8")
+ ) as { assets: Record };
+ const asset = Object.entries(manifest.assets).find(
+ ([target]) => target !== `${process.platform}-${process.arch}`
+ )?.[1];
+ assert.ok(asset, "the pinned TLS manifest must contain at least one native asset");
+
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-digest-project-"));
+ const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-digest-output-"));
+ const source = join(projectRoot, "node_modules", "tls-client-node", "bin", asset.file);
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file);
+
+ try {
+ mkdirSync(dirname(source), { recursive: true });
+ writeFileSync(source, "manifest filename, deliberately untrusted bytes");
+
+ await assert.rejects(
+ syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir),
+ /SHA-256|digest|integrity/i
+ );
+ assert.equal(
+ existsSync(destination),
+ false,
+ "an unverified TLS seed must never be emitted into the standalone output"
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("async standalone assembly copies a regular TLS seed whose fixture digest is pinned", async () => {
+ const fixture = "verified TLS fixture\n";
+ const fixtureAsset = {
+ file: "tls-client-test-fixture.so",
+ sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c",
+ };
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-valid-project-"));
+ const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-valid-output-"));
+ const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file);
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file);
+
+ try {
+ mkdirSync(dirname(source), { recursive: true });
+ writeFileSync(source, fixture);
+
+ const changed = await syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, {
+ tlsClientNativeAssets: { "test-fixture": fixtureAsset },
+ });
+
+ assert.equal(changed, true);
+ assert.equal(readFileSync(destination, "utf8"), fixture);
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("sync standalone assembly rejects a manifest-named TLS seed with the wrong digest", () => {
+ const manifest = JSON.parse(
+ readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8")
+ ) as { assets: Record };
+ const asset = Object.entries(manifest.assets).find(
+ ([target]) => target !== `${process.platform}-${process.arch}`
+ )?.[1];
+ assert.ok(asset, "the pinned TLS manifest must contain at least one native asset");
+
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-sync-digest-project-"));
+ const distDir = join(projectRoot, ".build", "next");
+ const outDir = join(projectRoot, "dist");
+ const source = join(projectRoot, "node_modules", "tls-client-node", "bin", asset.file);
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file);
+ const staleStandaloneSeed = join(
+ distDir,
+ "standalone",
+ "runtime-assets",
+ "tls-client",
+ "bin",
+ asset.file
+ );
+
+ try {
+ mkdirSync(join(distDir, "standalone"), { recursive: true });
+ writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n");
+ mkdirSync(dirname(staleStandaloneSeed), { recursive: true });
+ writeFileSync(staleStandaloneSeed, "stale unverified seed from an earlier assembly");
+ mkdirSync(dirname(source), { recursive: true });
+ writeFileSync(source, "manifest filename, deliberately untrusted sync bytes");
+
+ assert.throws(
+ () =>
+ assembleStandalone({
+ distDir,
+ outDir,
+ projectRoot,
+ copyNatives: true,
+ }),
+ /SHA-256|digest|integrity/i
+ );
+ assert.equal(
+ existsSync(destination),
+ false,
+ "a failed sync assembly must not leave an unverified TLS seed in its output"
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+});
+
+test("sync assembly rejects a stale unverified TLS seed when the source install is absent", () => {
+ const manifest = JSON.parse(
+ readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8")
+ ) as { assets: Record };
+ const asset = Object.values(manifest.assets)[0];
+ assert.ok(asset, "the pinned TLS manifest must contain at least one native asset");
+
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-stale-project-"));
+ const distDir = join(projectRoot, ".build", "next");
+ const outDir = join(projectRoot, "dist");
+ const staleStandaloneSeed = join(
+ distDir,
+ "standalone",
+ "runtime-assets",
+ "tls-client",
+ "bin",
+ asset.file
+ );
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file);
+
+ try {
+ mkdirSync(dirname(staleStandaloneSeed), { recursive: true });
+ writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n");
+ writeFileSync(staleStandaloneSeed, "stale bytes with no corresponding source install");
+
+ assert.throws(
+ () => assembleStandalone({ distDir, outDir, projectRoot, copyNatives: true }),
+ /SHA-256|digest|integrity/i
+ );
+ assert.equal(
+ existsSync(destination),
+ false,
+ "a stale unverified seed copied by the bulk standalone pass must be removed"
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+});
+
+test("sync assembly rejects TLS tamper copied through every standalone node_modules root", () => {
+ const manifest = JSON.parse(
+ readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8")
+ ) as { assets: Record };
+ const asset = Object.values(manifest.assets)[0];
+ assert.ok(asset, "the pinned TLS manifest must contain at least one native asset");
+
+ const layouts = [
+ (_projectRoot: string) => ["node_modules", "tls-client-node", "bin"],
+ (_projectRoot: string) => [".build", "next", "node_modules", "tls-client-node", "bin"],
+ (_projectRoot: string) => ["projects", "OmniRoute", "node_modules", "tls-client-node", "bin"],
+ (projectRoot: string) => [basename(projectRoot), "node_modules", "tls-client-node", "bin"],
+ ];
+
+ for (const layout of layouts) {
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-bulk-root-"));
+ const distDir = join(projectRoot, ".build", "next");
+ const outDir = join(projectRoot, "dist");
+ const relativeBin = layout(projectRoot);
+ const staleStandaloneSeed = join(distDir, "standalone", ...relativeBin, asset.file);
+ const destination = join(outDir, ...relativeBin, asset.file);
+
+ try {
+ mkdirSync(dirname(staleStandaloneSeed), { recursive: true });
+ writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n");
+ writeFileSync(staleStandaloneSeed, "manifest-named bytes with an invalid digest");
+
+ assert.throws(
+ () => assembleStandalone({ distDir, outDir, projectRoot, copyNatives: true }),
+ /SHA-256|digest|integrity/i,
+ `bulk-copied TLS seed must be audited at ${relativeBin.join("/")}`
+ );
+ assert.equal(
+ existsSync(destination),
+ false,
+ `tampered TLS seed must be removed from ${relativeBin.join("/")}`
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+ }
+});
+
+test(
+ "sync assembly rejects a writable stale TLS seed even when its digest is valid",
+ { skip: process.platform === "win32" },
+ () => {
+ const fixture = "sync verified TLS fixture\n";
+ const fixtureAsset = {
+ file: "tls-client-stale-mode-fixture.so",
+ sha256: "7729ee26e4baf77e5c5ad8289778943a4412621d275d41b8cff8fe8daa3a496e",
+ };
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-stale-mode-project-"));
+ const distDir = join(projectRoot, ".build", "next");
+ const outDir = join(projectRoot, "dist");
+ const staleStandaloneSeed = join(
+ distDir,
+ "standalone",
+ "runtime-assets",
+ "tls-client",
+ "bin",
+ fixtureAsset.file
+ );
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file);
+
+ try {
+ mkdirSync(dirname(staleStandaloneSeed), { recursive: true });
+ writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n");
+ writeFileSync(staleStandaloneSeed, fixture);
+ chmodSync(staleStandaloneSeed, 0o644);
+
+ assert.throws(
+ () =>
+ assembleStandalone({
+ distDir,
+ outDir,
+ projectRoot,
+ copyNatives: true,
+ tlsClientNativeAssets: { "stale-mode-fixture": fixtureAsset },
+ }),
+ /mode|permission|writable/i
+ );
+ assert.equal(
+ existsSync(destination),
+ false,
+ "a writable stale seed must be removed instead of being trusted by digest alone"
+ );
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+ }
+);
+
+test("sync standalone assembly copies a regular TLS seed whose fixture digest is pinned", () => {
+ const fixture = "sync verified TLS fixture\n";
+ const fixtureAsset = {
+ file: "tls-client-sync-test-fixture.so",
+ sha256: "7729ee26e4baf77e5c5ad8289778943a4412621d275d41b8cff8fe8daa3a496e",
+ };
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-sync-valid-project-"));
+ const distDir = join(projectRoot, ".build", "next");
+ const outDir = join(projectRoot, "dist");
+ const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file);
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file);
+
+ try {
+ mkdirSync(join(distDir, "standalone"), { recursive: true });
+ writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n");
+ mkdirSync(dirname(source), { recursive: true });
+ writeFileSync(source, fixture);
+
+ assembleStandalone({
+ distDir,
+ outDir,
+ projectRoot,
+ copyNatives: true,
+ tlsClientNativeAssets: { "sync-test-fixture": fixtureAsset },
+ });
+
+ assert.equal(readFileSync(destination, "utf8"), fixture);
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+});
+
+test("standalone assembly rejects symlink and oversized TLS seed sources", async () => {
+ const manifest = JSON.parse(
+ readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8")
+ ) as { assets: Record };
+ const asset = Object.values(manifest.assets)[0];
+ assert.ok(asset, "the pinned TLS manifest must contain at least one native asset");
+
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-unsafe-project-"));
+ const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-unsafe-output-"));
+ const source = join(projectRoot, "node_modules", "tls-client-node", "bin", asset.file);
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file);
+ const symlinkTarget = join(projectRoot, "untrusted-native-bytes");
+
+ try {
+ mkdirSync(dirname(source), { recursive: true });
+ writeFileSync(symlinkTarget, "untrusted symlink target");
+ symlinkSync(symlinkTarget, source);
+ await assert.rejects(
+ syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir),
+ /symlink\/non-regular file/i
+ );
+ assert.equal(existsSync(destination), false);
+
+ rmSync(source, { force: true });
+ writeFileSync(source, "");
+ truncateSync(source, 64 * 1024 * 1024 + 1);
+ await assert.rejects(
+ syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir),
+ /64 MiB limit/i
+ );
+ assert.equal(existsSync(destination), false);
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("standalone assembly rejects a TLS seed beneath a symlink source ancestor", async () => {
+ const fixture = "verified TLS fixture\n";
+ const fixtureAsset = {
+ file: "tls-client-source-ancestor-symlink-fixture.so",
+ sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c",
+ };
+ const temporaryRoot = mkdtempSync(join(tmpdir(), "tls-assembly-source-ancestor-"));
+ const projectRoot = join(temporaryRoot, "project");
+ const outsideBin = join(temporaryRoot, "outside-bin");
+ const sourceBin = join(projectRoot, "node_modules", "tls-client-node", "bin");
+ const outsideSource = join(outsideBin, fixtureAsset.file);
+ const outDir = join(temporaryRoot, "standalone");
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file);
+
+ try {
+ mkdirSync(dirname(sourceBin), { recursive: true });
+ mkdirSync(outsideBin, { recursive: true });
+ writeFileSync(outsideSource, fixture);
+ symlinkSync(outsideBin, sourceBin, "dir");
+
+ await assert.rejects(
+ syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, {
+ tlsClientNativeAssets: { "source-ancestor-symlink-fixture": fixtureAsset },
+ }),
+ /source ancestor|symlink/i
+ );
+ assert.equal(
+ existsSync(destination),
+ false,
+ "a TLS seed reached through a source ancestor symlink must not be distributed"
+ );
+ } finally {
+ rmSync(temporaryRoot, { recursive: true, force: true });
+ }
+});
+
+test("standalone assembly rejects a symlink destination without modifying its target", async () => {
+ const fixture = "verified TLS fixture\n";
+ const fixtureAsset = {
+ file: "tls-client-destination-symlink-fixture.so",
+ sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c",
+ };
+ const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-dest-project-"));
+ const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-dest-output-"));
+ const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file);
+ const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file);
+ const outsideTarget = join(projectRoot, "must-remain-untouched");
+
+ try {
+ mkdirSync(dirname(source), { recursive: true });
+ writeFileSync(source, fixture);
+ writeFileSync(outsideTarget, "outside sentinel");
+ mkdirSync(dirname(destination), { recursive: true });
+ symlinkSync(outsideTarget, destination);
+
+ await assert.rejects(
+ syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, {
+ tlsClientNativeAssets: { "destination-symlink-fixture": fixtureAsset },
+ }),
+ /destination \(symlink\/non-regular file\)/i
+ );
+ assert.equal(existsSync(destination), false);
+ assert.equal(readFileSync(outsideTarget, "utf8"), "outside sentinel");
+ } finally {
+ rmSync(projectRoot, { recursive: true, force: true });
+ rmSync(outDir, { recursive: true, force: true });
+ }
+});
+
+test("standalone assembly rejects an output root symlink without writing outside it", async () => {
+ const fixture = "verified TLS fixture\n";
+ const fixtureAsset = {
+ file: "tls-client-output-root-symlink-fixture.so",
+ sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c",
+ };
+ const temporaryRoot = mkdtempSync(join(tmpdir(), "tls-assembly-root-symlink-"));
+ const projectRoot = join(temporaryRoot, "project");
+ const outsideDir = join(temporaryRoot, "outside");
+ const outDir = join(temporaryRoot, "standalone-link");
+ const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file);
+ const escapedDestination = join(
+ outsideDir,
+ "runtime-assets",
+ "tls-client",
+ "bin",
+ fixtureAsset.file
+ );
+
+ try {
+ mkdirSync(dirname(source), { recursive: true });
+ writeFileSync(source, fixture);
+ mkdirSync(outsideDir, { recursive: true });
+ symlinkSync(outsideDir, outDir, "dir");
+
+ await assert.rejects(
+ syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, {
+ tlsClientNativeAssets: { "root-symlink-fixture": fixtureAsset },
+ }),
+ /destination.*(?:root|ancestor)|symlink/i
+ );
+ assert.equal(
+ existsSync(escapedDestination),
+ false,
+ "a symlinked output root must not redirect an authorized filename outside the bundle"
+ );
+ } finally {
+ rmSync(temporaryRoot, { recursive: true, force: true });
+ }
+});
diff --git a/tests/unit/build/tls-client-license-provenance.test.ts b/tests/unit/build/tls-client-license-provenance.test.ts
index 83ef564966..3732b3caab 100644
--- a/tests/unit/build/tls-client-license-provenance.test.ts
+++ b/tests/unit/build/tls-client-license-provenance.test.ts
@@ -1,12 +1,15 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
-import { syncStandaloneExtraModules } from "../../../scripts/build/assembleStandalone.mjs";
+import {
+ syncStandaloneExtraModules,
+ syncStandaloneNativeAssets,
+} from "../../../scripts/build/assembleStandalone.mjs";
import { PACK_ARTIFACT_REQUIRED_PATHS } from "../../../scripts/build/pack-artifact-policy.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
@@ -37,6 +40,8 @@ function extractVerbatimBlock(document: string, label: keyof typeof PRIMARY_SOUR
test("distributed tls-client notices reproduce every primary license and NOTICE verbatim", () => {
const notices = readFileSync(join(ROOT, "THIRD_PARTY_NOTICES.md"), "utf8");
+ assert.match(notices, /not OSI-approved/i);
+
for (const [label, expectedHash] of Object.entries(PRIMARY_SOURCE_HASHES)) {
const text = extractVerbatimBlock(notices, label as keyof typeof PRIMARY_SOURCE_HASHES);
assert.equal(
@@ -47,6 +52,155 @@ test("distributed tls-client notices reproduce every primary license and NOTICE
}
});
+test("public comparison qualifies MIT as the project's own code license", () => {
+ const comparison = readFileSync(join(ROOT, "docs", "diagrams", "comparison-table.svg"), "utf8");
+ assert.doesNotMatch(comparison, /100% MIT/i);
+ assert.match(comparison, /own code license/i);
+ assert.match(comparison, /optional (?:third-party )?dependencies retain their licenses/i);
+});
+
+test("localized public comparisons use the canonical provider count", () => {
+ const comparison = readFileSync(join(ROOT, "docs", "diagrams", "comparison-table.svg"), "utf8");
+ const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8");
+ const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8");
+ const canonicalCount = comparison.match(/full set: (\d+) providers/i)?.[1];
+
+ assert.ok(canonicalCount, "comparison SVG must expose the docs-counts-verified provider count");
+ assert.match(italian, new RegExp(`OmniRoute: ${canonicalCount} provider\\b`));
+ assert.match(turkish, new RegExp(`OmniRoute: ${canonicalCount} providers\\b`));
+});
+
+test("public privacy claims qualify MIT as the project's own code license", () => {
+ const privacyDiagram = readFileSync(join(ROOT, "docs", "diagrams", "privacy-local.svg"), "utf8");
+ const localizedReadmes = [
+ readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8"),
+ readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8"),
+ readFileSync(join(ROOT, "docs", "i18n", "ru", "README.md"), "utf8"),
+ ].join("\n");
+
+ assert.doesNotMatch(privacyDiagram, /MIT licensed (?:and|&) fully open-source/i);
+ assert.doesNotMatch(privacyDiagram, /never leak stack traces, paths or internals/i);
+ assert.doesNotMatch(localizedReadmes, /MIT-licensed fully open-source/i);
+ assert.doesNotMatch(localizedReadmes, /codice completamente open source con licenza MIT/i);
+ assert.doesNotMatch(localizedReadmes, /MIT, fully open-source/i);
+ assert.doesNotMatch(localizedReadmes, /sanitized errors that never leak internals/i);
+ assert.doesNotMatch(localizedReadmes, /errori sanitizzati che non espongono dettagli interni/i);
+ assert.match(privacyDiagram, /own code.*MIT/i);
+ assert.match(privacyDiagram, /third-party components retain their own licenses/i);
+ assert.match(privacyDiagram, /recognized filesystem paths/i);
+ assert.match(localizedReadmes, /third-party components retain their own licenses/i);
+ assert.match(localizedReadmes, /codice proprio di OmniRoute.*licenza MIT/i);
+ assert.match(localizedReadmes, /сторонние компоненты.*лицензии/i);
+ assert.match(localizedReadmes, /recognized filesystem paths/i);
+ assert.match(localizedReadmes, /percorsi filesystem riconosciuti/i);
+});
+
+test("public privacy copy avoids absolute no-network and no-cloud promises", () => {
+ const privacyDiagram = readFileSync(join(ROOT, "docs", "diagrams", "privacy-local.svg"), "utf8");
+ const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8");
+ const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8");
+ const russian = readFileSync(join(ROOT, "docs", "i18n", "ru", "README.md"), "utf8");
+
+ assert.doesNotMatch(privacyDiagram, /never phones home/i);
+ assert.doesNotMatch(privacyDiagram, /\b0 cloud hops\b/i);
+ assert.doesNotMatch(privacyDiagram, /no OmniRoute cloud in the request path/i);
+ assert.doesNotMatch(privacyDiagram, /prompts go only to the providers you choose, nowhere else/i);
+ assert.doesNotMatch(italian, /non comunica autonomamente con servizi cloud/i);
+ assert.doesNotMatch(italian, /\b0 passaggi cloud\b/i);
+ assert.doesNotMatch(turkish, /never phones home/i);
+ assert.doesNotMatch(turkish, /\b0 cloud hops\b/i);
+ assert.doesNotMatch(russian, /без «звонков домой»/i);
+ assert.doesNotMatch(russian, /100% на вашем железе/i);
+ assert.doesNotMatch(russian, /Нет cloud-hop OmniRoute/i);
+ assert.doesNotMatch(russian, /промпты уходят только выбранным провайдерам/i);
+
+ assert.match(privacyDiagram, /adds no OmniRoute-hosted prompt-processing hop/i);
+ assert.match(privacyDiagram, /telemetry is disabled by default/i);
+ assert.match(
+ italian,
+ /non aggiunge un passaggio di elaborazione dei prompt ospitato da OmniRoute/i
+ );
+ assert.match(italian, /telemetria (?:è )?disattivata per impostazione predefinita/i);
+ assert.match(turkish, /adds no OmniRoute-hosted prompt-processing hop/i);
+ assert.match(turkish, /telemetry is disabled by default/i);
+ assert.match(
+ russian,
+ /не добавляет этап обработки промптов, размещённый на инфраструктуре OmniRoute/i
+ );
+ assert.match(russian, /телеметрия по умолчанию отключена/i);
+ assert.match(
+ russian,
+ /Роутинг выполняется локально.*провайдеры остаются внешними upstream-сервисами/i
+ );
+});
+
+test("public privacy copy qualifies dashboard identity and opt-in redactions", () => {
+ const privacyDiagram = readFileSync(join(ROOT, "docs", "diagrams", "privacy-local.svg"), "utf8");
+
+ assert.doesNotMatch(privacyDiagram, /No account(?: and|,) no sign-up/i);
+ assert.doesNotMatch(privacyDiagram, /OmniRoute never asks who you are/i);
+ assert.doesNotMatch(privacyDiagram, /payloads are never mutated by default/i);
+ assert.match(privacyDiagram, /No OmniRoute-hosted account service/i);
+ assert.match(
+ privacyDiagram,
+ /operator controls dashboard identity.*local password.*optional OIDC/i
+ );
+ assert.match(privacyDiagram, /These redactions run only when enabled/i);
+ assert.match(
+ privacyDiagram,
+ /MCP tool calls & admin actions logged in your SQLite, not ours/i
+ );
+});
+
+test("localized privacy copy qualifies dashboard identity", () => {
+ const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8");
+ const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8");
+
+ assert.doesNotMatch(italian, /nessun account o registrazione/i);
+ assert.doesNotMatch(turkish, /no account or sign-up/i);
+ assert.match(
+ italian,
+ /nessun servizio di account ospitato da OmniRoute.*l'operatore controlla l'identità della dashboard.*password locale.*OIDC opzionale/i
+ );
+ assert.match(
+ turkish,
+ /OmniRoute tarafından barındırılan bir hesap hizmeti yoktur.*operatör pano kimliğini.*yerel parola.*isteğe bağlı OIDC/i
+ );
+});
+
+test("root README does not present third-party components as MIT-licensed", () => {
+ const readme = readFileSync(join(ROOT, "README.md"), "utf8");
+
+ assert.doesNotMatch(readme, /OmniRoute is MIT-licensed and self-hostable/i);
+ assert.doesNotMatch(readme, /OmniRoute is MIT-licensed and maintained in the open/i);
+ assert.match(readme, /OmniRoute's own code is MIT-licensed/i);
+ assert.match(readme, /third-party (?:components|dependencies) retain their own licenses/i);
+});
+
+test("localized support copy qualifies MIT as the project's own code license", () => {
+ const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8");
+ const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8");
+
+ assert.doesNotMatch(italian, /(?:^|\n)OmniRoute è distribuito con licenza MIT/i);
+ assert.match(italian, /codice proprio di OmniRoute.*licenza MIT/i);
+ assert.match(italian, /componenti di terze parti.*rispettive licenze/i);
+
+ assert.doesNotMatch(turkish, /OmniRoute, MIT lisanslıdır/i);
+ assert.match(turkish, /OmniRoute'un kendi kodu.*MIT lisanslıdır/i);
+ assert.match(turkish, /üçüncü taraf bileşenler.*kendi lisanslarını korur/i);
+});
+
+test("TLS seed documentation distinguishes fallback from unsafe-entry failure", () => {
+ const environmentReference = readFileSync(
+ join(ROOT, "docs", "reference", "ENVIRONMENT.md"),
+ "utf8"
+ );
+
+ assert.match(environmentReference, /absent file or SHA-256 mismatch falls through/i);
+ assert.match(environmentReference, /symlink, non-regular file, or file above 64 MiB/i);
+ assert.match(environmentReference, /unsafe entry and aborts resolution/i);
+});
+
test("the distributed wrapper is pinned to the exact audited tls-client-node release", () => {
const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
const packageLock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8"));
@@ -56,9 +210,14 @@ test("the distributed wrapper is pinned to the exact audited tls-client-node rel
assert.equal(packageLock.packages["node_modules/tls-client-node"].version, "0.2.0");
});
-test("npm pack, standalone, and Docker all transport THIRD_PARTY_NOTICES.md", async () => {
+test("npm pack, standalone, and Docker transport notices, manifest, and the runtime seed", async () => {
const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
+ assert.ok(packageJson.files.includes("LICENSE"));
assert.ok(packageJson.files.includes("THIRD_PARTY_NOTICES.md"));
+ assert.ok(
+ PACK_ARTIFACT_REQUIRED_PATHS.includes("dist/LICENSE"),
+ "check:pack-artifact must require the project license inside standalone artifacts"
+ );
assert.ok(
PACK_ARTIFACT_REQUIRED_PATHS.includes("THIRD_PARTY_NOTICES.md"),
"check:pack-artifact must fail when the distributed notices are absent"
@@ -68,18 +227,78 @@ test("npm pack, standalone, and Docker all transport THIRD_PARTY_NOTICES.md", as
const outDir = mkdtempSync(join(tmpdir(), "tls-client-notices-standalone-"));
try {
const expected = "legal-notice-sentinel\n";
+ const projectLicense = "omniroute-license-sentinel\n";
+ const manifest = readFileSync(
+ join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"),
+ "utf8"
+ );
+ const parsedManifest = JSON.parse(manifest) as {
+ assets: Record;
+ };
+ const nativeAsset = Object.values(parsedManifest.assets)[0];
+ assert.ok(nativeAsset, "the pinned TLS manifest must contain at least one asset");
+ const nativeBinary = "verified-native-seed-sentinel";
writeFileSync(join(projectRoot, "THIRD_PARTY_NOTICES.md"), expected);
+ writeFileSync(join(projectRoot, "LICENSE"), projectLicense);
+ mkdirSync(join(projectRoot, "open-sse", "config"), { recursive: true });
+ writeFileSync(
+ join(projectRoot, "open-sse", "config", "tlsClientNativeManifest.json"),
+ manifest
+ );
+ mkdirSync(join(projectRoot, "node_modules", "tls-client-node", "bin"), {
+ recursive: true,
+ });
+ writeFileSync(
+ join(projectRoot, "node_modules", "tls-client-node", "bin", nativeAsset.file),
+ nativeBinary
+ );
+ writeFileSync(
+ join(projectRoot, "node_modules", "tls-client-node", "bin", "untracked-extra.so"),
+ "must-not-be-distributed"
+ );
await syncStandaloneExtraModules(projectRoot, undefined, { log() {} }, outDir);
+ await syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, {
+ tlsClientNativeAssets: {
+ "provenance-fixture": {
+ file: nativeAsset.file,
+ sha256: "5637d0a3bf3174ac2be169c507151090fb0a4b6acc9917df8d4d2f904d5b6e81",
+ },
+ },
+ });
assert.equal(readFileSync(join(outDir, "THIRD_PARTY_NOTICES.md"), "utf8"), expected);
+ assert.equal(readFileSync(join(outDir, "LICENSE"), "utf8"), projectLicense);
+ assert.equal(
+ readFileSync(join(outDir, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8"),
+ manifest
+ );
+ assert.equal(
+ readFileSync(join(outDir, "runtime-assets", "tls-client", "bin", nativeAsset.file), "utf8"),
+ nativeBinary
+ );
+ assert.throws(
+ () =>
+ readFileSync(
+ join(outDir, "runtime-assets", "tls-client", "bin", "untracked-extra.so"),
+ "utf8"
+ ),
+ /ENOENT/,
+ "standalone assembly must not distribute non-manifest TLS native siblings"
+ );
} finally {
rmSync(projectRoot, { recursive: true, force: true });
rmSync(outDir, { recursive: true, force: true });
}
const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8");
+ const bunDockerfile = readFileSync(join(ROOT, "Dockerfile.bun"), "utf8");
assert.match(
dockerfile,
/COPY --from=builder \/app\/\.build\/next\/standalone \.\//,
"Docker runner must consume the standalone tree that carries THIRD_PARTY_NOTICES.md"
);
+ assert.match(
+ bunDockerfile,
+ /COPY --from=builder(?: --chown=bun:bun)? \/app\/\.build\/next\/standalone \.\//,
+ "Bun runner must consume the standalone tree that carries LICENSE and notices"
+ );
});
diff --git a/tests/unit/build/tls-client-pack-seed.test.ts b/tests/unit/build/tls-client-pack-seed.test.ts
new file mode 100644
index 0000000000..0fd9e2d6f5
--- /dev/null
+++ b/tests/unit/build/tls-client-pack-seed.test.ts
@@ -0,0 +1,184 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+import * as packPolicy from "../../../scripts/build/pack-artifact-policy.ts";
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
+
+type RuntimeSeedPathResolver = (platform?: NodeJS.Platform, arch?: string) => string;
+
+function allRuntimeSeedPaths(): string[] {
+ const manifest = JSON.parse(
+ readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8")
+ ) as { assets: Record };
+ return Object.values(manifest.assets)
+ .map((asset) => `runtime-assets/tls-client/bin/${asset.file}`)
+ .sort();
+}
+
+test("npm staging keeps and requires every exact manifest-backed TLS client runtime seed", () => {
+ const runtimeSeedPaths = allRuntimeSeedPaths();
+ assert.equal(runtimeSeedPaths.length, 6, "the pinned manifest currently supports six targets");
+
+ assert.deepEqual(
+ packPolicy.findUnexpectedArtifactPaths(runtimeSeedPaths, {
+ exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS,
+ prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES,
+ neverAllowedSegments: [],
+ }),
+ [],
+ "prepublish staging must not prune any officially supported runtime seed"
+ );
+ assert.deepEqual(
+ packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS.filter((path) =>
+ path.startsWith("runtime-assets/tls-client/bin/")
+ ).sort(),
+ runtimeSeedPaths,
+ "the staging allowlist must enumerate exactly the manifest assets"
+ );
+ assert.equal(
+ packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES.some((path) =>
+ path.startsWith("runtime-assets/tls-client")
+ ),
+ false,
+ "TLS native assets must never be authorized through a broad prefix"
+ );
+ assert.deepEqual(
+ packPolicy.findUnexpectedArtifactPaths(["runtime-assets/unrelated/surprise.bin"], {
+ exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS,
+ prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES,
+ neverAllowedSegments: [],
+ }),
+ ["runtime-assets/unrelated/surprise.bin"],
+ "the staging exception must stay scoped to the TLS client binary directory"
+ );
+ assert.deepEqual(
+ packPolicy.findUnexpectedArtifactPaths(
+ ["runtime-assets/tls-client/bin/untracked-extra-native.so"],
+ {
+ exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS,
+ prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES,
+ neverAllowedSegments: [],
+ }
+ ),
+ ["runtime-assets/tls-client/bin/untracked-extra-native.so"],
+ "the staging exception must not distribute an untracked native beside the pinned seed"
+ );
+
+ const requiredSeedPaths = runtimeSeedPaths.map((path) => `dist/${path}`);
+ assert.deepEqual(
+ packPolicy.PACK_ARTIFACT_REQUIRED_PATHS.filter((path) =>
+ path.startsWith("dist/runtime-assets/tls-client/bin/")
+ ).sort(),
+ requiredSeedPaths,
+ "check:pack-artifact must require every manifest asset and no untracked native"
+ );
+ for (const requiredSeedPath of requiredSeedPaths) {
+ assert.deepEqual(
+ packPolicy.findMissingArtifactPaths(
+ packPolicy.PACK_ARTIFACT_REQUIRED_PATHS.filter((path) => path !== requiredSeedPath),
+ packPolicy.PACK_ARTIFACT_REQUIRED_PATHS
+ ),
+ [requiredSeedPath],
+ `the pack gate must report ${requiredSeedPath} when it is absent`
+ );
+ }
+
+ const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
+ files: string[];
+ };
+ assert.ok(packageJson.files.includes("dist/"), "npm files must include the staged dist tree");
+ assert.equal(
+ packageJson.files.some(
+ (entry) => entry.startsWith("!") && /runtime-assets|tls-client|\.(?:so|dylib|dll)/.test(entry)
+ ),
+ false,
+ "npm files exclusions must not remove the exact native seeds after policy validation"
+ );
+});
+
+test("npm staging retains the TLS manifest and notices inside dist", () => {
+ const standaloneLegalPaths = [
+ "THIRD_PARTY_NOTICES.md",
+ "open-sse/config/tlsClientNativeManifest.json",
+ ];
+
+ assert.deepEqual(
+ packPolicy.findUnexpectedArtifactPaths(standaloneLegalPaths, {
+ exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS,
+ prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES,
+ neverAllowedSegments: [],
+ }),
+ [],
+ "prepublish must not prune legal provenance copied into the standalone"
+ );
+ for (const filePath of standaloneLegalPaths) {
+ assert.ok(
+ packPolicy.PACK_ARTIFACT_REQUIRED_PATHS.includes(`dist/${filePath}`),
+ `check:pack-artifact must require dist/${filePath}`
+ );
+ }
+});
+
+test("TLS client pack seed resolution fails explicitly on unsupported platforms", () => {
+ const resolver = (
+ packPolicy as typeof packPolicy & {
+ resolveTlsClientRuntimeSeedPath?: RuntimeSeedPathResolver;
+ }
+ ).resolveTlsClientRuntimeSeedPath;
+
+ assert.equal(typeof resolver, "function", "pack policy must expose manifest-backed resolution");
+ assert.throws(
+ () => resolver?.("aix", "ppc64"),
+ /Unsupported platform for tls-client-node native asset: aix\/ppc64/
+ );
+});
+
+test("build:cli verifies all TLS client targets before assembly and after final pruning", () => {
+ const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
+ scripts: Record;
+ };
+ assert.match(
+ packageJson.scripts["build:cli"],
+ /(?:^|\s)scripts\/build\/prepublish\.ts(?:\s|$)/,
+ "the published build must execute scripts/build/prepublish.ts"
+ );
+
+ const prepublish = readFileSync(join(ROOT, "scripts", "build", "prepublish.ts"), "utf8");
+ assert.match(
+ prepublish,
+ /import\s*\{[\s\S]*?fixTlsClientNodeBinary[\s\S]*?TLS_CLIENT_NATIVE_ASSETS[\s\S]*?\}\s*from\s*"\.\/fixTlsClientNodeBinary\.mjs";/,
+ "prepublish must use the audited TLS client binary verifier"
+ );
+ assert.match(prepublish, /Object\.keys\(TLS_CLIENT_NATIVE_ASSETS\)/);
+ const fixerCall = prepublish.match(/await\s+fixTlsClientNodeBinary\(\{([\s\S]*?)\}\);/);
+ assert.ok(fixerCall, "prepublish must await TLS client seed verification for each platform");
+ assert.match(fixerCall[1], /\bplatform\b/);
+ assert.match(fixerCall[1], /\barches\b/);
+ assert.match(fixerCall[1], /\bstrict:\s*true\b/);
+ assert.match(fixerCall[1], /\brequireStandalone:\s*true\b/);
+
+ const allTargetCalls = [
+ ...prepublish.matchAll(/await\s+verifyAllTlsClientRuntimeSeeds\(([^)]+)\);/g),
+ ];
+ assert.equal(allTargetCalls.length, 2, "verify all targets at both npm artifact boundaries");
+ assert.equal(allTargetCalls[0][1].trim(), "standaloneDir");
+ assert.equal(allTargetCalls[1][1].trim(), "DIST_DIR");
+
+ const sourceFixerIndex = allTargetCalls[0].index ?? -1;
+ const assembleIndex = prepublish.indexOf("assembleStandalone({");
+ const finalPruneIndex = prepublish.indexOf("const remainingUnexpectedFiles");
+ const finalFixerIndex = allTargetCalls[1].index ?? -1;
+ const doneIndex = prepublish.indexOf("// ── Done");
+ assert.ok(
+ sourceFixerIndex >= 0 && sourceFixerIndex < assembleIndex,
+ "all-target source verification must precede assembly"
+ );
+ assert.ok(
+ finalPruneIndex < finalFixerIndex && finalFixerIndex < doneIndex,
+ "strict all-target digest verification must follow final pruning and precede success"
+ );
+});
diff --git a/tests/unit/chatcore-stream-error-result.test.ts b/tests/unit/chatcore-stream-error-result.test.ts
index 352877046a..06868f7197 100644
--- a/tests/unit/chatcore-stream-error-result.test.ts
+++ b/tests/unit/chatcore-stream-error-result.test.ts
@@ -7,8 +7,11 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
+ createSafeAbortError,
+ formatStreamRecoveryRetryWarning,
isSemaphoreCapacityError,
createStreamingErrorResult,
+ getSafeErrorMetadata,
getUpstreamErrorIdentifier,
} from "../../open-sse/handlers/chatCore/streamErrorResult.ts";
@@ -20,6 +23,35 @@ test("isSemaphoreCapacityError matches the two semaphore codes only", () => {
assert.equal(isSemaphoreCapacityError("SEMAPHORE_TIMEOUT"), false);
});
+test("formatStreamRecoveryRetryWarning sanitizes hostile error names", () => {
+ const secret = "STREAM_RECOVERY_NAME_SECRET";
+ const credentialName = new Proxy(
+ {},
+ {
+ get(_target, key) {
+ if (key === "name") return `password=${secret} /home/alice/recovery.ts`;
+ throw new Error("hostile recovery metadata");
+ },
+ }
+ );
+ const credentialWarning = formatStreamRecoveryRetryWarning(1, 4, credentialName);
+ assert.doesNotMatch(credentialWarning, /STREAM_RECOVERY_NAME_SECRET|\/home\/alice/);
+ assert.match(credentialWarning, /\[REDACTED\]/);
+
+ const hostileGetter = new Proxy(
+ {},
+ {
+ get() {
+ throw new Error("hostile name getter");
+ },
+ }
+ );
+ assert.equal(
+ formatStreamRecoveryRetryWarning(2, 4, hostileGetter),
+ "transparent early-retry 2/4 after truncation"
+ );
+});
+
test("createStreamingErrorResult builds an SSE error envelope with [DONE] terminator", async () => {
const result = createStreamingErrorResult(503, "boom");
assert.equal(result.success, false);
@@ -43,10 +75,64 @@ test("createStreamingErrorResult attaches optional code and type", async () => {
assert.equal(json.error.type, "rate_limit_error");
});
+test("createStreamingErrorResult sanitizes message, code, and type at the SSE boundary", async () => {
+ const secret = "STREAM_RESULT_SECRET";
+ const result = createStreamingErrorResult(
+ 502,
+ `upstream password=${secret} at /home/alice/stream.ts:10:2`,
+ "password_hunter2",
+ "authorization_BearerSecret"
+ );
+ const body = await result.response.text();
+ const json = JSON.parse(body.slice("data: ".length, body.indexOf("\n\n")));
+
+ assert.equal(json.error.message, "upstream password=[REDACTED]");
+ assert.equal(json.error.code, "bad_gateway");
+ assert.equal(json.error.type, "server_error");
+ assert.doesNotMatch(body, new RegExp(`${secret}|/home/alice|\\bat \\S`));
+ assert.equal(result.error, `upstream password=${secret} at /home/alice/stream.ts:10:2`);
+});
+
test("getUpstreamErrorIdentifier returns a non-empty string code or undefined", () => {
assert.equal(getUpstreamErrorIdentifier({ code: "ECONNRESET" }), "ECONNRESET");
assert.equal(getUpstreamErrorIdentifier({ code: "" }), undefined);
assert.equal(getUpstreamErrorIdentifier({ code: 123 }), undefined);
assert.equal(getUpstreamErrorIdentifier(null), undefined);
assert.equal(getUpstreamErrorIdentifier("ECONNRESET"), undefined);
+ const hostile = new Proxy(
+ {},
+ {
+ get() {
+ throw new Error("hostile code getter");
+ },
+ }
+ );
+ assert.doesNotThrow(() => isSemaphoreCapacityError(hostile));
+ assert.equal(getUpstreamErrorIdentifier(hostile), undefined);
+ const hostileAbort = new Proxy(
+ {},
+ {
+ get(_target, key) {
+ if (key === "name") return "AbortError";
+ throw new Error("hostile abort metadata");
+ },
+ }
+ );
+ assert.equal(getSafeErrorMetadata(hostileAbort).name, "AbortError");
+ let codeReads = 0;
+ const mutableCode = new Proxy(
+ {},
+ {
+ get(_target, key) {
+ if (key !== "code") return undefined;
+ codeReads += 1;
+ return codeReads === 1 ? "SEMAPHORE_TIMEOUT" : "OTHER";
+ },
+ }
+ );
+ assert.equal(isSemaphoreCapacityError(mutableCode), true);
+ assert.equal(codeReads, 1);
+ const safeAbort = createSafeAbortError();
+ assert.equal(safeAbort.name, "AbortError");
+ assert.equal(safeAbort.message, "Request aborted");
});
diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts
index 6ecb048788..30b88aaaf8 100644
--- a/tests/unit/chatcore-translation-paths.test.ts
+++ b/tests/unit/chatcore-translation-paths.test.ts
@@ -16,7 +16,7 @@ const { invalidateCacheControlSettingsCache } =
const { clearCache, getCachedResponse, generateSignature } =
await import("../../src/lib/semanticCache.ts");
const { clearIdempotency } = await import("../../src/lib/idempotencyLayer.ts");
-const { getPendingRequests, clearPendingRequests } =
+const { getPendingRequests, clearPendingRequests, getUsageHistory } =
await import("../../src/lib/usage/usageHistory.ts");
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
const {
@@ -29,7 +29,6 @@ const { clearModelLock, isModelLocked } =
await import("../../open-sse/services/accountFallback.ts");
const { saveModelsDevCapabilities, clearModelsDevCapabilities } =
await import("../../src/lib/modelsDevSync.ts");
-// Dynamic import is required after TEST_DATA_DIR is initialized above.
const { clearReasoningCacheAll } = await import("../../open-sse/services/reasoningCache.ts");
const {
getBackgroundDegradationConfig,
@@ -326,9 +325,7 @@ async function resetStorage() {
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
-// 30s ceiling: c8 instrumentation plus --test-concurrency=8 can stall CI workers
-// well past the upstream timeout budget. Green runs return as soon as the condition
-// holds, so the ceiling only bounds the failure case.
+// The 30s ceiling bounds c8/CI stalls; green runs return as soon as the condition holds.
async function waitFor(fn, timeoutMs = 30000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
@@ -340,7 +337,6 @@ async function waitFor(fn, timeoutMs = 30000) {
}
async function flushAsyncSideEffects() {
- // setImmediate rounds drain the event loop more reliably than setTimeout under CI load.
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
}
@@ -372,6 +368,7 @@ async function invokeChatCore({
reasoningTransportFallback = "drop",
managedLease = null,
cachedSettings = null,
+ log = noopLog(),
}: any = {}) {
const calls: any[] = [];
@@ -406,7 +403,7 @@ async function invokeChatCore({
apiKey: "sk-test",
providerSpecificData: {},
},
- log: noopLog(),
+ log,
clientRawRequest: {
endpoint,
body: structuredClone(body),
@@ -451,10 +448,7 @@ test.after(async () => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("chatCore times out upstream execution before provider response headers", async () => {
- // This test asserts pendingDetail.providerRequest — only attached when the
- // call-log pipeline capture is enabled. Declare the dependency explicitly
- // (fresh-DB default leaves it off → the waitFor below would never resolve;
- // failed deterministically on CI and on an isolated run, incl. at v3.8.18).
+ // pendingDetail.providerRequest exists only when pipeline capture is enabled.
await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
const executor = getExecutor("openai");
const originalGetTimeoutMs = executor.getTimeoutMs?.bind(executor);
@@ -493,9 +487,7 @@ test("chatCore times out upstream execution before provider response headers", a
} as any);
const pendingDetail = (await waitFor(() =>
- // details[connectionId] is Record —
- // the original predicate tested each ARRAY's .providerRequest (always
- // undefined), so the waitFor could never resolve. Flatten to the details.
+ // Flatten the model-keyed detail arrays before matching providerRequest.
Object.values(getPendingRequests().details[connectionId] || {})
.flat()
.find((detail: any) => detail?.providerRequest?.model === "gpt-4o-mini")
@@ -528,7 +520,6 @@ test("chatCore can disable pipeline stream chunk capture through environment", a
messages: [{ role: "user", content: "stream without chunk logging" }],
},
});
-
assert.equal(result.success, true);
await result.response.text();
await flushAsyncSideEffects();
@@ -936,8 +927,7 @@ test("chatCore replays streamed DeepSeek Responses reasoning across a Chat tool
});
test("chatCore replays no-tool reasoning across public Responses turns", async () => {
- // Direct DeepSeek now speaks Responses upstream. Keep this regression on a
- // Chat-compatible DeepSeek host so it continues to exercise the Responses-to-Chat replay path.
+ // Use a Chat-compatible DeepSeek host to exercise Responses-to-Chat replay.
saveModelsDevCapabilities({
siliconflow: {
"deepseek-v4-pro": {
@@ -1284,11 +1274,7 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers"
assert.equal(call.body.messages[0].content[0].text, "Ping");
});
-// Fix #2468: normalizeClaudeUpstreamMessages() now runs on the pure Claude passthrough
-// path too. It extracts role:"system" messages into the top-level system parameter,
-// strips empty text blocks, converts inline document blocks (no url/data) to text, and
-// drops unknown block types (e.g. future_block). tool_result blocks are preserved via
-// preserveToolResultBlocks:true.
+// #2468: native Claude passthrough normalizes system/messages while preserving tool results.
test("chatCore normalizes native Claude Code messages for native Claude OAuth passthrough", async () => {
const clientMessages = [
{
@@ -1334,10 +1320,8 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa
assert.equal(result.success, true);
assert.equal(call.body.model, "claude-sonnet-4-6");
- // After normalization: role:"system" msg extracted → top-level system (3 msgs remain, not 4)
assert.equal(call.body.messages.length, 3);
- // system-role block appended to top-level system array
assert.equal(
call.body.system.some(
(block: { text?: string }) => block.text === "system-message-that-should-stay-in-messages"
@@ -1345,8 +1329,6 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa
true
);
- // user msg[0] (was clientMessages[1]): empty text, document and future_block are preserved
- // since it is a semantic passthrough request
assert.equal(call.body.messages[0].content.length, 4);
assert.equal(call.body.messages[0].content[0].type, "text");
assert.equal(call.body.messages[0].content[0].text, "");
@@ -1354,10 +1336,8 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa
assert.equal(call.body.messages[0].content[2].type, "document");
assert.equal(call.body.messages[0].content[3].type, "future_block");
- // assistant msg[1] (was clientMessages[2]): tool_use unchanged
assert.equal(call.body.messages[1].content[0].type, "tool_use");
- // user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
assert.equal(call.body.messages[2].content[0].type, "tool_result");
});
test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", async () => {
@@ -1457,9 +1437,7 @@ test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough
]);
});
-// Fix #2468: normalizeClaudeUpstreamMessages() runs on the CC-compatible bridge path too
-// (preserveClaudeMessages=true). Same normalization: system-role → top-level system,
-// empty text stripped, document→text, future_block dropped, tool_result preserved.
+// #2468: the CC-compatible bridge applies the same native-message normalization.
test("chatCore normalizes native Claude Code messages before CC-compatible relay transforms", async () => {
const clientMessages = [
{
@@ -1512,11 +1490,8 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay
assert.match(call.url, /\/v1\/messages\?beta=true$/);
assert.equal(call.body.stream, true);
- // After normalization: role:"system" msg extracted → top-level system (3 msgs remain, not 4)
assert.equal(call.body.messages.length, 3);
- // CC bridge prepends its dynamic billing/fingerprint blocks; the SDK identity and
- // extracted system block must both remain present regardless of their exact position.
assert.equal(
call.body.system.some(
(block: { text?: string }) =>
@@ -1531,8 +1506,6 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay
true
);
- // user msg[0] (was clientMessages[1]): empty text, document and future_block are preserved
- // since it is a semantic passthrough request
assert.equal(call.body.messages[0].content.length, 4);
assert.equal(call.body.messages[0].content[0].type, "text");
assert.equal(call.body.messages[0].content[0].text, "");
@@ -1540,10 +1513,8 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay
assert.equal(call.body.messages[0].content[2].type, "document");
assert.equal(call.body.messages[0].content[3].type, "future_block");
- // assistant msg[1] (was clientMessages[2]): tool_use unchanged
assert.equal(call.body.messages[1].content[0].type, "tool_use");
- // user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
assert.equal(call.body.messages[2].content[0].type, "tool_result");
});
test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => {
@@ -1736,9 +1707,7 @@ test("chatCore disables raw Claude passthrough when cache preservation is off an
),
true
);
- // Cache preservation is on for native Claude, so cache markers are intact. This PR:
- // an omitted TTL now defaults to "5m" once a "5m" boundary breakpoint (the system
- // block above) has already appeared, instead of always defaulting to "1h".
+ // An omitted TTL inherits the prior 5m cache boundary.
assert.deepEqual(call.body.messages[0].content[0].cache_control, {
type: "ephemeral",
ttl: "5m",
@@ -1860,7 +1829,9 @@ test("chatCore restores prefixed Claude passthrough tool names in upstream respo
},
});
- const payload = (await result.response.json()) as any;
+ const payload = (await result.response.json()) as {
+ error: { code?: string; message: string; type?: string };
+ };
assert.equal(result.success, true);
assert.equal(payload.content[0].name, "Bash");
});
@@ -1953,12 +1924,59 @@ test("chatCore surfaces translation errors with explicit status codes", async ()
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 () => {
+test("chatCore sanitizes typed translation failures in the response and warning log", async () => {
+ const secret = "TRANSLATION_BRANCH_SECRET";
+ const rawType = "access_token_SECRET123";
+ const warnings: string[] = [];
+ register(
+ FORMATS.OPENAI_RESPONSES,
+ FORMATS.OPENAI,
+ () => {
+ const error = new Error(`typed password=${secret} at /home/alice/translate.ts:8:3`);
+ error.statusCode = 422;
+ error.errorType = rawType;
+ throw error;
+ },
+ null
+ );
+
+ const { result } = await invokeChatCore({
+ provider: "openai",
+ model: "gpt-4o-mini",
+ endpoint: "/v1/responses",
+ body: {
+ model: "gpt-4o-mini",
+ input: "hello",
+ },
+ log: {
+ ...noopLog(),
+ warn(_scope: string, message: string) {
+ warnings.push(message);
+ },
+ },
+ });
+
+ assert.equal(result.success, false);
+ assert.equal(result.status, 422);
+
+ const payload = (await result.response.json()) as {
+ error: { code?: string; message: string; type?: string };
+ };
+ assert.equal(payload.error.message, "typed password=[REDACTED]");
+ assert.equal(payload.error.type, "invalid_request_error");
+ assert.equal(payload.error.code, "error");
+ assert.equal(result.rawMessage, `typed password=${secret} at /home/alice/translate.ts:8:3`);
+ assert.equal(result.errorType, rawType);
+ assert.equal(result.errorCode, rawType);
+ assert.doesNotMatch(JSON.stringify(payload), new RegExp(`${secret}|/home/alice|\\bat \\S`));
+ assert.equal(warnings.length, 1);
+ assert.doesNotMatch(warnings[0], new RegExp(`${secret}|/home/alice|\\bat \\S`));
+});
+test("chatCore preserves safe typed translation error identifiers", async () => {
register(
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI,
@@ -1975,19 +1993,79 @@ test("chatCore surfaces typed translation errors with the declared error type",
provider: "openai",
model: "gpt-4o-mini",
endpoint: "/v1/responses",
- body: {
- model: "gpt-4o-mini",
- input: "hello",
- },
+ body: { model: "gpt-4o-mini", input: "hello" },
});
+ const payload = (await result.response.json()) as {
+ error: { code?: string; type?: string };
+ };
- 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");
});
+test("chatCore projects non-string classifications and sanitizes provider failure logs", async () => {
+ const secret = "CHATCORE_SINK_SECRET_42";
+ const hostileMessage =
+ `upstream access_token=${secret}\n` + " at dispatch (/home/alice/transport.ts:10:4)";
+ const capturedConsole: string[] = [];
+ const originalConsoleLog = console.log;
+ await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
+ console.log = (...args: unknown[]) => capturedConsole.push(args.map(String).join(" "));
+ let result;
+ try {
+ ({ result } = await invokeChatCore({
+ provider: "openai",
+ model: "gpt-4o-mini",
+ body: {
+ model: "gpt-4o-mini",
+ stream: false,
+ messages: [{ role: "user", content: "trigger a provider error" }],
+ },
+ responseFactory() {
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: hostileMessage,
+ code: 401,
+ type: { credential: "OPAQUE_TYPE_VALUE" },
+ stack: "Error: failed\n at dispatch (/home/alice/provider.ts:7:3)",
+ },
+ diagnostic: { message: hostileMessage },
+ safe: { attempt: 2 },
+ sessionId: "OPAQUE_SESSION_VALUE",
+ }),
+ { status: 400, headers: { "Content-Type": "application/json" } }
+ );
+ },
+ }));
+ } finally {
+ console.log = originalConsoleLog;
+ }
+ assert.equal(result.success, false);
+ assert.equal(result.status, 400);
+ assert.equal(result.errorCode, undefined);
+ assert.equal(result.errorType, undefined);
+ const responsePayload = (await result.response.json()) as {
+ error: { code?: string; message: string; type?: string };
+ };
+ assert.equal(responsePayload.error.code, "bad_request");
+ assert.equal(responsePayload.error.type, "invalid_request_error");
+
+ const detail = await waitFor(() => getLatestCallLog());
+ assert.ok(detail?.pipelinePayloads);
+ const pipeline = detail.pipelinePayloads as Record;
+ const serializedSinks = JSON.stringify({
+ console: capturedConsole.filter((line) => line.includes("[ERROR]")),
+ callLogError: detail.error,
+ pipelineError: pipeline.error,
+ providerResponse: pipeline.providerResponse,
+ responsePayload,
+ });
+ assert.doesNotMatch(serializedSinks, /CHATCORE_SINK_SECRET_42|OPAQUE_TYPE_VALUE/);
+ assert.doesNotMatch(serializedSinks, /OPAQUE_SESSION_VALUE|\/home\/alice|\bat dispatch\b/);
+ assert.match(serializedSinks, /\[REDACTED\]/);
+ assert.match(serializedSinks, /\"attempt\":2/);
+});
test("chatCore returns 500 when translation throws a generic error", async () => {
register(
FORMATS.OPENAI_RESPONSES,
@@ -2012,6 +2090,77 @@ test("chatCore returns 500 when translation throws a generic error", async () =>
assert.equal(result.status, 500);
assert.equal(result.error, "unexpected translator crash");
});
+test("chatCore fails closed when a thrown translation message cannot be coerced", async () => {
+ register(
+ FORMATS.OPENAI_RESPONSES,
+ FORMATS.OPENAI,
+ () => {
+ throw {
+ statusCode: 422,
+ message: {
+ toString(): string {
+ throw new Error("hostile translation coercion");
+ },
+ },
+ };
+ },
+ null
+ );
+
+ const { result } = await invokeChatCore({
+ provider: "openai",
+ model: "gpt-4o-mini",
+ endpoint: "/v1/responses",
+ body: { model: "gpt-4o-mini", input: "hello" },
+ });
+ const payload = (await result.response.json()) as { error: { message: string } };
+
+ assert.equal(result.success, false);
+ assert.equal(result.status, 422);
+ assert.equal(result.rawMessage, "Invalid request");
+ assert.equal(payload.error.message, "Invalid request");
+});
+test("chatCore fails closed over hostile translation status and type accessors", async () => {
+ const hostileValues = [
+ {
+ statusCode: Symbol("hostile-status"),
+ errorType: "unsupported_feature",
+ message: "symbol status failure",
+ },
+ new Proxy(
+ { message: "proxy accessor failure" },
+ {
+ get(target, property, receiver) {
+ if (property === "statusCode" || property === "errorType") {
+ throw new Error("hostile classification accessor");
+ }
+ return Reflect.get(target, property, receiver);
+ },
+ }
+ ),
+ ];
+
+ for (const hostile of hostileValues) {
+ register(
+ FORMATS.OPENAI_RESPONSES,
+ FORMATS.OPENAI,
+ () => {
+ throw hostile;
+ },
+ 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, 500);
+ assert.equal(typeof result.error, "string");
+ }
+});
test("chatCore refreshes GitHub credentials after 401 and retries with the refreshed Copilot token", async () => {
let refreshedCredentials = null;
const { calls, result } = await invokeChatCore({
@@ -2580,12 +2729,10 @@ test("chatCore 429 lets account fallback apply the configured resilience cooldow
});
},
});
-
const afterCore = await providersDb.getProviderConnectionById((connection as any).id);
assert.equal(result.success, false);
assert.equal(result.status, 429);
assert.equal((afterCore as any).rateLimitedUntil, undefined);
-
const fallback = await auth.markAccountUnavailable(
(connection as any).id,
result.status,
@@ -2596,7 +2743,6 @@ test("chatCore 429 lets account fallback apply the configured resilience cooldow
const afterFallback = await providersDb.getProviderConnectionById((connection as any).id);
const cooldownRemaining =
new Date((afterFallback as any).rateLimitedUntil).getTime() - Date.now();
-
assert.equal(fallback.shouldFallback, true);
assert.equal(fallback.cooldownMs, 1000);
assert.equal((afterFallback as any).testStatus, "unavailable");
@@ -2634,7 +2780,6 @@ test("chatCore does not substitute an OpenAI model after context overflow", asyn
"gpt-4o": capabilityEntry(256_000),
},
});
-
const { calls, result } = await invokeChatCore({
provider: "openai",
model: "gpt-5",
@@ -2653,7 +2798,6 @@ test("chatCore does not substitute an OpenAI model after context overflow", asyn
return buildOpenAIResponse(false, "unexpected fallback");
},
});
-
assert.equal(result.success, false);
assert.equal(result.status, 400);
assert.equal(calls.length, 1);
@@ -2671,7 +2815,6 @@ test("chatCore parses upstream SSE payloads for non-streaming requests", async (
return buildOpenAIResponse(true, "sse json");
},
});
-
const payload = (await result.response.json()) as any;
assert.equal(result.success, true);
assert.equal(payload.choices[0].message.content, "sse json");
@@ -2692,12 +2835,13 @@ test("chatCore rejects malformed non-streaming SSE payloads", async () => {
});
},
});
-
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.match(result.error, /Invalid SSE response/);
});
test("chatCore rejects malformed non-streaming JSON payloads", async () => {
+ const secret = "MALFORMED_JSON_SINK_SECRET";
+ await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
const { result } = await invokeChatCore({
provider: "openai",
model: "gpt-4o-mini",
@@ -2707,18 +2851,27 @@ test("chatCore rejects malformed non-streaming JSON payloads", async () => {
messages: [{ role: "user", content: "return valid json" }],
},
responseFactory() {
- return new Response("{oops", {
- status: 200,
- headers: { "Content-Type": "application/json" },
- });
+ return new Response(
+ `not-json access_token=${secret} /home/alice/raw.ts\n at parse (/home/alice/parser.ts:1:2)`,
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
},
});
-
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.equal(result.error, "Invalid JSON response from provider");
+ const detail = await waitFor(() => getLatestCallLog());
+ assert.ok(detail?.pipelinePayloads);
+ const sinks = JSON.stringify({ error: detail.error, pipeline: detail.pipelinePayloads });
+ assert.doesNotMatch(sinks, /MALFORMED_JSON_SINK_SECRET|\/home\/alice|\bat parse\b/);
+ assert.match(sinks, /\[REDACTED\]|/);
});
test("chatCore does not substitute an OpenAI model after empty content", async () => {
+ const secret = "EMPTY_CONTENT_SINK_SECRET";
+ await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
const { calls, result } = await invokeChatCore({
provider: "openai",
model: "gpt-5.1",
@@ -2727,34 +2880,87 @@ test("chatCore does not substitute an OpenAI model after empty content", async (
stream: false,
messages: [{ role: "user", content: "recover from empty content" }],
},
- responseFactory(_captured, seenCalls) {
- if (seenCalls.length === 1) {
- return new Response(
- JSON.stringify({
- id: "chatcmpl-empty",
- object: "chat.completion",
- model: "gpt-5.1",
- choices: [
- {
- index: 0,
- message: { role: "assistant", content: "" },
- finish_reason: "stop",
- },
- ],
- }),
- {
- status: 200,
- headers: { "Content-Type": "application/json" },
- }
- );
- }
- return buildOpenAIResponse(false, "unexpected fallback");
+ responseFactory() {
+ return new Response(
+ JSON.stringify({
+ id: "chatcmpl-empty",
+ choices: [{ message: { content: "" }, finish_reason: "stop" }],
+ diagnostic: `access_token=${secret} /home/alice/empty.ts\n at inspect (/home/alice/trace.ts:1:2)`,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
},
});
-
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.equal(calls.length, 1);
+ const detail = await waitFor(() => getLatestCallLog());
+ assert.ok(detail?.pipelinePayloads);
+ const sinks = JSON.stringify({ error: detail.error, pipeline: detail.pipelinePayloads });
+ assert.doesNotMatch(sinks, /EMPTY_CONTENT_SINK_SECRET|\/home\/alice|\bat inspect\b/);
+ assert.match(sinks, /\[REDACTED\]|/);
+ const { result: malformedResult } = await invokeChatCore({
+ body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "translate" }] },
+ responseFactory() {
+ return new Response(
+ JSON.stringify({
+ object: "response",
+ output: [{ diagnostic: `token_v2=${secret} /home/alice/translated.ts` }],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ },
+ });
+ assert.equal(malformedResult.status, 502);
+ const malformedDetail = await waitFor(() => getLatestCallLog());
+ const malformedSinks = JSON.stringify({
+ responseBody: malformedDetail?.responseBody,
+ pipeline: malformedDetail?.pipelinePayloads,
+ });
+ assert.doesNotMatch(malformedSinks, /EMPTY_CONTENT_SINK_SECRET|\/home\/alice/);
+});
+test("chatCore sanitizes hostile ClinePass retry failures in warning logs", async () => {
+ const secret = "CLINEPASS_RETRY_SECRET";
+ const warnings: string[] = [];
+ let attempts = 0;
+ let prototypeReads = 0;
+ const retryError = new Proxy(new Error(`password=${secret} /home/alice/clinepass.ts`), {
+ get(target, key, receiver) {
+ if (key === "errorCode") throw new Error("hostile retry getter");
+ return Reflect.get(target, key, receiver);
+ },
+ getPrototypeOf(target) {
+ prototypeReads += 1;
+ if (prototypeReads > 1) throw new Error("hostile retry prototype");
+ return Reflect.getPrototypeOf(target);
+ },
+ });
+ const { result } = await invokeChatCore({
+ provider: "clinepass",
+ model: "cline-pass/glm-5.2",
+ body: { model: "cline-pass/glm-5.2", messages: [{ role: "user", content: "retry" }] },
+ log: {
+ ...noopLog(),
+ warn(_scope, message) {
+ warnings.push(message);
+ },
+ },
+ responseFactory() {
+ attempts += 1;
+ if (attempts === 1) {
+ return new Response(JSON.stringify({ success: false, error: "empty response" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ throw retryError;
+ },
+ });
+ assert.equal(result.status, 502);
+ assert.ok(prototypeReads >= 1);
+ const retryWarning = warnings.find((message) => message.includes("retry failed")) || "";
+ assert.doesNotMatch(retryWarning, /CLINEPASS_RETRY_SECRET|\/home\/alice/);
+ assert.match(retryWarning, /\[REDACTED\]/);
});
test("chatCore returns a gateway error without probing another OpenAI model", async () => {
const { result, calls } = await invokeChatCore({
@@ -2786,14 +2992,12 @@ test("chatCore returns a gateway error without probing another OpenAI model", as
}
);
}
-
return new Response("{invalid-json", {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
-
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.equal(result.error, "Provider returned empty content");
@@ -2802,7 +3006,6 @@ test("chatCore returns a gateway error without probing another OpenAI model", as
test("chatCore records Claude prompt cache and cache usage metadata in call logs", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" });
invalidateCacheControlSettingsCache();
-
const { result } = await invokeChatCore({
provider: "claude",
model: "claude-sonnet-4-6",
@@ -2859,9 +3062,7 @@ test("chatCore records Claude prompt cache and cache usage metadata in call logs
);
},
});
-
const detail = await waitFor(() => getLatestCallLog());
-
assert.equal(result.success, true);
assert.ok(detail);
assert.equal(detail.requestBody._omniroute.claudePromptCache.applied, true);
@@ -2877,12 +3078,7 @@ test("chatCore records Claude prompt cache and cache usage metadata in call logs
});
});
test("chatCore propagates budget errors without an executor-level emergency hop", async () => {
- // The emergency budget fallback is orchestrated by the routing layer
- // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency
- // provider through account selection. The old executor-level hop here re-sent
- // the FAILING provider's credentials to the emergency provider's endpoint
- // (cross-provider credential leak) — the engine must now surface the budget
- // error as-is, with no extra upstream call.
+ // Routing owns emergency fallback; this layer must not reuse credentials across providers.
const { calls, result } = await invokeChatCore({
provider: "openai",
model: "gpt-4o-mini",
@@ -2904,7 +3100,6 @@ test("chatCore propagates budget errors without an executor-level emergency hop"
);
},
});
-
assert.equal(result.success, false);
assert.equal(result.status, 402);
assert.equal(calls.length, 1, "no executor-level emergency hop may fire");
@@ -2930,7 +3125,6 @@ test("chatCore injects progress events into streaming responses when requested",
return buildOpenAIResponse(true, "streamed");
},
});
-
const streamText = await result.response.text();
assert.equal(result.success, true);
assert.equal(result.response.headers.get("X-OmniRoute-Progress"), "enabled");
@@ -2950,22 +3144,12 @@ test("chatCore keeps the SSE stream comment-free by default and still ends with
return buildOpenAIResponse(true, "streamed");
},
});
-
const streamText = await result.response.text();
-
assert.equal(result.success, true);
- // The per-request metadata reaches the client through these headers regardless
- // of the comment setting — that is what makes the trailer optional.
assert.equal(result.response.headers.get("X-OmniRoute-Provider"), "openai");
assert.equal(result.response.headers.get("X-OmniRoute-Model"), "gpt-4o-mini");
- // #10524 flipped OMNIROUTE_SSE_COMMENTS to off-by-default: strict SSE clients
- // JSON.parse every line and crash on `: x-omniroute-*` comments. This test used
- // to assert the opposite and went red on the release branch when that default
- // landed. The opt-in half — trailer present, after the finish chunk and before
- // [DONE] — is owned by sse-comments-optout-9305.test.ts, which drives the env
- // var through all three states; enabling it here instead leaks process.env into
- // the sibling call-log tests in this file.
+ // Strict SSE clients require comments off by default; the opt-in path has its own test.
assert.doesNotMatch(streamText, /: x-omniroute-/);
assert.match(streamText, /data: \[DONE\]/);
});
@@ -3032,29 +3216,155 @@ test("chatCore strips upstream compression and length headers from streaming res
await result.response.text();
});
test("chatCore maps upstream aborts to request-aborted errors", async () => {
+ const abortError = new Error("request aborted by client");
+ abortError.name = "AbortError";
const { result } = await invokeChatCore({
- provider: "openai",
- model: "gpt-4o-mini",
- body: {
- model: "gpt-4o-mini",
- stream: false,
- messages: [{ role: "user", content: "abort me" }],
- },
+ body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "abort me" }] },
responseFactory() {
- const error = new Error("request aborted by client");
- error.name = "AbortError";
- throw error;
+ throw abortError;
},
});
-
- assert.equal(result.success, false);
assert.equal(result.status, 499);
assert.equal(result.error, "Request aborted");
});
+test("chatCore sanitizes transport failure message and cause at logging sinks", async () => {
+ const secret = "TRANSPORT_SINK_SECRET_42";
+ const hostileCode = `access_token_${secret}_/home/alice/code.ts`;
+ const originalConsoleLog = console.log;
+ const capturedConsole: string[] = [];
+ await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
+ console.log = (...args: unknown[]) => capturedConsole.push(args.map(String).join(" "));
+
+ let result;
+ try {
+ ({ result } = await invokeChatCore({
+ provider: "openai",
+ model: "gpt-4o-mini",
+ body: {
+ model: "gpt-4o-mini",
+ stream: false,
+ messages: [{ role: "user", content: "trigger a transport failure" }],
+ },
+ responseFactory() {
+ const error = new Error(
+ `socket password=${secret}\n at connect (/home/alice/socket.ts:1:2)`
+ );
+ error.cause = {
+ code: "ECONNRESET",
+ message: `cause access_token=${secret} at /home/alice/cause.ts:3:4`,
+ };
+ error.code = hostileCode;
+ throw error;
+ },
+ }));
+ } finally {
+ console.log = originalConsoleLog;
+ }
+
+ assert.equal(result.success, false);
+ assert.equal(result.status, 502);
+ assert.equal(result.errorCode, hostileCode, "raw code remains available to internal callers");
+ assert.match(result.rawMessage, new RegExp(secret));
+ const responsePayload = (await result.response.json()) as {
+ error: { message: string };
+ };
+ const detail = await waitFor(() => getLatestCallLog());
+ assert.ok(detail?.pipelinePayloads);
+ const serializedSinks = JSON.stringify({
+ console: capturedConsole.filter((line) => line.includes("[ERROR]")),
+ callLogError: detail.error,
+ pipelineError: (detail.pipelinePayloads as Record).error,
+ responsePayload,
+ });
+ assert.doesNotMatch(serializedSinks, /TRANSPORT_SINK_SECRET_42|\/home\/alice/);
+ assert.doesNotMatch(serializedSinks, /\bat (?:connect|cause)\b/);
+ assert.match(serializedSinks, /\[REDACTED\]/);
+ const usage = await waitFor(async () => {
+ const entries = await getUsageHistory({ provider: "openai", model: "gpt-4o-mini" });
+ return entries.at(-1);
+ });
+ assert.ok(usage);
+ assert.equal(usage.errorCode, "upstream_error");
+ const proxySecret = "PROXY_METADATA_SECRET";
+ const semaphoreFailure = () =>
+ Object.assign(new Error(`password=${secret}\n at wait (/home/alice/semaphore.ts:1:2)`), {
+ code: "SEMAPHORE_TIMEOUT",
+ });
+ const hostileFailures = [
+ {
+ status: 502,
+ error: new Proxy(
+ {},
+ {
+ get(_target, key) {
+ if (key === "message") return `password=${proxySecret} /home/alice/proxy.ts`;
+ throw new Error("hostile metadata getter");
+ },
+ getPrototypeOf() {
+ throw new Error("hostile prototype");
+ },
+ }
+ ),
+ },
+ {
+ status: 499,
+ error: new Proxy(
+ {},
+ {
+ get(_target, key) {
+ if (key === "name") return "AbortError";
+ throw new Error("hostile abort getter");
+ },
+ getPrototypeOf() {
+ throw new Error("hostile abort prototype");
+ },
+ }
+ ),
+ },
+ { status: 429, stream: true, error: semaphoreFailure() },
+ { status: 429, stream: false, error: semaphoreFailure() },
+ ];
+ const executor = getExecutor("openai");
+ const originalExecute = executor.execute;
+ try {
+ for (const failure of hostileFailures) {
+ const previousLogId = failure.status === 429 ? (await getLatestCallLog())?.id : null;
+ executor.execute = async () => {
+ throw failure.error;
+ };
+ const { result: proxyResult } = await invokeChatCore({
+ accept: failure.stream ? "text/event-stream" : "application/json",
+ body: {
+ model: "gpt-4o-mini",
+ stream: failure.stream === true,
+ messages: [{ role: "user", content: "proxy" }],
+ },
+ });
+ assert.equal(proxyResult.status, failure.status);
+ const publicBody = await proxyResult.response.text();
+ assert.doesNotMatch(publicBody, /TRANSPORT_SINK_SECRET_42|\/home\/alice|hostile /);
+ if (failure.status === 429) {
+ assert.equal(proxyResult.errorCode, "SEMAPHORE_TIMEOUT");
+ assert.match(publicBody, failure.stream ? /"code":"SEMAPHORE_TIMEOUT"/ : /rate_limit/);
+ const semaphoreDetail = await waitFor(async () => {
+ const candidate = await getLatestCallLog();
+ return candidate?.id !== previousLogId ? candidate : null;
+ });
+ assert.equal(semaphoreDetail?.status, 429);
+ const semaphoreSinks = JSON.stringify({
+ error: semaphoreDetail?.error,
+ pipeline: semaphoreDetail?.pipelinePayloads,
+ });
+ assert.doesNotMatch(semaphoreSinks, /TRANSPORT_SINK_SECRET_42|\/home\/alice|\bat wait\b/);
+ assert.match(semaphoreSinks, /\[REDACTED\]/);
+ }
+ }
+ } finally {
+ executor.execute = originalExecute;
+ }
+});
test("chatCore maps raw string abort reasons to 499, not 502 (#7907)", async () => {
- // abort(reason) rejects the upstream fetch with the raw reason — often a
- // bare string with no `name`/`status`. It must map to 499 like a named
- // AbortError, not fall through to the 502 provider-failure default.
+ // Raw abort reasons must map to 499 like a named AbortError.
const { result } = await invokeChatCore({
provider: "openai",
model: "gpt-4o-mini",
@@ -3073,18 +3383,9 @@ test("chatCore maps raw string abort reasons to 499, not 502 (#7907)", async ()
assert.equal(result.error, "Request aborted");
});
-// Live incident territory (dashboard log id 1784504040241-6f8b9a): the client had
-// ALREADY disconnected before this synthetic error body was ever computed — nothing
-// was actually delivered to it. Persisting that body as `clientResponse` (the
-// dashboard's "what the client received" field) is misleading, since it implies a
-// response was sent when the client never got one. `error` above already records
-// the failure reason; `clientResponse`/`responseBody` should stay empty for an abort.
+// A disconnected client received no body, so its call log must not synthesize one.
test("chatCore does not log a synthetic clientResponse body for a client abort", async () => {
- // clientResponse only ever lands in the persisted pipeline payloads when detailed
- // call-log capture is on (attemptLogging.ts's detailedLoggingEnabled gate) — this is
- // exactly the setting a real "detailed logging" connection/request has enabled, which
- // is why the live incident's artifact JSON had a full pipeline (including the
- // misleading clientResponse) to begin with.
+ // Detailed capture is required to assert the persisted pipeline field.
await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
await invokeChatCore({
@@ -3160,12 +3461,7 @@ test("chatCore returns streaming responses without waiting for upstream completi
const raceResult = await Promise.race([
invocation.then(() => "returned"),
- // 10s ceiling: a non-buffering streaming impl resolves the invocation as soon
- // as the Response is returned (upstream still open), but on a starved CI event
- // loop that legitimate early return can exceed a 1s wall-clock budget (flake
- // repro: 5/8 runs returned at 1.3–2.2s under CPU contention → false "blocked").
- // The ceiling only bounds the buffered-failure case: a buffering impl never
- // resolves until closeUpstream() fires below, so it still trips "blocked".
+ // The 10s ceiling tolerates CI starvation while still detecting buffered responses.
new Promise((resolve) => setTimeout(() => resolve("blocked"), 10000)),
]);
@@ -3301,8 +3597,7 @@ test("chatCore caches streaming response and serves cache HIT on repeat", async
assert.equal(second.calls.length, 0, "second request should not reach upstream");
assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "HIT");
- // #2952 — a streaming client receives the cache HIT as an SSE stream (not a
- // raw JSON body), so content + reasoning_content arrive in the streaming shape.
+ // #2952: streaming cache hits remain SSE-framed.
assert.equal(
second.result.response.headers.get("Content-Type"),
"text/event-stream",
@@ -3431,9 +3726,7 @@ test("chatCore returns cache HIT as SSE when the client requests streaming", asy
assert.equal(second.calls.length, 0, "cached response should prevent upstream call");
assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "HIT");
- // #2952 — even though the cache was populated by a non-streaming request, a
- // later streaming request gets the cached completion SSE-wrapped, so streaming
- // clients keep their streaming shape (and reasoning_content) on cache hits.
+ // #2952: a JSON-populated cache still serves later streaming hits as SSE.
assert.equal(
second.result.response.headers.get("Content-Type"),
"text/event-stream",
diff --git a/tests/unit/chatgpt-web-handoff-resume.test.ts b/tests/unit/chatgpt-web-handoff-resume.test.ts
index bb309e9952..3dcabc1711 100644
--- a/tests/unit/chatgpt-web-handoff-resume.test.ts
+++ b/tests/unit/chatgpt-web-handoff-resume.test.ts
@@ -5,6 +5,7 @@ import type { TlsFetchOptions } from "../../open-sse/services/chatgptTlsClient.t
const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } =
await import("../../open-sse/executors/chatgpt-web.ts");
+const { resumeChatGptHandoff } = await import("../../open-sse/executors/chatgpt-web/handoff.ts");
const { __setTlsFetchOverrideForTesting } =
await import("../../open-sse/services/chatgptTlsClient.ts");
@@ -258,3 +259,101 @@ test("ChatGPT Web streaming appends the native resumed Pro answer", async () =>
mock.restore();
}
});
+
+test("ChatGPT Web handoff logs sanitize upstream response and transport details", async () => {
+ const cases = [
+ {
+ label: "response body",
+ run: async () => ({
+ status: 502,
+ headers: makeHeaders({ "Content-Type": "text/plain" }),
+ text:
+ "Cannot read download_url_https://files.oaiusercontent.com/private?sig=OPAQUE-HANDOFF-URL " +
+ "and '/srv/private/handoff.pem' access_token=sk-handoff-body\n" +
+ " at /srv/private/handoff.ts:1",
+ body: null,
+ }),
+ expected:
+ "conversation resume 502: Cannot read download_url_ and '' access_token=[REDACTED]",
+ },
+ {
+ label: "transport error",
+ run: async () => {
+ throw new Error(
+ "Cannot open '/srv/private/handoff.sock' access_token=sk-handoff-error\n" +
+ " at /srv/private/handoff.ts:2"
+ );
+ },
+ expected: "conversation resume failed: Cannot open '' access_token=[REDACTED]",
+ },
+ {
+ label: "stack-only transport error",
+ run: async () => {
+ throw new Error("\n at /srv/private/handoff.ts:3");
+ },
+ expected: "conversation resume failed: upstream error unavailable",
+ },
+ {
+ label: "hostile prototype transport error",
+ run: async () => {
+ throw new Proxy(
+ {},
+ {
+ getPrototypeOf() {
+ throw new Error(
+ "access_token=handoff-prototype-secret at /srv/private/handoff-prototype.ts:1:2"
+ );
+ },
+ get(_target, property) {
+ if (property === "toString") {
+ return () => {
+ throw new Error(
+ "access_token=handoff-coercion-secret at /srv/private/handoff-coercion.ts:1:2"
+ );
+ };
+ }
+ return undefined;
+ },
+ }
+ );
+ },
+ expected: "conversation resume failed: upstream error unavailable",
+ },
+ {
+ label: "conversation id",
+ conversationId: "conversation-opaque-01J9YQ8Z4K7M6N5P3R2T",
+ run: async () => ({
+ status: 404,
+ headers: makeHeaders({ "Content-Type": "text/plain" }),
+ text: "not ready",
+ body: null,
+ }),
+ expected: "conversation resume returned no assistant text for ",
+ },
+ ];
+
+ for (const { label, conversationId, run, expected } of cases) {
+ const warnings: string[] = [];
+ __setTlsFetchOverrideForTesting(run);
+ try {
+ const answer = await resumeChatGptHandoff({
+ conversationId: conversationId ?? `conversation-${label}`,
+ resumeToken: "resume-token",
+ headers: {},
+ timeoutMs: 1_000,
+ log: { warn: (_tag, message) => warnings.push(message) },
+ readContent: async function* () {},
+ });
+
+ assert.equal(answer, null);
+ assert.deepEqual(warnings, [expected]);
+ assert.doesNotMatch(
+ warnings.join("\n"),
+ /\/srv\/private|sk-handoff|handoff(?:-prototype|-coercion)?\.ts|handoff-(?:prototype|coercion)-secret|conversation-opaque|files\.oaiusercontent|OPAQUE-HANDOFF/,
+ `${label} must not expose upstream paths, tokens, or stack frames`
+ );
+ } finally {
+ __setTlsFetchOverrideForTesting(null);
+ }
+ }
+});
diff --git a/tests/unit/chatgpt-web-tools-5240.test.ts b/tests/unit/chatgpt-web-tools-5240.test.ts
index ac6bc9046a..69b4fe77a0 100644
--- a/tests/unit/chatgpt-web-tools-5240.test.ts
+++ b/tests/unit/chatgpt-web-tools-5240.test.ts
@@ -8,8 +8,12 @@
import test from "node:test";
import assert from "node:assert/strict";
+import type { ExecuteInput } from "../../open-sse/executors/base.ts";
+import type { TlsFetchOptions } from "../../open-sse/services/tlsClientBase.ts";
+
const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } =
await import("../../open-sse/executors/chatgpt-web.ts");
+const { buildToolModeResponse } = await import("../../open-sse/executors/chatgptWebTools.ts");
const { __setTlsFetchOverrideForTesting } =
await import("../../open-sse/services/chatgptTlsClient.ts");
@@ -55,7 +59,7 @@ function convWithAssistantText(parts: string) {
function installMockFetch(convEvents: unknown[]) {
const calls = { urls: [] as string[], bodies: [] as unknown[] };
- __setTlsFetchOverrideForTesting(async (url: string, opts: any = {}) => {
+ __setTlsFetchOverrideForTesting(async (url: string, opts: TlsFetchOptions = {}) => {
const u = String(url);
calls.urls.push(u);
calls.bodies.push(opts.body);
@@ -126,9 +130,11 @@ const WEATHER_TOOL = {
const TOOL_CALL_TEXT = '{"name":"get_weather","arguments":{"location":"Tokyo"}} ';
-function baseOpts(extra: Record) {
+function baseOpts(extra: Partial): ExecuteInput {
return {
model: "gpt-5.5",
+ body: {},
+ stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
@@ -136,6 +142,68 @@ function baseOpts(extra: Record) {
};
}
+test("Tool response helper preserves upstream errors before any SSE replay", async () => {
+ const body = JSON.stringify({
+ error: {
+ message: "Rate limited: access_token=[REDACTED] at ",
+ type: "upstream_error",
+ code: "HTTP_429",
+ },
+ });
+ const upstream = new Response(body, {
+ status: 429,
+ headers: { "Content-Type": "application/json", "Retry-After": "17" },
+ });
+
+ const response = await buildToolModeResponse(upstream, [WEATHER_TOOL], true, {
+ cid: "chatcmpl-error",
+ created: 1,
+ model: "gpt-5.5",
+ });
+
+ assert.equal(response, upstream, "the original error Response must pass through unchanged");
+ assert.equal(response.status, 429);
+ assert.equal(response.headers.get("Retry-After"), "17");
+ assert.equal(response.headers.get("Content-Type"), "application/json");
+ assert.equal(await response.text(), body);
+});
+
+test("Tools stream: ChatGPT chunk.error remains a sanitized HTTP error", async () => {
+ __resetChatGptWebCachesForTesting();
+ const m = installMockFetch([
+ {
+ error:
+ "Cannot stream /srv/private/tool-error.json access_token=sk-tool-stream\n" +
+ " at /srv/private/tool-error.ts:1",
+ },
+ ]);
+ try {
+ const executor = new ChatGptWebExecutor();
+ const result = await executor.execute(
+ baseOpts({
+ body: {
+ messages: [{ role: "user", content: "What is the weather in Tokyo?" }],
+ tools: [WEATHER_TOOL],
+ stream: true,
+ },
+ stream: true,
+ })
+ );
+
+ assert.equal(result.response.status, 502);
+ assert.equal(result.response.headers.get("Content-Type"), "application/json");
+ const body = await result.response.json();
+ assert.deepEqual(body.error, {
+ message: "Cannot stream ",
+ type: "upstream_error",
+ code: "CHATGPT_ERROR",
+ });
+ assert.doesNotMatch(JSON.stringify(body), /\/srv\/private|sk-tool-stream|tool-error\.ts/);
+ } finally {
+ m.restore();
+ }
+});
+
test("Tools request-side: contract is serialized into the upstream system message (#5240)", async () => {
__resetChatGptWebCachesForTesting();
const m = installMockFetch(convWithAssistantText("ok"));
@@ -148,13 +216,18 @@ test("Tools request-side: contract is serialized into the upstream system
tools: [WEATHER_TOOL],
},
stream: false,
- }) as any
+ })
);
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
assert.ok(convIdx >= 0, "conversation endpoint was hit");
- const convBody = JSON.parse(m.calls.bodies[convIdx] as string);
- const systemMsg = convBody.messages.find((mm: any) => mm.author.role === "system");
+ const convBody = JSON.parse(m.calls.bodies[convIdx] as string) as {
+ messages: Array<{
+ author: { role: string };
+ content: { parts: string[] };
+ }>;
+ };
+ const systemMsg = convBody.messages.find((message) => message.author.role === "system");
assert.ok(systemMsg, "a system message carrying the tool contract was sent");
const systemText = systemMsg.content.parts.join("");
assert.match(systemText, //, "system prompt instructs the model to emit blocks");
@@ -176,7 +249,7 @@ test("Tools non-stream: {...} text becomes OpenAI tool_calls + fini
tools: [WEATHER_TOOL],
},
stream: false,
- }) as any
+ })
);
assert.equal(result.response.status, 200);
@@ -207,7 +280,7 @@ test("Tools stream: terminal chunk carries delta.tool_calls + finish_reason tool
stream: true,
},
stream: true,
- }) as any
+ })
);
assert.equal(result.response.status, 200);
@@ -242,7 +315,7 @@ test("Tools regression: no-tools request still streams plain content with finish
baseOpts({
body: { messages: [{ role: "user", content: "hi" }], stream: true },
stream: true,
- }) as any
+ })
);
const text = await result.response.text();
diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts
index 267c0eb6dc..00a3114232 100644
--- a/tests/unit/chatgpt-web.test.ts
+++ b/tests/unit/chatgpt-web.test.ts
@@ -34,6 +34,27 @@ function makeHeaders(map = {}) {
return h;
}
+function hostilePrototypeFailure(label: string): unknown {
+ return new Proxy(
+ {},
+ {
+ getPrototypeOf() {
+ throw new Error(`access_token=${label}-prototype-secret at /srv/private/${label}.ts:1:2`);
+ },
+ get(_target, property) {
+ if (property === "toString") {
+ return () => {
+ throw new Error(
+ `access_token=${label}-coercion-secret at /srv/private/${label}-coercion.ts:1:2`
+ );
+ };
+ }
+ return undefined;
+ },
+ }
+ );
+}
+
async function withEnv(overrides, fn) {
const keys = [
"OMNIROUTE_PUBLIC_BASE_URL",
@@ -41,6 +62,9 @@ async function withEnv(overrides, fn) {
"NEXT_PUBLIC_BASE_URL",
"BASE_URL",
"PORT",
+ "OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS",
+ "OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS",
+ "OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS",
];
const previous = new Map(keys.map((key) => [key, process.env[key]]));
@@ -69,7 +93,9 @@ type MockTlsConfig = {
body?: unknown;
setCookie?: string;
error?: unknown;
+ requestError?: unknown;
events?: unknown[];
+ streamError?: unknown;
};
type MockFetchOptions = {
@@ -77,26 +103,31 @@ type MockFetchOptions = {
sentinel?: MockTlsConfig;
conv?: MockTlsConfig;
dpl?: MockTlsConfig;
+ warmup?: MockTlsConfig;
fileDownload?: MockTlsConfig;
attachmentDownload?: MockTlsConfig;
conversationDetail?: MockTlsConfig | MockTlsConfig[];
signedDownload?: MockTlsConfig;
+ webSocketRegister?: MockTlsConfig | MockTlsConfig[];
onSession?: (opts: TlsFetchOptions) => void;
onSentinel?: (opts: TlsFetchOptions) => void;
onConv?: (opts: TlsFetchOptions) => void;
onFileDownload?: (opts: TlsFetchOptions, fileId: string) => void;
onAttachmentDownload?: (opts: TlsFetchOptions, fileId: string) => void;
+ onWebSocketRegister?: (opts: TlsFetchOptions, call: number) => void;
};
type MockFetchCalls = {
session: number;
dpl: number;
+ warmup: number;
sentinel: number;
conv: number;
fileDownload: number;
attachmentDownload: number;
conversationDetail: number;
signedDownload: number;
+ webSocketRegister: number;
urls: string[];
headers: Array | undefined>;
bodies: Array;
@@ -109,25 +140,30 @@ function installMockFetch({
sentinel,
conv,
dpl,
+ warmup,
fileDownload,
attachmentDownload,
conversationDetail,
signedDownload,
+ webSocketRegister,
onSession,
onSentinel,
onConv,
onFileDownload,
onAttachmentDownload,
+ onWebSocketRegister,
}: MockFetchOptions = {}) {
const calls: MockFetchCalls = {
session: 0,
dpl: 0,
+ warmup: 0,
sentinel: 0,
conv: 0,
fileDownload: 0,
attachmentDownload: 0,
conversationDetail: 0,
signedDownload: 0,
+ webSocketRegister: 0,
urls: [],
headers: [],
bodies: [],
@@ -149,6 +185,7 @@ function installMockFetch({
status: 200,
body: '',
};
+ if (cfg.error) throw cfg.error;
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "text/html" }),
@@ -157,6 +194,22 @@ function installMockFetch({
};
}
+ if (
+ warmup &&
+ (u.includes("/backend-api/me") ||
+ u.includes("/backend-api/conversations?") ||
+ u.includes("/backend-api/models?"))
+ ) {
+ calls.warmup++;
+ if (warmup.error) throw warmup.error;
+ return {
+ status: warmup.status,
+ headers: makeHeaders({ "Content-Type": "application/json" }),
+ text: typeof warmup.body === "string" ? warmup.body : JSON.stringify(warmup.body || {}),
+ body: null,
+ };
+ }
+
if (u.includes("/api/auth/session")) {
calls.session++;
if (onSession) onSession(opts);
@@ -168,6 +221,7 @@ function installMockFetch({
user: { id: "user-1" },
},
};
+ if (cfg.error) throw cfg.error;
const headers = makeHeaders({ "Content-Type": "application/json" });
if (cfg.setCookie) headers.set("set-cookie", cfg.setCookie);
return {
@@ -185,6 +239,7 @@ function installMockFetch({
status: 200,
body: { token: "req-token", proofofwork: { required: false } },
};
+ if (cfg.error) throw cfg.error;
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
@@ -205,6 +260,7 @@ function installMockFetch({
status: 200,
body: { download_url: `https://files.oaiusercontent.com/${m1[1]}?sig=mock` },
};
+ if (cfg.error) throw cfg.error;
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
@@ -224,6 +280,7 @@ function installMockFetch({
status: 200,
body: { download_url: `https://files.oaiusercontent.com/${m1[1]}?sig=mock` },
};
+ if (cfg.error) throw cfg.error;
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
@@ -240,6 +297,7 @@ function installMockFetch({
if (/^https:\/\/files\.oaiusercontent\.com\//.test(u)) {
calls.signedDownload++;
const cfg = signedDownload ?? { status: 200 };
+ if (cfg.error) throw cfg.error;
if (cfg.status >= 400) {
return {
status: cfg.status,
@@ -262,6 +320,22 @@ function installMockFetch({
};
}
+ if (u.includes("/backend-api/celsius/ws/user") || u.includes("/register-websocket")) {
+ calls.webSocketRegister++;
+ if (onWebSocketRegister) onWebSocketRegister(opts, calls.webSocketRegister);
+ const cfg = Array.isArray(webSocketRegister)
+ ? (webSocketRegister[Math.min(calls.webSocketRegister - 1, webSocketRegister.length - 1)] ??
+ webSocketRegister[webSocketRegister.length - 1])
+ : (webSocketRegister ?? { status: 404, body: "not mocked" });
+ if (cfg.error) throw cfg.error;
+ return {
+ status: cfg.status,
+ headers: makeHeaders({ "Content-Type": "application/json" }),
+ text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}),
+ body: null,
+ };
+ }
+
// /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff.
{
const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/);
@@ -289,6 +363,7 @@ function installMockFetch({
},
},
});
+ if (cfg.error) throw cfg.error;
const text = typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {});
return {
status: cfg.status,
@@ -332,6 +407,7 @@ function installMockFetch({
},
],
};
+ if (cfg.requestError) throw cfg.requestError;
if (cfg.error) {
return {
status: cfg.status,
@@ -340,6 +416,18 @@ function installMockFetch({
body: null,
};
}
+ if (cfg.streamError) {
+ return {
+ status: cfg.status,
+ headers: makeHeaders({ "Content-Type": "text/event-stream" }),
+ text: null,
+ body: new ReadableStream({
+ start(controller) {
+ controller.error(cfg.streamError);
+ },
+ }),
+ };
+ }
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
@@ -546,6 +634,47 @@ test("Refreshed cookie: surfaced via onCredentialsRefreshed callback", async ()
}
});
+test("Refreshed cookie: persistence warning sanitizes external error details", async () => {
+ reset();
+ const sensitiveError = new Error(
+ "Cannot persist '/home/alice/My Project/private/cookie.pem' access_token=sk-super-secret"
+ );
+ const m = installMockFetch({
+ session: {
+ status: 200,
+ body: {
+ accessToken: "jwt-abc",
+ expires: new Date(Date.now() + 3600_000).toISOString(),
+ user: { id: "user-1" },
+ },
+ setCookie: "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure",
+ },
+ });
+ const warningLogs: string[] = [];
+ try {
+ const executor = new ChatGptWebExecutor();
+ const result = await executor.execute({
+ model: "gpt-5.5",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "old-cookie-warning" },
+ signal: AbortSignal.timeout(10_000),
+ log: { warn: (_tag, message) => warningLogs.push(message) },
+ onCredentialsRefreshed: async () => {
+ throw sensitiveError;
+ },
+ });
+
+ assert.equal(result.response.status, 200, "persistence failure must remain non-fatal");
+ assert.deepEqual(warningLogs, [
+ "Failed to persist refreshed cookie: Cannot persist '' access_token=[REDACTED]",
+ ]);
+ assert.doesNotMatch(warningLogs.join("\n"), /\/home\/alice|My Project|sk-super-secret/);
+ } finally {
+ m.restore();
+ }
+});
+
// ─── Sentinel + PoW ─────────────────────────────────────────────────────────
test("Sentinel: chat-requirements is hit before /backend-api/conversation", async () => {
@@ -742,6 +871,258 @@ test("Streaming: produces valid SSE chunks ending with [DONE]", async () => {
}
});
+test("Streaming: reader errors sanitize the public SSE delta", async () => {
+ reset();
+ const secret = "sk-stream-secret";
+ const m = installMockFetch({
+ conv: {
+ status: 200,
+ streamError: new Error(
+ "read failed /srv/private/key.pem access_token=" + secret + "\n at /srv/stack.ts:1"
+ ),
+ },
+ });
+ try {
+ const executor = new ChatGptWebExecutor();
+ const result = await executor.execute({
+ model: "gpt-5.5",
+ body: { messages: [{ role: "user", content: "hi" }], stream: true },
+ stream: true,
+ credentials: { apiKey: "test" },
+ signal: AbortSignal.timeout(10_000),
+ log: null,
+ });
+
+ assert.equal(result.response.status, 200);
+ const text = await result.response.text();
+ assert.match(text, /\[Stream error: read failed \]/);
+ assert.ok(!text.includes("/srv/private/key.pem"));
+ assert.ok(!text.includes(secret));
+ assert.ok(!text.includes("stack.ts"));
+ assert.match(text, /data: \[DONE\]/);
+ } finally {
+ m.restore();
+ }
+});
+
+test("Stack-only stream and fetch failures use a stable public fallback", async () => {
+ reset();
+ let m = installMockFetch({
+ conv: {
+ status: 200,
+ streamError: new Error("\n at /srv/private/stack-only-stream.ts:1"),
+ },
+ });
+ try {
+ const executor = new ChatGptWebExecutor();
+ const result = await executor.execute({
+ model: "gpt-5.5",
+ body: { messages: [{ role: "user", content: "hi" }], stream: true },
+ stream: true,
+ credentials: { apiKey: "stack-only-stream" },
+ signal: AbortSignal.timeout(10_000),
+ log: null,
+ });
+ const text = await result.response.text();
+ assert.match(text, /\[Stream error: upstream error unavailable\]/);
+ assert.doesNotMatch(text, /\/srv\/private|stack-only-stream\.ts/);
+ } finally {
+ m.restore();
+ }
+
+ reset();
+ m = installMockFetch({
+ conv: {
+ status: 0,
+ requestError: new Error("\n at /srv/private/stack-only-fetch.ts:1"),
+ },
+ });
+ const errors: string[] = [];
+ try {
+ const executor = new ChatGptWebExecutor();
+ const result = await executor.execute({
+ model: "gpt-5.5",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: "stack-only-fetch" },
+ signal: AbortSignal.timeout(10_000),
+ log: { error: (_tag, message) => errors.push(message) },
+ });
+ assert.equal(result.response.status, 502);
+ const body = await result.response.json();
+ assert.equal(body.error.message, "ChatGPT connection failed: upstream error unavailable");
+ assert.deepEqual(errors, ["Fetch failed: upstream error unavailable"]);
+ assert.doesNotMatch(
+ JSON.stringify(body) + errors.join("\n"),
+ /\/srv\/private|stack-only-fetch\.ts/
+ );
+ } finally {
+ m.restore();
+ }
+});
+
+test("Streaming: upstream error chunks sanitize the public SSE delta", async () => {
+ reset();
+ const secret = "sk-upstream-stream-secret";
+ const m = installMockFetch({
+ conv: {
+ status: 200,
+ events: [
+ {
+ error:
+ "upstream failed /srv/private/key.pem access_token=" +
+ secret +
+ "\n at /srv/stack.ts:1",
+ },
+ ],
+ },
+ });
+ try {
+ const executor = new ChatGptWebExecutor();
+ const result = await executor.execute({
+ model: "gpt-5.5",
+ body: { messages: [{ role: "user", content: "hi" }], stream: true },
+ stream: true,
+ credentials: { apiKey: "test" },
+ signal: AbortSignal.timeout(10_000),
+ log: null,
+ });
+
+ assert.equal(result.response.status, 200);
+ const text = await result.response.text();
+ assert.match(text, /\[Error: upstream failed