fix(providers): migrate web cookie TLS transport to wreq-js (#12429)

Migrates the Claude, Grok, LMArena, Notion and Perplexity web-cookie transports from the tls-client-node/Koffi sidecar to the exactly pinned wreq-js 3.2.0 runtime, keeping the per-provider browser/OS profiles, making request cookies ephemeral, bounding and generation-protecting the shared native transport pool, removing the legacy downloader and repair path, and carrying the native binding and license evidence through the npm, standalone, Electron, Docker and Bun packaging surfaces.

This is the consolidation of the two competing migrations, and the consolidation was decided by evidence rather than by preference. #11753's six suites were installed over this implementation and run as an independent specification: 31 of 36 passed. All five failures are artefacts of #11753 being the older design, not coverage gaps —

- two hardcode the 3.0.0 pin in their assertions (this branch pins 3.2.0, which is what the release tip already resolves; #11753's 3.0.0 would have conflicted);
- one reads open-sse/services/chatgptTlsClient.ts, deleted when #11754 retired ChatGPT Web, so the test is stale against the current tip;
- two import WREQ_JS_NATIVE_BINARY_NAMES / resolveWreqJsNativeBinaryName, which this branch redesigned into WREQ_JS_NATIVE_BINDINGS / resolveWreqJsNativeBinding plus WREQ_JS_VERSION — a rename from modelling natives as file names to modelling them as package bindings, verified as an API difference rather than a lost capability (the linux-x64-gnu .node is present and serviceable).

This branch is also the strict superset by scope: 7 files exclusive to it, including the wreq-js Rust license inventory and notices, .trivyignore, open-sse/utils/tlsClient.ts and assembleStandalone.mjs. #11753 had one exclusive file, its changelog fragment. Nothing needed porting, so #11753 is superseded rather than merged, and the changelog entry credits both.

Reconciled on merge: clean against the tip. The new migration suite (tests/unit/tls-client-wreq-migration.test.ts, 1374 lines, 31 cases) is frozen at its exact LOC with the rationale — it shares one native-transport harness, so splitting it mid-merge would duplicate that harness for no coverage gain. Verified that no existing cap moves.

Verified: 182/182 across the eight TLS, native-manifest, postinstall, standalone-bundle, pack-artifact and provider-validation suites, typecheck:core clean, check:cycles OK, check-changelog-integrity OK, check-file-size OK, and every changed TypeScript file parses.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-02 10:41:00 -03:00
committed by GitHub
parent 7ed8ada432
commit 500568a1cd
60 changed files with 16506 additions and 1566 deletions

View File

@@ -1466,17 +1466,15 @@ CURSOR_USER_AGENT="Cursor/3.4"
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev
# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s)
# ── 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 TLS transport (Chromium-fingerprinted client) ──
# Used by: open-sse/services/claudeTlsClient.ts — native wreq-js request timeout
# plus the absolute JS hard-deadline grace when the native request is wedged.
# 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 TLS transport (Firefox-fingerprinted client) ──
# Used by: open-sse/services/perplexityTlsClient.ts — native wreq-js request
# timeout plus the absolute JS hard-deadline grace.
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
@@ -1488,18 +1486,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 TLS transport (Chrome-fingerprinted client) ──
# Used by: open-sse/services/grokTlsClient.ts — native wreq-js request timeout
# plus the absolute JS hard-deadline grace.
# 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 TLS transport (Chrome-fingerprinted client) ──
# Used by: open-sse/services/notionTlsClient.ts — native wreq-js request timeout
# plus the absolute JS hard-deadline grace. 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

View File

@@ -187,6 +187,22 @@ jobs:
env:
NPM_CONFIG_LEGACY_PEER_DEPS: true
# The Linux leg produces x64 + arm64 installers from one x64 runner. npm
# deliberately installs only host-compatible optional dependencies, so
# hydrateNativeDeps cannot source the arm64 fork unless we fetch the exact
# package pinned in package-lock before either build path runs.
- name: Install Linux arm64 wreq binding for cross-package
if: matrix.platform == 'linux'
shell: bash
run: |
npm install --no-save --ignore-scripts --force --legacy-peer-deps \
@wreq-js/binding-linux-arm64-gnu@3.2.0
git diff --exit-code -- package.json package-lock.json
mkdir -p "$RUNNER_TEMP/omniroute-wreq-verify"
DATA_DIR="$RUNNER_TEMP/omniroute-wreq-verify" node --import tsx/esm --test \
--test-name-pattern='wreq-js 3.2 manifest pins all nine' \
tests/unit/wreq-native-manifest.test.ts
- name: Sanitize Windows home directory
if: runner.os == 'Windows'
shell: bash
@@ -235,9 +251,9 @@ jobs:
# targets, and no unlisted files) byte-for-byte.
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
# fsevents) carry linux forks. Replace them with the forks this
# @wreq-js/binding-*, fsevents) carry linux forks. Replace them with the forks this
# leg's own `npm ci` resolved, then assert every bundled native
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
# (better-sqlite3 prebuilds, wreq-js, onnxruntime)
# can service this leg's platform/arch before packaging starts.
run: |
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz

View File

@@ -18,13 +18,3 @@
#
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
# patched base / bump the dep) over suppressing. Stale entries are debt.
#
# CVE-2025-68121 — Go stdlib crypto/tls (session-resumption certificate validation)
# inside the PREBUILT bogdanfinn/tls-client v1.15.1 .so that tls-client-node's
# postinstall downloads (built with go 1.24.1; fixed in 1.24.13). No upstream
# rebuild exists (v1.15.1 is still the latest release) and nothing in this repo
# can bump it. The binary is only loaded by the browser-TLS web-provider
# executors (claude-web / grok-web / lmarena / perplexity-web / notion-web),
# whose handshakes go through utls. Tracking issue: #12084. Revisit at the next
# tls-client release or base-image bump and BEFORE the v3.8.51 tag (2026-09-15).
CVE-2025-68121

View File

@@ -103,25 +103,12 @@ 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 (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 "const wreq=require('wreq-js'); if(typeof wreq.createTransport!=='function') process.exit(1)"
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a

View File

@@ -31,10 +31,8 @@ COPY scripts/dev/sync-env.mjs ./scripts/dev/sync-env.mjs
# Fast Bun native package install
RUN bun install --include=optional --quiet
# Fetch tls-client-node native binary if script exists
RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ] && [ ! -d "node_modules/tls-client-node/bin" ]; then \
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
fi
# Fail the build if wreq-js cannot resolve its current platform binding.
RUN bun -e "const wreq = require('wreq-js'); if (typeof wreq.createTransport !== 'function') process.exit(1)"
# Smoke check native database driver used by Bun (bun:sqlite)
RUN bun -e "import { Database } from 'bun:sqlite'; const db = new Database(':memory:'); db.query('SELECT 1 AS ok').get(); db.close(); console.log('bun:sqlite smoke: OK');"

View File

@@ -1,5 +1,51 @@
# Third-Party Notices
## wreq-js 3.2.0 native transport
OmniRoute ships `wreq-js@3.2.0` and its platform-specific native bindings for browser-
fingerprinted HTTP transport. The npm package and all nine binding tarballs are tied by npm SLSA
attestations to signed tag `v3.2.0` and immutable source commit
[`0d52d5fa252841aeef34d4d063b1766a59612bf7`](https://github.com/sqdshguy/wreq-js/commit/0d52d5fa252841aeef34d4d063b1766a59612bf7).
- Root tarball:
<https://registry.npmjs.org/wreq-js/-/wreq-js-3.2.0.tgz>
- npm integrity:
`sha512-dawhEbhvd5hxivKZSvv/mAQGO3mwZYESyctOvIIZ/H3DvQJzUM2UoFQsij0fg7hIClQ/GEQgg+2259UcFwhpMQ==`
- Exact platform, integrity, size, and SHA-256 receipts for all nine native addons:
[`config/release/wreq-js-native-manifest.json`](config/release/wreq-js-native-manifest.json)
- Locked per-target Cargo closure, with runtime and compile-only packages kept separate:
[`config/release/wreq-js-rust-license-inventory.json`](config/release/wreq-js-rust-license-inventory.json)
- Deduplicated license texts and attribution notices for the conservative native runtime closure,
including patched BoringSSL, Unicode ICU4X components, and Mozilla root-certificate data:
[`config/release/wreq-js-rust-notices.md`](config/release/wreq-js-rust-notices.md)
The native tarballs themselves contain no LICENSE/NOTICE file. The bundled inventory is therefore
shipped beside them. It intentionally over-approximates the locked link-eligible Cargo closure;
exact post-LTO membership cannot be claimed without an upstream artifact SBOM/link map or a
reproducible-build receipt. The Android addon also dynamically requires `libc++_shared.so`, which
is not included in its npm tarball; any artifact that supplies that library needs its separate
LLVM/Apache-with-LLVM-exception notice.
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.
## codex-chatgpt-web
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from

View File

@@ -0,0 +1 @@
- **fix(providers):** Claude, Grok, LMArena, Notion, and Perplexity web-cookie transports now use pooled `wreq-js` 3.2 instead of the native sidecar, with all nine supported bindings pinned and audited, and the applicable platform binding plus native-license evidence included in each release artifact ([#12429](https://github.com/diegosouzapw/OmniRoute/pull/12429), supersedes [#11753](https://github.com/diegosouzapw/OmniRoute/pull/11753)).

View File

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

View File

@@ -141,7 +141,6 @@
"tailwind-merge",
"tailwindcss",
"tiktoken",
"tls-client-node",
"tsup",
"tsx",
"turndown",

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_09_02_12429_wreq_migration_suite": "PR #12429 (wreq-js web-cookie transport): new test file tests/unit/tls-client-wreq-migration.test.ts at 1374 lines, above the 1200 new-file testCap. Frozen rather than split: it is the single cohesive regression suite for the transport migration (31 cases covering streaming, fragmented EOF sentinels, proxy isolation, first-byte and hard deadlines, binary responses and cancellation), and the cases share the native-transport harness the file sets up once. Splitting it during a merge would duplicate that harness across files for no coverage gain. Entered at the exact LOC, so it can only ratchet down from here.",
"_rebaseline_2026_09_02_12239_chatgpt_web_cleanroom": "PR #12239 (backryun, codex/restore-chatgpt-web-cleanroom) own growth at the two existing chat chokepoints for the clean-room ChatGPT Web transport: src/sse/handlers/chat.ts 2384->2424 (+40); open-sse/handlers/chatCore.ts 5946->5976 (+30). Additive dispatch wiring; the retirement guard is narrowed to the GPL-derived cgpt-web alias rather than removed, so #11754's provenance decision still holds for the old implementation. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.",
"_rebaseline_2026_09_02_12412_grok_web_prettier": "PR #12412 (repository Prettier style applied to tests/unit/grok-web.test.ts): the reformat expands the file +277 lines (2436 -> 2713) with an identical parsed AST — no production code, no assertion changes. Cap set to 2985 rather than the exact 2713 on the operator's instruction (2026-09-02): ~10% headroom so routine additions to this suite do not re-trip the gate on formatting alone. Previous cap 2437. This is a deliberate exception to the down-only ratchet for one reformatted test file; every other entry keeps the #12411 tightening.",
"_rebaseline_2026_09_02_v3851_merged_growth_basereds": "Base-red drain: the 2026-09-02 merge waves (#12359-#12404, #11461, #11513, #12423) each grew a frozen file at an existing chokepoint, but the rebaseline was computed in the throwaway combined validation worktree and never reached any PR branch, so the growth landed while the caps did not and check-file-size went red on the release tip. Recorded here against the merged state: src/app/api/providers/[id]/models/route.ts 2429->2432 (#12389 gemini-business listing on top of #11461's 2429); src/app/api/v1/models/catalog.ts 2066->2075 (#12381 self-aliased canonical rows + #12403 NUL escape); src/lib/db/core.ts 1740->1745 (#12394 busy_timeout ordering + probe classification); src/sse/handlers/chat.ts 2375->2384 (#12360 breaker result classification + #12365 shadowed-node error); src/sse/services/auth.ts 3420->3427 (#12375 backoffLevel tie-break); open-sse/handlers/imageGeneration.ts 3255->3259 (#11513 uc-image branch + #12423 uc-image id scoping); open-sse/utils/proxyFetch.ts 1261->1271 (#12380 hasAmbientProxyContext()); tests/unit/image-generation-handler.test.ts 2110->2133 (#12362 regression coverage); tests/unit/sse-auth.test.ts 1697->1729 (#12375 regression coverage). No cap is raised beyond the merged LOC; every other entry is untouched.",
@@ -228,7 +229,8 @@
"tests/unit/translator-openai-to-kiro.test.ts": 1275,
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
"tests/unit/usage-service-hardening.test.ts": 1487,
"tests/unit/vscode-token-routes.test.ts": 1267
"tests/unit/vscode-token-routes.test.ts": 1267,
"tests/unit/tls-client-wreq-migration.test.ts": 1374
},
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",

View File

@@ -0,0 +1,159 @@
{
"schemaVersion": 1,
"package": "wreq-js",
"version": "3.2.0",
"license": "MIT",
"source": {
"repository": "https://github.com/sqdshguy/wreq-js",
"commit": "0d52d5fa252841aeef34d4d063b1766a59612bf7",
"signedTag": "v3.2.0",
"signedTagObject": "dfb277d51aa03d8c6ada9a0d78ba00bc8568150b",
"buildWorkflow": "https://github.com/sqdshguy/wreq-js/actions/runs/32649967431/attempts/1",
"attestation": "https://registry.npmjs.org/-/npm/v1/attestations/wreq-js@3.2.0",
"licenseUrl": "https://raw.githubusercontent.com/sqdshguy/wreq-js/0d52d5fa252841aeef34d4d063b1766a59612bf7/LICENSE",
"licenseSha256": "f5e211eaa1c732f23cae866f00c7a0d9f458cbb6e37051170a3f7bb45c2e5d8e"
},
"npm": {
"tarball": "https://registry.npmjs.org/wreq-js/-/wreq-js-3.2.0.tgz",
"integrity": "sha512-dawhEbhvd5hxivKZSvv/mAQGO3mwZYESyctOvIIZ/H3DvQJzUM2UoFQsij0fg7hIClQ/GEQgg+2259UcFwhpMQ=="
},
"nativeAddons": [
{
"target": "android-arm64",
"package": "@wreq-js/binding-android-arm64",
"version": "3.2.0",
"platform": "android",
"arch": "arm64",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-android-arm64/-/binding-android-arm64-3.2.0.tgz",
"integrity": "sha512-PRsy18Z+0fftLeDvFTQwpgdepihRk6oVzdQWt92hEdarI7DexhgDJvvZfDsylMp7GDfsys9sFks8nIAi4n7eKQ==",
"path": "wreq-js.android-arm64.node",
"size": 9746720,
"sha256": "10cfed8b7f8ce5767d74188bcc2c249f9b0102e8ae90b381b85ec53fbd84c59f"
},
{
"target": "darwin-arm64",
"package": "@wreq-js/binding-darwin-arm64",
"version": "3.2.0",
"platform": "darwin",
"arch": "arm64",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-darwin-arm64/-/binding-darwin-arm64-3.2.0.tgz",
"integrity": "sha512-TGbgqj7YKp6m2p79hyLtTBatKgU8SKEVL5e903KGSeSDKkLbgk8knFoZ2MakhJnlqKZhvLPCNLT7A3AStwIoHQ==",
"path": "wreq-js.darwin-arm64.node",
"size": 7754432,
"sha256": "f426855858e4c661361a93440ed5fd5bd1e4f6926b3b1c0bf8449bdfe35d0936"
},
{
"target": "darwin-x64",
"package": "@wreq-js/binding-darwin-x64",
"version": "3.2.0",
"platform": "darwin",
"arch": "x64",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-darwin-x64/-/binding-darwin-x64-3.2.0.tgz",
"integrity": "sha512-89JkGsik49nUcQR7HfO6M+Na3whkhAQBghVFWn+vGmz32RzTX+HVy6q7wThjN+XGT+xvn9ZQpzTie3B292S50g==",
"path": "wreq-js.darwin-x64.node",
"size": 8249144,
"sha256": "ef00da7db372d5a71403a17f8067655f7313ae58816150ec4a00680546b35f27"
},
{
"target": "linux-arm64-gnu",
"package": "@wreq-js/binding-linux-arm64-gnu",
"version": "3.2.0",
"platform": "linux",
"arch": "arm64",
"libc": "gnu",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-3.2.0.tgz",
"integrity": "sha512-WXqMK7AtOxMJAdwDpnAdDq0NZqf6wuRucKCQWQSLdSTUyTEuGo2anRkUsZwqvHbLrkWCTXNfl1hAfA+wIk/4kw==",
"path": "wreq-js.linux-arm64-gnu.node",
"size": 8669896,
"sha256": "5a515d02c9693f1440aa88da7a6a09332fb93844f66590e6eb1be582284a96e2"
},
{
"target": "linux-arm64-musl",
"package": "@wreq-js/binding-linux-arm64-musl",
"version": "3.2.0",
"platform": "linux",
"arch": "arm64",
"libc": "musl",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-arm64-musl/-/binding-linux-arm64-musl-3.2.0.tgz",
"integrity": "sha512-YSMWs3BNBCNhWvIAUHWyp2K/L17qxfaRTl+t97ykOIOIKZSduNYZn/Yn3hTNtpbdfrTmNMG9pltEcbixFKS4xQ==",
"path": "wreq-js.linux-arm64-musl.node",
"size": 8530208,
"sha256": "85dd40b3059b9fb1fc11923e0fca98ab2fff7bfe850aeb4dc18f8812e7125b07"
},
{
"target": "linux-x64-gnu",
"package": "@wreq-js/binding-linux-x64-gnu",
"version": "3.2.0",
"platform": "linux",
"arch": "x64",
"libc": "gnu",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-x64-gnu/-/binding-linux-x64-gnu-3.2.0.tgz",
"integrity": "sha512-6N7C1uc1qieM23rdKR5k07hfS50hVFExVHzLhHiWbmk9NyqBj0xyj2Mh5ThIrvz/or/6Pe79P8D7UWbl4aJTkw==",
"path": "wreq-js.linux-x64-gnu.node",
"size": 9110176,
"sha256": "32be0fe79325ee55216ac844130997ae24ff3df15570357194a8e7c6ae262743"
},
{
"target": "linux-x64-musl",
"package": "@wreq-js/binding-linux-x64-musl",
"version": "3.2.0",
"platform": "linux",
"arch": "x64",
"libc": "musl",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-linux-x64-musl/-/binding-linux-x64-musl-3.2.0.tgz",
"integrity": "sha512-0h0xJsmhVlmh+vHs9dYMIp5lpkKGNZrSedkl2Mh9XmR5slBahTcHT7oEEclhV+aNcY3V2Afmmqfil5huL+yDpA==",
"path": "wreq-js.linux-x64-musl.node",
"size": 9036248,
"sha256": "34c43f6694dfa5c749771f14bd19a4d4823707d428bc12d7d141ffa3176dccd6"
},
{
"target": "win32-arm64-msvc",
"package": "@wreq-js/binding-win32-arm64-msvc",
"version": "3.2.0",
"platform": "win32",
"arch": "arm64",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-3.2.0.tgz",
"integrity": "sha512-6bfaVFfbI61s5YLgQL+43uURk/VuOu7TPlUwY1l0Q9yJdWzD+Jpdokgl5cEiY2a0FEAw8Q4LqPI+XZT3YAMnQA==",
"path": "wreq-js.win32-arm64-msvc.node",
"size": 6994432,
"sha256": "c853e10e272f31d3e5bf3e14cf64a3bfb41ef94d428f895cb73a67f0c58c46fa"
},
{
"target": "win32-x64-msvc",
"package": "@wreq-js/binding-win32-x64-msvc",
"version": "3.2.0",
"platform": "win32",
"arch": "x64",
"tarball": "https://registry.npmjs.org/@wreq-js/binding-win32-x64-msvc/-/binding-win32-x64-msvc-3.2.0.tgz",
"integrity": "sha512-w4aktLElPgBXkWC/v9Ti9np6jBjYOzIyqmzgT41V/zuDq/V+V7s/13f9OzcX5hObH2WOpajo0KeljTJ2aRExNQ==",
"path": "wreq-js.win32-x64-msvc.node",
"size": 8003584,
"sha256": "2659898ee73ab64bb1ec4b4b1dd0c1e1d50f7dc579bad456d8bcad84349b01d4"
}
],
"rust": {
"cargoTomlSha256": "9dcc37ee9b254a57722402355ae483aff9eeae8dbb1a28e84a57e008ab05a747",
"cargoLockSha256": "b22954960bffe817721539c17c18d2c2fb5084b358ea3e009133b5403b123df3",
"cargoLockPackages": 229,
"normalClosureUnionPackages": 153,
"compileOnlyUnionPackages": 43,
"btlsSys": {
"version": "0.5.6",
"crateChecksum": "9b1b8638a2e1c38a5ae4efa90ae57e643baec35a30d03fc5b399b893adc4954b",
"sourceCommit": "4edbf5d716ba014384569ac5c631cea83827abfc",
"license": "MIT",
"licenseSha256": "2f55c7cce4da9f8334dce14d53e35410f67973510bc9793ac2dafa5e8cddd3c3"
},
"boringSsl": {
"sourceCommit": "91a66a59b6c1435120ff83e245d7719411294386",
"license": "Apache-2.0",
"licenseSha256": "827c8d8fc207c2392794eef9e00fe246f9f61fdcc132556c275be3dd8c3cd97f",
"modified": true,
"modificationNote": "btls-sys applies its published BoringSSL patch sets; the upstream wreq-js build workflow also adjusts btls-sys build logic on Windows targets."
}
},
"holds": {
"exactPostLtoSbom": "Published addons contain no cargo-auditable section, link map, CycloneDX/SPDX SBOM, or reproducible-build receipt; the Cargo normal closure is a conservative link-eligible superset.",
"androidRuntime": "The Android addon dynamically requires libc++_shared.so, which is absent from its npm tarball. Audit LLVM/Apache-with-LLVM-exception notices if a release artifact supplies that library."
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,9 @@ 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 fetch helper. It is not
used to install the pinned `wreq-js` transport binding and does not indicate that web-cookie
provider transport setup failed.
**No action needed** — the warnings cannot be fully silenced without forking upstream packages.
@@ -148,9 +150,9 @@ 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/binding-win32-<arch>-msvc-<hash>/wreq-js.win32-<arch>-msvc.node`
— the pinned `wreq-js` native binding used for browser-fingerprinted HTTP on web-cookie
providers (`<arch>` is `x64` or `arm64`).
**Why it fires:** the Windows installer is **not yet code-signed**, so an unsigned NSIS
installer has zero reputation and behavioral heuristics run at maximum aggression. Combined

View File

@@ -764,15 +764,15 @@ 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_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_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Native wreq-js request timeout (`claudeTlsClient.ts`). |
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | Absolute JS 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` | Absolute JS 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` | Absolute JS 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` | Absolute JS 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. |

View File

@@ -1,13 +1,13 @@
---
title: "Stealth Guide"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.51
lastUpdated: 2026-09-02
---
# Stealth Guide
> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{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,claudeTlsClient,perplexityTlsClient,grokTlsClient,notionTlsClient,lmarenaTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/`
> **Last updated:** 2026-09-02 — v3.8.51
> **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.
@@ -22,13 +22,56 @@ Stealth features exist so OmniRoute can act as a compatibility layer between use
### `open-sse/utils/tlsClient.ts` — wreq-js (Chrome 124)
Lazy-loaded `wreq-js` session that impersonates **Chrome 124 on macOS**. Used as a generic JA3/JA4 wrapper for upstreams behind Cloudflare. Falls back to native fetch when `wreq-js` is not installed (`available = false`).
Persistent `wreq-js` sessions are created lazily per account scope and resolved proxy. The
process-wide `TlsClient` pools at most 128 sessions that impersonate **Chrome 124 on macOS** for
upstreams behind Cloudflare. `TlsClient.fetch()` fails closed when the native runtime is
unavailable; a caller may explicitly select a fallback outside this wrapper.
- Singleton session: `browser: "chrome_124", os: "macos"`
- Session profile: `browser: "chrome_124", os: "macos"`
- Proxy resolution (priority): `HTTPS_PROXY``HTTP_PROXY``ALL_PROXY` (also lower-case)
- Timeout: `TLS_CLIENT_TIMEOUT_MS` (inherits from `FETCH_TIMEOUT_MS`, default 600000)
- `wreq-js` Response is fetch-compatible (`headers`, `text()`, `json()`, `clone()`, `body`).
### Web-cookie provider transport — wreq-js 3.2.0
`open-sse/services/tlsClientBase.ts` is the shared adapter for the five specialized
web-cookie transports below. Each thin provider wrapper selects a browser/OS profile. The adapter
uses the single wreq runtime loader and transport pool in `open-sse/utils/tlsClient.ts`, keyed by
profile + OS + resolved proxy, while every request uses `cookieMode: "ephemeral"`. Accounts and
requests therefore share transport-level connections, but never a wreq session or cookie jar.
| Provider | Profile | Emulated OS | Stream EOF policy |
| ---------- | ------------- | ----------- | -------------------------------- |
| 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 consumes 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), `TlsClientHangError`
(deadline exceeded), and `WreqTransportCapacityError` (the shared session-capacity error code)
when all 128 bounded profile/OS/proxy slots are active or closing.
The generic `TlsClient` session above remains specialized for persistent browser-backed cookie
state. Both paths reuse one cached wreq module loader and process lifecycle hook; their pools remain
separate because their cookie lifetimes are intentionally different.
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.
---
## Claude Code Stealth Bundle
When `cliCompatMode` is on, OmniRoute reshapes outgoing Claude requests so they are indistinguishable from `claude-cli` traffic. Three modules collaborate:

View File

@@ -349,9 +349,6 @@ const nextConfig = {
"keytar",
"wreq-js",
"zod",
"tls-client-node",
"koffi",
"tough-cookie",
"@ngrok/ngrok",
"@huggingface/transformers",
// The ESM entry imports tiktoken_bg.wasm as a module. Turbopack can compile

View File

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

View File

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

View File

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

View File

@@ -114,7 +114,7 @@ export function mapTlsUnavailable(
return {
response: errorResponse(
502,
`Arena TLS impersonation unavailable: ${error.message}. Install/repair tls-client-node native binary.`,
`Arena TLS impersonation unavailable: ${error.message}. Verify the wreq-js 3.2 native binding.`,
"upstream_error",
"TLS_CLIENT_UNAVAILABLE"
),

View File

@@ -22,7 +22,7 @@
* chunk — safer than assuming unverified incremental-delta semantics.
*
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id, user_id])
* Method: Browser-TLS impersonation via tls-client-node (Chrome JA3). Plain
* Method: Browser-TLS impersonation via pinned wreq-js (Chrome JA3/JA4). Plain
* Node/undici fetch is rejected by Notion's edge with in-band
* `temporarily-unavailable` (HTTP 200, empty assistant text) — curl/Schannel
* and Chrome work with the same cookie + body. See services/notionTlsClient.ts.
@@ -60,10 +60,7 @@ import {
messagesForNotionTranscript,
type NotionAgentOptions,
} from "../services/notionTranscriptBuilder.ts";
import {
tlsFetchNotion,
TlsClientUnavailableError,
} from "../services/notionTlsClient.ts";
import { tlsFetchNotion } 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,44 +497,22 @@ async function sendNotionInferenceRequest(opts: {
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
// Inference can take a while (tool-autoload + LLM first token).
timeoutMs:
Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
timeoutMs: Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
});
status = tlsRes.status;
rawText = tlsRes.text ?? "";
} catch (err) {
if (err instanceof TlsClientUnavailableError) {
// Fall back to plain fetch only when the native TLS sidecar is missing —
// better a degraded path than a hard crash on platforms without the binary.
try {
const upstream = await fetch(NOTION_URL, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
});
status = upstream.status;
rawText = await upstream.text().catch(() => "");
} catch (fallbackErr) {
return {
errorResult: makeErrorResult(
502,
`Notion fetch failed: ${fallbackErr instanceof Error ? fallbackErr.message : "unknown error"}`,
reqBody,
NOTION_URL
),
};
}
} else {
return {
errorResult: makeErrorResult(
502,
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
reqBody,
NOTION_URL
),
};
}
// Fail closed: plain fetch would bypass the resolved proxy and Notion rejects
// undici's fingerprint anyway. A missing native binding is a packaging error,
// not permission to leak a direct request.
return {
errorResult: makeErrorResult(
502,
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
reqBody,
NOTION_URL
),
};
}
if (status === 401 || status === 403) {
@@ -634,8 +599,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 +702,10 @@ export class NotionWebExecutor extends BaseExecutor {
// One automatic retry for transient Notion faults — same threadId, never create again
if (isFailedAttempt(attempt) && attempt.retryable) {
const delayMs = process.env.NODE_ENV === "test" || process.env.VITEST ? 20 : 700 + Math.floor(Math.random() * 400);
const delayMs =
process.env.NODE_ENV === "test" || process.env.VITEST
? 20
: 700 + Math.floor(Math.random() * 400);
await new Promise((r) => setTimeout(r, delayMs));
attempt = await runOnce({ createThread: false, threadId });
}

View File

@@ -501,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.2 native binding, " +
"or route perplexity-web through a residential proxy.";
log?.error?.("PPLX-WEB", "Cloudflare challenge detected — TLS bypass failed");
} else {

View File

@@ -1,16 +1,17 @@
/**
* 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. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants).
* 3. Otherwise undefined (no proxy).
* 2. Request-scoped dashboard/account proxy context.
* 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants).
* 4. Otherwise undefined (no proxy).
*
* They also pin that the resolved proxy is actually placed on the
* requestOptions object handed to the native binding — the original bug

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,6 +10,367 @@ function loadRuntimeModule(moduleName: string): unknown {
return Reflect.apply(runtimeRequire, undefined, [moduleName]);
}
export type WreqTransportLike = {
close: () => Promise<void> | void;
};
export type WreqTransportResponseLike = {
status: number;
headers:
| Record<string, string[]>
| (Iterable<[string, string]> & {
getSetCookie?: () => string[];
});
body:
| string
| (Pick<ReadableStream<Uint8Array>, "getReader"> & {
cancel?: (reason?: unknown) => Promise<void>;
})
| null;
text?: () => Promise<string>;
bytes?: () => Promise<Uint8Array>;
};
export type WreqTransportRuntime = {
createTransport: (options: Record<string, unknown>) => Promise<WreqTransportLike>;
fetch: (url: string, options: Record<string, unknown>) => Promise<WreqTransportResponseLike>;
};
export type WreqTransportRuntimeLoader = () => Promise<WreqTransportRuntime>;
export type WreqTransportRequestPromise = Promise<WreqTransportResponseLike> & {
/** Close this request's exact transport generation if it is still current. */
invalidateTransport: () => void;
/** Mark this request complete so its idle transport may be reused or evicted. */
releaseTransport: () => void;
};
export type WreqTransportRequestClient = {
request: (url: string, options: Record<string, unknown>) => WreqTransportRequestPromise;
};
export class WreqRuntimeUnavailableError extends Error {
override name = "WreqRuntimeUnavailableError";
}
export class WreqTransportCapacityError extends Error {
override name = "WreqTransportCapacityError";
readonly code = "TLS_SESSION_CAPACITY";
}
type EmulationOs = "windows" | "macos" | "linux" | "android" | "ios";
let wreqRuntimeModule: Record<string, unknown> | null = null;
let wreqRuntimeModuleError: unknown;
let wreqRuntimeModuleResolved = false;
function getWreqRuntimeModule(): Record<string, unknown> {
if (!wreqRuntimeModuleResolved) {
wreqRuntimeModuleResolved = true;
try {
wreqRuntimeModule = loadRuntimeModule("wreq-js") as Record<string, unknown>;
} catch (error) {
wreqRuntimeModuleError = error;
}
}
if (wreqRuntimeModule) return wreqRuntimeModule;
throw wreqRuntimeModuleError ?? new Error("wreq-js runtime unavailable");
}
const TRANSPORT_POOL_KEY = Symbol.for("omniroute.wreqTransportPool.instance");
const TRANSPORT_POOL_LIFECYCLE_KEY = Symbol.for("omniroute.wreqTransportPool.lifecycle");
type WreqLifecycleResource = {
closeAll: () => Promise<void> | void;
};
const transportPoolGlobal = globalThis as typeof globalThis & {
[TRANSPORT_POOL_KEY]?: WreqTransportPool;
[TRANSPORT_POOL_LIFECYCLE_KEY]?: {
pools: Set<WreqLifecycleResource>;
exitHookInstalled: boolean;
};
};
function registerWreqLifecycleResource(resource: WreqLifecycleResource): void {
const lifecycle = transportPoolGlobal[TRANSPORT_POOL_LIFECYCLE_KEY] ?? {
pools: new Set<WreqLifecycleResource>(),
exitHookInstalled: false,
};
transportPoolGlobal[TRANSPORT_POOL_LIFECYCLE_KEY] = lifecycle;
lifecycle.pools.add(resource);
if (lifecycle.exitHookInstalled) return;
lifecycle.exitHookInstalled = true;
process.once("exit", () => {
for (const registered of lifecycle.pools) {
try {
void registered.closeAll();
} catch {
// Process shutdown is best effort; every close has already been initiated.
}
}
lifecycle.pools.clear();
});
}
async function closeWreqLifecycleResources(): Promise<void> {
const resources = [...(transportPoolGlobal[TRANSPORT_POOL_LIFECYCLE_KEY]?.pools ?? [])];
await Promise.allSettled(
resources.map((resource) => Promise.resolve().then(() => resource.closeAll()))
);
}
/** Focused-test seam for proving the shared process lifecycle without emitting `exit`. */
export async function __closeWreqLifecycleResourcesForTesting(): Promise<void> {
await closeWreqLifecycleResources();
}
function loadWreqTransportRuntime(): Promise<WreqTransportRuntime> {
try {
const loaded = getWreqRuntimeModule() as Partial<WreqTransportRuntime>;
if (typeof loaded.createTransport !== "function" || typeof loaded.fetch !== "function") {
throw new Error("wreq-js runtime is missing createTransport/fetch");
}
return Promise.resolve(loaded as WreqTransportRuntime);
} catch (error) {
return Promise.reject(error);
}
}
type WreqTransportEntry = {
pending: Promise<WreqTransportLike>;
transport: WreqTransportLike | null;
activeRequests: number;
lastUsed: number;
closed: boolean;
closing: Promise<void> | null;
};
type WreqTransportLease = {
key: string | null;
entry: WreqTransportEntry | null;
released: boolean;
invalidated: boolean;
};
class WreqTransportPool {
private runtimePromise: Promise<WreqTransportRuntime> | null = null;
private readonly transports = new Map<string, WreqTransportEntry>();
private readonly pendingCloses = new Set<Promise<void>>();
private readonly maxTransports: number;
private capacityReservations = 0;
private accessSequence = 0;
constructor(
private readonly runtimeLoader: WreqTransportRuntimeLoader,
maxTransports = 128
) {
this.maxTransports = Number.isInteger(maxTransports) && maxTransports > 0 ? maxTransports : 128;
}
private getRuntime(): Promise<WreqTransportRuntime> {
if (!this.runtimePromise) {
const pending = this.runtimeLoader().catch((error: unknown) => {
if (this.runtimePromise === pending) this.runtimePromise = null;
throw new WreqRuntimeUnavailableError(
error instanceof Error && error.message
? `wreq-js runtime unavailable: ${error.message}`
: "wreq-js runtime unavailable"
);
});
this.runtimePromise = pending;
}
return this.runtimePromise;
}
private key(browser: string, os: EmulationOs, options: Record<string, unknown>): string {
const proxy = typeof options.proxyUrl === "string" ? options.proxyUrl : "";
return `${browser}\0${os}\0${proxy}`;
}
private closeEntry(key: string, entry: WreqTransportEntry): Promise<void> {
if (this.transports.get(key) !== entry) return entry.closing ?? Promise.resolve();
this.transports.delete(key);
if (entry.closed) return entry.closing ?? Promise.resolve();
entry.closed = true;
let closing: Promise<void>;
try {
closing = entry.transport
? Promise.resolve(entry.transport.close()).then(() => undefined)
: entry.pending.then((transport) => transport.close()).then(() => undefined);
} catch {
closing = Promise.resolve();
}
closing = closing
.catch(() => {
// Close is best-effort after eviction; capacity is released by the finalizer below.
})
.finally(() => {
this.pendingCloses.delete(closing);
});
entry.closing = closing;
this.pendingCloses.add(closing);
return closing;
}
private findOldestIdleEntry(): [string, WreqTransportEntry] | undefined {
let candidate: [string, WreqTransportEntry] | undefined;
for (const pair of this.transports) {
const [, entry] = pair;
if (entry.activeRequests > 0) continue;
if (!candidate || entry.lastUsed < candidate[1].lastUsed) candidate = pair;
}
return candidate;
}
private reserveCapacity(): Promise<void> | null {
const occupied = this.transports.size + this.pendingCloses.size + this.capacityReservations;
this.capacityReservations += 1;
if (occupied < this.maxTransports) return null;
const candidate = this.findOldestIdleEntry();
if (!candidate) {
this.capacityReservations -= 1;
throw new WreqTransportCapacityError(
`wreq-js transport capacity exhausted (${this.maxTransports} active proxy/profile keys)`
);
}
return this.closeEntry(candidate[0], candidate[1]);
}
private releaseCapacityReservation(): void {
this.capacityReservations = Math.max(0, this.capacityReservations - 1);
}
private releaseLease(lease: WreqTransportLease): void {
if (lease.released) return;
lease.released = true;
const entry = lease.entry;
if (!entry) return;
entry.activeRequests = Math.max(0, entry.activeRequests - 1);
entry.lastUsed = ++this.accessSequence;
}
private invalidateLease(lease: WreqTransportLease): void {
if (lease.invalidated) return;
lease.invalidated = true;
if (lease.key && lease.entry) this.closeEntry(lease.key, lease.entry);
this.releaseLease(lease);
}
async closeAll(): Promise<void> {
const closes = [...this.transports].map(([key, entry]) => this.closeEntry(key, entry));
await Promise.allSettled([...closes, ...this.pendingCloses]);
}
client(browser: string, os: EmulationOs): WreqTransportRequestClient {
registerWreqLifecycleResource(this);
return {
request: (url, options) => {
const lease: WreqTransportLease = {
key: null,
entry: null,
released: false,
invalidated: false,
};
const request = (async () => {
const runtime = await this.getRuntime();
if (lease.released) throw new Error("wreq-js request lease was released before dispatch");
const key = this.key(browser, os, options);
let entry = this.transports.get(key);
if (!entry) {
const capacityWait = this.reserveCapacity();
try {
if (capacityWait) await capacityWait;
if (lease.released) {
throw new Error("wreq-js request lease was released before dispatch");
}
entry = this.transports.get(key);
if (!entry) {
const proxy = typeof options.proxyUrl === "string" ? options.proxyUrl : undefined;
const transportOptions: Record<string, unknown> = { browser, os };
if (proxy) transportOptions.proxy = proxy;
let createdEntry: WreqTransportEntry;
const pending = runtime.createTransport(transportOptions).then((transport) => {
createdEntry.transport = transport;
return transport;
});
entry = {
pending,
transport: null,
activeRequests: 0,
lastUsed: ++this.accessSequence,
closed: false,
closing: null,
};
createdEntry = entry;
this.transports.set(key, entry);
void pending.catch(() => {
if (this.transports.get(key) === createdEntry) this.transports.delete(key);
createdEntry.closed = true;
});
}
} finally {
this.releaseCapacityReservation();
}
}
lease.key = key;
lease.entry = entry;
entry.activeRequests += 1;
entry.lastUsed = ++this.accessSequence;
const transport = await entry.pending;
if (lease.released) throw new Error("wreq-js request lease was released before dispatch");
return runtime.fetch(url, {
method: options.method,
headers: options.headers,
body: options.body,
redirect: "follow",
timeout: options.timeoutMilliseconds,
signal: options.signal,
transport,
cookieMode: "ephemeral",
});
})() as WreqTransportRequestPromise;
Object.defineProperties(request, {
invalidateTransport: {
value: () => this.invalidateLease(lease),
},
releaseTransport: {
value: () => this.releaseLease(lease),
},
});
void request.catch(() => this.releaseLease(lease));
return request;
},
};
}
}
/**
* Build an ephemeral-cookie wreq client backed by the process-wide transport pool.
* Tests that inject a runtime loader receive an isolated pool to avoid cross-test state.
*/
export function createWreqTransportClient(options: {
browser: string;
os: EmulationOs;
runtimeLoader?: WreqTransportRuntimeLoader;
maxTransports?: number;
}): WreqTransportRequestClient {
if (options.runtimeLoader) {
return new WreqTransportPool(options.runtimeLoader, options.maxTransports).client(
options.browser,
options.os
);
}
const pool =
transportPoolGlobal[TRANSPORT_POOL_KEY] ??
new WreqTransportPool(loadWreqTransportRuntime, options.maxTransports);
transportPoolGlobal[TRANSPORT_POOL_KEY] = pool;
return pool.client(options.browser, options.os);
}
export type WreqResponse = {
status: number;
statusText: string;
@@ -29,7 +390,7 @@ export type CreateSessionFn = (options: Record<string, unknown>) => Promise<Wreq
let createSession: CreateSessionFn | null;
try {
const loaded = loadRuntimeModule("wreq-js") as { createSession?: CreateSessionFn };
const loaded = getWreqRuntimeModule() as { createSession?: CreateSessionFn };
createSession = typeof loaded.createSession === "function" ? loaded.createSession : null;
} catch {
if (process.env.ENABLE_TLS_FINGERPRINT === "true") {
@@ -244,10 +605,15 @@ export class TlsClient {
private readonly _libraryAvailable: boolean;
private readonly maxSessions: number;
constructor(createSessionFn: CreateSessionFn | null = createSession, maxSessions = 128) {
constructor(
createSessionFn: CreateSessionFn | null = createSession,
maxSessions = 128,
registerLifecycle = false
) {
this.createSessionFn = createSessionFn;
this._libraryAvailable = !!createSessionFn;
this.maxSessions = Number.isInteger(maxSessions) && maxSessions > 0 ? maxSessions : 128;
if (registerLifecycle) registerWreqLifecycleResource(this);
}
/** Library availability only. Per-session circuit state is enforced inside fetch(). */
@@ -288,10 +654,17 @@ export class TlsClient {
}
private closeSession(session: WreqSession): Promise<void> {
let closeResult: Promise<void>;
try {
closeResult = Promise.resolve(session.close()).then(() => undefined);
} catch {
closeResult = Promise.resolve();
}
let closing: Promise<void>;
closing = Promise.resolve()
.then(() => session.close())
.catch(() => {})
closing = closeResult
.catch(() => {
// A native close failure must not leak the session-capacity slot.
})
.finally(() => {
this.pendingCloses.delete(closing);
});
@@ -408,7 +781,7 @@ export class TlsClient {
return session ? this.closeSession(session) : Promise.resolve();
}
private async closeSessions(): Promise<void> {
async closeAll(): Promise<void> {
const pending = [...this.pendingSessions.values()];
this.globalSessionEpoch++;
this.pendingSessions.clear();
@@ -615,7 +988,7 @@ export class TlsClient {
}
async exit(): Promise<void> {
await this.closeSessions();
await this.closeAll();
}
resetCircuit(proxy?: string | null, sessionScope?: string): void {
@@ -658,5 +1031,6 @@ const scopedGlobal = globalThis as typeof globalThis & {
};
const tlsClient = scopedGlobal[TLS_CLIENT_KEY] ?? new TlsClient();
scopedGlobal[TLS_CLIENT_KEY] = tlsClient;
registerWreqLifecycleResource(tlsClient);
export default tlsClient;

39
package-lock.json generated
View File

@@ -172,8 +172,7 @@
"keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.2.0"
"wreq-js": "3.2.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -26976,17 +26975,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",
@@ -37392,7 +37380,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"
@@ -37405,28 +37393,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",
@@ -37493,7 +37462,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"devOptional": true,
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"

View File

@@ -22,7 +22,6 @@
"src/types/",
".env.example",
"scripts/build/postinstall.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/fixPlaywrightAndroid.mjs",
"bin/cli/runtime/",
"scripts/postinstall.mjs",
@@ -38,6 +37,10 @@
"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",
"config/release/wreq-js-rust-license-inventory.json",
"config/release/wreq-js-rust-notices.md",
"scripts/build/build-next-isolated.mjs",
"scripts/build/runtime-env.mjs",
"scripts/packs/optionalPackManifest.mjs",
@@ -362,8 +365,7 @@
"keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.2.0"
"wreq-js": "3.2.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { colocateLlmlinguaOptionals, SEED_PACKAGES } from "./colocateOptionals.mjs";
import { WREQ_JS_NATIVE_BINDINGS } from "./wreqJsNative.mjs";
/**
* Check whether a path exists (async).
@@ -121,6 +122,31 @@ const EXTRA_MODULE_ENTRIES = [
src: ["node_modules", "wreq-js"],
dest: ["node_modules", "wreq-js"],
},
...WREQ_JS_NATIVE_BINDINGS.map((binding) => ({
label: `${binding.packageName} native binding`,
src: ["node_modules", ...binding.packageName.split("/")],
dest: ["node_modules", ...binding.packageName.split("/")],
})),
{
label: "third-party notices",
src: ["THIRD_PARTY_NOTICES.md"],
dest: ["THIRD_PARTY_NOTICES.md"],
},
{
label: "wreq-js native provenance manifest",
src: ["config", "release", "wreq-js-native-manifest.json"],
dest: ["config", "release", "wreq-js-native-manifest.json"],
},
{
label: "wreq-js Rust license inventory",
src: ["config", "release", "wreq-js-rust-license-inventory.json"],
dest: ["config", "release", "wreq-js-rust-license-inventory.json"],
},
{
label: "wreq-js Rust/native notice bundle",
src: ["config", "release", "wreq-js-rust-notices.md"],
dest: ["config", "release", "wreq-js-rust-notices.md"],
},
{
label: "@swc/helpers",
src: ["node_modules", "@swc", "helpers"],
@@ -557,9 +583,7 @@ function stampServiceWorkerBuildId(resolvedOutDir) {
const swDest = path.join(resolvedOutDir, "public", "sw.js");
if (!fsSync.existsSync(swDest)) return;
const buildId =
process.env.OMNIROUTE_SW_BUILD_ID ||
process.env.SOURCE_VERSION ||
String(Date.now());
process.env.OMNIROUTE_SW_BUILD_ID || process.env.SOURCE_VERSION || String(Date.now());
let sw = fsSync.readFileSync(swDest, "utf8");
sw = sw.replace(
/^const CACHE_NAME = "omniroute-pwa-v2";$/m,

View File

@@ -1,148 +0,0 @@
#!/usr/bin/env node
/**
* tls-client-node postinstall repair (#7802).
*
* tls-client-node's own postinstall.js fetches a platform-specific native
* binary (.so/.dylib/.dll) from the bogdanfinn/tls-client GitHub Releases
* API. That script is blocked by `npm ci --ignore-scripts` (the Dockerfile
* builder stage runs with scripts disabled for supply-chain hygiene) and,
* even when it does run, silently no-ops on a rate-limited/failed GitHub API
* call instead of raising — so `node_modules/tls-client-node/bin/` can end
* up empty with no visible signal until the first live request throws
* TlsClientUnavailableError (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(
" claude-web/grok-web/lmarena/perplexity-web will raise a clear " +
"TlsClientUnavailableError on first use until this is resolved."
);
console.warn(
` Manual fix: node ${join(rootTlsClientDir, "scripts", "postinstall.js")}\n`
);
return;
}
log(" ✅ tls-client-node native binary fetched successfully!\n");
}
if (!existsSync(distTlsClientDir) || !hasAnyFile(rootBinDir)) return;
const distBinDir = join(distTlsClientDir, "bin");
if (hasAnyFile(distBinDir)) return;
try {
copyBinDir(rootBinDir, distBinDir);
log(" ✅ tls-client-node native binary copied to standalone dist/node_modules.\n");
} catch (err) {
console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`);
}
}

View File

@@ -7,21 +7,26 @@
* 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
* onnxruntime-node ships `bin/napi-v6/<os>/<arch>`.
* - Bundled-for-all (verify only): better-sqlite3 v13 ships Node-API prebuilds
* for 8 platforms, 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
* ran `npm ci`. The ubuntu-built tree carries the linux forks; each leg
* replaces them with the forks from its OWN `npm ci`d node_modules.
* `@ngrok/ngrok-*`, `@wreq-js/binding-*`, and macOS-only `fsevents` resolve
* to whichever platform ran `npm ci`. The ubuntu-built tree carries the
* linux forks; each leg replaces them with the forks from its OWN install.
*/
import fs from "node:fs";
import path from "node:path";
import { resolveWreqJsNativeBinding } from "./wreqJsNative.mjs";
/** Scope prefixes whose members are install-machine-forked. */
export const HYDRATED_SCOPES = ["@img/sharp-", "@img/sharp-libvips-", "@ngrok/ngrok-"];
export const HYDRATED_SCOPES = [
"@img/sharp-",
"@img/sharp-libvips-",
"@ngrok/ngrok-",
"@wreq-js/binding-",
];
/** Standalone packages that are not forked but must never be platform-forked. */
export const HYDRATED_ROOT_PACKAGES = ["fsevents"];
@@ -33,8 +38,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 +110,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",
@@ -118,13 +119,23 @@ export function verifyBundledNatives({ nodeModulesDir, platform, arch }) {
if (!fs.existsSync(sqlitePrebuild))
errors.push(`better-sqlite3: missing prebuild ${triple.dash}.node`);
const wreqDir = path.join(nodeModulesDir, "wreq-js", "rust");
const wreqNames = fs.existsSync(wreqDir)
? fs
.readdirSync(wreqDir)
.filter((n) => n.startsWith(`wreq-js.${triple.dash}`) && n.endsWith(".node"))
: [];
if (wreqNames.length === 0) errors.push(`wreq-js: missing rust binary for ${triple.dash}`);
const wreqBinding = resolveWreqJsNativeBinding({
platform,
arch,
libc: platform === "linux" ? "gnu" : undefined,
});
if (!wreqBinding) {
errors.push(`wreq-js: unsupported target ${triple.dash}`);
} else {
const wreqBinary = path.join(
nodeModulesDir,
...wreqBinding.packageName.split("/"),
wreqBinding.fileName
);
if (!fs.existsSync(wreqBinary)) {
errors.push(`wreq-js: missing ${wreqBinding.packageName}/${wreqBinding.fileName}`);
}
}
const exempt = BUNDLED_EXEMPTIONS.has(`onnxruntime-node:${triple.dash}`);
if (!exempt) {

View File

@@ -94,6 +94,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
"LICENSE",
"README.md",
"THIRD_PARTY_NOTICES.md",
"config/release/wreq-js-native-manifest.json",
"config/release/wreq-js-rust-license-inventory.json",
"config/release/wreq-js-rust-notices.md",
"bin/aliasResolver.mjs",
"bin/chatgpt-web-codex-mcp.mjs",
// #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL
@@ -136,12 +139,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 (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",
@@ -222,13 +223,16 @@ 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",
"config/release/wreq-js-rust-license-inventory.json",
"config/release/wreq-js-rust-notices.md",
"package.json",
"scripts/build/native-binary-compat.mjs",
"scripts/build/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
"scripts/build/colocateOptionals.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/runtime-env.mjs",
"scripts/build/wreqJsNative.mjs",
// #10382: runtime imports of bin/cli/commands/packs.mjs (optional packs CLI) —
// listed REQUIRED so their absence from the tarball fails loudly.
"scripts/packs/optionalPackInstaller.mjs",

View File

@@ -14,8 +14,7 @@
*
* Modules repaired:
* - better-sqlite3 (SQLite bindings)
* - wreq-js (TLS client for OAuth providers)
* - tls-client-node (TLS client for 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 { resolveWreqJsNativeBinding, WREQ_JS_VERSION } from "./wreqJsNative.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -262,105 +253,60 @@ async function fixBetterSqliteBinary() {
console.warn("");
}
/**
* Fix wreq-js native binary for the standalone dist directory.
*
* wreq-js ships platform-specific .node binaries under rust/.
* The standalone build may only contain Linux binaries from the CI.
* This copies the correct platform binary from the root install.
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
*/
/** Copy the current wreq-js 3.2 optional binding into the standalone dist tree. */
async function fixWreqJsBinary() {
// wreq-js native module is not loadable in Termux (libgcc path mismatch).
// The runtime already falls back gracefully when wreq-js is unavailable.
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)"
);
return;
}
const appWreqDir = join(ROOT, "dist", "node_modules", "wreq-js", "rust");
const rootWreqDir = join(ROOT, "node_modules", "wreq-js", "rust");
if (!existsSync(join(ROOT, "dist", "node_modules", "wreq-js"))) {
return;
}
const binaryName = `wreq-js.${process.platform}-${process.arch}.node`;
const appBinaryPath = join(appWreqDir, binaryName);
const rootBinaryPath = join(rootWreqDir, binaryName);
const runtimePlatform = isTermux() ? "android" : process.platform;
const binding = resolveWreqJsNativeBinding({
platform: runtimePlatform,
arch: process.arch,
});
if (!binding) {
console.warn(
` ⚠️ wreq-js ${WREQ_JS_VERSION} has no native binding for ` +
`${runtimePlatform}-${process.arch}.`
);
return;
}
const packageSegments = binding.packageName.split("/");
const rootBindingDir = join(ROOT, "node_modules", ...packageSegments);
const appBindingDir = join(ROOT, "dist", "node_modules", ...packageSegments);
const rootBinaryPath = join(rootBindingDir, binding.fileName);
const appBinaryPath = join(appBindingDir, binding.fileName);
// Check if the platform binary already exists and loads
if (existsSync(appBinaryPath)) {
try {
process.dlopen({ exports: {} }, appBinaryPath);
return; // Already working
return;
} catch (err) {
console.warn(` ⚠️ wreq-js binary exists but failed to load: ${err.message}`);
}
}
console.log(`\n 🔧 Fixing wreq-js binary for ${process.platform}-${process.arch}...`);
console.log(`\n 🔧 Fixing ${binding.packageName} for ${runtimePlatform}-${process.arch}...`);
// Strategy 1: Copy from root node_modules
if (existsSync(rootBinaryPath)) {
if (existsSync(rootBindingDir) && existsSync(rootBinaryPath)) {
try {
mkdirSync(appWreqDir, { recursive: true });
copyFileSync(rootBinaryPath, appBinaryPath);
mkdirSync(dirname(appBindingDir), { recursive: true });
cpSync(rootBindingDir, appBindingDir, { recursive: true, force: true });
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module fixed successfully!\n");
console.log(`${binding.packageName} copied to standalone successfully!\n`);
return;
} catch (err) {
console.warn(` ⚠️ Copied wreq-js binary failed to load: ${err.message}`);
console.warn(` ⚠️ Copied ${binding.packageName} failed to load: ${err.message}`);
}
}
// 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/
console.log(" 📥 Attempting npm rebuild wreq-js...");
try {
const { execSync } = await import("node:child_process");
execSync("npm rebuild wreq-js", {
cwd: join(ROOT, "dist"),
stdio: "inherit",
timeout: 120_000,
});
if (existsSync(appBinaryPath)) {
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module rebuilt successfully!\n");
return;
}
} catch (err) {
console.warn(` ⚠️ wreq-js rebuild failed: ${err.message}`);
}
console.warn(
`\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${process.arch}.`
`\n ⚠️ Could not install ${binding.packageName}@${WREQ_JS_VERSION} for ` +
`${runtimePlatform}-${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: npm install --include=optional wreq-js@${WREQ_JS_VERSION}\n`);
}
async function ensureSwcHelpers() {
@@ -470,7 +416,6 @@ async function ensureStandaloneRuntimePackages() {
await verifyDevNativeModules();
await fixBetterSqliteBinary();
await fixWreqJsBinary();
await fixTlsClientNodeBinary({ rootDir: ROOT });
await fixPlaywrightAndroid({ rootDir: ROOT });
await ensureSwcHelpers();
await ensureStandaloneRuntimePackages();

View File

@@ -0,0 +1,131 @@
import { readFileSync } from "node:fs";
/** Exact native binding set published by wreq-js 3.2.0. */
export const WREQ_JS_VERSION = "3.2.0";
export const WREQ_JS_NATIVE_BINDINGS = Object.freeze([
{
target: "android-arm64",
packageName: "@wreq-js/binding-android-arm64",
fileName: "wreq-js.android-arm64.node",
platform: "android",
arch: "arm64",
},
{
target: "darwin-arm64",
packageName: "@wreq-js/binding-darwin-arm64",
fileName: "wreq-js.darwin-arm64.node",
platform: "darwin",
arch: "arm64",
},
{
target: "darwin-x64",
packageName: "@wreq-js/binding-darwin-x64",
fileName: "wreq-js.darwin-x64.node",
platform: "darwin",
arch: "x64",
},
{
target: "linux-arm64-gnu",
packageName: "@wreq-js/binding-linux-arm64-gnu",
fileName: "wreq-js.linux-arm64-gnu.node",
platform: "linux",
arch: "arm64",
libc: "gnu",
},
{
target: "linux-arm64-musl",
packageName: "@wreq-js/binding-linux-arm64-musl",
fileName: "wreq-js.linux-arm64-musl.node",
platform: "linux",
arch: "arm64",
libc: "musl",
},
{
target: "linux-x64-gnu",
packageName: "@wreq-js/binding-linux-x64-gnu",
fileName: "wreq-js.linux-x64-gnu.node",
platform: "linux",
arch: "x64",
libc: "gnu",
},
{
target: "linux-x64-musl",
packageName: "@wreq-js/binding-linux-x64-musl",
fileName: "wreq-js.linux-x64-musl.node",
platform: "linux",
arch: "x64",
libc: "musl",
},
{
target: "win32-arm64-msvc",
packageName: "@wreq-js/binding-win32-arm64-msvc",
fileName: "wreq-js.win32-arm64-msvc.node",
platform: "win32",
arch: "arm64",
},
{
target: "win32-x64-msvc",
packageName: "@wreq-js/binding-win32-x64-msvc",
fileName: "wreq-js.win32-x64-msvc.node",
platform: "win32",
arch: "x64",
},
]);
function readSystemLdd() {
const failures = [];
for (const lddPath of ["/usr/bin/ldd", "/bin/ldd"]) {
try {
return readFileSync(lddPath, "utf8");
} catch (error) {
failures.push(error);
}
}
throw failures[0] ?? new Error("ldd is unavailable");
}
/** Detect the C library used by the current Linux runtime. */
export function detectRuntimeLibc(options = {}) {
const platform = options.platform ?? process.platform;
if (platform !== "linux") return undefined;
const getReport = options.getReport ?? (() => process.report?.getReport());
const readLdd = options.readLdd ?? readSystemLdd;
let reportError;
try {
const report = getReport();
if (report?.header?.glibcVersionRuntime) return "gnu";
if (report?.header) return "musl";
} catch (error) {
reportError = error;
}
let lddError;
try {
const ldd = String(readLdd());
if (/\bmusl\b/i.test(ldd)) return "musl";
if (/\b(?:glibc|gnu libc|gnu c library)\b/i.test(ldd)) return "gnu";
lddError = new Error("ldd output did not identify glibc or musl");
} catch (error) {
lddError = error;
}
const detail = [reportError, lddError]
.filter((error) => error instanceof Error)
.map((error) => error.message)
.join("; ");
throw new Error(`Unable to detect Linux libc${detail ? `: ${detail}` : ""}`);
}
/** Resolve the exact package and addon filename wreq-js 3.2.0 loads. */
export function resolveWreqJsNativeBinding({ platform, arch, libc }) {
const runtimeLibc = platform === "linux" ? (libc ?? detectRuntimeLibc()) : undefined;
return (
WREQ_JS_NATIVE_BINDINGS.find(
(binding) =>
binding.platform === platform &&
binding.arch === arch &&
(binding.libc === undefined || binding.libc === runtimeLibc)
) ?? null
);
}

View File

@@ -287,7 +287,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,
@@ -455,7 +455,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.2 native binding or route " +
"perplexity-web through a residential proxy.",
};
}

View File

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

View File

@@ -222,6 +222,11 @@ test("hydratePlatformNatives swaps install-machine-forked packages for this leg"
'{"name":"@img/sharp-linux-x64"}'
);
writeNative(standalone, "node_modules/@img/sharp-linux-x64/lib/index.js", "linux fork");
writeNative(
standalone,
"node_modules/@wreq-js/binding-linux-x64-gnu/wreq-js.linux-x64-gnu.node",
"linux wreq"
);
writeNative(standalone, "node_modules/fsevents/fsevents.js", "mac only");
// This leg (darwin-arm64) resolved its own forks: different sharp, no fsevents.
writeNative(
@@ -230,6 +235,11 @@ test("hydratePlatformNatives swaps install-machine-forked packages for this leg"
'{"name":"@img/sharp-darwin-arm64"}'
);
writeNative(source, "node_modules/@img/sharp-darwin-arm64/lib/index.js", "darwin fork");
writeNative(
source,
"node_modules/@wreq-js/binding-darwin-arm64/wreq-js.darwin-arm64.node",
"darwin wreq"
);
const result = hydratePlatformNatives({
standaloneNodeModules: path.join(standalone, "node_modules"),
@@ -239,9 +249,16 @@ test("hydratePlatformNatives swaps install-machine-forked packages for this leg"
// Platform forks ship under different package names, so hydration is
// remove(standalone fork) + copy(this leg's fork); `replaced` stays empty
// unless the exact same name exists on both sides.
assert.deepEqual(result.copied.sort(), ["@img/sharp-darwin-arm64"]);
assert.deepEqual(result.copied.sort(), [
"@img/sharp-darwin-arm64",
"@wreq-js/binding-darwin-arm64",
]);
assert.deepEqual(result.replaced, []);
assert.deepEqual(result.removed.sort(), ["@img/sharp-linux-x64", "fsevents"]);
assert.deepEqual(result.removed.sort(), [
"@img/sharp-linux-x64",
"@wreq-js/binding-linux-x64-gnu",
"fsevents",
]);
assert.ok(
fs.existsSync(
path.join(standalone, "node_modules", "@img", "sharp-darwin-arm64", "lib", "index.js")
@@ -252,6 +269,18 @@ test("hydratePlatformNatives swaps install-machine-forked packages for this leg"
!fs.existsSync(path.join(standalone, "node_modules", "@img", "sharp-linux-x64")),
"linux fork removed"
);
assert.ok(
fs.existsSync(
path.join(
standalone,
"node_modules",
"@wreq-js",
"binding-darwin-arm64",
"wreq-js.darwin-arm64.node"
)
),
"darwin wreq binding copied in"
);
assert.ok(
!fs.existsSync(path.join(standalone, "node_modules", "fsevents")),
"fsevents dropped on non-matching leg"
@@ -266,9 +295,8 @@ 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, "@wreq-js/binding-linux-x64-gnu/wreq-js.linux-x64-gnu.node", "rust");
writeNative(nm, "onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime.so", "ort");
const good = verifyBundledNatives({ nodeModulesDir: nm, platform: "linux", arch: "x64" });
@@ -278,20 +306,21 @@ 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");
writeNative(nm2, "@wreq-js/binding-darwin-x64/wreq-js.darwin-x64.node", "rust");
const exempted = verifyBundledNatives({ nodeModulesDir: nm2, platform: "darwin", arch: "x64" });
assert.equal(
exempted.ok,

View File

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

View File

@@ -9,13 +9,15 @@ import assert from "node:assert/strict";
const mod = await import("../../open-sse/executors/notion-web.ts");
const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts");
const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/providers/web-cookie.ts");
const { __setTlsFetchOverrideForTesting } = await import(
"../../open-sse/services/notionTlsClient.ts"
);
const { __setTlsFetchOverrideForTesting, TlsClientUnavailableError } =
await import("../../open-sse/services/notionTlsClient.ts");
/** Mock the Chrome-JA3 path used by sendNotionInferenceRequest (not global fetch). */
function installNotionTlsMock(
handler: (url: string, opts: { headers?: Record<string, string>; body?: string }) => Promise<{
handler: (
url: string,
opts: { headers?: Record<string, string>; body?: string }
) => Promise<{
status: number;
text: string;
}>
@@ -389,6 +391,42 @@ describe("NotionWebExecutor — upstream translation (mocked TLS fetch)", () =>
}
});
it("fails closed without plain fetch when the binding is unavailable behind a proxy", async () => {
const executor = new mod.NotionWebExecutor();
const previousHttpsProxy = process.env.HTTPS_PROXY;
const previousFetch = globalThis.fetch;
let resolvedProxyUrl: string | undefined;
let plainFetchCalls = 0;
process.env.HTTPS_PROXY = "http://account-proxy.test:8080";
__setTlsFetchOverrideForTesting(async (_url, options) => {
resolvedProxyUrl = options.proxyUrl;
throw new TlsClientUnavailableError("native binding unavailable");
});
globalThis.fetch = (async () => {
plainFetchCalls += 1;
return new Response("plain fallback must not run", { status: 200 });
}) as typeof fetch;
try {
const result = await executor.execute({
model: "notion-ai",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.equal(result.response.status, 502);
assert.equal(resolvedProxyUrl, "http://account-proxy.test:8080");
assert.equal(plainFetchCalls, 0, "plain fetch would bypass the resolved proxy");
} finally {
__setTlsFetchOverrideForTesting(null);
globalThis.fetch = previousFetch;
if (previousHttpsProxy === undefined) delete process.env.HTTPS_PROXY;
else process.env.HTTPS_PROXY = previousHttpsProxy;
}
});
it("surfaces nested patch-start temporarily-unavailable as a typed error (not empty-body 502)", async () => {
const executor = new mod.NotionWebExecutor();
const restore = installNotionTlsMock(async () => ({
@@ -529,9 +567,7 @@ describe("buildNotionTranscript", () => {
},
{
role: "user",
content: [
{ type: "text", text: "find icon skill" },
] as unknown as string,
content: [{ type: "text", text: "find icon skill" }] as unknown as string,
},
],
{ spaceId: "s1" }

View File

@@ -1,113 +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, maxRetries: 5, retryDelay: 100 });
}
});
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, maxRetries: 5, retryDelay: 100 });
}
});
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, maxRetries: 5, retryDelay: 100 });
}
});
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, maxRetries: 5, retryDelay: 100 });
}
});

View File

@@ -268,6 +268,9 @@ 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",
"config/release/wreq-js-rust-license-inventory.json",
"config/release/wreq-js-rust-notices.md",
"dist/head-response-guard.cjs",
"dist/http-method-guard.cjs",
"dist/main-server-timeouts.mjs",
@@ -282,9 +285,9 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
"dist/tls-options.mjs",
"dist/webdav-handler.mjs",
"scripts/build/colocateOptionals.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/native-binary-compat.mjs",
"scripts/build/runtime-env.mjs",
"scripts/build/wreqJsNative.mjs",
"scripts/packs/optionalPackInstaller.mjs",
"scripts/packs/optionalPackManifest.mjs",
"src/shared/utils/nodeRuntimeSupport.ts",

View File

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

View File

@@ -2354,7 +2354,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.2 native binding unavailable");
});
const result = await validateProviderApiKey({
@@ -2363,7 +2363,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\.2 native binding unavailable/i);
__setClaudeTlsFetchOverride(null);
});

View File

@@ -1,80 +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/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 remaining web-provider tls clients wire downloadDir through buildNativeTlsClientOptions (#8579)", () => {
const base = readFileSync(join(ROOT, "open-sse/services/tlsClientBase.ts"), "utf8");
assert.match(
base,
/buildNativeTlsClientOptions\(\)/,
"tlsClientBase.ts must pass buildNativeTlsClientOptions() to TLSClient"
);
assert.doesNotMatch(
base,
/new TLSClient\(\{\s*runtimeMode:\s*"native"\s*\}\)/,
"tlsClientBase.ts must not construct TLSClient without downloadDir"
);
for (const relPath of TLS_CLIENT_WRAPPERS) {
const source = readFileSync(join(ROOT, relPath), "utf8");
assert.match(
source,
/createTlsClientModule\(/,
`${relPath} must go through createTlsClientModule so downloadDir is inherited`
);
assert.doesNotMatch(
source,
/new TLSClient\(\{\s*runtimeMode:\s*"native"\s*\}\)/,
`${relPath} must not construct TLSClient without downloadDir`
);
}
});

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
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)), "..", "..");
const EXPECTED_BINDINGS = [
"@wreq-js/binding-android-arm64",
"@wreq-js/binding-darwin-arm64",
"@wreq-js/binding-darwin-x64",
"@wreq-js/binding-linux-arm64-gnu",
"@wreq-js/binding-linux-arm64-musl",
"@wreq-js/binding-linux-x64-gnu",
"@wreq-js/binding-linux-x64-musl",
"@wreq-js/binding-win32-arm64-msvc",
"@wreq-js/binding-win32-x64-msvc",
].sort();
test("the distributable pins wreq-js 3.2.0 and carries all nine native lock entries", () => {
const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
files: string[];
optionalDependencies: Record<string, string>;
};
assert.equal(packageJson.optionalDependencies["wreq-js"], "3.2.0");
assert.equal(packageJson.optionalDependencies["tls-client-node"], undefined);
assert.equal(packageJson.files.includes("scripts/build/fixTlsClientNodeBinary.mjs"), false);
const packageLock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8")) as {
packages: Record<
string,
{ version?: string; integrity?: string; license?: string; optional?: boolean }
>;
};
const bindingNames = Object.keys(packageLock.packages)
.filter((key) => key.startsWith("node_modules/@wreq-js/binding-"))
.map((key) => key.slice("node_modules/".length))
.sort();
assert.deepEqual(bindingNames, EXPECTED_BINDINGS);
for (const packageName of EXPECTED_BINDINGS) {
const entry = packageLock.packages[`node_modules/${packageName}`];
assert.equal(entry.version, "3.2.0", `${packageName}: version`);
assert.match(entry.integrity || "", /^sha512-/, `${packageName}: npm integrity`);
assert.equal(entry.license, "MIT", `${packageName}: license`);
assert.equal(entry.optional, true, `${packageName}: optional binding`);
}
for (const relativePath of [
"package-lock.json",
"next.config.mjs",
"Dockerfile",
"Dockerfile.bun",
".trivyignore",
"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",
"docs/guides/TROUBLESHOOTING.md",
]) {
const source = readFileSync(join(ROOT, relativePath), "utf8");
assert.doesNotMatch(source, /tls-client-node/i, `${relativePath} still names the old sidecar`);
assert.doesNotMatch(source, /\bkoffi\b/i, `${relativePath} still names the old FFI loader`);
}
});
test("persistent sessions and ephemeral transports share one wreq runtime loader", () => {
const source = readFileSync(join(ROOT, "open-sse/utils/tlsClient.ts"), "utf8");
assert.equal(
source.match(/loadRuntimeModule\("wreq-js"\)/g)?.length,
1,
"wreq-js must be resolved through one cached module loader"
);
});

View File

@@ -4,13 +4,11 @@ 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
// 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
// Codex Responses WebSocket upstream rejected the upgrade ("Invalid JSON body").
// This test reads the supported set straight from the installed wreq-js type
// definitions, so it stays correct as the dependency is upgraded.
// #5591 regression guard: every TLS impersonation profile referenced in the
// source must be a real BrowserProfile in the pinned wreq-js package. An invalid
// value makes the native layer produce a degenerate fingerprint. Read the
// supported set straight from the installed type definitions so this guard
// moves with an intentional dependency upgrade.
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -19,15 +17,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 TRANSPORT_PROFILES = {
"open-sse/utils/tlsClient.ts": ["chrome_124", "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(TRANSPORT_PROFILES),
];
// Strip comments before scanning — explanatory comments may name the bad
@@ -36,16 +43,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 +61,20 @@ test("#5591 all configured chrome_* TLS profiles exist in wreq-js", () => {
}
});
}
for (const [rel, [profile, os]] of Object.entries(TRANSPORT_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 if (rel.endsWith("utils\/tlsClient.ts")) {
assert.match(source, /browser: "chrome_124"/);
assert.match(source, /os: "macos"/);
} else {
assert.match(source, new RegExp(`tlsProfile: ["']${profile}["']`));
assert.match(source, new RegExp(`emulationOs: ["']${os}["']`));
}
}
});

View File

@@ -18,10 +18,9 @@ vi.mock("next-intl", () => ({
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. " +
const TLS_BINDING_ERROR =
"TLS impersonation client failed to start: wreq-js 3.2.x is not installed or unsupported " +
"on this platform. Verify the matching @wreq-js native binding is packaged. " +
"(claude-web requires this — without it, Cloudflare blocks every request)";
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
@@ -62,7 +61,7 @@ async function waitFor(fn: () => boolean, timeoutMs = 2000) {
beforeEach(() => {
vi.clearAllMocks();
// /api/providers/validate fails with the detailed TLS/EACCES reason; any other
// /api/providers/validate fails with the detailed TLS/binding reason; any other
// call (e.g. model lookups) succeeds.
vi.stubGlobal(
"fetch",
@@ -70,7 +69,7 @@ beforeEach(() => {
if (String(url).includes("/api/providers/validate")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ valid: false, error: TLS_EACCES_ERROR }),
json: () => Promise.resolve({ valid: false, error: TLS_BINDING_ERROR }),
} as Response);
}
return Promise.resolve({
@@ -108,7 +107,7 @@ describe("AddApiKeyModal — surfaces the detailed validation error (#5088)", ()
});
// The full reason must reach the DOM — a bare "invalid" badge is not enough.
await waitFor(() => el.textContent?.includes("EACCES: permission denied") ?? false);
await waitFor(() => el.textContent?.includes("wreq-js 3.2.x") ?? false);
expect(el.textContent).toContain("TLS impersonation client failed to start");
});
});

View File

@@ -0,0 +1,358 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { syncStandaloneExtraModules } from "../../scripts/build/assembleStandalone.mjs";
import {
PACK_ARTIFACT_REQUIRED_PATHS,
PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS,
} from "../../scripts/build/pack-artifact-policy.ts";
import {
WREQ_JS_NATIVE_BINDINGS,
resolveWreqJsNativeBinding,
} from "../../scripts/build/wreqJsNative.mjs";
const ROOT = process.cwd();
const MANIFEST_PATH = join(ROOT, "config/release/wreq-js-native-manifest.json");
const INVENTORY_PATH = join(ROOT, "config/release/wreq-js-rust-license-inventory.json");
const NATIVE_NOTICES_PATH = join(ROOT, "config/release/wreq-js-rust-notices.md");
const RELEASE_EVIDENCE_PATHS = [
"config/release/wreq-js-native-manifest.json",
"config/release/wreq-js-rust-license-inventory.json",
"config/release/wreq-js-rust-notices.md",
];
interface NativeAddon {
target: string;
package: string;
version: string;
platform: string;
arch: string;
libc?: string;
tarball: string;
integrity: string;
path: string;
size: number;
sha256: string;
}
interface NativeManifest {
package: string;
version: string;
license: string;
source: { commit: string; licenseSha256: string };
npm: { integrity: string };
nativeAddons: NativeAddon[];
rust: {
cargoLockPackages: number;
normalClosureUnionPackages: number;
compileOnlyUnionPackages: number;
boringSsl: { sourceCommit: string; licenseSha256: string; modified: boolean };
};
}
interface CargoComponent {
name: string;
version: string;
license: string;
targets: string[];
}
interface RustLicenseInventory {
component: { name: string; version: string; sourceCommit: string };
targetNormalClosureCounts: Record<string, number>;
normalClosure: {
uniquePackages: number;
unknownLicenses: number;
licenseExpressionCounts: Record<string, number>;
components: CargoComponent[];
};
compileOnlyClosure: {
uniquePackages: number;
components: CargoComponent[];
};
embeddedComponents: Array<{ name: string; sourceCommit: string; modified: boolean }>;
limitations: string[];
}
const EXPECTED_BINARY_HASHES: Record<string, [number, string]> = {
"@wreq-js/binding-android-arm64": [
9_746_720,
"10cfed8b7f8ce5767d74188bcc2c249f9b0102e8ae90b381b85ec53fbd84c59f",
],
"@wreq-js/binding-darwin-arm64": [
7_754_432,
"f426855858e4c661361a93440ed5fd5bd1e4f6926b3b1c0bf8449bdfe35d0936",
],
"@wreq-js/binding-darwin-x64": [
8_249_144,
"ef00da7db372d5a71403a17f8067655f7313ae58816150ec4a00680546b35f27",
],
"@wreq-js/binding-linux-arm64-gnu": [
8_669_896,
"5a515d02c9693f1440aa88da7a6a09332fb93844f66590e6eb1be582284a96e2",
],
"@wreq-js/binding-linux-arm64-musl": [
8_530_208,
"85dd40b3059b9fb1fc11923e0fca98ab2fff7bfe850aeb4dc18f8812e7125b07",
],
"@wreq-js/binding-linux-x64-gnu": [
9_110_176,
"32be0fe79325ee55216ac844130997ae24ff3df15570357194a8e7c6ae262743",
],
"@wreq-js/binding-linux-x64-musl": [
9_036_248,
"34c43f6694dfa5c749771f14bd19a4d4823707d428bc12d7d141ffa3176dccd6",
],
"@wreq-js/binding-win32-arm64-msvc": [
6_994_432,
"c853e10e272f31d3e5bf3e14cf64a3bfb41ef94d428f895cb73a67f0c58c46fa",
],
"@wreq-js/binding-win32-x64-msvc": [
8_003_584,
"2659898ee73ab64bb1ec4b4b1dd0c1e1d50f7dc579bad456d8bcad84349b01d4",
],
};
test("wreq-js 3.2 manifest pins all nine audited native addons to package-lock", () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as NativeManifest;
const packageLock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8")) as {
packages: Record<
string,
{
version?: string;
integrity?: string;
license?: string;
os?: string[];
cpu?: string[];
libc?: string[];
}
>;
};
assert.equal(manifest.package, "wreq-js");
assert.equal(manifest.version, "3.2.0");
assert.equal(manifest.license, "MIT");
assert.equal(manifest.source.commit, "0d52d5fa252841aeef34d4d063b1766a59612bf7");
assert.equal(manifest.rust.cargoLockPackages, 229);
assert.equal(manifest.rust.boringSsl.modified, true);
assert.equal(manifest.nativeAddons.length, 9);
assert.deepEqual(
manifest.nativeAddons.map((entry) => entry.package).sort(),
WREQ_JS_NATIVE_BINDINGS.map((entry) => entry.packageName).sort()
);
for (const addon of manifest.nativeAddons) {
const helper = WREQ_JS_NATIVE_BINDINGS.find((entry) => entry.packageName === addon.package);
assert.ok(helper, `${addon.package}: build helper entry`);
assert.equal(addon.target, helper.target, `${addon.package}: target`);
assert.equal(addon.path, helper.fileName, `${addon.package}: binary path`);
assert.equal(addon.platform, helper.platform, `${addon.package}: platform`);
assert.equal(addon.arch, helper.arch, `${addon.package}: arch`);
assert.equal(addon.libc, helper.libc, `${addon.package}: libc`);
const lock = packageLock.packages[`node_modules/${addon.package}`];
assert.equal(lock.version, addon.version, `${addon.package}: lock version`);
assert.equal(lock.integrity, addon.integrity, `${addon.package}: lock integrity`);
assert.equal(lock.license, "MIT", `${addon.package}: lock license`);
assert.deepEqual(lock.os, [addon.platform], `${addon.package}: lock platform`);
assert.deepEqual(lock.cpu, [addon.arch], `${addon.package}: lock arch`);
if (addon.libc) {
assert.deepEqual(
lock.libc,
[addon.libc === "gnu" ? "glibc" : addon.libc],
`${addon.package}: lock libc`
);
}
assert.deepEqual(
[addon.size, addon.sha256],
EXPECTED_BINARY_HASHES[addon.package],
`${addon.package}: audited binary receipt`
);
assert.match(addon.tarball, /^https:\/\/registry\.npmjs\.org\//);
const installedBinary = join(ROOT, "node_modules", ...addon.package.split("/"), addon.path);
if (existsSync(installedBinary)) {
const bytes = readFileSync(installedBinary);
assert.equal(bytes.byteLength, addon.size, `${addon.package}: installed byte size`);
assert.equal(
createHash("sha256").update(bytes).digest("hex"),
addon.sha256,
`${addon.package}: installed sha256`
);
}
}
const current = resolveWreqJsNativeBinding({
platform: process.platform === "android" ? "android" : process.platform,
arch: process.arch,
});
if (current) {
const installedBinary = join(
ROOT,
"node_modules",
...current.packageName.split("/"),
current.fileName
);
if (existsSync(installedBinary)) {
const expected = EXPECTED_BINARY_HASHES[current.packageName];
const bytes = readFileSync(installedBinary);
assert.equal(bytes.byteLength, expected[0], "installed host binding byte size");
assert.equal(
createHash("sha256").update(bytes).digest("hex"),
expected[1],
"installed host binding sha256"
);
}
}
});
test("Cargo license inventory separates 153 runtime packages from 43 compile-only packages", () => {
const inventory = JSON.parse(readFileSync(INVENTORY_PATH, "utf8")) as RustLicenseInventory;
const notices = readFileSync(NATIVE_NOTICES_PATH, "utf8");
assert.equal(inventory.component.name, "wreq-js");
assert.equal(inventory.component.version, "3.2.0");
assert.equal(inventory.normalClosure.uniquePackages, 153);
assert.equal(inventory.normalClosure.components.length, 153);
assert.equal(
Object.values(inventory.normalClosure.licenseExpressionCounts).reduce(
(sum, count) => sum + count,
0
),
153
);
assert.equal(inventory.normalClosure.unknownLicenses, 0);
assert.equal(inventory.compileOnlyClosure.uniquePackages, 43);
assert.equal(inventory.compileOnlyClosure.components.length, 43);
const componentKey = (component: CargoComponent): string =>
`${component.name}@${component.version}`;
const normalKeys = new Set(inventory.normalClosure.components.map(componentKey));
const compileOnlyKeys = new Set(inventory.compileOnlyClosure.components.map(componentKey));
assert.equal(normalKeys.size, 153);
assert.equal(compileOnlyKeys.size, 43);
assert.deepEqual(
[...normalKeys].filter((key) => compileOnlyKeys.has(key)),
[]
);
for (const [target, expected] of Object.entries(inventory.targetNormalClosureCounts)) {
assert.equal(
inventory.normalClosure.components.filter((component) => component.targets.includes(target))
.length,
expected,
`${target}: normal closure count`
);
}
for (const component of inventory.normalClosure.components) {
assert.ok(
notices.includes(`| \`${componentKey(component)}\` | \`${component.license}\` |`),
`${componentKey(component)}: notice inventory row`
);
}
assert.match(notices, /BoringSSL@91a66a59b6c1435120ff83e245d7719411294386/);
assert.match(notices, /modified Apache-2\.0 work/);
assert.match(notices, /UNICODE LICENSE V3/);
assert.match(notices, /Community Data License Agreement - Permissive - Version 2\.0/);
assert.match(notices, /<!-- END WREQ NOTICE BUNDLE -->\s*$/);
assert.equal(
inventory.limitations.some((item) => item.includes("post-LTO")),
true
);
});
test("npm, standalone, Electron, and container assembly carry the wreq license evidence", async () => {
const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
files: string[];
};
for (const relativePath of RELEASE_EVIDENCE_PATHS) {
assert.equal(packageJson.files.includes(relativePath), true, `${relativePath}: npm files`);
assert.equal(
PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS.includes(relativePath),
true,
`${relativePath}: pack allowlist`
);
assert.equal(
PACK_ARTIFACT_REQUIRED_PATHS.includes(relativePath),
true,
`${relativePath}: pack required`
);
}
const topLevelNotices = readFileSync(join(ROOT, "THIRD_PARTY_NOTICES.md"), "utf8");
assert.match(topLevelNotices, /^## wreq-js 3\.2\.0 native transport$/m);
assert.match(topLevelNotices, /Copyright \(c\) 2025 will-work-for-meal/);
assert.match(topLevelNotices, /Copyright \(c\) 2025 Oleksandr Herasymov/);
assert.match(topLevelNotices, /wreq-js-rust-notices\.md/);
const fixtureRoot = mkdtempSync(join(tmpdir(), "omniroute-wreq-notices-source-"));
const outputRoot = mkdtempSync(join(tmpdir(), "omniroute-wreq-notices-output-"));
try {
const copiedPaths = ["THIRD_PARTY_NOTICES.md", ...RELEASE_EVIDENCE_PATHS];
for (const relativePath of copiedPaths) {
const target = join(fixtureRoot, relativePath);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, `${relativePath}: receipt\n`);
}
const changed = await syncStandaloneExtraModules(
fixtureRoot,
undefined,
{ log: () => undefined },
outputRoot
);
assert.equal(changed, true);
for (const relativePath of copiedPaths) {
assert.equal(
readFileSync(join(outputRoot, relativePath), "utf8"),
`${relativePath}: receipt\n`,
`${relativePath}: shared standalone/Electron/container assembly`
);
}
} finally {
rmSync(fixtureRoot, { recursive: true, force: true });
rmSync(outputRoot, { recursive: true, force: true });
}
});
test("Electron installs the Linux arm64 binding inside the platform matrix job", () => {
const workflow = readFileSync(join(ROOT, ".github/workflows/electron-release.yml"), "utf8");
const webBuildStart = workflow.indexOf("\n web-build:");
const buildStart = workflow.indexOf("\n build:");
const releaseStart = workflow.indexOf("\n release:");
assert.ok(webBuildStart >= 0, "web-build job exists");
assert.ok(buildStart > webBuildStart, "matrix build job follows web-build");
assert.ok(releaseStart > buildStart, "release job follows matrix build");
const webBuildJob = workflow.slice(webBuildStart, buildStart);
const matrixBuildJob = workflow.slice(buildStart, releaseStart);
const bindingStep = "Install Linux arm64 wreq binding for cross-package";
assert.doesNotMatch(webBuildJob, new RegExp(bindingStep));
assert.match(
matrixBuildJob,
new RegExp(
`${bindingStep}[\\s\\S]*?if: matrix\\.platform == 'linux'[\\s\\S]*?@wreq-js/binding-linux-arm64-gnu@3\\.2\\.0`
)
);
assert.doesNotMatch(matrixBuildJob, /--package-lock=false/);
assert.match(matrixBuildJob, /git diff --exit-code -- package\.json package-lock\.json/);
assert.match(
matrixBuildJob,
/tests\/unit\/wreq-native-manifest\.test\.ts/,
"the cross-installed binding must be verified against the audited binary manifest"
);
assert.ok(
matrixBuildJob.indexOf(bindingStep) <
matrixBuildJob.indexOf("Build Next.js standalone (legacy per-leg fallback)"),
"cross-arch binding must exist before either fallback build or shared-bundle hydration"
);
});

View File

@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
WREQ_JS_NATIVE_BINDINGS,
WREQ_JS_VERSION,
detectRuntimeLibc,
resolveWreqJsNativeBinding,
} from "../../scripts/build/wreqJsNative.mjs";
test("wreq-js 3.2 resolver covers all nine published native bindings", () => {
assert.equal(WREQ_JS_VERSION, "3.2.0");
assert.deepEqual(WREQ_JS_NATIVE_BINDINGS.map((binding) => binding.packageName).sort(), [
"@wreq-js/binding-android-arm64",
"@wreq-js/binding-darwin-arm64",
"@wreq-js/binding-darwin-x64",
"@wreq-js/binding-linux-arm64-gnu",
"@wreq-js/binding-linux-arm64-musl",
"@wreq-js/binding-linux-x64-gnu",
"@wreq-js/binding-linux-x64-musl",
"@wreq-js/binding-win32-arm64-msvc",
"@wreq-js/binding-win32-x64-msvc",
]);
assert.equal(
resolveWreqJsNativeBinding({ platform: "darwin", arch: "arm64" })?.fileName,
"wreq-js.darwin-arm64.node"
);
assert.equal(
resolveWreqJsNativeBinding({ platform: "linux", arch: "arm64", libc: "gnu" })?.packageName,
"@wreq-js/binding-linux-arm64-gnu"
);
assert.equal(
resolveWreqJsNativeBinding({ platform: "linux", arch: "x64", libc: "musl" })?.fileName,
"wreq-js.linux-x64-musl.node"
);
assert.equal(
resolveWreqJsNativeBinding({ platform: "win32", arch: "arm64" })?.packageName,
"@wreq-js/binding-win32-arm64-msvc"
);
assert.equal(
resolveWreqJsNativeBinding({ platform: "android", arch: "arm64" })?.fileName,
"wreq-js.android-arm64.node"
);
assert.equal(resolveWreqJsNativeBinding({ platform: "freebsd", arch: "x64" }), null);
});
test("libc detection falls back to ldd when process.report fails", () => {
assert.equal(
detectRuntimeLibc({
platform: "linux",
getReport() {
throw new Error("report unavailable");
},
readLdd() {
return "musl libc (x86_64) Version 1.2.5";
},
}),
"musl"
);
assert.equal(
detectRuntimeLibc({
platform: "linux",
getReport() {
throw new Error("report unavailable");
},
readLdd() {
return "ldd (GNU libc) 2.39";
},
}),
"gnu"
);
});
test("libc detection fails closed when neither report nor ldd is conclusive", () => {
assert.throws(
() =>
detectRuntimeLibc({
platform: "linux",
getReport() {
throw new Error("report unavailable");
},
readLdd() {
throw new Error("ldd unavailable");
},
}),
/unable to detect linux libc/i
);
});