Compare commits

..

2 Commits

Author SHA1 Message Date
diegosouzapw
31d3702a37 chore(changelog): link wreq transport migration to PR 11753 2026-08-27 02:43:56 -03:00
diegosouzapw
cb623b3cef refactor(stealth): migrate web-cookie TLS transport to wreq-js 2026-08-27 02:39:44 -03:00
78 changed files with 3008 additions and 1608 deletions

View File

@@ -1469,28 +1469,25 @@ CURSOR_USER_AGENT="Cursor/3.4"
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev
# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s)
# ── ChatGPT TLS sidecar (Firefox-fingerprinted client) ──
# Used by: open-sse/services/chatgptTlsClient.ts — wire-level timeout for
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
# layered on top of it when the native library is wedged.
# ── ChatGPT browser transport (Firefox-fingerprinted client) ──
# Used by: open-sse/services/chatgptTlsClient.ts — native wreq-js request
# timeout and the JS-side hard-deadline grace layered on top of it.
# OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS=60000
# OMNIROUTE_CHATGPT_TLS_GRACE_MS=10000
# Max wait for the FIRST streamed byte from the ChatGPT TLS sidecar before the
# request is aborted as a dead stream, in milliseconds. Default 30000 (30s).
# Raise it if upstream cold-starts routinely exceed the window.
# Max wait for the FIRST streamed byte before switching from direct streaming
# to a buffered response, in milliseconds. Default 30000 (30s). The request's
# hard deadline continues to apply while the buffered body is read.
# OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS=30000
# ── Claude TLS sidecar (Chromium-fingerprinted client) ──
# Used by: open-sse/services/claudeTlsClient.ts — wire-level timeout for
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
# layered on top of it when the native library is wedged.
# ── Claude browser transport (Chromium-fingerprinted client) ──
# Used by: open-sse/services/claudeTlsClient.ts — native wreq-js request
# timeout and the JS-side hard-deadline grace layered on top of it.
# OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS=60000
# OMNIROUTE_CLAUDE_TLS_GRACE_MS=10000
# ── Perplexity TLS sidecar (Firefox-fingerprinted client) ──
# Used by: open-sse/services/perplexityTlsClient.ts — wire-level timeout for
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
# layered on top of it when the native library is wedged.
# ── Perplexity browser transport (Firefox-fingerprinted client) ──
# Used by: open-sse/services/perplexityTlsClient.ts — native wreq-js request
# timeout and the JS-side hard-deadline grace layered on top of it.
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
@@ -1502,18 +1499,16 @@ CURSOR_USER_AGENT="Cursor/3.4"
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
# OMNIROUTE_PPLX_SEARCH_HINT=0
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
# top of it when the native library is wedged.
# ── Grok web browser transport (Chrome-fingerprinted client) ──
# Used by: open-sse/services/grokTlsClient.ts — native wreq-js request timeout
# and the JS-side hard-deadline grace layered on top of it.
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
# ── Notion web TLS sidecar (Chrome-fingerprinted client) ──
# Used by: open-sse/services/notionTlsClient.ts — wire-level timeout for the
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
# top of it when the native library is wedged. The notion-web executor raises
# the wire timeout per-request to 180000 for long generations.
# ── Notion web browser transport (Chrome-fingerprinted client) ──
# Used by: open-sse/services/notionTlsClient.ts — native wreq-js request timeout
# and the JS-side hard-deadline grace layered on top of it. The notion-web
# executor raises the native timeout per-request to 180000 for long generations.
# OMNIROUTE_NOTION_TLS_TIMEOUT_MS=30000
# OMNIROUTE_NOTION_TLS_GRACE_MS=10000
@@ -2525,11 +2520,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/jobs/backupScheduleJob.ts
# OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS=30000
# ── TLS sidecar override ──
# Used by: open-sse/services/chatgptTlsClient.ts tests. Production deployments
# should leave this unset; the sidecar is auto-managed.
# OMNIROUTE_TLS_PROXY_URL=
# ── Skills sandbox (experimental) ──
# Used by: src/lib/skills/builtins.ts. All values support comma lists where
# noted in the source.

View File

@@ -222,7 +222,7 @@ jobs:
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
# fsevents) carry linux forks. Replace them with the forks this
# leg's own `npm ci` resolved, then assert every bundled native
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
# (better-sqlite3 prebuilds, wreq-js, onnxruntime)
# can service this leg's platform/arch before packaging starts.
run: |
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz

View File

@@ -103,25 +103,11 @@ RUN test -f package-lock.json \
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
# instead of `npx --yes`, which would install an arbitrary registry version
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
#
# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
# better-sqlite3 above, that script never throws on failure — it only
# `console.warn`s and exits 0 — so a rate-limited or offline build would
# otherwise succeed silently with an empty bin/ and only fail at first request
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
# broken image.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& (cd node_modules/better-sqlite3 \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
&& node -e "require('better-sqlite3')(':memory:').close()" \
&& node node_modules/tls-client-node/scripts/postinstall.js \
&& (test -n "$(find node_modules/tls-client-node/bin -mindepth 1 -print -quit 2>/dev/null)" \
|| (echo "tls-client-node native binary missing after postinstall — GitHub API fetch likely rate-limited or failed (#7802)" >&2 && exit 1))
&& node -e "require('better-sqlite3')(':memory:').close()"
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a

View File

@@ -29,11 +29,6 @@ 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
# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
ENV OMNIROUTE_USE_TURBOPACK=0

View File

@@ -557,7 +557,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md)
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **352-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)

View File

@@ -24,3 +24,28 @@ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPO
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## wreq-js 3.0.0
OmniRoute distributes `wreq-js` and its seven platform-specific native addons from
[`wreq-js@3.0.0`](https://www.npmjs.com/package/wreq-js/v/3.0.0).
MIT License
Copyright (c) 2025 will-work-for-meal
Copyright (c) 2025 Oleksandr Herasymov
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1 +0,0 @@
- **chore(audio):** retire the built-in EdgeTTS provider and its 11-voice catalog while provenance/licensing review remains on HOLD; the generic `/v1/audio/speech` endpoint and the other speech providers remain available ([#11711](https://github.com/diegosouzapw/OmniRoute/pull/11711))

View File

@@ -0,0 +1 @@
- **chore(stealth):** replace the `tls-client-node` sidecar/temp-file transport used by the six web-cookie providers with the exactly pinned `wreq-js` 3.0.0 native transport, preserving streaming, proxy isolation, deadlines, EOF policies, binary responses, and cancellation while removing the obsolete downloader and native repair path ([#11753](https://github.com/diegosouzapw/OmniRoute/pull/11753)).

View File

@@ -74,12 +74,6 @@
"justification": "CC-BY-4.0 applies to the caniuse browser-support data (a dataset, not code). The Creative Commons Attribution license requires attribution when distributing — OmniRoute does not distribute caniuse-lite data directly to end users; it is consumed by browserslist/PostCSS at build time to generate CSS compatibility info. This is a widely accepted pattern in the Node.js ecosystem (caniuse-lite is in millions of projects). Attribution is satisfied by keeping the package in node_modules with its original license file.",
"risk": "low",
"reviewAt": "v4.0.0"
},
"tls-client-node": {
"license": "Custom: LICENSE (Apache-2.0 + Commons Clause)",
"justification": "TODO: revisar — tls-client-node uses Apache-2.0 with a 'Commons Clause' addendum that restricts 'Selling' the software (i.e., offering it as a hosted/commercial service whose value derives substantially from tls-client-node). OmniRoute is an open-source proxy; however if deployed as a paid SaaS/hosting service, this restriction could apply. The package is used by grokTlsClient.ts for Grok TLS fingerprinting. RISK: medium — legal review recommended before commercial deployment. Alternatives: consider replacing with a native TLS fingerprinting approach or a truly permissive library.",
"risk": "medium",
"reviewAt": "v3.9.0"
}
}
}

View File

@@ -129,7 +129,6 @@
"sqlite-vec",
"tailwind-merge",
"tailwindcss",
"tls-client-node",
"turndown",
"turndown-plugin-gfm",
"tsup",

View File

@@ -74,16 +74,14 @@
"reason": "v3.8.50 #9126 (commit 8fac6bcd48): pluginWorker.ts, sandbox.ts e signing.ts foram removidos por completo (\"zero importers confirmed\") — o subsistema de sandbox de plugins com worker-thread nunca foi ligado a nenhum consumidor. O teste era source-scan sobre pluginWorker.ts (ver docstring do arquivo deletado); sem o arquivo-fonte não há mais o que testar. OMNIROUTE_PLUGINS_ALLOW_EXEC também foi removido de .env.example e da doc na mesma release. Sem substituto porque a feature foi extinta, não migrada."
},
"tests/unit/plugins-sandbox.test.ts": {
"sourceRemoved": ["src/lib/plugins/sandbox.ts"],
"sourceRemoved": [
"src/lib/plugins/sandbox.ts"
],
"reason": "v3.8.50 #9126 (commit 8fac6bcd48): sandbox.ts foi removido por completo junto com pluginWorker.ts e signing.ts (\"zero importers confirmed\", subsistema de sandbox de plugins nunca ligado a nenhum consumidor). O teste cobria SandboxLevel/getSandboxLabel exportados por sandbox.ts; sem o arquivo-fonte não há mais símbolo a testar. Mesma causa-raiz de tests/unit/plugin-sandbox-permissions.test.ts nesta entrada."
},
"tests/unit/gemini-3-5-flash-thinking.test.ts": {
"replacement": "tests/unit/gemini-models-parser.test.ts",
"reason": "v3.8.50 #10286 superseded by 2764812ee4 \"fix(providers): eliminate Gemini 3.5 Flash\": the model was RETIRED on this line, so its thinking-capability regression test (added by #10450 for the pre-retirement behavior) was deleted together with the model's MODEL_SPECS entry, its changelog fragment and its rate-limit config — feature removal by the maintainer, not masking. The replacement pins the NEW contract: \"parseGeminiModelsList excludes retired Gemini 3.5 Flash but keeps Flash Lite\". The gate only sees the deletion because main still carries the pre-retirement file; it prunes itself once v3.8.50 merges to main. Human review: presented to the operator during the v3.8.50 release pre-flight."
},
"tests/unit/edgetts-provider.test.ts": {
"replacement": "tests/unit/edgetts-retirement.test.ts",
"reason": "v3.8.50 #11711: the EdgeTTS executor and its positive protocol tests were retired together after a provenance/licensing HOLD. The replacement pins the new public contract across the speech registry, handler and UI-derived catalog while preserving gTTS, AWS Polly and ElevenLabs. Feature retirement, not test masking; prune after v3.8.50 merges to main."
}
},
"tests/unit/catalog-updates-v3x.test.ts": "v3.8.45 #6248: fix(providers) remove deprecated MiMo V2 entries — os 5 asserts removidos pinavam specs de modelos mimo-v2-* que deixaram de existir no catálogo (54→49). Asserts seguem a remoção dos modelos, não enfraquecimento. Verificado legítimo. Prune após v3.8.45 mergear para main.",

View File

@@ -0,0 +1,44 @@
{
"package": "wreq-js",
"version": "3.0.0",
"source": "https://registry.npmjs.org/wreq-js/-/wreq-js-3.0.0.tgz",
"npmIntegrity": "sha512-RZCoRSevVPpH4A4B4MxbFGo/pVPFveWd2gbe4ENKpPWlKXEYklZSDESOjBMmrIsmnkHh+nhM4PNJvG+NL7wBPA==",
"license": "MIT",
"nativeAddons": [
{
"path": "rust/wreq-js.darwin-arm64.node",
"size": 7690880,
"sha256": "c82eec39df691adb94f2cd09a8ff51335de8587cf132cd8b3ec797469a4b5002"
},
{
"path": "rust/wreq-js.darwin-x64.node",
"size": 8192028,
"sha256": "073b8a8a4c26aedbce7c14eef3e5567918e62e8dbf4d28296b23f9d2beec2981"
},
{
"path": "rust/wreq-js.linux-arm64-gnu.node",
"size": 8520824,
"sha256": "861d96a78caf7ce02c9ae8d37f1c59f5b0480e3142775c32917fcfe9b88524b0"
},
{
"path": "rust/wreq-js.linux-arm64-musl.node",
"size": 8735472,
"sha256": "2409a3578c8c440df419b4d5abe3ac149bec48881611a6dc1571b95e6246552d"
},
{
"path": "rust/wreq-js.linux-x64-gnu.node",
"size": 9048992,
"sha256": "55b40f4602c52111dfcdcc93db83f9d0de55d0ef7540348757709d58d05a9b64"
},
{
"path": "rust/wreq-js.linux-x64-musl.node",
"size": 8974880,
"sha256": "bd52d15b1bb4704b11561a8aa95648a6c91150082b5af0e39dd1608b7db2d317"
},
{
"path": "rust/wreq-js.win32-x64-msvc.node",
"size": 7967232,
"sha256": "7451a8701b82c946b03ba2be2f15257260a250b9e0ed9910611b22564fbec7a9"
}
]
}

View File

@@ -27,7 +27,7 @@ Core capabilities:
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
- Image generation via `/v1/images/generations` (10+ providers, 20+ models)
- Audio transcription via `/v1/audio/transcriptions` (7 providers)
- Text-to-speech via `/v1/audio/speech` (24 built-in providers)
- Text-to-speech via `/v1/audio/speech` (10 providers)
- Video generation via `/v1/videos/generations` (ComfyUI + SD WebUI)
- Music generation via `/v1/music/generations` (ComfyUI)
- Web search via `/v1/search` (5 providers)
@@ -980,7 +980,7 @@ All other providers (including custom compatible nodes) use the `DefaultExecutor
| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Devin Desktop | openai | Imported API key | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Per request |
| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ |
| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API |
| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API |
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limits |
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API |
| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ |

View File

@@ -62,7 +62,7 @@ Set these in the OmniRoute process environment (the daemon, e.g. via the LaunchA
**How to verify it worked**: run your agent/cron twice in quick succession and confirm both succeed. Before the fix, the second run typically throws `429`/`401`. After the fix, failures (if any) are retried transparently and the call completes. You can also `curl /monitoring/health` and watch the `rateLimitedUntil` field on the provider connections and the `circuitBreakers.providerBreakers[].state` for the affected providers — the state is one of `CLOSED`, `DEGRADED`, `OPEN`, or `HALF_OPEN` (see `src/shared/utils/circuitBreaker.ts`), and a provider that keeps failing will flip `CLOSED → DEGRADED → OPEN` before the reset window lets a probe through (`HALF_OPEN`).
**If you still see 429**: the active account for that provider has genuinely exhausted its *quota* (not just rate). Add a second account for the same provider in the OmniRoute dashboard → Providers → Accounts, or mix in another free provider (e.g. `routeway`, `auggie`). Rotation only helps with transient rate/400/401; a hard quota exhaustion requires a second credential or a different provider.
**If you still see 429**: the active account for that provider has genuinely exhausted its _quota_ (not just rate). Add a second account for the same provider in the OmniRoute dashboard → Providers → Accounts, or mix in another free provider (e.g. `routeway`, `auggie`). Rotation only helps with transient rate/400/401; a hard quota exhaustion requires a second credential or a different provider.
**If you see 403 on vision models (`auto/vision`, `bazaarlink/*`)**: the connected account lacks a paid plan that includes vision, or the API key has insufficient permissions. Verify in the provider dashboard that the key scope includes vision/multimodal, or connect a paid tier account and keep it as the vision target.
@@ -75,7 +75,7 @@ When you run `npm install -g omniroute`, you may see a wall of warnings like `np
The warnings come from stale peer-dependency ranges in third-party packages OmniRoute doesn't control:
1. **`marked-terminal` wants `marked >=1 <16`, found `marked@18`** — works fine in practice; the upstream peer range is just stale.
2. **`deprecated prebuild-install@7.1.3`** — the native-binary fetch helper. Only relevant later if a web-cookie provider reports a missing `tls-client-node` native binary (a separate issue, not caused by this warning).
2. **`deprecated prebuild-install@7.1.3`** — a transitive native-binary helper used by another dependency. The pinned `wreq-js@3.0.0` package bundles its seven supported platform addons directly; this warning does not diagnose the web-cookie transport.
**No action needed** — the warnings cannot be fully silenced without forking upstream packages.
@@ -148,9 +148,10 @@ desktop app, for example:
- `resources/app/.build/next/node_modules/playwright-<hash>/lib/…/agentParser.js` and
`workerProcessEntry.js` — [Playwright](https://playwright.dev), the browser-automation
library used for in-app provider login and browser-backed chat.
- `resources/app/.build/next/node_modules/tls-client-node-<hash>/bin/tls-client-windows-64-<ver>.dll`
— the native binary from `tls-client-node`, used for Cloudflare-tolerant HTTP on some web
providers.
- `resources/app/.build/next/node_modules/wreq-js-<hash>/rust/wreq-js.win32-x64-msvc.node`
— the declared MIT-licensed native addon from pinned `wreq-js@3.0.0`, used for
browser-fingerprinted HTTP on some web providers. Its expected SHA-256 is recorded in
`config/release/wreq-js-native-manifest.json`.
**Why it fires:** the Windows installer is **not yet code-signed**, so an unsigned NSIS
installer has zero reputation and behavioral heuristics run at maximum aggression. Combined

View File

@@ -1027,6 +1027,7 @@ If only OpenRouter is configured, use `openrouter/deepgram/nova-3`.
- `kie/`
- `aws-polly/`
- `xiaomi-mimo/`
- `edgetts/` (Microsoft Edge "Read Aloud" — free, no API key; unofficial/reverse-engineered endpoint)
- `coqui/`, `tortoise/`
- `qwen/`

View File

@@ -486,7 +486,7 @@ Radar è opt-in e usa soltanto richieste GET. Il client OmniRoute non carica pro
- **🧠 Memoria sotto il tuo controllo** — disattivata per impostazione predefinita, quantizzazione vettoriale int8 opt-in + decadimento tipizzato, `x-omniroute-no-memory` per-request. → [Memoria](../../frameworks/MEMORY.md)
- **🛡️ Sicurezza** — guard contro la prompt injection su ogni route LLM (suite red-team), guardrail opzionale per il masking delle credenziali (oscura API key/secret trapelati in entrambe le direzioni), web search DuckDuckGo gratuita come ultima risorsa e gate di login OIDC opzionale per la dashboard (il login con password resta sempre disponibile). → [Guardrail](../../security/GUARDRAILS.md)
- **🖼️ Nuovi endpoint** — `/v1/ocr` (Mistral OCR) e `/v1/audio/translations` (stile Whisper) completano la superficie media. → [Riferimento API](../../reference/API_REFERENCE.md)
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind e provider vocali come ElevenLabs. → [Riferimento API](../../reference/API_REFERENCE.md)
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [Riferimento API](../../reference/API_REFERENCE.md)
- **🌍 Deployment e operazioni** — `basePath` del reverse proxy, rilevamento automatico della lingua del browser, tracking dei dispositivi per chiave, trust MITM senza root, localizzazione zh-TW. → [Ambiente](../../reference/ENVIRONMENT.md)
- **🤝 Più provider e agenti** — Cursor Cloud Agent, Grok Build (xAI) con login browser + OAuth, scheda Ollama di prima classe, Claude Opus 5 e Sonnet 5, partnership ufficiale Kimi (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… e un catalogo aggiornato di **350 provider**. → [Provider](../../reference/PROVIDER_REFERENCE.md)
- **📡 Trasparenza del routing** — ogni risposta include un header `X-OmniRoute-Decision` con strategia/provider/latenza che l'ha servita; una nuova strategia combo `cache-optimized` + il fattore `cacheAffinity` di Auto-Combo riportano le richieste ripetute alla connessione che possiede il prefisso in cache; un endpoint read-only `/v1/auto-combo/{channel}/candidates` espone il pool di candidati live di un canale `auto/*`. → [Auto-Combo](../../routing/AUTO-COMBO.md)

View File

@@ -973,6 +973,7 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \
- `kie/`
- `aws-polly/`
- `xiaomi-mimo/`
- `edgetts/` (Microsoft Edge „Read Aloud” — darmowe, bez klucza API; nieoficjalny/reverse-engineered endpoint)
- `coqui/`, `tortoise/`
- `qwen/`

View File

@@ -548,7 +548,7 @@ Radar isteğe bağlıdır (opt-in) ve yalnızca GET istekleri yapar. OmniRoute i
- **🧠 Kontrol ettiğiniz bellek** — Varsayılan olarak kapalı, isteğe bağlı int8 vektör niceleme + tipli sönümleme, istek başına `x-omniroute-no-memory`. → [Bellek](docs/frameworks/MEMORY.md)
- **🛡️ Güvenlik** — Her LLM rotasında istem enjeksiyonu koruması (red-team paketi), isteğe bağlı kimlik bilgisi maskeleme koruması (her iki yönde de sızan API anahtarlarını/gizli bilgileri sansürler), ücretsiz DuckDuckGo son çare web araması ve pano için isteğe bağlı OIDC giriş kapısı (şifreyle giriş her zaman kullanılabilir kalır). → [Güvenlik Önlemleri (Guardrails)](docs/security/GUARDRAILS.md)
- **🖼️ Yeni uç noktalar** — `/v1/ocr` (Mistral OCR) ve `/v1/audio/translations` (Whisper tarzı) medya yüzeyini tamamlar. → [API Referansı](docs/reference/API_REFERENCE.md)
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind ve ElevenLabs gibi konuşma sağlayıcıları. → [API Referansı](docs/reference/API_REFERENCE.md)
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Referansı](docs/reference/API_REFERENCE.md)
- **🌍 Dağıtım ve operasyonlar** — Ters proxy `basePath`, tarayıcı dili otomatik algılama, anahtar başına cihaz takibi, root gerektirmeyen MITM güveni, zh-TW yerelleştirmesi. → [Ortam Değişkenleri](docs/reference/ENVIRONMENT.md)
- **🤝 Daha fazla sağlayıcı ve ajan** — Cursor Cloud Agent, tarayıcı + OAuth girişiyle Grok Build (xAI), Ollama birinci sınıf kartı, Claude Opus 5 ve Sonnet 5, Kimi resmi ortaklığı (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… ve yenilenen **350 sağlayıcılı katalog**. → [Sağlayıcılar](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Yönlendirme şeffaflığı** — Her yanıt, isteğe hizmet veren stratejiyi/sağlayıcıyı/gecikmeyi belirten bir `X-OmniRoute-Decision` başlığı taşır, yeni bir `cache-optimized` kombo stratejisi + Auto-Combo `cacheAffinity` faktörü yinelenen istekleri önbelleğe alınmış öneki tutan bağlantıya geri yönlendirir ve salt okunur bir `/v1/auto-combo/{channel}/candidates` uç noktası bir `auto/*` kanalının canlı aday havuzunu gösterir. → [Auto-Combo](docs/routing/AUTO-COMBO.md)

View File

@@ -973,6 +973,7 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \
- `kie/`
- `aws-polly/`
- `xiaomi-mimo/`
- `edgetts/`Microsoft Edge「朗讀功能」— 免費,無需 API 金鑰;非官方/逆向工程端點)
- `coqui/`, `tortoise/`
- `qwen/`

View File

@@ -766,18 +766,18 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS` | `8000` | Timeout (ms) for the `validationRead` and `modelsProbe` presets in `src/shared/network/safeOutboundFetch.ts`. Raise for slow endpoints (Cerebras, Cloudflare AI, Groq) to prevent flapping between active/error in the dashboard. Falls back to 8000ms for invalid (<1000) or non-numeric values. |
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). |
| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte from the ChatGPT TLS sidecar (`chatgptTlsClient.ts`) before aborting a dead stream. Raise if upstream cold-starts exceed the window. |
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). |
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). |
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`chatgptTlsClient.ts`). |
| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side hard-deadline grace added on top of the native timeout. |
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte before ChatGPT switches to a buffered response; the hard request deadline remains active. |
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`claudeTlsClient.ts`). |
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side hard-deadline grace added on top of the native timeout. |
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Native wreq-js request timeout (`perplexityTlsClient.ts`). |
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side hard-deadline grace added on top of the native timeout. |
| `OMNIROUTE_PPLX_SEARCH_HINT` | `0` (off) | Appends "You have built-in web search. Answer questions directly using search results." to the caller's system message (`perplexity-web/protocol.ts`). Off by default — Perplexity searches anyway, and the sentence leaks into replies as meta-commentary for coding clients. Set `1`/`true`/`yes`/`on` to restore. |
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). |
| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`notionTlsClient.ts`); the `notion-web` executor raises it per-request to `180000` for long generations. |
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`grokTlsClient.ts`). |
| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side hard-deadline grace added on top of the native timeout. |
| `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Native wreq-js request timeout (`notionTlsClient.ts`); `notion-web` raises it per request to `180000` for long generations. |
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side hard-deadline grace added on top of the native timeout. |
| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. |
| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. |
| `KIMI_WEB_BASE_URL` | `https://www.kimi.ai` | Base URL for the Kimi Web (international kimi.ai Connect-RPC) executor (`kimi-web.ts`); override only for mirror/proxy endpoints. |
@@ -1310,7 +1310,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum SQLite backup files retained on disk. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
| `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. |
| `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`). |

View File

@@ -1,13 +1,13 @@
---
title: "Stealth Guide"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.50
lastUpdated: 2026-08-26
---
# Stealth Guide
> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{chatgptTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/`
> **Last updated:** 2026-06-28 — v3.8.40
> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{tlsClientBase,chatgptTlsClient,claudeTlsClient,perplexityTlsClient,grokTlsClient,notionTlsClient,lmarenaTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/`
> **Last updated:** 2026-08-26 — v3.8.50
> **Audience:** Engineers maintaining provider-specific stealth integrations.
OmniRoute integrates with providers whose edges actively fingerprint non-official clients (TLS JA3/JA4, header ordering, JSON body shape, integrity tokens). This page documents the stealth surfaces OmniRoute exposes and where they are implemented.
@@ -29,17 +29,38 @@ Lazy-loaded `wreq-js` session that impersonates **Chrome 124 on macOS**. Used as
- Timeout: `TLS_CLIENT_TIMEOUT_MS` (inherits from `FETCH_TIMEOUT_MS`, default 600000)
- `wreq-js` Response is fetch-compatible (`headers`, `text()`, `json()`, `clone()`, `body`).
### `open-sse/services/chatgptTlsClient.ts` — tls-client-node (Firefox 148)
### Web-cookie provider transport — wreq-js 3.0.0
Dedicated TLS impersonator for `chatgpt.com`. ChatGPT's Cloudflare config pins `cf_clearance` to JA3/JA4 + HTTP/2 SETTINGS frame ordering — undici's handshake gets `cf-mitigated: challenge` even with valid cookies.
`open-sse/services/tlsClientBase.ts` is the shared transport for ChatGPT, Claude, Perplexity,
Grok, Notion, and LMArena web sessions. Each thin provider wrapper selects a browser/OS profile;
the base loads `wreq-js` lazily, reuses only transport-level connections keyed by
profile + OS + resolved proxy, and gives every request an ephemeral cookie scope. It never shares a
wreq session or cookie jar between accounts or requests.
- Profile: `firefox_148` (must match the Firefox 148 `User-Agent` sent)
- Mode: `runtimeMode: "native"` (koffi-loaded shared library; avoids managed sidecar HTTP)
- `withRandomTLSExtensionOrder: true`
- `tlsFetchChatGpt(url, options)` supports streaming (writes body to temp file, tailed as `ReadableStream`)
- Hang detection: `raceWithTimeout` + `TlsClientHangError` triggers `resetClientCache()` so the next call respawns the binding
- Proxy resolution (priority): per-call `proxyUrl``OMNIROUTE_TLS_PROXY_URL``HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY` (the native binding does **not** read these envs itself; it must be threaded through)
- Errors: `TlsClientUnavailableError` (binary missing), `TlsClientHangError` (binding deadlocked)
| Provider | Profile | Emulated OS | Stream EOF policy |
| ---------- | ------------- | ----------- | -------------------------------- |
| ChatGPT | `firefox_148` | macOS | include `[DONE]` |
| Claude | `chrome_146` | Linux | include `[DONE]` |
| Perplexity | `firefox_148` | macOS | include `event: end_of_stream` |
| Grok | `chrome_146` | Linux | exclude `[DONE]` |
| Notion | `chrome_146` | Windows | include `[DONE]` |
| LMArena | `chrome_146` | Windows | no sentinel; close on native EOF |
- Streaming uses the native response `ReadableStream` directly; no temp file or sidecar is created.
- Up to 256 initial bytes are inspected before exposing a stream. SSE providers buffer non-SSE
errors; Grok/LMArena map Cloudflare challenges to `403` and HTML interstitials to `502`.
- The native request timeout remains wrapped by an absolute JS hard deadline. A hang invalidates
and closes only the affected profile/OS/proxy transport before the next request recreates it.
- Proxy resolution priority is per-call `proxyUrl` → request-scoped account/dashboard context →
`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY` (including lowercase variants). Resolution errors fail
closed instead of leaking a direct connection. LMArena deliberately resolves against `arena.ai`.
- `byteResponse` returns a content-typed `data:` URL without UTF-8 corruption.
- Errors are `TlsClientUnavailableError` (package/addon unavailable) and `TlsClientHangError`
(deadline exceeded).
The profiles are supported by the pinned package, but real WAF acceptance can change independently
of local contract tests. Validate fingerprint changes against an explicitly authorized live account
before claiming parity with an upstream browser.
---

View File

@@ -288,9 +288,6 @@ const nextConfig = {
"keytar",
"wreq-js",
"zod",
"tls-client-node",
"koffi",
"tough-cookie",
"@ngrok/ngrok",
"@huggingface/transformers",
// copilot-m365-web.ts imports 'ws' as a client-side WebSocket. When bundled,

View File

@@ -544,6 +544,31 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
],
},
edgetts: {
id: "edgetts",
// Microsoft Edge "Read Aloud" — reverse-engineered, no API key required.
// WebSocket transport (unlike every other entry here) — handled by
// open-sse/executors/edgeTts.ts, dispatched via the "edgetts" format.
baseUrl: "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1",
authType: "none",
authHeader: "none",
format: "edgetts",
supportedFormats: ["mp3"],
models: [
{ id: "en-US-AriaNeural", name: "Aria (EN-US, Female)" },
{ id: "en-US-GuyNeural", name: "Guy (EN-US, Male)" },
{ id: "en-GB-SoniaNeural", name: "Sonia (EN-GB, Female)" },
{ id: "en-GB-RyanNeural", name: "Ryan (EN-GB, Male)" },
{ id: "es-ES-ElviraNeural", name: "Elvira (ES-ES, Female)" },
{ id: "pt-BR-FranciscaNeural", name: "Francisca (PT-BR, Female)" },
{ id: "pt-BR-AntonioNeural", name: "Antonio (PT-BR, Male)" },
{ id: "fr-FR-DeniseNeural", name: "Denise (FR-FR, Female)" },
{ id: "de-DE-KatjaNeural", name: "Katja (DE-DE, Female)" },
{ id: "ja-JP-NanamiNeural", name: "Nanami (JA-JP, Female)" },
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (ZH-CN, Female)" },
],
},
gtts: {
id: "gtts",
// Google Translate TTS — reverse-engineered, no API key required.

View File

@@ -2,8 +2,9 @@
* AWS Polly TTS handler.
*
* Extracted out of `open-sse/handlers/audioSpeech.ts` (frozen at its
* file-size ratchet baseline — config/quality/file-size-baseline.json).
* Pure provider adapter, no behavior change vs. the original inline implementation.
* file-size ratchet baseline — config/quality/file-size-baseline.json) to
* make room for the new EdgeTTS WebSocket branch (#6668). Pure provider
* adapter, no behavior change vs. the original inline implementation.
*
* POST /v1/speech signed with AWS SigV4. The configured apiKey stores AWS
* Secret Access Key; providerSpecificData.accessKeyId stores AWS Access Key

View File

@@ -2280,10 +2280,8 @@ async function imageUrlToCachedImageUrl(
if (response.text == null || response.text.length === 0) return null;
// tls-client-node already returns binary bodies as a "data:<mime>;base64,..."
// string (see node_modules/tls-client-node/dist/response.js — its bytes()
// method splits on the comma to extract base64). Decode back into bytes
// so we can hand them to the cache.
// The shared browser transport returns binary bodies as a
// "data:<mime>;base64,..." string. Decode it back into bytes for the cache.
let bytes: Buffer;
let mime: string;
if (/^data:[^;]{1,256};base64,/.test(response.text)) {

View File

@@ -0,0 +1,352 @@
/**
* EdgeTTS — Microsoft Edge "Read Aloud" text-to-speech (#6668).
*
* Reverse-engineered, unofficial, undocumented endpoint (not a published
* Microsoft public API) — the same class of integration this codebase
* already accepts for other "-web" style providers (chatgpt-web.ts,
* copilot-web.ts). No user account/API key is required; Microsoft gates
* abuse with a `Sec-MS-GEC` header computed from a public "trusted client
* token" (see `open-sse/utils/publicCreds.ts::edgetts_token` — Hard Rule
* #11, this is a constant hardcoded in every Edge browser build and every
* open-source edge-tts reimplementation, not a per-user secret).
*
* Protocol (verified against rany2/edge-tts + msedge-tts + edge-tts-universal):
* 1. WS connect to
* wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1
* with `TrustedClientToken`, `Sec-MS-GEC`, `Sec-MS-GEC-Version` query params.
* 2. Send a `speech.config` text frame (output format, metadata options).
* 3. Send an `ssml` text frame carrying the SSML payload to synthesize.
* 4. Receive interleaved text frames (turn.start / audio.metadata / turn.end)
* and binary frames — each binary frame is a 2-byte big-endian header
* length, followed by ASCII headers, followed by raw audio bytes.
* 5. `turn.end` (or WS close) marks the end of the stream; concatenated
* audio chunks are the final MP3.
*
* All parsing above (Sec-MS-GEC HMAC input, message framing, binary chunk
* demux) is implemented as pure functions so it can be unit-tested without a
* live upstream connection — only `synthesizeEdgeTts()` itself touches the
* network, and it accepts an injectable WebSocket constructor for tests.
*/
import { createHash, randomBytes } from "node:crypto";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { errorResponse } from "../utils/error.ts";
import { SlidingWindowLimiter } from "../services/slidingWindowLimiter.ts";
const EDGE_TTS_WS_URL =
"wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1";
const EDGE_TTS_GEC_VERSION = "1-138.0.0.0";
const WIN_EPOCH_OFFSET_SECONDS = 11644473600;
const SEC_MS_GEC_ROUND_SECONDS = 300; // 5 minutes
const DEFAULT_VOICE = "en-US-AriaNeural";
const DEFAULT_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
const CONNECT_TIMEOUT_MS = 10_000;
const SYNTH_TIMEOUT_MS = 30_000;
// Per-client-IP throttle — EdgeTTS has no per-user key, so every OmniRoute
// deployment shares the same trusted-token identity upstream. A single
// abusive caller could get the shared token rate-limited/blocked for
// everyone, so we cap requests per source IP before we ever open a socket.
const EDGE_TTS_RATE_WINDOW = { requests: 20, windowMs: 60_000 };
const edgeTtsLimiter = new SlidingWindowLimiter();
export interface EdgeTtsSynthInput {
text: string;
voice?: string;
rate?: string;
pitch?: string;
volume?: string;
}
export interface EdgeTtsSynthResult {
audio: Buffer<ArrayBuffer>;
contentType: string;
}
/**
* A minimal shape of the subset of the `ws`/DOM WebSocket API this module
* needs — lets tests inject a fake implementation without touching the real
* network or the `ws` package.
*/
export interface MinimalWebSocket {
on(event: "open" | "message" | "close" | "error", listener: (...args: unknown[]) => void): void;
send(data: string): void;
close(): void;
}
export type WebSocketCtor = new (url: string, opts?: unknown) => MinimalWebSocket;
// ─── Pure helpers (unit-testable, no I/O) ──────────────────────────────────
/**
* Compute the `Sec-MS-GEC` anti-abuse token Microsoft's Read Aloud endpoint
* requires. `nowMs` is injectable so the function is deterministic in tests.
* Algorithm ported from rany2/edge-tts `drm.py::generate_sec_ms_gec()`.
*/
export function computeSecMsGec(nowMs: number = Date.now()): string {
let ticks = nowMs / 1000 + WIN_EPOCH_OFFSET_SECONDS;
ticks -= ticks % SEC_MS_GEC_ROUND_SECONDS;
ticks *= 1e7; // seconds -> 100-nanosecond Windows file-time ticks
const strToHash = `${Math.floor(ticks)}${resolvePublicCred("edgetts_token")}`;
return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase();
}
/** Random 32-hex-char connection id (no dashes), as the protocol expects. */
export function buildConnectionId(): string {
return randomBytes(16).toString("hex");
}
function toIsoTimestamp(): string {
// Edge's protocol wants a JS-Date-toString-like timestamp; ISO is accepted
// by every reference implementation and is trivially deterministic/testable.
return new Date().toUTCString();
}
/** Build the `speech.config` WS text frame sent right after connecting. */
export function buildSpeechConfigMessage(timestamp: string = toIsoTimestamp()): string {
const config = {
context: {
synthesis: {
audio: {
metadataoptions: {
sentenceBoundaryEnabled: "false",
wordBoundaryEnabled: "false",
},
outputFormat: DEFAULT_OUTPUT_FORMAT,
},
},
},
};
return (
`X-Timestamp:${timestamp}\r\n` +
`Content-Type:application/json; charset=utf-8\r\n` +
`Path:speech.config\r\n\r\n` +
`${JSON.stringify(config)}`
);
}
/** Escape user text for safe embedding inside an SSML `<voice>` element. */
export function escapeSsmlText(text: string): string {
return String(text ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** Normalize a caller-supplied voice name, falling back to the default voice. */
export function normalizeEdgeVoice(voice: unknown): string {
const value = typeof voice === "string" ? voice.trim() : "";
// Edge voice names are e.g. "en-US-AriaNeural" — locale-Name-Neural.
return /^[A-Za-z]{2,3}-[A-Za-z]{2,3}-[A-Za-z0-9]+Neural$/.test(value) ? value : DEFAULT_VOICE;
}
function clampProsodyValue(value: unknown, fallback: string): string {
const str = typeof value === "string" ? value.trim() : "";
// Accept "+10%", "-20%", "default", or a bare number — reject anything else
// to keep this untrusted-input path from injecting SSML markup.
return /^(default|[+-]?\d{1,3}%|[+-]?\d{1,3}(\.\d+)?)$/.test(str) ? str : fallback;
}
/** Build the full SSML payload for one synthesis request. */
export function buildSsml(input: EdgeTtsSynthInput): string {
const voice = normalizeEdgeVoice(input.voice);
const rate = clampProsodyValue(input.rate, "default");
const pitch = clampProsodyValue(input.pitch, "default");
const volume = clampProsodyValue(input.volume, "default");
const text = escapeSsmlText(input.text);
return (
`<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>` +
`<voice name='${voice}'>` +
`<prosody rate='${rate}' pitch='${pitch}' volume='${volume}'>${text}</prosody>` +
`</voice></speak>`
);
}
/** Build the `ssml` WS text frame carrying the synthesis payload. */
export function buildSsmlMessage(
requestId: string,
ssml: string,
timestamp: string = toIsoTimestamp()
): string {
return (
`X-RequestId:${requestId}\r\n` +
`Content-Type:application/ssml+xml\r\n` +
`X-Timestamp:${timestamp}\r\n` +
`Path:ssml\r\n\r\n` +
`${ssml}`
);
}
/** True when a received text frame marks the end of the synthesis turn. */
export function isTurnEndMessage(message: string): boolean {
return typeof message === "string" && message.includes("Path:turn.end");
}
/**
* Demux one binary WS frame into its header block and raw audio payload.
* Frame shape: 2-byte big-endian header length, then that many bytes of
* ASCII headers, then the remaining bytes are audio data. Returns `null`
* for a frame too short to contain a valid header-length prefix.
*/
export function demuxAudioChunk(frame: Buffer): { headers: string; audio: Buffer } | null {
if (!Buffer.isBuffer(frame) || frame.length < 2) return null;
const headerLength = frame.readUInt16BE(0);
if (2 + headerLength > frame.length) return null;
const headers = frame.subarray(2, 2 + headerLength).toString("ascii");
const audio = frame.subarray(2 + headerLength);
return { headers, audio };
}
/** Build the WS connection URL, including the freshly-computed Sec-MS-GEC token. */
export function buildEdgeTtsWsUrl(nowMs: number = Date.now()): string {
const params = new URLSearchParams({
TrustedClientToken: resolvePublicCred("edgetts_token"),
"Sec-MS-GEC": computeSecMsGec(nowMs),
"Sec-MS-GEC-Version": EDGE_TTS_GEC_VERSION,
ConnectionId: buildConnectionId(),
});
return `${EDGE_TTS_WS_URL}?${params.toString()}`;
}
// ─── Network I/O ────────────────────────────────────────────────────────────
/**
* Open a WS connection to Edge's Read Aloud service and synthesize `input`.
* `WebSocketCtor` is injectable for tests; production callers omit it and
* this lazily imports the `ws` package (mirrors the pattern used in
* copilot-web.ts / chipotle.ts — keeps `ws` out of the esbuild CJS bundle's
* top-level graph).
*/
export async function synthesizeEdgeTts(
input: EdgeTtsSynthInput,
WebSocketCtor?: WebSocketCtor
): Promise<EdgeTtsSynthResult> {
const Ctor = WebSocketCtor ?? ((await import("ws")).default as unknown as WebSocketCtor);
const url = buildEdgeTtsWsUrl();
const ssml = buildSsml(input);
const requestId = buildConnectionId();
return new Promise<EdgeTtsSynthResult>((resolve, reject) => {
const chunks: Buffer[] = [];
let settled = false;
let contentType = "audio/mpeg";
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
finish(() => {
try {
ws.close();
} catch {
// best-effort close on timeout
}
reject(new Error("EdgeTTS synthesis timed out"));
});
}, SYNTH_TIMEOUT_MS);
let ws: MinimalWebSocket;
try {
ws = new Ctor(url, { handshakeTimeout: CONNECT_TIMEOUT_MS });
} catch (err) {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
return;
}
ws.on("open", () => {
ws.send(buildSpeechConfigMessage());
ws.send(buildSsmlMessage(requestId, ssml));
});
ws.on("message", (data: unknown, isBinary?: unknown) => {
const binary = isBinary === true || Buffer.isBuffer(data);
if (binary) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
const demuxed = demuxAudioChunk(buf);
if (demuxed) {
const typeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(demuxed.headers);
if (typeMatch) contentType = typeMatch[1].trim();
if (demuxed.audio.length > 0) chunks.push(demuxed.audio);
}
return;
}
const text = String(data);
if (isTurnEndMessage(text)) {
finish(() => {
try {
ws.close();
} catch {
// best-effort close
}
resolve({ audio: Buffer.concat(chunks), contentType });
});
}
});
ws.on("error", (err: unknown) => {
finish(() => reject(err instanceof Error ? err : new Error(String(err))));
});
ws.on("close", () => {
finish(() => {
if (chunks.length > 0) {
resolve({ audio: Buffer.concat(chunks), contentType });
} else {
reject(new Error("EdgeTTS connection closed before receiving audio"));
}
});
});
});
}
// ─── Handler entrypoint (called from audioSpeech.ts) ───────────────────────
/**
* Handle an EdgeTTS `/v1/audio/speech` request. `clientIp` is optional — when
* provided, this enforces the per-IP sliding-window throttle described above.
*/
export async function handleEdgeTtsSpeech(
body: { input?: unknown; voice?: unknown },
clientIp?: string | null,
WebSocketCtor?: WebSocketCtor
): Promise<Response> {
if (clientIp) {
const { allowed, retryAfterMs } = edgeTtsLimiter.tryAcquire(clientIp, EDGE_TTS_RATE_WINDOW);
if (!allowed) {
return errorResponse(
429,
`EdgeTTS rate limit exceeded, retry after ${Math.ceil(retryAfterMs / 1000)}s`
);
}
}
const text = typeof body?.input === "string" ? body.input : "";
if (!text.trim()) {
return errorResponse(400, "input is required");
}
try {
const { audio, contentType } = await synthesizeEdgeTts(
{
text,
voice: typeof body.voice === "string" ? body.voice : undefined,
},
WebSocketCtor
);
return new Response(audio, {
status: 200,
headers: { "Content-Type": contentType },
});
} catch (err) {
return errorResponse(
502,
`EdgeTTS request failed: ${err instanceof Error ? err.message : String(err)}`
);
}
}

View File

@@ -939,8 +939,8 @@ export class GrokWebExecutor extends BaseExecutor {
// Fetch from Grok via TLS-impersonating client (#3180).
// Grok sits behind Cloudflare Enterprise which rejects Node's native TLS
// fingerprint even with valid sso+sso-rw cookies. We use tls-client-node
// to send a Chrome-like handshake instead.
// fingerprint even with valid sso+sso-rw cookies. The pinned wreq-js
// transport sends a Chrome-like handshake instead.
let tlsResult: TlsFetchResult;
try {
tlsResult = await tlsFetchGrok(GROK_CHAT_API, {

View File

@@ -3,7 +3,7 @@
*
* Reverse-engineered, unofficial, undocumented endpoint (not a published
* Google public API) — the same class of integration this codebase already
* accepts for other "-web"/no-auth style providers.
* accepts for other "-web"/no-auth style providers (edgeTts.ts, chipotle.ts).
* No user account/API key is required.
*
* The issue's originally proposed endpoint

View File

@@ -2,8 +2,8 @@
* LMArenaExecutor — Arena (formerly LMArena) web-session provider.
*
* Routes requests through arena.ai create-evaluation with session cookies.
* Upstream sits behind Cloudflare; traffic goes through tls-client-node Chrome
* impersonation (see services/lmarenaTlsClient.ts).
* Upstream sits behind Cloudflare; traffic goes through wreq-js Chrome
* impersonation with isolated ephemeral cookies (see services/lmarenaTlsClient.ts).
*
* Helpers: open-sse/executors/lmarena/{cookie,models,stream,response}.ts
*/
@@ -174,7 +174,6 @@ export class LMArenaExecutor extends BaseExecutor {
body: JSON.stringify(transformedBody),
signal: ctx.signal,
stream: ctx.stream,
streamEofSymbol: "__OMNIROUTE_LMARENA_EOF_NEVER__",
});
const failed = mapFailedTlsResult({

View File

@@ -6,9 +6,9 @@ export const LMARENA_API_BASE = "https://arena.ai";
export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`;
/**
* Current Chrome stable UA (header surface).
* TLS JA3 profile is separate: tls-client-node tops out at chrome_146 — see
* LMARENA_PROFILE in lmarenaTlsClient.ts. Headers track the live browser string;
* fingerprint stays at the newest native profile we can actually impersonate.
* TLS JA3/JA4 profile is separate: the provider-tested wreq-js profile is pinned
* to chrome_146 in lmarenaTlsClient.ts while headers track the live browser string.
* Treat that deliberate version skew as a WAF-sensitive compatibility surface.
*/
export const LMARENA_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";

View File

@@ -114,7 +114,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: ${error.message}. Verify the wreq-js 3.0.0 native addon.`,
"upstream_error",
"TLS_CLIENT_UNAVAILABLE"
),

View File

@@ -22,7 +22,7 @@
* chunk — safer than assuming unverified incremental-delta semantics.
*
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id, user_id])
* Method: Browser-TLS impersonation via tls-client-node (Chrome JA3). Plain
* Method: Browser-TLS impersonation via pinned wreq-js (Chrome JA3/JA4). Plain
* Node/undici fetch is rejected by Notion's edge with in-band
* `temporarily-unavailable` (HTTP 200, empty assistant text) — curl/Schannel
* and Chrome work with the same cookie + body. See services/notionTlsClient.ts.
@@ -60,10 +60,7 @@ import {
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.<name>` on this module.
export {
@@ -225,7 +222,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 +232,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;
@@ -393,9 +387,8 @@ function buildNotionExecuteHeaders(opts: {
const isCustom = Boolean(opts.agent?.workflowId);
// Browser uses /agent/<workflowId without dashes>?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<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
@@ -453,11 +446,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 +467,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,8 +497,7 @@ 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 ?? "";
@@ -634,8 +620,7 @@ export class NotionWebExecutor extends BaseExecutor {
const inboundHeaders =
(input.clientHeaders as Record<string, string> | null | undefined) ??
((input as { headers?: Record<string, string> }).headers as
| Record<string, string>
| undefined);
Record<string, string> | 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 +723,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 });
}

View File

@@ -16,10 +16,7 @@ import {
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 { buildSessionCookieHeader, mergeRefreshedCookie } from "../utils/nextAuthCookie.ts";
import {
PPLX_SSE_ENDPOINT,
PPLX_USER_AGENT,
@@ -362,7 +359,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<string, unknown>;
const rawMessages = bodyObj.messages as Array<Record<string, unknown>> | undefined;
if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) {
@@ -496,7 +501,7 @@ export class PerplexityWebExecutor extends BaseExecutor {
if (isCloudflareChallenge(response.text)) {
errMsg =
"Cloudflare blocked the request — Perplexity's edge rejected this server's TLS fingerprint " +
"(common on VPS/datacenter IPs). Ensure tls-client-node is installed with its native binary, " +
"(common on VPS/datacenter IPs). Verify the wreq-js 3.0.0 native addon, " +
"or route perplexity-web through a residential proxy.";
log?.error?.("PPLX-WEB", "Cloudflare challenge detected — TLS bypass failed");
} else {

View File

@@ -23,6 +23,7 @@ import { kieExecutor } from "../executors/kie.ts";
import { vertexGenerateSpeech } from "../executors/vertexMedia.ts";
import { handleGeminiTtsSpeech } from "../executors/geminiTts.ts";
import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts";
import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts";
import { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts";
import { errorResponse } from "../utils/error.ts";
import { resolveElevenLabsVoiceId } from "./elevenLabsVoiceMap.ts";
@@ -843,6 +844,7 @@ export async function handleAudioSpeech({
credentials,
resolvedProvider = null,
resolvedModel = null,
clientIp = null,
}) {
if (!body.model) {
return errorResponse(400, "model is required");
@@ -864,7 +866,7 @@ export async function handleAudioSpeech({
if (!providerConfig) {
return errorResponse(
400,
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, fishaudio, playht, kie, aws-polly, xiaomi-mimo, gtts, coqui, tortoise, qwen`
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, fishaudio, playht, kie, aws-polly, xiaomi-mimo, edgetts, gtts, coqui, tortoise, qwen`
);
}
@@ -944,6 +946,10 @@ export async function handleAudioSpeech({
return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials);
}
if (providerConfig.format === "edgetts") {
return handleEdgeTtsSpeech(body, clientIp);
}
if (providerConfig.format === "gtts") {
return handleGttsSpeech(body);
}

View File

@@ -1,15 +1,15 @@
/**
* Regression tests for the proxy-leak fix in chatgptTlsClient.
*
* Bug context (#2022): tlsFetchChatGpt() built its native tls-client-node
* requestOptions without a `proxyUrl` field, so every chatgpt-web call
* Bug context (#2022): tlsFetchChatGpt() built its native transport options
* without a `proxyUrl` field, so every chatgpt-web call
* egressed with the bare host IP regardless of the dashboard proxy config
* or HTTP_PROXY / HTTPS_PROXY env vars (the koffi-loaded Go binary does not
* consult Go's `http.ProxyFromEnvironment`).
* or HTTP_PROXY / HTTPS_PROXY env vars. Native browser transports require the
* resolved proxy to be passed explicitly.
*
* These tests pin the resolution-order contract:
* 1. Per-call `options.proxyUrl` wins.
* 2. OMNIROUTE_TLS_PROXY_URL env var (single-flag opt-in).
* 2. Request-scoped dashboard/account proxy context.
* 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants).
* 4. Otherwise undefined (no proxy).
*
@@ -24,7 +24,6 @@ import { describe, it, beforeEach, afterEach, expect } from "vitest";
import { tlsFetchChatGpt, __setTlsFetchOverrideForTesting } from "../chatgptTlsClient.ts";
const PROXY_ENV_KEYS = [
"OMNIROUTE_TLS_PROXY_URL",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
@@ -62,7 +61,6 @@ describe("chatgptTlsClient — proxy plumbing (#2022)", async () => {
});
it("per-call proxyUrl overrides everything", async () => {
process.env.OMNIROUTE_TLS_PROXY_URL = "http://env-omni:0/";
process.env.HTTPS_PROXY = "http://env-https:0/";
let observedUrl: string | undefined;

View File

@@ -1,15 +1,15 @@
/**
* Regression tests for the proxy-leak fix in grokTlsClient.
*
* Bug context (#3180): tlsFetchGrok() built its native tls-client-node
* requestOptions without a `proxyUrl` field, so every grok-web call
* Bug context (#3180): tlsFetchGrok() built its native transport options
* without a `proxyUrl` field, so every grok-web call
* egressed with the bare host IP regardless of the dashboard proxy config
* or HTTP_PROXY / HTTPS_PROXY env vars (the koffi-loaded Go binary does not
* consult Go's `http.ProxyFromEnvironment`).
* or HTTP_PROXY / HTTPS_PROXY env vars. Native browser transports require the
* resolved proxy to be passed explicitly.
*
* These tests pin the resolution-order contract:
* 1. Per-call `options.proxyUrl` wins.
* 2. OMNIROUTE_TLS_PROXY_URL env var (single-flag opt-in).
* 2. Request-scoped dashboard/account proxy context.
* 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants).
* 4. Otherwise undefined (no proxy).
*
@@ -24,7 +24,6 @@ import { describe, it, beforeEach, afterEach, expect } from "vitest";
import { tlsFetchGrok, __setTlsFetchOverrideForTesting } from "../grokTlsClient.ts";
const PROXY_ENV_KEYS = [
"OMNIROUTE_TLS_PROXY_URL",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
@@ -62,7 +61,6 @@ describe("grokTlsClient — proxy plumbing (#3180)", async () => {
});
it("per-call proxyUrl overrides everything", async () => {
process.env.OMNIROUTE_TLS_PROXY_URL = "http://env-omni:0/";
process.env.HTTPS_PROXY = "http://env-https:0/";
let observedUrl: string | undefined;

View File

@@ -2,8 +2,8 @@
* Browser-TLS-impersonating HTTP client for chatgpt.com.
*
* Thin re-export over the shared `tlsClientBase.ts` factory
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
* streaming tail-file, proxy resolution, error classes, SSE detection) lives
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
* pooling, direct streaming, proxy resolution, deadlines, SSE detection) lives
* in the base module; this file supplies only ChatGPT-specific config and
* preserves the original public export surface.
*/
@@ -24,9 +24,9 @@ const STREAM_FIRST_BYTE_TIMEOUT_MS =
export const tlsClientModule = createTlsClientModule({
providerName: "ChatGPT",
tlsProfile: "firefox_148",
emulationOs: "macos",
domain: "https://chatgpt.com",
tempDirPrefix: "cgpt-stream-",
tailFileVariant: "A",
streamEofPolicy: "include",
responseValidation: "sse",
exportCloudflareCheck: false,
exposeStreamingForTesting: true,

View File

@@ -2,8 +2,8 @@
* Browser-TLS-impersonating HTTP client for claude.ai.
*
* Thin re-export over the shared `tlsClientBase.ts` factory
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
* streaming tail-file, proxy resolution, error classes, SSE detection) lives
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
* pooling, direct streaming, proxy resolution, deadlines, SSE detection) lives
* in the base module; this file supplies only Claude-specific config and
* preserves the original public export surface.
*/
@@ -24,13 +24,13 @@ const HARD_TIMEOUT_GRACE_MS =
export const tlsClientModule = createTlsClientModule({
providerName: "Claude",
tlsProfile: `chrome_${CLAUDE_TLS_BROWSER_MAJOR_VERSION}`,
emulationOs: "linux",
domain: "https://claude.ai",
tempDirPrefix: "cgpt-stream-",
tailFileVariant: "A",
streamEofPolicy: "include",
responseValidation: "sse",
exportCloudflareCheck: false,
exposeStreamingForTesting: true,
// Claude waits indefinitely for the first SSE byte (original 2-arg waitForContent).
// Claude allows the native/hard request deadline to bound a slow first SSE byte.
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS,
firstByteTimeoutMs: Number.POSITIVE_INFINITY,

View File

@@ -7,7 +7,7 @@
* 3. Waits for Turnstile challenge to appear
* 4. Waits for challenge to be solved (with retry)
* 5. Extracts cf_clearance cookie
* 6. Returns fresh cookie for tls-client-node
* 6. Returns a fresh cookie for the isolated wreq-js request
*/
import type { Browser, Page } from "playwright";

View File

@@ -2,8 +2,8 @@
* Browser-TLS-impersonating HTTP client for grok.com.
*
* Thin re-export over the shared `tlsClientBase.ts` factory
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
* streaming tail-file, proxy resolution, error classes, Cloudflare challenge
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
* pooling, direct streaming, proxy resolution, deadlines, Cloudflare challenge
* detection) lives in the base module; this file supplies only Grok-specific
* config and preserves the original public export surface.
*/
@@ -22,9 +22,9 @@ const HARD_TIMEOUT_GRACE_MS =
export const tlsClientModule = createTlsClientModule({
providerName: "Grok",
tlsProfile: "chrome_146",
emulationOs: "linux",
domain: "https://grok.com",
tempDirPrefix: "grok-stream-",
tailFileVariant: "B1",
streamEofPolicy: "exclude",
responseValidation: "cf",
exportCloudflareCheck: true,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,

View File

@@ -2,8 +2,8 @@
* Browser-TLS-impersonating HTTP client for arena.ai.
*
* Thin re-export over the shared `tlsClientBase.ts` factory
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
* streaming tail-file, proxy resolution, error classes, Cloudflare challenge
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
* pooling, direct streaming, proxy resolution, deadlines, Cloudflare challenge
* detection) lives in the base module; this file supplies only LMArena-specific
* config and preserves the original public export surface.
*/
@@ -20,11 +20,12 @@ const HARD_TIMEOUT_GRACE_MS = 10_000;
export const tlsClientModule = createTlsClientModule({
providerName: "LMArena",
tlsProfile: "chrome_146",
emulationOs: "windows",
domain: "https://lmarena.ai",
// LMArena's proxy resolution domain is hardcoded to arena.ai, not the config domain.
proxyDomainOverride: "https://arena.ai",
tempDirPrefix: "LMArena-stream-",
tailFileVariant: "B2",
streamEofPolicy: "none",
streamEofSymbol: "",
responseValidation: "cf",
exportCloudflareCheck: true,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,

View File

@@ -2,8 +2,8 @@
* Browser-TLS-impersonating HTTP client for app.notion.com.
*
* Thin re-export over the shared `tlsClientBase.ts` factory
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
* streaming tail-file, proxy resolution, error classes, SSE detection,
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
* pooling, direct streaming, proxy resolution, deadlines, SSE detection,
* Cloudflare challenge detection) lives in the base module; this file supplies
* only Notion-specific config and preserves the original public export surface.
*/
@@ -22,9 +22,9 @@ const HARD_TIMEOUT_GRACE_MS =
export const tlsClientModule = createTlsClientModule({
providerName: "Notion",
tlsProfile: "chrome_146",
emulationOs: "windows",
domain: "https://app.notion.com",
tempDirPrefix: "pplx-stream-",
tailFileVariant: "A",
streamEofPolicy: "include",
responseValidation: "sse",
exportCloudflareCheck: true,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,

View File

@@ -2,8 +2,8 @@
* Browser-TLS-impersonating HTTP client for www.perplexity.ai.
*
* Thin re-export over the shared `tlsClientBase.ts` factory
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
* streaming tail-file, proxy resolution, error classes, SSE detection,
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
* pooling, direct streaming, proxy resolution, deadlines, SSE detection,
* Cloudflare challenge detection) lives in the base module; this file supplies
* only Perplexity-specific config and preserves the original public export
* surface.
@@ -23,9 +23,9 @@ const HARD_TIMEOUT_GRACE_MS =
export const tlsClientModule = createTlsClientModule({
providerName: "Perplexity",
tlsProfile: "firefox_148",
emulationOs: "macos",
domain: "https://www.perplexity.ai",
tempDirPrefix: "pplx-stream-",
tailFileVariant: "A",
streamEofPolicy: "include",
responseValidation: "sse",
exportCloudflareCheck: true,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,

View File

@@ -24,6 +24,7 @@ import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import { calculateModalCost } from "@/lib/usage/costCalculator";
import { getClientIpFromRequest } from "@/lib/ipUtils";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
@@ -34,6 +35,10 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
export async function executeSpeechCombo(
comboName: string,
body: Record<string, unknown>,
auth: {
request: Request;
policy: { apiKeyInfo?: { id?: string; name?: string } | null };
},
startTime: number
): Promise<Response> {
const combo = await getComboByName(comboName);
@@ -75,6 +80,7 @@ export async function executeSpeechCombo(
);
}
const clientIp = getClientIpFromRequest(auth.request);
let lastError: { status: number; error: string } | null = null;
let fallbackCount = 0;
@@ -123,6 +129,7 @@ export async function executeSpeechCombo(
credentials,
resolvedProvider: providerConfig,
resolvedModel,
clientIp,
});
if (response?.ok) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +0,0 @@
import { join } from "node:path";
import { resolveDataDir } from "@/lib/dataPaths";
/**
* Writable cache directory for tls-client-node's native binary.
*
* Without an explicit `downloadDir`, the library defaults to its own package
* `node_modules/tls-client-node/bin`, which is root-owned on global installs
* and fails with EACCES for normal users (#8579).
*/
export function resolveTlsClientDownloadDir(): string {
return join(resolveDataDir(), "tls-client", "bin");
}
export function buildNativeTlsClientOptions(): {
runtimeMode: "native";
downloadDir: string;
} {
return {
runtimeMode: "native",
downloadDir: resolveTlsClientDownloadDir(),
};
}

View File

@@ -186,6 +186,15 @@ const EMBEDDED_DEFAULTS = {
12, 93, 15, 11, 74, 12, 16, 77, 72, 72, 73, 20, 82, 65, 93, 81, 72, 65, 28, 13, 93, 88, 93, 95,
92, 70, 16, 81, 31, 66, 17, 4, 88, 88, 5, 28,
],
// Microsoft Edge Read Aloud (EdgeTTS) — public "trusted client token" used to
// derive the Sec-MS-GEC anti-abuse header. Hardcoded in every known Edge
// browser build and every open-source edge-tts reimplementation (e.g.
// rany2/edge-tts constants.py) — not a per-user secret, just an
// abuse-mitigation constant Microsoft ships in public client binaries.
edgetts_token: [
89, 44, 91, 40, 51, 94, 49, 64, 32, 108, 54, 51, 86, 41, 80, 37, 111, 69, 6, 42, 95, 93, 45, 68,
87, 65, 77, 84, 105, 70, 51, 86,
],
// Adobe Firefly web (firefly.adobe.com) — public x-api-key + IMS client_id
// (`clio-playground-web`). Captured from live browser generate/discovery calls.
// Not a per-user secret; every Firefly SPA session sends the same value.

50
package-lock.json generated
View File

@@ -162,8 +162,7 @@
"keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0"
"wreq-js": "3.0.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -25388,17 +25387,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/koffi": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz",
"integrity": "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -25588,17 +25576,6 @@
"node": ">= 14"
}
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/libxmljs2/node_modules/cacache": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
@@ -35641,7 +35618,7 @@
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
"integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.27"
@@ -35654,28 +35631,9 @@
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
"integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/tls-client-node": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/tls-client-node/-/tls-client-node-0.2.0.tgz",
"integrity": "sha512-0PHJgaGPvMK9ly7xohviOoe8Oxos43IOIdsEhibgku4ce/3/YLhxJTPPKNQZII0PdcOjlfPweB9eRs13mWaWIg==",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"optional": true,
"dependencies": {
"koffi": "^2.8.9",
"tough-cookie": "^6.0.1"
},
"engines": {
"node": ">=18.17"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/fatihkabakk"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -35728,7 +35686,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"devOptional": true,
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"

View File

@@ -22,7 +22,6 @@
"src/types/",
".env.example",
"scripts/build/postinstall.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/fixPlaywrightAndroid.mjs",
"bin/cli/runtime/",
"scripts/postinstall.mjs",
@@ -38,6 +37,8 @@
"scripts/build/backendOnlyPages.mjs",
"scripts/build/build-tproxy-native.mjs",
"scripts/build/native-binary-compat.mjs",
"scripts/build/wreqJsNative.mjs",
"config/release/wreq-js-native-manifest.json",
"scripts/build/build-next-isolated.mjs",
"scripts/build/runtime-env.mjs",
"scripts/packs/optionalPackManifest.mjs",
@@ -347,8 +348,7 @@
"keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0"
"wreq-js": "3.0.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",

View File

@@ -12,12 +12,10 @@ allowBuilds:
core-js: true
esbuild: true
keytar: true
koffi: true
libxmljs2: true
onnxruntime-node: true
protobufjs: true
sharp: true
tls-client-node: true
unrs-resolver: true
onlyBuiltDependencies:
- "@parcel/watcher"
@@ -26,11 +24,9 @@ onlyBuiltDependencies:
- "core-js"
- "esbuild"
- "keytar"
- "koffi"
- "libxmljs2"
- "onnxruntime-node"
- "omniroute"
- "protobufjs"
- "sharp"
- "tls-client-node"
- "unrs-resolver"

View File

@@ -6,13 +6,11 @@
"core-js",
"esbuild",
"keytar",
"koffi",
"libxmljs2",
"omniroute",
"onnxruntime-node",
"protobufjs",
"sharp",
"tls-client-node",
"unrs-resolver"
]
}

View File

@@ -1,148 +0,0 @@
#!/usr/bin/env node
/**
* tls-client-node postinstall repair (#7802).
*
* tls-client-node's own postinstall.js fetches a platform-specific native
* binary (.so/.dylib/.dll) from the bogdanfinn/tls-client GitHub Releases
* API. That script is blocked by `npm ci --ignore-scripts` (the Dockerfile
* builder stage runs with scripts disabled for supply-chain hygiene) and,
* even when it does run, silently no-ops on a rate-limited/failed GitHub API
* call instead of raising — so `node_modules/tls-client-node/bin/` can end
* up empty with no visible signal until the first live request throws
* TlsClientUnavailableError (chatgpt-web/claude-web/grok-web/lmarena/
* perplexity-web all share this transport).
*
* This module:
* 1. Copies an already-fetched root `bin/` into the standalone
* `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.
* 2. When the root `bin/` is empty (--ignore-scripts blocked it, or a
* transient GitHub rate-limit ate the first attempt), retries the
* module's own postinstall.js with exponential backoff instead of
* giving up on the first failure.
*
* Best-effort throughout: a failure here never throws out of postinstall.mjs
* — it only warns, matching the other fix*Binary() steps. The runtime layer
* (perplexityTlsClient.ts and its 4 siblings) already surfaces a clear
* TlsClientUnavailableError pointing at the missing binary, so an operator
* who hits a still-empty bin/ after this repair gets an actionable message
* rather than an opaque crash.
*/
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { join } from "node:path";
const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000, 8_000];
function hasAnyFile(dir) {
if (!existsSync(dir)) return false;
try {
return readdirSync(dir).length > 0;
} catch {
return false;
}
}
function copyBinDir(sourceDir, destDir) {
mkdirSync(destDir, { recursive: true });
for (const file of readdirSync(sourceDir)) {
copyFileSync(join(sourceDir, file), join(destDir, file));
}
}
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, retryDelaysMs, log) {
const postinstallScript = join(rootTlsClientDir, "scripts", "postinstall.js");
const binDir = join(rootTlsClientDir, "bin");
if (!existsSync(postinstallScript)) return false;
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...`
);
await sleep(retryDelaysMs[attempt - 1]);
}
try {
const { execFileSync } = await import("node:child_process");
execFileSync(process.execPath, [postinstallScript], {
cwd: rootTlsClientDir,
stdio: "pipe",
timeout: 30_000,
});
} catch (err) {
log(` ⚠️ tls-client-node postinstall attempt failed: ${err.message.split("\n")[0]}`);
}
if (hasAnyFile(binDir)) return true;
}
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)
*/
export async function fixTlsClientNodeBinary({
rootDir,
log = (m) => console.log(m),
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
} = {}) {
const rootTlsClientDir = join(rootDir, "node_modules", "tls-client-node");
const rootBinDir = join(rootTlsClientDir, "bin");
const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node");
if (!existsSync(rootTlsClientDir)) return;
if (!hasAnyFile(rootBinDir)) {
log(
"\n 🔧 tls-client-node native binary missing (blocked by --ignore-scripts or a " +
"failed fetch) — attempting repair...\n"
);
const recovered = await downloadWithRetry(rootTlsClientDir, retryDelaysMs, log);
if (!recovered) {
console.warn(
"\n ⚠️ Could not fetch tls-client-node's native binary " +
"(GitHub API rate-limited or unreachable after retries)."
);
console.warn(
" chatgpt-web/claude-web/grok-web/lmarena/perplexity-web will raise a clear " +
"TlsClientUnavailableError on first use until this is resolved."
);
console.warn(
` Manual fix: node ${join(rootTlsClientDir, "scripts", "postinstall.js")}\n`
);
return;
}
log(" ✅ tls-client-node native binary fetched successfully!\n");
}
if (!existsSync(distTlsClientDir) || !hasAnyFile(rootBinDir)) return;
const distBinDir = join(distTlsClientDir, "bin");
if (hasAnyFile(distBinDir)) return;
try {
copyBinDir(rootBinDir, distBinDir);
log(" ✅ tls-client-node native binary copied to standalone dist/node_modules.\n");
} catch (err) {
console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`);
}
}

View File

@@ -7,9 +7,9 @@
* matrix leg. Everything except install-machine-forked optional packages is
* platform-independent:
*
* - Bundled-for-all (verify only): koffi ships every triplet under
* `build/koffi/<os>_<arch>`, better-sqlite3 v13 ships Node-API prebuilds for
* 8 platforms, wreq-js ships `rust/wreq-js.<plat>-<arch>[-libc].node`, and
* - Bundled-for-all (verify only): better-sqlite3 v13 ships Node-API prebuilds
* for 8 platforms, wreq-js ships
* `rust/wreq-js.<plat>-<arch>[-libc].node`, and
* onnxruntime-node ships `bin/napi-v6/<os>/<arch>`.
* - Install-machine-forked (hydrate): `@img/sharp-*`, `@img/sharp-libvips-*`,
* `@ngrok/ngrok-*` and macOS-only `fsevents` resolve to whichever platform
@@ -33,8 +33,7 @@ export const HYDRATED_ROOT_PACKAGES = ["fsevents"];
export const BUNDLED_EXEMPTIONS = new Set(["onnxruntime-node:darwin-x64"]);
function platformTriple(platform, arch) {
// koffi uses underscore triplets; better-sqlite3/wreq-js/onnx use dashes.
return { koffi: `${platform}_${arch}`, dash: `${platform}-${arch}` };
return { dash: `${platform}-${arch}` };
}
function rmrf(target) {
@@ -106,9 +105,6 @@ export function verifyBundledNatives({ nodeModulesDir, platform, arch }) {
const errors = [];
const triple = platformTriple(platform, arch);
const koffiDir = path.join(nodeModulesDir, "koffi", "build", "koffi", triple.koffi);
if (!fs.existsSync(koffiDir)) errors.push(`koffi: missing bundled triplet ${triple.koffi}`);
const sqlitePrebuild = path.join(
nodeModulesDir,
"better-sqlite3",

View File

@@ -94,6 +94,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
"LICENSE",
"README.md",
"THIRD_PARTY_NOTICES.md",
"config/release/wreq-js-native-manifest.json",
"bin/aliasResolver.mjs",
"bin/chatgpt-web-codex-mcp.mjs",
// #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL
@@ -136,12 +137,10 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
"scripts/build/build-next-isolated.mjs",
"scripts/check/check-supported-node-runtime.ts",
"scripts/build/native-binary-compat.mjs",
"scripts/build/wreqJsNative.mjs",
"scripts/build/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
"scripts/build/colocateOptionals.mjs",
// #7802: imported by scripts/build/postinstall.mjs to repair tls-client-node's
// native binary (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web transport).
"scripts/build/fixTlsClientNodeBinary.mjs",
// #8859: imported by scripts/build/postinstall.mjs to repair playwright-core's
// browser resolution on Termux/Android (no glibc, no bundled browsers).
"scripts/build/fixPlaywrightAndroid.mjs",
@@ -220,13 +219,14 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
// or the CLI fails to boot — list them REQUIRED so a regression is loud.
"bin/aliasResolver.mjs",
"bin/aliasResolverHook.mjs",
"config/release/wreq-js-native-manifest.json",
"package.json",
"scripts/build/native-binary-compat.mjs",
"scripts/build/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
"scripts/build/colocateOptionals.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/runtime-env.mjs",
"scripts/build/wreqJsNative.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",

View File

@@ -14,8 +14,7 @@
*
* Modules repaired:
* - better-sqlite3 (SQLite bindings)
* - wreq-js (TLS client for OAuth providers)
* - tls-client-node (TLS client for chatgpt-web/claude-web/grok-web/lmarena/perplexity-web)
* - wreq-js (TLS client for OAuth and web-cookie providers)
* - sql.js (WASM SQLite fallback runtime)
* - node-machine-id (local CLI machine-token server runtime)
*
@@ -26,15 +25,7 @@
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/7802
*/
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -42,8 +33,8 @@ import { fileURLToPath } from "node:url";
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
import { colocateLlmlinguaOptionals } from "./colocateOptionals.mjs";
import { fixTlsClientNodeBinary } from "./fixTlsClientNodeBinary.mjs";
import { fixPlaywrightAndroid } from "./fixPlaywrightAndroid.mjs";
import { resolveWreqJsNativeBinaryName } from "./wreqJsNative.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -271,7 +262,7 @@ async function fixWreqJsBinary() {
if (process.platform === "android" || isTermux()) {
console.log(
" [postinstall] wreq-js: skipped on Termux/Android " +
"(libgcc not available — OAuth TLS fingerprinting will use the fallback path)"
"(wreq-js 3.0.0 does not publish an Android native addon)"
);
return;
}
@@ -283,7 +274,16 @@ async function fixWreqJsBinary() {
return;
}
const binaryName = `wreq-js.${process.platform}-${process.arch}.node`;
const binaryName = resolveWreqJsNativeBinaryName({
platform: process.platform,
arch: process.arch,
});
if (!binaryName) {
console.warn(
` ⚠️ wreq-js 3.0.0 has no native addon for ${process.platform}-${process.arch}.`
);
return;
}
const appBinaryPath = join(appWreqDir, binaryName);
const rootBinaryPath = join(rootWreqDir, binaryName);
@@ -312,27 +312,7 @@ async function fixWreqJsBinary() {
}
}
// Strategy 2: Copy entire rust/ directory from root (gets all platform binaries)
if (existsSync(rootWreqDir)) {
try {
mkdirSync(appWreqDir, { recursive: true });
const files = readdirSync(rootWreqDir);
for (const file of files) {
if (file.endsWith(".node")) {
copyFileSync(join(rootWreqDir, file), join(appWreqDir, file));
}
}
if (existsSync(appBinaryPath)) {
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module fixed (full copy) successfully!\n");
return;
}
} catch (err) {
console.warn(` ⚠️ wreq-js full copy failed: ${err.message}`);
}
}
// Strategy 3: Rebuild wreq-js inside dist/
// Strategy 2: Rebuild wreq-js inside dist/
console.log(" 📥 Attempting npm rebuild wreq-js...");
try {
const { execSync } = await import("node:child_process");
@@ -353,8 +333,10 @@ async function fixWreqJsBinary() {
console.warn(
`\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${process.arch}.`
);
console.warn(" OAuth-based providers (Codex, Cursor, etc.) may not work.");
console.warn(` Manual fix: cd ${join(ROOT, "dist")} && npm install wreq-js --no-save\n`);
console.warn(" Browser-TLS OAuth and web-cookie providers may not work.");
console.warn(
` Manual fix: cd ${join(ROOT, "dist")} && npm install wreq-js@3.0.0 --save-exact\n`
);
}
async function ensureSwcHelpers() {
@@ -464,7 +446,6 @@ async function ensureStandaloneRuntimePackages() {
await verifyDevNativeModules();
await fixBetterSqliteBinary();
await fixWreqJsBinary();
await fixTlsClientNodeBinary({ rootDir: ROOT });
await fixPlaywrightAndroid({ rootDir: ROOT });
await ensureSwcHelpers();
await ensureStandaloneRuntimePackages();

View File

@@ -0,0 +1,37 @@
/** Exact native-addon layout published by wreq-js 3.0.0. */
export const WREQ_JS_NATIVE_BINARY_NAMES = Object.freeze([
"wreq-js.darwin-arm64.node",
"wreq-js.darwin-x64.node",
"wreq-js.linux-arm64-gnu.node",
"wreq-js.linux-arm64-musl.node",
"wreq-js.linux-x64-gnu.node",
"wreq-js.linux-x64-musl.node",
"wreq-js.win32-x64-msvc.node",
]);
/** Detect the C library used by the current Linux runtime. */
export function detectRuntimeLibc() {
if (process.platform !== "linux") return undefined;
try {
const report = process.report?.getReport();
return report?.header?.glibcVersionRuntime ? "gnu" : "musl";
} catch {
return "musl";
}
}
/** Resolve the exact addon filename that wreq-js 3.0.0 will load. */
export function resolveWreqJsNativeBinaryName({ platform, arch, libc }) {
if (platform === "darwin" && (arch === "arm64" || arch === "x64")) {
return `wreq-js.darwin-${arch}.node`;
}
if (platform === "linux" && (arch === "arm64" || arch === "x64")) {
const linuxLibc = libc ?? detectRuntimeLibc();
if (linuxLibc !== "gnu" && linuxLibc !== "musl") return null;
return `wreq-js.linux-${arch}-${linuxLibc}.node`;
}
if (platform === "win32" && arch === "x64") {
return "wreq-js.win32-x64-msvc.node";
}
return null;
}

View File

@@ -18,6 +18,7 @@ import {
import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta";
import { calculateModalCost } from "@/lib/usage/costCalculator";
import { generateRequestId } from "@/shared/utils/requestId";
import { getClientIpFromRequest } from "@/lib/ipUtils";
/**
* Handle CORS preflight
@@ -63,7 +64,7 @@ async function postHandler(request, context) {
const combo = await getComboByName(body.model);
if (combo) {
const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo");
return executeSpeechCombo(body.model, body, startTime);
return executeSpeechCombo(body.model, body, { request, policy }, startTime);
}
}
@@ -101,6 +102,7 @@ async function postHandler(request, context) {
credentials,
resolvedProvider: providerConfig,
resolvedModel,
clientIp: getClientIpFromRequest(request),
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);

View File

@@ -404,7 +404,7 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = {
errorDetail = (response.text || "").slice(0, 240);
} catch {}
// Detect Cloudflare challenge pages even with a 200 status from tls-client-node
// Detect Cloudflare challenge pages even when the browser transport reports status 200.
if (isCloudflareChallenge(errorDetail)) {
return {
valid: false,
@@ -680,7 +680,7 @@ export async function validatePerplexityWebProvider({ apiKey, providerSpecificDa
valid: false,
error:
"Cloudflare is blocking connections from this server's IP (TLS fingerprint rejected). " +
"The session cookie may still be valid — install tls-client-node's native binary or route " +
"The session cookie may still be valid — verify the wreq-js 3.0.0 native addon or route " +
"perplexity-web through a residential proxy.",
};
}

View File

@@ -226,6 +226,7 @@
"tests/unit/db/stats-dbstat-optional.test.ts",
"tests/unit/ddg-circuit-breaker-null-content-6999-7000.test.ts",
"tests/unit/domain-persistence.test.ts",
"tests/unit/edgetts-provider.test.ts",
"tests/unit/embedding-account-cooldown-10347.test.ts",
"tests/unit/embedding-cooldown-integration-10347.test.ts",
"tests/unit/embeddings-auth.test.ts",

View File

@@ -205,17 +205,17 @@ 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: a medium-risk custom exception still returns 'exception'", () => {
const allowlist = makeAllowlist({
exceptions: {
"tls-client-node": {
license: "Custom: LICENSE",
justification: "Commons Clause + Apache-2.0. TODO: revisar.",
"custom-runtime": {
license: "Custom: reviewed terms",
justification: "Reviewed custom runtime terms.",
risk: "medium",
},
},
});
const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
const result = classifyLicense("custom-runtime@1.0.0", "Custom: reviewed terms", allowlist);
assert.equal(result.status, "exception");
});
@@ -285,13 +285,6 @@ test("loadAllowlist: exceptions entries have required fields", () => {
}
});
test("loadAllowlist: tls-client-node exception has risk=medium (Commons Clause)", () => {
const allowlist = loadAllowlist();
const exc = allowlist.exceptions["tls-client-node"] as any;
assert.ok(exc, "tls-client-node exception must be registered");
assert.equal(exc.risk, "medium", "tls-client-node is a medium-risk exception (Commons Clause)");
});
test("loadAllowlist: LGPL packages have registered exceptions", () => {
const allowlist = loadAllowlist();
const lgplPkgs = ["@img/sharp-libvips-linux-x64", "@img/sharp-libvips-linuxmusl-x64"];
@@ -326,12 +319,6 @@ test("integration: classifyLicense passes MIT packages against real allowlist",
assert.equal(result.status, "allowed");
});
test("integration: classifyLicense passes tls-client-node as exception against real allowlist", () => {
const allowlist = loadAllowlist();
const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
assert.equal(result.status, "exception");
});
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);

View File

@@ -266,7 +266,6 @@ test("verifyBundledNatives asserts serviceability and honors the onnx darwin-x64
const root = tmpDir("s8-natives-");
try {
const nm = path.join(root, "node_modules");
writeNative(nm, "koffi/build/koffi/linux_x64/koffi.node", "elf");
writeNative(nm, "better-sqlite3/prebuilds/linux-x64.node", "napi");
writeNative(nm, "wreq-js/rust/wreq-js.linux-x64-gnu.node", "rust");
writeNative(nm, "onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime.so", "ort");
@@ -278,18 +277,18 @@ test("verifyBundledNatives asserts serviceability and honors the onnx darwin-x64
`expected serviceable: ${(good as { errors?: string[] }).errors?.join("; ")}`
);
const missingKoffi = verifyBundledNatives({
const missingPlatformNatives = verifyBundledNatives({
nodeModulesDir: nm,
platform: "darwin",
arch: "arm64",
});
assert.equal(missingKoffi.ok, false);
assert.ok((missingKoffi as { errors: string[] }).errors.some((e) => e.startsWith("koffi:")));
assert.equal(missingPlatformNatives.ok, false);
assert.ok(
(missingPlatformNatives as { errors: string[] }).errors.some((e) => e.startsWith("wreq-js:"))
);
// darwin-x64 has no onnxruntime-node prebuild at all — the exemption must keep it green
// as long as the other bundled natives service that triple.
const nm2 = path.join(root, "node_modules2");
writeNative(nm2, "koffi/build/koffi/darwin_x64/koffi.node", "macho");
writeNative(nm2, "better-sqlite3/prebuilds/darwin-x64.node", "napi");
writeNative(nm2, "wreq-js/rust/wreq-js.darwin-x64.node", "rust");
const exempted = verifyBundledNatives({ nodeModulesDir: nm2, platform: "darwin", arch: "x64" });

View File

@@ -1,6 +1,5 @@
import test from "node:test";
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import type { TlsFetchOptions } from "../../open-sse/services/chatgptTlsClient.ts";
const { ChatGptWebExecutor, __derivePublicBaseUrlForTesting, __resetChatGptWebCachesForTesting } =
@@ -255,8 +254,8 @@ function installMockFetch({
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "image/png" }),
// tls-client-node packages binary bodies as a data:<mime>;base64,...
// string when isByteResponse is set; the mock mirrors that contract.
// The shared browser transport packages byte responses as a
// data:<mime>;base64,... string; the mock mirrors that contract.
text: `data:image/png;base64,${tinyPng.toString("base64")}`,
body: null,
};
@@ -1901,30 +1900,30 @@ test("looksLikeSse: rejects non-SSE bodies that previously passed as 200", () =>
assert.equal(looksLikeSse("error: rate limit"), false, "non-SSE field name");
});
test("tls streaming: late first byte is read from streamOutputPath instead of empty body", async () => {
test("tls streaming: a late first byte falls back to the complete buffered body", async () => {
const fakeClient = {
async request(_url, opts) {
await new Promise((resolve) => setTimeout(resolve, 25));
await writeFile(
String(opts.streamOutputPath),
mockChatGptStreamText([
{
conversation_id: "conv-late",
message: {
id: "msg-late",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Late title answer"] },
status: "finished_successfully",
},
async request() {
const payload = mockChatGptStreamText([
{
conversation_id: "conv-late",
message: {
id: "msg-late",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Late title answer"] },
status: "finished_successfully",
},
]),
"utf8"
},
]);
return new Response(
new ReadableStream({
async pull(controller) {
await new Promise((resolve) => setTimeout(resolve, 25));
controller.enqueue(new TextEncoder().encode(payload));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } }
);
return {
status: 200,
headers: { "content-type": ["text/event-stream"] },
body: "",
};
},
};
@@ -2345,9 +2344,8 @@ test("Image gen: signed URL bytes are cached and exposed via /v1/chatgpt-web/ima
}
if (u.startsWith(downloadUrl)) {
calls.signed++;
// tls-client-node returns binary bodies as a "data:<mime>;base64,..."
// string (see its response.js bytes() impl); the executor decodes it
// back into bytes before putting the image in OmniRoute's cache.
// The shared browser transport returns binary bodies as a
// "data:<mime>;base64,..." string; the executor decodes it for the cache.
return {
status: 200,
headers: makeHeaders({ "Content-Type": "image/png" }),

View File

@@ -1,5 +1,4 @@
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import test from "node:test";
import { tlsFetchStreaming } from "../../open-sse/services/claudeTlsClient.ts";
@@ -15,19 +14,17 @@ const SSE_BODY = [
test("Claude Web keeps waiting when the first Opus SSE event takes longer than five seconds", async () => {
const client = {
request: async (_url: string, options: Record<string, unknown>) => {
await new Promise((resolve) => setTimeout(resolve, SLOW_FIRST_BYTE_MS));
await writeFile(String(options.streamOutputPath), SSE_BODY);
return {
status: 200,
headers: {},
body: "",
cookies: {},
text: async () => "",
json: async () => ({}),
bytes: async () => new Uint8Array(),
};
},
request: async () =>
new Response(
new ReadableStream<Uint8Array>({
async pull(controller) {
await new Promise((resolve) => setTimeout(resolve, SLOW_FIRST_BYTE_MS));
controller.enqueue(new TextEncoder().encode(SSE_BODY));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } }
),
};
const result = await tlsFetchStreaming(

View File

@@ -22,6 +22,21 @@ const core = await import("@/lib/db/core.ts");
const { createCombo } = await import("@/lib/db/combos");
const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo");
function createRequest(model: string): Request {
return new Request("http://localhost:20128/v1/audio/speech", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, input: "hello there" }),
});
}
function createMockAuth() {
return {
request: createRequest("test-combo"),
policy: { apiKeyInfo: { id: "test-key", name: "test-key" } },
};
}
async function cleanupTestDataDir() {
let lastError: unknown;
for (let attempt = 0; attempt < 5; attempt += 1) {
@@ -51,6 +66,7 @@ test("returns 400 when combo is not found", async () => {
const response = await executeSpeechCombo(
"nonexistent-combo",
{ model: "nonexistent-combo", input: "hello there" },
createMockAuth(),
Date.now()
);
assert.equal(response.status, 400);
@@ -68,6 +84,7 @@ test("returns 400 when combo has no speech-capable targets", async () => {
const response = await executeSpeechCombo(
"chat-only-combo",
{ model: "chat-only-combo", input: "hello there" },
createMockAuth(),
Date.now()
);
assert.equal(response.status, 400);
@@ -76,31 +93,13 @@ test("returns 400 when combo has no speech-capable targets", async () => {
assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces");
});
test("does not select retired EdgeTTS targets as speech-capable", async () => {
await createCombo({
name: "retired-edgetts-combo",
strategy: "priority",
models: ["edgetts/en-US-AriaNeural"],
});
const response = await executeSpeechCombo(
"retired-edgetts-combo",
{ model: "retired-edgetts-combo", input: " " },
Date.now()
);
const bodyStr = JSON.stringify(await response.json());
assert.equal(response.status, 400);
assert.ok(bodyStr.includes("No speech-capable targets"));
assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces");
});
test("returns 400 when combo has no usable targets", async () => {
await createCombo({ name: "empty-combo", strategy: "priority", models: [] });
const response = await executeSpeechCombo(
"empty-combo",
{ model: "empty-combo", input: "hello there" },
createMockAuth(),
Date.now()
);
assert.equal(response.status, 400);
@@ -116,6 +115,7 @@ test("fails cleanly when speech targets exist but no provider connection does",
const response = await executeSpeechCombo(
"spc-no-conn",
{ model: "spc-no-conn", input: "hello there" },
createMockAuth(),
Date.now()
);
assert.ok(response.status >= 400, "Surfaces a failure rather than a fake success");

View File

@@ -0,0 +1,294 @@
// EdgeTTS (Microsoft Edge "Read Aloud") audio-tts provider (#6668).
//
// EdgeTTS is a reverse-engineered WebSocket endpoint with no API key, so
// there is no live upstream we can validate against in CI (Hard Rule #18
// TDD path). This suite covers everything that is a pure function: the
// Sec-MS-GEC token/HMAC construction, WS message framing, binary-chunk
// demuxing, SSML building/escaping, registry lookup, and the error path
// (mocked WebSocket failure -> sanitized error response, no stack leak).
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
computeSecMsGec,
buildConnectionId,
buildSpeechConfigMessage,
buildSsmlMessage,
buildSsml,
buildEdgeTtsWsUrl,
escapeSsmlText,
normalizeEdgeVoice,
isTurnEndMessage,
demuxAudioChunk,
synthesizeEdgeTts,
handleEdgeTtsSpeech,
type MinimalWebSocket,
} from "../../open-sse/executors/edgeTts.ts";
import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts";
import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts";
// ─── Sec-MS-GEC token (HMAC-ish SHA-256 construction) ──────────────────────
test("computeSecMsGec is deterministic for the same 5-minute window", () => {
const base = Date.UTC(2026, 6, 17, 12, 0, 0); // 2026-07-17T12:00:00Z
const a = computeSecMsGec(base);
const b = computeSecMsGec(base + 60_000); // +1 minute, same 5-minute bucket
assert.equal(a, b, "same rounded-down 5-minute window must hash identically");
assert.match(a, /^[0-9A-F]{64}$/, "output must be a 64-char uppercase hex SHA-256 digest");
});
test("computeSecMsGec changes across a 5-minute window boundary", () => {
const base = Date.UTC(2026, 6, 17, 12, 0, 0);
const before = computeSecMsGec(base - 1); // just before the 5-min bucket
const after = computeSecMsGec(base);
assert.notEqual(before, after);
});
test("computeSecMsGec matches the reference rany2/edge-tts algorithm shape", () => {
// Cross-check against a hand-computed reference vector for a fixed instant,
// using the same constants/algorithm documented in drm.py:
// ticks = floor((nowMs/1000 + 11644473600) - (... % 300)) * 1e7
// sha256(`${ticks}${TRUSTED_CLIENT_TOKEN}`).hexdigest().upper()
const nowMs = Date.UTC(2026, 0, 1, 0, 0, 0);
const winEpoch = 11644473600;
let ticks = nowMs / 1000 + winEpoch;
ticks -= ticks % 300;
ticks *= 1e7;
const token = resolvePublicCred("edgetts_token");
const expected = createHash("sha256")
.update(`${Math.floor(ticks)}${token}`, "ascii")
.digest("hex")
.toUpperCase();
assert.equal(computeSecMsGec(nowMs), expected);
});
// ─── publicCreds shape assertion (Hard Rule #11) ───────────────────────────
test("edgetts_token is embedded via resolvePublicCred, not a string literal", () => {
const token = resolvePublicCred("edgetts_token");
assert.equal(typeof token, "string");
assert.ok(token.length > 0, "embedded default must decode to a non-empty token");
// The well-known public trusted-client-token format used by every Edge
// build and every open-source edge-tts port: 32 uppercase hex chars.
assert.match(token, /^[0-9A-F]{32}$/);
});
test("resolvePublicCred('edgetts_token') is stable across repeated calls", () => {
// No envName is passed for this key (there's no legacy .env var to migrate
// from — it's a brand-new provider), so it must always resolve to the same
// embedded default rather than reading from process.env.
assert.equal(resolvePublicCred("edgetts_token"), resolvePublicCred("edgetts_token"));
});
// ─── Connection id / message framing ───────────────────────────────────────
test("buildConnectionId returns a 32-char lowercase hex id with no dashes", () => {
const id = buildConnectionId();
assert.match(id, /^[0-9a-f]{32}$/);
});
test("buildConnectionId is unique per call", () => {
const ids = new Set(Array.from({ length: 20 }, () => buildConnectionId()));
assert.equal(ids.size, 20);
});
test("buildSpeechConfigMessage frames a valid speech.config WS text message", () => {
const msg = buildSpeechConfigMessage("Tue, 01 Jan 2026 00:00:00 GMT");
assert.match(msg, /^X-Timestamp:Tue, 01 Jan 2026 00:00:00 GMT\r\n/);
assert.match(msg, /Content-Type:application\/json; charset=utf-8\r\n/);
assert.match(msg, /Path:speech\.config\r\n\r\n/);
const jsonPart = msg.slice(msg.indexOf("\r\n\r\n") + 4);
const parsed = JSON.parse(jsonPart);
assert.equal(
parsed.context.synthesis.audio.outputFormat,
"audio-24khz-48kbitrate-mono-mp3"
);
});
test("buildSsmlMessage frames a valid ssml WS text message carrying the SSML body", () => {
const ssml = buildSsml({ text: "hello" });
const msg = buildSsmlMessage("req-123", ssml, "Tue, 01 Jan 2026 00:00:00 GMT");
assert.match(msg, /^X-RequestId:req-123\r\n/);
assert.match(msg, /Content-Type:application\/ssml\+xml\r\n/);
assert.match(msg, /Path:ssml\r\n\r\n/);
assert.ok(msg.endsWith(ssml), "message must end with the exact SSML payload");
});
test("buildEdgeTtsWsUrl includes TrustedClientToken, Sec-MS-GEC, and ConnectionId", () => {
const url = new URL(buildEdgeTtsWsUrl(Date.UTC(2026, 6, 17)));
assert.equal(url.protocol, "wss:");
assert.equal(url.hostname, "speech.platform.bing.com");
assert.ok(url.searchParams.get("TrustedClientToken"));
assert.match(url.searchParams.get("Sec-MS-GEC") || "", /^[0-9A-F]{64}$/);
assert.match(url.searchParams.get("ConnectionId") || "", /^[0-9a-f]{32}$/);
});
// ─── SSML building / escaping (untrusted-input safety) ─────────────────────
test("escapeSsmlText escapes all five XML special characters", () => {
assert.equal(
escapeSsmlText(`<tag> & "quoted" 'single'`),
"&lt;tag&gt; &amp; &quot;quoted&quot; &apos;single&apos;"
);
});
test("buildSsml embeds escaped text and rejects SSML injection via prosody attrs", () => {
const ssml = buildSsml({
text: "</voice><voice name='evil'>pwned",
rate: "'; </prosody><script>alert(1)</script>",
});
assert.ok(!ssml.includes("<script>"), "malicious prosody rate must be clamped, not embedded");
assert.ok(ssml.includes("&lt;/voice&gt;"), "malicious text must be XML-escaped");
assert.ok(ssml.includes("rate='default'"), "invalid rate falls back to default");
});
test("normalizeEdgeVoice accepts well-formed voice names and rejects everything else", () => {
assert.equal(normalizeEdgeVoice("en-US-AriaNeural"), "en-US-AriaNeural");
assert.equal(normalizeEdgeVoice("pt-BR-FranciscaNeural"), "pt-BR-FranciscaNeural");
assert.equal(normalizeEdgeVoice("not a voice; DROP TABLE"), "en-US-AriaNeural");
assert.equal(normalizeEdgeVoice(undefined), "en-US-AriaNeural");
});
// ─── Binary audio chunk demux (pure, no live socket needed) ────────────────
test("demuxAudioChunk splits a binary frame into headers + raw audio bytes", () => {
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
const headerBuf = Buffer.from(headers, "ascii");
const audioBuf = Buffer.from([1, 2, 3, 4, 5]);
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(headerBuf.length, 0);
const frame = Buffer.concat([lenBuf, headerBuf, audioBuf]);
const result = demuxAudioChunk(frame);
assert.ok(result);
assert.equal(result!.headers, headers);
assert.deepEqual(Array.from(result!.audio), [1, 2, 3, 4, 5]);
});
test("demuxAudioChunk returns null for a truncated/malformed frame", () => {
assert.equal(demuxAudioChunk(Buffer.from([0])), null);
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(100, 0); // claims 100 header bytes but frame is short
assert.equal(demuxAudioChunk(Buffer.concat([lenBuf, Buffer.from("short")])), null);
});
test("isTurnEndMessage recognizes the turn.end marker and nothing else", () => {
assert.equal(isTurnEndMessage("X-RequestId:abc\r\nPath:turn.end\r\n\r\n"), true);
assert.equal(isTurnEndMessage("Path:turn.start\r\n\r\n"), false);
assert.equal(isTurnEndMessage(""), false);
});
// ─── Registry wiring ────────────────────────────────────────────────────────
test("edgetts is registered in AUDIO_SPEECH_PROVIDERS with no-key WS transport", () => {
const provider = getSpeechProvider("edgetts");
assert.ok(provider);
assert.equal(provider!.authType, "none");
assert.equal(provider!.format, "edgetts");
assert.match(provider!.baseUrl, /^wss:\/\//);
assert.ok(provider!.models.length > 0);
});
test("parseSpeechModel resolves 'edgetts/<voice>' to the edgetts provider", () => {
const { provider, model } = parseSpeechModel("edgetts/en-US-AriaNeural");
assert.equal(provider, "edgetts");
assert.equal(model, "en-US-AriaNeural");
});
// ─── synthesizeEdgeTts / handleEdgeTtsSpeech with an injected fake WebSocket ─
class FakeEmitterSocket implements MinimalWebSocket {
private listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
sent: string[] = [];
closed = false;
on(event: string, listener: (...args: unknown[]) => void) {
(this.listeners[event] ??= []).push(listener);
}
send(data: string) {
this.sent.push(data);
}
close() {
this.closed = true;
}
emit(event: string, ...args: unknown[]) {
for (const l of this.listeners[event] || []) l(...args);
}
}
test("synthesizeEdgeTts resolves with concatenated audio on turn.end", async () => {
let socket: FakeEmitterSocket;
const Ctor = function (this: unknown, _url: string) {
socket = new FakeEmitterSocket();
// Simulate the server's protocol asynchronously after `open` is sent.
queueMicrotask(() => {
socket.emit("open");
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
const headerBuf = Buffer.from(headers, "ascii");
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(headerBuf.length, 0);
const frame = Buffer.concat([lenBuf, headerBuf, Buffer.from("audiobytes")]);
socket.emit("message", frame, true);
socket.emit("message", "Path:turn.end\r\n\r\n", false);
});
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
const result = await synthesizeEdgeTts({ text: "hello" }, Ctor);
assert.equal(result.audio.toString(), "audiobytes");
assert.equal(result.contentType, "audio/mpeg");
});
test("synthesizeEdgeTts rejects when the socket errors", async () => {
const Ctor = function (this: unknown, _url: string) {
const socket = new FakeEmitterSocket();
queueMicrotask(() => socket.emit("error", new Error("upstream refused connection")));
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
await assert.rejects(
() => synthesizeEdgeTts({ text: "hello" }, Ctor),
/upstream refused connection/
);
});
test("handleEdgeTtsSpeech returns 400 without touching the network on empty input", async () => {
const response = await handleEdgeTtsSpeech({ input: "" });
assert.equal(response.status, 400);
const bodyJson = await response.json();
assert.equal(bodyJson.error.message, "input is required");
});
test("handleEdgeTtsSpeech returns a sanitized 502 on upstream WS failure (Hard Rule #12)", async () => {
const Ctor = function (this: unknown, _url: string) {
const socket = new FakeEmitterSocket();
// Simulate a raw upstream failure that could contain a stack trace or an
// absolute filesystem path — the handler must never leak it verbatim.
const err = new Error("connect ECONNREFUSED 127.0.0.1:443");
(err as Error).stack = `Error: connect ECONNREFUSED\n at /home/user/secret/app.js:42:10`;
queueMicrotask(() => socket.emit("error", err));
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
const response = await handleEdgeTtsSpeech({ input: "hello" }, null, Ctor);
assert.equal(response.status, 502);
const bodyJson = await response.json();
assert.ok(bodyJson.error.message.includes("ECONNREFUSED"));
assert.ok(!bodyJson.error.message.includes("/home/user/secret"), "must not leak a filesystem path");
assert.ok(!bodyJson.error.message.includes("at /"), "must not leak a stack trace frame");
});
test("handleEdgeTtsSpeech enforces the per-IP sliding-window rate limit", async () => {
const ip = `203.0.113.${Math.floor(Math.random() * 250) + 1}`;
// Drain the window with the input-validation fast-path (still exercises
// tryAcquire before validation) using an always-invalid body to avoid a
// real network call, then assert the last call is 429, not 400.
let last: Response | undefined;
for (let i = 0; i < 21; i++) {
last = await handleEdgeTtsSpeech({ input: "" }, ip);
}
assert.equal(last!.status, 429);
const bodyJson = await last!.json();
assert.match(bodyJson.error.message, /rate limit/i);
});

View File

@@ -1,52 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
AUDIO_SPEECH_PROVIDERS,
getAllAudioModels,
getSpeechProvider,
parseSpeechModel,
} from "../../open-sse/config/audioRegistry.ts";
import { getRegistryMediaKinds } from "../../open-sse/config/mediaServiceKinds.ts";
import { handleAudioSpeech } from "../../open-sse/handlers/audioSpeech.ts";
import { toProviderModels } from "../../src/app/(dashboard)/dashboard/cache/media/mediaProviderModels.ts";
test("retired EdgeTTS is absent from every published speech catalog", () => {
assert.equal(getSpeechProvider("edgetts"), null);
assert.deepEqual(parseSpeechModel("edgetts/en-US-AriaNeural"), {
provider: null,
model: "edgetts/en-US-AriaNeural",
});
assert.equal(Object.hasOwn(AUDIO_SPEECH_PROVIDERS, "edgetts"), false);
assert.equal(
getAllAudioModels().some((model) => model.provider === "edgetts"),
false
);
assert.deepEqual(getRegistryMediaKinds("edgetts"), []);
assert.equal(
toProviderModels(AUDIO_SPEECH_PROVIDERS).some((provider) => provider.id === "edgetts"),
false
);
});
test("speech requests no longer advertise or dispatch EdgeTTS", async () => {
const response = await handleAudioSpeech({
body: { model: "edgetts/en-US-AriaNeural", input: "hello" },
credentials: null,
});
const payload = (await response.json()) as { error: { message: string } };
assert.equal(response.status, 400);
assert.match(payload.error.message, /No speech provider found/);
const available = payload.error.message.split("Available:")[1] || "";
assert.doesNotMatch(available, /edgetts/i);
for (const control of ["elevenlabs", "aws-polly", "gtts"]) {
assert.match(available, new RegExp(`\\b${control}\\b`));
}
});
test("retiring EdgeTTS preserves the supported speech providers", () => {
for (const control of ["gtts", "aws-polly", "elevenlabs"]) {
assert.ok(getSpeechProvider(control), `${control} must remain registered`);
}
});

View File

@@ -1,115 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fixTlsClientNodeBinary } from "../../scripts/build/fixTlsClientNodeBinary.mjs";
function makeRoot() {
return mkdtempSync(join(tmpdir(), "fix-tls-client-node-binary-7802-"));
}
function collectLogs() {
const logs: string[] = [];
return { logs, log: (m: string) => logs.push(m) };
}
test("no-ops when node_modules/tls-client-node is absent (module not installed)", async () => {
const rootDir = makeRoot();
try {
const { logs, log } = collectLogs();
await fixTlsClientNodeBinary({ rootDir, log });
assert.deepEqual(logs, []);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
test("copies an already-populated root bin/ into the standalone dist bundle (#7802 item 2)", async () => {
const rootDir = makeRoot();
try {
const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin");
mkdirSync(rootBin, { recursive: true });
writeFileSync(join(rootBin, "tls-client-linux-ubuntu-amd64-1.0.0.so"), "fake-binary");
const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node");
mkdirSync(distTlsClientDir, { recursive: true });
const { log } = collectLogs();
await fixTlsClientNodeBinary({ rootDir, log });
const distBin = join(distTlsClientDir, "bin");
assert.ok(existsSync(distBin), "dist bin/ should have been created");
assert.deepEqual(readdirSync(distBin), ["tls-client-linux-ubuntu-amd64-1.0.0.so"]);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
test("retries the download when root bin/ is empty, and stops once a file appears (#7802 item 3)", async () => {
const rootDir = makeRoot();
try {
const tlsClientDir = join(rootDir, "node_modules", "tls-client-node");
const rootBin = join(tlsClientDir, "bin");
mkdirSync(rootBin, { recursive: true });
const scriptsDir = join(tlsClientDir, "scripts");
mkdirSync(scriptsDir, { recursive: true });
// A postinstall.js stand-in that drops a file into bin/ on its 2nd invocation —
// simulating a first attempt eaten by a GitHub rate-limit and a 2nd that recovers.
writeFileSync(
join(scriptsDir, "postinstall.js"),
`const fs = require("fs");
const path = require("path");
const marker = path.join(__dirname, "..", ".attempts");
const attempts = fs.existsSync(marker) ? Number(fs.readFileSync(marker, "utf8")) : 0;
fs.writeFileSync(marker, String(attempts + 1));
if (attempts + 1 >= 2) {
fs.writeFileSync(path.join(__dirname, "..", "bin", "tls-client-linux-ubuntu-amd64-1.0.0.so"), "ok");
}`
);
const { logs, log } = collectLogs();
await fixTlsClientNodeBinary({ rootDir, log, retryDelaysMs: [1, 1, 1] });
assert.ok(existsSync(join(rootBin, "tls-client-linux-ubuntu-amd64-1.0.0.so")));
assert.ok(
logs.some((m) => m.includes("fetched successfully")),
"expected a success log once the retry recovered"
);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
test("warns without throwing when every retry leaves bin/ empty (still rate-limited)", async () => {
const rootDir = makeRoot();
try {
const tlsClientDir = join(rootDir, "node_modules", "tls-client-node");
mkdirSync(join(tlsClientDir, "bin"), { recursive: true });
const scriptsDir = join(tlsClientDir, "scripts");
mkdirSync(scriptsDir, { recursive: true });
// A postinstall.js stand-in that always fails to produce a binary (persistent rate-limit).
writeFileSync(join(scriptsDir, "postinstall.js"), `process.exitCode = 0;`);
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (m: string) => warnings.push(m);
try {
const { log } = collectLogs();
await assert.doesNotReject(
fixTlsClientNodeBinary({ rootDir, log, retryDelaysMs: [1, 1] })
);
} finally {
console.warn = originalWarn;
}
assert.ok(
warnings.some((m) => m.includes("Could not fetch tls-client-node")),
"expected a clear warning pointing at the manual fix, not a silent no-op"
);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});

View File

@@ -252,6 +252,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
"bin/mcp-server.mjs",
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"config/release/wreq-js-native-manifest.json",
"dist/head-response-guard.cjs",
"dist/http-method-guard.cjs",
"dist/main-server-timeouts.mjs",
@@ -266,9 +267,9 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
"dist/tls-options.mjs",
"dist/webdav-handler.mjs",
"scripts/build/colocateOptionals.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/native-binary-compat.mjs",
"scripts/build/runtime-env.mjs",
"scripts/build/wreqJsNative.mjs",
"scripts/packs/optionalPackInstaller.mjs",
"scripts/packs/optionalPackManifest.mjs",
"src/shared/utils/nodeRuntimeSupport.ts",

View File

@@ -1,25 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
// Issue #7134 — claude-web reported "Claude Web API error (400) with no
// response body" even when Claude's upstream DID send a real JSON error body.
//
// Root cause: tlsFetchStreaming() streams the upstream response to a temp
// file via tls-client-node's `streamOutputPath` mode. For a non-SSE,
// non-2xx response, the native binding resolves with an EMPTY in-memory
// `body` field (it only populates `body` for its non-streaming mode) even
// though the real error bytes were already written to the temp file and
// even peeked (`looksLikeSse`) to decide the response wasn't SSE. The old
// code read the empty `r.body` instead of the file it just peeked, throwing
// away the real upstream error detail.
// The browser transport must peek a requested stream to distinguish SSE from
// an upstream JSON error. Once it decides the response is not SSE, it must
// buffer the same native body stream without discarding the bytes it peeked.
//
// This test injects a fake `client` (matching the `{ request }` shape
// tlsFetchStreaming already accepts for DI) that reproduces the exact
// tls-client-node contract under `streamOutputPath`: write bytes to the file,
// resolve with an empty `body`. No `--experimental-test-module-mocks` flag
// needed — this exercises the real, unmodified `tlsFetchStreaming` via
// dependency injection instead of module-mocking `tls-client-node`.
// tlsFetchStreaming accepts for DI). No experimental module mocks are needed:
// the test exercises the production wreq response-stream path directly.
const { tlsFetchStreaming } = await import("../../open-sse/services/claudeTlsClient.ts");
@@ -33,21 +24,16 @@ const REAL_CLAUDE_ERROR_BODY = JSON.stringify({
function makeFakeClient(status: number, bodyOnFile: string) {
return {
request: async (_url: string, opts: Record<string, unknown>) => {
const streamOutputPath = opts.streamOutputPath as string;
await writeFile(streamOutputPath, bodyOnFile);
return {
status,
headers: {},
// tls-client-node does not populate `body` for streamed requests —
// this is the exact defect condition.
body: "",
cookies: {},
text: async () => "",
json: async () => ({}),
bytes: async () => new Uint8Array(),
};
},
request: async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(bodyOnFile));
controller.close();
},
}),
{ status }
),
};
}
@@ -73,19 +59,11 @@ test("issue #7134: tlsFetchStreaming surfaces the real error body for a non-SSE
test("issue #7134: tlsFetchStreaming still uses r.body when the native client DOES populate it", async () => {
const client = {
request: async (_url: string, opts: Record<string, unknown>) => {
const streamOutputPath = opts.streamOutputPath as string;
await writeFile(streamOutputPath, "{}");
return {
status: 403,
headers: {},
body: "populated body from native client",
cookies: {},
text: async () => "",
json: async () => ({}),
bytes: async () => new Uint8Array(),
};
},
request: async () => ({
status: 403,
headers: {},
body: "populated body from native client",
}),
};
const result = await tlsFetchStreaming(

View File

@@ -16,7 +16,6 @@ const { __setTlsFetchOverrideForTesting: __setPplxTlsFetchOverride } =
const { __setTlsFetchOverrideForTesting: __setGrokTlsFetchOverride } =
await import("../../open-sse/services/grokTlsClient.ts");
const originalFetch = globalThis.fetch;
test.afterEach(() => {
@@ -2458,7 +2457,7 @@ test("claude-web validator: 500 → Claude.ai unavailable", async () => {
test("claude-web validator: TLS client unavailable → clear error", async () => {
const { TlsClientUnavailableError } = await import("../../open-sse/services/claudeTlsClient.ts");
__setClaudeTlsFetchOverride(async () => {
throw new TlsClientUnavailableError("tls-client-node not installed");
throw new TlsClientUnavailableError("wreq-js 3.0.0 native addon unavailable");
});
const result = await validateProviderApiKey({
@@ -2467,7 +2466,7 @@ test("claude-web validator: TLS client unavailable → clear error", async () =>
});
assert.equal(result.valid, false);
assert.match(result.error || "", /tls-client-node not installed/i);
assert.match(result.error || "", /wreq-js 3\.0\.0 native addon unavailable/i);
__setClaudeTlsFetchOverride(null);
});

View File

@@ -1,81 +0,0 @@
import { test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const TLS_CLIENT_WRAPPERS = [
"open-sse/services/chatgptTlsClient.ts",
"open-sse/services/claudeTlsClient.ts",
"open-sse/services/grokTlsClient.ts",
"open-sse/services/perplexityTlsClient.ts",
"open-sse/services/lmarenaTlsClient.ts",
"open-sse/services/notionTlsClient.ts",
] as const;
const originalDataDir = process.env.DATA_DIR;
afterEach(() => {
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
});
test("resolveTlsClientDownloadDir caches native binary under DATA_DIR/tls-client/bin (#8579)", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-8579-"));
process.env.DATA_DIR = dataDir;
const { resolveTlsClientDownloadDir } =
await import("../../open-sse/services/tlsClientDownloadDir.ts");
assert.equal(resolveTlsClientDownloadDir(), join(dataDir, "tls-client", "bin"));
});
test("buildNativeTlsClientOptions passes downloadDir to tls-client-node (#8579)", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-opts-8579-"));
process.env.DATA_DIR = dataDir;
const { buildNativeTlsClientOptions } =
await import("../../open-sse/services/tlsClientDownloadDir.ts");
const options = buildNativeTlsClientOptions();
assert.equal(options.runtimeMode, "native");
assert.equal(options.downloadDir, join(dataDir, "tls-client", "bin"));
});
test("all web-provider tls clients wire downloadDir through buildNativeTlsClientOptions (#8579)", () => {
const base = readFileSync(join(ROOT, "open-sse/services/tlsClientBase.ts"), "utf8");
assert.match(
base,
/buildNativeTlsClientOptions\(\)/,
"tlsClientBase.ts must pass buildNativeTlsClientOptions() to TLSClient"
);
assert.doesNotMatch(
base,
/new TLSClient\(\{\s*runtimeMode:\s*"native"\s*\}\)/,
"tlsClientBase.ts must not construct TLSClient without downloadDir"
);
for (const relPath of TLS_CLIENT_WRAPPERS) {
const source = readFileSync(join(ROOT, relPath), "utf8");
assert.match(
source,
/createTlsClientModule\(/,
`${relPath} must go through createTlsClientModule so downloadDir is inherited`
);
assert.doesNotMatch(
source,
/new TLSClient\(\{\s*runtimeMode:\s*"native"\s*\}\)/,
`${relPath} must not construct TLSClient without downloadDir`
);
}
});

View File

@@ -1,48 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
test("Dockerfile's --ignore-scripts npm ci is compensated for tls-client-node's native binary, same as it is for wreq-js and better-sqlite3 (#7802)", () => {
const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8");
const postinstall = readFileSync(join(ROOT, "scripts/build/postinstall.mjs"), "utf8");
assert.match(
dockerfile,
// Flag-order tolerant on purpose: the assertion is about the --ignore-scripts
// PRECONDITION, not the exact flag list. #9185 inserted --include=optional
// (LLMLingua optional deps) and broke the literal pin without touching intent.
/npm ci(?: --[\w-]+(?:=[\w-]+)?)* --ignore-scripts/,
"expected the builder stage to install with --ignore-scripts (precondition of #7802)"
);
assert.match(
dockerfile,
/better-sqlite3[\s\S]*node-gyp\.js rebuild/,
"expected an explicit better-sqlite3 rebuild step after --ignore-scripts"
);
assert.match(
postinstall,
/fixWreqJsBinary/,
"expected postinstall.mjs to repair wreq-js's native binary"
);
const dockerfileHandlesIt = /tls-client-node[\s\S]{0,200}(postinstall|rebuild|download)/i.test(
dockerfile
);
const postinstallHandlesIt = /tls-client-node/i.test(postinstall);
assert.ok(
dockerfileHandlesIt || postinstallHandlesIt,
"tls-client-node has no --ignore-scripts compensation in Dockerfile or " +
"scripts/build/postinstall.mjs (unlike better-sqlite3 and wreq-js) — " +
"node_modules/tls-client-node/bin/ is never populated in the official " +
"Docker image, so chatgpt-web/claude-web/grok-web/lmarena/perplexity-web " +
"all fail with TlsClientUnavailableError at first request (#7802)"
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
test("the distributable is pinned to wreq-js 3.0.0 with no tls-client-node or orphaned koffi residue", () => {
const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
files: string[];
optionalDependencies: Record<string, string>;
};
assert.equal(packageJson.optionalDependencies["wreq-js"], "3.0.0");
assert.equal(packageJson.optionalDependencies["tls-client-node"], undefined);
assert.equal(packageJson.files.includes("scripts/build/fixTlsClientNodeBinary.mjs"), false);
for (const relativePath of [
"package-lock.json",
"next.config.mjs",
"Dockerfile",
"Dockerfile.bun",
"pnpm.json",
"pnpm-workspace.yaml",
"config/quality/dependency-allowlist.json",
"config/quality/.license-allowlist.json",
"scripts/build/postinstall.mjs",
"scripts/build/pack-artifact-policy.ts",
]) {
const source = readFileSync(join(ROOT, relativePath), "utf8");
assert.doesNotMatch(
source,
/tls-client-node/i,
`${relativePath} still references tls-client-node`
);
assert.doesNotMatch(source, /\bkoffi\b/i, `${relativePath} still references orphaned koffi`);
}
assert.equal(existsSync(join(ROOT, "open-sse/services/tlsClientDownloadDir.ts")), false);
assert.equal(existsSync(join(ROOT, "scripts/build/fixTlsClientNodeBinary.mjs")), false);
for (const relativePath of [
".env.example",
"docs/reference/ENVIRONMENT.md",
"docs/security/STEALTH_GUIDE.md",
]) {
const source = readFileSync(join(ROOT, relativePath), "utf8");
assert.doesNotMatch(
source,
/OMNIROUTE_TLS_PROXY_URL/,
`${relativePath} still documents the removed sidecar override`
);
}
});

View File

@@ -4,7 +4,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// #5591 regression guard: every chrome_* TLS impersonation profile referenced in
// #5591 regression guard: every TLS impersonation profile referenced in
// the source must be a real wreq-js BrowserProfile. PR #5237 set them to
// "chrome_149", which does not exist in wreq-js 2.3.1 (the union tops out at
// chrome_147) — the native layer then produced a degenerate fingerprint and the
@@ -19,15 +19,24 @@ function supportedProfiles() {
path.join(ROOT, "node_modules", "wreq-js", "dist", "wreq-js.d.ts"),
"utf8"
);
return new Set([...dts.matchAll(/chrome_(\d+)/g)].map((m) => `chrome_${m[1]}`));
const union = dts.match(/type BrowserProfile = ([^;]+);/)?.[1] ?? "";
return new Set([...union.matchAll(/'([^']+)'/g)].map((match) => match[1]));
}
// Source files that hand a `browser`/PROFILE value to wreq-js.
const PROVIDER_PROFILES = {
"open-sse/services/chatgptTlsClient.ts": ["firefox_148", "macos"],
"open-sse/services/claudeTlsClient.ts": ["chrome_146", "linux"],
"open-sse/services/perplexityTlsClient.ts": ["firefox_148", "macos"],
"open-sse/services/grokTlsClient.ts": ["chrome_146", "linux"],
"open-sse/services/notionTlsClient.ts": ["chrome_146", "windows"],
"open-sse/services/lmarenaTlsClient.ts": ["chrome_146", "windows"],
};
// Other source files that hand a browser profile directly to wreq-js.
const SOURCES = [
"src/app/api/internal/codex-responses-ws/route.ts",
"scripts/dev/responses-ws-proxy.mjs",
"open-sse/services/grokTlsClient.ts",
"open-sse/services/claudeTlsClient.ts",
...Object.keys(PROVIDER_PROFILES),
];
// Strip comments before scanning — explanatory comments may name the bad
@@ -36,16 +45,16 @@ function stripComments(line) {
return line.replace(/\/\*.*?\*\//g, "").replace(/\/\/.*$/, "");
}
test("#5591 all configured chrome_* TLS profiles exist in wreq-js", () => {
test("#5591 all configured browser TLS profiles exist in pinned wreq-js", () => {
const supported = supportedProfiles();
assert.ok(supported.size > 0, "expected to parse chrome_* profiles from wreq-js d.ts");
assert.ok(supported.size > 0, "expected to parse BrowserProfile from wreq-js d.ts");
for (const rel of SOURCES) {
const lines = fs.readFileSync(path.join(ROOT, rel), "utf8").split("\n");
lines.forEach((line, i) => {
const code = stripComments(line);
for (const m of code.matchAll(/chrome_(\d+)/g)) {
const profile = `chrome_${m[1]}`;
for (const m of code.matchAll(/\b(?:chrome|firefox|edge|opera|safari|okhttp)_[\w.]+/g)) {
const profile = m[0];
assert.ok(
supported.has(profile),
`${rel}:${i + 1} uses ${profile} which is NOT a wreq-js BrowserProfile ` +
@@ -54,4 +63,16 @@ test("#5591 all configured chrome_* TLS profiles exist in wreq-js", () => {
}
});
}
for (const [rel, [profile, os]] of Object.entries(PROVIDER_PROFILES)) {
const source = fs.readFileSync(path.join(ROOT, rel), "utf8");
assert.ok(supported.has(profile), `${rel} expected unsupported ${profile}`);
if (rel.endsWith("claudeTlsClient.ts")) {
assert.match(source, /CLAUDE_TLS_BROWSER_MAJOR_VERSION = "146"/);
assert.match(source, /tlsProfile: `chrome_\$\{CLAUDE_TLS_BROWSER_MAJOR_VERSION\}`/);
} else {
assert.match(source, new RegExp(`tlsProfile: ["']${profile}["']`));
}
assert.match(source, new RegExp(`emulationOs: ["']${os}["']`));
}
});

View File

@@ -19,9 +19,9 @@ const { default: AddApiKeyModal } =
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal");
const TLS_EACCES_ERROR =
"TLS impersonation client failed to start: EACCES: permission denied, mkdir " +
"'/usr/lib/node_modules/omniroute/dist/node_modules/tls-client-node/bin'. " +
"Verify tls-client-node is installed and its native binary downloaded. " +
"TLS impersonation client failed to start: EACCES: permission denied, open " +
"'/usr/lib/node_modules/omniroute/dist/node_modules/wreq-js/rust'. " +
"Verify wreq-js 3.0.0 and its platform addon are installed. " +
"(claude-web requires this — without it, Cloudflare blocks every request)";
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
@@ -32,7 +32,12 @@ function render(props: Record<string, unknown>) {
const root = createRoot(el);
act(() => {
root.render(
<AddApiKeyModal isOpen onSave={async () => undefined} onClose={() => {}} {...(props as any)} />
<AddApiKeyModal
isOpen
onSave={async () => undefined}
onClose={() => {}}
{...(props as any)}
/>
);
});
containers.push({ root, el });
@@ -68,7 +73,10 @@ beforeEach(() => {
json: () => Promise.resolve({ valid: false, error: TLS_EACCES_ERROR }),
} as Response);
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ valid: true }),
} as Response);
})
);
});
@@ -92,8 +100,7 @@ describe("AddApiKeyModal — surfaces the detailed validation error (#5088)", ()
// The validate ("check") button is the first button that follows the
// credential input in DOM order (it sits right next to it).
const checkBtn = Array.from(el.querySelectorAll("button")).find(
(b) =>
(apiKeyInput.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
(b) => (apiKeyInput.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
)!;
expect(checkBtn).toBeTruthy();
act(() => {

View File

@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const ROOT = process.cwd();
const MANIFEST_PATH = join(ROOT, "config/release/wreq-js-native-manifest.json");
interface NativeEntry {
path: string;
size: number;
sha256: string;
}
interface NativeManifest {
package: string;
version: string;
npmIntegrity: string;
license: string;
nativeAddons: NativeEntry[];
}
test("wreq-js 3.0.0 has an exact seven-platform native hash manifest and notice", () => {
assert.equal(existsSync(MANIFEST_PATH), true, "native manifest must be committed");
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as NativeManifest;
assert.equal(manifest.package, "wreq-js");
assert.equal(manifest.version, "3.0.0");
assert.equal(
manifest.npmIntegrity,
"sha512-RZCoRSevVPpH4A4B4MxbFGo/pVPFveWd2gbe4ENKpPWlKXEYklZSDESOjBMmrIsmnkHh+nhM4PNJvG+NL7wBPA=="
);
assert.equal(manifest.license, "MIT");
assert.equal(manifest.nativeAddons.length, 7);
assert.deepEqual(manifest.nativeAddons.map((entry) => entry.path).sort(), [
"rust/wreq-js.darwin-arm64.node",
"rust/wreq-js.darwin-x64.node",
"rust/wreq-js.linux-arm64-gnu.node",
"rust/wreq-js.linux-arm64-musl.node",
"rust/wreq-js.linux-x64-gnu.node",
"rust/wreq-js.linux-x64-musl.node",
"rust/wreq-js.win32-x64-msvc.node",
]);
const packageRoot = join(ROOT, "node_modules/wreq-js");
const installedPackage = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
version: string;
};
assert.equal(installedPackage.version, manifest.version);
for (const entry of manifest.nativeAddons) {
const bytes = readFileSync(join(packageRoot, entry.path));
assert.equal(bytes.byteLength, entry.size, `${entry.path}: byte size`);
assert.equal(
createHash("sha256").update(bytes).digest("hex"),
entry.sha256,
`${entry.path}: sha256`
);
}
const notices = readFileSync(join(ROOT, "THIRD_PARTY_NOTICES.md"), "utf8");
assert.match(notices, /^## wreq-js 3\.0\.0$/m);
assert.match(notices, /Copyright \(c\) 2025 will-work-for-meal/);
assert.match(notices, /Copyright \(c\) 2025 Oleksandr Herasymov/);
});

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
WREQ_JS_NATIVE_BINARY_NAMES,
resolveWreqJsNativeBinaryName,
} from "../../scripts/build/wreqJsNative.mjs";
test("wreq-js 3.0.0 resolver covers every shipped native addon name", () => {
assert.deepEqual([...WREQ_JS_NATIVE_BINARY_NAMES].sort(), [
"wreq-js.darwin-arm64.node",
"wreq-js.darwin-x64.node",
"wreq-js.linux-arm64-gnu.node",
"wreq-js.linux-arm64-musl.node",
"wreq-js.linux-x64-gnu.node",
"wreq-js.linux-x64-musl.node",
"wreq-js.win32-x64-msvc.node",
]);
assert.equal(
resolveWreqJsNativeBinaryName({ platform: "darwin", arch: "arm64" }),
"wreq-js.darwin-arm64.node"
);
assert.equal(
resolveWreqJsNativeBinaryName({ platform: "darwin", arch: "x64" }),
"wreq-js.darwin-x64.node"
);
assert.equal(
resolveWreqJsNativeBinaryName({ platform: "linux", arch: "arm64", libc: "gnu" }),
"wreq-js.linux-arm64-gnu.node"
);
assert.equal(
resolveWreqJsNativeBinaryName({ platform: "linux", arch: "arm64", libc: "musl" }),
"wreq-js.linux-arm64-musl.node"
);
assert.equal(
resolveWreqJsNativeBinaryName({ platform: "linux", arch: "x64", libc: "gnu" }),
"wreq-js.linux-x64-gnu.node"
);
assert.equal(
resolveWreqJsNativeBinaryName({ platform: "linux", arch: "x64", libc: "musl" }),
"wreq-js.linux-x64-musl.node"
);
assert.equal(
resolveWreqJsNativeBinaryName({ platform: "win32", arch: "x64" }),
"wreq-js.win32-x64-msvc.node"
);
assert.equal(resolveWreqJsNativeBinaryName({ platform: "win32", arch: "arm64" }), null);
assert.equal(resolveWreqJsNativeBinaryName({ platform: "android", arch: "arm64" }), null);
});