diff --git a/Dockerfile b/Dockerfile index 98e0a3215b..71539f9c9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,11 +65,25 @@ RUN test -f package-lock.json \ # node-gyp comes from npm's own bundled copy (deterministic, already in the image) # instead of `npx --yes`, which would install an arbitrary registry version # on-demand and run its lifecycle scripts (Sonar docker:S6505). +# +# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS +# impersonation) hits the same --ignore-scripts wall: its own postinstall.js +# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub +# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike +# better-sqlite3 above, that script never throws on failure — it only +# `console.warn`s and exits 0 — so a rate-limited or offline build would +# otherwise succeed silently with an empty bin/ and only fail at first request +# in production (TlsClientUnavailableError, #7802). Run it explicitly here so +# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a +# broken image. RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ npm ci --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 -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)) # 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 diff --git a/changelog.d/fixes/7802-tls-client-native-lib.md b/changelog.d/fixes/7802-tls-client-native-lib.md new file mode 100644 index 0000000000..c67d6078a9 --- /dev/null +++ b/changelog.d/fixes/7802-tls-client-native-lib.md @@ -0,0 +1 @@ +- fix(docker): repair tls-client-node native binary after --ignore-scripts, retry rate-limited GitHub fetches (#7802) diff --git a/package.json b/package.json index 4890100535..b44a78e63f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "src/types/", ".env.example", "scripts/build/postinstall.mjs", + "scripts/build/fixTlsClientNodeBinary.mjs", "bin/cli/runtime/", "scripts/postinstall.mjs", "scripts/build/postinstallSupport.mjs", diff --git a/scripts/build/fixTlsClientNodeBinary.mjs b/scripts/build/fixTlsClientNodeBinary.mjs new file mode 100644 index 0000000000..9db5f4bbb4 --- /dev/null +++ b/scripts/build/fixTlsClientNodeBinary.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node + +/** + * tls-client-node postinstall repair (#7802). + * + * tls-client-node's own postinstall.js fetches a platform-specific native + * binary (.so/.dylib/.dll) from the bogdanfinn/tls-client GitHub Releases + * API. That script is blocked by `npm ci --ignore-scripts` (the Dockerfile + * builder stage runs with scripts disabled for supply-chain hygiene) and, + * even when it does run, silently no-ops on a rate-limited/failed GitHub API + * call instead of raising — so `node_modules/tls-client-node/bin/` can end + * up empty with no visible signal until the first live request throws + * TlsClientUnavailableError (chatgpt-web/claude-web/grok-web/lmarena/ + * perplexity-web all share this transport). + * + * This module: + * 1. Copies an already-fetched root `bin/` into the standalone + * `dist/node_modules/tls-client-node/bin/` bundle (same pattern as + * fixWreqJsBinary), so the published npm package works even though its + * own `files` allowlist never ships the binary. + * 2. When the root `bin/` is empty (--ignore-scripts blocked it, or a + * transient GitHub rate-limit ate the first attempt), retries the + * module's own postinstall.js with exponential backoff instead of + * giving up on the first failure. + * + * Best-effort throughout: a failure here never throws out of postinstall.mjs + * — it only warns, matching the other fix*Binary() steps. The runtime layer + * (perplexityTlsClient.ts and its 4 siblings) already surfaces a clear + * TlsClientUnavailableError pointing at the missing binary, so an operator + * who hits a still-empty bin/ after this repair gets an actionable message + * rather than an opaque crash. + */ + +import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000, 8_000]; + +function hasAnyFile(dir) { + if (!existsSync(dir)) return false; + try { + return readdirSync(dir).length > 0; + } catch { + return false; + } +} + +function copyBinDir(sourceDir, destDir) { + mkdirSync(destDir, { recursive: true }); + for (const file of readdirSync(sourceDir)) { + copyFileSync(join(sourceDir, file), join(destDir, file)); + } +} + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Re-run tls-client-node's own postinstall.js in-process, retrying with + * backoff when the attempt leaves `bin/` empty (covers transient GitHub API + * rate-limiting — the upstream script itself never throws on failure, it + * only warns, so "still empty after running it" is the only failure signal + * available). + */ +async function downloadWithRetry(rootTlsClientDir, retryDelaysMs, log) { + const postinstallScript = join(rootTlsClientDir, "scripts", "postinstall.js"); + const binDir = join(rootTlsClientDir, "bin"); + if (!existsSync(postinstallScript)) return false; + + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) { + if (attempt > 0) { + log( + ` ⏳ tls-client-node native binary still missing — retrying download ` + + `(attempt ${attempt + 1}/${retryDelaysMs.length + 1}) after rate-limit/backoff...` + ); + await sleep(retryDelaysMs[attempt - 1]); + } + + try { + const { execFileSync } = await import("node:child_process"); + execFileSync(process.execPath, [postinstallScript], { + cwd: rootTlsClientDir, + stdio: "pipe", + timeout: 30_000, + }); + } catch (err) { + log(` ⚠️ tls-client-node postinstall attempt failed: ${err.message.split("\n")[0]}`); + } + + if (hasAnyFile(binDir)) return true; + } + + return false; +} + +/** + * @param {object} opts + * @param {string} opts.rootDir - repo root + * @param {(msg: string) => void} [opts.log] + * @param {number[]} [opts.retryDelaysMs] - override for tests (avoid real sleeps) + */ +export async function fixTlsClientNodeBinary({ + rootDir, + log = (m) => console.log(m), + retryDelaysMs = DEFAULT_RETRY_DELAYS_MS, +} = {}) { + const rootTlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBinDir = join(rootTlsClientDir, "bin"); + const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node"); + + if (!existsSync(rootTlsClientDir)) return; + + if (!hasAnyFile(rootBinDir)) { + log( + "\n 🔧 tls-client-node native binary missing (blocked by --ignore-scripts or a " + + "failed fetch) — attempting repair...\n" + ); + const recovered = await downloadWithRetry(rootTlsClientDir, retryDelaysMs, log); + if (!recovered) { + console.warn( + "\n ⚠️ Could not fetch tls-client-node's native binary " + + "(GitHub API rate-limited or unreachable after retries)." + ); + console.warn( + " chatgpt-web/claude-web/grok-web/lmarena/perplexity-web will raise a clear " + + "TlsClientUnavailableError on first use until this is resolved." + ); + console.warn( + ` Manual fix: node ${join(rootTlsClientDir, "scripts", "postinstall.js")}\n` + ); + return; + } + log(" ✅ tls-client-node native binary fetched successfully!\n"); + } + + if (!existsSync(distTlsClientDir) || !hasAnyFile(rootBinDir)) return; + + const distBinDir = join(distTlsClientDir, "bin"); + if (hasAnyFile(distBinDir)) return; + + try { + copyBinDir(rootBinDir, distBinDir); + log(" ✅ tls-client-node native binary copied to standalone dist/node_modules.\n"); + } catch (err) { + console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`); + } +} diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 90e9216dd2..aa1bc03318 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -117,6 +117,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ "scripts/build/postinstall.mjs", "scripts/build/postinstallSupport.mjs", "scripts/build/colocateOptionals.mjs", + // #7802: imported by scripts/build/postinstall.mjs to repair tls-client-node's + // native binary (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web transport). + "scripts/build/fixTlsClientNodeBinary.mjs", // #5227: imported at runtime by bin/cli/commands/serve.mjs (heap auto-calibration). "scripts/build/runtime-env.mjs", "scripts/build/sync-env.mjs", @@ -177,6 +180,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "scripts/build/postinstall.mjs", "scripts/build/postinstallSupport.mjs", "scripts/build/colocateOptionals.mjs", + "scripts/build/fixTlsClientNodeBinary.mjs", "scripts/build/runtime-env.mjs", "src/shared/utils/nodeRuntimeSupport.ts", ]; diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index 9ce512643a..9972e4771e 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -15,11 +15,13 @@ * Modules repaired: * - better-sqlite3 (SQLite bindings) * - wreq-js (TLS client for OAuth providers) + * - tls-client-node (TLS client for chatgpt-web/claude-web/grok-web/lmarena/perplexity-web) * * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129 * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321 * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/426 * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634 + * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/7802 */ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; @@ -29,6 +31,7 @@ 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"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -344,6 +347,7 @@ async function ensureLlmlinguaOptionals() { await fixBetterSqliteBinary(); await fixWreqJsBinary(); +await fixTlsClientNodeBinary({ rootDir: ROOT }); await ensureSwcHelpers(); await ensureLlmlinguaOptionals(); await syncProjectEnv(); diff --git a/tests/unit/fix-tls-client-node-binary-7802.test.ts b/tests/unit/fix-tls-client-node-binary-7802.test.ts new file mode 100644 index 0000000000..c4c1209949 --- /dev/null +++ b/tests/unit/fix-tls-client-node-binary-7802.test.ts @@ -0,0 +1,115 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { fixTlsClientNodeBinary } from "../../scripts/build/fixTlsClientNodeBinary.mjs"; + +function makeRoot() { + return mkdtempSync(join(tmpdir(), "fix-tls-client-node-binary-7802-")); +} + +function collectLogs() { + const logs: string[] = []; + return { logs, log: (m: string) => logs.push(m) }; +} + +test("no-ops when node_modules/tls-client-node is absent (module not installed)", async () => { + const rootDir = makeRoot(); + try { + const { logs, log } = collectLogs(); + await fixTlsClientNodeBinary({ rootDir, log }); + assert.deepEqual(logs, []); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("copies an already-populated root bin/ into the standalone dist bundle (#7802 item 2)", async () => { + const rootDir = makeRoot(); + try { + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + mkdirSync(rootBin, { recursive: true }); + writeFileSync(join(rootBin, "tls-client-linux-ubuntu-amd64-1.0.0.so"), "fake-binary"); + + const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node"); + mkdirSync(distTlsClientDir, { recursive: true }); + + const { log } = collectLogs(); + await fixTlsClientNodeBinary({ rootDir, log }); + + const distBin = join(distTlsClientDir, "bin"); + assert.ok(existsSync(distBin), "dist bin/ should have been created"); + assert.deepEqual(readdirSync(distBin), ["tls-client-linux-ubuntu-amd64-1.0.0.so"]); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("retries the download when root bin/ is empty, and stops once a file appears (#7802 item 3)", async () => { + const rootDir = makeRoot(); + try { + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + mkdirSync(rootBin, { recursive: true }); + + const scriptsDir = join(tlsClientDir, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + // A postinstall.js stand-in that drops a file into bin/ on its 2nd invocation — + // simulating a first attempt eaten by a GitHub rate-limit and a 2nd that recovers. + writeFileSync( + join(scriptsDir, "postinstall.js"), + `const fs = require("fs"); + const path = require("path"); + const marker = path.join(__dirname, "..", ".attempts"); + const attempts = fs.existsSync(marker) ? Number(fs.readFileSync(marker, "utf8")) : 0; + fs.writeFileSync(marker, String(attempts + 1)); + if (attempts + 1 >= 2) { + fs.writeFileSync(path.join(__dirname, "..", "bin", "tls-client-linux-ubuntu-amd64-1.0.0.so"), "ok"); + }` + ); + + const { logs, log } = collectLogs(); + await fixTlsClientNodeBinary({ rootDir, log, retryDelaysMs: [1, 1, 1] }); + + assert.ok(existsSync(join(rootBin, "tls-client-linux-ubuntu-amd64-1.0.0.so"))); + assert.ok( + logs.some((m) => m.includes("fetched successfully")), + "expected a success log once the retry recovered" + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("warns without throwing when every retry leaves bin/ empty (still rate-limited)", async () => { + const rootDir = makeRoot(); + try { + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + mkdirSync(join(tlsClientDir, "bin"), { recursive: true }); + const scriptsDir = join(tlsClientDir, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + // A postinstall.js stand-in that always fails to produce a binary (persistent rate-limit). + writeFileSync(join(scriptsDir, "postinstall.js"), `process.exitCode = 0;`); + + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (m: string) => warnings.push(m); + try { + const { log } = collectLogs(); + await assert.doesNotReject( + fixTlsClientNodeBinary({ rootDir, log, retryDelaysMs: [1, 1] }) + ); + } finally { + console.warn = originalWarn; + } + + assert.ok( + warnings.some((m) => m.includes("Could not fetch tls-client-node")), + "expected a clear warning pointing at the manual fix, not a silent no-op" + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index 27a486e4f9..978b0474d0 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -122,6 +122,7 @@ 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", "src/shared/utils/nodeRuntimeSupport.ts", diff --git a/tests/unit/tls-client-node-docker-binary-7802.test.ts b/tests/unit/tls-client-node-docker-binary-7802.test.ts new file mode 100644 index 0000000000..45fb106f79 --- /dev/null +++ b/tests/unit/tls-client-node-docker-binary-7802.test.ts @@ -0,0 +1,45 @@ +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, + /npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts/, + "expected the builder stage to install with --ignore-scripts (precondition of #7802)" + ); + + assert.match( + dockerfile, + /better-sqlite3[\s\S]*node-gyp\.js rebuild/, + "expected an explicit better-sqlite3 rebuild step after --ignore-scripts" + ); + + assert.match( + postinstall, + /fixWreqJsBinary/, + "expected postinstall.mjs to repair wreq-js's native binary" + ); + + const dockerfileHandlesIt = /tls-client-node[\s\S]{0,200}(postinstall|rebuild|download)/i.test( + dockerfile + ); + const postinstallHandlesIt = /tls-client-node/i.test(postinstall); + + assert.ok( + dockerfileHandlesIt || postinstallHandlesIt, + "tls-client-node has no --ignore-scripts compensation in Dockerfile or " + + "scripts/build/postinstall.mjs (unlike better-sqlite3 and wreq-js) — " + + "node_modules/tls-client-node/bin/ is never populated in the official " + + "Docker image, so chatgpt-web/claude-web/grok-web/lmarena/perplexity-web " + + "all fail with TlsClientUnavailableError at first request (#7802)" + ); +});