mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-27 09:32:11 +03:00
Compare commits
2 Commits
fix/v3850-
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d3702a37 | ||
|
|
cb623b3cef |
48
.env.example
48
.env.example
@@ -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.
|
||||
|
||||
2
.github/workflows/electron-release.yml
vendored
2
.github/workflows/electron-release.yml
vendored
@@ -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
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
1
changelog.d/maintenance/11753-wreq-js-transport.md
Normal file
1
changelog.d/maintenance/11753-wreq-js-transport.md
Normal 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)).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
"sqlite-vec",
|
||||
"tailwind-merge",
|
||||
"tailwindcss",
|
||||
"tls-client-node",
|
||||
"turndown",
|
||||
"turndown-plugin-gfm",
|
||||
"tsup",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
44
config/release/wreq-js-native-manifest.json
Normal file
44
config/release/wreq-js-native-manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -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`). |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
352
open-sse/executors/edgeTts.ts
Normal file
352
open-sse/executors/edgeTts.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** 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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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
50
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,13 +6,11 @@
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"keytar",
|
||||
"koffi",
|
||||
"libxmljs2",
|
||||
"omniroute",
|
||||
"onnxruntime-node",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"tls-client-node",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
37
scripts/build/wreqJsNative.mjs
Normal file
37
scripts/build/wreqJsNative.mjs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
294
tests/unit/edgetts-provider.test.ts
Normal file
294
tests/unit/edgetts-provider.test.ts
Normal 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'`),
|
||||
"<tag> & "quoted" 'single'"
|
||||
);
|
||||
});
|
||||
|
||||
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("</voice>"), "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);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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)"
|
||||
);
|
||||
});
|
||||
1078
tests/unit/tls-client-wreq-migration.test.ts
Normal file
1078
tests/unit/tls-client-wreq-migration.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
54
tests/unit/tls-client-wreq-residue.test.ts
Normal file
54
tests/unit/tls-client-wreq-residue.test.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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}["']`));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
64
tests/unit/wreq-native-manifest.test.ts
Normal file
64
tests/unit/wreq-native-manifest.test.ts
Normal 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/);
|
||||
});
|
||||
49
tests/unit/wreq-postinstall-native.test.ts
Normal file
49
tests/unit/wreq-postinstall-native.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user