mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 11:52:26 +03:00
fix(providers): migrate web cookie TLS transport to wreq-js
This commit is contained in:
30
.env.example
30
.env.example
@@ -1470,17 +1470,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
|
||||
|
||||
@@ -1492,18 +1490,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
|
||||
|
||||
|
||||
20
.github/workflows/electron-release.yml
vendored
20
.github/workflows/electron-release.yml
vendored
@@ -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
|
||||
|
||||
10
.trivyignore
10
.trivyignore
@@ -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
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -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
|
||||
|
||||
@@ -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');"
|
||||
|
||||
@@ -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
|
||||
|
||||
1
changelog.d/fixes/v3851-wreq-js-web-cookie-transport.md
Normal file
1
changelog.d/fixes/v3851-wreq-js-web-cookie-transport.md
Normal 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"tailwind-merge",
|
||||
"tailwindcss",
|
||||
"tiktoken",
|
||||
"tls-client-node",
|
||||
"tsup",
|
||||
"tsx",
|
||||
"turndown",
|
||||
|
||||
159
config/release/wreq-js-native-manifest.json
Normal file
159
config/release/wreq-js-native-manifest.json
Normal 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."
|
||||
}
|
||||
}
|
||||
3111
config/release/wreq-js-rust-license-inventory.json
Normal file
3111
config/release/wreq-js-rust-license-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
9709
config/release/wreq-js-rust-notices.md
Normal file
9709
config/release/wreq-js-rust-notices.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -765,15 +765,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. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* LMArenaExecutor — Arena (formerly LMArena) web-session provider.
|
||||
*
|
||||
* Routes requests through arena.ai create-evaluation with session cookies.
|
||||
* Upstream sits behind Cloudflare; traffic goes through tls-client-node Chrome
|
||||
* impersonation (see services/lmarenaTlsClient.ts).
|
||||
* Upstream sits behind Cloudflare; traffic goes through wreq-js Chrome
|
||||
* impersonation with isolated ephemeral cookies (see services/lmarenaTlsClient.ts).
|
||||
*
|
||||
* Helpers: open-sse/executors/lmarena/{cookie,models,stream,response}.ts
|
||||
*/
|
||||
@@ -174,7 +174,6 @@ export class LMArenaExecutor extends BaseExecutor {
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: ctx.signal,
|
||||
stream: ctx.stream,
|
||||
streamEofSymbol: "__OMNIROUTE_LMARENA_EOF_NEVER__",
|
||||
});
|
||||
|
||||
const failed = mapFailedTlsResult({
|
||||
|
||||
@@ -6,9 +6,9 @@ export const LMARENA_API_BASE = "https://arena.ai";
|
||||
export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`;
|
||||
/**
|
||||
* Current Chrome stable UA (header surface).
|
||||
* TLS JA3 profile is separate: tls-client-node tops out at chrome_146 — see
|
||||
* LMARENA_PROFILE in lmarenaTlsClient.ts. Headers track the live browser string;
|
||||
* fingerprint stays at the newest native profile we can actually impersonate.
|
||||
* TLS JA3/JA4 profile is separate: the provider-tested wreq-js profile is pinned
|
||||
* to chrome_146 in lmarenaTlsClient.ts while headers track the live browser string.
|
||||
* Treat that deliberate version skew as a WAF-sensitive compatibility surface.
|
||||
*/
|
||||
export const LMARENA_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
||||
|
||||
@@ -114,7 +114,7 @@ export function mapTlsUnavailable(
|
||||
return {
|
||||
response: errorResponse(
|
||||
502,
|
||||
`Arena TLS impersonation unavailable: ${error.message}. Install/repair tls-client-node native binary.`,
|
||||
`Arena TLS impersonation unavailable: ${error.message}. Verify the wreq-js 3.2 native binding.`,
|
||||
"upstream_error",
|
||||
"TLS_CLIENT_UNAVAILABLE"
|
||||
),
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* chunk — safer than assuming unverified incremental-delta semantics.
|
||||
*
|
||||
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id, user_id])
|
||||
* Method: Browser-TLS impersonation via tls-client-node (Chrome JA3). Plain
|
||||
* Method: Browser-TLS impersonation via pinned wreq-js (Chrome JA3/JA4). Plain
|
||||
* Node/undici fetch is rejected by Notion's edge with in-band
|
||||
* `temporarily-unavailable` (HTTP 200, empty assistant text) — curl/Schannel
|
||||
* and Chrome work with the same cookie + body. See services/notionTlsClient.ts.
|
||||
@@ -60,10 +60,7 @@ import {
|
||||
messagesForNotionTranscript,
|
||||
type NotionAgentOptions,
|
||||
} from "../services/notionTranscriptBuilder.ts";
|
||||
import {
|
||||
tlsFetchNotion,
|
||||
TlsClientUnavailableError,
|
||||
} from "../services/notionTlsClient.ts";
|
||||
import { tlsFetchNotion } 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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for claude.ai.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, SSE detection) lives
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, SSE detection) lives
|
||||
* in the base module; this file supplies only Claude-specific config and
|
||||
* preserves the original public export surface.
|
||||
*/
|
||||
@@ -24,13 +24,13 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Claude",
|
||||
tlsProfile: `chrome_${CLAUDE_TLS_BROWSER_MAJOR_VERSION}`,
|
||||
emulationOs: "linux",
|
||||
domain: "https://claude.ai",
|
||||
tempDirPrefix: "cgpt-stream-",
|
||||
tailFileVariant: "A",
|
||||
streamEofPolicy: "include",
|
||||
responseValidation: "sse",
|
||||
exportCloudflareCheck: false,
|
||||
exposeStreamingForTesting: true,
|
||||
// Claude waits indefinitely for the first SSE byte (original 2-arg waitForContent).
|
||||
// Claude allows the native/hard request deadline to bound a slow first SSE byte.
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS,
|
||||
firstByteTimeoutMs: Number.POSITIVE_INFINITY,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 3. Waits for Turnstile challenge to appear
|
||||
* 4. Waits for challenge to be solved (with retry)
|
||||
* 5. Extracts cf_clearance cookie
|
||||
* 6. Returns fresh cookie for tls-client-node
|
||||
* 6. Returns a fresh cookie for the isolated wreq-js request
|
||||
*/
|
||||
|
||||
import type { Browser, Page } from "playwright";
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for grok.com.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, Cloudflare challenge
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, Cloudflare challenge
|
||||
* detection) lives in the base module; this file supplies only Grok-specific
|
||||
* config and preserves the original public export surface.
|
||||
*/
|
||||
@@ -22,9 +22,9 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Grok",
|
||||
tlsProfile: "chrome_146",
|
||||
emulationOs: "linux",
|
||||
domain: "https://grok.com",
|
||||
tempDirPrefix: "grok-stream-",
|
||||
tailFileVariant: "B1",
|
||||
streamEofPolicy: "exclude",
|
||||
responseValidation: "cf",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for arena.ai.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, Cloudflare challenge
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, Cloudflare challenge
|
||||
* detection) lives in the base module; this file supplies only LMArena-specific
|
||||
* config and preserves the original public export surface.
|
||||
*/
|
||||
@@ -20,11 +20,12 @@ const HARD_TIMEOUT_GRACE_MS = 10_000;
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "LMArena",
|
||||
tlsProfile: "chrome_146",
|
||||
emulationOs: "windows",
|
||||
domain: "https://lmarena.ai",
|
||||
// LMArena's proxy resolution domain is hardcoded to arena.ai, not the config domain.
|
||||
proxyDomainOverride: "https://arena.ai",
|
||||
tempDirPrefix: "LMArena-stream-",
|
||||
tailFileVariant: "B2",
|
||||
streamEofPolicy: "none",
|
||||
streamEofSymbol: "",
|
||||
responseValidation: "cf",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for app.notion.com.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, SSE detection,
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, SSE detection,
|
||||
* Cloudflare challenge detection) lives in the base module; this file supplies
|
||||
* only Notion-specific config and preserves the original public export surface.
|
||||
*/
|
||||
@@ -22,9 +22,9 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Notion",
|
||||
tlsProfile: "chrome_146",
|
||||
emulationOs: "windows",
|
||||
domain: "https://app.notion.com",
|
||||
tempDirPrefix: "pplx-stream-",
|
||||
tailFileVariant: "A",
|
||||
streamEofPolicy: "include",
|
||||
responseValidation: "sse",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Browser-TLS-impersonating HTTP client for www.perplexity.ai.
|
||||
*
|
||||
* Thin re-export over the shared `tlsClientBase.ts` factory
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle,
|
||||
* streaming tail-file, proxy resolution, error classes, SSE detection,
|
||||
* (`createTlsClientModule`). All provider-agnostic logic (wreq-js transport
|
||||
* pooling, direct streaming, proxy resolution, deadlines, SSE detection,
|
||||
* Cloudflare challenge detection) lives in the base module; this file supplies
|
||||
* only Perplexity-specific config and preserves the original public export
|
||||
* surface.
|
||||
@@ -23,9 +23,9 @@ const HARD_TIMEOUT_GRACE_MS =
|
||||
export const tlsClientModule = createTlsClientModule({
|
||||
providerName: "Perplexity",
|
||||
tlsProfile: "firefox_148",
|
||||
emulationOs: "macos",
|
||||
domain: "https://www.perplexity.ai",
|
||||
tempDirPrefix: "pplx-stream-",
|
||||
tailFileVariant: "A",
|
||||
streamEofPolicy: "include",
|
||||
responseValidation: "sse",
|
||||
exportCloudflareCheck: true,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "@/lib/dataPaths";
|
||||
|
||||
/**
|
||||
* Writable cache directory for tls-client-node's native binary.
|
||||
*
|
||||
* Without an explicit `downloadDir`, the library defaults to its own package
|
||||
* `node_modules/tls-client-node/bin`, which is root-owned on global installs
|
||||
* and fails with EACCES for normal users (#8579).
|
||||
*/
|
||||
export function resolveTlsClientDownloadDir(): string {
|
||||
return join(resolveDataDir(), "tls-client", "bin");
|
||||
}
|
||||
|
||||
export function buildNativeTlsClientOptions(): {
|
||||
runtimeMode: "native";
|
||||
downloadDir: string;
|
||||
} {
|
||||
return {
|
||||
runtimeMode: "native",
|
||||
downloadDir: resolveTlsClientDownloadDir(),
|
||||
};
|
||||
}
|
||||
@@ -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
39
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,12 +12,10 @@ allowBuilds:
|
||||
core-js: true
|
||||
esbuild: true
|
||||
keytar: true
|
||||
koffi: true
|
||||
libxmljs2: true
|
||||
onnxruntime-node: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
tls-client-node: true
|
||||
unrs-resolver: true
|
||||
onlyBuiltDependencies:
|
||||
- "@parcel/watcher"
|
||||
@@ -26,11 +24,9 @@ onlyBuiltDependencies:
|
||||
- "core-js"
|
||||
- "esbuild"
|
||||
- "keytar"
|
||||
- "koffi"
|
||||
- "libxmljs2"
|
||||
- "onnxruntime-node"
|
||||
- "omniroute"
|
||||
- "protobufjs"
|
||||
- "sharp"
|
||||
- "tls-client-node"
|
||||
- "unrs-resolver"
|
||||
|
||||
@@ -6,13 +6,11 @@
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"keytar",
|
||||
"koffi",
|
||||
"libxmljs2",
|
||||
"omniroute",
|
||||
"onnxruntime-node",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"tls-client-node",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
131
scripts/build/wreqJsNative.mjs
Normal file
131
scripts/build/wreqJsNative.mjs
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -253,6 +253,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",
|
||||
@@ -267,9 +270,9 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
|
||||
"dist/tls-options.mjs",
|
||||
"dist/webdav-handler.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
"scripts/build/fixTlsClientNodeBinary.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"scripts/build/wreqJsNative.mjs",
|
||||
"scripts/packs/optionalPackInstaller.mjs",
|
||||
"scripts/packs/optionalPackManifest.mjs",
|
||||
"src/shared/utils/nodeRuntimeSupport.ts",
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
// Issue #7134 — claude-web reported "Claude Web API error (400) with no
|
||||
// response body" even when Claude's upstream DID send a real JSON error body.
|
||||
//
|
||||
// Root cause: tlsFetchStreaming() streams the upstream response to a temp
|
||||
// file via tls-client-node's `streamOutputPath` mode. For a non-SSE,
|
||||
// non-2xx response, the native binding resolves with an EMPTY in-memory
|
||||
// `body` field (it only populates `body` for its non-streaming mode) even
|
||||
// though the real error bytes were already written to the temp file and
|
||||
// even peeked (`looksLikeSse`) to decide the response wasn't SSE. The old
|
||||
// code read the empty `r.body` instead of the file it just peeked, throwing
|
||||
// away the real upstream error detail.
|
||||
// The browser transport must peek a requested stream to distinguish SSE from
|
||||
// an upstream JSON error. Once it decides the response is not SSE, it must
|
||||
// buffer the same native body stream without discarding the bytes it peeked.
|
||||
//
|
||||
// This test injects a fake `client` (matching the `{ request }` shape
|
||||
// tlsFetchStreaming already accepts for DI) that reproduces the exact
|
||||
// tls-client-node contract under `streamOutputPath`: write bytes to the file,
|
||||
// resolve with an empty `body`. No `--experimental-test-module-mocks` flag
|
||||
// needed — this exercises the real, unmodified `tlsFetchStreaming` via
|
||||
// dependency injection instead of module-mocking `tls-client-node`.
|
||||
// tlsFetchStreaming accepts for DI). No experimental module mocks are needed:
|
||||
// the test exercises the production wreq response-stream path directly.
|
||||
|
||||
const { tlsFetchStreaming } = await import("../../open-sse/services/claudeTlsClient.ts");
|
||||
|
||||
@@ -33,21 +24,16 @@ const REAL_CLAUDE_ERROR_BODY = JSON.stringify({
|
||||
|
||||
function makeFakeClient(status: number, bodyOnFile: string) {
|
||||
return {
|
||||
request: async (_url: string, opts: Record<string, unknown>) => {
|
||||
const streamOutputPath = opts.streamOutputPath as string;
|
||||
await writeFile(streamOutputPath, bodyOnFile);
|
||||
return {
|
||||
status,
|
||||
headers: {},
|
||||
// tls-client-node does not populate `body` for streamed requests —
|
||||
// this is the exact defect condition.
|
||||
body: "",
|
||||
cookies: {},
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
bytes: async () => new Uint8Array(),
|
||||
};
|
||||
},
|
||||
request: async () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bodyOnFile));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,19 +59,11 @@ test("issue #7134: tlsFetchStreaming surfaces the real error body for a non-SSE
|
||||
|
||||
test("issue #7134: tlsFetchStreaming still uses r.body when the native client DOES populate it", async () => {
|
||||
const client = {
|
||||
request: async (_url: string, opts: Record<string, unknown>) => {
|
||||
const streamOutputPath = opts.streamOutputPath as string;
|
||||
await writeFile(streamOutputPath, "{}");
|
||||
return {
|
||||
status: 403,
|
||||
headers: {},
|
||||
body: "populated body from native client",
|
||||
cookies: {},
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
bytes: async () => new Uint8Array(),
|
||||
};
|
||||
},
|
||||
request: async () => ({
|
||||
status: 403,
|
||||
headers: {},
|
||||
body: "populated body from native client",
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await tlsFetchStreaming(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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)"
|
||||
);
|
||||
});
|
||||
1373
tests/unit/tls-client-wreq-migration.test.ts
Normal file
1373
tests/unit/tls-client-wreq-migration.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
93
tests/unit/tls-client-wreq-residue.test.ts
Normal file
93
tests/unit/tls-client-wreq-residue.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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}["']`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
358
tests/unit/wreq-native-manifest.test.ts
Normal file
358
tests/unit/wreq-native-manifest.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
89
tests/unit/wreq-postinstall-native.test.ts
Normal file
89
tests/unit/wreq-postinstall-native.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user