From e66885314abc10e146f883f37b787359b171ab75 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:46:32 -0300 Subject: [PATCH 1/6] security(deps): pin and verify tls-client native artifacts --- Dockerfile | 22 +- THIRD_PARTY_NOTICES.md | 292 ++++++++++++++++++ .../00000-tls-client-license-provenance.md | 1 + config/quality/.license-allowlist.json | 5 +- open-sse/config/tlsClientNativeManifest.json | 30 ++ open-sse/services/tlsClientBase.ts | 16 +- open-sse/services/tlsClientDownloadDir.ts | 110 ++++++- package-lock.json | 2 +- package.json | 2 +- scripts/build/assembleStandalone.mjs | 7 + scripts/build/fixTlsClientNodeBinary.mjs | 171 +++++++--- scripts/build/pack-artifact-policy.ts | 4 +- scripts/build/postinstall.mjs | 2 +- tests/unit/build/check-licenses.test.ts | 18 +- .../tls-client-license-provenance.test.ts | 85 +++++ .../fix-tls-client-node-binary-7802.test.ts | 151 ++++++++- tests/unit/pack-artifact-policy.test.ts | 2 + .../unit/tls-client-download-dir-8579.test.ts | 71 ++++- ...tls-client-node-docker-binary-7802.test.ts | 21 ++ 19 files changed, 935 insertions(+), 77 deletions(-) create mode 100644 changelog.d/maintenance/00000-tls-client-license-provenance.md create mode 100644 open-sse/config/tlsClientNativeManifest.json create mode 100644 tests/unit/build/tls-client-license-provenance.test.ts diff --git a/Dockerfile b/Dockerfile index a35f57e2808..c0e4cf45f33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,6 +83,8 @@ COPY open-sse/package.json ./open-sse/package.json COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs +COPY scripts/build/fixTlsClientNodeBinary.mjs ./scripts/build/fixTlsClientNodeBinary.mjs +COPY open-sse/config/tlsClientNativeManifest.json ./open-sse/config/tlsClientNativeManifest.json ENV NPM_CONFIG_LEGACY_PEER_DEPS=true # --ignore-scripts blocks broad dependency install/postinstall hooks, closing # the supply-chain attack surface where a transitive dep can run arbitrary code @@ -104,24 +106,18 @@ RUN test -f package-lock.json \ # 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. +# tls-client-node (shared by six web-provider transports) hits the same +# --ignore-scripts wall. Its upstream postinstall downloads the latest native +# release without verifying a checksum and exits 0 on failure. Our repair helper +# pins bogdanfinn/tls-client v1.15.1, checks GitHub's official SHA-256 for this +# platform, and runs in strict mode so Docker cannot ship an absent or tampered +# library. 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 scripts/build/fixTlsClientNodeBinary.mjs --strict # 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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 45fcfed7bdc..5e62c56b83a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -24,3 +24,295 @@ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPO NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## fatihkabakk/tls-client-node 0.2.0 + +OmniRoute includes [`tls-client-node@0.2.0`](https://github.com/fatihkabakk/tls-client-node/tree/v0.2.0) +as an optional runtime dependency. The package is source-available under Apache License 2.0 +with the Commons Clause License Condition v1.0. The following license and NOTICE blocks are +reproduced verbatim from the tagged primary sources. + +### tls-client-node license + + + +```text +"Commons Clause" License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined +below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under the +License will not include, and the License does not grant to you, the right to +Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of the rights +granted to you under the License to provide to third parties, for a fee or other +consideration (including without limitation fees for hosting or consulting/ +support services related to the Software), a product or service whose value +derives, entirely or substantially, from the functionality of the Software. Any +license notice or attribution required by the License must also include this +Commons Clause License Condition notice. + +Software: tls-client-node +License: Apache License 2.0 +Licensor: Fatih Kabak + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with that +entity. For the purposes of this definition, "control" means (i) the power, +direct or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or +more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work. + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the purposes +of this definition, "submitted" means any form of electronic, verbal, or +written communication sent to the Licensor or its representatives, including +but not limited to communication on electronic mailing lists, source code +control systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise designated in +writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and +such Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such +Contributor that are necessarily infringed by their Contribution(s) alone or by +combination of their Contribution(s) with the Work to which such Contribution(s) +was submitted. If You institute patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct or +contributory patent infringement, then any patent licenses granted to You under +this License for that Work shall terminate as of the date such litigation is +filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy +of this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any +part of the Derivative Works; and + +(d) If the Work includes a NOTICE text file as part of its distribution, then +any Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of +the following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a +whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms +of any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides +the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License +or out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, +or any and all other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. +However, in accepting such obligations, You may act only on Your own behalf +and on Your sole responsibility, not on behalf of any other Contributor, and +only if You agree to indemnify, defend, and hold each Contributor harmless for +any liability incurred by, or claims asserted against, such Contributor by +reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS +``` + + + +### tls-client-node NOTICE + + + +```text +Third-Party Notices for tls-client-node + +This project is distributed under Apache License 2.0 with Commons Clause. + +Public license summary: + +- You can use, modify, and redistribute the software under the Apache 2.0 base + terms and the Commons Clause condition. +- You cannot sell the software itself under the public license where the value + derives entirely or substantially from tls-client-node itself. + +This package interoperates with and downloads runtime components from the +following upstream projects at install time or first use: + +- bogdanfinn/tls-client +- bogdanfinn/tls-client-api + +Those upstream runtime components are distributed separately under their own +licenses. If you redistribute those downloaded runtime components, you are +responsible for complying with the upstream license terms that apply to them. + +Required acknowledgement for materials mentioning features or use of the +upstream runtime components: + +This product includes software developed by Bogdan Finn and contributors. + +Upstream project references: + +- https://github.com/bogdanfinn/tls-client +- https://github.com/bogdanfinn/tls-client-api +``` + + + +## bogdanfinn/tls-client v1.15.1 + +`tls-client-node` downloads and OmniRoute redistributes a platform-specific native library from +[`bogdanfinn/tls-client@v1.15.1`](https://github.com/bogdanfinn/tls-client/tree/v1.15.1). +The following license is reproduced verbatim from that tagged primary source. The resolved +acknowledgement required by the upstream integration is included in the `tls-client-node` NOTICE +above. + +### bogdanfinn/tls-client license + + + +```text +Copyright (c) 2023, Bogdan Finn +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. All advertising materials mentioning features or use of this software + must display the following acknowledgement: + This product includes software developed by the . +4. Neither the name of the nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + + diff --git a/changelog.d/maintenance/00000-tls-client-license-provenance.md b/changelog.d/maintenance/00000-tls-client-license-provenance.md new file mode 100644 index 00000000000..aa03e5a71bf --- /dev/null +++ b/changelog.d/maintenance/00000-tls-client-license-provenance.md @@ -0,0 +1 @@ +- **security(deps):** pin `tls-client-node@0.2.0`, ship its exact Commons Clause/Apache and upstream BSD-4 notices, pin `bogdanfinn/tls-client` to v1.15.1, and verify every native binary against GitHub's official SHA-256 before loading it ([#00000](https://github.com/diegosouzapw/OmniRoute/pull/00000)). diff --git a/config/quality/.license-allowlist.json b/config/quality/.license-allowlist.json index 06dbba4aa06..0f314dfa155 100644 --- a/config/quality/.license-allowlist.json +++ b/config/quality/.license-allowlist.json @@ -77,8 +77,11 @@ }, "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.", + "justification": "TEMPORARY bridge for a restrictive, source-available dependency: tls-client-node uses Apache-2.0 with the Commons Clause, which removes the right to Sell the software when a paid product or service derives entirely or substantially from its functionality. The shared native TLS transport serves six providers: chatgpt-web, claude-web, perplexity-web, grok-web, notion-web, and lmarena. Shipping the required notices does not grant commercial rights. Legal review is required before commercial deployment; replace with a permissive transport such as wreq-js or obtain separate permission before this exception expires.", "risk": "medium", + "temporary": true, + "owner": "@diegosouzapw", + "reviewBy": "2026-09-30", "reviewAt": "v3.9.0" } } diff --git a/open-sse/config/tlsClientNativeManifest.json b/open-sse/config/tlsClientNativeManifest.json new file mode 100644 index 00000000000..06093cc0c53 --- /dev/null +++ b/open-sse/config/tlsClientNativeManifest.json @@ -0,0 +1,30 @@ +{ + "version": "1.15.1", + "source": "https://github.com/bogdanfinn/tls-client/releases/tag/v1.15.1", + "assets": { + "darwin-arm64": { + "file": "tls-client-darwin-arm64-1.15.1.dylib", + "sha256": "b36167372a93337195b84a8b8e7ed2e63ba654b7bbe3e35cd4f96ad3196458e6" + }, + "darwin-x64": { + "file": "tls-client-darwin-amd64-1.15.1.dylib", + "sha256": "7cb2c6833dc2b7e4b59bf46798f0e214bac746143e36bf9cd5ec92fde6ec8465" + }, + "linux-arm64": { + "file": "tls-client-linux-arm64-1.15.1.so", + "sha256": "048b75c4fb0898a306228198d545eece39a7d5348200487f0395fbdc4168fe39" + }, + "linux-x64": { + "file": "tls-client-linux-ubuntu-amd64-1.15.1.so", + "sha256": "e393e866060e238bc36509f853293cebf5af8286aede59814462693efb603b1e" + }, + "win32-ia32": { + "file": "tls-client-windows-32-1.15.1.dll", + "sha256": "46f44779f41c74918a6d1d0ecadc090aa8bd5303e07ca8dd3a0b999467b76a42" + }, + "win32-x64": { + "file": "tls-client-windows-64-1.15.1.dll", + "sha256": "414b5e5c60f9200948a46afd023865ad00c7d37403056a7e74ceee27ce2b0287" + } + } +} diff --git a/open-sse/services/tlsClientBase.ts b/open-sse/services/tlsClientBase.ts index 11249864f2c..2b3fef63d4b 100644 --- a/open-sse/services/tlsClientBase.ts +++ b/open-sse/services/tlsClientBase.ts @@ -33,7 +33,10 @@ import { open, unlink, rmdir, readFile, mkdtemp, stat } from "node:fs/promises"; // --------------------------------------------------------------------------- import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; -import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; +import { + buildNativeTlsClientOptions, + resolveVerifiedTlsClientNativeLibrary, +} from "./tlsClientDownloadDir.ts"; // --------------------------------------------------------------------------- // Types @@ -652,8 +655,17 @@ export function createGetClient(config: { `tls-client-node is not installed — cannot start TLS client for ${config.providerName}` ); } + let nativeLibraryPath: string; + try { + nativeLibraryPath = await resolveVerifiedTlsClientNativeLibrary(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new TlsClientUnavailableError( + `tls-client native binary verification failed for ${config.providerName}: ${detail}` + ); + } const tlsOptions: Record = { - ...buildNativeTlsClientOptions(), + ...buildNativeTlsClientOptions(nativeLibraryPath), }; if (config.tlsProfile) { tlsOptions.clientIdentifier = config.tlsProfile; diff --git a/open-sse/services/tlsClientDownloadDir.ts b/open-sse/services/tlsClientDownloadDir.ts index 4ded7fbf012..9daa9ea6399 100644 --- a/open-sse/services/tlsClientDownloadDir.ts +++ b/open-sse/services/tlsClientDownloadDir.ts @@ -1,5 +1,29 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { resolveDataDir } from "@/lib/dataPaths"; +import tlsClientNativeManifest from "../config/tlsClientNativeManifest.json"; + +type TlsClientNativeAsset = { + file: string; + sha256: string; +}; + +type FetchLike = (input: string | URL, init?: RequestInit) => Promise; + +const TLS_CLIENT_NATIVE_ASSETS = tlsClientNativeManifest.assets as Record< + string, + TlsClientNativeAsset +>; + +async function fileMatchesSha256(filePath: string, expectedSha256: string): Promise { + try { + const bytes = await readFile(filePath); + return createHash("sha256").update(bytes).digest("hex") === expectedSha256; + } catch { + return false; + } +} /** * Writable cache directory for tls-client-node's native binary. @@ -12,12 +36,96 @@ export function resolveTlsClientDownloadDir(): string { return join(resolveDataDir(), "tls-client", "bin"); } -export function buildNativeTlsClientOptions(): { +/** + * Materialize only the pinned bogdanfinn/tls-client native library after its + * GitHub-published SHA-256 has been verified. Passing the resulting path to + * tls-client-node prevents its unchecked runtime downloader from running. + */ +export async function resolveVerifiedTlsClientNativeLibrary({ + platform = process.platform, + arch = process.arch, + asset, + downloadDir = resolveTlsClientDownloadDir(), + fetchImpl = globalThis.fetch, +}: { + platform?: NodeJS.Platform; + arch?: string; + asset?: TlsClientNativeAsset; + downloadDir?: string; + fetchImpl?: FetchLike; +} = {}): Promise { + const expectedAsset = asset ?? TLS_CLIENT_NATIVE_ASSETS[`${platform}-${arch}`]; + if (!expectedAsset) { + throw new Error(`Unsupported platform for tls-client native asset: ${platform}/${arch}`); + } + + const destinationPath = join(downloadDir, expectedAsset.file); + if (await fileMatchesSha256(destinationPath, expectedAsset.sha256)) { + return destinationPath; + } + + const assetUrl = + `https://github.com/bogdanfinn/tls-client/releases/download/v${tlsClientNativeManifest.version}/` + + expectedAsset.file; + const response = await fetchImpl(assetUrl, { + redirect: "follow", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error( + `Failed to download pinned tls-client v${tlsClientNativeManifest.version} native asset: ` + + `${response.status} ${response.statusText}` + ); + } + + const bytes = Buffer.from(await response.arrayBuffer()); + const actualSha256 = createHash("sha256").update(bytes).digest("hex"); + if (actualSha256 !== expectedAsset.sha256) { + throw new Error( + `SHA-256 mismatch for tls-client v${tlsClientNativeManifest.version} native asset ` + + `${expectedAsset.file}: ` + + `expected ${expectedAsset.sha256}, received ${actualSha256}` + ); + } + + await mkdir(downloadDir, { recursive: true }); + const temporaryPath = join( + downloadDir, + `.${expectedAsset.file}.${process.pid}.${randomUUID()}.tmp` + ); + try { + await writeFile(temporaryPath, bytes, { mode: 0o755 }); + if (!(await fileMatchesSha256(temporaryPath, expectedAsset.sha256))) { + throw new Error(`SHA-256 mismatch after writing ${expectedAsset.file}`); + } + + await rm(destinationPath, { force: true }); + try { + await rename(temporaryPath, destinationPath); + } catch (err) { + // A concurrent process may have installed the same verified asset first. + if (!(await fileMatchesSha256(destinationPath, expectedAsset.sha256))) throw err; + } + if (!(await fileMatchesSha256(destinationPath, expectedAsset.sha256))) { + await rm(destinationPath, { force: true }); + throw new Error(`SHA-256 mismatch after installing ${expectedAsset.file}`); + } + return destinationPath; + } finally { + await rm(temporaryPath, { force: true }); + } +} + +export function buildNativeTlsClientOptions(nativeLibraryPath?: string): { runtimeMode: "native"; + version: string; downloadDir: string; + nativeLibraryPath?: string; } { return { runtimeMode: "native", + version: tlsClientNativeManifest.version, downloadDir: resolveTlsClientDownloadDir(), + ...(nativeLibraryPath ? { nativeLibraryPath } : {}), }; } diff --git a/package-lock.json b/package-lock.json index 10f668c47e3..6d0ce257952 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,7 +162,7 @@ "keytar": "^7.9.0", "onnxruntime-node": "1.24.3", "sqlite-vec": "^0.1.9", - "tls-client-node": "^0.2.0", + "tls-client-node": "0.2.0", "wreq-js": "^3.0.0" } }, diff --git a/package.json b/package.json index 54a0bc149ed..38d9043c367 100644 --- a/package.json +++ b/package.json @@ -347,7 +347,7 @@ "keytar": "^7.9.0", "onnxruntime-node": "1.24.3", "sqlite-vec": "^0.1.9", - "tls-client-node": "^0.2.0", + "tls-client-node": "0.2.0", "wreq-js": "^3.0.0" }, "devDependencies": { diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index ee8d730ccf2..fa46b26c64f 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -112,6 +112,13 @@ export const NATIVE_ASSET_ENTRIES = [ /** @type {{label:string, src:string[], dest:string[]}[]} */ const EXTRA_MODULE_ENTRIES = [ + { + // Legal notices must travel with every standalone bundle. Docker copies the + // complete standalone tree into /app, so this one entry covers both outputs. + label: "third-party license notices", + src: ["THIRD_PARTY_NOTICES.md"], + dest: ["THIRD_PARTY_NOTICES.md"], + }, { // tlsClient.ts intentionally resolves wreq-js through a runtime-dynamic // require so Turbopack cannot rewrite the package name to a hashed external. diff --git a/scripts/build/fixTlsClientNodeBinary.mjs b/scripts/build/fixTlsClientNodeBinary.mjs index 9db5f4bbb4f..155b3f4b2d9 100644 --- a/scripts/build/fixTlsClientNodeBinary.mjs +++ b/scripts/build/fixTlsClientNodeBinary.mjs @@ -10,46 +10,73 @@ * 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). + * TlsClientUnavailableError (chatgpt-web/claude-web/perplexity-web/grok-web/ + * notion-web/lmarena all share this transport). * * This module: - * 1. Copies an already-fetched root `bin/` into the standalone + * 1. Accepts only bogdanfinn/tls-client v1.15.1 assets whose SHA-256 matches + * the digest published by GitHub for the tagged release. + * 2. Copies the verified root asset 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. + * 3. When that verified asset is absent, invokes the module's postinstall + * with TLS_CLIENT_VERSION pinned and retries with exponential backoff. * - * 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. + * Normal npm postinstall remains best-effort and warns on failure. Docker and + * release callers use --strict, which fails closed instead of shipping an + * absent or unverified binary. */ -import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000, 8_000]; +const NATIVE_MANIFEST = JSON.parse( + readFileSync( + new URL("../../open-sse/config/tlsClientNativeManifest.json", import.meta.url), + "utf8" + ) +); -function hasAnyFile(dir) { - if (!existsSync(dir)) return false; +export const TLS_CLIENT_NATIVE_VERSION = NATIVE_MANIFEST.version; +export const TLS_CLIENT_NATIVE_ASSETS = NATIVE_MANIFEST.assets; + +/** @typedef {{ file: string; sha256: string }} NativeAsset */ + +/** + * Resolve the exact native asset supported by tls-client-node@0.2.0. + * + * @param {NodeJS.Platform} [platform] + * @param {string} [arch] + * @returns {NativeAsset} + */ +export function resolveTlsClientNativeAsset(platform = process.platform, arch = process.arch) { + const asset = TLS_CLIENT_NATIVE_ASSETS[`${platform}-${arch}`]; + if (!asset) { + throw new Error(`Unsupported platform for tls-client-node native asset: ${platform}/${arch}`); + } + return asset; +} + +function sha256File(filePath) { + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +/** @param {string} filePath @param {NativeAsset} asset */ +function isVerifiedBinary(filePath, asset) { + if (!existsSync(filePath)) return false; try { - return readdirSync(dir).length > 0; + return sha256File(filePath) === asset.sha256; } catch { return false; } } -function copyBinDir(sourceDir, destDir) { - mkdirSync(destDir, { recursive: true }); - for (const file of readdirSync(sourceDir)) { - copyFileSync(join(sourceDir, file), join(destDir, file)); - } +function removeIfPresent(filePath) { + if (existsSync(filePath)) unlinkSync(filePath); } async function sleep(ms) { @@ -63,11 +90,17 @@ async function sleep(ms) { * only warns, so "still empty after running it" is the only failure signal * available). */ -async function downloadWithRetry(rootTlsClientDir, retryDelaysMs, log) { +async function downloadWithRetry(rootTlsClientDir, asset, version, retryDelaysMs, log) { const postinstallScript = join(rootTlsClientDir, "scripts", "postinstall.js"); const binDir = join(rootTlsClientDir, "bin"); + const binaryPath = join(binDir, asset.file); if (!existsSync(postinstallScript)) return false; + if (existsSync(binaryPath) && !isVerifiedBinary(binaryPath, asset)) { + removeIfPresent(binaryPath); + log(` ⚠️ Removed tls-client-node binary with an invalid SHA-256: ${asset.file}`); + } + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) { if (attempt > 0) { log( @@ -81,6 +114,11 @@ async function downloadWithRetry(rootTlsClientDir, retryDelaysMs, log) { const { execFileSync } = await import("node:child_process"); execFileSync(process.execPath, [postinstallScript], { cwd: rootTlsClientDir, + env: { + ...process.env, + TLS_CLIENT_SKIP_DOWNLOAD: "0", + TLS_CLIENT_VERSION: version, + }, stdio: "pipe", timeout: 30_000, }); @@ -88,7 +126,11 @@ async function downloadWithRetry(rootTlsClientDir, retryDelaysMs, log) { log(` ⚠️ tls-client-node postinstall attempt failed: ${err.message.split("\n")[0]}`); } - if (hasAnyFile(binDir)) return true; + if (isVerifiedBinary(binaryPath, asset)) return true; + if (existsSync(binaryPath)) { + removeIfPresent(binaryPath); + log(` ⚠️ Rejected tls-client-node binary with an invalid SHA-256: ${asset.file}`); + } } return false; @@ -99,50 +141,99 @@ async function downloadWithRetry(rootTlsClientDir, retryDelaysMs, log) { * @param {string} opts.rootDir - repo root * @param {(msg: string) => void} [opts.log] * @param {number[]} [opts.retryDelaysMs] - override for tests (avoid real sleeps) + * @param {NativeAsset} [opts.asset] - injected only for deterministic tests + * @param {boolean} [opts.strict] - fail instead of warning (Docker/release builds) */ export async function fixTlsClientNodeBinary({ rootDir, log = (m) => console.log(m), retryDelaysMs = DEFAULT_RETRY_DELAYS_MS, + asset, + strict = false, } = {}) { + const version = TLS_CLIENT_NATIVE_VERSION; 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 (!existsSync(rootTlsClientDir)) { + if (strict) throw new Error("tls-client-node is not installed; cannot verify native binary"); + return; + } - if (!hasAnyFile(rootBinDir)) { + let expectedAsset = asset; + try { + expectedAsset ??= resolveTlsClientNativeAsset(); + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ ${err.message}`); + return; + } + + const rootBinaryPath = join(rootBinDir, expectedAsset.file); + + if (!isVerifiedBinary(rootBinaryPath, expectedAsset)) { log( - "\n 🔧 tls-client-node native binary missing (blocked by --ignore-scripts or a " + - "failed fetch) — attempting repair...\n" + `\n 🔧 tls-client-node native binary missing or unverified — fetching pinned ` + + `v${version} and checking SHA-256...\n` + ); + const recovered = await downloadWithRetry( + rootTlsClientDir, + expectedAsset, + version, + retryDelaysMs, + log ); - const recovered = await downloadWithRetry(rootTlsClientDir, retryDelaysMs, log); if (!recovered) { + const message = + `Could not fetch tls-client-node v${version} verified native binary ` + + `(${expectedAsset.file}) after retries.`; + if (strict) throw new Error(message); + console.warn(`\n ⚠️ ${message} GitHub may be rate-limited or unreachable.`); 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." + " chatgpt-web/claude-web/perplexity-web/grok-web/notion-web/lmarena will " + + "raise a clear TlsClientUnavailableError on first use until this is resolved." ); console.warn( - ` Manual fix: node ${join(rootTlsClientDir, "scripts", "postinstall.js")}\n` + ` Verified repair: node ${join(rootDir, "scripts", "build", "fixTlsClientNodeBinary.mjs")} --strict\n` ); return; } log(" ✅ tls-client-node native binary fetched successfully!\n"); } - if (!existsSync(distTlsClientDir) || !hasAnyFile(rootBinDir)) return; + if (!existsSync(distTlsClientDir) || !isVerifiedBinary(rootBinaryPath, expectedAsset)) return; const distBinDir = join(distTlsClientDir, "bin"); - if (hasAnyFile(distBinDir)) return; + const distBinaryPath = join(distBinDir, expectedAsset.file); + if (isVerifiedBinary(distBinaryPath, expectedAsset)) return; try { - copyBinDir(rootBinDir, distBinDir); - log(" ✅ tls-client-node native binary copied to standalone dist/node_modules.\n"); + removeIfPresent(distBinaryPath); + mkdirSync(distBinDir, { recursive: true }); + copyFileSync(rootBinaryPath, distBinaryPath); + if (!isVerifiedBinary(distBinaryPath, expectedAsset)) { + removeIfPresent(distBinaryPath); + throw new Error(`SHA-256 mismatch after copying ${expectedAsset.file}`); + } + log( + ` ✅ Verified tls-client-node v${version} native binary copied to standalone ` + + "dist/node_modules.\n" + ); } catch (err) { + if (strict) throw err; console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`); } } + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await fixTlsClientNodeBinary({ + rootDir: process.cwd(), + strict: process.argv.includes("--strict"), + }); + } catch (err) { + console.error(` ❌ ${err.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 0e6d37908b5..79505024a5a 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -140,7 +140,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ "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). + // native binary (chatgpt-web/claude-web/perplexity-web/grok-web/notion-web/lmarena). "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). @@ -197,6 +197,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ // #7065: regression guard for the HEAD response guard (dist/server-ws.mjs import). "dist/head-response-guard.cjs", "dist/webdav-handler.mjs", + "open-sse/config/tlsClientNativeManifest.json", "bin/cli/program.mjs", // Direct imports of bin/omniroute.mjs — bin/cli/ is only an allowlist PREFIX, so a // file vanishing from the tarball never fails the unexpected-paths check; only these @@ -220,6 +221,7 @@ 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", + "THIRD_PARTY_NOTICES.md", "package.json", "scripts/build/native-binary-compat.mjs", "scripts/build/postinstall.mjs", diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index 1628aca7cf1..d5cb85967dd 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -15,7 +15,7 @@ * Modules repaired: * - better-sqlite3 (SQLite bindings) * - wreq-js (TLS client for OAuth providers) - * - tls-client-node (TLS client for chatgpt-web/claude-web/grok-web/lmarena/perplexity-web) + * - tls-client-node (TLS for chatgpt-web/claude-web/perplexity-web/grok-web/notion-web/lmarena) * - sql.js (WASM SQLite fallback runtime) * - node-machine-id (local CLI machine-token server runtime) * diff --git a/tests/unit/build/check-licenses.test.ts b/tests/unit/build/check-licenses.test.ts index 6ee28694db3..ace523a3b46 100644 --- a/tests/unit/build/check-licenses.test.ts +++ b/tests/unit/build/check-licenses.test.ts @@ -285,11 +285,27 @@ test("loadAllowlist: exceptions entries have required fields", () => { } }); -test("loadAllowlist: tls-client-node exception has risk=medium (Commons Clause)", () => { +test("loadAllowlist: tls-client-node exception is temporary, owned, and covers all consumers", () => { 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)"); + assert.equal(exc.temporary, true, "Commons Clause exception must not become permanent policy"); + assert.equal(exc.owner, "@diegosouzapw"); + assert.equal(exc.reviewBy, "2026-09-30"); + assert.equal(exc.reviewAt, "v3.9.0"); + assert.match(exc.justification, /source-available/i); + assert.match(exc.justification, /commercial deployment/i); + for (const provider of [ + "chatgpt-web", + "claude-web", + "perplexity-web", + "grok-web", + "notion-web", + "lmarena", + ]) { + assert.match(exc.justification, new RegExp(provider), `missing consumer ${provider}`); + } }); test("loadAllowlist: LGPL packages have registered exceptions", () => { diff --git a/tests/unit/build/tls-client-license-provenance.test.ts b/tests/unit/build/tls-client-license-provenance.test.ts new file mode 100644 index 00000000000..83ef564966d --- /dev/null +++ b/tests/unit/build/tls-client-license-provenance.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { syncStandaloneExtraModules } from "../../../scripts/build/assembleStandalone.mjs"; +import { PACK_ARTIFACT_REQUIRED_PATHS } from "../../../scripts/build/pack-artifact-policy.ts"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + +const PRIMARY_SOURCE_HASHES = { + "tls-client-node@0.2.0 LICENSE": + "086c687026ff693ad76589dda1af12304a3ff33fc5f15030035ed62ef6a6d6eb", + "tls-client-node@0.2.0 NOTICE": + "80e5a526273788f2ace0164ec131daac697c54084add90855afdd03f5fadd3d3", + "bogdanfinn/tls-client@v1.15.1 LICENSE": + "7dab9a4dd66987fbe576d53c1ee047c193725df6f4fac67de315a127417fd151", +} as const; + +function extractVerbatimBlock(document: string, label: keyof typeof PRIMARY_SOURCE_HASHES): string { + const beginMarker = ``; + const endMarker = ``; + const markerStart = document.indexOf(beginMarker); + assert.notEqual(markerStart, -1, `missing begin marker for ${label}`); + const fenceStart = document.indexOf("```text\n", markerStart + beginMarker.length); + assert.notEqual(fenceStart, -1, `missing text fence for ${label}`); + const contentStart = fenceStart + "```text\n".length; + const finish = document.indexOf("\n```", contentStart); + assert.notEqual(finish, -1, `missing closing fence for ${label}`); + assert.notEqual(document.indexOf(endMarker, finish), -1, `missing end marker for ${label}`); + return document.slice(contentStart, finish); +} + +test("distributed tls-client notices reproduce every primary license and NOTICE verbatim", () => { + const notices = readFileSync(join(ROOT, "THIRD_PARTY_NOTICES.md"), "utf8"); + + for (const [label, expectedHash] of Object.entries(PRIMARY_SOURCE_HASHES)) { + const text = extractVerbatimBlock(notices, label as keyof typeof PRIMARY_SOURCE_HASHES); + assert.equal( + createHash("sha256").update(text).digest("hex"), + expectedHash, + `${label} must remain byte-for-byte identical to its tagged primary source` + ); + } +}); + +test("the distributed wrapper is pinned to the exact audited tls-client-node release", () => { + const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); + const packageLock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8")); + + assert.equal(packageJson.optionalDependencies["tls-client-node"], "0.2.0"); + assert.equal(packageLock.packages[""].optionalDependencies["tls-client-node"], "0.2.0"); + assert.equal(packageLock.packages["node_modules/tls-client-node"].version, "0.2.0"); +}); + +test("npm pack, standalone, and Docker all transport THIRD_PARTY_NOTICES.md", async () => { + const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); + assert.ok(packageJson.files.includes("THIRD_PARTY_NOTICES.md")); + assert.ok( + PACK_ARTIFACT_REQUIRED_PATHS.includes("THIRD_PARTY_NOTICES.md"), + "check:pack-artifact must fail when the distributed notices are absent" + ); + + const projectRoot = mkdtempSync(join(tmpdir(), "tls-client-notices-project-")); + const outDir = mkdtempSync(join(tmpdir(), "tls-client-notices-standalone-")); + try { + const expected = "legal-notice-sentinel\n"; + writeFileSync(join(projectRoot, "THIRD_PARTY_NOTICES.md"), expected); + await syncStandaloneExtraModules(projectRoot, undefined, { log() {} }, outDir); + assert.equal(readFileSync(join(outDir, "THIRD_PARTY_NOTICES.md"), "utf8"), expected); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(outDir, { recursive: true, force: true }); + } + + const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8"); + assert.match( + dockerfile, + /COPY --from=builder \/app\/\.build\/next\/standalone \.\//, + "Docker runner must consume the standalone tree that carries THIRD_PARTY_NOTICES.md" + ); +}); diff --git a/tests/unit/fix-tls-client-node-binary-7802.test.ts b/tests/unit/fix-tls-client-node-binary-7802.test.ts index c4c12099498..705c377f84a 100644 --- a/tests/unit/fix-tls-client-node-binary-7802.test.ts +++ b/tests/unit/fix-tls-client-node-binary-7802.test.ts @@ -1,11 +1,57 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fixTlsClientNodeBinary } from "../../scripts/build/fixTlsClientNodeBinary.mjs"; +const ROOT = join(import.meta.dirname, "..", ".."); + +test("native manifest pins v1.15.1 to GitHub's official digests on every supported platform", () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8") + ); + + assert.equal(manifest.version, "1.15.1"); + assert.equal(manifest.source, "https://github.com/bogdanfinn/tls-client/releases/tag/v1.15.1"); + assert.deepEqual(manifest.assets, { + "darwin-arm64": { + file: "tls-client-darwin-arm64-1.15.1.dylib", + sha256: "b36167372a93337195b84a8b8e7ed2e63ba654b7bbe3e35cd4f96ad3196458e6", + }, + "darwin-x64": { + file: "tls-client-darwin-amd64-1.15.1.dylib", + sha256: "7cb2c6833dc2b7e4b59bf46798f0e214bac746143e36bf9cd5ec92fde6ec8465", + }, + "linux-arm64": { + file: "tls-client-linux-arm64-1.15.1.so", + sha256: "048b75c4fb0898a306228198d545eece39a7d5348200487f0395fbdc4168fe39", + }, + "linux-x64": { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: "e393e866060e238bc36509f853293cebf5af8286aede59814462693efb603b1e", + }, + "win32-ia32": { + file: "tls-client-windows-32-1.15.1.dll", + sha256: "46f44779f41c74918a6d1d0ecadc090aa8bd5303e07ca8dd3a0b999467b76a42", + }, + "win32-x64": { + file: "tls-client-windows-64-1.15.1.dll", + sha256: "414b5e5c60f9200948a46afd023865ad00c7d37403056a7e74ceee27ce2b0287", + }, + }); +}); + function makeRoot() { return mkdtempSync(join(tmpdir(), "fix-tls-client-node-binary-7802-")); } @@ -15,6 +61,51 @@ function collectLogs() { return { logs, log: (m: string) => logs.push(m) }; } +test("replaces a tampered binary with the pinned version and copies only verified bytes", async () => { + const rootDir = makeRoot(); + try { + const goodBytes = "verified-native-binary"; + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(goodBytes).digest("hex"), + }; + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + const scriptsDir = join(tlsClientDir, "scripts"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(rootBin, asset.file), "tampered"); + writeFileSync( + join(scriptsDir, "postinstall.js"), + `const fs = require("fs"); + const path = require("path"); + if (process.env.TLS_CLIENT_VERSION !== "1.15.1") process.exit(9); + fs.writeFileSync(path.join(__dirname, "..", ".observed-version"), process.env.TLS_CLIENT_VERSION); + fs.writeFileSync(path.join(__dirname, "..", "bin", ${JSON.stringify(asset.file)}), ${JSON.stringify(goodBytes)});` + ); + mkdirSync(join(rootDir, "dist", "node_modules", "tls-client-node"), { recursive: true }); + + await fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + log() {}, + }); + + assert.equal(readFileSync(join(tlsClientDir, ".observed-version"), "utf8"), "1.15.1"); + assert.equal( + readFileSync( + join(rootDir, "dist", "node_modules", "tls-client-node", "bin", asset.file), + "utf8" + ), + goodBytes + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + test("no-ops when node_modules/tls-client-node is absent (module not installed)", async () => { const rootDir = makeRoot(); try { @@ -29,19 +120,24 @@ test("no-ops when node_modules/tls-client-node is absent (module not installed)" test("copies an already-populated root bin/ into the standalone dist bundle (#7802 item 2)", async () => { const rootDir = makeRoot(); try { + const binary = "fake-binary"; + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; 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"); + writeFileSync(join(rootBin, asset.file), binary); const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node"); mkdirSync(distTlsClientDir, { recursive: true }); const { log } = collectLogs(); - await fixTlsClientNodeBinary({ rootDir, log }); + await fixTlsClientNodeBinary({ rootDir, asset, 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"]); + assert.deepEqual(readdirSync(distBin), [asset.file]); } finally { rmSync(rootDir, { recursive: true, force: true }); } @@ -50,6 +146,11 @@ test("copies an already-populated root bin/ into the standalone dist bundle (#78 test("retries the download when root bin/ is empty, and stops once a file appears (#7802 item 3)", async () => { const rootDir = makeRoot(); try { + const binary = "ok"; + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); const rootBin = join(tlsClientDir, "bin"); mkdirSync(rootBin, { recursive: true }); @@ -66,14 +167,14 @@ test("retries the download when root bin/ is empty, and stops once a file appear 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"); + fs.writeFileSync(path.join(__dirname, "..", "bin", ${JSON.stringify(asset.file)}), ${JSON.stringify(binary)}); }` ); const { logs, log } = collectLogs(); - await fixTlsClientNodeBinary({ rootDir, log, retryDelaysMs: [1, 1, 1] }); + await fixTlsClientNodeBinary({ rootDir, asset, log, retryDelaysMs: [1, 1, 1] }); - assert.ok(existsSync(join(rootBin, "tls-client-linux-ubuntu-amd64-1.0.0.so"))); + assert.ok(existsSync(join(rootBin, asset.file))); assert.ok( logs.some((m) => m.includes("fetched successfully")), "expected a success log once the retry recovered" @@ -98,9 +199,7 @@ test("warns without throwing when every retry leaves bin/ empty (still rate-limi console.warn = (m: string) => warnings.push(m); try { const { log } = collectLogs(); - await assert.doesNotReject( - fixTlsClientNodeBinary({ rootDir, log, retryDelaysMs: [1, 1] }) - ); + await assert.doesNotReject(fixTlsClientNodeBinary({ rootDir, log, retryDelaysMs: [1, 1] })); } finally { console.warn = originalWarn; } @@ -113,3 +212,35 @@ test("warns without throwing when every retry leaves bin/ empty (still rate-limi rmSync(rootDir, { recursive: true, force: true }); } }); + +test("strict mode rejects an unverified download instead of shipping it", async () => { + const rootDir = makeRoot(); + try { + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update("expected").digest("hex"), + }; + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const scriptsDir = join(tlsClientDir, "scripts"); + mkdirSync(join(tlsClientDir, "bin"), { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync( + join(scriptsDir, "postinstall.js"), + `require("fs").writeFileSync(require("path").join(__dirname, "..", "bin", ${JSON.stringify(asset.file)}), "tampered");` + ); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /Could not fetch tls-client-node v1\.15\.1 verified native binary/ + ); + assert.equal(existsSync(join(tlsClientDir, "bin", asset.file)), false); + } 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 065ba832517..026b5782eba 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -241,6 +241,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", // alphabetically (bin/ < dist/ < scripts/ < src/), minus the paths present // above (dist/server.js, bin/omniroute.mjs, package.json, the postinstall scripts). assert.deepEqual(missingPaths, [ + "THIRD_PARTY_NOTICES.md", "bin/aliasResolver.mjs", "bin/aliasResolverHook.mjs", "bin/cli/data-dir.mjs", @@ -265,6 +266,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", "dist/systemd-notify.mjs", "dist/tls-options.mjs", "dist/webdav-handler.mjs", + "open-sse/config/tlsClientNativeManifest.json", "scripts/build/colocateOptionals.mjs", "scripts/build/fixTlsClientNodeBinary.mjs", "scripts/build/native-binary-compat.mjs", diff --git a/tests/unit/tls-client-download-dir-8579.test.ts b/tests/unit/tls-client-download-dir-8579.test.ts index 272ee732453..67351c5f0d0 100644 --- a/tests/unit/tls-client-download-dir-8579.test.ts +++ b/tests/unit/tls-client-download-dir-8579.test.ts @@ -1,9 +1,9 @@ import { test, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync } 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"; @@ -39,7 +39,7 @@ test("resolveTlsClientDownloadDir caches native binary under DATA_DIR/tls-client assert.equal(resolveTlsClientDownloadDir(), join(dataDir, "tls-client", "bin")); }); -test("buildNativeTlsClientOptions passes downloadDir to tls-client-node (#8579)", async () => { +test("buildNativeTlsClientOptions pins v1.15.1 and passes downloadDir to tls-client-node", async () => { const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-opts-8579-")); process.env.DATA_DIR = dataDir; @@ -49,15 +49,76 @@ test("buildNativeTlsClientOptions passes downloadDir to tls-client-node (#8579)" const options = buildNativeTlsClientOptions(); assert.equal(options.runtimeMode, "native"); + assert.equal(options.version, "1.15.1"); assert.equal(options.downloadDir, join(dataDir, "tls-client", "bin")); }); +test("runtime downloader verifies v1.15.1 before exposing nativeLibraryPath", async () => { + const downloadDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-verified-")); + try { + const bytes = Buffer.from("verified-runtime-binary"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const requestedUrls: string[] = []; + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + const libraryPath = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + fetchImpl: async (url: string | URL) => { + requestedUrls.push(String(url)); + return new Response(bytes, { status: 200 }); + }, + }); + + assert.deepEqual(requestedUrls, [ + `https://github.com/bogdanfinn/tls-client/releases/download/v1.15.1/${asset.file}`, + ]); + assert.equal(libraryPath, join(downloadDir, asset.file)); + assert.deepEqual(readFileSync(libraryPath), bytes); + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } +}); + +test("runtime downloader rejects bytes that do not match the official digest", async () => { + const downloadDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-rejected-")); + try { + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update("expected").digest("hex"), + }; + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + await assert.rejects( + resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + fetchImpl: async () => new Response("tampered", { status: 200 }), + }), + /SHA-256 mismatch for tls-client v1\.15\.1/ + ); + assert.equal(existsSync(join(downloadDir, asset.file)), false); + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } +}); + test("all web-provider tls clients wire downloadDir through buildNativeTlsClientOptions (#8579)", () => { const base = readFileSync(join(ROOT, "open-sse/services/tlsClientBase.ts"), "utf8"); assert.match( base, - /buildNativeTlsClientOptions\(\)/, - "tlsClientBase.ts must pass buildNativeTlsClientOptions() to TLSClient" + /resolveVerifiedTlsClientNativeLibrary\(\)/, + "tlsClientBase.ts must verify the pinned native library before TLSClient loads it" + ); + assert.match( + base, + /buildNativeTlsClientOptions\(nativeLibraryPath\)/, + "tlsClientBase.ts must pass the verified nativeLibraryPath to TLSClient" ); assert.doesNotMatch( base, diff --git a/tests/unit/tls-client-node-docker-binary-7802.test.ts b/tests/unit/tls-client-node-docker-binary-7802.test.ts index ff4aab0a609..6a1cb171f51 100644 --- a/tests/unit/tls-client-node-docker-binary-7802.test.ts +++ b/tests/unit/tls-client-node-docker-binary-7802.test.ts @@ -32,6 +32,27 @@ test("Dockerfile's --ignore-scripts npm ci is compensated for tls-client-node's "expected postinstall.mjs to repair wreq-js's native binary" ); + assert.match( + dockerfile, + /COPY scripts\/build\/fixTlsClientNodeBinary\.mjs \.\/scripts\/build\/fixTlsClientNodeBinary\.mjs/, + "Docker builder must copy the checksum-verifying repair helper" + ); + assert.match( + dockerfile, + /COPY open-sse\/config\/tlsClientNativeManifest\.json \.\/open-sse\/config\/tlsClientNativeManifest\.json/, + "Docker builder must copy the pinned version and official SHA-256 manifest" + ); + assert.match( + dockerfile, + /node scripts\/build\/fixTlsClientNodeBinary\.mjs --strict/, + "Docker build must fail closed when the pinned native binary is absent or unverified" + ); + assert.doesNotMatch( + dockerfile, + /node node_modules\/tls-client-node\/scripts\/postinstall\.js/, + "Docker must not bypass checksum verification by invoking the upstream downloader directly" + ); + const dockerfileHandlesIt = /tls-client-node[\s\S]{0,200}(postinstall|rebuild|download)/i.test( dockerfile ); From e963aba3b68f974b93adc57167aca3df40209df7 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:48:41 -0300 Subject: [PATCH 2/6] docs(changelog): link tls-client provenance PR --- ...nse-provenance.md => 11742-tls-client-license-provenance.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/maintenance/{00000-tls-client-license-provenance.md => 11742-tls-client-license-provenance.md} (62%) diff --git a/changelog.d/maintenance/00000-tls-client-license-provenance.md b/changelog.d/maintenance/11742-tls-client-license-provenance.md similarity index 62% rename from changelog.d/maintenance/00000-tls-client-license-provenance.md rename to changelog.d/maintenance/11742-tls-client-license-provenance.md index aa03e5a71bf..b0fcaf68dfa 100644 --- a/changelog.d/maintenance/00000-tls-client-license-provenance.md +++ b/changelog.d/maintenance/11742-tls-client-license-provenance.md @@ -1 +1 @@ -- **security(deps):** pin `tls-client-node@0.2.0`, ship its exact Commons Clause/Apache and upstream BSD-4 notices, pin `bogdanfinn/tls-client` to v1.15.1, and verify every native binary against GitHub's official SHA-256 before loading it ([#00000](https://github.com/diegosouzapw/OmniRoute/pull/00000)). +- **security(deps):** pin `tls-client-node@0.2.0`, ship its exact Commons Clause/Apache and upstream BSD-4 notices, pin `bogdanfinn/tls-client` to v1.15.1, and verify every native binary against GitHub's official SHA-256 before loading it ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)). From b40a9fc3daa567a485540263f5ddc54648cba6db Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:48:13 -0300 Subject: [PATCH 3/6] security(runtime): harden TLS and public error boundaries --- .env.example | 12 + .github/workflows/electron-release.yml | 5 + Dockerfile | 4 +- Dockerfile.bun | 20 +- README.md | 6 +- THIRD_PARTY_NOTICES.md | 4 +- .../11742-tls-client-license-provenance.md | 4 +- config/quality/.license-allowlist.json | 8 +- config/quality/eslint-suppressions.json | 5 - docs/diagrams/comparison-table.svg | 8 +- docs/diagrams/privacy-local.svg | 6 +- docs/i18n/it/README.md | 10 +- docs/i18n/ru/README.md | 8 +- docs/i18n/tr/README.md | 10 +- docs/reference/ENVIRONMENT.md | 3 + docs/security/ERROR_SANITIZATION.md | 111 +- open-sse/executors/chatgpt-web.ts | 220 ++-- open-sse/executors/chatgpt-web/handoff.ts | 34 +- open-sse/executors/chatgptWebTools.ts | 1 + open-sse/executors/claude-web.ts | 30 +- open-sse/executors/grok-web.ts | 43 +- open-sse/executors/lmarena.ts | 20 +- open-sse/executors/lmarena/error.ts | 17 + open-sse/executors/lmarena/models.ts | 5 +- open-sse/executors/lmarena/response.ts | 75 +- open-sse/executors/notion-web.ts | 69 +- open-sse/executors/perplexity-web.ts | 503 ++++++--- open-sse/executors/perplexity-web/protocol.ts | 13 +- open-sse/handlers/chatCore.ts | 402 ++++--- .../handlers/chatCore/streamErrorResult.ts | 76 +- open-sse/handlers/moderations.ts | 20 +- open-sse/handlers/ocr.ts | 18 +- open-sse/services/tlsClientBase.ts | 778 +++++-------- open-sse/services/tlsClientDownloadDir.ts | 592 +++++++++- .../services/tlsClientLifecycleRegistry.ts | 273 +++++ open-sse/services/tlsClientStream.ts | 416 +++++++ open-sse/services/tlsClientTimeout.ts | 70 ++ open-sse/utils/error.ts | 287 +++-- open-sse/utils/errorPathRedaction.ts | 830 ++++++++++++++ open-sse/utils/errorSanitization.ts | 440 ++++++++ open-sse/utils/upstreamErrorPassthrough.ts | 39 +- open-sse/utils/upstreamErrorResponse.ts | 47 + package.json | 10 +- scripts/build/assembleStandalone.mjs | 246 ++-- scripts/build/build-next-isolated.mjs | 94 +- scripts/build/fixTlsClientNodeBinary.mjs | 827 ++++++++++++-- scripts/build/pack-artifact-policy.ts | 38 + scripts/build/prepare-electron-standalone.mjs | 27 + scripts/build/prepublish.ts | 35 +- scripts/build/standaloneSidecarCopy.mjs | 121 ++ scripts/build/tlsClientAssetCopy.mjs | 446 ++++++++ scripts/check/check-licenses.mjs | 112 +- src/app/api/providers/validate/route.ts | 8 +- src/lib/logPayloads.ts | 17 +- src/lib/providers/validation/transport.ts | 99 +- src/lib/providers/validation/webProvidersA.ts | 145 ++- src/lib/providers/validation/webProvidersB.ts | 86 +- ...next-isolated-assembly-fail-closed.test.ts | 185 +++ tests/unit/build/check-licenses.test.ts | 299 ++++- .../build/electron-tls-client-seed.test.ts | 165 +++ .../build/tls-client-assembly-digest.test.ts | 445 ++++++++ .../tls-client-license-provenance.test.ts | 225 +++- tests/unit/build/tls-client-pack-seed.test.ts | 184 +++ .../unit/chatcore-stream-error-result.test.ts | 86 ++ tests/unit/chatcore-translation-paths.test.ts | 579 +++++++--- tests/unit/chatgpt-web-handoff-resume.test.ts | 99 ++ tests/unit/chatgpt-web-tools-5240.test.ts | 89 +- tests/unit/chatgpt-web.test.ts | 1004 ++++++++++++++++- tests/unit/combo-diagnostics-trace.test.ts | 87 +- ...r-message-sanitization-credentials.test.ts | 989 ++++++++++++++++ tests/unit/error-message-sanitization.test.ts | 539 +++++++++ tests/unit/executor-notion-web.test.ts | 174 ++- .../fix-tls-client-node-binary-7802.test.ts | 741 +++++++++++- ...ix-tls-client-node-binary-security.test.ts | 493 ++++++++ tests/unit/grok-web.test.ts | 315 +++++- tests/unit/lmarena-provider.test.ts | 291 ++++- ...marena-stream-readiness-repro-9306.test.ts | 162 ++- tests/unit/moderations-handler.test.ts | 134 ++- tests/unit/ocr-handler-dispatch.test.ts | 189 ++++ tests/unit/pack-artifact-policy.test.ts | 46 + tests/unit/perplexity-web.test.ts | 527 +++++++-- ...ider-validation-error-sanitization.test.ts | 156 +++ .../provider-validation-specialty.test.ts | 483 +++++++- .../repro-9406-claude-web-429-valid.test.ts | 77 +- tests/unit/request-log-payloads.test.ts | 28 + .../stream-failure-499-classification.test.ts | 51 +- .../unit/tls-client-download-dir-8579.test.ts | 846 +++++++++++++- .../unit/tls-client-install-lock-race.test.ts | 220 ++++ tests/unit/tls-client-lifecycle.test.ts | 873 ++++++++++++++ ...tls-client-node-docker-binary-7802.test.ts | 107 +- tests/unit/tls-client-timeout.test.ts | 88 ++ .../unit/types-barrel-model-cooldown.test.ts | 20 + tests/unit/upstream-error-passthrough.test.ts | 60 +- 93 files changed, 16941 insertions(+), 1968 deletions(-) create mode 100644 open-sse/executors/lmarena/error.ts create mode 100644 open-sse/services/tlsClientLifecycleRegistry.ts create mode 100644 open-sse/services/tlsClientStream.ts create mode 100644 open-sse/services/tlsClientTimeout.ts create mode 100644 open-sse/utils/errorPathRedaction.ts create mode 100644 open-sse/utils/errorSanitization.ts create mode 100644 open-sse/utils/upstreamErrorResponse.ts create mode 100644 scripts/build/standaloneSidecarCopy.mjs create mode 100644 scripts/build/tlsClientAssetCopy.mjs create mode 100644 tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts create mode 100644 tests/unit/build/electron-tls-client-seed.test.ts create mode 100644 tests/unit/build/tls-client-assembly-digest.test.ts create mode 100644 tests/unit/build/tls-client-pack-seed.test.ts create mode 100644 tests/unit/error-message-sanitization-credentials.test.ts create mode 100644 tests/unit/fix-tls-client-node-binary-security.test.ts create mode 100644 tests/unit/provider-validation-error-sanitization.test.ts create mode 100644 tests/unit/tls-client-install-lock-race.test.ts create mode 100644 tests/unit/tls-client-lifecycle.test.ts create mode 100644 tests/unit/tls-client-timeout.test.ts diff --git a/.env.example b/.env.example index c1a1f06e674..76fb26df3a0 100644 --- a/.env.example +++ b/.env.example @@ -2530,6 +2530,11 @@ APP_LOG_TO_FILE=true # should leave this unset; the sidecar is auto-managed. # OMNIROUTE_TLS_PROXY_URL= +# Optional read-only seed directory for the pinned tls-client-node native binary. +# The file name and SHA-256 must match the bundled manifest; symlinks are rejected. +# Used by: open-sse/services/tlsClientDownloadDir.ts +# OMNIROUTE_TLS_CLIENT_SEED_DIR= + # ── Skills sandbox (experimental) ── # Used by: src/lib/skills/builtins.ts. All values support comma lists where # noted in the source. @@ -2637,6 +2642,13 @@ APP_LOG_TO_FILE=true # Default (when unset): 1 (tarballs emitted). Set to 0 to disable. # OMNIROUTE_OPTIONAL_PACK_TAR=1 +# Electron packaging target passed to prepare-electron-standalone.mjs. The root +# electron:build:* scripts and release workflow set these automatically; leave +# them unset for host-native local preparation. Platform accepts win32, darwin, +# or linux. Arches is a comma-separated list such as x64 or x64,arm64. +# OMNIROUTE_ELECTRON_TARGET_PLATFORM= +# OMNIROUTE_ELECTRON_TARGET_ARCHES= + # Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs). # ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login # ELECTRON_SMOKE_TIMEOUT_MS=45000 diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index e899a664eaa..cb2684ed476 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -95,6 +95,9 @@ jobs: OMNIROUTE_USE_TURBOPACK: "0" run: npm run build + - name: Verify TLS client runtime seed + run: node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone + - name: Pack standalone bundle # Deterministic tar.gz + byte-level manifest; the manifest embeds the # archive's own sha256 so artifact-transfer corruption is caught before @@ -258,6 +261,8 @@ jobs: working-directory: electron env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OMNIROUTE_ELECTRON_TARGET_PLATFORM: ${{ matrix.os }} + OMNIROUTE_ELECTRON_TARGET_ARCHES: ${{ matrix.arch }} run: npm run build:${{ matrix.target }} - name: Smoke packaged Electron app diff --git a/Dockerfile b/Dockerfile index c0e4cf45f33..1e500e70ceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -199,6 +199,7 @@ COPY . ./ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \ mkdir -p /app/data \ && npm run build \ + && node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone \ && node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);" # ── Runner base ──────────────────────────────────────────────────────────── @@ -207,8 +208,7 @@ FROM base AS runner-base LABEL org.opencontainers.image.title="omniroute" \ org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \ org.opencontainers.image.url="https://omniroute.online" \ - org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \ - org.opencontainers.image.licenses="MIT" + org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" ENV NODE_ENV=production ENV PORT=20128 diff --git a/Dockerfile.bun b/Dockerfile.bun index bb547ce210d..71b4b3aaf34 100644 --- a/Dockerfile.bun +++ b/Dockerfile.bun @@ -29,10 +29,8 @@ RUN if [ -d "node_modules/better-sqlite3" ]; then \ (cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \ fi -# Fetch tls-client-node native binary if script exists -RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \ - bun node_modules/tls-client-node/scripts/postinstall.js || true; \ - fi +# Pin and checksum-verify the tls-client-node native binary; fail closed if unavailable. +RUN bun scripts/build/fixTlsClientNodeBinary.mjs --strict # Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node) ENV OMNIROUTE_USE_TURBOPACK=0 @@ -47,7 +45,9 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production # Bun native Next.js build execution -RUN bun run --quiet build +RUN bun run --quiet build \ + && bun scripts/build/fixTlsClientNodeBinary.mjs --strict \ + --standalone-dir .build/next/standalone # ── Runner Base stage (100% Bun Native Production Runtime) ────────────────── FROM oven/bun:1.3.14-slim AS runner-base @@ -55,8 +55,7 @@ FROM oven/bun:1.3.14-slim AS runner-base LABEL org.opencontainers.image.title="omniroute" \ org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \ org.opencontainers.image.url="https://omniroute.online" \ - org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \ - org.opencontainers.image.licenses="MIT" + org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" WORKDIR /app @@ -73,14 +72,17 @@ ENV HOSTNAME=0.0.0.0 ENV OMNIROUTE_MEMORY_MB=1024 ENV DATA_DIR=/app/data -RUN mkdir -p /app/data +RUN mkdir -p /app/data \ + && chown -R bun:bun /app/data -COPY --from=builder /app/.build/next/standalone ./ +COPY --from=builder --chown=bun:bun /app/.build/next/standalone ./ COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs +USER bun + EXPOSE 20128 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ diff --git a/README.md b/README.md index aca28600b8d..eba33a294b4 100644 --- a/README.md +++ b/README.md @@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 352 providers, 90+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 352 providers, 90+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute's own code is MIT-licensed and self-hostable; optional third-party dependencies retain their own licenses. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -469,7 +469,7 @@ All **19** strategies — mix & match per combo step: ## 💚 Support OmniRoute -OmniRoute is MIT-licensed and maintained in the open. If it saves you time or money, here's how to keep it independent — pick whatever fits you. Sponsorship never affects routing priority; it buys visibility, not ranking. +OmniRoute's own code is MIT-licensed and maintained in the open; bundled and optional third-party components retain their own licenses. If it saves you time or money, here's how to keep it independent — pick whatever fits you. Sponsorship never affects routing priority; it buys visibility, not ranking. @@ -769,7 +769,7 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl -Private and local-first — OmniRoute's gateway and control plane run on your machine. Prompts are sent to the upstream provider selected for each request; OmniRoute adds no hosted prompt-processing hop and telemetry is disabled by default. Credentials are encrypted at rest with AES-256-GCM; controls include API-key scoping, IP filtering, rate limits, prompt-injection guards, upstream-header scrubbing, opt-in PII redaction, sanitized errors and a local SQLite audit trail. OmniRoute is MIT-licensed and self-hostable. +Private and local-first — OmniRoute's gateway and control plane run on your machine. Prompts are sent to the upstream provider selected for each request; OmniRoute adds no hosted prompt-processing hop and telemetry is disabled by default. Credentials are encrypted at rest with AES-256-GCM; controls include API-key scoping, IP filtering, rate limits, prompt-injection guards, upstream-header scrubbing, opt-in PII redaction, sanitized errors and a local SQLite audit trail. OmniRoute's own code is MIT-licensed and self-hostable; bundled and optional third-party components retain their own licenses.📖 [Authorization](docs/architecture/AUTHZ_GUIDE.md) · [Guardrails](docs/security/GUARDRAILS.md) · [Compliance](docs/security/COMPLIANCE.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5e62c56b83a..d262d12c67b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -28,8 +28,8 @@ OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWA ## fatihkabakk/tls-client-node 0.2.0 OmniRoute includes [`tls-client-node@0.2.0`](https://github.com/fatihkabakk/tls-client-node/tree/v0.2.0) -as an optional runtime dependency. The package is source-available under Apache License 2.0 -with the Commons Clause License Condition v1.0. The following license and NOTICE blocks are +as an optional runtime dependency. The package is source-available, not OSI-approved, under +Apache License 2.0 with the Commons Clause License Condition v1.0. The following license and NOTICE blocks are reproduced verbatim from the tagged primary sources. ### tls-client-node license diff --git a/changelog.d/maintenance/11742-tls-client-license-provenance.md b/changelog.d/maintenance/11742-tls-client-license-provenance.md index b0fcaf68dfa..3ec28786266 100644 --- a/changelog.d/maintenance/11742-tls-client-license-provenance.md +++ b/changelog.d/maintenance/11742-tls-client-license-provenance.md @@ -1 +1,3 @@ -- **security(deps):** pin `tls-client-node@0.2.0`, ship its exact Commons Clause/Apache and upstream BSD-4 notices, pin `bogdanfinn/tls-client` to v1.15.1, and verify every native binary against GitHub's official SHA-256 before loading it ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)). +- **security(deps):** pin `tls-client-node@0.2.0`, ship its exact Commons Clause/Apache and upstream BSD-4 notices, pin `bogdanfinn/tls-client` to v1.15.1, and verify every supported native binary against GitHub's official SHA-256 before loading it ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)). +- **fix(runtime):** serialize native TLS installation and client ownership, bound caller waits for startup/requests, and coordinate stream and lease cleanup across completion, error, cancellation, and process-exit hooks ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)). +- **security(runtime):** fail closed on hostile thrown values and redact credentials, recognized filesystem paths, and stack frames from upstream HTTP/SSE errors and diagnostic logs in chat, provider validation, moderation, and OCR boundaries ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)). diff --git a/config/quality/.license-allowlist.json b/config/quality/.license-allowlist.json index 0f314dfa155..a4582965f4c 100644 --- a/config/quality/.license-allowlist.json +++ b/config/quality/.license-allowlist.json @@ -76,13 +76,15 @@ "reviewAt": "v4.0.0" }, "tls-client-node": { - "license": "Custom: LICENSE (Apache-2.0 + Commons Clause)", - "justification": "TEMPORARY bridge for a restrictive, source-available dependency: tls-client-node uses Apache-2.0 with the Commons Clause, which removes the right to Sell the software when a paid product or service derives entirely or substantially from its functionality. The shared native TLS transport serves six providers: chatgpt-web, claude-web, perplexity-web, grok-web, notion-web, and lmarena. Shipping the required notices does not grant commercial rights. Legal review is required before commercial deployment; replace with a permissive transport such as wreq-js or obtain separate permission before this exception expires.", + "version": "0.2.0", + "license": "Custom: LICENSE", + "justification": "TEMPORARY bridge for a restrictive, source-available dependency: tls-client-node uses Apache-2.0 with the Commons Clause, which removes the right to Sell the software when a paid product or service derives entirely or substantially from its functionality. The shared native TLS transport serves six providers: chatgpt-web, claude-web, perplexity-web, grok-web, notion-web, and lmarena. Shipping the required notices does not grant commercial rights. Legal review is required before commercial deployment; replace with a permissive transport such as wreq-js or obtain separate permission before this exception expires. Tracked by PR #11742.", "risk": "medium", "temporary": true, "owner": "@diegosouzapw", "reviewBy": "2026-09-30", - "reviewAt": "v3.9.0" + "reviewAt": "v3.9.0", + "classification": "Apache-2.0 with Commons Clause; non-OSI source-available" } } } diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index ff58151ba20..55aa0f9d3c6 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -706,11 +706,6 @@ "count": 1 } }, - "open-sse/services/tlsClientBase.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "open-sse/services/tokenLimitCounter.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index d75b8c2db11..577714bb384 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. @@ -124,8 +124,8 @@ - Self-hosted · 100% MIT - MIT + Self-hosted · own code license + MIT* MIT MIT @@ -133,6 +133,6 @@ - full • partial • none  ·  *OpenRouter counts models & is a hosted SaaS, not self-hosted. + full • partial • none  ·  *Own code MIT; optional dependencies retain their licenses. OpenRouter counts models. verified from each project's docs diff --git a/docs/diagrams/privacy-local.svg b/docs/diagrams/privacy-local.svg index b571eb7936d..3f8673c3a23 100644 --- a/docs/diagrams/privacy-local.svg +++ b/docs/diagrams/privacy-local.svg @@ -1,4 +1,4 @@ - + Animated privacy ledger: eleven fully readable rows on the first frame; a soft green highlight sweeps down the rows in a continuous cycle. @@ -15,11 +15,11 @@ PRIVATE & LOCAL-FIRST - Your keys, your machine, your data. OmniRoute is a local proxy — it never phones home. + Your keys, your machine, your data. No OmniRoute-hosted prompt hop; telemetry off by default. - Runs 100% on your hardware — npm, Docker, desktop, or your phone — no OmniRoute cloud in the request path0 CLOUD HOPSZero telemetry by default — your prompts go only to the providers you choose, nowhere elseDEFAULTCredentials encrypted at rest — API keys & OAuth tokens sealed on your own diskAES-256-GCMNo account, no sign-up — a local password guards the dashboard — OmniRoute never asks who you areLOCAL AUTHHardened gateway — API-key scoping, IP filtering, rate limits, prompt-injection guardAUTHZ TIERSProcess routes are loopback-only — a token leaked through a tunnel can’t spawn processes127.0.0.1Upstream header scrubbing — deny-listed headers stripped before every provider callDENY-LISTPII redaction & response sanitization — built in, strictly opt-in — payloads are never mutated by defaultOPT-INSanitized errors — responses never leak stack traces, paths or internalsNO LEAKSLocal audit trail — MCP tool calls & admin actions logged in your SQLite, not oursYOUR DBMIT licensed & fully open-source — audit every line, self-host foreverMIT + Request routing runs on your hardware — npm, Docker, desktop, or your phoneSELF-HOSTEDTelemetry disabled by default — activates only when explicitly configuredDEFAULTCredentials encrypted at rest — API keys & OAuth tokens sealed on your own diskAES-256-GCMNo OmniRoute-hosted account service — operator controls dashboard identity: local password or optional OIDCLOCAL AUTHHardened gateway — API-key scoping, IP filtering, rate limits, prompt-injection guardAUTHZ TIERSProcess routes are loopback-only — a token leaked through a tunnel can’t spawn processes127.0.0.1Upstream header scrubbing — deny-listed headers stripped before every provider callDENY-LISTPII redaction & response sanitization — built in, strictly opt-in — These redactions run only when enabledOPT-INSanitized errors — redact credentials, stack traces & recognized file pathsREDACTEDLocal audit trail — MCP tool calls & admin actions logged in your SQLite, not oursYOUR DBOmniRoute own code: MIT — bundled third-party components retain their own licensesMIT diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md index cbb7f49c4ca..f9d0c70ff82 100644 --- a/docs/i18n/it/README.md +++ b/docs/i18n/it/README.md @@ -397,7 +397,7 @@ Tutte e **19** le strategie — combinabili liberamente per ogni passaggio della -Cosa distingue OmniRoute — tabella di confronto con 9router, OpenRouter, CLIProxyAPI e LiteLLM su 13 capacità. OmniRoute: 349 provider, oltre 90 provider gratuiti integrati, 19 strategie di routing, compressione token con 12 motori, server MCP integrato con 110 tool, protocollo agenti A2A, memoria persistente, guardrail, cloud agent, TLS fingerprint stealth, Desktop/Termux/PWA, 43 locale UI i18n, self-hosting 100% MIT. OmniRoute è l'unico a includere l'intero insieme; i concorrenti mostrano combinazioni di supporto completo, parziale e assente. Verificato sulla documentazione di ciascun progetto. +Cosa distingue OmniRoute — tabella di confronto con 9router, OpenRouter, CLIProxyAPI e LiteLLM su 13 capacità. OmniRoute: 352 provider, oltre 90 provider gratuiti integrati, 19 strategie di routing, compressione token con 12 motori, server MCP integrato con 110 tool, protocollo agenti A2A, memoria persistente, guardrail, cloud agent, TLS fingerprint stealth, Desktop/Termux/PWA e 43 locale UI i18n. Il codice proprio di OmniRoute è MIT; le dipendenze opzionali di terze parti mantengono le rispettive licenze. OmniRoute è l'unico a includere l'intero insieme; i concorrenti mostrano combinazioni di supporto completo, parziale e assente. Verificato sulla documentazione di ciascun progetto.📊 Metodologia completa e dettaglio per funzionalità rispetto a 9router, OpenRouter, CLIProxyAPI e LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](../../comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -406,7 +406,11 @@ Tutte e **19** le strategie — combinabili liberamente per ogni passaggio della ## 💚 Supporta OmniRoute -OmniRoute è distribuito con licenza MIT e mantenuto apertamente. Se ti fa risparmiare tempo o denaro, ecco come aiutarlo a restare indipendente — scegli ciò che preferisci. Le sponsorizzazioni non influenzano mai la priorità del routing: acquistano visibilità, non posizionamento. +Il codice proprio di OmniRoute è distribuito con licenza MIT e mantenuto apertamente; i +componenti di terze parti inclusi e opzionali mantengono le rispettive licenze. Se ti fa +risparmiare tempo o denaro, ecco come aiutarlo a restare indipendente — scegli ciò che preferisci. +Le sponsorizzazioni non influenzano mai la priorità del routing: acquistano visibilità, non +posizionamento.
Star the repoFree — genuinely helps visibilityStar OmniRoute
@@ -701,7 +705,7 @@ Dall'editor: apri la vista **Extensions**, cerca **"OmniRoute"**, fai clic su ** -Privato e local-first — le tue chiavi, la tua macchina, i tuoi dati; OmniRoute è un proxy locale che non comunica autonomamente con servizi cloud. Undici garanzie: gira al 100% sul tuo hardware (0 passaggi cloud), telemetria disattivata per impostazione predefinita, credenziali cifrate a riposo (AES-256-GCM), nessun account o registrazione, gateway rafforzato (scoping delle API key, filtro IP, rate limit, difesa dalla prompt injection), route di processo limitate al loopback, pulizia degli header upstream, redazione PII rigorosamente opt-in, errori sanitizzati che non espongono dettagli interni, audit trail locale nel tuo SQLite e codice completamente open source con licenza MIT. +Privato e local-first — le tue chiavi, la tua macchina, i tuoi dati; OmniRoute non aggiunge un passaggio di elaborazione dei prompt ospitato da OmniRoute; la telemetria è disattivata per impostazione predefinita. Undici garanzie: il routing delle richieste viene eseguito sul tuo hardware, credenziali cifrate a riposo (AES-256-GCM), nessun servizio di account ospitato da OmniRoute; l'operatore controlla l'identità della dashboard tramite una password locale o OIDC opzionale, gateway rafforzato (scoping delle API key, filtro IP, rate limit, difesa dalla prompt injection), route di processo limitate al loopback, pulizia degli header upstream, redazione PII rigorosamente opt-in, errori sanitizzati che oscurano credenziali, stack trace e percorsi filesystem riconosciuti, audit trail locale nel tuo SQLite e codice proprio di OmniRoute con licenza MIT; i componenti di terze parti inclusi mantengono le rispettive licenze.📖 [Autorizzazione](../../architecture/AUTHZ_GUIDE.md) · [Guardrail](../../security/GUARDRAILS.md) · [Conformità](../../security/COMPLIANCE.md) diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index 54664d68d39..a3fccc452bd 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -354,13 +354,13 @@ Combo: "always-on" strategy: priority -> Ваши ключи, ваша машина, ваши данные. OmniRoute — **локальный прокси**, без «звонков домой». +> Ваши ключи, ваша машина, ваши данные. OmniRoute не добавляет этап обработки промптов, размещённый на инфраструктуре OmniRoute; телеметрия по умолчанию отключена. -- 🏠 **100% на вашем железе** — npm, Docker, desktop или телефон. Нет cloud-hop OmniRoute. +- 🏠 **Роутинг выполняется локально** — npm, Docker, desktop или телефон; выбранные провайдеры остаются внешними upstream-сервисами. - 🔐 **Credentials at rest** — API keys и OAuth в **AES-256-GCM**. -- 🚫 **Zero telemetry по умолчанию** — промпты уходят только выбранным провайдерам. +- 🚫 **Телеметрия по умолчанию отключена** — включается только при явной настройке. - 🛡️ **Жёсткий gateway** — scoping ключей, IP filter, rate limits, prompt-injection guard, loopback-only process routes. -- 📜 **MIT, fully open-source** — аудируйте построчно, self-host навсегда. +- 📜 **Собственный код OmniRoute — MIT** — проект открыт; включённые сторонние компоненты сохраняют свои лицензии. Смотрите `THIRD_PARTY_NOTICES.md`. 📖 [Authorization](../../architecture/AUTHZ_GUIDE.md) · [Guardrails](../../security/GUARDRAILS.md) · [Compliance](../../security/COMPLIANCE.md) diff --git a/docs/i18n/tr/README.md b/docs/i18n/tr/README.md index 463c70caaa8..e7977dcee63 100644 --- a/docs/i18n/tr/README.md +++ b/docs/i18n/tr/README.md @@ -461,7 +461,7 @@ Tüm **19** strateji — kombo adımı başına karıştırın ve eşleştirin: -What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 349 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. +What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 352 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute's own code is MIT; optional third-party dependencies retain their licenses. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs.📊 9router, OpenRouter, CLIProxyAPI ve LiteLLM'e karşı tam metodoloji ve özellik bazında detaylar → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -469,7 +469,11 @@ Tüm **19** strateji — kombo adımı başına karıştırın ve eşleştirin: ## 💚 OmniRoute'u Destekleyin -OmniRoute, MIT lisanslıdır ve açık olarak sürdürülmektedir. Size zaman veya para tasarrufu sağlıyorsa, bağımsız kalmasını nasıl sağlayabileceğinizi buradan görebilirsiniz — size en uygun yöntemi seçin. Sponsorluk yönlendirme önceliğini asla etkilemez; sıralamayı değil, görünürlüğü sağlar. +OmniRoute'un kendi kodu MIT lisanslıdır ve açık olarak sürdürülmektedir; birlikte gelen ve +isteğe bağlı üçüncü taraf bileşenler kendi lisanslarını korur. Size zaman veya para tasarrufu +sağlıyorsa, bağımsız kalmasını nasıl sağlayabileceğinizi buradan görebilirsiniz — size en uygun +yöntemi seçin. Sponsorluk yönlendirme önceliğini asla etkilemez; sıralamayı değil, görünürlüğü +sağlar.
Metti una stella alla repoGratis — aiuta davvero la visibilitàDai una stella a OmniRoute
@@ -753,7 +757,7 @@ Düzenleyicinin içinden: **Uzantılar (Extensions)** görünümünü açın, ** -Private and local-first — your keys, your machine, your data; OmniRoute is a local proxy that never phones home. Eleven guarantees: runs 100% on your hardware (0 cloud hops), zero telemetry by default, credentials encrypted at rest (AES-256-GCM), no account or sign-up, hardened gateway (API-key scoping, IP filtering, rate limits, prompt-injection guard), loopback-only process routes, upstream header scrubbing, strictly opt-in PII redaction, sanitized errors that never leak internals, a local audit trail in your own SQLite, and MIT-licensed fully open-source code. +Private and local-first — your keys, your machine, your data; OmniRoute adds no OmniRoute-hosted prompt-processing hop; telemetry is disabled by default. Eleven guarantees: request routing runs on your hardware, credentials encrypted at rest (AES-256-GCM), OmniRoute tarafından barındırılan bir hesap hizmeti yoktur; operatör pano kimliğini yerel parola veya isteğe bağlı OIDC ile yönetir, hardened gateway (API-key scoping, IP filtering, rate limits, prompt-injection guard), loopback-only process routes, upstream header scrubbing, strictly opt-in PII redaction, sanitized errors that redact credentials, stack traces, and recognized filesystem paths, a local audit trail in your own SQLite, and OmniRoute's own code is MIT-licensed and maintained in the open; bundled third-party components retain their own licenses.📖 [Yetkilendirme](docs/architecture/AUTHZ_GUIDE.md) · [Güvenlik Önlemleri](docs/security/GUARDRAILS.md) · [Uyumluluk](docs/security/COMPLIANCE.md) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b383b5696fe..f2fbdb3b14e 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1311,6 +1311,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. | | `OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS` | `30000` | `src/lib/jobs/backupScheduleJob.ts` | Tick interval (ms) of the server-side job that executes `backup-schedule.json`. Must stay well under the 1-minute cron granularity; values below `5000` or unparseable fall back to `30000`. | | `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | +| `OMNIROUTE_TLS_CLIENT_SEED_DIR` | _(unset)_ | `open-sse/services/tlsClientDownloadDir.ts` | Optional read-only directory containing the pinned `tls-client-node` native binary. An absent file or SHA-256 mismatch falls through to the bundled seeds and verified download; a symlink, non-regular file, or file above 64 MiB is treated as an unsafe entry and aborts resolution. | | `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` for any Podman topology. Because the container cannot determine whether the engine is local or reached through Podman Machine, the warning stays topology-neutral and points to `contrib/podman/README.md`. | | `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | | `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | @@ -1601,6 +1602,8 @@ These settings were introduced after the previous environment-contract snapshot. | `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. | | `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. | | `OMNIROUTE_OPTIONAL_PACK_TAR` | `1` (enabled) | `scripts/build/optionalPackStaging.mjs` | Set `0` to skip emitting `.tar.gz` tarballs while staging optional ML/browser packs for the Electron standalone tree (pack directories and `optional-packs.index.json` are still produced). Used by the desktop release workflow to trim artifact upload size. | +| `OMNIROUTE_ELECTRON_TARGET_PLATFORM` | host platform | `scripts/build/prepare-electron-standalone.mjs` | Build-only Electron packaging target (`win32`, `darwin`, or `linux`). Root `electron:build:*` scripts and the release workflow set it automatically; leave unset for a host-native local preparation. | +| `OMNIROUTE_ELECTRON_TARGET_ARCHES` | `x64,arm64` on Linux; host arch otherwise | `scripts/build/prepare-electron-standalone.mjs` | Comma-separated Electron packaging architectures whose pinned TLS native seeds must be staged and verified. Root `electron:build:*` scripts and the release workflow set it automatically. | ### ChatGPT Web (Codex) Globale Defaults für den headless Browser und den ausgehenden Tool-Tunnel. Im Dashboard gesetzte Connection-Werte haben Vorrang. diff --git a/docs/security/ERROR_SANITIZATION.md b/docs/security/ERROR_SANITIZATION.md index 898ca209d97..d8fa088d9d4 100644 --- a/docs/security/ERROR_SANITIZATION.md +++ b/docs/security/ERROR_SANITIZATION.md @@ -1,16 +1,16 @@ --- title: "Error Message Sanitization" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.50 +lastUpdated: 2026-09-01 --- # Error Message Sanitization -> **Source of truth:** `open-sse/utils/error.ts` — `sanitizeErrorMessage`, `buildErrorBody`, `createErrorResult` -> **Tests:** `tests/unit/error-message-sanitization.test.ts` -> **Last updated:** 2026-06-28 — v3.8.40 -> **Audience:** Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers). -> **Status:** **MANDATORY** for every code path that returns an error message to a client. +> **Source of truth:** `open-sse/utils/errorSanitization.ts`, which composes `errorPathRedaction.ts` and is re-exported by `open-sse/utils/error.ts` +> **Tests:** `tests/unit/error-message-sanitization.test.ts` and `error-message-sanitization-credentials.test.ts` +> **Last updated:** 2026-09-01 — v3.8.50 +> **Audience:** Any engineer touching error responses or error log sinks (HTTP routes, SSE streams, executors, MCP handlers). +> **Status:** **MANDATORY** for every client-visible error and every untrusted or upstream-derived error value sent to a log sink. ## Why this exists @@ -20,7 +20,8 @@ CodeQL rule `js/stack-trace-exposure` (CWE-209) flags any code path where an err - Library / framework versions inferred from stack frames → targeted exploit selection. - Sensitive runtime values that may be string-interpolated into errors (DB queries, config values). -The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both classes of leakage: +The `sanitizeErrorMessage` helper implemented in `open-sse/utils/errorSanitization.ts` and +re-exported by `open-sse/utils/error.ts` strips both classes of leakage: 1. Multi-line stack traces — only the first line (the actual error message) is kept. 2. Absolute paths (`/...*.{ts,js,tsx,jsx,mjs,cjs}[:line[:col]]` and `C:\...`) — replaced with ``. @@ -32,13 +33,14 @@ The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both class Use `buildErrorBody()` — sanitization is built-in: ```ts -import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; export async function POST(req: Request) { try { // ... handler logic ... } catch (err) { - return new Response(JSON.stringify(buildErrorBody(500, String(err))), { + const safeMessage = sanitizeErrorMessage(err) || "Internal server error"; + return new Response(JSON.stringify(buildErrorBody(500, safeMessage)), { status: 500, headers: { "Content-Type": "application/json" }, }); @@ -59,7 +61,11 @@ import { } from "@omniroute/open-sse/utils/error.ts"; ``` -All of these route through `buildErrorBody` and therefore through `sanitizeErrorMessage`. **You never need to call `sanitizeErrorMessage` manually** when using these helpers. +All of these enforce the canonical sanitization/projection boundary. Helpers backed by +`buildErrorBody` sanitize automatically; helpers with protocol-specific envelopes apply equivalent +safe projections. Pass an already typed string directly. At a `catch (err)` boundary where the +value is `unknown`, normalize it with `sanitizeErrorMessage(err)` as in the example above; never +pre-coerce an unknown value with `String(err)` because a hostile coercion hook can throw. ### 2. Custom error envelopes (rare) @@ -79,17 +85,49 @@ const body = JSON.stringify({ This is the only sanctioned way to assemble a custom error body. See `open-sse/executors/cursor.ts::buildErrorResponse` for the reference implementation. +When a provider error must retain its upstream JSON shape, use +`buildSanitizedUpstreamErrorResponse()` from `open-sse/utils/upstreamErrorResponse.ts`. It parses +and recursively sanitizes valid JSON; mislabeled text or HTML becomes OmniRoute's canonical JSON +error envelope, so the response bytes always match `Content-Type: application/json`. + ### 3. Logging vs. responding -`sanitizeErrorMessage` should **only** wrap the value that crosses the network boundary. Internal logs (`pino`, `console`) should keep the full message, including stack, so operators can debug. Pattern: +Responses and logs are separate boundaries, but neither may receive untrusted upstream material +verbatim: + +- **Responses:** every dynamic message or detail goes through `buildErrorBody`, one of its wrappers, + or `sanitizeErrorMessage`. A response never receives a raw exception message, stack, or upstream + body. +- **Logs:** trusted local fields such as a provider identifier, numeric HTTP status, enumerated + classification, request ID, or validated domain identifier may be logged directly. Any error + message, error detail, or classification text derived from an upstream response, external + exception, request, plugin, or other untrusted source must go through `sanitizeErrorMessage` + (with a safe fallback) before reaching `pino`, `console`, or another sink. Request transcripts + and other non-error observability fields follow their own data-minimization policy; this error + sanitizer is not a universal transcript encoder. Do not pass a raw upstream `Error` object to the + logger: serializers may include its message and stack. +- **Classification:** code may inspect raw material in memory to classify the failure. Log only the + trusted classification and sanitized projection; do not attach the raw input as structured + context. + +A locally generated `Error` may retain its stack in access-controlled internal observability when +the application proves that neither its message nor stack contains upstream or otherwise untrusted +data. This policy does not claim that every locally generated internal stack is removed. + +Pattern for an upstream-derived failure: ```ts -try { - // ... -} catch (err) { - log.error({ err }, "handler failed"); // full err with stack — internal log - return errorResponse(500, getErrorMessage(err)); // sanitized — sent to client -} +const upstreamStatus = response.status; +const rawUpstreamText = await response.text(); +const classification = upstreamStatus === 429 ? "rate_limited" : "upstream_error"; +const safeDetail = + sanitizeErrorMessage(rawUpstreamText.trim()) || `Provider returned HTTP ${upstreamStatus}`; + +log.warn( + { providerId, status: upstreamStatus, classification, detail: safeDetail }, + "upstream request failed" +); +return errorResponse(upstreamStatus, safeDetail); ``` ### 4. Forbidden patterns @@ -112,11 +150,14 @@ const safe = String(err).split("\n")[0]; ❌ **Never** sanitize in the route and forget the SSE path. Anything that writes to a stream goes through `writeStreamError` (or its underlying `buildErrorBody`). -❌ **Never** include `process.cwd()`, `__filename`, `__dirname`, env-derived paths in error messages — they bypass the path regex and reveal the deployment topology. +❌ **Never** include `process.cwd()`, `__filename`, `__dirname`, or env-derived paths in error +messages. Path detection is deliberately bounded defense in depth; callers must not rely on the +redactor to make an avoidable disclosure safe. ## Coverage in CI -`tests/unit/error-message-sanitization.test.ts` enforces: +`tests/unit/error-message-sanitization.test.ts` and +`tests/unit/error-message-sanitization-credentials.test.ts` enforce: - Every route under `/api/model-combo-mappings/*` returns sanitized bodies on 4xx/5xx. - `sanitizeErrorMessage` strips multi-line stack traces. @@ -129,7 +170,9 @@ When adding a new route or executor, copy the assertion pattern from this file. ## Related controls - `js/stack-trace-exposure` CodeQL alerts in `.github/security` should always be **either** fixed via these helpers **or** dismissed with a comment citing this doc. -- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles structured log redaction separately. This doc covers only the response-message surface. +- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) is defense in depth for known + structured fields. It does not make arbitrary upstream strings or raw `Error` objects safe to + log, and it does not replace the untrusted-to-log boundary documented above. - Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage — keep both files aligned when adding a new exfiltration concern. ## Upstream details passthrough @@ -138,16 +181,20 @@ When adding a new route or executor, copy the assertion pattern from this file. parsed body from the upstream provider). When provided, it is sanitized by `sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`. -An optional fourth argument `classification` (`{ type?: string; code?: string }`) -preserves an explicit error type/code instead of re-deriving both from the -status-code table — used when the caller already classified the failure (e.g. -HTTP 499 → `client_disconnected`). +An optional fourth argument `classification` (`{ type?: string; code?: string }`) accepts a +caller's explicit error type/code and projects it onto the public identifier policy. Runtime guards +also reject non-string values received from untyped JavaScript or upstream parsing. Unsafe, +non-string, or empty values fall back to the status-code table rather than being reflected +verbatim — for example, HTTP 499 falls back to `client_disconnected` unless the supplied identifier +is safe. Sanitization rules applied to `upstreamDetails`: -1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths). -2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i` - are removed. +1. String leaves: run through `sanitizeErrorMessage` (strips stacks, absolute paths, labeled or + strongly identifiable credentials, and JWT-shaped secrets). +2. Key blocklist: stack/path/file/directory fields, credential/key material, authorization/cookie + fields, and opaque credential or session identifiers are removed. Explicit aggregate fields + such as `session_count` and `session_status` remain eligible after normal sanitization. 3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`. 4. Arrays are capped at 32 elements. @@ -155,6 +202,14 @@ Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pa `upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content, guardrail blocks) do not include `upstream_details`. +Those call sites may also opt into `createErrorResult(..., { passthrough: true })`. Passthrough is +limited to eligible upstream 4xx object bodies and excludes authentication-adjacent 401, 403, and +407 responses. The selected body keeps its upstream JSON shape only after recursive sanitization; +otherwise the normal OmniRoute envelope remains in place. The option replaces only the public +`Response`: internal classification fields and retry logic continue using their original values. +The tests prove this response-shape contract with synthetic payloads; they do not by themselves +prove a client's end-to-end recovery behavior. + Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to `upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)` without an upstream body. diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 438565b45cf..99c99792dd8 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -21,6 +21,7 @@ import { prepareToolMessages } from "../translator/webTools.ts"; import { buildToolModeResponse } from "./chatgptWebTools.ts"; import { createHash, randomUUID, randomBytes } from "node:crypto"; import { sha3_512Hex } from "../utils/sha3-512.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; import { tlsFetchChatGpt, TlsClientUnavailableError, @@ -403,7 +404,7 @@ async function runSessionWarmup( } catch (err) { log?.debug?.( "CGPT-WEB", - `warmup ${url} failed: ${err instanceof Error ? err.message : String(err)}` + `warmup ${sanitizeUpstreamLogDetail(url, "ChatGPT warmup endpoint")} failed: ${sanitizeUpstreamLogDetail(err)}` ); } } @@ -678,9 +679,10 @@ async function solvePow(opts: PowOptions): Promise { return `${opts.prefix}${b64}`; } } + const safeTarget = opts.target ? "" : ""; opts.log?.warn?.( "CGPT-WEB", - `PoW (${opts.label}) exhausted ${opts.maxIter} iterations against target=${opts.target || ""}; submitting unsolved token (Sentinel may reject)` + `PoW (${opts.label}) exhausted ${opts.maxIter} iterations against target=${safeTarget}; submitting unsolved token (Sentinel may reject)` ); const b64 = Buffer.from(JSON.stringify(cfg)).toString("base64"); return `${opts.prefix}${b64}`; @@ -1439,7 +1441,9 @@ async function fetchConversationDetail( if (response.status >= 400) { ctx.log?.warn?.( "CGPT-WEB", - `conversation poll ${response.status}: ${(response.text || "").slice(0, 300)}` + `conversation poll ${response.status}: ${sanitizeUpstreamLogDetail( + (response.text || "").slice(0, 300) + )}` ); return { detail: null, terminal: [401, 403, 404].includes(response.status) }; } @@ -1449,10 +1453,7 @@ async function fetchConversationDetail( terminal: false, }; } catch (err) { - ctx.log?.warn?.( - "CGPT-WEB", - `conversation poll failed: ${err instanceof Error ? err.message : String(err)}` - ); + ctx.log?.warn?.("CGPT-WEB", `conversation poll failed: ${sanitizeUpstreamLogDetail(err)}`); return { detail: null, terminal: false }; } } @@ -1485,19 +1486,20 @@ async function pollForFinalAssistantAnswer( await delayWithAbort(Math.min(intervalMs, remaining), ctx.signal); } + const safeConversationId = sanitizeOpaqueLogId(conversationId); if (last) { ctx.log?.warn?.( "CGPT-WEB", terminalPollFailure - ? `conversation poll stopped before finished_successfully; returning latest assistant text for ${conversationId}` - : `conversation poll timed out before finished_successfully; returning latest assistant text for ${conversationId}` + ? `conversation poll stopped before finished_successfully; returning latest assistant text for ${safeConversationId}` + : `conversation poll timed out before finished_successfully; returning latest assistant text for ${safeConversationId}` ); } else { ctx.log?.warn?.( "CGPT-WEB", terminalPollFailure - ? `conversation poll stopped without assistant text for ${conversationId}` - : `conversation poll timed out without assistant text for ${conversationId}` + ? `conversation poll stopped without assistant text for ${safeConversationId}` + : `conversation poll timed out without assistant text for ${safeConversationId}` ); } return last; @@ -1558,9 +1560,10 @@ async function resolveImagePointers( ); if (url) urls.push(url); } catch (err) { + const safePointerScheme = safeAssetPointerScheme(ref.pointer); log?.warn?.( "CGPT-WEB", - `Image resolve failed (${ref.pointer}): ${err instanceof Error ? err.message : String(err)}` + `Image resolve failed (${safePointerScheme}): ${sanitizeUpstreamLogDetail(err)}` ); } } @@ -1712,7 +1715,7 @@ function buildStreamingResponse( choices: [ { index: 0, - delta: { content: `[Error: ${chunk.error}]` }, + delta: { content: `[Error: ${sanitizeUpstreamLogDetail(chunk.error)}]` }, finish_reason: null, logprobs: null, }, @@ -1803,10 +1806,7 @@ function buildStreamingResponse( const polled = await pollAsyncImage(conversationId); if (polled.length > 0) imagePointers = polled; } catch (err) { - log?.warn?.( - "CGPT-WEB", - `Async image poll failed: ${err instanceof Error ? err.message : String(err)}` - ); + log?.warn?.("CGPT-WEB", `Async image poll failed: ${sanitizeUpstreamLogDetail(err)}`); } finally { stopHb(); } @@ -1902,7 +1902,7 @@ function buildStreamingResponse( { index: 0, delta: { - content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, + content: `[Stream error: ${sanitizeCaughtError(err)}]`, }, finish_reason: "stop", logprobs: null, @@ -1954,7 +1954,11 @@ async function buildNonStreamingResponse( if (chunk.error) { return new Response( JSON.stringify({ - error: { message: chunk.error, type: "upstream_error", code: "CHATGPT_ERROR" }, + error: { + message: sanitizeUpstreamLogDetail(chunk.error), + type: "upstream_error", + code: "CHATGPT_ERROR", + }, }), { status: 502, headers: { "Content-Type": "application/json" } } ); @@ -2013,10 +2017,7 @@ async function buildNonStreamingResponse( const polled = await pollAsyncImage(conversationId); if (polled.length > 0) imagePointers = polled; } catch (err) { - log?.warn?.( - "CGPT-WEB", - `Async image poll failed: ${err instanceof Error ? err.message : String(err)}` - ); + log?.warn?.("CGPT-WEB", `Async image poll failed: ${sanitizeUpstreamLogDetail(err)}`); } } @@ -2037,7 +2038,9 @@ async function buildNonStreamingResponse( ); if (imageResolutionFailed && log?.warn) { const schemes = (imagePointers ?? []) - .map((p) => p.pointer.split("://")[0] || p.pointer.slice(0, 24)) + .map((p) => { + return safeAssetPointerScheme(p.pointer); + }) .join(", "); log.warn( "CGPT-WEB", @@ -2078,11 +2081,76 @@ async function buildNonStreamingResponse( function errorResponse(status: number, message: string, code?: string): Response { return new Response( - JSON.stringify({ error: { message, type: "upstream_error", ...(code ? { code } : {}) } }), + JSON.stringify({ + error: { + message: sanitizeErrorMessage(message), + type: "upstream_error", + ...(code ? { code } : {}), + }, + }), { status, headers: { "Content-Type": "application/json" } } ); } +function sanitizeCaughtError(err: unknown): string { + return sanitizeUpstreamLogDetail(err); +} + +const UPSTREAM_LOG_DETAIL_MAX_CHARS = 300; +const UPSTREAM_LOG_URL_PATTERN = /(?:https?|wss?):\/\/[^\s"'<>]+/giu; + +function sanitizeUpstreamLogDetail( + value: unknown, + fallback = "upstream error unavailable" +): string { + let raw = value; + try { + if (value instanceof Error) raw = value.message; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + } + const sanitized = sanitizeErrorMessage(raw).replace(UPSTREAM_LOG_URL_PATTERN, "").trim(); + return sanitized ? sanitized.slice(0, UPSTREAM_LOG_DETAIL_MAX_CHARS) : fallback; +} + +function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError { + try { + return error instanceof TlsClientUnavailableError; + } catch { + // Hostile prototype access must degrade to the generic connection error. + return false; + } +} + +function isSessionAuthError(error: unknown): error is SessionAuthError { + try { + return error instanceof SessionAuthError; + } catch { + // Hostile prototype access must degrade to the generic session error. + return false; + } +} + +function isSentinelBlockedError(error: unknown): error is SentinelBlockedError { + try { + return error instanceof SentinelBlockedError; + } catch { + // Hostile prototype access must degrade to the generic Sentinel error. + return false; + } +} + +function sanitizeOpaqueLogId(value: string | null | undefined): string { + return value?.trim() ? "" : "unknown"; +} + +function safeAssetPointerScheme(pointer: string): string { + const separator = pointer.indexOf("://"); + if (separator <= 0) return "unknown"; + const scheme = pointer.slice(0, separator).toLowerCase(); + return /^[a-z][a-z0-9+.-]{0,31}$/.test(scheme) ? scheme : "unknown"; +} + function normalizePublicBaseUrl(value?: string | null): string | null { const trimmed = value?.trim(); if (!trimmed) return null; @@ -2148,10 +2216,14 @@ function derivePublicBaseUrl( const configuredBase = normalizePublicBaseUrl(process.env.OMNIROUTE_BASE_URL) || normalizePublicBaseUrl(process.env.NEXT_PUBLIC_BASE_URL); + const safeConfiguredBase = configuredBase + ? sanitizeUpstreamLogDetail(configuredBase, "unavailable") + : "-"; + const safeHeaderBase = headerBase ? sanitizeUpstreamLogDetail(headerBase, "unavailable") : "-"; log?.debug?.( "CGPT-WEB", - `derivePublicBaseUrl: configured=${configuredBase ?? "-"} header=${headerBase ?? "-"}` + `derivePublicBaseUrl: configured=${safeConfiguredBase} header=${safeHeaderBase}` ); if (configuredBase && (!headerBase || !isLocalBaseUrl(configuredBase))) return configuredBase; @@ -2210,10 +2282,7 @@ async function fetchDownloadUrl(endpoint: string, ctx: ResolverContext): Promise signal: ctx.signal, }); if (response.status !== 200) { - ctx.log?.warn?.( - "CGPT-WEB", - `Image download URL fetch failed (${response.status}) for ${endpoint}` - ); + ctx.log?.warn?.("CGPT-WEB", `Image download URL fetch failed (${response.status})`); return null; } let parsed: { download_url?: string } = {}; @@ -2263,17 +2332,16 @@ async function imageUrlToCachedImageUrl( byteResponse: true, }); } catch (err) { - ctx.log?.warn?.( - "CGPT-WEB", - `Image fetch failed: ${err instanceof Error ? err.message : String(err)}` - ); + ctx.log?.warn?.("CGPT-WEB", `Image fetch failed: ${sanitizeUpstreamLogDetail(err)}`); return null; } if (response.status !== 200) { ctx.log?.warn?.( "CGPT-WEB", - `Image fetch returned HTTP ${response.status} (${(response.text || "").slice(0, 120)})` + `Image fetch returned HTTP ${response.status} (${sanitizeUpstreamLogDetail( + (response.text || "").slice(0, 120) + )})` ); return null; } @@ -2370,9 +2438,10 @@ async function registerWebSocket(ctx: ResolverContext): Promise { signal: ctx.signal, }); } catch (err) { + const safeUrl = sanitizeUpstreamLogDetail(url, "ChatGPT WebSocket endpoint"); ctx.log?.warn?.( "CGPT-WEB", - `register-websocket fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}` + `register-websocket fetch failed for ${safeUrl}: ${sanitizeUpstreamLogDetail(err)}` ); continue; } @@ -2384,7 +2453,10 @@ async function registerWebSocket(ctx: ResolverContext): Promise { }; const ws = data.websocket_url ?? data.wss_url; if (ws) { - ctx.log?.debug?.("CGPT-WEB", `Got WebSocket URL via ${url}`); + ctx.log?.debug?.( + "CGPT-WEB", + `Got WebSocket URL via ${sanitizeUpstreamLogDetail(url, "ChatGPT WebSocket endpoint")}` + ); return ws; } } catch { @@ -2394,7 +2466,10 @@ async function registerWebSocket(ctx: ResolverContext): Promise { } ctx.log?.warn?.( "CGPT-WEB", - `register-websocket via ${url} → ${r.status}: ${(r.text || "").slice(0, 200)}` + `register-websocket via ${sanitizeUpstreamLogDetail( + url, + "ChatGPT WebSocket endpoint" + )} → ${r.status}: ${sanitizeUpstreamLogDetail((r.text || "").slice(0, 200))}` ); } return null; @@ -2453,7 +2528,13 @@ async function waitForImageViaWebSocket( }; ws.onerror = (e) => { errored = true; - ctx.log?.warn?.("CGPT-WEB", `WebSocket error: ${(e as ErrorEvent).message ?? "unknown"}`); + ctx.log?.warn?.( + "CGPT-WEB", + `WebSocket error: ${sanitizeUpstreamLogDetail( + (e as ErrorEvent).message, + "upstream error unavailable" + )}` + ); }; ws.onclose = () => { clearTimeout(timer); @@ -2653,7 +2734,10 @@ function makeImageResolver(ctx: ResolverContext): ImageResolver { } else if (assetPointer.startsWith(SEDIMENT_PREFIX)) { fileId = assetPointer.slice(SEDIMENT_PREFIX.length); } else { - ctx.log?.warn?.("CGPT-WEB", `Unknown asset_pointer scheme: ${assetPointer}`); + ctx.log?.warn?.( + "CGPT-WEB", + `Unknown asset_pointer scheme: ${safeAssetPointerScheme(assetPointer)}` + ); } let signedUrl: string | null = null; @@ -2703,7 +2787,10 @@ function makeImageResolver(ctx: ResolverContext): ImageResolver { const preview = finalUrl.startsWith("data:") ? `data:... (${finalUrl.length} chars)` : finalUrl.slice(0, 80) + "..."; - ctx.log?.debug?.("CGPT-WEB", `Resolved ${assetPointer} → ${preview}`); + ctx.log?.debug?.( + "CGPT-WEB", + `Resolved ${safeAssetPointerScheme(assetPointer)} asset → ${sanitizeUpstreamLogDetail(preview, "local image URL unavailable")}` + ); } return finalUrl; }; @@ -2768,8 +2855,8 @@ export class ChatGptWebExecutor extends BaseExecutor { try { tokenEntry = await exchangeSession(cookie, signal); } catch (err) { - if (err instanceof SessionAuthError) { - log?.warn?.("CGPT-WEB", err.message); + if (isSessionAuthError(err)) { + log?.warn?.("CGPT-WEB", sanitizeCaughtError(err)); return { response: errorResponse( 401, @@ -2781,15 +2868,10 @@ export class ChatGptWebExecutor extends BaseExecutor { transformedBody: body, }; } - log?.error?.( - "CGPT-WEB", - `Session exchange failed: ${err instanceof Error ? err.message : String(err)}` - ); + const safeErrorMessage = sanitizeCaughtError(err); + log?.error?.("CGPT-WEB", `Session exchange failed: ${safeErrorMessage}`); return { - response: errorResponse( - 502, - `ChatGPT session exchange failed: ${err instanceof Error ? err.message : String(err)}` - ), + response: errorResponse(502, `ChatGPT session exchange failed: ${safeErrorMessage}`), url: SESSION_URL, headers: {}, transformedBody: body, @@ -2802,10 +2884,7 @@ export class ChatGptWebExecutor extends BaseExecutor { try { await onCredentialsRefreshed?.(updated); } catch (err) { - log?.warn?.( - "CGPT-WEB", - `Failed to persist refreshed cookie: ${err instanceof Error ? err.message : String(err)}` - ); + log?.warn?.("CGPT-WEB", `Failed to persist refreshed cookie: ${sanitizeCaughtError(err)}`); } } @@ -2816,7 +2895,7 @@ export class ChatGptWebExecutor extends BaseExecutor { } catch (err) { log?.warn?.( "CGPT-WEB", - `DPL warmup failed (continuing with fallback): ${err instanceof Error ? err.message : String(err)}` + `DPL warmup failed (continuing with fallback): ${sanitizeCaughtError(err)}` ); dplInfo = { dpl: `dpl=${OAI_CLIENT_VERSION.replace(/^prod-/, "")}`, @@ -2855,8 +2934,8 @@ export class ChatGptWebExecutor extends BaseExecutor { log ); } catch (err) { - if (err instanceof SentinelBlockedError) { - log?.warn?.("CGPT-WEB", err.message); + if (isSentinelBlockedError(err)) { + log?.warn?.("CGPT-WEB", sanitizeCaughtError(err)); return { response: errorResponse( 403, @@ -2868,15 +2947,10 @@ export class ChatGptWebExecutor extends BaseExecutor { transformedBody: body, }; } - log?.error?.( - "CGPT-WEB", - `Sentinel failed: ${err instanceof Error ? err.message : String(err)}` - ); + const safeErrorMessage = sanitizeCaughtError(err); + log?.error?.("CGPT-WEB", `Sentinel failed: ${safeErrorMessage}`); return { - response: errorResponse( - 502, - `ChatGPT sentinel failed: ${err instanceof Error ? err.message : String(err)}` - ), + response: errorResponse(502, `ChatGPT sentinel failed: ${safeErrorMessage}`), url: SENTINEL_PREPARE_URL, headers: {}, transformedBody: body, @@ -2980,14 +3054,11 @@ export class ChatGptWebExecutor extends BaseExecutor { stream, }); } catch (err) { - log?.error?.("CGPT-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); - const code = err instanceof TlsClientUnavailableError ? "TLS_UNAVAILABLE" : undefined; + const safeErrorMessage = sanitizeCaughtError(err); + log?.error?.("CGPT-WEB", `Fetch failed: ${safeErrorMessage}`); + const code = isTlsClientUnavailableError(err) ? "TLS_UNAVAILABLE" : undefined; return { - response: errorResponse( - 502, - `ChatGPT connection failed: ${err instanceof Error ? err.message : String(err)}`, - code - ), + response: errorResponse(502, `ChatGPT connection failed: ${safeErrorMessage}`, code), url: CONV_URL, headers, transformedBody: cgptBody, @@ -2999,7 +3070,8 @@ export class ChatGptWebExecutor extends BaseExecutor { // Log the upstream body on 4xx/5xx — error responses are small and the // upstream message is much more useful than our wrapper. Goes through // the executor logger so it respects the application's log config. - log?.warn?.("CGPT-WEB", `conv ${status}: ${(response.text || "").slice(0, 400)}`); + const safeUpstreamSnippet = sanitizeUpstreamLogDetail((response.text || "").slice(0, 400)); + log?.warn?.("CGPT-WEB", `conv ${status}: ${safeUpstreamSnippet}`); const errMsg = describeChatGptWebHttpError(status); if (status === 401 || status === 403) { tokenCache.delete(cookieKey(cookie)); diff --git a/open-sse/executors/chatgpt-web/handoff.ts b/open-sse/executors/chatgpt-web/handoff.ts index c0f66b50922..334fb1c7a61 100644 --- a/open-sse/executors/chatgpt-web/handoff.ts +++ b/open-sse/executors/chatgpt-web/handoff.ts @@ -1,7 +1,27 @@ import { tlsFetchChatGpt } from "../../services/chatgptTlsClient.ts"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; const CONVERSATION_RESUME_URL = "https://chatgpt.com/backend-api/f/conversation/resume"; const RESUME_OFFSETS = [0, 1, 2] as const; +const UPSTREAM_LOG_DETAIL_MAX_CHARS = 300; +const UPSTREAM_LOG_URL_PATTERN = /(?:https?|wss?):\/\/[^\s"'<>]+/giu; + +function sanitizeUpstreamLogDetail(value: unknown): string { + let raw = value; + try { + if (value instanceof Error) raw = value.message; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + } + const sanitized = sanitizeErrorMessage(raw).replace(UPSTREAM_LOG_URL_PATTERN, "").trim(); + return sanitized + ? sanitized.slice(0, UPSTREAM_LOG_DETAIL_MAX_CHARS) + : "upstream error unavailable"; +} + +function sanitizeOpaqueLogId(value: string | null | undefined): string { + return value?.trim() ? "" : "unknown"; +} export interface FinalAssistantAnswer { text: string; @@ -98,7 +118,9 @@ async function attemptResumeOffset({ if (response.status >= 400) { log?.warn?.( "CGPT-WEB", - `conversation resume ${response.status}: ${(response.text || "").slice(0, 300)}` + `conversation resume ${response.status}: ${sanitizeUpstreamLogDetail( + (response.text || "").slice(0, 300) + )}` ); return { answer: null, shouldRetry: false }; } @@ -109,10 +131,7 @@ async function attemptResumeOffset({ const answer = await readFinalAssistantAnswer(eventStream, signal, readContent); return { answer, shouldRetry: !answer }; } catch (error) { - log?.warn?.( - "CGPT-WEB", - `conversation resume failed: ${error instanceof Error ? error.message : String(error)}` - ); + log?.warn?.("CGPT-WEB", `conversation resume failed: ${sanitizeUpstreamLogDetail(error)}`); return { answer: null, shouldRetry: false }; } } @@ -149,6 +168,9 @@ export async function resumeChatGptHandoff({ if (!attempt.shouldRetry) return null; } - log?.warn?.("CGPT-WEB", `conversation resume returned no assistant text for ${conversationId}`); + log?.warn?.( + "CGPT-WEB", + `conversation resume returned no assistant text for ${sanitizeOpaqueLogId(conversationId)}` + ); return null; } diff --git a/open-sse/executors/chatgptWebTools.ts b/open-sse/executors/chatgptWebTools.ts index 55a1c5be911..ba4848994ed 100644 --- a/open-sse/executors/chatgptWebTools.ts +++ b/open-sse/executors/chatgptWebTools.ts @@ -113,6 +113,7 @@ export async function buildToolModeResponse( stream: boolean, meta: { cid: string; created: number; model: string; idSeed?: string } ): Promise { + if (!bufferedJson.ok) return bufferedJson; const jsonResponse = await applyToolCallsToJsonResponse( bufferedJson, requestedTools, diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 5034b0da6cc..12d7a46d5e2 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -39,6 +39,9 @@ const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`; const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; const MAX_ERROR_BODY_BYTES = 64 * 1024; +const MAX_FORWARDED_RETRY_AFTER_SECONDS = 24 * 60 * 60; +const HTTP_DATE_PATTERN = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/; const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT.userAgent; type SendClaudeWebTransport = ( @@ -290,7 +293,9 @@ async function readTransportErrorText(result: ClaudeWebTransportResult): Promise output += decoder.decode(); return output; } finally { - await reader.cancel().catch(() => {}); + await reader.cancel().catch(() => { + // The bounded error body is already captured; cancel can race an upstream close. + }); try { reader.releaseLock(); } catch { @@ -299,6 +304,26 @@ async function readTransportErrorText(result: ClaudeWebTransportResult): Promise } } +function normalizeForwardedRetryAfter(value: string | null): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + + if (/^\d{1,9}$/.test(trimmed)) { + const seconds = Number(trimmed); + return Number.isSafeInteger(seconds) && seconds <= MAX_FORWARDED_RETRY_AFTER_SECONDS + ? String(seconds) + : null; + } + + if (!HTTP_DATE_PATTERN.test(trimmed)) return null; + const timestamp = Date.parse(trimmed); + if (!Number.isFinite(timestamp)) return null; + const normalized = new Date(timestamp).toUTCString(); + if (normalized !== trimmed) return null; + if (Math.abs(timestamp - Date.now()) > MAX_FORWARDED_RETRY_AFTER_SECONDS * 1000) return null; + return normalized; +} + async function errorResponseForTransport( result: ClaudeWebTransportResult, turn: PreparedClaudeWebTurn @@ -310,7 +335,7 @@ async function errorResponseForTransport( } if (result.status === 429) { const extraHeaders: Record = {}; - const upstreamRetryAfter = result.headers.get("retry-after"); + const upstreamRetryAfter = normalizeForwardedRetryAfter(result.headers.get("retry-after")); if (upstreamRetryAfter) { extraHeaders["Retry-After"] = upstreamRetryAfter; } @@ -348,6 +373,7 @@ export class ClaudeWebExecutor extends BaseExecutor { const cookieHeader = normalizeClaudeSessionCookie(rawCookie); return verifyCookieValidity(cookieHeader, readClaudeWebDeviceId(credentials), signal); } catch { + // Connection checks deliberately collapse malformed credentials and transport failures to false. return false; } } diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts index a99bc2590a8..6caef0afb0e 100644 --- a/open-sse/executors/grok-web.ts +++ b/open-sse/executors/grok-web.ts @@ -54,6 +54,23 @@ import { const GROK_CHAT_API = "https://grok.com/rest/app-chat/conversations/new"; const GROK_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; +const GROK_PUBLIC_UPSTREAM_ERROR = "Grok upstream error"; + +function sanitizeGrokUpstreamError(message: unknown): string { + const sanitized = sanitizeErrorMessage(message); + return sanitized.trim() && !/^(?:[A-Za-z_$][\w$]*)?Error:\s*$/.test(sanitized) + ? sanitized + : GROK_PUBLIC_UPSTREAM_ERROR; +} + +function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError { + try { + return error instanceof TlsClientUnavailableError; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + return false; + } +} // ─── Model mappings ───────────────────────────────────────────────────────── // Grok Web exposes UI modes, not stable public model IDs. Keep OmniRoute model @@ -383,6 +400,7 @@ function buildStreamingResponse( if (chunk.fingerprint) fp = chunk.fingerprint; if (chunk.error) { + const publicError = sanitizeGrokUpstreamError(chunk.error); controller.enqueue( encoder.encode( sseChunk({ @@ -394,7 +412,7 @@ function buildStreamingResponse( choices: [ { index: 0, - delta: { content: `[Error: ${chunk.error}]` }, + delta: { content: `[Error: ${publicError}]` }, finish_reason: null, logprobs: null, }, @@ -518,9 +536,7 @@ function buildStreamingResponse( { index: 0, delta: { - content: sanitizeErrorMessage( - `[Stream error: ${err instanceof Error ? err.message : String(err)}]` - ), + content: `[Stream error: ${sanitizeGrokUpstreamError(err)}]`, }, finish_reason: "stop", logprobs: null, @@ -560,7 +576,11 @@ async function buildNonStreamingResponse( if (chunk.error) { return new Response( JSON.stringify({ - error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" }, + error: { + message: sanitizeGrokUpstreamError(chunk.error), + type: "upstream_error", + code: "GROK_ERROR", + }, }), { status: 502, headers: { "Content-Type": "application/json" } } ); @@ -953,12 +973,13 @@ export class GrokWebExecutor extends BaseExecutor { streamEofSymbol: "[DONE]", }); } catch (err) { - if (err instanceof TlsClientUnavailableError) { - log?.error?.("GROK-WEB", `TLS client unavailable: ${err.message}`); + const publicError = sanitizeGrokUpstreamError(err); + if (isTlsClientUnavailableError(err)) { + log?.error?.("GROK-WEB", `TLS client unavailable: ${publicError}`); const errResp = new Response( JSON.stringify({ error: { - message: sanitizeErrorMessage(`Grok TLS client unavailable: ${err.message}`), + message: `Grok TLS client unavailable: ${publicError}`, type: "upstream_error", code: "TLS_CLIENT_UNAVAILABLE", }, @@ -967,13 +988,11 @@ export class GrokWebExecutor extends BaseExecutor { ); return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; } - log?.error?.("GROK-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); + log?.error?.("GROK-WEB", `Fetch failed: ${publicError}`); const errResp = new Response( JSON.stringify({ error: { - message: sanitizeErrorMessage( - `Grok connection failed: ${err instanceof Error ? err.message : String(err)}` - ), + message: `Grok connection failed: ${publicError}`, type: "upstream_error", }, }), diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 42bcd9cf7e5..77772b49077 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -11,6 +11,7 @@ import { v7 as uuidv7 } from "uuid"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { tlsFetchLMArena, TlsClientUnavailableError } from "../services/lmarenaTlsClient.ts"; import { readLMArenaCookie, reconstructLMArenaCookie } from "./lmarena/cookie.ts"; +import { sanitizeLMArenaError } from "./lmarena/error.ts"; import { LMARENA_STREAM_URL, LMARENA_USER_AGENT, @@ -50,6 +51,15 @@ interface OpenAIMessage { content?: unknown; } +function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError { + try { + return error instanceof TlsClientUnavailableError; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + return false; + } +} + /** Optional browser-issued reCAPTCHA v3 token (operator-supplied). */ function readRecaptchaToken(credentials: unknown, body: unknown): string | null { const fromObj = (v: unknown): string | null => { @@ -146,13 +156,13 @@ export class LMArenaExecutor extends BaseExecutor { log, }); } catch (error) { - if (error instanceof TlsClientUnavailableError) { - log?.error?.("LMArenaExecutor", `TLS client unavailable: ${error.message}`); + if (isTlsClientUnavailableError(error)) { + log?.error?.("LMArenaExecutor", `TLS client unavailable: ${sanitizeLMArenaError(error)}`); return mapTlsUnavailable(error, url, headers, transformedBody); } - const message = error instanceof Error ? error.message : String(error); - log?.error?.("LMArenaExecutor", `Request failed: ${message}`); - return mapNetworkError(message, url, headers, transformedBody); + const publicMessage = sanitizeLMArenaError(error); + log?.error?.("LMArenaExecutor", `Request failed: ${publicMessage}`); + return mapNetworkError(error, url, headers, transformedBody); } } diff --git a/open-sse/executors/lmarena/error.ts b/open-sse/executors/lmarena/error.ts new file mode 100644 index 00000000000..be0e404699a --- /dev/null +++ b/open-sse/executors/lmarena/error.ts @@ -0,0 +1,17 @@ +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +const ERROR_NAME_ONLY_RE = /^[A-Za-z]*Error:?$/; + +/** Convert an unknown Arena failure into a stable, public-safe message. */ +export function sanitizeLMArenaError(value: unknown, fallback = "Arena upstream error"): string { + let candidate = value; + try { + if (value instanceof Error) candidate = value.message; + } catch { + // Hostile thrown values can expose coercing prototype/message accessors. + } + + const sanitized = sanitizeErrorMessage(candidate).trim(); + if (!sanitized || ERROR_NAME_ONLY_RE.test(sanitized)) return fallback; + return sanitized; +} diff --git a/open-sse/executors/lmarena/models.ts b/open-sse/executors/lmarena/models.ts index fbfe2778095..8883ba09f45 100644 --- a/open-sse/executors/lmarena/models.ts +++ b/open-sse/executors/lmarena/models.ts @@ -2,6 +2,8 @@ * LMArena live model list parsing, catalog normalization, and name→UUID resolution. */ +import { sanitizeLMArenaError } from "./error.ts"; + export const LMARENA_API_BASE = "https://arena.ai"; export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`; /** @@ -297,10 +299,9 @@ export async function resolveLMArenaModelId(model: string, log?: LogFn): Promise if (fromSeed) return fromSeed; return pickLMArenaModelId(requested, await getLMArenaModels(log)); } catch (error) { - const message = error instanceof Error ? error.message : String(error); log?.warn?.( "LMArenaExecutor", - `Using raw model id after static catalog lookup failed: ${message}` + `Using raw model id after static catalog lookup failed: ${sanitizeLMArenaError(error, "Arena catalog lookup error")}` ); return requested; } diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts index acc86f915af..f99218fd47c 100644 --- a/open-sse/executors/lmarena/response.ts +++ b/open-sse/executors/lmarena/response.ts @@ -2,12 +2,38 @@ * Response mapping helpers for the Arena (lmarena) executor — kept small so * the executor methods stay under complexity / max-lines gates. */ -import { sanitizeErrorMessage } from "../../utils/error.ts"; import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts"; +import { sanitizeLMArenaError } from "./error.ts"; import { markLMArenaCatalogModelDead } from "./models.ts"; import { parseArenaSSE } from "./stream.ts"; const encoder = new TextEncoder(); +const SAFE_ARENA_STREAM_ERROR_NAMES = new Set([ + "AbortError", + "ResponseAborted", + "TimeoutError", + "BodyTimeoutError", +]); + +function projectArenaStreamError(error: unknown, publicMessage: string): Error { + const projected = new Error(publicMessage) as Error & { statusCode?: number }; + projected.stack = undefined; + if (!error || typeof error !== "object") return projected; + + try { + const name = (error as { name?: unknown }).name; + if (typeof name === "string" && SAFE_ARENA_STREAM_ERROR_NAMES.has(name)) { + projected.name = name; + } + const statusCode = Number((error as { statusCode?: unknown }).statusCode); + if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) { + projected.statusCode = statusCode; + } + } catch { + // Hostile thrown values must not escape through coercing metadata accessors. + } + return projected; +} export function errorResponse( status: number, @@ -15,9 +41,10 @@ export function errorResponse( type: string, code: string ): Response { + const publicMessage = sanitizeLMArenaError(message); return new Response( JSON.stringify({ - error: { message: sanitizeErrorMessage(message), type, code }, + error: { message: publicMessage, type, code }, }), { status, headers: { "Content-Type": "application/json" } } ); @@ -114,7 +141,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: ${sanitizeLMArenaError(error)}. Install/repair tls-client-node native binary.`, "upstream_error", "TLS_CLIENT_UNAVAILABLE" ), @@ -125,13 +152,13 @@ export function mapTlsUnavailable( } export function mapNetworkError( - message: string, + message: unknown, url: string, headers: Record, transformedBody: unknown ) { return { - response: errorResponse(502, message, "network_error", "request_failed"), + response: errorResponse(502, sanitizeLMArenaError(message), "network_error", "request_failed"), url, headers, transformedBody, @@ -199,7 +226,7 @@ function handleArenaEventLine( enqueueSse(controller, { ...baseChunk(model), choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - error: { message: sanitizeErrorMessage(event.content || "Unknown error") }, + error: { message: sanitizeLMArenaError(event.content) }, }); controller.close(); return true; @@ -220,9 +247,28 @@ export function createOpenAIArenaStream(opts: { const { reader, model, signal, log } = opts; const decoder = new TextDecoder(); let buffer = ""; + let readerCleanup: Promise | null = null; + + const cleanupReader = (): Promise => { + if (readerCleanup) return readerCleanup; + readerCleanup = (async () => { + try { + await reader.cancel(); + } catch { + // The upstream may already be closed or errored; still release its lock below. + } + try { + reader.releaseLock(); + } catch { + // A concurrent cleanup may already have released this reader. + } + })(); + return readerCleanup; + }; const onAbort = () => { - void reader.cancel().catch(() => undefined); + // The upstream reader may already be closed; cleanup failure must not replace the abort outcome. + void cleanupReader(); }; if (signal) { if (signal.aborted) onAbort(); @@ -234,7 +280,8 @@ export function createOpenAIArenaStream(opts: { try { while (true) { if (signal?.aborted) { - await reader.cancel().catch(() => undefined); + // Cancellation is best-effort cleanup; the already-observed abort remains authoritative. + await cleanupReader(); controller.close(); return; } @@ -252,15 +299,17 @@ export function createOpenAIArenaStream(opts: { } emitStopAndDone(controller, model); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); - controller.error(error); + const publicMessage = sanitizeLMArenaError(error, "Arena upstream stream error"); + log?.error?.("LMArenaExecutor", `Streaming error: ${publicMessage}`); + controller.error(projectArenaStreamError(error, publicMessage)); } finally { + await cleanupReader(); if (signal) signal.removeEventListener("abort", onAbort); } }, - cancel() { - void reader.cancel().catch(() => undefined); + async cancel() { + // The consumer may cancel after the upstream reader closed; cleanup must not mask that outcome. + await cleanupReader(); if (signal) signal.removeEventListener("abort", onAbort); }, }); diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index b53bd696693..c38e822cb6b 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -29,7 +29,10 @@ */ import { randomUUID } from "node:crypto"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { + makeExecutorErrorResult as makeErrorResult, + sanitizeErrorMessage, +} from "../utils/error.ts"; import { BROWSER_HEADERS, extractNotionUserIdFromCookie, @@ -57,13 +60,9 @@ import { } from "../services/notionStreamParser.ts"; import { buildNotionTranscript, - messagesForNotionTranscript, type NotionAgentOptions, } from "../services/notionTranscriptBuilder.ts"; -import { - tlsFetchNotion, - TlsClientUnavailableError, -} from "../services/notionTlsClient.ts"; +import { tlsFetchNotion, TlsClientUnavailableError } from "../services/notionTlsClient.ts"; // Re-exported for unit tests that destructure `mod.` on this module. export { @@ -225,7 +224,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 +234,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; @@ -317,6 +313,25 @@ function clientFacingModelId(model: unknown): string { return clientFacingModel; } +function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError { + try { + return error instanceof TlsClientUnavailableError; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + return false; + } +} + +function sanitizeNotionTransportError(error: unknown): string { + let candidate = error; + try { + if (error instanceof Error) candidate = error.message; + } catch { + // Keep the unknown value for the canonical fail-closed sanitizer. + } + return sanitizeErrorMessage(candidate).trim() || "unknown error"; +} + /** Resolves workspace + user (cached). Required for createThread payloads. */ async function resolveExecuteWorkspace( cookie: string, @@ -393,9 +408,8 @@ function buildNotionExecuteHeaders(opts: { const isCustom = Boolean(opts.agent?.workflowId); // Browser uses /agent/?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 = { "Content-Type": "application/json", "User-Agent": USER_AGENT, @@ -453,11 +467,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 +488,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,13 +518,12 @@ 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) { + if (isTlsClientUnavailableError(err)) { // 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 { @@ -532,7 +539,7 @@ async function sendNotionInferenceRequest(opts: { return { errorResult: makeErrorResult( 502, - `Notion fetch failed: ${fallbackErr instanceof Error ? fallbackErr.message : "unknown error"}`, + `Notion fetch failed: ${sanitizeNotionTransportError(fallbackErr)}`, reqBody, NOTION_URL ), @@ -542,7 +549,7 @@ async function sendNotionInferenceRequest(opts: { return { errorResult: makeErrorResult( 502, - `Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`, + `Notion fetch failed: ${sanitizeNotionTransportError(err)}`, reqBody, NOTION_URL ), @@ -634,8 +641,7 @@ export class NotionWebExecutor extends BaseExecutor { const inboundHeaders = (input.clientHeaders as Record | null | undefined) ?? ((input as { headers?: Record }).headers as - | Record - | undefined); + Record | 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 +744,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 }); } diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index fa1a0f0258e..62f13a357fe 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -15,11 +15,8 @@ import { } from "../services/perplexityTlsClient.ts"; import { prepareToolMessages } from "../translator/webTools.ts"; import { buildToolModeResponse } from "./chatgptWebTools.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; -import { - buildSessionCookieHeader, - mergeRefreshedCookie, -} from "../utils/nextAuthCookie.ts"; +import { projectPublicErrorIdentifier, sanitizeErrorMessage } from "../utils/error.ts"; +import { buildSessionCookieHeader, mergeRefreshedCookie } from "../utils/nextAuthCookie.ts"; import { PPLX_SSE_ENDPOINT, PPLX_USER_AGENT, @@ -31,13 +28,38 @@ import { buildPplxRequestBody, buildQuery, extractContent, + PPLX_ADVANCED_QUOTA_DEFAULT_RESET_SECONDS, sseChunk, + type ContentChunk, } from "./perplexity-web/protocol.ts"; // ─── Session continuity ───────────────────────────────────────────────────── const SESSION_MAX_AGE_MS = 3600_000; const SESSION_MAX_ENTRIES = 200; +const PPLX_PUBLIC_UPSTREAM_ERROR = "Perplexity upstream error"; + +function sanitizePerplexityUpstreamError(message: unknown): string { + const sanitized = sanitizeErrorMessage(message); + return sanitized.trim() && !/^(?:[A-Za-z_$][\w$]*)?Error:\s*$/.test(sanitized) + ? sanitized + : PPLX_PUBLIC_UPSTREAM_ERROR; +} + +function isTlsClientUnavailableError(error: unknown): error is TlsClientUnavailableError { + try { + return error instanceof TlsClientUnavailableError; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + return false; + } +} + +export function toPublicPerplexityErrorCode(errorCode: unknown, isQuota: boolean): string { + if (isQuota) return "quota_exhausted"; + if (typeof errorCode !== "string" || errorCode.length > 64) return "PPLX_ERROR"; + return projectPublicErrorIdentifier(errorCode, "PPLX_ERROR"); +} interface SessionEntry { backendUuid: string; @@ -95,118 +117,131 @@ function sessionStore( } } +const PPLX_STREAM_PREFLIGHT_MAX_CHUNKS = 32; +const PPLX_STREAM_PREFLIGHT_TIMEOUT_MS = 250; +const PPLX_STREAM_PREFLIGHT_TIMED_OUT = Symbol("pplx-stream-preflight-timeout"); + +async function waitForPreflightChunk( + pending: Promise>, + timeoutMs: number +): Promise | typeof PPLX_STREAM_PREFLIGHT_TIMED_OUT> { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + pending, + new Promise((resolve) => { + timer = setTimeout(() => resolve(PPLX_STREAM_PREFLIGHT_TIMED_OUT), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function* replayContentChunks( + buffered: ContentChunk[], + pending: Promise> | null, + remaining: AsyncGenerator +): AsyncGenerator { + try { + yield* buffered; + if (pending) { + const nextChunk = await pending; + if (!nextChunk.done) yield nextChunk.value; + } + yield* remaining; + } finally { + // Releasing the iterator unlocks the upstream reader; cleanup cannot replace the SSE outcome. + await remaining.return(undefined).catch(() => {}); + } +} + +interface ContentPreflightResult { + quotaError: ContentChunk | null; + contentChunks: AsyncIterable | null; +} + +async function preflightContentChunks( + source: AsyncGenerator +): Promise { + const buffered: ContentChunk[] = []; + const deadline = Date.now() + PPLX_STREAM_PREFLIGHT_TIMEOUT_MS; + + for (let index = 0; index < PPLX_STREAM_PREFLIGHT_MAX_CHUNKS; index += 1) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) }; + } + + const pending = source.next(); + const nextChunk = await waitForPreflightChunk(pending, remainingMs); + if (nextChunk === PPLX_STREAM_PREFLIGHT_TIMED_OUT) { + return { quotaError: null, contentChunks: replayContentChunks(buffered, pending, source) }; + } + if (nextChunk.done) { + return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) }; + } + + const chunk = nextChunk.value; + buffered.push(chunk); + if (chunk.error && isPerplexityQuotaError(chunk)) { + // Closing the primed generator releases its reader; cleanup must not mask the quota response. + await source.return(undefined).catch(() => {}); + return { quotaError: chunk, contentChunks: null }; + } + if (chunk.error || chunk.delta || chunk.answer || chunk.done) { + return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) }; + } + } + + // Once either bound is exhausted, preserve the original SSE 200 behavior for later errors. + return { quotaError: null, contentChunks: replayContentChunks(buffered, null, source) }; +} + +async function* throwContentError(error: unknown): AsyncGenerator { + throw error; +} + function buildStreamingResponse( - eventStream: ReadableStream, + contentChunks: AsyncIterable, model: string, cid: string, created: number, history: Array<{ role: string; content: string }>, currentMsg: string, - signal?: AbortSignal | null -): ReadableStream { + onCancel?: (reason: unknown) => void +): Response { const encoder = new TextEncoder(); + const contentIterator = contentChunks[Symbol.asyncIterator](); + const ownedChunks = { [Symbol.asyncIterator]: () => contentIterator }; + let cancelled = false; - return new ReadableStream( - { - async start(controller) { - try { - // Initial role chunk - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }, - ], - }) - ) - ); + const pump = async (controller: ReadableStreamDefaultController) => { + try { + // Initial role chunk + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }, + ], + }) + ) + ); - let fullAnswer = ""; - let respBackendUuid: string | null = null; - - for await (const chunk of extractContent(eventStream, signal)) { - if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; - - if (chunk.error) { - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { - index: 0, - delta: { content: `[Error: ${chunk.error}]` }, - finish_reason: null, - logprobs: null, - }, - ], - }) - ) - ); - break; - } - - if (chunk.thinking) { - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { - index: 0, - delta: { reasoning_content: chunk.thinking + "\n" }, - finish_reason: null, - logprobs: null, - }, - ], - }) - ) - ); - continue; - } - - if (chunk.done) { - fullAnswer = chunk.answer || fullAnswer; - break; - } - - let dt = chunk.delta || ""; - if (dt) { - dt = cleanResponse(dt, false); - if (dt) { - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { index: 0, delta: { content: dt }, finish_reason: null, logprobs: null }, - ], - }) - ) - ); - } - } - if (chunk.answer) fullAnswer = chunk.answer; - } + let fullAnswer = ""; + let respBackendUuid: string | null = null; + + for await (const chunk of ownedChunks) { + if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; - // Stop chunk + if (chunk.error) { + const publicError = sanitizePerplexityUpstreamError(chunk.error); controller.enqueue( encoder.encode( sseChunk({ @@ -215,14 +250,21 @@ function buildStreamingResponse( created, model, system_fingerprint: null, - choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + choices: [ + { + index: 0, + delta: { content: `[Error: ${publicError}]` }, + finish_reason: null, + logprobs: null, + }, + ], }) ) ); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + break; + } - sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); - } catch (err) { + if (chunk.thinking) { controller.enqueue( encoder.encode( sseChunk({ @@ -234,26 +276,150 @@ function buildStreamingResponse( choices: [ { index: 0, - delta: { - content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, - }, - finish_reason: "stop", + delta: { reasoning_content: chunk.thinking + "\n" }, + finish_reason: null, logprobs: null, }, ], }) ) ); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - } finally { - try { - controller.close(); - } catch {} + continue; + } + + if (chunk.done) { + fullAnswer = chunk.answer || fullAnswer; + break; + } + + let dt = chunk.delta || ""; + if (dt) { + dt = cleanResponse(dt, false); + if (dt) { + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { index: 0, delta: { content: dt }, finish_reason: null, logprobs: null }, + ], + }) + ) + ); + } + } + if (chunk.answer) fullAnswer = chunk.answer; + } + if (cancelled) return; + + // Stop chunk + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + + sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); + } catch (err) { + if (cancelled) return; + controller.enqueue( + encoder.encode( + sseChunk({ + id: cid, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { + content: `[Stream error: ${sanitizePerplexityUpstreamError(err)}]`, + }, + finish_reason: "stop", + logprobs: null, + }, + ], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } finally { + try { + controller.close(); + } catch { + // Consumer cancellation or an already-closed stream must not replace the terminal outcome. + } + } + }; + + const stream = new ReadableStream( + { + start(controller) { + // The pump must not own start(): cancellation is unavailable until start() settles. + void pump(controller); + }, + async cancel(reason) { + cancelled = true; + onCancel?.(reason); + try { + await contentIterator.return?.(undefined); + } catch { + // Upstream cleanup cannot replace the caller's already-selected cancellation outcome. } }, }, { highWaterMark: 16384 } ); + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + }); +} + +function buildUpstreamErrorResponse(chunk: ContentChunk): Response { + // Quota exhaustion → 429 + reset_seconds so OmniRoute marks rate_limited_until + // and VibeProxy limit badges / rotation skip parse the same shape as model_cooldown. + const isQuota = isPerplexityQuotaError(chunk); + const resetSeconds = + typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0 + ? chunk.resetSeconds + : isQuota + ? PPLX_ADVANCED_QUOTA_DEFAULT_RESET_SECONDS + : undefined; + const error: Record = { + message: sanitizePerplexityUpstreamError(chunk.error), + type: isQuota ? "quota_exhausted" : "upstream_error", + code: toPublicPerplexityErrorCode(chunk.errorCode, isQuota), + }; + if (resetSeconds !== undefined) error.reset_seconds = resetSeconds; + const headers: Record = { "Content-Type": "application/json" }; + if (resetSeconds !== undefined) headers["Retry-After"] = String(resetSeconds); + return new Response(JSON.stringify({ error }), { status: isQuota ? 429 : 502, headers }); +} + +function isPerplexityQuotaError(chunk: ContentChunk): boolean { + return ( + chunk.errorCode === "quota_exhausted" || + /quota exhausted/i.test(chunk.error || "") || + (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) + ); } async function buildNonStreamingResponse( @@ -272,28 +438,7 @@ async function buildNonStreamingResponse( for await (const chunk of extractContent(eventStream, signal)) { if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; if (chunk.error) { - // Quota exhaustion → 429 + reset_seconds so OmniRoute marks rate_limited_until - // and VibeProxy limit badges / rotation skip parse the same shape as model_cooldown. - const isQuota = - chunk.errorCode === "quota_exhausted" || - /quota exhausted/i.test(chunk.error) || - (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0); - const status = isQuota ? 429 : 502; - const code = chunk.errorCode || (isQuota ? "quota_exhausted" : "PPLX_ERROR"); - const type = isQuota ? "quota_exhausted" : "upstream_error"; - const errBody: Record = { - message: chunk.error, - type, - code, - }; - if (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) { - errBody.reset_seconds = chunk.resetSeconds; - } - const respHeaders: Record = { "Content-Type": "application/json" }; - if (typeof chunk.resetSeconds === "number" && chunk.resetSeconds > 0) { - respHeaders["Retry-After"] = String(chunk.resetSeconds); - } - return new Response(JSON.stringify({ error: errBody }), { status, headers: respHeaders }); + return buildUpstreamErrorResponse(chunk); } if (chunk.thinking) { thinkingParts.push(chunk.thinking); @@ -348,10 +493,8 @@ async function persistRotatedSessionCookie( await onCredentialsRefreshed({ ...credentials, apiKey: refreshed }); } } catch (err) { - log?.warn?.( - "PPLX-WEB", - `Failed to persist refreshed cookie: ${err instanceof Error ? err.message : String(err)}` - ); + const publicError = sanitizePerplexityUpstreamError(err); + log?.warn?.("PPLX-WEB", `Failed to persist refreshed cookie: ${publicError}`); } } @@ -362,7 +505,15 @@ export class PerplexityWebExecutor extends BaseExecutor { super("perplexity-web", { id: "perplexity-web", baseUrl: PPLX_SSE_ENDPOINT }); } - async execute({ model, body, stream, credentials, signal, log, onCredentialsRefreshed }: ExecuteInput) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + onCredentialsRefreshed, + }: ExecuteInput) { const bodyObj = (body || {}) as Record; const rawMessages = bodyObj.messages as Array> | undefined; if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { @@ -406,7 +557,7 @@ export class PerplexityWebExecutor extends BaseExecutor { const parsed = parseOpenAIMessages(effectiveMessages); const followUpUuid = sessionLookup(parsed.history); if (followUpUuid) { - log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`); + log?.info?.("PPLX-WEB", "Continuing existing session"); } const query = buildQuery(parsed, followUpUuid); @@ -473,14 +624,15 @@ export class PerplexityWebExecutor extends BaseExecutor { streamEofSymbol: PPLX_STREAM_EOF_SYMBOL, }); } catch (err) { - const isTlsUnavail = err instanceof TlsClientUnavailableError; - log?.error?.("PPLX-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); + const isTlsUnavail = isTlsClientUnavailableError(err); + const publicError = sanitizePerplexityUpstreamError(err); + log?.error?.("PPLX-WEB", `Fetch failed: ${publicError}`); const errResp = new Response( JSON.stringify({ error: { message: isTlsUnavail - ? `Perplexity TLS client unavailable: ${sanitizeErrorMessage((err as Error).message)}` - : `Perplexity connection failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`, + ? `Perplexity TLS client unavailable: ${publicError}` + : `Perplexity connection failed: ${publicError}`, type: "upstream_error", }, }), @@ -596,23 +748,36 @@ export class PerplexityWebExecutor extends BaseExecutor { idSeed: "pplx", }); } else if (stream) { - const sseStream = buildStreamingResponse( - response.body, - model, - cid, - created, - parsed.history, - parsed.currentMsg, - signal - ); - finalResponse = new Response(sseStream, { - status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - }); + const contentAbortController = new AbortController(); + const contentSignal = signal + ? AbortSignal.any([signal, contentAbortController.signal]) + : contentAbortController.signal; + const contentChunks = extractContent(response.body, contentSignal); + try { + const preflight = await preflightContentChunks(contentChunks); + if (preflight.quotaError) { + finalResponse = buildUpstreamErrorResponse(preflight.quotaError); + } else { + finalResponse = buildStreamingResponse( + preflight.contentChunks as AsyncIterable, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + (reason) => contentAbortController.abort(reason) + ); + } + } catch (err) { + finalResponse = buildStreamingResponse( + throwContentError(err), + model, + cid, + created, + parsed.history, + parsed.currentMsg + ); + } } else { finalResponse = await buildNonStreamingResponse( response.body, diff --git a/open-sse/executors/perplexity-web/protocol.ts b/open-sse/executors/perplexity-web/protocol.ts index 12e98ccdc40..0ea35f994f2 100644 --- a/open-sse/executors/perplexity-web/protocol.ts +++ b/open-sse/executors/perplexity-web/protocol.ts @@ -213,6 +213,12 @@ export async function* readPplxSseEvents( const decoder = new TextDecoder(); let buffer = ""; let dataLines: string[] = []; + const cancelPendingRead = () => { + // Abort only releases pending upstream I/O; the executor retains the public stream outcome. + void reader.cancel(signal?.reason).catch(() => {}); + }; + if (signal?.aborted) cancelPendingRead(); + else signal?.addEventListener("abort", cancelPendingRead, { once: true }); function flush(): PplxStreamEvent | null | "done" { if (dataLines.length === 0) return null; @@ -263,6 +269,7 @@ export async function* readPplxSseEvents( const tail = flush(); if (tail && tail !== "done") yield tail; } finally { + signal?.removeEventListener("abort", cancelPendingRead); reader.releaseLock(); } } @@ -417,9 +424,8 @@ export interface ContentChunk { /** Structured error code for quota / rate-limit surfaces (e.g. quota_exhausted). */ errorCode?: string; /** - * Suggested client/account cooldown in seconds when the stream failed due to - * advanced-model weekly quota (or similar). Downstream marks the connection - * rate_limited_until and VibeProxy limit badges parse this + "reset after Xs". + * Suggested cooldown when quota is classified before the HTTP stream is committed. + * Once SSE 200 starts, a late error cannot retroactively add status or Retry-After metadata. */ resetSeconds?: number; done?: boolean; @@ -775,6 +781,7 @@ export async function* extractContent( if (event.error_code || event.error_message) { yield { error: event.error_message || `Perplexity error: ${event.error_code}`, + errorCode: event.error_code, done: true, }; return; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 3c240717ad8..d294b115cc4 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -224,7 +224,9 @@ import { createErrorResult, parseUpstreamError, formatProviderError, + projectPublicErrorIdentifier, sanitizeErrorMessage, + sanitizeUpstreamDetails, } from "../utils/error.ts"; import { reportMalformed200, @@ -266,8 +268,10 @@ import { recordKeyHealthStatus as recordKeyHealthStatusFor } from "./chatCore/ke import { getSkillsModelIdForFormat } from "./chatCore/skillsFormat.ts"; import { readNonStreamingResponseBody } from "./chatCore/nonStreamingResponseBody.ts"; import { - isSemaphoreCapacityError, + createSafeAbortError, createStreamingErrorResult, + formatStreamRecoveryRetryWarning, + getSafeErrorMetadata, getUpstreamErrorIdentifier, } from "./chatCore/streamErrorResult.ts"; import { wrapReadableStreamWithFinalize } from "./chatCore/streamFinalize.ts"; @@ -596,12 +600,11 @@ export async function handleChatCore({ status: 409, }); }; - const isManagedLeaseFenceError = (error: unknown): boolean => - managedLease !== null && - typeof (error as { code?: unknown })?.code === "string" && - String((error as { code: string }).code).startsWith("LEASE_"); - const managedLeaseFenceErrorResult = (error: unknown) => { - const code = (error as { code: string }).code; + const getManagedLeaseFenceErrorCode = (code: string | undefined): string | undefined => { + if (managedLease === null) return undefined; + return code?.startsWith("LEASE_") ? code : undefined; + }; + const managedLeaseFenceErrorResult = (code: string) => { return { ...createErrorResult(409, "Managed lease request fence rejected the dispatch", null, code), errorType: "lease_error", @@ -2433,48 +2436,47 @@ export async function handleChatCore({ error instanceof Error ? error : new Error(String(error)) ); } catch (pluginErr) { - log?.debug?.( - "PLUGIN", - `onError hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}` - ); + const pluginErrorMessage = sanitizeErrorMessage(pluginErr) || "Plugin onError hook failed"; + log?.debug?.("PLUGIN", `onError hook error (non-fatal): ${pluginErrorMessage}`); } - const parsedStatus = Number(error?.statusCode); + let parsedStatus = Number.NaN; + try { + parsedStatus = Number(error?.statusCode); + } catch { + // Hostile thrown values may expose Symbols or throwing status accessors. + } const statusCode = Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599 ? parsedStatus : HTTP_STATUS.SERVER_ERROR; - const message = error?.message || "Invalid request"; - const errorType = typeof error?.errorType === "string" ? error.errorType : null; - - log?.warn?.("TRANSLATE", `Request translation failed: ${message}`); - - if (errorType) { - trackPendingRequest(model, provider, connectionId, false); - return { - success: false, - status: statusCode, - error: message, - response: new Response( - JSON.stringify({ - error: { - message, - type: errorType, - code: errorType, - }, - }), - { - status: statusCode, - headers: { - "Content-Type": "application/json", - }, - } - ), - }; + let message = "Invalid request"; + try { + const candidate = error?.message; + message = + (typeof candidate === "string" ? candidate : sanitizeErrorMessage(candidate)) || message; + } catch { + // Hostile thrown values may expose throwing property accessors. + } + let errorType: string | null = null; + try { + const candidate = error?.errorType; + errorType = typeof candidate === "string" ? candidate : null; + } catch { + // Hostile thrown values may expose throwing classification accessors. } + const result = createErrorResult( + statusCode, + message, + null, + errorType ?? undefined, + errorType ?? undefined + ); + log?.warn?.("TRANSLATE", `Request translation failed: ${result.error}`); + trackPendingRequest(model, provider, connectionId, false); - return createErrorResult(statusCode, message); + return result; } // The latest OmniGlyph release has protocol-native OpenAI transforms. Run @@ -3458,9 +3460,11 @@ export async function handleChatCore({ onRetry: (attempt, err) => log?.warn?.( "STREAM_RECOVERY", - `transparent early-retry ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX} after ${ - (err as { name?: string })?.name || "truncation" - }` + formatStreamRecoveryRetryWarning( + attempt, + STREAM_RECOVERY.EARLY_RETRY_MAX, + err + ) ), continueStream, onContinue: (attempt) => @@ -3717,15 +3721,21 @@ export async function handleChatCore({ } } catch (error) { trackPendingRequest(model, provider, connectionId, false); - if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error); - if (isSemaphoreCapacityError(error)) { + const errorMetadata = getSafeErrorMetadata(error); + const managedLeaseFenceCode = getManagedLeaseFenceErrorCode(errorMetadata.code); + if (managedLeaseFenceCode) return managedLeaseFenceErrorResult(managedLeaseFenceCode); + if ( + errorMetadata.code === "SEMAPHORE_TIMEOUT" || + errorMetadata.code === "SEMAPHORE_QUEUE_FULL" + ) { + const semaphoreCode = errorMetadata.code as "SEMAPHORE_TIMEOUT" | "SEMAPHORE_QUEUE_FULL"; appendRequestLog({ model, provider, connectionId, - status: `FAILED ${error.code}`, + status: `FAILED ${semaphoreCode}`, }).catch(() => {}); - const failureMessage = error.message || "Semaphore timeout"; + const failureMessage = sanitizeErrorMessage(errorMetadata.message) || "Semaphore timeout"; persistAttemptLogs({ status: HTTP_STATUS.RATE_LIMITED, error: failureMessage, @@ -3734,25 +3744,32 @@ export async function handleChatCore({ claudeCacheMeta: claudePromptCacheLogMeta, cacheSource: "upstream", }); - persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code); + persistFailureUsage(HTTP_STATUS.RATE_LIMITED, semaphoreCode); const result = stream - ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code) + ? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, semaphoreCode) : createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage); return { ...result, errorType: "account_semaphore_capacity", - errorCode: error.code, + errorCode: semaphoreCode, }; } // abort(reason) can reject with a raw string lacking `name`/`status`; classify // it through isLocalStreamLifecycleError so it maps to 499 rather than the // 502 provider-failure default. - const isRequestAborted = isLocalStreamLifecycleError(error); + let isRequestAborted = errorMetadata.name === "AbortError"; + if (!isRequestAborted) { + try { + isRequestAborted = isLocalStreamLifecycleError(error); + } catch { + // A hostile Proxy must not escape the provider-error boundary during abort classification. + } + } // #8376: proxyFetch tags unreachable transport failures so they remain // distinguishable from ordinary provider 5xx responses. const isProxyUnreachableFailure = - !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable"; - const errorCode = getUpstreamErrorIdentifier(error); + !isRequestAborted && errorMetadata.errorCode === "proxy_unreachable"; + const errorCode = errorMetadata.code; const localRateLimitFailure = localLimiterErrors.getClientSafeLocalRateLimitError(error); const failureStatus = isRequestAborted ? 499 @@ -3760,14 +3777,27 @@ export async function handleChatCore({ ? HTTP_STATUS.BAD_GATEWAY : localRateLimitFailure ? localRateLimitFailure.status - : error.name === "TimeoutError" || error.name === "BodyTimeoutError" + : errorMetadata.name === "TimeoutError" || errorMetadata.name === "BodyTimeoutError" ? HTTP_STATUS.GATEWAY_TIMEOUT - : error.status && typeof error.status === "number" - ? error.status + : errorMetadata.status + ? errorMetadata.status : HTTP_STATUS.BAD_GATEWAY; const failureMessage = isRequestAborted ? "Request aborted" - : formatProviderError(localRateLimitFailure ?? error, provider, model, failureStatus); + : (() => { + try { + return formatProviderError( + localRateLimitFailure ?? error, + provider, + model, + failureStatus + ); + } catch { + // Formatting is diagnostic only; hostile rejection metadata falls back safely. + return errorMetadata.message || "Upstream provider error"; + } + })(); + const safeFailureMessage = sanitizeErrorMessage(failureMessage) || "Upstream provider error"; const upstreamErrorCode = localRateLimitFailure?.code ?? (isProxyUnreachableFailure ? "proxy_unreachable" : errorCode); // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, @@ -3776,7 +3806,7 @@ export async function handleChatCore({ // tags its pre-response timeout via the code below.) const isOwnDeadlineTimeout = failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT && - (error.name === "TimeoutError" || error.name === "BodyTimeoutError"); + (errorMetadata.name === "TimeoutError" || errorMetadata.name === "BodyTimeoutError"); const upstreamErrorType = upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout ? "upstream_timeout" @@ -3791,7 +3821,7 @@ export async function handleChatCore({ }).catch(() => {}); persistAttemptLogs({ status: failureStatus, - error: failureMessage, + error: safeFailureMessage, providerRequest: finalBody || translatedBody, // On a client-abort (AbortError), the client already disconnected before // we ever got here — this body is what we WOULD have sent, not what was @@ -3799,19 +3829,22 @@ export async function handleChatCore({ // dashboard reads that field as "what the client received"), so omit it // for this case; `error` above already records the failure reason. clientResponse: - error.name === "AbortError" ? undefined : buildErrorBody(failureStatus, failureMessage), + errorMetadata.name === "AbortError" + ? undefined + : buildErrorBody(failureStatus, failureMessage), claudeCacheMeta: claudePromptCacheLogMeta, cacheSource: "upstream", }); if (isRequestAborted) { - streamController.handleError(error); + streamController.handleError(createSafeAbortError()); return createErrorResult(499, "Request aborted"); } - persistFailureUsage( - failureStatus, - upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error") + const failureUsageCode = projectPublicErrorIdentifier( + upstreamErrorCode || errorMetadata.name, + "upstream_error" ); - console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`); + persistFailureUsage(failureStatus, failureUsageCode); + console.log(`${COLORS.red}[ERROR] ${safeFailureMessage}${COLORS.reset}`); if (stream && upstreamErrorCode) { const result = createStreamingErrorResult( failureStatus, @@ -3972,7 +4005,10 @@ export async function handleChatCore({ upstreamErrorParsed = false; // Let it be parsed downstream } } catch (retryErr) { - if (isManagedLeaseFenceError(retryErr)) return managedLeaseFenceErrorResult(retryErr); + const retryLeaseFenceCode = getManagedLeaseFenceErrorCode( + getUpstreamErrorIdentifier(retryErr) + ); + if (retryLeaseFenceCode) return managedLeaseFenceErrorResult(retryLeaseFenceCode); // Refresh succeeded but the retry leg failed (network blip, AbortError, // executor throw). Don't swallow — the operator-visible signal "the user // saw 401 even though auth was actually fixed" is much more confusing @@ -4032,8 +4068,8 @@ export async function handleChatCore({ message = details.message; retryAfterMs = details.retryAfterMs; upstreamErrorBody = details.responseBody; - upstreamErrorCode = details.errorCode as string | undefined; - upstreamErrorType = details.errorType as string | undefined; + upstreamErrorCode = typeof details.errorCode === "string" ? details.errorCode : undefined; + upstreamErrorType = typeof details.errorType === "string" ? details.errorType : undefined; } // Gateways like agentrouter misstate temporary quota exhaustion as 403/400, @@ -4089,8 +4125,14 @@ export async function handleChatCore({ message = signatureRecovery.error.message; retryAfterMs = signatureRecovery.error.retryAfterMs; upstreamErrorBody = signatureRecovery.error.responseBody; - upstreamErrorCode = signatureRecovery.error.errorCode as string | undefined; - upstreamErrorType = signatureRecovery.error.errorType as string | undefined; + upstreamErrorCode = + typeof signatureRecovery.error.errorCode === "string" + ? signatureRecovery.error.errorCode + : undefined; + upstreamErrorType = + typeof signatureRecovery.error.errorType === "string" + ? signatureRecovery.error.errorType + : undefined; } } @@ -4225,79 +4267,84 @@ export async function handleChatCore({ `[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active` ); } else { - // Kimi's 403 says "billing cycle" for both an exhausted subscription and a - // temporary request window. Read its official usage endpoint before making - // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit - // window must recover automatically at the reported reset time. - let kimiRateLimitResetAt: string | null = null; - if (provider === "kimi-coding") { - try { - const { fetchAndPersistProviderLimits } = - await import("@/lib/usage/providerLimits"); - const { usage } = await fetchAndPersistProviderLimits(errorConnectionId, "manual"); - kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage); - } catch { - // Preserve the existing quota handling when Kimi's usage endpoint is unavailable. + // Kimi's 403 says "billing cycle" for both an exhausted subscription and a + // temporary request window. Read its official usage endpoint before making + // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit + // window must recover automatically at the reported reset time. + let kimiRateLimitResetAt: string | null = null; + if (provider === "kimi-coding") { + try { + const { fetchAndPersistProviderLimits } = + await import("@/lib/usage/providerLimits"); + const { usage } = await fetchAndPersistProviderLimits( + errorConnectionId, + "manual" + ); + kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage); + } catch { + // Preserve the existing quota handling when Kimi's usage endpoint is unavailable. + } } - } - // Providers with per-model quotas — lock the model only, not the connection - const quotaCooldownMs = kimiRateLimitResetAt - ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0) - : retryAfterMs || COOLDOWN_MS.rateLimit; - const accountSemaphoreKey = resolveAccountSemaphoreKey({ - provider, - model: currentModel, - connectionId: errorConnectionId, - credentials, - }); - if (accountSemaphoreKey) { - markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); - } - if (kimiRateLimitResetAt) { - await updateProviderConnection(errorConnectionId, { - testStatus: "unavailable", - rateLimitedUntil: kimiRateLimitResetAt, - backoffLevel: 0, - lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, - lastError: message, - errorCode: statusCode, - }); - console.warn( - `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}` - ); - } else if (isModelScope() && errorConnectionId) { - const lockFn = provider === "antigravity" ? lockExactModel : lockModel; - lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); - console.warn( - `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` - ); - } else if ( - lockModelIfPerModelQuota( + // Providers with per-model quotas — lock the model only, not the connection + const quotaCooldownMs = kimiRateLimitResetAt + ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0) + : retryAfterMs || COOLDOWN_MS.rateLimit; + const accountSemaphoreKey = resolveAccountSemaphoreKey({ provider, - errorConnectionId, - model, - "quota_exhausted", - quotaCooldownMs - ) - ) { - const quotaScope = getQuotaScopeLabelForProvider(provider, model); - console.warn( - `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)` - ); - } else { - await writeTerminalStatus( - errorConnectionId, - { - testStatus: "credits_exhausted", + model: currentModel, + connectionId: errorConnectionId, + credentials, + }); + if (accountSemaphoreKey) { + markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); + } + if (kimiRateLimitResetAt) { + await updateProviderConnection(errorConnectionId, { + testStatus: "unavailable", + rateLimitedUntil: kimiRateLimitResetAt, + backoffLevel: 0, + lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, lastError: message, - lastErrorType: errorType, - errorCode: String(statusCode), - }, - "production" - ); - console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`); - } + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}` + ); + } else if (isModelScope() && errorConnectionId) { + const lockFn = provider === "antigravity" ? lockExactModel : lockModel; + lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); + console.warn( + `[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` + ); + } else if ( + lockModelIfPerModelQuota( + provider, + errorConnectionId, + model, + "quota_exhausted", + quotaCooldownMs + ) + ) { + const quotaScope = getQuotaScopeLabelForProvider(provider, model); + console.warn( + `[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)` + ); + } else { + await writeTerminalStatus( + errorConnectionId, + { + testStatus: "credits_exhausted", + lastError: message, + lastErrorType: errorType, + errorCode: String(statusCode), + }, + "production" + ); + console.warn( + `[provider] Node ${errorConnectionId} exhausted quota (${statusCode})` + ); + } } // close probeIsolated3 else } } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) { @@ -4408,7 +4455,9 @@ export async function handleChatCore({ }).catch(() => {}); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); - console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + const safeErrMsg = sanitizeErrorMessage(errMsg) || "Upstream provider error"; + const safeUpstreamErrorBody = sanitizeUpstreamDetails(upstreamErrorBody); + console.log(`${COLORS.red}[ERROR] ${safeErrMsg}${COLORS.reset}`); // Log Antigravity retry time if available if (retryAfterMs && provider === "antigravity") { @@ -4422,7 +4471,7 @@ export async function handleChatCore({ providerResponse.status, providerResponse.statusText, providerResponse.headers, - upstreamErrorBody + safeUpstreamErrorBody ); // Update rate limiter from error response headers @@ -4464,9 +4513,9 @@ export async function handleChatCore({ // Fallback also failed — return original error persistAttemptLogs({ status: statusCode, - error: errMsg, + error: safeErrMsg, providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, + providerResponse: safeUpstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); @@ -4484,9 +4533,9 @@ export async function handleChatCore({ } catch { persistAttemptLogs({ status: statusCode, - error: errMsg, + error: safeErrMsg, providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, + providerResponse: safeUpstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); @@ -4504,9 +4553,9 @@ export async function handleChatCore({ } else { persistAttemptLogs({ status: statusCode, - error: errMsg, + error: safeErrMsg, providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, + providerResponse: safeUpstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); @@ -4553,9 +4602,9 @@ export async function handleChatCore({ } else { persistAttemptLogs({ status: statusCode, - error: errMsg, + error: safeErrMsg, providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, + providerResponse: safeUpstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); @@ -4573,9 +4622,9 @@ export async function handleChatCore({ } catch { persistAttemptLogs({ status: statusCode, - error: errMsg, + error: safeErrMsg, providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, + providerResponse: safeUpstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); @@ -4593,9 +4642,9 @@ export async function handleChatCore({ } else { persistAttemptLogs({ status: statusCode, - error: errMsg, + error: safeErrMsg, providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, + providerResponse: safeUpstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); @@ -4613,9 +4662,9 @@ export async function handleChatCore({ } else { persistAttemptLogs({ status: statusCode, - error: errMsg, + error: safeErrMsg, providerRequest: finalBody || translatedBody, - providerResponse: upstreamErrorBody, + providerResponse: safeUpstreamErrorBody, clientResponse: buildErrorBody(statusCode, errMsg), cacheSource: "upstream", }); @@ -4662,11 +4711,13 @@ export async function handleChatCore({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, }).catch(() => {}); const invalidSseMessage = parsed.message; + const safeInvalidSseMessage = + sanitizeErrorMessage(invalidSseMessage) || "Invalid SSE response for non-streaming request"; persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, - error: invalidSseMessage, + error: safeInvalidSseMessage, providerRequest: finalBody || translatedBody, - providerResponse: normalizedProviderPayload, + providerResponse: sanitizeUpstreamDetails(normalizedProviderPayload), clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage), cacheSource: "upstream", }); @@ -4684,11 +4735,12 @@ export async function handleChatCore({ }).catch(() => {}); const detailedError = parsed.detailedError; const invalidJsonMessage = parsed.message; + const safeDetailedError = sanitizeErrorMessage(detailedError) || invalidJsonMessage; persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, - error: detailedError, + error: safeDetailedError, providerRequest: finalBody || translatedBody, - providerResponse: normalizedProviderPayload, + providerResponse: sanitizeUpstreamDetails(normalizedProviderPayload), clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage), cacheSource: "upstream", }); @@ -4735,12 +4787,10 @@ export async function handleChatCore({ } } } catch (retryErr) { - log?.warn?.( - "RETRY", - `clinepass retry failed: ${ - retryErr instanceof Error ? retryErr.message : String(retryErr) - }` - ); + const retryMessage = + sanitizeErrorMessage(getSafeErrorMetadata(retryErr).message ?? retryErr) || + "Upstream provider error"; + log?.warn?.("RETRY", `clinepass retry failed: ${retryMessage}`); } } if (envError) { @@ -4777,7 +4827,7 @@ export async function handleChatCore({ status: HTTP_STATUS.BAD_GATEWAY, error: emptyContentMessage, providerRequest: finalBody || translatedBody, - providerResponse: normalizedProviderPayload, + providerResponse: sanitizeUpstreamDetails(normalizedProviderPayload), clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage), cacheSource: "upstream", }); @@ -5144,14 +5194,26 @@ export async function handleChatCore({ const malformedClientBody = buildErrorBody(HTTP_STATUS.BAD_GATEWAY, malformedMessage); malformedClientBody.error.code = malformed.code; malformedClientBody.error.type = malformed.type; + const sanitizedMalformedResponse = sanitizeUpstreamDetails(responseBody); + const sanitizedMalformedProviderResponse = looksLikeSSE + ? { + _streamed: true, + _format: "sse-json", + summary: sanitizedMalformedResponse, + } + : sanitizedMalformedResponse; + reqLogger.logProviderResponse( + providerResponse.status, + providerResponse.statusText, + providerResponse.headers, + sanitizedMalformedProviderResponse + ); persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, tokens: usage, - responseBody, + responseBody: sanitizedMalformedResponse, providerRequest: finalBody || translatedBody, - providerResponse: looksLikeSSE - ? { _streamed: true, _format: "sse-json", summary: responseBody } - : responseBody, + providerResponse: sanitizedMalformedProviderResponse, clientResponse: malformedClientBody, claudeCacheMeta: claudePromptCacheLogMeta, claudeCacheUsageMeta: cacheUsageLogMeta, diff --git a/open-sse/handlers/chatCore/streamErrorResult.ts b/open-sse/handlers/chatCore/streamErrorResult.ts index 77244b611d0..7bc0dbaaaf3 100644 --- a/open-sse/handlers/chatCore/streamErrorResult.ts +++ b/open-sse/handlers/chatCore/streamErrorResult.ts @@ -4,19 +4,65 @@ * * Extracted from chatCore: identify semaphore capacity errors, build a sanitized SSE error result * (an `data: {...}\n\ndata: [DONE]\n\n` body wrapped in an event-stream Response), and pull a string - * error code off an unknown error. Side-effect-free; behaviour is byte-identical to the previous - * module-level functions. + * error code off an unknown error. The status and SSE envelope remain stable while every public + * message/code/type crosses the canonical sanitizer; raw internal fields stay outside this body. */ -import { buildErrorBody } from "../../utils/error.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "../../utils/error.ts"; + +export interface SafeErrorMetadata { + code?: string; + errorCode?: string; + message?: string; + name?: string; + status?: number; +} + +function readErrorProperty(error: object, property: string): unknown { + try { + return Reflect.get(error, property); + } catch { + // Provider rejections may be hostile Proxies; public error handling fails closed per field. + return undefined; + } +} + +export function getSafeErrorMetadata(error: unknown): SafeErrorMetadata { + if (error === null || (typeof error !== "object" && typeof error !== "function")) { + return {}; + } + const code = readErrorProperty(error, "code"); + const errorCode = readErrorProperty(error, "errorCode"); + const message = readErrorProperty(error, "message"); + const name = readErrorProperty(error, "name"); + const status = readErrorProperty(error, "status"); + return { + code: typeof code === "string" && code.length > 0 ? code : undefined, + errorCode: typeof errorCode === "string" && errorCode.length > 0 ? errorCode : undefined, + message: typeof message === "string" && message.length > 0 ? message : undefined, + name: typeof name === "string" && name.length > 0 ? name : undefined, + status: typeof status === "number" && Number.isFinite(status) ? status : undefined, + }; +} + +export function createSafeAbortError(): Error { + const error = new Error("Request aborted"); + error.name = "AbortError"; + return error; +} + +export function formatStreamRecoveryRetryWarning( + attempt: number, + maxAttempts: number, + error: unknown +): string { + const safeName = sanitizeErrorMessage(getSafeErrorMetadata(error).name) || "truncation"; + return `transparent early-retry ${attempt}/${maxAttempts} after ${safeName}`; +} export function isSemaphoreCapacityError(error: unknown): error is Error & { code: string } { - return ( - !!error && - typeof error === "object" && - ((error as { code?: unknown }).code === "SEMAPHORE_TIMEOUT" || - (error as { code?: unknown }).code === "SEMAPHORE_QUEUE_FULL") - ); + const code = getSafeErrorMetadata(error).code; + return code === "SEMAPHORE_TIMEOUT" || code === "SEMAPHORE_QUEUE_FULL"; } export function createStreamingErrorResult( @@ -25,13 +71,7 @@ export function createStreamingErrorResult( code?: string, type?: string ) { - const errorBody = buildErrorBody(statusCode, message); - if (code) { - errorBody.error.code = code; - } - if (type) { - errorBody.error.type = type; - } + const errorBody = buildErrorBody(statusCode, message, undefined, { code, type }); const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`; @@ -52,7 +92,5 @@ export function createStreamingErrorResult( } export function getUpstreamErrorIdentifier(error: unknown): string | undefined { - if (!error || typeof error !== "object") return undefined; - const value = (error as { code?: unknown }).code; - return typeof value === "string" && value.length > 0 ? value : undefined; + return getSafeErrorMetadata(error).code; } diff --git a/open-sse/handlers/moderations.ts b/open-sse/handlers/moderations.ts index c153e10eea4..fa5cb72a16a 100644 --- a/open-sse/handlers/moderations.ts +++ b/open-sse/handlers/moderations.ts @@ -6,7 +6,8 @@ import { CORS_HEADERS } from "../utils/cors.ts"; */ import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.ts"; -import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts"; +import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; +import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; @@ -57,14 +58,11 @@ export async function handleModeration({ body, credentials }) { if (!res.ok) { const errText = await res.text(); - // secret-leak hardening: redact any credential the upstream echoed back - // before relaying the error body to the client (structure-preserving). - return new Response(redactSensitiveErrorText(errText), { + return buildSanitizedUpstreamErrorResponse({ status: res.status, - headers: { - "Content-Type": "application/json", - ...CORS_HEADERS, - }, + rawBody: errText, + fallbackMessage: `Moderation provider returned HTTP ${res.status}`, + headers: CORS_HEADERS, }); } @@ -79,6 +77,10 @@ export async function handleModeration({ body, credentials }) { }); return new Response(JSON.stringify(data), { status: 200, headers }); } catch (err) { - return errorResponse(500, `Moderation request failed: ${err.message}`); + const safeDetail = + sanitizeErrorMessage(err) + .replace(/^[A-Za-z]*Error:\s*/, "") + .trim() || "unknown upstream failure"; + return errorResponse(500, `Moderation request failed: ${safeDetail}`); } } diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts index f5d52f01067..16538e08cc1 100644 --- a/open-sse/handlers/ocr.ts +++ b/open-sse/handlers/ocr.ts @@ -11,7 +11,8 @@ import { parseOcrModel, OCR_PROVIDERS, } from "../config/ocrRegistry.ts"; -import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts"; +import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; +import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; import { @@ -151,15 +152,11 @@ export async function handleOcr({ if (!res.ok) { const errText = await res.text(); - // secret-leak hardening: an upstream OCR provider can echo the offending - // request (Authorization header / api key) inside its error text. Redact - // secret patterns (structure-preserving) before relaying to the client. - return new Response(redactSensitiveErrorText(errText), { + return buildSanitizedUpstreamErrorResponse({ status: res.status, - headers: { - "Content-Type": "application/json", - ...CORS_HEADERS, - }, + rawBody: errText, + fallbackMessage: `OCR provider returned HTTP ${res.status}`, + headers: CORS_HEADERS, }); } @@ -184,7 +181,8 @@ export async function handleOcr({ }); return new Response(JSON.stringify(parsed), { status: 200, headers }); } catch (err) { - console.error("[OCR]", err); + const safeErrorMessage = sanitizeErrorMessage(err).trim() || "OCR request failed"; + console.error("[OCR]", safeErrorMessage); return errorResponse(500, "OCR request failed"); } } diff --git a/open-sse/services/tlsClientBase.ts b/open-sse/services/tlsClientBase.ts index 2b3fef63d4b..386b78b8658 100644 --- a/open-sse/services/tlsClientBase.ts +++ b/open-sse/services/tlsClientBase.ts @@ -25,18 +25,41 @@ // --------------------------------------------------------------------------- import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { join, dirname } from "node:path"; -import { open, unlink, rmdir, readFile, mkdtemp, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { open, readFile, mkdtemp, stat } from "node:fs/promises"; // --------------------------------------------------------------------------- // Proxy resolution — every provider file imports both of these // --------------------------------------------------------------------------- import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; +import { logger } from "../utils/logger.ts"; import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; import { buildNativeTlsClientOptions, resolveVerifiedTlsClientNativeLibrary, } from "./tlsClientDownloadDir.ts"; +import { + acquireNativeTlsClientLease, + activateNativeTlsClientLease, + installNativeTlsClientExitHook, + releaseNativeTlsClientLease, + type NativeTlsClientLease, +} from "./tlsClientLifecycleRegistry.ts"; +import { + cleanupTlsClientStreamPath, + createSanitizedTlsStreamError, + createTlsClientTailStream, + sanitizeTlsClientErrorMessage, + type TlsClientTailVariant, +} from "./tlsClientStream.ts"; +import { makeAbortError, raceWithTimeout, TlsClientHangError } from "./tlsClientTimeout.ts"; + +export { makeAbortError, raceWithTimeout, TlsClientHangError }; + +const tlsClientLogger = logger("TLSClient"); +const DEFAULT_CLIENT_START_TIMEOUT_MS = 30_000; +const DEFAULT_PARTIAL_CLIENT_CLEANUP_TIMEOUT_MS = 5_000; // --------------------------------------------------------------------------- // Types @@ -94,7 +117,7 @@ export interface TlsClientConfig { * "B1" — Buffer.from enqueue, excludes EOF, inline drainRemaining * "B2" — Buffer.from enqueue, excludes EOF, extracted helpers */ - tailFileVariant: "A" | "B1" | "B2"; + tailFileVariant: TlsClientTailVariant; /** * Response validation mode: * "sse" — check looksLikeSse → fall back to buffered @@ -125,8 +148,13 @@ export class TlsClientUnavailableError extends Error { override name = "TlsClientUnavailableError"; } -export class TlsClientHangError extends Error { - override name = "TlsClientHangError"; +function isTlsClientHangError(error: unknown): error is TlsClientHangError { + try { + return error instanceof TlsClientHangError; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + return false; + } } // --------------------------------------------------------------------------- @@ -137,14 +165,6 @@ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function makeAbortError(signal: AbortSignal): Error { - const reason = signal.reason; - if (reason instanceof Error) return reason; - const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); - err.name = "AbortError"; - return err; -} - export function toHeaders(raw: Record | null | undefined): Headers { const h = new Headers(); for (const [k, vs] of Object.entries(raw || {})) { @@ -153,65 +173,6 @@ export function toHeaders(raw: Record | null | undefined): Hea return h; } -export async function raceWithTimeout( - promise: Promise, - timeoutMs: number, - signal: AbortSignal | null | undefined -): Promise { - // If no signal, just race with a simple timeout. - if (!signal) { - return await Promise.race([ - promise, - new Promise((_, reject) => { - setTimeout(() => reject(new TlsClientHangError()), timeoutMs); - }), - ]); - } - - // With signal, race against both timeout and abort. - return await new Promise((resolve, reject) => { - let settled = false; - - const done = (fn: () => void) => { - if (!settled) { - settled = true; - fn(); - } - }; - - const timer = setTimeout(() => { - done(() => reject(new TlsClientHangError())); - }, timeoutMs); - - const onAbort = () => { - done(() => reject(makeAbortError(signal))); - }; - - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } - - promise.then( - (v) => { - done(() => { - clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - resolve(v); - }); - }, - (e) => { - done(() => { - clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - reject(e); - }); - } - ); - }); -} - /** Read up to N bytes from a file, returning the utf-8 decoded text. */ export async function readFirstBytes(path: string, n: number): Promise { const fd = await open(path, "r"); @@ -280,23 +241,6 @@ export function isCloudflareChallenge(text: string | null | undefined): boolean ); } -// --------------------------------------------------------------------------- -// Temp-path cleanup — two variants -// --------------------------------------------------------------------------- - -/** Variant A: substring-based parent dir extraction (ChatGPT, Claude, Perplexity, Notion) */ -async function cleanupTempPathSubstring(path: string): Promise { - await unlink(path).catch(() => {}); - const dir = path.substring(0, path.lastIndexOf("/")); - await rmdir(dir).catch(() => {}); -} - -/** Variant B: dirname-based parent dir extraction (Grok, LMArena) */ -async function cleanupTempPathDirname(path: string): Promise { - await unlink(path).catch(() => {}); - await rmdir(dirname(path)).catch(() => {}); -} - async function readTextFileIfExists(path: string): Promise { try { return await readFile(path, "utf8"); @@ -305,302 +249,12 @@ async function readTextFileIfExists(path: string): Promise { } } -// --------------------------------------------------------------------------- -// TailFile — Variant A -// Uint8Array enqueue, includes EOF symbol, substring cleanup -// Used by: ChatGPT, Claude, Perplexity, Notion -// --------------------------------------------------------------------------- - -function tailFileVariantA( - path: string, - eofSymbol: string, - done: Promise, - signal: AbortSignal | null = null, - cleanupPath: string -): ReadableStream { - return new ReadableStream({ - async start(controller) { - const fd = await open(path, "r"); - const buf = Buffer.alloc(64 * 1024); - let offset = 0; - let finished = false; - let aborted = false; - let upstreamError: Error | null = null; - - done.then( - () => { - finished = true; - }, - (err) => { - upstreamError = err instanceof Error ? err : new Error(String(err)); - finished = true; - } - ); - - const onAbort = () => { - aborted = true; - }; - if (signal) { - if (signal.aborted) aborted = true; - else signal.addEventListener("abort", onAbort, { once: true }); - } - - let errored = false; - try { - while (!aborted) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offset); - if (bytesRead > 0) { - const chunk = buf.subarray(0, bytesRead); - offset += bytesRead; - const text = chunk.toString("utf8"); - if (text.includes(eofSymbol)) { - const cutAt = text.indexOf(eofSymbol) + eofSymbol.length; - controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt))); - break; - } - controller.enqueue(new Uint8Array(chunk)); - } else if (finished) { - if (upstreamError) { - controller.error(upstreamError); - errored = true; - } - break; - } else { - await sleep(25); - } - } - } catch (err) { - controller.error(err); - errored = true; - } finally { - if (signal) signal.removeEventListener("abort", onAbort); - await fd.close().catch(() => {}); - await cleanupTempPathSubstring(cleanupPath); - if (!errored) controller.close(); - } - }, - }); -} - -// --------------------------------------------------------------------------- -// TailFile — Variant B1 -// Buffer.from enqueue, excludes EOF symbol, inline drainRemaining loop -// Used by: Grok -// --------------------------------------------------------------------------- - -function tailFileVariantB1( - path: string, - eofSymbol: string, - done: Promise, - signal: AbortSignal | null = null, - cleanupPath: string -): ReadableStream { - return new ReadableStream({ - async start(controller) { - const fd = await open(path, "r"); - const buf = Buffer.alloc(64 * 1024); - let offset = 0; - let finished = false; - let aborted = false; - let upstreamError: Error | null = null; - - done.then( - () => { - finished = true; - }, - (err) => { - upstreamError = err instanceof Error ? err : new Error(String(err)); - finished = true; - } - ); - - const onAbort = () => { - aborted = true; - }; - if (signal) { - if (signal.aborted) aborted = true; - else signal.addEventListener("abort", onAbort, { once: true }); - } - - let errored = false; - try { - while (!aborted) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offset); - if (bytesRead > 0) { - const chunk = buf.subarray(0, bytesRead); - offset += bytesRead; - const text = chunk.toString("utf8"); - - if (text.includes(eofSymbol)) { - const beforeEof = text.substring(0, text.indexOf(eofSymbol)); - if (beforeEof) { - controller.enqueue(Buffer.from(beforeEof, "utf8")); - } - controller.close(); - return; - } - - controller.enqueue(Buffer.from(chunk)); - } - - if (finished) { - // Request finished — drain any remaining bytes then close. - while (true) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offset); - if (bytesRead === 0) break; - const chunk = buf.subarray(0, bytesRead); - offset += bytesRead; - const text = chunk.toString("utf8"); - - if (text.includes(eofSymbol)) { - const beforeEof = text.substring(0, text.indexOf(eofSymbol)); - if (beforeEof) { - controller.enqueue(Buffer.from(beforeEof, "utf8")); - } - controller.close(); - return; - } - - controller.enqueue(Buffer.from(chunk)); - } - - if (upstreamError && !errored) { - errored = true; - controller.error(upstreamError); - return; - } - - controller.close(); - return; - } - - await sleep(25); - } - } catch (err) { - if (!errored) { - errored = true; - controller.error(err instanceof Error ? err : new Error(String(err))); - } - } finally { - await fd.close().catch(() => {}); - await cleanupTempPathDirname(cleanupPath); - if (signal) signal.removeEventListener("abort", onAbort); - } - }, - }); -} - -// --------------------------------------------------------------------------- -// TailFile — Variant B2 -// Buffer.from enqueue, excludes EOF symbol, extracted helpers -// Used by: LMArena -// --------------------------------------------------------------------------- - -type FileHandle = Awaited>; - -function enqueueChunkMaybeEof( - controller: ReadableStreamDefaultController, - chunk: Buffer, - eofSymbol: string -): boolean { - const text = chunk.toString("utf8"); - if (!text.includes(eofSymbol)) { - controller.enqueue(Buffer.from(chunk)); - return false; - } - const beforeEof = text.substring(0, text.indexOf(eofSymbol)); - if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); - controller.close(); - return true; -} - -async function drainRemaining( - fd: FileHandle, - buf: Buffer, - offsetRef: { offset: number }, - controller: ReadableStreamDefaultController, - eofSymbol: string -): Promise<"closed" | "drained"> { - while (true) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); - if (bytesRead === 0) return "drained"; - const chunk = buf.subarray(0, bytesRead); - offsetRef.offset += bytesRead; - if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed"; - } -} - -function tailFileVariantB2( - path: string, - eofSymbol: string, - done: Promise, - signal: AbortSignal | null = null, - cleanupPath: string -): ReadableStream { - return new ReadableStream({ - async start(controller) { - const fd = await open(path, "r"); - const buf = Buffer.alloc(64 * 1024); - const offsetRef = { offset: 0 }; - let finished = false; - let aborted = false; - let upstreamError: Error | null = null; - let errored = false; - - done.then( - () => { - finished = true; - }, - (err) => { - upstreamError = err instanceof Error ? err : new Error(String(err)); - finished = true; - } - ); - - const onAbort = () => { - aborted = true; - }; - if (signal) { - if (signal.aborted) aborted = true; - else signal.addEventListener("abort", onAbort, { once: true }); - } - - try { - while (!aborted) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); - if (bytesRead > 0) { - const chunk = buf.subarray(0, bytesRead); - offsetRef.offset += bytesRead; - if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return; - } - - if (!finished) { - await sleep(25); - continue; - } - - const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol); - if (drained === "closed") return; - if (upstreamError && !errored) { - errored = true; - controller.error(upstreamError); - return; - } - controller.close(); - return; - } - } catch (err) { - if (!errored) { - errored = true; - controller.error(err instanceof Error ? err : new Error(String(err))); - } - } finally { - await fd.close().catch(() => {}); - await cleanupTempPathDirname(cleanupPath); - if (signal) signal.removeEventListener("abort", onAbort); - } - }, - }); +function sanitizeTlsFetchRejection(error: unknown): TlsResponseLike { + return { + status: 502, + headers: {}, + body: sanitizeTlsClientErrorMessage(error), + }; } // --------------------------------------------------------------------------- @@ -612,43 +266,66 @@ function tailFileVariantB2( * Uses dynamic `import("tls-client-node")` with `{ runtimeMode: "native" }` * and `client.start()`, matching the original per-provider lifecycle. */ -export function createGetClient(config: { - providerName: string; - tlsProfile?: string; -}): () => Promise<{ +type TlsClientConstructor = new (config: Record) => { + start: () => Promise; request: (url: string, opts: Record) => Promise; -}> { - let clientPromise: Promise<{ - request: (url: string, opts: Record) => Promise; - }> | null = null; - let exitHookInstalled = false; - - const installExitHook = (client: { stop: () => Promise }): void => { - if (!exitHookInstalled) { - exitHookInstalled = true; - process.on("exit", () => { - void client.stop(); + stop: () => Promise; +}; + +type TlsClientInstance = InstanceType; +type TlsClientRequestClient = Pick; +type ManagedTlsClientGetter = { + (): Promise; + invalidate: (expectedClient: TlsClientRequestClient) => Promise; +}; + +export function createGetClient( + config: { + providerName: string; + tlsProfile?: string; + }, + dependencies: { + loadTlsClient?: () => Promise<{ TLSClient: TlsClientConstructor }>; + resolveNativeLibrary?: () => Promise; + startTimeoutMs?: number; + cleanupTimeoutMs?: number; + installExitHook?: (hook: () => void) => void; + } = {} +): ManagedTlsClientGetter { + let clientPromise: Promise | null = null; + let activeClient: TlsClientInstance | null = null; + let invalidationPromise: Promise | null = null; + let invalidatingClient: TlsClientRequestClient | null = null; + const clientLeases = new WeakMap(); + const cleanupTimeoutMs = + dependencies.cleanupTimeoutMs ?? DEFAULT_PARTIAL_CLIENT_CLEANUP_TIMEOUT_MS; + + const releaseClientLeaseBounded = async ( + lease: NativeTlsClientLease, + warning: string + ): Promise => { + try { + await releaseNativeTlsClientLease(lease, cleanupTimeoutMs); + } catch (stopErr) { + tlsClientLogger.warn(warning, { + provider: config.providerName, + error: sanitizeErrorMessage(stopErr), }); } }; - return async function getClient(): Promise<{ - request: (url: string, opts: Record) => Promise; - }> { + const getClient = async function getClient(): Promise { + if (invalidationPromise) await invalidationPromise; if (!clientPromise) { clientPromise = (async () => { - let TLSClientCtor: { - new (config: Record): { - start: () => Promise; - request: (url: string, opts: Record) => Promise; - stop: () => Promise; - }; - }; + let TLSClientCtor: TlsClientConstructor; try { // tls-client-node uses a native binary loaded at runtime. // The dynamic import delays the binary load until first use — no // point crashing startup on machines where it's not installed. - const mod = await import("tls-client-node"); + const mod = dependencies.loadTlsClient + ? await dependencies.loadTlsClient() + : ((await import("tls-client-node")) as { TLSClient: TlsClientConstructor }); TLSClientCtor = mod.TLSClient; } catch { throw new TlsClientUnavailableError( @@ -657,11 +334,16 @@ export function createGetClient(config: { } let nativeLibraryPath: string; try { - nativeLibraryPath = await resolveVerifiedTlsClientNativeLibrary(); + nativeLibraryPath = await ( + dependencies.resolveNativeLibrary ?? resolveVerifiedTlsClientNativeLibrary + )(); } catch (err) { - const detail = err instanceof Error ? err.message : String(err); + tlsClientLogger.warn("Native binary verification failed", { + provider: config.providerName, + error: sanitizeErrorMessage(err), + }); throw new TlsClientUnavailableError( - `tls-client native binary verification failed for ${config.providerName}: ${detail}` + `tls-client native binary verification failed for ${config.providerName}` ); } const tlsOptions: Record = { @@ -670,16 +352,115 @@ export function createGetClient(config: { if (config.tlsProfile) { tlsOptions.clientIdentifier = config.tlsProfile; } - const client = new TLSClientCtor(tlsOptions); - // Start the native TLS client binding - await client.start(); - installExitHook(client); + let client: InstanceType | undefined; + let clientLease: NativeTlsClientLease | null = null; + let rawStartPromise: Promise | null = null; + try { + client = new TLSClientCtor(tlsOptions); + const constructedClient = client; + clientLease = await acquireNativeTlsClientLease( + nativeLibraryPath, + () => constructedClient.stop(), + cleanupTimeoutMs + ); + clientLeases.set(client, clientLease); + // Start the native TLS client binding. + rawStartPromise = client.start(); + await raceWithTimeout( + rawStartPromise, + dependencies.startTimeoutMs ?? DEFAULT_CLIENT_START_TIMEOUT_MS, + null + ); + activateNativeTlsClientLease(clientLease); + } catch (err) { + tlsClientLogger.warn("Native TLS client initialization failed", { + provider: config.providerName, + error: sanitizeErrorMessage(err), + }); + if (client && clientLease) { + if (rawStartPromise && isTlsClientHangError(err)) { + const discardedLease = clientLease; + const settledLateStart = rawStartPromise.then( + () => { + activateNativeTlsClientLease(discardedLease); + }, + (lateStartErr: unknown) => { + tlsClientLogger.warn("Timed-out native TLS client start later rejected", { + provider: config.providerName, + error: sanitizeErrorMessage(lateStartErr), + }); + } + ); + void settledLateStart.then(() => + releaseClientLeaseBounded( + discardedLease, + "Late native TLS client initialization cleanup failed" + ) + ); + } else { + await releaseClientLeaseBounded( + clientLease, + "Partial native TLS client cleanup failed" + ); + } + } + throw new TlsClientUnavailableError( + `tls-client native initialization failed for ${config.providerName}` + ); + } + activeClient = client; + installNativeTlsClientExitHook(dependencies.installExitHook); return client; })(); } - return clientPromise; + const pending = clientPromise; + try { + return await pending; + } catch (err) { + // A transient download/start failure must not poison this provider until + // process restart. Concurrent callers still share the same pending attempt; + // the next call creates a fresh one only after that attempt rejects. + if (clientPromise === pending) clientPromise = null; + throw err; + } + }; + + getClient.invalidate = async (expectedClient: TlsClientRequestClient): Promise => { + if (invalidationPromise && invalidatingClient === expectedClient) { + await invalidationPromise; + return; + } + if (activeClient !== expectedClient) return; + + const pendingClient = clientPromise; + activeClient = null; + clientPromise = null; + invalidatingClient = expectedClient; + + const cleanupPromise = (async () => { + let client: TlsClientInstance | null = null; + try { + client = pendingClient ? await pendingClient : null; + } catch { + // A rejected pending client never became active, so no client lease exists to release here. + return; + } + if (client !== expectedClient) return; + const lease = clientLeases.get(client); + if (!lease) return; + + await releaseClientLeaseBounded(lease, "Native TLS client invalidation cleanup failed"); + })(); + + invalidationPromise = cleanupPromise.finally(() => { + invalidationPromise = null; + invalidatingClient = null; + }); + await invalidationPromise; }; + + return getClient; } /** @@ -694,17 +475,6 @@ export function resolveProxyUrl(domain: string, perCall: string | undefined): st // Factory — creates provider-specific tlsFetch + helpers // --------------------------------------------------------------------------- -const CLEANUP_VARIANTS = { - A: cleanupTempPathSubstring, - B: cleanupTempPathDirname, -} as const; - -const TAIL_FILE_VARIANTS = { - A: tailFileVariantA, - B1: tailFileVariantB1, - B2: tailFileVariantB2, -} as const; - export interface TlsClientModule { tlsFetch: (url: string, options: TlsFetchOptions) => Promise; __setTlsFetchOverrideForTesting: ( @@ -720,6 +490,13 @@ export interface TlsClientModule { hardTimeoutMs?: number, firstByteTimeoutMs?: number ) => Promise; + __tlsFetchNonStreamingForTesting?: ( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + signal?: AbortSignal | null, + hardTimeoutMs?: number + ) => Promise; } /** @@ -747,29 +524,15 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule const getClient = createGetClient({ providerName, tlsProfile }); - function resetClientCache(): void { - // The getClient closure holds clientPromise — by design the only - // reference is inside getClient's closure. After a hang we need - // the next call to spawn a fresh binding. We achieve this by - // clearing the local reference; the module-level tlsFetch will - // re-read via getClient which recreates it. - // Since getClient's clientPromise is a closure variable, we - // re-create getClient itself: - Object.assign(localState, { - getClient: createGetClient({ providerName, tlsProfile }), - }); - // Note: this is safe because only tlsFetch calls getClient. - // A concurrent in-flight call holds its own reference. + async function resetClientCache(client: TlsClientRequestClient): Promise { + await getClient.invalidate(client); } - const localState: { getClient: typeof getClient } = { getClient }; - let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = null; - const tailFileFn = TAIL_FILE_VARIANTS[tailFileVariant]; - - const cleanupFn = tailFileVariant === "A" ? cleanupTempPathSubstring : cleanupTempPathDirname; + const cleanupFn = (path: string): Promise => + cleanupTlsClientStreamPath(tailFileVariant, path); async function tlsFetchStreaming( client: { request: (url: string, opts: Record) => Promise }, @@ -790,25 +553,29 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule streamOutputEOFSymbol: eofSymbol, }; + let nativeRequest: Promise; + try { + nativeRequest = client.request(url, streamOpts); + } catch (err) { + await cleanupFn(path); + throw createSanitizedTlsStreamError(err); + } + let resetOnHang = true; - const requestPromise = raceWithTimeout( - client.request(url, streamOpts), - hardTimeoutMs, - signal - ).catch((err: unknown) => { - if (resetOnHang && err instanceof TlsClientHangError) { - resetClientCache(); - resetOnHang = false; + const requestPromise = raceWithTimeout(nativeRequest, hardTimeoutMs, signal).catch( + async (err: unknown) => { + if (resetOnHang && isTlsClientHangError(err)) { + resetOnHang = false; + await resetClientCache(client); + } + throw err; } - throw err; - }); + ); // Wait for the file to exist AND have at least one byte. const ready = await waitForContent(path, firstByteMs, requestPromise); if (!ready) { - const r = await requestPromise.catch( - (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike - ); + const r = await requestPromise.catch(sanitizeTlsFetchRejection); const fileText = await readTextFileIfExists(path); await cleanupFn(path); return { @@ -819,7 +586,13 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule }; } - const peek = await readFirstBytes(path, 256); + let peek: string; + try { + peek = await readFirstBytes(path, 256); + } catch (err) { + await cleanupFn(path); + throw createSanitizedTlsStreamError(err); + } if (responseValidation === "cf") { // Cloudflare challenge check @@ -845,9 +618,7 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule } else { // SSE validation — if it doesn't look like SSE, return buffered if (!looksLikeSse(peek)) { - const r = await requestPromise.catch( - (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike - ); + const r = await requestPromise.catch(sanitizeTlsFetchRejection); const fileText = await readTextFileIfExists(path); await cleanupFn(path); return { @@ -860,7 +631,13 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule } // Looks valid — create streaming response. - const stream = tailFileFn(path, eofSymbol, requestPromise, signal, path); + const stream = createTlsClientTailStream({ + variant: tailFileVariant, + path, + eofSymbol, + done: requestPromise, + signal, + }); const contentType = responseValidation === "cf" ? "application/x-ndjson" : "text/event-stream"; @@ -871,6 +648,35 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule return { status: 200, headers, text: null, body: stream }; } + async function tlsFetchNonStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + signal: AbortSignal | null, + hardTimeoutMs: number + ): Promise { + let tlsResponse: TlsResponseLike; + try { + tlsResponse = await raceWithTimeout( + client.request(url, requestOptions), + hardTimeoutMs, + signal + ); + } catch (err) { + if (isTlsClientHangError(err)) { + await resetClientCache(client); + } + throw err; + } + if (signal?.aborted) throw makeAbortError(signal); + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; + } + async function tlsFetch(url: string, options: TlsFetchOptions = {}): Promise { // Resolve proxyUrl early so test overrides and the real path both see it. const resolvedProxyUrl = resolveProxyUrl(proxyDomainOverride ?? domain, options.proxyUrl); @@ -879,7 +685,7 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule if (options.signal?.aborted) { throw makeAbortError(options.signal); } - const client = await localState.getClient(); + const client = await getClient(); if (options.signal?.aborted) { throw makeAbortError(options.signal); } @@ -909,28 +715,13 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule ); } - let tlsResponse: TlsResponseLike; - try { - tlsResponse = await raceWithTimeout( - client.request(url, requestOptions), - (options.timeoutMs ?? defaultTimeoutMs) + hardTimeoutGraceMs, - options.signal ?? null - ); - } catch (err) { - if (err instanceof TlsClientHangError) { - resetClientCache(); - } - throw err; - } - if (options.signal?.aborted) { - throw makeAbortError(options.signal); - } - return { - status: tlsResponse.status, - headers: toHeaders(tlsResponse.headers), - text: tlsResponse.body, - body: null, - }; + return await tlsFetchNonStreaming( + client, + url, + requestOptions, + options.signal ?? null, + (options.timeoutMs ?? defaultTimeoutMs) + hardTimeoutGraceMs + ); } const module: TlsClientModule = { @@ -964,6 +755,15 @@ export function createTlsClientModule(config: TlsClientConfig): TlsClientModule firstByteMs ); }; + module.__tlsFetchNonStreamingForTesting = ( + client, + url, + requestOptions, + signal = null, + hardTimeoutMs = defaultTimeoutMs + hardTimeoutGraceMs + ): Promise => { + return tlsFetchNonStreaming(client, url, requestOptions, signal, hardTimeoutMs); + }; } return module; diff --git a/open-sse/services/tlsClientDownloadDir.ts b/open-sse/services/tlsClientDownloadDir.ts index 9daa9ea6399..04d277e3012 100644 --- a/open-sse/services/tlsClientDownloadDir.ts +++ b/open-sse/services/tlsClientDownloadDir.ts @@ -1,6 +1,9 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { constants as fsConstants } from "node:fs"; +import { chmod, lstat, mkdir, open, realpath, rename, rm, rmdir } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { resolveDataDir } from "@/lib/dataPaths"; import tlsClientNativeManifest from "../config/tlsClientNativeManifest.json"; @@ -10,21 +13,502 @@ type TlsClientNativeAsset = { }; type FetchLike = (input: string | URL, init?: RequestInit) => Promise; +type NativeResolverTestHooks = { + afterOpenedFileStat?: (filePath: string) => void | Promise; + afterInstallLockCreated?: (lockPath: string) => void | Promise; + afterInstallLockExists?: (lockPath: string) => void | Promise; + resolveTlsClientPackageJson?: () => string; +}; const TLS_CLIENT_NATIVE_ASSETS = tlsClientNativeManifest.assets as Record< string, TlsClientNativeAsset >; +const INSTALL_LOCK_TIMEOUT_MS = 75_000; +const STALE_INSTALL_LOCK_MS = 60_000; +const MAX_NATIVE_ASSET_BYTES = 64 * 1024 * 1024; +const NATIVE_ASSET_READ_CHUNK_BYTES = 64 * 1024; +const MAX_INSTALL_LOCK_TOKEN_BYTES = 128; +const moduleRequire = createRequire(import.meta.url); + +function validateNativeAsset(asset: TlsClientNativeAsset): void { + if ( + !asset.file || + asset.file === "." || + asset.file === ".." || + basename(asset.file) !== asset.file || + asset.file.includes("/") || + asset.file.includes("\\") || + asset.file.includes("\0") + ) { + throw new Error(`Invalid tls-client native asset path: ${JSON.stringify(asset.file)}`); + } + if (!/^[a-f0-9]{64}$/.test(asset.sha256)) { + throw new Error(`Invalid SHA-256 in tls-client native manifest for ${asset.file}`); + } +} + +async function resolveSafeDirectory( + directoryPath: string, + trustedRoot: string = directoryPath +): Promise { + const absolutePath = resolve(directoryPath); + const absoluteRoot = resolve(trustedRoot); + const relativePath = relative(absoluteRoot, absolutePath); + if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error(`Unsafe tls-client native directory outside trusted root: ${absolutePath}`); + } + + // The configured DATA_DIR itself is an operator-controlled trust anchor and + // may legitimately be a symlink (for example to a mounted volume). Every + // component created below it must be a real directory, never another link. + await mkdir(absoluteRoot, { recursive: true, mode: 0o700 }); + const canonicalRoot = await realpath(absoluteRoot); + const rootStats = await lstat(canonicalRoot); + if (!rootStats.isDirectory()) { + throw new Error(`Unsafe tls-client trusted directory: ${absoluteRoot}`); + } + + let currentPath = absoluteRoot; + const components = relativePath ? relativePath.split(sep).filter(Boolean) : []; + for (const component of components) { + currentPath = join(currentPath, component); + try { + await mkdir(currentPath, { mode: 0o700 }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + const stats = await lstat(currentPath); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`Unsafe tls-client native directory component: ${currentPath}`); + } + } + + const canonicalPath = await realpath(absolutePath); + const expectedCanonicalPath = resolve(canonicalRoot, relativePath); + if (canonicalPath !== expectedCanonicalPath) { + throw new Error(`Unsafe tls-client native directory redirection: ${absolutePath}`); + } + await chmod(canonicalPath, 0o700); + return canonicalPath; +} + +function sameFileIdentity( + left: { dev: number | bigint; ino: number | bigint }, + right: { dev: number | bigint; ino: number | bigint } +): boolean { + return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino); +} + +async function readInstallLockClaim(lockPath: string) { + let handle; + try { + handle = await open( + lockPath, + fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0) + ); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ELOOP" || code === "EISDIR") return undefined; + throw err; + } + + try { + const openedStats = await handle.stat({ bigint: true }); + if ( + !openedStats.isFile() || + openedStats.size <= 0n || + openedStats.size > BigInt(MAX_INSTALL_LOCK_TOKEN_BYTES) + ) { + return undefined; + } + + const bytes = Buffer.alloc(Number(openedStats.size)); + let bytesReadTotal = 0; + while (bytesReadTotal < bytes.length) { + const { bytesRead } = await handle.read( + bytes, + bytesReadTotal, + bytes.length - bytesReadTotal, + bytesReadTotal + ); + if (bytesRead === 0) break; + bytesReadTotal += bytesRead; + } + + const verifiedStats = await handle.stat({ bigint: true }); + if ( + bytesReadTotal !== bytes.length || + verifiedStats.size !== openedStats.size || + !sameFileIdentity(openedStats, verifiedStats) + ) { + return undefined; + } + + let pathStats; + try { + pathStats = await lstat(lockPath, { bigint: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw err; + } + if ( + pathStats.isSymbolicLink() || + !pathStats.isFile() || + !sameFileIdentity(openedStats, pathStats) + ) { + return undefined; + } + + return { stats: openedStats, token: bytes.toString("utf8") }; + } finally { + await handle.close(); + } +} + +/** + * Read and verify one regular file without following a final-component symlink. + * The second lstat closes the ordinary check/read path-swap window: callers only + * receive bytes when the pathname still identifies the inode that was opened. + */ +async function readVerifiedRegularFile( + filePath: string, + expectedSha256: string, + { + normalizeMode = false, + afterOpenedFileStat, + }: { + normalizeMode?: boolean; + afterOpenedFileStat?: NativeResolverTestHooks["afterOpenedFileStat"]; + } = {} +): Promise { + let pathStats; + try { + pathStats = await lstat(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw err; + } + if (pathStats.isSymbolicLink() || !pathStats.isFile()) { + throw new Error(`Unsafe tls-client native cache entry (symlink/non-regular file): ${filePath}`); + } + + let handle; + try { + handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ELOOP") { + throw new Error(`Unsafe tls-client native cache entry (symlink): ${filePath}`); + } + throw err; + } + + try { + const openedStats = await handle.stat(); + if (!openedStats.isFile()) { + throw new Error(`Unsafe tls-client native cache entry (not a regular file): ${filePath}`); + } + if (openedStats.size > MAX_NATIVE_ASSET_BYTES) { + throw new Error(`Local tls-client native asset exceeds the 64 MiB limit: ${filePath}`); + } + await afterOpenedFileStat?.(filePath); + + const readBuffer = Buffer.alloc(openedStats.size); + let bytesReadTotal = 0; + while (bytesReadTotal < readBuffer.length) { + const bytesToRead = Math.min( + NATIVE_ASSET_READ_CHUNK_BYTES, + readBuffer.length - bytesReadTotal + ); + const { bytesRead } = await handle.read( + readBuffer, + bytesReadTotal, + bytesToRead, + bytesReadTotal + ); + if (bytesRead === 0) break; + bytesReadTotal += bytesRead; + } + const bytes = readBuffer.subarray(0, bytesReadTotal); + const verifiedStats = await handle.stat(); + if (verifiedStats.size > MAX_NATIVE_ASSET_BYTES) { + throw new Error(`Local tls-client native asset exceeds the 64 MiB limit: ${filePath}`); + } + if ( + !verifiedStats.isFile() || + !sameFileIdentity(openedStats, verifiedStats) || + verifiedStats.size !== openedStats.size || + bytes.length !== verifiedStats.size + ) { + throw new Error( + `Unsafe tls-client native cache entry changed during verification: ${filePath}` + ); + } + const currentStats = await lstat(filePath); + if ( + currentStats.isSymbolicLink() || + !currentStats.isFile() || + !sameFileIdentity(openedStats, currentStats) + ) { + throw new Error( + `Unsafe tls-client native cache entry changed during verification: ${filePath}` + ); + } + if (createHash("sha256").update(bytes).digest("hex") !== expectedSha256) return undefined; + if (normalizeMode && process.platform !== "win32") await handle.chmod(0o500); + return bytes; + } finally { + await handle.close(); + } +} + +async function installVerifiedBytes( + downloadDir: string, + asset: TlsClientNativeAsset, + bytes: Buffer +): Promise { + const actualSha256 = createHash("sha256").update(bytes).digest("hex"); + if (actualSha256 !== asset.sha256) { + throw new Error( + `SHA-256 mismatch for tls-client v${tlsClientNativeManifest.version} native asset ` + + `${asset.file}: expected ${asset.sha256}, received ${actualSha256}` + ); + } + + const safeDir = await resolveSafeDirectory(downloadDir); + const destinationPath = join(safeDir, asset.file); + if (dirname(destinationPath) !== safeDir) { + throw new Error(`Invalid tls-client native asset path: ${asset.file}`); + } + + const existingBytes = await readVerifiedRegularFile(destinationPath, asset.sha256, { + normalizeMode: true, + }); + if (existingBytes) return destinationPath; + + const temporaryPath = join(safeDir, `.${asset.file}.${process.pid}.${randomUUID()}.tmp`); + let handle; + try { + handle = await open( + temporaryPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (fsConstants.O_NOFOLLOW ?? 0), + 0o500 + ); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + + if (!(await readVerifiedRegularFile(temporaryPath, asset.sha256, { normalizeMode: true }))) { + throw new Error(`SHA-256 mismatch after writing ${asset.file}`); + } + + // POSIX rename replaces atomically. No rm-before-rename window means six + // provider clients can initialize together without deleting each other's + // verified result. On Windows, accept a concurrently installed valid file. + try { + await rename(temporaryPath, destinationPath); + } catch (err) { + if (!(await readVerifiedRegularFile(destinationPath, asset.sha256, { normalizeMode: true }))) + throw err; + } + + if (!(await readVerifiedRegularFile(destinationPath, asset.sha256, { normalizeMode: true }))) { + throw new Error(`SHA-256 mismatch after installing ${asset.file}`); + } + return destinationPath; + } finally { + await handle?.close(); + await rm(temporaryPath, { force: true }); + } +} + +/** + * Local-filesystem lease for one native-asset install. O_EXCL + a bounded nonce + * fences ordinary owner replacement while the handle is live. This is not a + * distributed NFS/CIFS lock: after the 60-second stale lease, Node has no + * portable compare-and-unlink primitive, so digest checks and atomic install + * remain the final safety boundary if an event-loop stall allows overlap. + */ +async function withNativeAssetInstallLock( + destinationPath: string, + expectedSha256: string, + operation: () => Promise, + testHooks?: NativeResolverTestHooks +): Promise { + const lockPath = `${destinationPath}.lock`; + const startedAt = Date.now(); + let acquiredLockHandle: Awaited> | undefined; + let acquiredLockToken: string | undefined; + let afterInstallLockCreated = testHooks?.afterInstallLockCreated; + + while (!acquiredLockHandle) { + let createdLockHandle: Awaited> | undefined; + try { + createdLockHandle = await open( + lockPath, + fsConstants.O_RDWR | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (fsConstants.O_NOFOLLOW ?? 0), + 0o600 + ); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + + if (createdLockHandle) { + try { + const createdToken = randomUUID(); + await createdLockHandle.writeFile(createdToken); + await createdLockHandle.sync(); + const createdStats = await createdLockHandle.stat({ bigint: true }); + if (!createdStats.isFile()) { + throw new Error(`Unsafe tls-client install lock: ${lockPath}`); + } + + // Capture ownership from the O_EXCL handle before exposing the test + // scheduling seam. A pathname lstat alone could capture a replacement + // lock created by another process after this owner was descheduled. + const afterCreated = afterInstallLockCreated; + afterInstallLockCreated = undefined; + if (afterCreated) await afterCreated(lockPath); + + const currentClaim = await readInstallLockClaim(lockPath); + if ( + currentClaim && + currentClaim.token === createdToken && + sameFileIdentity(createdStats, currentClaim.stats) + ) { + acquiredLockHandle = createdLockHandle; + acquiredLockToken = createdToken; + createdLockHandle = undefined; + } + } finally { + await createdLockHandle?.close(); + } + if (acquiredLockHandle) break; + continue; + } + + const afterExists = testHooks?.afterInstallLockExists; + if (afterExists) await afterExists(lockPath); + let lockStats; + try { + lockStats = await lstat(lockPath); + } catch (lockErr) { + if ((lockErr as NodeJS.ErrnoException).code === "ENOENT") continue; + throw lockErr; + } + if (lockStats.isSymbolicLink() || (!lockStats.isDirectory() && !lockStats.isFile())) { + throw new Error(`Unsafe tls-client install lock: ${lockPath}`); + } + if (await readVerifiedRegularFile(destinationPath, expectedSha256, { normalizeMode: true })) { + return destinationPath; + } + if (Date.now() - lockStats.mtimeMs > STALE_INSTALL_LOCK_MS) { + const removed = await (lockStats.isDirectory() ? rmdir(lockPath) : rm(lockPath)).then( + () => true, + () => false + ); + if (removed) continue; + } + if (Date.now() - startedAt >= INSTALL_LOCK_TIMEOUT_MS) { + if (await readVerifiedRegularFile(destinationPath, expectedSha256, { normalizeMode: true })) { + return destinationPath; + } + throw new Error(`Timed out waiting for tls-client native install lock: ${lockPath}`); + } + await delay(25); + } + + const ownerHandle = acquiredLockHandle; + const ownerToken = acquiredLockToken; + if (!ownerHandle || !ownerToken) { + await ownerHandle?.close(); + throw new Error(`Invalid tls-client install lock ownership: ${lockPath}`); + } + try { + if (await readVerifiedRegularFile(destinationPath, expectedSha256, { normalizeMode: true })) { + return destinationPath; + } + return await operation(); + } finally { + try { + const ownerStats = await ownerHandle.stat({ bigint: true }); + const currentClaim = await readInstallLockClaim(lockPath); + if ( + currentClaim && + currentClaim.token === ownerToken && + sameFileIdentity(ownerStats, currentClaim.stats) + ) { + await rm(lockPath); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } finally { + await ownerHandle.close(); + } + } +} + +async function readBoundedNativeAssetResponse(response: Response): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_NATIVE_ASSET_BYTES) { + throw new Error("Pinned tls-client native asset exceeds the 64 MiB download limit"); + } + if (!response.body) return Buffer.alloc(0); -async function fileMatchesSha256(filePath: string, expectedSha256: string): Promise { + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; try { - const bytes = await readFile(filePath); - return createHash("sha256").update(bytes).digest("hex") === expectedSha256; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_NATIVE_ASSET_BYTES) { + // Cancellation is best-effort cleanup; its failure must not mask the size-limit error. + await reader.cancel("native asset exceeds download limit").catch(() => {}); + throw new Error("Pinned tls-client native asset exceeds the 64 MiB download limit"); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes); +} + +function resolveInstalledTlsClientSeedDir(testHooks?: NativeResolverTestHooks): string | undefined { + try { + const packageJsonPath = + testHooks?.resolveTlsClientPackageJson?.() ?? + moduleRequire.resolve("tls-client-node/package.json"); + return join(dirname(packageJsonPath), "bin"); } catch { - return false; + // The seed package is optional; configured/runtime paths are still scanned and verified below. + return undefined; } } +function resolveBundledSeedDirs(testHooks?: NativeResolverTestHooks): string[] { + const configured = process.env.OMNIROUTE_TLS_CLIENT_SEED_DIR?.trim(); + const installedPackageSeedDir = resolveInstalledTlsClientSeedDir(testHooks); + return [ + ...new Set([ + ...(configured ? [configured] : []), + join(process.cwd(), "runtime-assets", "tls-client", "bin"), + join(process.cwd(), "data", "tls-client", "bin"), + ...(installedPackageSeedDir ? [installedPackageSeedDir] : []), + join(process.cwd(), "node_modules", "tls-client-node", "bin"), + ]), + ]; +} + /** * Writable cache directory for tls-client-node's native binary. * @@ -45,75 +529,67 @@ export async function resolveVerifiedTlsClientNativeLibrary({ platform = process.platform, arch = process.arch, asset, - downloadDir = resolveTlsClientDownloadDir(), + downloadDir, + seedDirs, fetchImpl = globalThis.fetch, + testHooks, }: { platform?: NodeJS.Platform; arch?: string; asset?: TlsClientNativeAsset; downloadDir?: string; + seedDirs?: string[]; fetchImpl?: FetchLike; + /** Deterministic filesystem-race seam used only by native resolver tests. */ + testHooks?: NativeResolverTestHooks; } = {}): Promise { const expectedAsset = asset ?? TLS_CLIENT_NATIVE_ASSETS[`${platform}-${arch}`]; if (!expectedAsset) { throw new Error(`Unsupported platform for tls-client native asset: ${platform}/${arch}`); } + validateNativeAsset(expectedAsset); - const destinationPath = join(downloadDir, expectedAsset.file); - if (await fileMatchesSha256(destinationPath, expectedAsset.sha256)) { - return destinationPath; - } - - const assetUrl = - `https://github.com/bogdanfinn/tls-client/releases/download/v${tlsClientNativeManifest.version}/` + - expectedAsset.file; - const response = await fetchImpl(assetUrl, { - redirect: "follow", - signal: AbortSignal.timeout(30_000), + const requestedDownloadDir = downloadDir ?? resolveTlsClientDownloadDir(); + const trustedDownloadRoot = downloadDir === undefined ? resolveDataDir() : requestedDownloadDir; + const safeDownloadDir = await resolveSafeDirectory(requestedDownloadDir, trustedDownloadRoot); + const resolvedSeedDirs = seedDirs ?? resolveBundledSeedDirs(testHooks); + const destinationPath = join(safeDownloadDir, expectedAsset.file); + const cachedBytes = await readVerifiedRegularFile(destinationPath, expectedAsset.sha256, { + normalizeMode: true, + afterOpenedFileStat: testHooks?.afterOpenedFileStat, }); - if (!response.ok) { - throw new Error( - `Failed to download pinned tls-client v${tlsClientNativeManifest.version} native asset: ` + - `${response.status} ${response.statusText}` - ); - } + if (cachedBytes) return destinationPath; - const bytes = Buffer.from(await response.arrayBuffer()); - const actualSha256 = createHash("sha256").update(bytes).digest("hex"); - if (actualSha256 !== expectedAsset.sha256) { - throw new Error( - `SHA-256 mismatch for tls-client v${tlsClientNativeManifest.version} native asset ` + - `${expectedAsset.file}: ` + - `expected ${expectedAsset.sha256}, received ${actualSha256}` - ); - } + return withNativeAssetInstallLock( + destinationPath, + expectedAsset.sha256, + async () => { + for (const seedDir of resolvedSeedDirs) { + const seedPath = join(resolve(seedDir), expectedAsset.file); + if (seedPath === destinationPath) continue; + const seedBytes = await readVerifiedRegularFile(seedPath, expectedAsset.sha256); + if (seedBytes) return installVerifiedBytes(safeDownloadDir, expectedAsset, seedBytes); + } - await mkdir(downloadDir, { recursive: true }); - const temporaryPath = join( - downloadDir, - `.${expectedAsset.file}.${process.pid}.${randomUUID()}.tmp` - ); - try { - await writeFile(temporaryPath, bytes, { mode: 0o755 }); - if (!(await fileMatchesSha256(temporaryPath, expectedAsset.sha256))) { - throw new Error(`SHA-256 mismatch after writing ${expectedAsset.file}`); - } + const assetUrl = + `https://github.com/bogdanfinn/tls-client/releases/download/v${tlsClientNativeManifest.version}/` + + expectedAsset.file; + const response = await fetchImpl(assetUrl, { + redirect: "follow", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error( + `Failed to download pinned tls-client v${tlsClientNativeManifest.version} native asset: ` + + `${response.status}` + ); + } - await rm(destinationPath, { force: true }); - try { - await rename(temporaryPath, destinationPath); - } catch (err) { - // A concurrent process may have installed the same verified asset first. - if (!(await fileMatchesSha256(destinationPath, expectedAsset.sha256))) throw err; - } - if (!(await fileMatchesSha256(destinationPath, expectedAsset.sha256))) { - await rm(destinationPath, { force: true }); - throw new Error(`SHA-256 mismatch after installing ${expectedAsset.file}`); - } - return destinationPath; - } finally { - await rm(temporaryPath, { force: true }); - } + const bytes = await readBoundedNativeAssetResponse(response); + return installVerifiedBytes(safeDownloadDir, expectedAsset, bytes); + }, + testHooks + ); } export function buildNativeTlsClientOptions(nativeLibraryPath?: string): { diff --git a/open-sse/services/tlsClientLifecycleRegistry.ts b/open-sse/services/tlsClientLifecycleRegistry.ts new file mode 100644 index 00000000000..1f94a89f55d --- /dev/null +++ b/open-sse/services/tlsClientLifecycleRegistry.ts @@ -0,0 +1,273 @@ +/** + * Process-wide ownership for tls-client-node's native backend. + * + * The dependency caches one binding per native library path and implements + * `TLSClient.stop()` as binding-wide `destroyAll()`. A per-provider singleton + * therefore cannot decide independently when stop is safe. This registry keeps + * that global fact behind a process-wide lease interface. + */ + +import { realpath } from "node:fs/promises"; +import { resolve } from "node:path"; + +declare const nativeTlsLeaseBrand: unique symbol; + +export type NativeTlsClientLease = { + readonly [nativeTlsLeaseBrand]: true; +}; + +type InternalLease = NativeTlsClientLease & { + cleanup: () => Promise; + cleanupTimeoutMs: number; + completion: Promise | null; + state: NativeLibraryState; + status: "pending" | "active" | "released"; +}; + +type NativeLibraryState = { + cleanupBarrier: Promise | null; + cleanupCandidate: (() => Promise) | null; + cleanupFailed: boolean; + cleanupToken: symbol | null; + owners: Set; +}; + +type NativeLibraryPathCanonicalizer = (nativeLibraryPath: string) => Promise; +type BeforeOwnerReservationHook = () => Promise; + +const nativeLibraryStates = new Map(); +const RESOLVED_VOID = Promise.resolve(); +let exitCleanupStarted = false; +let exitHookInstalled = false; + +async function canonicalizeNativeLibraryPath(nativeLibraryPath: string): Promise { + const absolutePath = resolve(nativeLibraryPath); + try { + return await realpath(absolutePath); + } catch { + // Tests and early failures may use a path that cannot be resolved yet. The + // absolute spelling is still a stable key for all callers using that path. + return absolutePath; + } +} + +function getNativeLibraryState( + states: Map, + canonicalPath: string +): NativeLibraryState { + let state = states.get(canonicalPath); + if (!state) { + state = { + cleanupBarrier: null, + cleanupCandidate: null, + cleanupFailed: false, + cleanupToken: null, + owners: new Set(), + }; + states.set(canonicalPath, state); + } + return state; +} + +function waitBounded(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error("Native TLS cleanup did not finish within its safety budget")), + Math.max(1, timeoutMs) + ); + timer.unref?.(); + void promise.then( + () => { + clearTimeout(timer); + resolvePromise(); + }, + (error: unknown) => { + clearTimeout(timer); + rejectPromise(error); + } + ); + }); +} + +function startCleanup( + state: NativeLibraryState, + cleanup: () => Promise, + timeoutMs: number +): Promise { + let rawCleanup: Promise; + try { + rawCleanup = Promise.resolve(cleanup()); + } catch (error) { + rawCleanup = Promise.reject(error); + } + + const cleanupToken = Symbol("native-tls-cleanup"); + state.cleanupBarrier = rawCleanup; + state.cleanupToken = cleanupToken; + void rawCleanup.then( + () => { + if (state.cleanupToken !== cleanupToken) return; + state.cleanupBarrier = null; + state.cleanupCandidate = null; + state.cleanupFailed = false; + state.cleanupToken = null; + }, + () => { + if (state.cleanupToken !== cleanupToken) return; + state.cleanupBarrier = null; + state.cleanupFailed = true; + state.cleanupToken = null; + } + ); + + return waitBounded(rawCleanup, timeoutMs); +} + +/** + * Acquire ownership before starting a native client. + * + * A cleanup that already became the last-owner cleanup is an ordering barrier: + * no successor may start until its raw Promise settles. Callers wait only for + * their bounded safety budget and fail closed if the barrier is still pending. + */ +async function acquireNativeTlsClientLeaseFromRegistry( + states: Map, + canonicalizePath: NativeLibraryPathCanonicalizer, + nativeLibraryPath: string, + cleanup: () => Promise, + cleanupTimeoutMs: number, + beforeOwnerReservation?: BeforeOwnerReservationHook +): Promise { + const canonicalPath = await canonicalizePath(nativeLibraryPath); + const state = getNativeLibraryState(states, canonicalPath); + const lease = { + cleanup, + cleanupTimeoutMs, + completion: null, + state, + status: "pending", + } as InternalLease; + let reservationHookInvoked = false; + + while (true) { + if (state.cleanupFailed) { + throw new Error("Native TLS cleanup failed; refusing to start a new owner"); + } + const cleanupBarrier = state.cleanupBarrier; + if (cleanupBarrier) { + try { + await waitBounded(cleanupBarrier, cleanupTimeoutMs); + } catch { + throw new Error("Native TLS cleanup is incomplete; refusing to start a new owner"); + } + continue; + } + + if (!reservationHookInvoked && beforeOwnerReservation) { + reservationHookInvoked = true; + await beforeOwnerReservation(); + continue; + } + + // No await is allowed between this final state check and the reservation. + // A last-owner release is synchronous too, so exactly one side wins: the + // new owner is counted first, or its acquire observes the raw cleanup. + if (state.cleanupFailed || state.cleanupBarrier) continue; + state.owners.add(lease); + return lease; + } +} + +export async function acquireNativeTlsClientLease( + nativeLibraryPath: string, + cleanup: () => Promise, + cleanupTimeoutMs: number +): Promise { + return acquireNativeTlsClientLeaseFromRegistry( + nativeLibraryStates, + canonicalizeNativeLibraryPath, + nativeLibraryPath, + cleanup, + cleanupTimeoutMs + ); +} + +/** Create an isolated acquirer for deterministic registry race tests. */ +export function createNativeTlsClientLeaseAcquirerForTesting( + beforeOwnerReservation: BeforeOwnerReservationHook +): ( + nativeLibraryPath: string, + cleanup: () => Promise, + cleanupTimeoutMs: number +) => Promise { + const states = new Map(); + return (nativeLibraryPath, cleanup, cleanupTimeoutMs) => + acquireNativeTlsClientLeaseFromRegistry( + states, + async (path) => resolve(path), + nativeLibraryPath, + cleanup, + cleanupTimeoutMs, + beforeOwnerReservation + ); +} + +export function activateNativeTlsClientLease(lease: NativeTlsClientLease): void { + const internalLease = lease as InternalLease; + if (internalLease.status !== "pending") return; + internalLease.status = "active"; + internalLease.state.cleanupCandidate = internalLease.cleanup; +} + +/** Install one coordinated process-exit hook for every native library path. */ +export function installNativeTlsClientExitHook( + installExitHook: (hook: () => void) => void = (hook) => process.on("exit", hook) +): void { + if (exitHookInstalled) return; + + const hook = () => { + if (exitCleanupStarted) return; + exitCleanupStarted = true; + for (const state of nativeLibraryStates.values()) { + for (const lease of [...state.owners]) { + // `exit` cannot await Promises; release already invokes the final native stop synchronously. + void releaseNativeTlsClientLease(lease, lease.cleanupTimeoutMs).catch(() => {}); + } + } + }; + + installExitHook(hook); + exitHookInstalled = true; +} + +/** + * Release one owner. The preserved active cleanup candidate is invoked only + * for the final owner. A timed-out start remains pending until its raw start + * settles; there is deliberately no TTL or pending-owner cap in this wrapper. + * + * Invocation is synchronous so process-exit hooks can at least begin native + * cleanup; Promise completion remains best-effort during exit. + */ +export function releaseNativeTlsClientLease( + lease: NativeTlsClientLease, + cleanupTimeoutMs: number +): Promise { + const internalLease = lease as InternalLease; + if (internalLease.status === "released") { + return internalLease.completion ?? RESOLVED_VOID; + } + + const wasActive = internalLease.status === "active"; + internalLease.status = "released"; + internalLease.state.owners.delete(internalLease); + if (wasActive) internalLease.state.cleanupCandidate = internalLease.cleanup; + if (internalLease.state.owners.size > 0) { + internalLease.completion = RESOLVED_VOID; + return RESOLVED_VOID; + } + + const cleanup = internalLease.state.cleanupCandidate ?? internalLease.cleanup; + const completion = startCleanup(internalLease.state, cleanup, cleanupTimeoutMs); + internalLease.completion = completion; + return completion; +} diff --git a/open-sse/services/tlsClientStream.ts b/open-sse/services/tlsClientStream.ts new file mode 100644 index 00000000000..f8444d8060e --- /dev/null +++ b/open-sse/services/tlsClientStream.ts @@ -0,0 +1,416 @@ +/** + * File-backed stream adapter shared by the native TLS client wrappers. + * + * The native binding writes response bytes to a temporary file. This module + * tails that file, applies each provider family's EOF convention, owns cleanup, + * and guarantees that every terminal stream error is safe for public sinks. + */ + +import { open, rm, rmdir } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { sanitizeErrorMessage } from "../utils/error.ts"; + +export type TlsClientTailVariant = "A" | "B1" | "B2"; + +type FileHandle = Awaited>; + +const TLS_CLIENT_REQUEST_FAILED = "TLS client request failed"; +const SAFE_TLS_ERROR_NAMES = new Set([ + "Error", + "AbortError", + "TimeoutError", + "BodyTimeoutError", + "TlsClientHangError", + "NativeTlsError", +]); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function cleanupTempPathSubstring(path: string): Promise { + // This request-owned scratch path is never reused; cleanup cannot replace the stream outcome. + await rm(path, { force: true, recursive: true }).catch(() => {}); + const dir = path.substring(0, path.lastIndexOf("/")); + // This request-owned directory is never reused; removal failure cannot alter response semantics. + await rmdir(dir).catch(() => {}); +} + +async function cleanupTempPathDirname(path: string): Promise { + // This request-owned scratch path is never reused; cleanup cannot replace the stream outcome. + await rm(path, { force: true, recursive: true }).catch(() => {}); + // This request-owned directory is never reused; removal failure cannot alter response semantics. + await rmdir(dirname(path)).catch(() => {}); +} + +export async function cleanupTlsClientStreamPath( + variant: TlsClientTailVariant, + path: string +): Promise { + if (variant === "A") await cleanupTempPathSubstring(path); + else await cleanupTempPathDirname(path); +} + +function projectTlsClientError(error: unknown): { message: string; name: string } { + let rawMessage: unknown = error; + let name = "Error"; + try { + if (error instanceof Error) { + try { + rawMessage = error.message; + } catch { + rawMessage = undefined; + } + try { + const rawName: unknown = error.name; + if (typeof rawName === "string" && SAFE_TLS_ERROR_NAMES.has(rawName)) { + name = rawName; + } + } catch { + name = "Error"; + } + } + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + rawMessage = undefined; + } + const sanitized = rawMessage == null ? "" : sanitizeErrorMessage(rawMessage); + const message = + sanitized.trim() && !/^(?:[A-Za-z_$][\w$]*)?Error:\s*$/.test(sanitized) + ? sanitized + : TLS_CLIENT_REQUEST_FAILED; + return { message, name }; +} + +export function sanitizeTlsClientErrorMessage(error: unknown): string { + return projectTlsClientError(error).message; +} + +export function createSanitizedTlsStreamError(error: unknown): Error { + const projection = projectTlsClientError(error); + const sanitizedError = new Error(projection.message); + sanitizedError.name = projection.name; + // Do not retain the native error as `cause`: it can contain credentials and + // absolute paths. Keep a safe single-line stack for consumers that inspect it. + sanitizedError.stack = `${sanitizedError.name}: ${sanitizedError.message}`; + return sanitizedError; +} + +function tailFileVariantA( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + let fd: FileHandle; + try { + fd = await open(path, "r"); + } catch (err) { + await cleanupTempPathSubstring(path); + controller.error(createSanitizedTlsStreamError(err)); + return; + } + const buf = Buffer.alloc(64 * 1024); + let offset = 0; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = createSanitizedTlsStreamError(err); + finished = true; + } + ); + + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + let errored = false; + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + if (text.includes(eofSymbol)) { + const cutAt = text.indexOf(eofSymbol) + eofSymbol.length; + controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt))); + break; + } + controller.enqueue(new Uint8Array(chunk)); + } else if (finished) { + if (upstreamError) { + errored = true; + controller.error(upstreamError); + } + break; + } else { + await sleep(25); + } + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(createSanitizedTlsStreamError(err)); + } + } finally { + if (signal) signal.removeEventListener("abort", onAbort); + // The handle is no longer used; a close error must not skip temp cleanup or replace EOF. + await fd.close().catch(() => {}); + await cleanupTempPathSubstring(path); + if (!errored) controller.close(); + } + }, + }); +} + +function tailFileVariantB1( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + let fd: FileHandle; + try { + fd = await open(path, "r"); + } catch (err) { + await cleanupTempPathDirname(path); + controller.error(createSanitizedTlsStreamError(err)); + return; + } + const buf = Buffer.alloc(64 * 1024); + let offset = 0; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = createSanitizedTlsStreamError(err); + finished = true; + } + ); + + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + let errored = false; + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + + if (text.includes(eofSymbol)) { + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); + controller.close(); + return; + } + + controller.enqueue(Buffer.from(chunk)); + } + + if (finished) { + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead === 0) break; + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + + if (text.includes(eofSymbol)) { + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); + controller.close(); + return; + } + + controller.enqueue(Buffer.from(chunk)); + } + + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + + controller.close(); + return; + } + + await sleep(25); + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(createSanitizedTlsStreamError(err)); + } + } finally { + // The handle is no longer used; a close error must not skip temp cleanup or replace EOF. + await fd.close().catch(() => {}); + await cleanupTempPathDirname(path); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +function enqueueChunkMaybeEof( + controller: ReadableStreamDefaultController, + chunk: Buffer, + eofSymbol: string +): boolean { + const text = chunk.toString("utf8"); + if (!text.includes(eofSymbol)) { + controller.enqueue(Buffer.from(chunk)); + return false; + } + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); + controller.close(); + return true; +} + +async function drainRemaining( + fd: FileHandle, + buf: Buffer, + offsetRef: { offset: number }, + controller: ReadableStreamDefaultController, + eofSymbol: string +): Promise<"closed" | "drained"> { + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead === 0) return "drained"; + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed"; + } +} + +function tailFileVariantB2( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + let fd: FileHandle; + try { + fd = await open(path, "r"); + } catch (err) { + await cleanupTempPathDirname(path); + controller.error(createSanitizedTlsStreamError(err)); + return; + } + const buf = Buffer.alloc(64 * 1024); + const offsetRef = { offset: 0 }; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + let errored = false; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = createSanitizedTlsStreamError(err); + finished = true; + } + ); + + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return; + } + + if (!finished) { + await sleep(25); + continue; + } + + const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol); + if (drained === "closed") return; + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + controller.close(); + return; + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(createSanitizedTlsStreamError(err)); + } + } finally { + // The handle is no longer used; a close error must not skip temp cleanup or replace EOF. + await fd.close().catch(() => {}); + await cleanupTempPathDirname(path); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +const TAIL_FILE_VARIANTS = { + A: tailFileVariantA, + B1: tailFileVariantB1, + B2: tailFileVariantB2, +} as const; + +export function createTlsClientTailStream({ + variant, + path, + eofSymbol, + done, + signal, +}: { + variant: TlsClientTailVariant; + path: string; + eofSymbol: string; + done: Promise; + signal: AbortSignal | null; +}): ReadableStream { + return TAIL_FILE_VARIANTS[variant](path, eofSymbol, done, signal); +} diff --git a/open-sse/services/tlsClientTimeout.ts b/open-sse/services/tlsClientTimeout.ts new file mode 100644 index 00000000000..4d077e356cd --- /dev/null +++ b/open-sse/services/tlsClientTimeout.ts @@ -0,0 +1,70 @@ +/** Timeout and abort primitives shared by every native TLS client wrapper. */ + +export class TlsClientHangError extends Error { + override name = "TlsClientHangError"; + + constructor(message = "TLS client operation timed out") { + super(message); + } +} + +export function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + try { + if (reason instanceof Error) return reason; + } catch { + // A hostile Proxy reason must not keep the already-aborted race pending. + } + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + +export async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + signal: AbortSignal | null | undefined +): Promise { + return await new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | undefined; + + const cleanup = () => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + + const done = (fn: () => void) => { + if (!settled) { + settled = true; + cleanup(); + fn(); + } + }; + + const onAbort = () => { + if (signal) done(() => reject(makeAbortError(signal))); + }; + + timer = setTimeout(() => { + done(() => reject(new TlsClientHangError())); + }, timeoutMs); + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + promise.then( + (value) => { + done(() => resolve(value)); + }, + (error) => { + done(() => reject(error)); + } + ); + }); +} diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index f8cff79844c..8d08335ea48 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,15 +1,19 @@ import { CORS_HEADERS } from "./cors.ts"; import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts"; +import { + containsStrongCredentialToken, + redactSensitiveErrorText, + sanitizeErrorMessage, + sanitizeUpstreamDetails, +} from "./errorSanitization.ts"; import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts"; -/** - * Sanitize an error message to prevent stack trace exposure in API responses. - * Strips stack traces, file paths, and absolute Windows/POSIX paths from - * error messages before they reach the client. - */ +export { redactSensitiveErrorText, sanitizeErrorMessage, sanitizeUpstreamDetails }; + +/** Client-visible error shape; dynamic fields are projected through canonical boundaries. */ interface ErrorResponseBody { error: { message: string; @@ -19,97 +23,121 @@ interface ErrorResponseBody { upstream_details?: Record | null; // sanitized upstream provider body } -// Length cap protects against pathological inputs even before tokenization. -const MAX_ERROR_LEN = 4096; -const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const; - -function looksLikeAbsolutePath(tok: string): boolean { - // POSIX: "/<...>.ts" (optionally followed by :line[:col]). - // Windows: "C:\<...>.ts" or "C:/<...>.ts". - if (tok.length < 4 || tok.length > 2048) return false; - const isPosix = tok.charCodeAt(0) === 0x2f; // '/' - const isWindows = tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]); - if (!isPosix && !isWindows) return false; - const dot = tok.lastIndexOf("."); - if (dot <= 0 || dot === tok.length - 1) return false; - const ext = tok - .slice(dot + 1) - .split(":", 1)[0] - .toLowerCase(); - return (SOURCE_EXT as readonly string[]).includes(ext); -} - -export function redactSensitiveErrorText(value: string): string { - return value - .replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]") - .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") - .replace( - /(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["'])[^"']*(["'])/gi, - "$1[REDACTED]$2" - ) - .replace( - /(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*)[^"',\s}]+/gi, - "$1[REDACTED]" - ); -} - -/** - * Strip stack-trace tail and absolute source paths from error messages. - * - * Implemented via simple whitespace tokenization (linear time) instead of a - * single complex regex, so CodeQL `js/polynomial-redos` stays clean even when - * the runtime error message is attacker-controlled. - */ -export function sanitizeErrorMessage(message: unknown): string { - let str = typeof message === "string" ? message : String(message ?? ""); - if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN); - const nl = str.indexOf("\n"); - const firstLine = nl >= 0 ? str.slice(0, nl) : str; - // Preserve original whitespace by splitting on captured separator. - const parts = firstLine.split(/(\s+)/); - for (let i = 0; i < parts.length; i++) { - if (looksLikeAbsolutePath(parts[i])) parts[i] = ""; - } - return redactSensitiveErrorText(parts.join("")); -} - -const BLOCKED_KEYS = - /stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie/i; -const MAX_DEPTH = 4; - -/** - * Recursively sanitize an arbitrary JSON value from an upstream provider body. - * - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths). - * - Keys matching BLOCKED_KEYS are dropped (credential/path guards). - * - Depth capped at MAX_DEPTH to prevent pathological nesting. - * - Arrays capped at 32 elements. - * - Returns null for null/undefined/non-JSON-serializable values. - */ -export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown { - if (depth > MAX_DEPTH) return "[truncated]"; - if (value === null || value === undefined) return null; - if (typeof value === "string") return sanitizeErrorMessage(value); - if (typeof value === "number" || typeof value === "boolean") return value; - if (Array.isArray(value)) { - return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1)); - } - if (typeof value === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - if (BLOCKED_KEYS.test(k)) continue; - out[k] = sanitizeUpstreamDetails(v, depth + 1); - } - return out; - } - return null; -} - /** Optional caller classification; when set, wins over status-derived defaults. */ export type ErrorBodyClassification = { type?: string; code?: string; }; +const PUBLIC_ERROR_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SAFE_PUBLIC_CREDENTIAL_ERROR_IDENTIFIERS = new Set([ + "acp_session_mismatch", + "access_token_missing", + "access_token_required", + "api_key_invalid", + "authorization_code", + "authorization_code_pkce", + "authorization_endpoint", + "authorization_failed", + "authorization_pending", + "bearer_error", + "bearer_expired", + "bearer_invalid", + "bearer_required", + "client_secret_missing", + "codex_credentials_unavailable", + "codex_access_token_missing", + "codex_oauth_token_missing", + "expired_token", + "cursor_session_stale", + "github_access_token_invalid", + "github_token_expired", + "invalid_api_key", + "invalid_password", + "invalid_token", + "invalid_token_response", + "lease_api_key_invalid", + "lease_authorization_mismatch", + "missing_access_token", + "missing_api_key", + "missing_authorization", + "missing_cookie", + "missing_id_token", + "missing_refresh_token", + "missing_credentials", + "missing_session_id", + "no_refresh_token", + "no_access_token", + "no_credentials", + "oauth_invalid_token", + "password_mismatch", + "password_required", + "provider_bearer_error", + "provider_token_expired", + "refresh_token_invalid", + "refresh_token_invalidated", + "refresh_token_reused", + "risk_session_stale", + "session_expired", + "session_pool_exhausted", + "token_expired", + "token_health_check", + "token_limit_exceeded", + "token_refresh_failed", + "token_refresh_transient", + "token_required", + "tls_session_capacity", + "token_type", + "token_usage", +]); +const SAFE_PUBLIC_CREDENTIAL_LEXICAL_IDENTIFIERS = new Set([ + "passwordless", + "passwordless_auth_required", + "passwordless_error", + "tokenization_error", + "tokenizer_error", +]); +const PUBLIC_ERROR_CREDENTIAL_MARKERS = [ + "accesstoken", + "refreshtoken", + "apikey", + "privatekey", + "sessionkey", + "encryptionkey", + "secretkey", + "signingkey", + "sessionid", + "cfclearance", + "credential", + "authorization", + "password", + "secret", + "bearer", + "cookie", + "token", + "session", + "ssorw", + "sso", +] as const; + +function isSafePublicErrorIdentifier(value: string): boolean { + if (!PUBLIC_ERROR_IDENTIFIER.test(value)) return false; + const lowerValue = value.toLowerCase(); + if (SAFE_PUBLIC_CREDENTIAL_ERROR_IDENTIFIERS.has(lowerValue)) return true; + if (SAFE_PUBLIC_CREDENTIAL_LEXICAL_IDENTIFIERS.has(lowerValue)) return true; + if (containsStrongCredentialToken(value)) return false; + const compactValue = lowerValue.replace(/[._-]+/g, ""); + return !PUBLIC_ERROR_CREDENTIAL_MARKERS.some((marker) => compactValue.includes(marker)); +} + +/** Project an internal classification onto the bounded client-visible identifier vocabulary. */ +export function projectPublicErrorIdentifier(value: unknown, fallback: unknown): string { + const safeFallback = + typeof fallback === "string" && isSafePublicErrorIdentifier(fallback) ? fallback : "error"; + if (typeof value !== "string") return safeFallback; + return isSafePublicErrorIdentifier(value) ? value : safeFallback; +} + /** * Build OpenAI-compatible error response body. Message is always sanitized * so callers do not need to remember to strip stack traces themselves. @@ -130,8 +158,8 @@ export function buildErrorBody( const body: ErrorResponseBody = { error: { message: safeMessage, - type: classification?.type ?? errorInfo.type, - code: classification?.code ?? errorInfo.code, + type: projectPublicErrorIdentifier(classification?.type, errorInfo.type), + code: projectPublicErrorIdentifier(classification?.code, errorInfo.code), }, }; @@ -200,21 +228,21 @@ export interface ComboDiagnostics { } function clampDiagStr(v: unknown, max = 128): string { - return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : ""; + return typeof v === "string" ? sanitizeErrorMessage(v).slice(0, max) : ""; } /** - * HTTP header values must be Latin1/ByteString (undici throws a TypeError - * otherwise — see #6612). Replace any codepoint outside the Latin1 range - * (0-255) with "?" so header construction never throws. Only used for the - * literal header value; the JSON body keeps the original, unsanitized - * readable text via `sanitizeComboDiagnostics`. + * HTTP header values must exclude controls and remain ByteString-compatible + * (undici throws a TypeError otherwise — see #6612). Replace every codepoint + * outside printable ASCII (0x20-0x7e) with "?" so construction never throws. Only used for the + * literal header value; the JSON body keeps the sanitized readable text via + * `sanitizeComboDiagnostics`. */ function toHeaderSafeAscii(v: string): string { let out = ""; for (let i = 0; i < v.length; i++) { const code = v.charCodeAt(i); - out += code > 255 ? "?" : v[i]; + out += code < 0x20 || code > 0x7e ? "?" : v[i]; } return out; } @@ -290,12 +318,10 @@ export function errorResponseWithComboDiagnostics( opts: { code?: string; type?: string } = {} ): Response { const safe = sanitizeComboDiagnostics(diagnostics); - const body = buildErrorBody(statusCode, message) as ErrorResponseBody & { + const body = buildErrorBody(statusCode, message, undefined, opts) as ErrorResponseBody & { diagnostics?: ComboDiagnostics; recovery_hint?: ComboRecoveryHint; }; - if (opts.code) body.error.code = opts.code; - if (opts.type) body.error.type = opts.type; body.diagnostics = safe; if (safe.recovery) body.recovery_hint = safe.recovery; const excludedHeader = toHeaderSafeAscii( @@ -387,6 +413,29 @@ function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null): return 1; } +const MAX_PUBLIC_CONTEXT_LABEL_LENGTH = 256; + +function projectPublicContextLabel(value: unknown): string | null { + if (typeof value !== "string") return null; + const label = value.trim(); + if ( + label.length === 0 || + label.length > MAX_PUBLIC_CONTEXT_LABEL_LENGTH || + /[\u0000-\u001f\u007f]/.test(label) + ) { + return null; + } + return sanitizeErrorMessage(label) === label ? label : null; +} + +function projectPublicRetryTimestamp(value: unknown): string | null { + if (typeof value !== "string") return null; + const timestamp = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp)) return null; + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === timestamp ? timestamp : null; +} + /** * Parse Antigravity error message to extract retry time * Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s." @@ -533,13 +582,10 @@ export function createErrorResult( upstreamDetails?: unknown, opts?: { passthrough?: boolean } ) { - const body = buildErrorBody(statusCode, message, upstreamDetails); - if (errorCode) { - body.error.code = errorCode; - } - if (errorType) { - body.error.type = errorType; - } + const body = buildErrorBody(statusCode, message, upstreamDetails, { + code: errorCode, + type: errorType, + }); const result: { success: false; @@ -579,8 +625,9 @@ export function createErrorResult( result.retryAfterMs = retryAfterMs; } - // Opt-in relay of the verbatim upstream error body (Claude Code auto-recover - // contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`; + // Opt-in relay of the upstream wording/shape needed by Claude Code auto-recovery, + // after canonical recursive sanitization (see upstreamErrorPassthrough.ts). + // Only swaps `result.response`; // `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so // server-side classification (checkFallbackError, combo retry logic, etc.) // never sees a different value depending on this flag. @@ -613,7 +660,9 @@ export function unavailableResponse( retryAfterHuman?: string ) { const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); - const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message; + const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode); + const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : ""; + const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage; return new Response(JSON.stringify({ error: { message: msg } }), { status: statusCode, headers: { @@ -628,13 +677,14 @@ export function providerCircuitOpenResponse( retryAfter?: string | number | Date | null ) { const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); + const safeProvider = projectPublicContextLabel(provider) ?? "unknown"; return new Response( JSON.stringify({ error: { - message: `Provider ${provider} circuit breaker is open`, + message: `Provider ${safeProvider} circuit breaker is open`, type: "server_error", code: "provider_circuit_open", - provider, + provider: safeProvider, retry_after: retryAfterSec, }, }), @@ -660,9 +710,10 @@ export function buildModelCooldownBody({ retryAfterAt?: string | null; credentialsCoolingCount?: number | null; }): ModelCooldownErrorPayload { - const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null; - const resolvedRetryAfterAt = - typeof retryAfterAt === "string" && retryAfterAt.length > 0 ? retryAfterAt : null; + const resolvedModel = projectPublicContextLabel(model); + const resolvedRetryAfterAt = projectPublicRetryTimestamp(retryAfterAt); + const resolvedResetSeconds = + Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? Math.max(Math.ceil(retryAfterSec), 1) : 1; const resolvedCoolingCount = typeof credentialsCoolingCount === "number" && Number.isFinite(credentialsCoolingCount) && @@ -678,7 +729,7 @@ export function buildModelCooldownBody({ type: "rate_limit_error", code: "model_cooldown", ...(resolvedModel ? { model: resolvedModel } : {}), - reset_seconds: Math.max(Math.ceil(retryAfterSec), 1), + reset_seconds: resolvedResetSeconds, ...(resolvedRetryAfterAt ? { retry_after: resolvedRetryAfterAt } : {}), ...(resolvedCoolingCount ? { credentials_cooling: resolvedCoolingCount } : {}), }, diff --git a/open-sse/utils/errorPathRedaction.ts b/open-sse/utils/errorPathRedaction.ts new file mode 100644 index 00000000000..1131f28c82d --- /dev/null +++ b/open-sse/utils/errorPathRedaction.ts @@ -0,0 +1,830 @@ +const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const; +const NATIVE_EXT = ["node", "so", "dylib", "dll"] as const; +const LEADING_PATH_PUNCTUATION = "'\"`([{<"; +const TRAILING_PATH_PUNCTUATION = "'\"`)]}>.,;:!?"; +const PATH_SPAN_END_PUNCTUATION = "'\"`)]}>.,;:!?"; +const FILE_URI_PREFIX = "file://"; +const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "HEAD", + "CONNECT", + "TRACE", +] as const; +const CLEAR_PROSE_BOUNDARIES = [ + "after", + "because", + "before", + "but", + "crashed", + "denied", + "eacces", + "enoent", + "expired", + "failed", + "rejected", + "retry", + "then", + "when", + "while", +] as const; +const POSIX_FILESYSTEM_ROOTS = [ + "/Users", + "/app", + "/boot", + "/data", + "/dev", + "/etc", + "/home", + "/media", + "/mnt", + "/nix", + "/opt", + "/private", + "/proc", + "/root", + "/run", + "/srv", + "/sys", + "/tmp", + "/usr", + "/var", + "/workspace", +] as const; + +function isWindowsAbsolutePathAt(value: string, start: number): boolean { + const remaining = value.length - start; + if (remaining > 2) { + const first = value.charCodeAt(start); + const second = value.charCodeAt(start + 1); + if ((first === 0x5c && second === 0x5c) || (first === 0x2f && second === 0x2f)) { + return true; + } + } + if (remaining < 3 || value.charCodeAt(start + 1) !== 0x3a) return false; + const driveLetter = value.charCodeAt(start); + const isAsciiLetter = + (driveLetter >= 0x41 && driveLetter <= 0x5a) || (driveLetter >= 0x61 && driveLetter <= 0x7a); + return ( + isAsciiLetter && (value.charCodeAt(start + 2) === 0x2f || value.charCodeAt(start + 2) === 0x5c) + ); +} + +function isWindowsAbsolutePath(value: string): boolean { + return isWindowsAbsolutePathAt(value, 0); +} + +function hasAbsoluteFileUriAt(value: string, start: number): boolean { + const prefixEnd = start + FILE_URI_PREFIX.length; + return ( + value.length > prefixEnd && + value.slice(start, prefixEnd).toLowerCase() === FILE_URI_PREFIX && + !isWhitespace(value[prefixEnd]) + ); +} + +function hasAbsoluteFileUri(value: string): boolean { + return hasAbsoluteFileUriAt(value, 0); +} + +function isSyntacticallyAbsolutePathAt(value: string, start: number): boolean { + return ( + value.charCodeAt(start) === 0x2f || + isWindowsAbsolutePathAt(value, start) || + hasAbsoluteFileUriAt(value, start) + ); +} + +function isAsciiDigit(code: number): boolean { + return code >= 0x30 && code <= 0x39; +} + +function isAsciiLetter(code: number): boolean { + return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); +} + +function isAsciiAlphaNumeric(code: number): boolean { + return isAsciiDigit(code) || isAsciiLetter(code); +} + +function hasHttpUrlSchemeBefore(value: string, slashIndex: number): boolean { + for (const scheme of ["http:", "https:"]) { + const schemeStart = slashIndex - scheme.length; + if (schemeStart < 0 || value.slice(schemeStart, slashIndex).toLowerCase() !== scheme) continue; + if (schemeStart === 0 || !isAsciiAlphaNumeric(value.charCodeAt(schemeStart - 1))) return true; + } + return false; +} + +function isWhitespace(value: string): boolean { + return /\s/.test(value); +} + +function isRouteContextWord(value: string): boolean { + return value === "Route" || (HTTP_METHODS as readonly string[]).includes(value); +} + +function hasRouteContextBefore(value: string, candidateIndex: number): boolean { + let index = candidateIndex - 1; + while ( + index >= 0 && + (isWhitespace(value[index]) || + value.charCodeAt(index) === 0x28 || + value.charCodeAt(index) === 0x3a) + ) { + index--; + } + + const contextEnd = index + 1; + while (index >= 0 && isAsciiAlphaNumeric(value.charCodeAt(index))) index--; + return isRouteContextWord(value.slice(index + 1, contextEnd)); +} + +function isRouteContextToken(value: string): boolean { + let end = value.length; + while (end > 0 && !isAsciiAlphaNumeric(value.charCodeAt(end - 1))) end--; + let start = end; + while (start > 0 && isAsciiAlphaNumeric(value.charCodeAt(start - 1))) start--; + return isRouteContextWord(value.slice(start, end)); +} + +function matchesPosixFilesystemRootAt(value: string, start: number, root: string): boolean { + if (!value.startsWith(root, start)) return false; + const rootEnd = start + root.length; + return ( + rootEnd === value.length || + value.charCodeAt(rootEnd) === 0x2f || + PATH_SPAN_END_PUNCTUATION.includes(value[rootEnd]) + ); +} + +function isKnownPosixFilesystemPathAt(value: string, start: number): boolean { + return POSIX_FILESYSTEM_ROOTS.some((root) => matchesPosixFilesystemRootAt(value, start, root)); +} + +function isKnownPosixFilesystemPath(value: string): boolean { + return isKnownPosixFilesystemPathAt(value, 0); +} + +function isUnambiguousPosixFilesystemPathAt(value: string, start: number): boolean { + if (!isKnownPosixFilesystemPathAt(value, start)) return false; + // `/app` is also a common application route and is shielded only when an + // explicit Route/HTTP context proves that interpretation. + return !matchesPosixFilesystemRootAt(value, start, "/app"); +} + +function isUnambiguousPosixFilesystemPath(value: string): boolean { + return isUnambiguousPosixFilesystemPathAt(value, 0); +} + +function looksLikeAbsolutePath(token: string): boolean { + // POSIX: common filesystem roots, with or without a source extension. + // Windows: drive-letter, UNC, or extended-length absolute paths. + // Source-file paths rooted elsewhere remain covered by SOURCE_EXT below. + if (token.length < 4 || token.length > 2048) return false; + const isPosix = token.charCodeAt(0) === 0x2f; + const isWindows = isWindowsAbsolutePath(token); + if (!isPosix && !isWindows) return false; + if (isWindows) return true; + if (isKnownPosixFilesystemPath(token)) return true; + const dot = token.lastIndexOf("."); + if (dot <= 0 || dot === token.length - 1) return false; + const extension = token + .slice(dot + 1) + .split(":", 1)[0] + .toLowerCase(); + return ( + (SOURCE_EXT as readonly string[]).includes(extension) || + (NATIVE_EXT as readonly string[]).includes(extension) + ); +} + +function redactAbsolutePathToken(token: string, followsRouteContext: boolean): string { + let start = 0; + let end = token.length; + + while (start < end && LEADING_PATH_PUNCTUATION.includes(token[start])) start++; + while (end > start && TRAILING_PATH_PUNCTUATION.includes(token[end - 1])) end--; + + const candidate = token.slice(start, end); + const isFileUri = hasAbsoluteFileUri(candidate); + const pathCandidate = isFileUri ? candidate.slice(FILE_URI_PREFIX.length) : candidate; + + if ( + !isFileUri && + !isWindowsAbsolutePath(pathCandidate) && + pathCandidate.charCodeAt(0) === 0x2f && + followsRouteContext && + !isUnambiguousPosixFilesystemPath(pathCandidate) + ) { + return token; + } + if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token; + return `${token.slice(0, start)}${token.slice(end)}`; +} + +function findPathQuote(value: string, start: number, quote: string, takeFirst: boolean): number { + let candidate = value.indexOf(quote, start); + if (takeFirst || candidate < 0) return candidate < 0 ? value.length : candidate; + + while (candidate < value.length) { + const nextQuote = value.indexOf(quote, candidate + 1); + if (nextQuote < 0) return candidate; + // Two separately quoted absolute paths are unambiguous. Close the first + // candidate so the second one is scanned on its own; otherwise keep + // consuming quotes fail-closed because POSIX filenames may contain them. + if (isSyntacticallyAbsolutePathAt(value, nextQuote + 1)) return candidate; + candidate = nextQuote; + } + return value.length; +} + +function redactQuotedAbsolutePaths(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + const quote = value[index]; + if (quote !== "'" && quote !== '"' && quote !== "`") { + index++; + continue; + } + const candidateStart = index + 1; + if (!isSyntacticallyAbsolutePathAt(value, candidateStart)) { + index++; + continue; + } + + const isShieldedRoute = + value.charCodeAt(candidateStart) === 0x2f && + !isWindowsAbsolutePathAt(value, candidateStart) && + hasRouteContextBefore(value, index) && + !isUnambiguousPosixFilesystemPathAt(value, candidateStart); + // Route/API contexts use their first closing quote so a later quoted + // filesystem path is still scanned independently. Filesystem candidates + // take the last matching quote on the line: POSIX filenames may themselves + // contain quote characters, whitespace, and punctuation, so earlier + // matches are ambiguous and must fail closed rather than expose a suffix. + const closingQuote = findPathQuote(value, candidateStart, quote, isShieldedRoute); + if (isShieldedRoute) { + if (closingQuote >= value.length) break; + index = closingQuote + 1; + continue; + } + parts.push(value.slice(copyStart, candidateStart), ""); + copyStart = closingQuote; + + if (closingQuote >= value.length) break; + index = closingQuote + 1; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +function findPathExtensionEnd(value: string, dot: number): number { + let end = dot + 1; + const maxExtensionEnd = Math.min(value.length, end + 16); + while (end < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end))) end++; + if (end === dot + 1 || (end === maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end)))) { + return -1; + } + let hasLetter = false; + for (let index = dot + 1; index < end; index++) { + if (isAsciiLetter(value.charCodeAt(index))) hasLetter = true; + } + if (!hasLetter) return -1; + + while (value.charCodeAt(end) === 0x3a) { + let coordinateEnd = end + 1; + if (!isAsciiDigit(value.charCodeAt(coordinateEnd))) break; + while (coordinateEnd < value.length && isAsciiDigit(value.charCodeAt(coordinateEnd))) { + coordinateEnd++; + } + end = coordinateEnd; + } + + if ( + end === value.length || + isWhitespace(value[end]) || + PATH_SPAN_END_PUNCTUATION.includes(value[end]) + ) { + return end; + } + return -1; +} + +function findTokenEnd(value: string, start: number): number { + let end = start; + while (end < value.length && !isWhitespace(value[end])) end++; + return end; +} + +function findExtensionEndInToken(value: string, start: number, end: number): number { + let lastExtensionEnd = -1; + for (let index = start; index < end; index++) { + const code = value.charCodeAt(index); + if (code === 0x2f || code === 0x5c) { + lastExtensionEnd = -1; + continue; + } + if (code !== 0x2e) continue; + const extensionEnd = findPathExtensionEnd(value, index); + if (extensionEnd >= 0 && extensionEnd <= end) lastExtensionEnd = extensionEnd; + } + return lastExtensionEnd; +} + +function tokenContainsPathExtensionEvidence(value: string, start: number, end: number): boolean { + for (let dot = start; dot < end; dot++) { + if (value.charCodeAt(dot) !== 0x2e) continue; + let extensionEnd = dot + 1; + const maxExtensionEnd = Math.min(end, extensionEnd + 16); + let hasLetter = false; + while (extensionEnd < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) { + if (isAsciiLetter(value.charCodeAt(extensionEnd))) hasLetter = true; + extensionEnd++; + } + if ( + extensionEnd === dot + 1 || + !hasLetter || + (extensionEnd === maxExtensionEnd && + extensionEnd < end && + isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) + ) { + continue; + } + if ( + extensionEnd === end || + value.charCodeAt(extensionEnd) === 0x2f || + value.charCodeAt(extensionEnd) === 0x5c || + PATH_SPAN_END_PUNCTUATION.includes(value[extensionEnd]) + ) { + return true; + } + } + return false; +} + +function tokenContainsPathSeparator(value: string, start: number, end: number): boolean { + for (let index = start; index < end; index++) { + const code = value.charCodeAt(index); + if (code === 0x2f || code === 0x5c) return true; + } + return false; +} + +function remainderContainsFilesystemSeparator(value: string, start: number): boolean { + let tokenStart = start; + let previousToken = ""; + while (tokenStart < value.length) { + while (tokenStart < value.length && isWhitespace(value[tokenStart])) tokenStart++; + if (tokenStart >= value.length) return false; + + const tokenEnd = findTokenEnd(value, tokenStart); + const token = value.slice(tokenStart, tokenEnd).toLowerCase(); + const isHttpUrl = token.includes("http://") || token.includes("https://"); + let separatorIndex = tokenStart; + while ( + separatorIndex < tokenEnd && + value.charCodeAt(separatorIndex) !== 0x2f && + value.charCodeAt(separatorIndex) !== 0x5c + ) { + separatorIndex++; + } + const precedingSeparatorCode = + separatorIndex > tokenStart ? value.charCodeAt(separatorIndex - 1) : -1; + const contextIndex = + precedingSeparatorCode === 0x27 || + precedingSeparatorCode === 0x22 || + precedingSeparatorCode === 0x60 + ? separatorIndex - 1 + : separatorIndex; + const isShieldedRoute = + separatorIndex < tokenEnd && + value.charCodeAt(separatorIndex) === 0x2f && + !isWindowsAbsolutePathAt(value, separatorIndex) && + (isRouteContextToken(previousToken) || hasRouteContextBefore(value, contextIndex)) && + !isUnambiguousPosixFilesystemPathAt(value, separatorIndex); + if (!isHttpUrl && separatorIndex < tokenEnd && !isShieldedRoute) return true; + previousToken = value.slice(tokenStart, tokenEnd); + tokenStart = tokenEnd; + } + return false; +} + +function trimPathSpanEnd(value: string, start: number, end: number): number { + while (end > start && PATH_SPAN_END_PUNCTUATION.includes(value[end - 1])) end--; + return end; +} + +function isClearProseBoundaryToken(value: string, start: number, end: number): boolean { + while (start < end && LEADING_PATH_PUNCTUATION.includes(value[start])) start++; + end = trimPathSpanEnd(value, start, end); + return (CLEAR_PROSE_BOUNDARIES as readonly string[]).includes( + value.slice(start, end).toLowerCase() + ); +} + +function findUnquotedPathEnd( + value: string, + start: number, + acceptFirstTokenPunctuation: boolean, + acceptEndpointBeforeAnotherAbsolute: boolean, + failClosedAmbiguity: boolean +): number { + let tokenStart = start; + let isFirstToken = true; + let firstTokenEnd = -1; + let firstTrimmedTokenEnd = -1; + let lastPathTokenEnd = -1; + let resolvedExtensionEnd = -1; + let hasFilesystemEvidence = false; + let hasUnresolvedFragments = false; + + const resolveEndpoint = (): number => { + if (hasUnresolvedFragments) { + return failClosedAmbiguity || hasFilesystemEvidence ? value.length : -1; + } + if (resolvedExtensionEnd >= 0) return resolvedExtensionEnd; + if (hasFilesystemEvidence && lastPathTokenEnd >= 0) return lastPathTokenEnd; + if ( + acceptFirstTokenPunctuation && + firstTrimmedTokenEnd >= 0 && + firstTrimmedTokenEnd < firstTokenEnd + ) { + return firstTrimmedTokenEnd; + } + return -1; + }; + + while (tokenStart < value.length) { + const tokenEnd = findTokenEnd(value, tokenStart); + const extensionEnd = findExtensionEndInToken(value, tokenStart, tokenEnd); + const trimmedTokenEnd = trimPathSpanEnd(value, tokenStart, tokenEnd); + + if (isFirstToken) { + firstTokenEnd = tokenEnd; + firstTrimmedTokenEnd = trimmedTokenEnd; + lastPathTokenEnd = trimmedTokenEnd; + // A prose-looking token may itself be a directory name. It is a safe + // boundary only when no later token carries path-separator evidence; + // otherwise keep scanning so a filesystem suffix cannot survive. + } else if ( + isClearProseBoundaryToken(value, tokenStart, tokenEnd) && + (!remainderContainsFilesystemSeparator(value, tokenEnd) || + (!failClosedAmbiguity && !hasFilesystemEvidence)) + ) { + return resolveEndpoint(); + } + + const containsSeparator = tokenContainsPathSeparator(value, tokenStart, tokenEnd); + const containsExtensionEvidence = tokenContainsPathExtensionEvidence( + value, + tokenStart, + tokenEnd + ); + if (!isFirstToken && containsSeparator) { + lastPathTokenEnd = trimmedTokenEnd; + hasFilesystemEvidence = true; + hasUnresolvedFragments = false; + resolvedExtensionEnd = extensionEnd >= 0 ? extensionEnd : -1; + if (extensionEnd < 0 && containsExtensionEvidence) { + resolvedExtensionEnd = trimmedTokenEnd; + } + } else if (extensionEnd >= 0) { + resolvedExtensionEnd = extensionEnd; + hasFilesystemEvidence = true; + hasUnresolvedFragments = false; + } else if (containsExtensionEvidence) { + resolvedExtensionEnd = trimmedTokenEnd; + hasFilesystemEvidence = true; + hasUnresolvedFragments = false; + } else if (!isFirstToken) { + hasUnresolvedFragments = true; + } + + let nextTokenStart = tokenEnd; + while (nextTokenStart < value.length && isWhitespace(value[nextTokenStart])) nextTokenStart++; + if (nextTokenStart >= value.length) return resolveEndpoint(); + if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) { + const endpoint = resolveEndpoint(); + if (endpoint >= 0) return endpoint; + return acceptEndpointBeforeAnotherAbsolute ? lastPathTokenEnd : -1; + } + + tokenStart = nextTokenStart; + isFirstToken = false; + } + return resolveEndpoint(); +} + +function isUnquotedPosixSpanCandidateAt(value: string, start: number): boolean { + const tokenEnd = findTokenEnd(value, start); + const token = value.slice(start, tokenEnd); + if (isKnownPosixFilesystemPath(token)) return true; + if ( + findExtensionEndInToken(value, start, tokenEnd) >= 0 || + tokenContainsPathExtensionEvidence(value, start, tokenEnd) + ) { + return true; + } + + let slashCount = 0; + for (let index = start; index < tokenEnd; index++) { + if (value.charCodeAt(index) === 0x2f) slashCount++; + } + return slashCount >= 2; +} + +function redactUnquotedAbsolutePathSpans(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + const previous = index > 0 ? value[index - 1] : ""; + const followsQuote = previous === "'" || previous === '"' || previous === "`"; + const hasCommonBoundary = + index === 0 || + isWhitespace(previous) || + LEADING_PATH_PUNCTUATION.includes(previous) || + previous === "=" || + previous === ":" || + previous === "," || + previous === ";" || + previous === "." || + previous === ">" || + previous === "|"; + const startsForwardSlashUnc = + value.charCodeAt(index) === 0x2f && value.charCodeAt(index + 1) === 0x2f; + const startsHttpUrl = + startsForwardSlashUnc && previous === ":" && hasHttpUrlSchemeBefore(value, index); + const isWindowsPath = !followsQuote && isWindowsAbsolutePathAt(value, index) && !startsHttpUrl; + const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index); + const isUnambiguousPosixFilesystemPathCandidate = isUnambiguousPosixFilesystemPathAt( + value, + index + ); + const isPosixPath = + !followsQuote && + value.charCodeAt(index) === 0x2f && + value.charCodeAt(index + 1) !== 0x2f && + (!hasRouteContextBefore(value, index) || isUnambiguousPosixFilesystemPathCandidate) && + isUnquotedPosixSpanCandidateAt(value, index); + const hasBoundary = hasCommonBoundary || (isWindowsPath && previous === ":"); + if (!hasBoundary || (!isWindowsPath && !isFileUriPath && !isPosixPath)) { + index++; + continue; + } + + // Whitespace makes an unquoted path ambiguous. Extend through adjacent + // separator-bearing tokens or to a deterministic filename extension. + // Unequivocal Windows, file-URI, and known-root candidates fail closed; + // arbitrary extensionless POSIX text falls back to token-level handling so + // ordinary `/x/y` route text is not redacted indiscriminately. + const isKnownPosixPath = isKnownPosixFilesystemPathAt(value, index); + const pathEnd = findUnquotedPathEnd( + value, + index, + isWindowsPath || isFileUriPath || isKnownPosixPath, + isWindowsPath || isFileUriPath || isKnownPosixPath, + isWindowsPath || isFileUriPath || isKnownPosixPath + ); + if (pathEnd < 0) { + const mustFailClosed = isWindowsPath || isFileUriPath || isKnownPosixPath; + if (mustFailClosed) { + // An unequivocal filesystem prefix with an unknowable endpoint must + // fail closed over the rest of the first line rather than expose a + // suffix such as `Files\\secret` or `My Project`. + parts.push(value.slice(copyStart, index), ""); + copyStart = value.length; + index = value.length; + break; + } + index++; + continue; + } + parts.push(value.slice(copyStart, index), ""); + copyStart = pathEnd; + index = pathEnd; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +function isPhysicalLineSeparator(code: number): boolean { + return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029; +} + +function serializedLineSeparatorLengthAt(value: string, start: number): number { + if (value.charCodeAt(start) !== 0x5c) return 0; + const marker = value[start + 1]?.toLowerCase(); + if (marker === "n" || marker === "r") return 2; + const unicodeMarker = value.slice(start + 1, start + 6).toLowerCase(); + return unicodeMarker === "u000a" || + unicodeMarker === "u000d" || + unicodeMarker === "u2028" || + unicodeMarker === "u2029" + ? 6 + : 0; +} + +function looksLikeRelativeStackLocation(token: string): boolean { + if (token.length < 6 || token.length > 2048) return false; + const lowerToken = token.toLowerCase(); + if (lowerToken.startsWith("http://") || lowerToken.startsWith("https://")) return false; + + const lastForwardSlash = token.lastIndexOf("/"); + const lastBackslash = token.lastIndexOf("\\"); + const lastSeparator = Math.max(lastForwardSlash, lastBackslash); + if (lastSeparator < 0 || lastSeparator === token.length - 1) return false; + + const dot = token.lastIndexOf("."); + if (dot <= lastSeparator || dot === token.length - 1) return false; + const lineSeparator = token.indexOf(":", dot + 1); + if (lineSeparator < 0) return false; + const extension = token.slice(dot + 1, lineSeparator).toLowerCase(); + if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false; + + let index = lineSeparator + 1; + if (!isAsciiDigit(token.charCodeAt(index))) return false; + while (index < token.length && isAsciiDigit(token.charCodeAt(index))) index++; + if (index === token.length) return true; + if (token.charCodeAt(index) !== 0x3a) return false; + + index++; + if (!isAsciiDigit(token.charCodeAt(index))) return false; + while (index < token.length && isAsciiDigit(token.charCodeAt(index))) index++; + return index === token.length; +} + +function hasNumericLineColumnSuffix(value: string, separator: number): boolean { + if (value.charCodeAt(separator) !== 0x3a) return false; + let index = separator + 1; + if (!isAsciiDigit(value.charCodeAt(index))) return false; + while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++; + if (value.charCodeAt(index) !== 0x3a) return false; + + index++; + if (!isAsciiDigit(value.charCodeAt(index))) return false; + while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++; + return index === value.length; +} + +function isNodeModulePathCode(code: number): boolean { + return ( + isAsciiAlphaNumeric(code) || code === 0x2e || code === 0x2f || code === 0x5f || code === 0x2d + ); +} + +function looksLikeNodeStackLocation(token: string): boolean { + if (token.length < 10 || token.length > 2048 || !token.startsWith("node:")) return false; + const columnSeparator = token.lastIndexOf(":"); + const lineSeparator = token.lastIndexOf(":", columnSeparator - 1); + if (lineSeparator <= 5 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false; + for (let index = 5; index < lineSeparator; index++) { + if (!isNodeModulePathCode(token.charCodeAt(index))) return false; + } + return true; +} + +function looksLikeEvalStackLocation(token: string): boolean { + return token.length <= 64 && token.startsWith("[eval]") && hasNumericLineColumnSuffix(token, 6); +} + +function isRecognizedStackPathAt(value: string, start: number): boolean { + if (hasAbsoluteFileUriAt(value, start)) return true; + const tokenEnd = trimPathSpanEnd(value, start, findTokenEnd(value, start)); + const token = value.slice(start, tokenEnd); + return ( + looksLikeAbsolutePath(token) || + looksLikeRelativeStackLocation(token) || + looksLikeNodeStackLocation(token) || + looksLikeEvalStackLocation(token) + ); +} + +function isStackFrameLabel(value: string, start: number, end: number): boolean { + const label = value.slice(start, end).trim(); + if (label.length === 0 || label.length > 256) return false; + if (!/^[A-Za-z_$<]/.test(label) || /[^A-Za-z0-9_$.[\]<>:/ -]/.test(label)) return false; + if (!/\s/.test(label)) return true; + return /^(?:async|new)\s+\S+$/.test(label) || /^\S+\s+\[as\s+\S+\]$/.test(label); +} + +function skipAsyncStackPrefix(value: string, start: number): number { + if (value.slice(start, start + 5) !== "async" || !isWhitespace(value[start + 5])) return start; + let locationStart = start + 6; + while (locationStart < value.length && isWhitespace(value[locationStart])) locationStart++; + return locationStart; +} + +function isAggregateIndexLocationAt(value: string, start: number): boolean { + if (value.slice(start, start + 5) !== "index" || !isWhitespace(value[start + 5])) return false; + let index = start + 6; + while (index < value.length && isWhitespace(value[index])) index++; + if (!isAsciiDigit(value.charCodeAt(index))) return false; + while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++; + while (index < value.length && isWhitespace(value[index])) index++; + return value.charCodeAt(index) === 0x29; +} + +function looksLikeStackFrameAt(value: string, atIndex: number, allowDirectPath: boolean): boolean { + if (value.slice(atIndex, atIndex + 2).toLowerCase() !== "at") return false; + let labelStart = atIndex + 2; + if (!isWhitespace(value[labelStart])) return false; + while (labelStart < value.length && isWhitespace(value[labelStart])) labelStart++; + labelStart = skipAsyncStackPrefix(value, labelStart); + if (allowDirectPath && isRecognizedStackPathAt(value, labelStart)) return true; + + const openParen = value.indexOf("(", labelStart); + if (openParen < 0 || openParen - labelStart > 256) return false; + let pathStart = openParen + 1; + while (pathStart < value.length && isWhitespace(value[pathStart])) pathStart++; + return ( + isStackFrameLabel(value, labelStart, openParen) && + (isRecognizedStackPathAt(value, pathStart) || + (allowDirectPath && isAggregateIndexLocationAt(value, pathStart))) + ); +} + +function findSerializedStackFrameStart(value: string): number { + for (let index = 0; index < value.length; index++) { + const separatorLength = serializedLineSeparatorLengthAt(value, index); + if (separatorLength === 0) continue; + let frameStart = index + separatorLength; + while (frameStart < value.length) { + while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++; + const adjacentSeparatorLength = serializedLineSeparatorLengthAt(value, frameStart); + if (adjacentSeparatorLength === 0) break; + frameStart += adjacentSeparatorLength; + } + if (looksLikeStackFrameAt(value, frameStart, true)) { + let separatorStart = index; + while (separatorStart > 0 && value.charCodeAt(separatorStart - 1) === 0x5c) { + separatorStart--; + } + return separatorStart; + } + } + return -1; +} + +function findInlineStackFrameStart(value: string): number { + let marker = value.indexOf(" at "); + while (marker >= 0) { + if (looksLikeStackFrameAt(value, marker + 1, false)) return marker; + marker = value.indexOf(" at ", marker + 4); + } + return -1; +} + +/** Strip physical, serialized, and unambiguously inline JavaScript stack-frame tails. */ +export function stripErrorStackTail(value: string): string { + let firstLineEnd = value.length; + for (let index = 0; index < value.length; index++) { + if (isPhysicalLineSeparator(value.charCodeAt(index))) { + firstLineEnd = index; + break; + } + } + + const firstLine = value.slice(0, firstLineEnd); + const serializedFrameStart = findSerializedStackFrameStart(firstLine); + const inlineFrameStart = findInlineStackFrameStart(firstLine); + const frameStart = + serializedFrameStart < 0 + ? inlineFrameStart + : inlineFrameStart < 0 + ? serializedFrameStart + : Math.min(serializedFrameStart, inlineFrameStart); + return frameStart < 0 ? firstLine : firstLine.slice(0, frameStart); +} + +/** + * Redact absolute filesystem paths while preserving URLs, explicitly marked + * API routes, and punctuation around determinable endpoints. Unequivocal + * filesystem prefixes fail closed when an unquoted endpoint is ambiguous. + */ +export function redactErrorPaths(value: string): string { + const quotedPathsRedacted = redactQuotedAbsolutePaths(value); + const pathSpansRedacted = redactUnquotedAbsolutePathSpans(quotedPathsRedacted); + const parts = pathSpansRedacted.split(/(\s+)/); + let previousToken = ""; + for (let index = 0; index < parts.length; index++) { + const token = parts[index]; + if (isWhitespace(token)) continue; + parts[index] = redactAbsolutePathToken(token, isRouteContextToken(previousToken)); + previousToken = token; + } + return parts.join(""); +} diff --git a/open-sse/utils/errorSanitization.ts b/open-sse/utils/errorSanitization.ts new file mode 100644 index 00000000000..5b101e32a11 --- /dev/null +++ b/open-sse/utils/errorSanitization.ts @@ -0,0 +1,440 @@ +import { redactErrorPaths, stripErrorStackTail } from "./errorPathRedaction.ts"; + +// Length cap protects against pathological inputs even before tokenization. +const MAX_ERROR_LEN = 4096; +const MAX_SECURITY_ESCAPE_LAYERS = 3; +const STRONG_CREDENTIAL_TOKEN_SOURCE = + "(?:eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|" + + "github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|" + + "xox[a-z]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|" + + "(?= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) + ); +} + +function asciiHexValue(code: number): number { + if (code >= 0x30 && code <= 0x39) return code - 0x30; + if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10; + if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10; + return -1; +} + +function unicodeEscapeCodeAt(value: string, start: number): number | null { + if ( + value.charCodeAt(start) !== 0x5c || + (value[start + 1] !== "u" && value[start + 1] !== "U") || + start + 5 >= value.length + ) { + return null; + } + + let decoded = 0; + for (let digit = start + 2; digit <= start + 5; digit++) { + const nibble = asciiHexValue(value.charCodeAt(digit)); + if (nibble < 0) return null; + decoded = decoded * 16 + nibble; + } + return decoded; +} + +function isPrintableAscii(code: number | null): code is number { + return code !== null && code >= 0x20 && code <= 0x7e; +} + +function isEscapeTokenBoundary(code: number): boolean { + return !isAsciiAlphaNumericCode(code) && code !== 0x2e && code !== 0x5f && code !== 0x2d; +} + +function shouldPreserveUnicodeUncEvidence( + value: string, + runStart: number, + runEnd: number, + decoded: number +): boolean { + if ( + runEnd - runStart < 2 || + decoded === 0x2f || + decoded === 0x5c || + decoded === 0x3a || + (runStart > 0 && !isEscapeTokenBoundary(value.charCodeAt(runStart - 1))) + ) { + return false; + } + + const afterEscape = runEnd + 5; + let tokenEnd = afterEscape; + while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++; + if (value.slice(afterEscape, tokenEnd).includes("=")) return false; + return afterEscape < tokenEnd; +} + +function decodeSecurityEscapesOnce(value: string, decodeQuotes: boolean): string { + const output: string[] = []; + let changed = false; + + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) !== 0x5c) { + output.push(value[index]); + continue; + } + + const runStart = index; + while (index < value.length && value.charCodeAt(index) === 0x5c) index++; + const runEnd = index; + if (runEnd >= value.length) { + output.push(value.slice(runStart)); + break; + } + + const escaped = value[runEnd]; + if (escaped === "u" || escaped === "U") { + const decoded = unicodeEscapeCodeAt(value, runEnd - 1); + const isQuote = decoded === 0x22 || decoded === 0x27; + if ( + isPrintableAscii(decoded) && + (decodeQuotes || !isQuote) && + !shouldPreserveUnicodeUncEvidence(value, runStart, runEnd, decoded) + ) { + output.push(String.fromCharCode(decoded)); + index = runEnd + 4; + changed = true; + continue; + } + output.push(value.slice(runStart, runEnd + 5)); + index = runEnd + 4; + continue; + } + + if (escaped === "/" || (decodeQuotes && (escaped === '"' || escaped === "'"))) { + output.push(escaped); + index = runEnd; + changed = true; + continue; + } + + output.push(value.slice(runStart, runEnd)); + index = runEnd - 1; + } + + return changed ? output.join("").slice(0, MAX_ERROR_LEN) : value; +} + +function hasResidualSecurityEscape(value: string): boolean { + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) !== 0x5c) continue; + while (index < value.length && value.charCodeAt(index) === 0x5c) index++; + if (index >= value.length) return false; + const escaped = value[index]; + if (escaped === "/" || escaped === '"' || escaped === "'") return true; + if (escaped === "u" || escaped === "U") { + const decoded = unicodeEscapeCodeAt(value, index - 1); + if (isPrintableAscii(decoded)) return true; + } + } + return false; +} + +/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */ +function normalizeSecurityEscapes(value: string, decodeQuotes: boolean): string { + let normalized = value.slice(0, MAX_ERROR_LEN); + for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) { + const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes); + if (decoded === normalized) break; + normalized = decoded.slice(0, MAX_ERROR_LEN); + } + return normalized; +} + +function isCredentialLabelBoundary(code: number): boolean { + return !isAsciiAlphaNumericCode(code) && code !== 0x5f && code !== 0x2d; +} + +function matchCredentialAssignmentAt(value: string, start: number): CredentialAssignment | null { + const keyQuote = value[start] === '"' || value[start] === "'" ? value[start] : ""; + const labelStart = start + (keyQuote ? 1 : 0); + + for (const [label, failClosed] of CREDENTIAL_LABELS) { + const labelEnd = labelStart + label.length; + if (value.slice(labelStart, labelEnd).toLowerCase() !== label) continue; + let index = labelEnd; + if ( + (label === "arena-auth-prod-v1" || label === "__secure-next-auth.session-token") && + value[index] === "." + ) { + const chunkStart = ++index; + while (index < value.length && /\d/.test(value[index])) index++; + if (index === chunkStart) continue; + } + if (keyQuote) { + if (value[index] !== keyQuote) continue; + index++; + } else if (!isCredentialLabelBoundary(value.charCodeAt(index))) { + continue; + } else if (value[index] === '"' || value[index] === "'") { + index++; + } + while (/\s/.test(value[index])) index++; + if (value[index] !== ":" && value[index] !== "=") continue; + index++; + while (/\s/.test(value[index])) index++; + return { valueStart: index, failClosed }; + } + return null; +} + +function findQuotedCredentialEnd(value: string, start: number, quote: string): number { + let index = start + 1; + while (index < value.length) { + if (value.charCodeAt(index) === 0x5c) { + index += 2; + continue; + } + if (value[index] === quote) return index; + index++; + } + return -1; +} + +function findUnquotedCredentialEnd(value: string, start: number): number { + let end = start; + while (end < value.length) { + const char = value[end]; + if (/\s/.test(char) || char === '"' || char === "'" || char === "," || char === "}") break; + end++; + } + return end; +} + +function redactLabeledCredentialAssignments(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + const assignment = matchCredentialAssignmentAt(value, index); + if (!assignment) { + index++; + continue; + } + + const { valueStart, failClosed } = assignment; + const quote = value[valueStart] === '"' || value[valueStart] === "'" ? value[valueStart] : ""; + if (quote) { + const closingQuote = findQuotedCredentialEnd(value, valueStart, quote); + parts.push(value.slice(copyStart, valueStart + 1), "[REDACTED]"); + if (closingQuote < 0) { + copyStart = value.length; + index = value.length; + } else { + parts.push(quote); + copyStart = closingQuote + 1; + index = copyStart; + } + continue; + } + + // A leading backslash may be a serialized quote or another encoded + // delimiter. Do not redact only that prefix and leave the value behind. + const valueEnd = + failClosed || value.charCodeAt(valueStart) === 0x5c + ? value.length + : findUnquotedCredentialEnd(value, valueStart); + parts.push(value.slice(copyStart, valueStart), "[REDACTED]"); + copyStart = valueEnd; + index = Math.max(valueEnd, valueStart + 1); + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +function redactPrivateKeyPemBlocks(value: string): string { + // ASCII-only fold keeps offsets aligned even when the surrounding message + // contains Unicode characters whose full uppercase form expands in length. + const upperValue = value.replace(/[a-z]/g, (char) => char.toUpperCase()); + const beginPrefix = "-----BEGIN "; + const parts: string[] = []; + let copyStart = 0; + let searchStart = 0; + + while (searchStart < value.length) { + const blockStart = upperValue.indexOf(beginPrefix, searchStart); + if (blockStart < 0) break; + const labelStart = blockStart + beginPrefix.length; + const headerEnd = upperValue.indexOf("-----", labelStart); + if (headerEnd < 0) break; + const label = upperValue.slice(labelStart, headerEnd).trim(); + if (!/^(?:[A-Z0-9]+ )*PRIVATE KEY$/.test(label)) { + searchStart = headerEnd + 5; + continue; + } + + const endMarker = `-----END ${label}-----`; + const closingStart = upperValue.indexOf(endMarker, headerEnd + 5); + const blockEnd = closingStart < 0 ? value.length : closingStart + endMarker.length; + parts.push(value.slice(copyStart, blockStart), "[REDACTED]"); + copyStart = blockEnd; + searchStart = blockEnd; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +export function redactSensitiveErrorText(value: string): string { + const commonCredentialsRedacted = redactPrivateKeyPemBlocks(value) + .replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]") + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") + .replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]"); + return redactLabeledCredentialAssignments(commonCredentialsRedacted); +} + +function coerceErrorText(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return String(value); + } catch { + // Fail closed when an attacker-controlled toString/valueOf accessor throws. + return ""; + } +} + +/** + * Strip stack-trace tails, credentials, and absolute source paths from a + * client-visible error message. + */ +export function sanitizeErrorMessage(message: unknown): string { + let str = coerceErrorText(message); + if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN); + // Preserve quote provenance until hidden labels/delimiters have been + // exposed and redacted, then decode safe quote escapes in the clean text. + str = redactSensitiveErrorText(str); + str = normalizeSecurityEscapes(str, false); + str = redactSensitiveErrorText(redactErrorPaths(stripErrorStackTail(str))); + str = normalizeSecurityEscapes(str, true); + str = redactSensitiveErrorText(redactErrorPaths(stripErrorStackTail(str))); + return hasResidualSecurityEscape(str) ? "[REDACTED]" : str; +} + +const BLOCKED_KEYS = + /stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie|credential|session(?!_?(?:count|status)$)/i; +const BLOCKED_CREDENTIAL_ALIAS_KEYS = + /^(?:cf_clearance|__cf_bm|_cfuvid|_puid|sso|sso-rw|arena-auth-prod-v1(?:\.\d+)?)$/i; +const PROTOTYPE_CONTROL_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const MAX_DEPTH = 4; +const MAX_UPSTREAM_KEY_LEN = 256; + +function isSafeUpstreamDetailKey(key: string): boolean { + if ( + key.length === 0 || + key.length > MAX_UPSTREAM_KEY_LEN || + BLOCKED_KEYS.test(key) || + BLOCKED_CREDENTIAL_ALIAS_KEYS.test(key) || + PROTOTYPE_CONTROL_KEYS.has(key.toLowerCase()) + ) { + return false; + } + return sanitizeErrorMessage(key) === key; +} + +/** + * Recursively sanitize an arbitrary JSON value from an upstream provider body. + * Unsafe keys are dropped rather than renamed so sanitized-key collisions + * cannot restore a secret under a public placeholder. + */ +export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown { + if (depth > MAX_DEPTH) return "[truncated]"; + if (value === null || value === undefined) return null; + if (typeof value === "string") return sanitizeErrorMessage(value); + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) { + return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1)); + } + if (typeof value === "object") { + const out = Object.create(null) as Record; + for (const [key, entryValue] of Object.entries(value as Record)) { + if (!isSafeUpstreamDetailKey(key)) continue; + out[key] = sanitizeUpstreamDetails(entryValue, depth + 1); + } + return out; + } + return null; +} diff --git a/open-sse/utils/upstreamErrorPassthrough.ts b/open-sse/utils/upstreamErrorPassthrough.ts index 21d0c6c964a..64a247971a2 100644 --- a/open-sse/utils/upstreamErrorPassthrough.ts +++ b/open-sse/utils/upstreamErrorPassthrough.ts @@ -1,12 +1,13 @@ +import { sanitizeUpstreamDetails } from "./errorSanitization.ts"; + /** * Selective upstream 4xx error passthrough (Claude Code auto-recover contract). * - * Claude Code matches the upstream error WORDING to auto-disable capabilities - * (thinking / output_config) for the rest of the conversation. Wrapping the body - * via buildErrorBody() truncates the message and breaks that recovery. For - * upstream-originated 4xx errors the body is the provider's public API message — - * not our internals — so it is safe and required to relay it verbatim. - * OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12). + * Claude Code matches upstream error wording to auto-disable capabilities + * (thinking / output_config) for the rest of the conversation. This path keeps + * the wording and JSON shape required for that recovery after applying the + * canonical recursive sanitizer. OmniRoute-generated errors MUST keep using + * buildErrorBody() (Hard Rule #12). */ const PASSTHROUGH_MIN = 400; const PASSTHROUGH_MAX = 499; @@ -17,13 +18,9 @@ const EXCLUDED_STATUSES = new Set([401, 403, 407]); const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i; // #10898-sec / secret-in-error hardening: some providers echo the offending // request (including an Authorization header or api key) inside a 400/422/429 -// validation body. Passthrough relays the body VERBATIM (the Claude Code -// capability-recovery contract needs the exact wording), so we cannot key-drop -// via sanitizeUpstreamDetails without breaking that contract. Instead, if the -// body actually carries a credential pattern, REFUSE passthrough and let the -// caller fall back to the sanitized buildErrorBody path. Bodies without a -// secret (the overwhelming majority, carrying capability/quota wording) still -// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts. +// validation body. The eligibility filter still refuses obvious credential +// echoes, and the response builder independently applies the canonical recursive +// sanitizer. Safe capability/quota wording remains unchanged for Claude Code. const CREDENTIAL_LEAK_RE = /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i; @@ -31,7 +28,14 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false; if (EXCLUDED_STATUSES.has(statusCode)) return false; if (!upstreamBody || typeof upstreamBody !== "object") return false; - const text = JSON.stringify(upstreamBody); + let text: string | undefined; + try { + text = JSON.stringify(upstreamBody); + } catch { + // Relay only JSON-stable objects; cyclic/BigInt/hostile toJSON bodies fail closed. + return false; + } + if (typeof text !== "string") return false; if (INTERNAL_LEAK_RE.test(text)) return false; // Refuse passthrough when the provider echoed a credential back to us. if (CREDENTIAL_LEAK_RE.test(text)) return false; @@ -44,7 +48,12 @@ export function buildPassthroughErrorResponse( headers?: Record ): Response | null { if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null; - return new Response(JSON.stringify(upstreamBody), { + const sanitizedBody = sanitizeUpstreamDetails(upstreamBody); + const publicBody = + sanitizedBody && typeof sanitizedBody === "object" + ? sanitizedBody + : { error: { message: "Upstream error" } }; + return new Response(JSON.stringify(publicBody), { status: statusCode, headers: { "Content-Type": "application/json", ...(headers || {}) }, }); diff --git a/open-sse/utils/upstreamErrorResponse.ts b/open-sse/utils/upstreamErrorResponse.ts new file mode 100644 index 00000000000..5048dafea16 --- /dev/null +++ b/open-sse/utils/upstreamErrorResponse.ts @@ -0,0 +1,47 @@ +import { buildErrorBody, sanitizeErrorMessage, sanitizeUpstreamDetails } from "./error.ts"; + +interface SanitizedUpstreamErrorResponseOptions { + status: number; + rawBody: string; + fallbackMessage: string; + headers?: Record; +} + +/** + * Preserve a provider's JSON error shape while applying the canonical recursive sanitizer. + * Providers sometimes label plain text as JSON; those bodies use OmniRoute's canonical error + * envelope so the advertised content type always matches the response bytes. + */ +export function buildSanitizedUpstreamErrorResponse({ + status, + rawBody, + fallbackMessage, + headers, +}: SanitizedUpstreamErrorResponseOptions): Response { + const trimmedBody = rawBody.trim(); + + if (trimmedBody) { + try { + const parsedBody: unknown = JSON.parse(trimmedBody); + const serializedBody = JSON.stringify(sanitizeUpstreamDetails(parsedBody)); + if (serializedBody !== undefined) { + return new Response(serializedBody, { + status, + headers: { ...headers, "Content-Type": "application/json" }, + }); + } + } catch { + // Upstreams commonly return text or HTML despite an application/json response header. + // Treat it as an opaque message and use the canonical JSON envelope below. + } + } + + const safeMessage = + sanitizeErrorMessage(`Upstream error: ${trimmedBody}`) + .replace(/^Upstream error:\s*/, "") + .trim() || fallbackMessage; + return new Response(JSON.stringify(buildErrorBody(status, safeMessage)), { + status, + headers: { ...headers, "Content-Type": "application/json" }, + }); +} diff --git a/package.json b/package.json index 38d9043c367..d71544b3343 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "scripts/check/check-supported-node-runtime.ts", "scripts/dev/sync-env.mjs", "scripts/build/assembleStandalone.mjs", + "scripts/build/standaloneSidecarCopy.mjs", + "scripts/build/tlsClientAssetCopy.mjs", "scripts/build/backendOnlyPages.mjs", "scripts/build/build-tproxy-native.mjs", "scripts/build/native-binary-compat.mjs", @@ -102,7 +104,7 @@ "build:backend": "cross-env OMNIROUTE_BUILD_BACKEND_ONLY=1 node scripts/build/build-next-isolated.mjs", "build:cli": "node --import tsx scripts/build/prepublish.ts", "omniroute:verify": "node scripts/check/omniroute-verify.mjs", - "build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs", + "build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone && npm run build:cli && node scripts/build/write-build-sha.mjs", "build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild", "start": "node scripts/dev/run-next.mjs start", "homolog": "node scripts/homolog/run.mjs", @@ -112,9 +114,9 @@ "lint:prose": "vale docs", "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"", "electron:build": "npm run build && cd electron && npm run build", - "electron:build:win": "npm run build && cd electron && npm run build:win", - "electron:build:mac": "npm run build && cd electron && npm run build:mac", - "electron:build:linux": "npm run build && cd electron && npm run build:linux", + "electron:build:win": "npm run build && cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=win32 OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:win", + "electron:build:mac": "npm run build && cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:mac-x64 && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=arm64 npm run build:mac-arm64", + "electron:build:linux": "npm run build && cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=linux OMNIROUTE_ELECTRON_TARGET_ARCHES=x64,arm64 npm run build:linux", "electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs", "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\"", "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index fa46b26c64f..77e5e07dc88 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -45,10 +45,24 @@ * removeGeneratedElectronArtifacts - - Y UNIQUE (electron) */ -import fs from "node:fs/promises"; import fsSync from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; import { colocateLlmlinguaOptionals, SEED_PACKAGES } from "./colocateOptionals.mjs"; +import { TLS_CLIENT_NATIVE_ASSETS } from "./fixTlsClientNodeBinary.mjs"; +import { + clearStaleDest, + copyNativeAssetsAndExtraModules, + repairEmptyExternalPackageDirs, + resolvesToSamePath, +} from "./standaloneSidecarCopy.mjs"; +import { + auditTlsClientStandaloneBundle, + copyVerifiedTlsClientNativeAsset, + createTlsClientNativeAssetEntries, +} from "./tlsClientAssetCopy.mjs"; + +const TLS_CLIENT_NATIVE_ASSET_ENTRIES = createTlsClientNativeAssetEntries(TLS_CLIENT_NATIVE_ASSETS); /** * Check whether a path exists (async). @@ -73,9 +87,14 @@ async function exists(targetPath) { * * Each entry uses path SEGMENT arrays (not pre-joined strings) so the source * (relative to projectRoot) and destination (relative to outDir) can be joined - * for either path/platform. @type {{label:string, src:string[], dest:string[]}[]} + * for either path/platform. + * @type {{label:string, src:string[], dest:string[], tlsClientSha256?:string}[]} */ export const NATIVE_ASSET_ENTRIES = [ + // tls-client-node loads one platform library dynamically, outside Next.js + // tracing. Copy only manifest-declared filenames: recursively copying bin/ + // would distribute unverified upstream residue beside the pinned seeds. + ...TLS_CLIENT_NATIVE_ASSET_ENTRIES, { label: "better-sqlite3 native binary", src: ["node_modules", "better-sqlite3", "build"], @@ -112,6 +131,18 @@ export const NATIVE_ASSET_ENTRIES = [ /** @type {{label:string, src:string[], dest:string[]}[]} */ const EXTRA_MODULE_ENTRIES = [ + { + // The project MIT license must accompany standalone, Docker, and Electron + // distributions just like third-party notices do. + label: "OmniRoute project license", + src: ["LICENSE"], + dest: ["LICENSE"], + }, + { + label: "tls-client pinned native manifest", + src: ["open-sse", "config", "tlsClientNativeManifest.json"], + dest: ["open-sse", "config", "tlsClientNativeManifest.json"], + }, { // Legal notices must travel with every standalone bundle. Docker copies the // complete standalone tree into /app, so this one entry covers both outputs. @@ -308,6 +339,14 @@ const EXTRA_MODULE_ENTRIES = [ ].map((pkg) => ({ label: pkg, src: ["node_modules", pkg], dest: ["node_modules", pkg] })), ]; +function nativeAssetEntriesFor(tlsClientNativeAssets) { + if (tlsClientNativeAssets === TLS_CLIENT_NATIVE_ASSETS) return NATIVE_ASSET_ENTRIES; + return [ + ...createTlsClientNativeAssetEntries(tlsClientNativeAssets), + ...NATIVE_ASSET_ENTRIES.filter((entry) => !entry.tlsClientSha256), + ]; +} + /** * Copy native standalone assets (better-sqlite3 build/prebuilds and TPROXY). * @@ -317,12 +356,25 @@ const EXTRA_MODULE_ENTRIES = [ * @param {string} rootDir - project root (node_modules are read from here) * @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests) * @param {Console|{log:Function}} [log] - logger + * @param {{tlsClientNativeAssets?:Record}} [options] * @returns {Promise} true if any asset was copied */ -export async function syncStandaloneNativeAssets(rootDir, fsImpl = fs, log = console, outDir) { +export async function syncStandaloneNativeAssets( + rootDir, + fsImpl = fs, + log = console, + outDir, + { tlsClientNativeAssets = TLS_CLIENT_NATIVE_ASSETS } = {} +) { const standaloneRoot = outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone"); - return syncNativeAssetsToDir(rootDir, standaloneRoot, fsImpl, log); + return syncNativeAssetsToDir( + rootDir, + standaloneRoot, + fsImpl, + log, + nativeAssetEntriesFor(tlsClientNativeAssets) + ); } /** @@ -349,16 +401,41 @@ export async function syncStandaloneExtraModules(rootDir, fsImpl = fs, log = con * @param {string} outDir * @param {typeof fs} fsImpl * @param {Console|{log:Function}} log + * @param {{label:string, src:string[], dest:string[], tlsClientSha256?:string}[]} nativeAssetEntries * @returns {Promise} */ -async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) { +async function syncNativeAssetsToDir( + projectRoot, + outDir, + fsImpl, + log, + nativeAssetEntries = NATIVE_ASSET_ENTRIES +) { let changed = false; - for (const entry of NATIVE_ASSET_ENTRIES) { + for (const entry of nativeAssetEntries) { const sourcePath = path.join(projectRoot, ...entry.src); + const destinationPath = path.join(outDir, ...entry.dest); + if (entry.tlsClientSha256) { + const copied = copyVerifiedTlsClientNativeAsset({ + sourceRoot: projectRoot, + sourcePath, + destinationPath, + expectedSha256: entry.tlsClientSha256, + outDir, + }); + if (!copied) continue; + log.log( + `[assembleStandalone] Copied verified native standalone asset: ${path.relative( + projectRoot, + destinationPath + )}` + ); + changed = true; + continue; + } if (!(await exists(sourcePath))) continue; - const destinationPath = path.join(outDir, ...entry.dest); // See resolvesToSamePath/clearStaleDest (sync copy path, same module) — the same // ERR_FS_CP_EINVAL/ERR_FS_CP_DIR_TO_NON_DIR races apply to fsImpl.cp here. if (resolvesToSamePath(sourcePath, destinationPath)) continue; @@ -550,145 +627,6 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir } } -/** - * Two independent copy passes assemble a bundle: the bulk "standalone -> outDir" tree - * copy (step 1 of assembleStandalone) can already have carried a prior entry's result - * into `dest` (e.g. an absolute pnpm-store symlink, or a directory) BEFORE this entry's - * own copy runs. `fs.cpSync`/`fs.cp` refuse to overwrite in two such cases even with - * `force: true`: - * - dest already resolves (via symlink chain) to the exact same real path as src -> - * ERR_FS_CP_EINVAL "src and dest cannot be the same". - * - dest exists with a different node type than src (file/symlink vs directory) -> - * ERR_FS_CP_DIR_TO_NON_DIR / ERR_FS_CP_NON_DIR_TO_DIR. - * Under heavy concurrent build I/O this manifested non-deterministically across - * different EXTRA_MODULE_ENTRIES/NATIVE_ASSET_ENTRIES on every retry. Resolve both - * cases up front: skip entirely when dest is already the right target, otherwise clear - * whatever stale node occupies dest (via lstat, so it also removes a broken symlink) - * so the fresh copy always lands cleanly. - * - * @param {string} src - * @param {string} dest - * @returns {boolean} true when dest already IS src's target and no copy is needed - */ -function resolvesToSamePath(src, dest) { - if (path.resolve(src) === path.resolve(dest)) return true; - if (!fsSync.existsSync(dest)) return false; - try { - return fsSync.realpathSync(src) === fsSync.realpathSync(dest); - } catch { - return false; - } -} - -/** @see resolvesToSamePath — clears whatever stale node sits at `dest` before a copy. */ -function clearStaleDest(dest) { - try { - fsSync.lstatSync(dest); - } catch { - return; - } - fsSync.rmSync(dest, { recursive: true, force: true }); -} - -/** - * Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars - * (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …) - * into the assembled bundle. Missing sources are skipped silently. - * - * @param {string} projectRoot - * @param {string} resolvedOutDir - */ -function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { - for (const asset of NATIVE_ASSET_ENTRIES) { - const src = path.join(projectRoot, ...asset.src); - if (!fsSync.existsSync(src)) continue; - const dest = path.join(resolvedOutDir, ...asset.dest); - if (resolvesToSamePath(src, dest)) continue; - clearStaleDest(dest); - fsSync.mkdirSync(path.dirname(dest), { recursive: true }); - fsSync.cpSync(src, dest, { recursive: true, force: true }); - console.log(`[assembleStandalone] Copied native asset: ${asset.label}`); - } - - for (const mod of EXTRA_MODULE_ENTRIES) { - const src = path.join(projectRoot, ...mod.src); - if (!fsSync.existsSync(src)) continue; - const dest = path.join(resolvedOutDir, ...mod.dest); - if (resolvesToSamePath(src, dest)) continue; - clearStaleDest(dest); - fsSync.mkdirSync(path.dirname(dest), { recursive: true }); - fsSync.cpSync(src, dest, { recursive: true, force: true }); - console.log(`[assembleStandalone] Synced module: ${mod.label}`); - } -} - -/** - * Next/Turbopack standalone output can leave behind hollow top-level package - * directories for externalized runtime deps (directory exists, but contains no - * files). Those empty placeholders shadow the real repo-level install and make - * runtime ESM externals fail with "Cannot find package '/node_modules//index.js'" - * even though the dependency is present in the source tree. - * - * Repair strategy: for each empty top-level package dir already present in the - * assembled bundle, if the same package exists in the project root node_modules, - * replace the hollow directory with a full recursive copy from the source install. - * This keeps the fix narrowly scoped to packages the standalone already expects. - * - * @param {string} projectRoot - * @param {string} bundleNodeModules - * @returns {{repaired: number, packages: string[]}} - */ -function repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules) { - const summary = { repaired: 0, packages: [] }; - const sourceNodeModules = path.join(projectRoot, "node_modules"); - if (!fsSync.existsSync(bundleNodeModules) || !fsSync.existsSync(sourceNodeModules)) { - return summary; - } - - for (const name of fsSync.readdirSync(bundleNodeModules)) { - if (name.startsWith(".") || name.startsWith("@")) continue; - - const bundlePkgDir = path.join(bundleNodeModules, name); - const sourcePkgDir = path.join(sourceNodeModules, name); - - let bundleStat; - try { - bundleStat = fsSync.statSync(bundlePkgDir); - } catch { - continue; - } - if (!bundleStat.isDirectory()) continue; - - let bundleEntries = []; - try { - bundleEntries = fsSync.readdirSync(bundlePkgDir); - } catch { - continue; - } - if (bundleEntries.length > 0 || !fsSync.existsSync(sourcePkgDir)) continue; - - let sourceStat; - try { - sourceStat = fsSync.statSync(sourcePkgDir); - } catch { - continue; - } - if (!sourceStat.isDirectory()) continue; - // See resolvesToSamePath/clearStaleDest above: bundlePkgDir can itself be a - // symlink to sourcePkgDir's realpath whose target momentarily read as empty - // under heavy concurrent build I/O (a transient readdirSync race, not a real - // hollow placeholder), or a stale non-directory node from an earlier pass. - if (resolvesToSamePath(sourcePkgDir, bundlePkgDir)) continue; - clearStaleDest(bundlePkgDir); - - fsSync.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true }); - summary.repaired += 1; - summary.packages.push(name); - } - - return summary; -} - /** * Materialize Turbopack "hashed external module" symlinks inside a bundled * node_modules dir into real, self-contained directories. @@ -840,6 +778,7 @@ export function syncRebuiltNativeModuleIntoHashedEntries(rootModuleDir, nodeModu * @param {boolean} [opts.patchTurbopackChunks] - strip hashed externals from .next/server js files (default false) * @param {boolean} [opts.copyNatives] - copy native assets + extra modules (default true) * @param {boolean} [opts.materializeSymlinks] - dereference Turbopack hashed-module symlinks in node_modules (default false) + * @param {Record} [opts.tlsClientNativeAssets] - integrity manifest (default pinned production manifest) * @returns {void} */ export function assembleStandalone({ @@ -850,6 +789,7 @@ export function assembleStandalone({ patchTurbopackChunks: doPatchChunks = false, copyNatives = true, materializeSymlinks = false, + tlsClientNativeAssets = TLS_CLIENT_NATIVE_ASSETS, }) { if (!distDir) throw new Error("[assembleStandalone] distDir is required"); if (!outDir) throw new Error("[assembleStandalone] outDir is required"); @@ -904,7 +844,12 @@ export function assembleStandalone({ // 6. Optionally copy native assets + extra modules (synchronous) if (copyNatives) { - copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir); + copyNativeAssetsAndExtraModules({ + projectRoot, + outDir: resolvedOutDir, + nativeAssetEntries: nativeAssetEntriesFor(tlsClientNativeAssets), + extraModuleEntries: EXTRA_MODULE_ENTRIES, + }); // Repair hollow externalized package dirs in BOTH locations Turbopack's standalone // tracer can populate: the top-level bundle node_modules, and — for projects with a // custom distDir (see next.config.mjs) — the nested /node_modules mirrored @@ -955,4 +900,11 @@ export function assembleStandalone({ } } } + + auditTlsClientStandaloneBundle({ + outDir: resolvedOutDir, + projectRoot, + relativeNextDistDir: relDistDir, + nativeAssets: tlsClientNativeAssets, + }); } diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index fa58607ff5c..429adb3878b 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -11,6 +11,7 @@ import { syncStandaloneNativeAssets as _syncNativeAssets, syncStandaloneExtraModules as _syncExtraModules, } from "./assembleStandalone.mjs"; +import { fixTlsClientNodeBinary } from "./fixTlsClientNodeBinary.mjs"; import { isBackendOnlyBuild, stubDashboardPages, @@ -259,6 +260,35 @@ export async function syncStandaloneExtraModules( return _syncExtraModules(rootDir, fsImpl, log); } +/** + * Assemble the movable standalone runtime, then verify the exact TLS native seed + * that consumers will execute. Keeping both operations in one awaited composition + * prevents a successful copy/prune from bypassing the pinned-digest gate. + */ +export async function assembleAndVerifyStandalone({ + rootDir = projectRoot, + buildDistDir = distDir, + standaloneDir = path.join(buildDistDir, "standalone"), + assembleImpl = assembleStandalone, + verifyImpl = fixTlsClientNodeBinary, +} = {}) { + await assembleImpl({ + distDir: buildDistDir, + outDir: standaloneDir, + projectRoot: rootDir, + patchTurbopackChunks: true, + copyNatives: true, + materializeSymlinks: true, + }); + + await verifyImpl({ + rootDir, + standaloneDir, + strict: true, + requireStandalone: true, + }); +} + export async function main() { const movedPaths = []; const transientBuildPaths = getTransientBuildPaths(); @@ -299,7 +329,21 @@ export async function main() { const result = await runNextBuild(); const standaloneDir = path.join(distDir, "standalone"); - if (result.code === 0 && (await exists(standaloneDir))) { + if (result.code === 0) { + let standaloneStats; + try { + standaloneStats = await fs.lstat(standaloneDir); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + + if (!standaloneStats?.isDirectory() || standaloneStats.isSymbolicLink()) { + throw new Error( + `Next.js build exited successfully but did not produce a standalone directory at ${standaloneDir}. ` + + 'Ensure Next.js output is set to "standalone" and inspect the preceding build-worker logs.' + ); + } + try { await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), { recursive: true, @@ -337,34 +381,28 @@ export async function main() { ); } - try { - console.log( - "[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..." - ); - assembleStandalone({ - distDir, - outDir: standaloneDir, - projectRoot, - // Match the hardened packaging path used by Electron builds: - // Turbopack can emit hashed external-package references and - // standalone symlinks that break after the bundle is moved/copied. - patchTurbopackChunks: true, - copyNatives: true, - materializeSymlinks: true, - }); - const { spawnSync } = await import("node:child_process"); - const basePathWrite = spawnSync( - process.execPath, - [path.join(projectRoot, "scripts", "build", "write-build-base-path.mjs")], - { cwd: projectRoot, env: process.env, stdio: "inherit" } + console.log( + "[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..." + ); + // Match the hardened packaging path used by Electron builds: + // Turbopack can emit hashed external-package references and standalone + // symlinks that break after the bundle is moved/copied. The composition + // verifies the copied TLS seed before any later build step can succeed. + await assembleAndVerifyStandalone({ + rootDir: projectRoot, + buildDistDir: distDir, + standaloneDir, + }); + const { spawnSync } = await import("node:child_process"); + const basePathWrite = spawnSync( + process.execPath, + [path.join(projectRoot, "scripts", "build", "write-build-base-path.mjs")], + { cwd: projectRoot, env: process.env, stdio: "inherit" } + ); + if (basePathWrite.status !== 0) { + console.warn( + "[build-next-isolated] Non-fatal error writing BUILD_OMNIROUTE_BASE_PATH sentinel" ); - if (basePathWrite.status !== 0) { - console.warn( - "[build-next-isolated] Non-fatal error writing BUILD_OMNIROUTE_BASE_PATH sentinel" - ); - } - } catch (assembleErr) { - console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr); } } process.exitCode = result.code; diff --git a/scripts/build/fixTlsClientNodeBinary.mjs b/scripts/build/fixTlsClientNodeBinary.mjs index 155b3f4b2d9..33bac77b58b 100644 --- a/scripts/build/fixTlsClientNodeBinary.mjs +++ b/scripts/build/fixTlsClientNodeBinary.mjs @@ -20,20 +20,41 @@ * `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. - * 3. When that verified asset is absent, invokes the module's postinstall - * with TLS_CLIENT_VERSION pinned and retries with exponential backoff. + * 3. When that verified asset is absent, downloads the exact tagged release + * asset directly and retries with exponential backoff. * * Normal npm postinstall remains best-effort and warns on failure. Docker and * release callers use --strict, which fails closed instead of shipping an * absent or unverified binary. */ -import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + existsSync, + fchmodSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + readSync, + realpathSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000, 8_000]; +const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000; +const MAX_NATIVE_ASSET_BYTES = 64 * 1024 * 1024; +const NATIVE_ASSET_READ_CHUNK_BYTES = 64 * 1024; +const TLS_CLIENT_RELEASE_DOWNLOAD_BASE = + "https://github.com/bogdanfinn/tls-client/releases/download"; const NATIVE_MANIFEST = JSON.parse( readFileSync( new URL("../../open-sse/config/tlsClientNativeManifest.json", import.meta.url), @@ -46,6 +67,24 @@ export const TLS_CLIENT_NATIVE_ASSETS = NATIVE_MANIFEST.assets; /** @typedef {{ file: string; sha256: string }} NativeAsset */ +/** @param {NativeAsset} asset */ +function validateNativeAsset(asset) { + if ( + !asset?.file || + asset.file === "." || + asset.file === ".." || + basename(asset.file) !== asset.file || + asset.file.includes("/") || + asset.file.includes("\\") || + asset.file.includes("\0") + ) { + throw new Error(`Invalid tls-client native asset path: ${JSON.stringify(asset?.file)}`); + } + if (!/^[a-f0-9]{64}$/.test(asset.sha256)) { + throw new Error(`Invalid SHA-256 in tls-client native manifest for ${asset.file}`); + } +} + /** * Resolve the exact native asset supported by tls-client-node@0.2.0. * @@ -58,179 +97,793 @@ export function resolveTlsClientNativeAsset(platform = process.platform, arch = if (!asset) { throw new Error(`Unsupported platform for tls-client-node native asset: ${platform}/${arch}`); } + validateNativeAsset(asset); return asset; } -function sha256File(filePath) { - return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +function sameFileIdentity(left, right) { + return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino); +} + +/** @param {string} filePath */ +function lstatIfPresent(filePath) { + try { + return lstatSync(filePath); + } catch (err) { + if (err?.code === "ENOENT") return undefined; + throw err; + } +} + +function assertNativeAssetSize(size, label) { + if (!Number.isSafeInteger(size) || size < 0 || size > MAX_NATIVE_ASSET_BYTES) { + throw new Error(`${label} exceeds the 64 MiB limit`); + } +} + +function pathEscapesRoot(rootPath, candidatePath) { + const relativePath = relative(rootPath, candidatePath); + return isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${sep}`); +} + +function readSafeDirectoryIdentity(directoryPath) { + const initialStats = lstatIfPresent(directoryPath); + if (!initialStats) return undefined; + if (initialStats.isSymbolicLink() || !initialStats.isDirectory()) { + throw new Error( + `Unsafe tls-client native destination ancestor (symlink/non-directory): ${directoryPath}` + ); + } + const canonicalPath = realpathSync(directoryPath); + const finalStats = lstatSync(directoryPath); + if ( + finalStats.isSymbolicLink() || + !finalStats.isDirectory() || + !sameFileIdentity(initialStats, finalStats) + ) { + throw new Error( + `Unsafe tls-client native destination ancestor changed during verification: ${directoryPath}` + ); + } + return canonicalPath; +} + +/** + * Validate every existing directory component without following symlinks. The + * trusted root itself must already be a real directory; descendants may be + * absent because the caller creates them only after this check succeeds. + */ +function assertSafeDestinationAncestors(trustedRoot, destinationPath) { + const resolvedRoot = resolve(trustedRoot); + const resolvedDestination = resolve(destinationPath); + const destinationRelativePath = relative(resolvedRoot, resolvedDestination); + if ( + destinationRelativePath === "" || + isAbsolute(destinationRelativePath) || + destinationRelativePath === ".." || + destinationRelativePath.startsWith(`..${sep}`) + ) { + throw new Error( + `Unsafe tls-client native destination outside trusted root: ${resolvedDestination}` + ); + } + + const canonicalRoot = readSafeDirectoryIdentity(resolvedRoot); + if (!canonicalRoot) { + throw new Error(`Trusted tls-client native destination root not found: ${resolvedRoot}`); + } + + let currentPath = resolvedRoot; + const relativeParent = relative(resolvedRoot, dirname(resolvedDestination)); + for (const component of relativeParent.split(sep).filter(Boolean)) { + currentPath = join(currentPath, component); + const canonicalPath = readSafeDirectoryIdentity(currentPath); + if (canonicalPath && pathEscapesRoot(canonicalRoot, canonicalPath)) { + throw new Error( + `Unsafe tls-client native destination ancestor outside trusted root: ${currentPath}` + ); + } + } + + return resolvedDestination; +} + +function collectAllowedNativeAssets(nativeAssets, targetAsset) { + const assetsByFile = new Map(); + for (const candidate of Object.values(nativeAssets ?? {})) { + validateNativeAsset(candidate); + const previous = assetsByFile.get(candidate.file); + if (previous && previous.sha256 !== candidate.sha256) { + throw new Error(`Ambiguous SHA-256 for tls-client native asset: ${candidate.file}`); + } + assetsByFile.set(candidate.file, candidate); + } + if (targetAsset) { + validateNativeAsset(targetAsset); + // A deterministic test target intentionally overrides the production asset + // with the same filename. Production calls do not provide this seam. + assetsByFile.set(targetAsset.file, targetAsset); + } + return assetsByFile; +} + +function assertNativeAssetDirectoryInventory( + trustedRoot, + binDir, + allowedAssets, + verifyDigests = false +) { + const auditSentinel = join(binDir, ".tls-client-native-audit"); + assertSafeDestinationAncestors(trustedRoot, auditSentinel); + if (!lstatIfPresent(binDir)) return; + + for (const entryName of readdirSync(binDir)) { + const expectedAsset = allowedAssets.get(entryName); + if (!expectedAsset) { + throw new Error( + `Unlisted tls-client native sibling is not in the manifest: ${join(binDir, entryName)}` + ); + } + const entryPath = join(binDir, entryName); + const entryStats = lstatIfPresent(entryPath); + if (!entryStats || entryStats.isSymbolicLink() || !entryStats.isFile()) { + throw new Error(`Unsafe tls-client native sibling (symlink/non-regular file): ${entryPath}`); + } + if (verifyDigests && !isVerifiedBinary(entryPath, expectedAsset)) { + throw new Error( + `Manifested tls-client native sibling has an unverified SHA-256: ${entryPath}` + ); + } + } + assertSafeDestinationAncestors(trustedRoot, auditSentinel); +} + +function readFileDescriptorBounded(fd, filePath) { + assertNativeAssetSize(fstatSync(fd).size, `Local tls-client native asset: ${filePath}`); + + const chunks = []; + let totalBytes = 0; + while (true) { + const remainingWithSentinel = MAX_NATIVE_ASSET_BYTES - totalBytes + 1; + const chunk = Buffer.allocUnsafe( + Math.min(NATIVE_ASSET_READ_CHUNK_BYTES, remainingWithSentinel) + ); + const bytesRead = readSync(fd, chunk, 0, chunk.byteLength, null); + if (bytesRead === 0) break; + totalBytes += bytesRead; + assertNativeAssetSize(totalBytes, `Local tls-client native asset: ${filePath}`); + chunks.push(chunk.subarray(0, bytesRead)); + } + + const finalStats = fstatSync(fd); + assertNativeAssetSize(finalStats.size, `Local tls-client native asset: ${filePath}`); + const bytes = Buffer.concat(chunks, totalBytes); + assertNativeAssetSize(bytes.length, `Local tls-client native asset: ${filePath}`); + return bytes; +} + +/** + * @param {string} filePath + * @param {NativeAsset} asset + * @param {(filePath: string) => void} [afterInitialStat] + */ +function readVerifiedBinary(filePath, asset, afterInitialStat) { + const pathStats = lstatIfPresent(filePath); + if (!pathStats) return undefined; + if (pathStats.isSymbolicLink() || !pathStats.isFile()) { + throw new Error(`Unsafe tls-client native path (symlink/non-regular file): ${filePath}`); + } + + const fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); + try { + const openedStats = fstatSync(fd); + if (!openedStats.isFile()) { + throw new Error(`Unsafe tls-client native path (not a regular file): ${filePath}`); + } + assertNativeAssetSize(openedStats.size, `Local tls-client native asset: ${filePath}`); + afterInitialStat?.(filePath); + const bytes = readFileDescriptorBounded(fd, filePath); + const currentStats = lstatSync(filePath); + if ( + currentStats.isSymbolicLink() || + !currentStats.isFile() || + !sameFileIdentity(openedStats, currentStats) + ) { + throw new Error(`Unsafe tls-client native path changed during verification: ${filePath}`); + } + if (createHash("sha256").update(bytes).digest("hex") !== asset.sha256) return undefined; + if (process.platform !== "win32") fchmodSync(fd, 0o555); + return bytes; + } finally { + closeSync(fd); + } } /** @param {string} filePath @param {NativeAsset} asset */ function isVerifiedBinary(filePath, asset) { - if (!existsSync(filePath)) return false; try { - return sha256File(filePath) === asset.sha256; + return Boolean(readVerifiedBinary(filePath, asset)); } catch { return false; } } +/** + * @param {string} destinationPath + * @param {Uint8Array} bytes + * @param {NativeAsset} asset + * @param {string} trustedRoot + */ +function writeVerifiedBinary(destinationPath, bytes, asset, trustedRoot) { + assertNativeAssetSize(bytes.byteLength, `tls-client native asset: ${asset.file}`); + if (createHash("sha256").update(bytes).digest("hex") !== asset.sha256) { + throw new Error(`SHA-256 mismatch for tls-client native asset: ${asset.file}`); + } + const resolvedDestination = assertSafeDestinationAncestors(trustedRoot, destinationPath); + const destinationStats = lstatIfPresent(resolvedDestination); + if (destinationStats?.isSymbolicLink() || (destinationStats && !destinationStats.isFile())) { + throw new Error(`Unsafe tls-client native destination path: ${resolvedDestination}`); + } + mkdirSync(dirname(resolvedDestination), { recursive: true }); + assertSafeDestinationAncestors(trustedRoot, resolvedDestination); + const temporaryPath = join( + dirname(resolvedDestination), + `.${asset.file}.${process.pid}.${randomUUID()}.tmp` + ); + try { + assertSafeDestinationAncestors(trustedRoot, resolvedDestination); + writeFileSync(temporaryPath, bytes, { flag: "wx", mode: 0o555 }); + if (!isVerifiedBinary(temporaryPath, asset)) { + throw new Error(`SHA-256 mismatch after writing ${asset.file}`); + } + assertSafeDestinationAncestors(trustedRoot, resolvedDestination); + renameSync(temporaryPath, resolvedDestination); + assertSafeDestinationAncestors(trustedRoot, resolvedDestination); + if (!isVerifiedBinary(resolvedDestination, asset)) { + throw new Error(`SHA-256 mismatch after installing ${asset.file}`); + } + assertSafeDestinationAncestors(trustedRoot, resolvedDestination); + } finally { + try { + assertSafeDestinationAncestors(trustedRoot, temporaryPath); + removeIfPresent(temporaryPath); + } catch { + // Never follow a destination ancestor that changed while the write was in progress. + } + } +} + +/** + * @param {string} sourcePath + * @param {string} destinationPath + * @param {NativeAsset} asset + * @param {string} trustedRoot + */ +function copyVerifiedBinary(sourcePath, destinationPath, asset, trustedRoot) { + assertSafeDestinationAncestors(trustedRoot, sourcePath); + const bytes = readVerifiedBinary(sourcePath, asset); + if (!bytes) throw new Error(`Source native binary is absent or unverified: ${sourcePath}`); + assertSafeDestinationAncestors(trustedRoot, sourcePath); + writeVerifiedBinary(destinationPath, bytes, asset, trustedRoot); +} + function removeIfPresent(filePath) { - if (existsSync(filePath)) unlinkSync(filePath); + if (lstatIfPresent(filePath)) unlinkSync(filePath); } 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, asset, version, retryDelaysMs, log) { - const postinstallScript = join(rootTlsClientDir, "scripts", "postinstall.js"); - const binDir = join(rootTlsClientDir, "bin"); - const binaryPath = join(binDir, asset.file); - if (!existsSync(postinstallScript)) return false; +function pinnedReleaseAssetUrl(version, asset) { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid pinned tls-client native version: ${version}`); + } + return `${TLS_CLIENT_RELEASE_DOWNLOAD_BASE}/v${version}/${encodeURIComponent(asset.file)}`; +} + +async function readBoundedResponseBytes(response, asset) { + const contentLength = response.headers?.get?.("content-length"); + if (contentLength !== null && contentLength !== undefined) { + if (!/^\d+$/.test(contentLength)) { + throw new Error(`Invalid Content-Length for tls-client native asset: ${asset.file}`); + } + assertNativeAssetSize( + Number(contentLength), + `Downloaded tls-client native asset: ${asset.file}` + ); + } + + const reader = response.body?.getReader?.(); + if (!reader) { + throw new Error(`Downloaded tls-client native asset has no readable body: ${asset.file}`); + } + + const chunks = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = Buffer.from(value); + totalBytes += chunk.byteLength; + try { + assertNativeAssetSize(totalBytes, `Downloaded tls-client native asset: ${asset.file}`); + } catch (err) { + // Cancellation is advisory after the size violation; preserve the actionable size error. + await reader.cancel().catch(() => {}); + throw err; + } + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks, totalBytes); + assertNativeAssetSize(bytes.length, `Downloaded tls-client native asset: ${asset.file}`); + return bytes; +} - if (existsSync(binaryPath) && !isVerifiedBinary(binaryPath, asset)) { +async function downloadPinnedAssetWithRetry( + rootTlsClientDir, + trustedRoot, + asset, + version, + retryDelaysMs, + downloadTimeoutMs, + fetchImpl, + log +) { + const binaryPath = join(rootTlsClientDir, "bin", asset.file); + assertSafeDestinationAncestors(trustedRoot, binaryPath); + const initialStats = lstatIfPresent(binaryPath); + if (initialStats?.isSymbolicLink() || (initialStats && !initialStats.isFile())) { + throw new Error(`Unsafe tls-client native path (symlink/non-regular file): ${binaryPath}`); + } + if (initialStats && !isVerifiedBinary(binaryPath, asset)) { + assertSafeDestinationAncestors(trustedRoot, binaryPath); removeIfPresent(binaryPath); + assertSafeDestinationAncestors(trustedRoot, binaryPath); log(` ⚠️ Removed tls-client-node binary with an invalid SHA-256: ${asset.file}`); + assertSafeDestinationAncestors(trustedRoot, binaryPath); + } + + if (typeof fetchImpl !== "function") { + throw new Error("Fetch is unavailable; cannot download the pinned tls-client native asset"); + } + if (!Number.isFinite(downloadTimeoutMs) || downloadTimeoutMs <= 0) { + throw new Error(`Invalid tls-client native download timeout: ${downloadTimeoutMs}`); } + const downloadUrl = pinnedReleaseAssetUrl(version, asset); 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...` + ` ⏳ tls-client-node ${asset.file} still missing — retrying pinned download ` + + `(attempt ${attempt + 1}/${retryDelaysMs.length + 1})...` ); await sleep(retryDelaysMs[attempt - 1]); } + assertSafeDestinationAncestors(trustedRoot, binaryPath); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs); + timeout.unref?.(); try { - const { execFileSync } = await import("node:child_process"); - execFileSync(process.execPath, [postinstallScript], { - cwd: rootTlsClientDir, - env: { - ...process.env, - TLS_CLIENT_SKIP_DOWNLOAD: "0", - TLS_CLIENT_VERSION: version, - }, - stdio: "pipe", - timeout: 30_000, - }); + const response = await fetchImpl(downloadUrl, { signal: controller.signal }); + if (!response?.ok) { + throw new Error(`HTTP ${response?.status ?? "unknown"}`); + } + const bytes = await readBoundedResponseBytes(response, asset); + writeVerifiedBinary(binaryPath, bytes, asset, trustedRoot); } catch (err) { - log(` ⚠️ tls-client-node postinstall attempt failed: ${err.message.split("\n")[0]}`); + assertSafeDestinationAncestors(trustedRoot, binaryPath); + log( + ` ⚠️ Pinned tls-client native download attempt failed for ${asset.file}: ` + + `${err.message.split("\n")[0]}` + ); + assertSafeDestinationAncestors(trustedRoot, binaryPath); + } finally { + clearTimeout(timeout); } + assertSafeDestinationAncestors(trustedRoot, binaryPath); if (isVerifiedBinary(binaryPath, asset)) return true; - if (existsSync(binaryPath)) { + const currentStats = lstatIfPresent(binaryPath); + if (currentStats?.isSymbolicLink() || (currentStats && !currentStats.isFile())) { + throw new Error(`Unsafe tls-client native path after download: ${binaryPath}`); + } + if (currentStats) { + assertSafeDestinationAncestors(trustedRoot, binaryPath); removeIfPresent(binaryPath); - log(` ⚠️ Rejected tls-client-node binary with an invalid SHA-256: ${asset.file}`); + assertSafeDestinationAncestors(trustedRoot, binaryPath); } } return false; } +function normalizeTargetPlatform(platform) { + if (typeof platform !== "string" || !/^[a-z0-9]+$/.test(platform)) { + throw new Error(`Invalid tls-client target platform: ${JSON.stringify(platform)}`); + } + return platform; +} + +function normalizeTargetArches(arches) { + const values = (Array.isArray(arches) ? arches : [arches]) + .flatMap((arch) => (typeof arch === "string" ? arch.split(",") : [])) + .map((arch) => arch.trim()) + .filter(Boolean); + if (values.length === 0 || values.some((arch) => !/^[a-z0-9_-]+$/.test(arch))) { + throw new Error(`Invalid tls-client target arches: ${JSON.stringify(arches)}`); + } + return [...new Set(values)]; +} + +function resolveTargetNativeAsset(platform, arch, nativeAssets) { + const expectedAsset = nativeAssets?.[`${platform}-${arch}`]; + if (!expectedAsset) { + throw new Error(`Unsupported platform for tls-client-node native asset: ${platform}/${arch}`); + } + validateNativeAsset(expectedAsset); + return expectedAsset; +} + +async function fixTlsClientNodeTarget({ + rootDir, + rootTlsClientDir, + distTlsClientDir, + expectedAsset, + targetPlatform, + targetArch, + version, + log, + retryDelaysMs, + downloadTimeoutMs, + fetchImpl, + strict, + standaloneDir, + requireStandalone, + afterSourceStat, +}) { + const rootBinDir = join(rootTlsClientDir, "bin"); + const rootBinaryPath = join(rootBinDir, expectedAsset.file); + + try { + assertSafeDestinationAncestors(rootDir, rootBinaryPath); + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ ${err.message}`); + return; + } + const rootBinaryStats = lstatIfPresent(rootBinaryPath); + if (rootBinaryStats?.isSymbolicLink() || (rootBinaryStats && !rootBinaryStats.isFile())) { + const message = `Unsafe tls-client native source path: ${rootBinaryPath}`; + if (strict) throw new Error(message); + console.warn(` ⚠️ ${message}`); + return; + } + + let rootBinaryVerified; + try { + rootBinaryVerified = Boolean( + readVerifiedBinary(rootBinaryPath, expectedAsset, afterSourceStat) + ); + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ ${err.message}`); + return; + } + + if (!rootBinaryVerified) { + log( + `\n 🔧 tls-client-node native binary missing or unverified — fetching pinned ` + + `v${version} for ${targetPlatform}/${targetArch} and checking SHA-256...\n` + ); + let recovered = false; + try { + recovered = await downloadPinnedAssetWithRetry( + rootTlsClientDir, + rootDir, + expectedAsset, + version, + retryDelaysMs, + downloadTimeoutMs, + fetchImpl, + log + ); + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ Could not recover tls-client-node binary: ${err.message}`); + return; + } + if (!recovered) { + const message = + `Could not fetch tls-client-node v${version} verified native binary ` + + `(${expectedAsset.file}, ${targetPlatform}/${targetArch}) after retries.`; + if (strict) throw new Error(message); + console.warn(`\n ⚠️ ${message} GitHub may be rate-limited or unreachable.`); + console.warn( + " chatgpt-web/claude-web/perplexity-web/grok-web/notion-web/lmarena will " + + "raise a clear TlsClientUnavailableError on first use until this is resolved." + ); + console.warn( + ` Verified repair: node ${join(rootDir, "scripts", "build", "fixTlsClientNodeBinary.mjs")} --strict\n` + ); + return; + } + try { + assertSafeDestinationAncestors(rootDir, rootBinaryPath); + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ ${err.message}`); + return; + } + log(" ✅ tls-client-node native binary fetched successfully!\n"); + try { + assertSafeDestinationAncestors(rootDir, rootBinaryPath); + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ ${err.message}`); + return; + } + } + + if (!isVerifiedBinary(rootBinaryPath, expectedAsset)) { + const message = + `tls-client-node v${version} root native binary failed post-recovery verification ` + + `(${expectedAsset.file}); refusing to copy or seed standalone artifacts.`; + if (strict) throw new Error(message); + console.warn(` ⚠️ ${message}`); + return; + } + + if (existsSync(distTlsClientDir)) { + const distBinaryPath = join(distTlsClientDir, "bin", expectedAsset.file); + try { + assertSafeDestinationAncestors(rootDir, distBinaryPath); + if (!isVerifiedBinary(distBinaryPath, expectedAsset)) { + copyVerifiedBinary(rootBinaryPath, distBinaryPath, expectedAsset, rootDir); + log( + ` ✅ Verified tls-client-node v${version} native binary copied to standalone ` + + "dist/node_modules.\n" + ); + } + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`); + } + } + + if (requireStandalone && !standaloneDir) { + throw new Error("Final standalone artifact path is required for strict verification"); + } + if (standaloneDir) { + const resolvedStandaloneDir = resolve(rootDir, standaloneDir); + if (!existsSync(resolvedStandaloneDir)) { + const message = `Final standalone artifact not found: ${resolvedStandaloneDir}`; + if (requireStandalone || strict) throw new Error(message); + console.warn(` ⚠️ ${message}`); + return; + } + const runtimeBinaryPath = join( + resolvedStandaloneDir, + "runtime-assets", + "tls-client", + "bin", + expectedAsset.file + ); + try { + assertSafeDestinationAncestors(rootDir, runtimeBinaryPath); + if (!isVerifiedBinary(runtimeBinaryPath, expectedAsset)) { + copyVerifiedBinary(rootBinaryPath, runtimeBinaryPath, expectedAsset, rootDir); + } + if (!isVerifiedBinary(runtimeBinaryPath, expectedAsset)) { + throw new Error(`Final standalone runtime seed is unverified: ${runtimeBinaryPath}`); + } + log(` ✅ Verified tls-client native runtime seed: ${runtimeBinaryPath}\n`); + } catch (err) { + if (strict || requireStandalone) throw err; + console.warn(` ⚠️ Could not seed standalone TLS runtime binary: ${err.message}`); + } + } +} + /** * @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) - * @param {NativeAsset} [opts.asset] - injected only for deterministic tests + * @param {number} [opts.downloadTimeoutMs] - timeout for each pinned fetch attempt + * @param {NativeAsset} [opts.asset] - legacy single-target injection for deterministic tests + * @param {Record} [opts.nativeAssets] - manifest injection for tests + * @param {typeof fetch} [opts.fetchImpl] - pinned download boundary, injectable for tests + * @param {string} [opts.platform] - target platform (defaults to the current host) + * @param {string|string[]} [opts.arches] - one or more target arches (defaults to host arch) + * @param {(filePath: string) => void} [opts.afterSourceStat] - deterministic race hook for tests * @param {boolean} [opts.strict] - fail instead of warning (Docker/release builds) + * @param {string} [opts.standaloneDir] - final standalone root to seed and verify + * @param {boolean} [opts.requireStandalone] - fail if standaloneDir does not exist */ export async function fixTlsClientNodeBinary({ rootDir, log = (m) => console.log(m), retryDelaysMs = DEFAULT_RETRY_DELAYS_MS, + downloadTimeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, asset, + nativeAssets = TLS_CLIENT_NATIVE_ASSETS, + fetchImpl = globalThis.fetch, + platform = process.platform, + arches = [process.arch], + afterSourceStat, strict = false, + standaloneDir, + requireStandalone = false, } = {}) { const version = TLS_CLIENT_NATIVE_VERSION; const rootTlsClientDir = join(rootDir, "node_modules", "tls-client-node"); - const rootBinDir = join(rootTlsClientDir, "bin"); const distTlsClientDir = join(rootDir, "dist", "node_modules", "tls-client-node"); + const resolvedStandaloneDir = standaloneDir ? resolve(rootDir, standaloneDir) : undefined; if (!existsSync(rootTlsClientDir)) { if (strict) throw new Error("tls-client-node is not installed; cannot verify native binary"); return; } + if (requireStandalone && !standaloneDir) { + throw new Error("Final standalone artifact path is required for strict verification"); + } + if (resolvedStandaloneDir) { + if (!existsSync(resolvedStandaloneDir)) { + const message = `Final standalone artifact not found: ${resolvedStandaloneDir}`; + if (requireStandalone || strict) throw new Error(message); + console.warn(` ⚠️ ${message}`); + return; + } + } + + let allowedNativeAssets; + let strictBinDirs; + if (strict) { + allowedNativeAssets = collectAllowedNativeAssets(nativeAssets, asset); + const resolvedRootDir = resolve(rootDir); + const configuredNextDistDir = resolve( + resolvedRootDir, + process.env.NEXT_DIST_DIR || ".build/next" + ); + if (pathEscapesRoot(resolvedRootDir, configuredNextDistDir)) { + throw new Error( + `Unsafe NEXT_DIST_DIR outside tls-client trusted root: ${configuredNextDistDir}` + ); + } + const relativeNextDistDir = relative(resolvedRootDir, configuredNextDistDir); + strictBinDirs = [ + join(rootTlsClientDir, "bin"), + join(distTlsClientDir, "bin"), + join(configuredNextDistDir, "node_modules", "tls-client-node", "bin"), + ...(resolvedStandaloneDir + ? [ + join(resolvedStandaloneDir, "node_modules", "tls-client-node", "bin"), + join( + resolvedStandaloneDir, + "projects", + "OmniRoute", + "node_modules", + "tls-client-node", + "bin" + ), + join( + resolvedStandaloneDir, + basename(resolvedRootDir), + "node_modules", + "tls-client-node", + "bin" + ), + join( + resolvedStandaloneDir, + relativeNextDistDir, + "node_modules", + "tls-client-node", + "bin" + ), + join(resolvedStandaloneDir, "runtime-assets", "tls-client", "bin"), + ] + : []), + ]; + for (const binDir of new Set(strictBinDirs)) { + assertNativeAssetDirectoryInventory(rootDir, binDir, allowedNativeAssets); + } + } - let expectedAsset = asset; + let targetPlatform; + let targetArches; try { - expectedAsset ??= resolveTlsClientNativeAsset(); + targetPlatform = normalizeTargetPlatform(platform); + targetArches = normalizeTargetArches(arches); + if (asset && targetArches.length !== 1) { + throw new Error("A synthetic tls-client asset can only be used with one target arch"); + } } catch (err) { if (strict) throw err; console.warn(` ⚠️ ${err.message}`); return; } - const rootBinaryPath = join(rootBinDir, expectedAsset.file); + for (const targetArch of targetArches) { + let expectedAsset; + try { + expectedAsset = asset ?? resolveTargetNativeAsset(targetPlatform, targetArch, nativeAssets); + validateNativeAsset(expectedAsset); + } catch (err) { + if (strict) throw err; + console.warn(` ⚠️ ${err.message}`); + continue; + } - if (!isVerifiedBinary(rootBinaryPath, expectedAsset)) { - log( - `\n 🔧 tls-client-node native binary missing or unverified — fetching pinned ` + - `v${version} and checking SHA-256...\n` - ); - const recovered = await downloadWithRetry( + await fixTlsClientNodeTarget({ + rootDir, rootTlsClientDir, + distTlsClientDir, expectedAsset, + targetPlatform, + targetArch, version, + log, retryDelaysMs, - log - ); - if (!recovered) { - const message = - `Could not fetch tls-client-node v${version} verified native binary ` + - `(${expectedAsset.file}) after retries.`; - if (strict) throw new Error(message); - console.warn(`\n ⚠️ ${message} GitHub may be rate-limited or unreachable.`); - console.warn( - " chatgpt-web/claude-web/perplexity-web/grok-web/notion-web/lmarena will " + - "raise a clear TlsClientUnavailableError on first use until this is resolved." - ); - console.warn( - ` Verified repair: node ${join(rootDir, "scripts", "build", "fixTlsClientNodeBinary.mjs")} --strict\n` - ); - return; - } - log(" ✅ tls-client-node native binary fetched successfully!\n"); + downloadTimeoutMs, + fetchImpl, + strict, + standaloneDir, + requireStandalone, + afterSourceStat, + }); } - if (!existsSync(distTlsClientDir) || !isVerifiedBinary(rootBinaryPath, expectedAsset)) return; + if (strict) { + for (const binDir of new Set(strictBinDirs)) { + assertNativeAssetDirectoryInventory(rootDir, binDir, allowedNativeAssets, true); + } + } +} - const distBinDir = join(distTlsClientDir, "bin"); - const distBinaryPath = join(distBinDir, expectedAsset.file); - if (isVerifiedBinary(distBinaryPath, expectedAsset)) return; +function readCliOptionValues(argv, optionNames) { + const values = []; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + const matchingName = optionNames.find( + (optionName) => argument === optionName || argument.startsWith(`${optionName}=`) + ); + if (!matchingName) continue; - try { - removeIfPresent(distBinaryPath); - mkdirSync(distBinDir, { recursive: true }); - copyFileSync(rootBinaryPath, distBinaryPath); - if (!isVerifiedBinary(distBinaryPath, expectedAsset)) { - removeIfPresent(distBinaryPath); - throw new Error(`SHA-256 mismatch after copying ${expectedAsset.file}`); + if (argument === matchingName) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${matchingName} requires a value`); + } + values.push(value); + index += 1; + } else { + const value = argument.slice(matchingName.length + 1); + if (!value) throw new Error(`${matchingName} requires a value`); + values.push(value); } - log( - ` ✅ Verified tls-client-node v${version} native binary copied to standalone ` + - "dist/node_modules.\n" - ); - } catch (err) { - if (strict) throw err; - console.warn(` ⚠️ Could not copy tls-client-node binary into dist/: ${err.message}`); } + return values; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { + const cliArgs = process.argv.slice(2); + const standaloneValues = readCliOptionValues(cliArgs, ["--standalone-dir"]); + const platformValues = readCliOptionValues(cliArgs, ["--platform"]); + const archValues = readCliOptionValues(cliArgs, ["--arch", "--arches"]); + if (standaloneValues.length > 1) throw new Error("--standalone-dir may only be passed once"); + if (platformValues.length > 1) throw new Error("--platform may only be passed once"); + + const standaloneDir = standaloneValues[0]; await fixTlsClientNodeBinary({ rootDir: process.cwd(), - strict: process.argv.includes("--strict"), + strict: cliArgs.includes("--strict"), + platform: platformValues[0], + arches: archValues.length > 0 ? archValues : undefined, + standaloneDir, + requireStandalone: standaloneValues.length > 0, }); } catch (err) { console.error(` ❌ ${err.message}`); diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 79505024a5a..141e9ee152a 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -6,6 +6,32 @@ * directories out of the staged dist/ tree and out of the final tarball. */ +import { + resolveTlsClientNativeAsset, + TLS_CLIENT_NATIVE_ASSETS, +} from "./fixTlsClientNodeBinary.mjs"; + +export function resolveTlsClientRuntimeSeedPath( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + const asset = resolveTlsClientNativeAsset(platform, arch); + return `runtime-assets/tls-client/bin/${asset.file}`; +} + +export const TLS_CLIENT_RUNTIME_SEED_PATHS: string[] = Object.keys(TLS_CLIENT_NATIVE_ASSETS) + .sort() + .map((target) => { + const separatorIndex = target.indexOf("-"); + if (separatorIndex <= 0 || separatorIndex === target.length - 1) { + throw new Error(`Invalid tls-client native manifest target: ${target}`); + } + return resolveTlsClientRuntimeSeedPath( + target.slice(0, separatorIndex) as NodeJS.Platform, + target.slice(separatorIndex + 1) + ); + }); + const STAGING_FORBIDDEN_DIRECTORIES = [ "app.__qa_backup", "coverage", @@ -34,6 +60,8 @@ export const APP_STAGING_REMOVAL_PATHS: string[] = [ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ ".env.example", "BUILD_SHA", + "LICENSE", + "THIRD_PARTY_NOTICES.md", "docs/openapi.yaml", // #7065: imported by dist/server-ws.mjs; assembleStandalone copies it but without // this bare entry the prepublish prune deleted it → every `omniroute` boot of the @@ -50,6 +78,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "package.json", "peer-stamp.mjs", "main-server-timeouts.mjs", + "open-sse/config/tlsClientNativeManifest.json", // server-ws.mjs import (sd_notify helper) — enforced by the closure test // tests/unit/pack-artifact-server-ws-closure.test.ts. "systemd-notify.mjs", @@ -64,6 +93,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ // this bare entry the prepublish prune (Step 10.7) deletes it → `omniroute serve` // crashes with ERR_MODULE_NOT_FOUND (regressed in the published 3.8.41 tarball). "tls-options.mjs", + ...TLS_CLIENT_RUNTIME_SEED_PATHS, "webdav-handler.mjs", ]; @@ -131,6 +161,8 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ "open-sse/utils/setupPolyfill.ts", "package.json", "scripts/build/assembleStandalone.mjs", + "scripts/build/standaloneSidecarCopy.mjs", + "scripts/build/tlsClientAssetCopy.mjs", "scripts/build/backendOnlyPages.mjs", "scripts/build/build-tproxy-native.mjs", "scripts/build/build-next-isolated.mjs", @@ -179,6 +211,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [ ]; export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ + "dist/LICENSE", + "dist/THIRD_PARTY_NOTICES.md", + "dist/open-sse/config/tlsClientNativeManifest.json", "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", "dist/src/lib/usage/callLogArtifactWorker.js", "dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", @@ -191,6 +226,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ // server-ws.mjs import (sd_notify helper) — enforced by the closure test. "dist/systemd-notify.mjs", "dist/http-method-guard.cjs", + ...TLS_CLIENT_RUNTIME_SEED_PATHS.map((seedPath) => `dist/${seedPath}`), // #5452: regression guard — make check:pack-artifact fail loudly if the TLS // opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball. "dist/tls-options.mjs", @@ -229,6 +265,8 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "scripts/build/colocateOptionals.mjs", "scripts/build/fixTlsClientNodeBinary.mjs", "scripts/build/runtime-env.mjs", + "scripts/build/standaloneSidecarCopy.mjs", + "scripts/build/tlsClientAssetCopy.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", diff --git a/scripts/build/prepare-electron-standalone.mjs b/scripts/build/prepare-electron-standalone.mjs index b929a942fbe..804e09e3dc5 100644 --- a/scripts/build/prepare-electron-standalone.mjs +++ b/scripts/build/prepare-electron-standalone.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { assembleStandalone } from "./assembleStandalone.mjs"; import { assertSqlitePrebuildExists } from "./electronRebuildPlan.mjs"; import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs"; +import { fixTlsClientNodeBinary } from "./fixTlsClientNodeBinary.mjs"; import { stageOptionalPacks } from "./optionalPackStaging.mjs"; import { runBuildTool } from "./buildToolRunner.mjs"; @@ -17,6 +18,14 @@ const NEXT_DIST_DIR = process.env.NEXT_DIST_DIR || ".build/next"; const DIST_DIR = join(ROOT, NEXT_DIST_DIR); const STANDALONE_DIR = join(DIST_DIR, "standalone"); const ELECTRON_STANDALONE_DIR = join(ROOT, ".build", "electron-standalone"); +const ELECTRON_TARGET_PLATFORM = process.env.OMNIROUTE_ELECTRON_TARGET_PLATFORM ?? process.platform; +const configuredTargetArches = process.env.OMNIROUTE_ELECTRON_TARGET_ARCHES; +const ELECTRON_TARGET_ARCHES = + configuredTargetArches === undefined + ? ELECTRON_TARGET_PLATFORM === "linux" + ? ["x64", "arm64"] + : [process.arch] + : configuredTargetArches.split(",").map((arch) => arch.trim()); // --- Electron-UNIQUE: resolve the nested server.js location ---------------- @@ -152,6 +161,15 @@ process.on("uncaughtException", logContextualError); const bundleDir = resolveStandaloneBundleDir(); assertBundleIsPackagable(bundleDir); +await fixTlsClientNodeBinary({ + rootDir: ROOT, + strict: true, + platform: ELECTRON_TARGET_PLATFORM, + arches: ELECTRON_TARGET_ARCHES, + standaloneDir: STANDALONE_DIR, + requireStandalone: true, +}); + // Clean the stage dir before assembly rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true }); @@ -236,6 +254,15 @@ await stageOptionalPacks({ log: (msg) => console.log(msg.replace(/^\[optional-packs\]/, "[electron]")), }); +await fixTlsClientNodeBinary({ + rootDir: ROOT, + strict: true, + platform: ELECTRON_TARGET_PLATFORM, + arches: ELECTRON_TARGET_ARCHES, + standaloneDir: ELECTRON_STANDALONE_DIR, + requireStandalone: true, +}); + console.log( `[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}` ); diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index 41d6e867b37..fadb96cc621 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url"; import { assembleStandalone } from "./assembleStandalone.mjs"; import { isNativeExecutable, resolveLocalBinEntry } from "./buildToolRunner.mjs"; +import { fixTlsClientNodeBinary, TLS_CLIENT_NATIVE_ASSETS } from "./fixTlsClientNodeBinary.mjs"; import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts"; import { APP_STAGING_ALLOWED_EXACT_PATHS, @@ -88,6 +89,31 @@ function runBuildTool( const DIST_DIR = join(ROOT, "dist"); const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n'; +const TLS_CLIENT_ARCHES_BY_PLATFORM = Object.keys(TLS_CLIENT_NATIVE_ASSETS).reduce< + Record +>((targets, target) => { + const separatorIndex = target.indexOf("-"); + if (separatorIndex <= 0 || separatorIndex === target.length - 1) { + throw new Error(`Invalid tls-client native manifest target: ${target}`); + } + const platform = target.slice(0, separatorIndex); + const arch = target.slice(separatorIndex + 1); + (targets[platform] ??= []).push(arch); + return targets; +}, {}); + +async function verifyAllTlsClientRuntimeSeeds(targetStandaloneDir: string): Promise { + for (const [platform, arches] of Object.entries(TLS_CLIENT_ARCHES_BY_PLATFORM)) { + await fixTlsClientNodeBinary({ + rootDir: ROOT, + platform, + arches, + standaloneDir: targetStandaloneDir, + strict: true, + requireStandalone: true, + }); + } +} function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] { let entries: string[] = []; @@ -169,7 +195,8 @@ if (existsSync(DIST_DIR)) { // .build/next/standalone artifact produced by `npm run build` (build-next-isolated.mjs). // If the artifact is absent we invoke it exactly once. const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next"; -const standaloneServerJs = join(ROOT, NEXT_DIST, "standalone", "server.js"); +const standaloneDir = join(ROOT, NEXT_DIST, "standalone"); +const standaloneServerJs = join(standaloneDir, "server.js"); if (!existsSync(standaloneServerJs)) { console.log(" 🏗️ .build/next/standalone not found — running `npm run build` once..."); execFileSync(process.execPath, ["scripts/build/build-next-isolated.mjs"], { @@ -187,6 +214,9 @@ if (!existsSync(standaloneServerJs)) { } console.log(" ✅ Standalone artifact present:", standaloneServerJs); +console.log(" 🔐 Verifying every pinned TLS client runtime seed..."); +await verifyAllTlsClientRuntimeSeeds(standaloneDir); + // ── Step 3–7: Assemble standalone into dist/ ─────────────── // All shared copy/sync/sanitize/chunk-patch operations are delegated to // assembleStandalone. npm-UNIQUE steps (MITM, MCP, CLI, sidecars) follow. @@ -707,6 +737,9 @@ if (remainingUnexpectedFiles.length > 0) { process.exit(1); } +console.log(" 🔐 Re-verifying every staged TLS client runtime seed after pruning..."); +await verifyAllTlsClientRuntimeSeeds(DIST_DIR); + // ── Done ─────────────────────────────────────────────────── const distPkg = join(DIST_DIR, "package.json"); if (existsSync(distPkg)) { diff --git a/scripts/build/standaloneSidecarCopy.mjs b/scripts/build/standaloneSidecarCopy.mjs new file mode 100644 index 00000000000..105dbfe2dde --- /dev/null +++ b/scripts/build/standaloneSidecarCopy.mjs @@ -0,0 +1,121 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { copyVerifiedTlsClientNativeAsset } from "./tlsClientAssetCopy.mjs"; + +/** + * A bulk standalone copy may already have carried a source symlink or a stale + * node with the wrong type into a sidecar destination. Skip an identical real + * target; otherwise clear the direct stale node before the explicit copy. + */ +export function resolvesToSamePath(src, dest) { + if (path.resolve(src) === path.resolve(dest)) return true; + if (!fs.existsSync(dest)) return false; + try { + return fs.realpathSync(src) === fs.realpathSync(dest); + } catch { + // An unresolved path cannot be proven identical, so use the normal stale-destination copy path. + return false; + } +} + +export function clearStaleDest(dest) { + try { + fs.lstatSync(dest); + } catch { + // A missing destination is clear; later copy operations surface non-ENOENT access failures. + return; + } + fs.rmSync(dest, { recursive: true, force: true }); +} + +/** + * Copy registered native assets and runtime sidecars into an assembled bundle. + * TLS entries take the digest-verified path; ordinary entries retain the + * existing recursive-copy behavior. + * + * @param {{projectRoot:string, outDir:string, nativeAssetEntries:{label:string,src:string[],dest:string[],tlsClientSha256?:string}[], extraModuleEntries:{label:string,src:string[],dest:string[]}[]}} options + */ +export function copyNativeAssetsAndExtraModules({ + projectRoot, + outDir, + nativeAssetEntries, + extraModuleEntries, +}) { + for (const asset of nativeAssetEntries) { + const src = path.join(projectRoot, ...asset.src); + const dest = path.join(outDir, ...asset.dest); + if (asset.tlsClientSha256) { + const copied = copyVerifiedTlsClientNativeAsset({ + sourceRoot: projectRoot, + sourcePath: src, + destinationPath: dest, + expectedSha256: asset.tlsClientSha256, + outDir, + }); + if (copied) console.log(`[assembleStandalone] Copied verified native asset: ${asset.label}`); + continue; + } + if (!fs.existsSync(src) || resolvesToSamePath(src, dest)) continue; + clearStaleDest(dest); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.cpSync(src, dest, { recursive: true, force: true }); + console.log(`[assembleStandalone] Copied native asset: ${asset.label}`); + } + + for (const mod of extraModuleEntries) { + const src = path.join(projectRoot, ...mod.src); + if (!fs.existsSync(src)) continue; + const dest = path.join(outDir, ...mod.dest); + if (resolvesToSamePath(src, dest)) continue; + clearStaleDest(dest); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.cpSync(src, dest, { recursive: true, force: true }); + console.log(`[assembleStandalone] Synced module: ${mod.label}`); + } +} + +/** Repair hollow top-level external package directories emitted by Next/Turbopack. */ +export function repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules) { + const summary = { repaired: 0, packages: [] }; + const sourceNodeModules = path.join(projectRoot, "node_modules"); + if (!fs.existsSync(bundleNodeModules) || !fs.existsSync(sourceNodeModules)) return summary; + + for (const name of fs.readdirSync(bundleNodeModules)) { + if (name.startsWith(".") || name.startsWith("@")) continue; + const bundlePkgDir = path.join(bundleNodeModules, name); + const sourcePkgDir = path.join(sourceNodeModules, name); + + let bundleStat; + try { + bundleStat = fs.statSync(bundlePkgDir); + } catch { + // An unreadable or vanished entry cannot be classified safely as a hollow repair candidate. + continue; + } + if (!bundleStat.isDirectory()) continue; + + let bundleEntries; + try { + bundleEntries = fs.readdirSync(bundlePkgDir); + } catch { + // Without a readable listing we cannot prove the destination is hollow enough to replace. + continue; + } + if (bundleEntries.length > 0 || !fs.existsSync(sourcePkgDir)) continue; + + let sourceStat; + try { + sourceStat = fs.statSync(sourcePkgDir); + } catch { + // An unreadable or vanished source cannot safely repair this optional package copy. + continue; + } + if (!sourceStat.isDirectory() || resolvesToSamePath(sourcePkgDir, bundlePkgDir)) continue; + clearStaleDest(bundlePkgDir); + fs.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true }); + summary.repaired += 1; + summary.packages.push(name); + } + return summary; +} diff --git a/scripts/build/tlsClientAssetCopy.mjs b/scripts/build/tlsClientAssetCopy.mjs new file mode 100644 index 00000000000..4a7f32f33c1 --- /dev/null +++ b/scripts/build/tlsClientAssetCopy.mjs @@ -0,0 +1,446 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const MAX_NATIVE_ASSET_BYTES = 64 * 1024 * 1024; +const READ_CHUNK_BYTES = 64 * 1024; + +/** + * Convert the pinned manifest into copy entries while validating every name and + * digest. Keeping the digest on the entry makes the async and sync assemblers + * consume one source of truth. + * + * @param {Record} nativeAssets + * @returns {{label:string, src:string[], dest:string[], tlsClientSha256:string}[]} + */ +export function createTlsClientNativeAssetEntries(nativeAssets) { + const entriesByFile = new Map(); + for (const asset of Object.values(nativeAssets)) { + const file = asset?.file; + if ( + typeof file !== "string" || + file.length === 0 || + file === "." || + file === ".." || + path.basename(file) !== file || + file.includes("/") || + file.includes("\\") || + file.includes("\0") + ) { + throw new Error(`Invalid tls-client native asset path: ${JSON.stringify(file)}`); + } + if (!/^[a-f0-9]{64}$/.test(asset.sha256)) { + throw new Error(`Invalid SHA-256 in tls-client native manifest for ${file}`); + } + const prior = entriesByFile.get(file); + if (prior && prior.tlsClientSha256 !== asset.sha256) { + throw new Error(`Conflicting SHA-256 values in tls-client native manifest for ${file}`); + } + entriesByFile.set(file, { + label: `manifest-declared tls-client native runtime seed (${file})`, + src: ["node_modules", "tls-client-node", "bin", file], + // Keep the public bootstrap seed outside DATA_DIR: Docker deployments + // commonly mount an empty /app/data volume, which must not hide it. + dest: ["runtime-assets", "tls-client", "bin", file], + tlsClientSha256: asset.sha256, + }); + } + return [...entriesByFile.values()]; +} + +function lstatIfPresent(filePath) { + try { + return fs.lstatSync(filePath); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + throw error; + } +} + +function sameFileIdentity(left, right) { + return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino); +} + +function assertNativeAssetSize(size, filePath) { + if (!Number.isSafeInteger(size) || size < 0 || size > MAX_NATIVE_ASSET_BYTES) { + throw new Error( + `[assembleStandalone] tls-client native asset exceeds the 64 MiB limit: ${filePath}` + ); + } +} + +/** + * Read a native seed through a no-follow descriptor and cap the read itself, + * not merely the initial stat. This also detects path replacement during the + * read before a digest can authorize the bytes. + * + * @param {string} filePath + * @returns {Buffer|undefined} + */ +function readBoundedRegularAsset(filePath) { + const pathStats = lstatIfPresent(filePath); + if (!pathStats) return undefined; + if (pathStats.isSymbolicLink() || !pathStats.isFile()) { + throw new Error( + `[assembleStandalone] unsafe tls-client native asset (symlink/non-regular file): ${filePath}` + ); + } + + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + try { + const openedStats = fs.fstatSync(fd); + if (!openedStats.isFile() || !sameFileIdentity(pathStats, openedStats)) { + throw new Error( + `[assembleStandalone] unsafe tls-client native asset changed before read: ${filePath}` + ); + } + assertNativeAssetSize(openedStats.size, filePath); + + const chunks = []; + let totalBytes = 0; + while (true) { + const remainingWithSentinel = MAX_NATIVE_ASSET_BYTES - totalBytes + 1; + const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remainingWithSentinel)); + const bytesRead = fs.readSync(fd, chunk, 0, chunk.byteLength, null); + if (bytesRead === 0) break; + totalBytes += bytesRead; + assertNativeAssetSize(totalBytes, filePath); + chunks.push(chunk.subarray(0, bytesRead)); + } + + const finalDescriptorStats = fs.fstatSync(fd); + const finalPathStats = fs.lstatSync(filePath); + if ( + !finalDescriptorStats.isFile() || + finalPathStats.isSymbolicLink() || + !finalPathStats.isFile() || + !sameFileIdentity(openedStats, finalDescriptorStats) || + !sameFileIdentity(openedStats, finalPathStats) + ) { + throw new Error( + `[assembleStandalone] unsafe tls-client native asset changed during read: ${filePath}` + ); + } + assertNativeAssetSize(finalDescriptorStats.size, filePath); + if (finalDescriptorStats.size !== totalBytes) { + throw new Error( + `[assembleStandalone] tls-client native asset size changed during read: ${filePath}` + ); + } + return Buffer.concat(chunks, totalBytes); + } finally { + fs.closeSync(fd); + } +} + +function assertDigest(bytes, expectedSha256, filePath) { + const actualSha256 = createHash("sha256").update(bytes).digest("hex"); + if (actualSha256 !== expectedSha256) { + throw new Error( + `[assembleStandalone] SHA-256 mismatch for tls-client native asset: ${filePath}` + ); + } +} + +function assertSafeExistingDestinationMode(filePath) { + if (process.platform === "win32") return; + const mode = fs.lstatSync(filePath).mode & 0o777; + if (mode !== 0o555) { + throw new Error( + `[assembleStandalone] unsafe tls-client native destination mode ` + + `(expected 0555, received ${mode.toString(8).padStart(4, "0")}): ${filePath}` + ); + } +} + +function assertContainedPath(rootPath, candidatePath, label) { + const root = path.resolve(rootPath); + const candidate = path.resolve(candidatePath); + const relativeCandidate = path.relative(root, candidate); + if ( + relativeCandidate === "" || + path.isAbsolute(relativeCandidate) || + relativeCandidate === ".." || + relativeCandidate.startsWith(`..${path.sep}`) + ) { + throw new Error(`[assembleStandalone] unsafe tls-client native ${label}: ${candidate}`); + } + return { root, candidate, relativeCandidate }; +} + +function pathEscapesRoot(rootPath, candidatePath) { + const relativePath = path.relative(rootPath, candidatePath); + return ( + path.isAbsolute(relativePath) || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) + ); +} + +function readSafeDirectoryIdentity(directoryPath, label) { + const initialStats = lstatIfPresent(directoryPath); + if (!initialStats) return undefined; + if (initialStats.isSymbolicLink() || !initialStats.isDirectory()) { + throw new Error( + `[assembleStandalone] unsafe tls-client native ${label} ` + + `(symlink/non-directory): ${directoryPath}` + ); + } + const canonicalPath = fs.realpathSync(directoryPath); + const finalStats = fs.lstatSync(directoryPath); + if ( + finalStats.isSymbolicLink() || + !finalStats.isDirectory() || + !sameFileIdentity(initialStats, finalStats) + ) { + throw new Error( + `[assembleStandalone] tls-client native ${label} changed during verification: ${directoryPath}` + ); + } + return canonicalPath; +} + +function assertSafeAncestorChain( + rootPath, + candidatePath, + { label, createMissing = false, missingIsError = true } +) { + const { root, relativeCandidate } = assertContainedPath( + rootPath, + candidatePath, + `${label} outside trusted root` + ); + if (!lstatIfPresent(root) && createMissing) fs.mkdirSync(root, { recursive: true }); + const canonicalRoot = readSafeDirectoryIdentity(root, `${label} root`); + if (!canonicalRoot) { + if (!missingIsError) return false; + throw new Error(`[assembleStandalone] tls-client native ${label} root missing: ${root}`); + } + + let current = root; + for (const component of path.dirname(relativeCandidate).split(path.sep).filter(Boolean)) { + current = path.join(current, component); + if (!lstatIfPresent(current) && createMissing) fs.mkdirSync(current); + const canonicalCurrent = readSafeDirectoryIdentity(current, `${label} ancestor`); + if (!canonicalCurrent) { + if (!missingIsError) return false; + throw new Error( + `[assembleStandalone] tls-client native ${label} ancestor missing: ${current}` + ); + } + if (pathEscapesRoot(canonicalRoot, canonicalCurrent)) { + throw new Error( + `[assembleStandalone] unsafe tls-client native ${label} ancestor outside root: ${current}` + ); + } + } + return true; +} + +function assertSafeSourceAncestors(sourceRoot, sourcePath) { + assertSafeAncestorChain(sourceRoot, sourcePath, { label: "source" }); +} + +function ensureSafeDestinationParent(outDir, destinationPath) { + assertSafeAncestorChain(outDir, destinationPath, { + label: "destination", + createMissing: true, + }); +} + +function revalidateSafeDestinationParent(outDir, destinationPath) { + return assertSafeAncestorChain(outDir, destinationPath, { + label: "destination", + missingIsError: false, + }); +} + +function assertStillSafeDestinationParent(outDir, destinationPath) { + if (!revalidateSafeDestinationParent(outDir, destinationPath)) { + throw new Error( + `[assembleStandalone] tls-client native destination ancestor disappeared: ${destinationPath}` + ); + } +} + +function removeDirectDestinationSafely(outDir, destinationPath) { + try { + if (!revalidateSafeDestinationParent(outDir, destinationPath)) return false; + if (!lstatIfPresent(destinationPath)) return true; + if (!revalidateSafeDestinationParent(outDir, destinationPath)) return false; + fs.rmSync(destinationPath, { recursive: true, force: true }); + return revalidateSafeDestinationParent(outDir, destinationPath); + } catch { + // The chain may now resolve through a symlink. Do not traverse it merely + // to clean up: preserving an external file is safer than an unsafe rmSync. + return false; + } +} + +function auditNativeBin(outDir, binDir, assetsByFile) { + const auditSentinel = path.join(binDir, ".tls-client-native-audit"); + if (!revalidateSafeDestinationParent(outDir, auditSentinel)) return; + + for (const entryName of fs.readdirSync(binDir)) { + const entryPath = path.join(binDir, entryName); + try { + const expectedSha256 = assetsByFile.get(entryName); + if (!expectedSha256) { + throw new Error(`[assembleStandalone] unlisted tls-client native sibling: ${entryPath}`); + } + assertStillSafeDestinationParent(outDir, entryPath); + const bytes = readBoundedRegularAsset(entryPath); + assertStillSafeDestinationParent(outDir, entryPath); + if (!bytes) { + throw new Error( + `[assembleStandalone] tls-client native bundle entry disappeared: ${entryPath}` + ); + } + assertDigest(bytes, expectedSha256, entryPath); + assertSafeExistingDestinationMode(entryPath); + assertStillSafeDestinationParent(outDir, entryPath); + } catch (error) { + removeDirectDestinationSafely(outDir, entryPath); + throw error; + } + } + assertStillSafeDestinationParent(outDir, auditSentinel); +} + +/** + * Audit every tls-client bin topology that Next.js may bulk-copy into a + * standalone output. This prevents a valid manifest filename with unauthorized + * bytes from surviving outside runtime-assets merely because a later platform + * gate verifies only selected target arches. + * + * @param {{outDir:string, projectRoot:string, relativeNextDistDir:string, nativeAssets:Record}} options + */ +export function auditTlsClientStandaloneBundle({ + outDir, + projectRoot, + relativeNextDistDir, + nativeAssets, +}) { + const assetsByFile = new Map( + createTlsClientNativeAssetEntries(nativeAssets).map((entry) => [ + entry.src.at(-1), + entry.tlsClientSha256, + ]) + ); + const projectBasename = path.basename(path.resolve(projectRoot)); + const binDirs = [ + path.join(outDir, "node_modules", "tls-client-node", "bin"), + path.join(outDir, "projects", "OmniRoute", "node_modules", "tls-client-node", "bin"), + path.join(outDir, projectBasename, "node_modules", "tls-client-node", "bin"), + path.join(outDir, relativeNextDistDir, "node_modules", "tls-client-node", "bin"), + path.join(outDir, "runtime-assets", "tls-client", "bin"), + ]; + for (const binDir of new Set(binDirs)) auditNativeBin(outDir, binDir, assetsByFile); +} + +/** + * Copy only bytes authorized by the pinned digest, then independently read and + * verify the emitted file. Any failed verification removes the direct output so + * a failed assembly cannot leave a distributable manifest-named seed behind. + * + * @param {{sourceRoot:string, sourcePath:string, destinationPath:string, expectedSha256:string, outDir:string}} options + * @returns {boolean} true only when source bytes were copied + */ +export function copyVerifiedTlsClientNativeAsset({ + sourceRoot, + sourcePath, + destinationPath, + expectedSha256, + outDir, +}) { + ensureSafeDestinationParent(outDir, destinationPath); + try { + const sourcePathStats = lstatIfPresent(sourcePath); + let sourceBytes; + if (sourcePathStats) { + assertSafeSourceAncestors(sourceRoot, sourcePath); + sourceBytes = readBoundedRegularAsset(sourcePath); + assertSafeSourceAncestors(sourceRoot, sourcePath); + if (!sourceBytes) { + throw new Error( + `[assembleStandalone] tls-client native source disappeared during verification: ${sourcePath}` + ); + } + } + if (!sourceBytes) { + // assembleStandalone first bulk-copies a prior Next standalone tree. + // If the source install is now absent, a manifest-named seed may still + // have arrived through that pass; authorize it independently or fail and + // remove it instead of silently distributing stale bytes. + assertStillSafeDestinationParent(outDir, destinationPath); + const existingDestinationBytes = readBoundedRegularAsset(destinationPath); + assertStillSafeDestinationParent(outDir, destinationPath); + if (!existingDestinationBytes) return false; + assertDigest(existingDestinationBytes, expectedSha256, destinationPath); + assertSafeExistingDestinationMode(destinationPath); + assertStillSafeDestinationParent(outDir, destinationPath); + return false; + } + assertDigest(sourceBytes, expectedSha256, sourcePath); + + assertStillSafeDestinationParent(outDir, destinationPath); + const destinationStats = lstatIfPresent(destinationPath); + if (destinationStats?.isSymbolicLink() || (destinationStats && !destinationStats.isFile())) { + throw new Error( + `[assembleStandalone] unsafe tls-client native destination (symlink/non-regular file): ${destinationPath}` + ); + } + if (!removeDirectDestinationSafely(outDir, destinationPath)) { + throw new Error( + `[assembleStandalone] unsafe tls-client native destination changed before removal: ${destinationPath}` + ); + } + assertStillSafeDestinationParent(outDir, destinationPath); + + const fd = fs.openSync( + destinationPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_NOFOLLOW ?? 0), + 0o555 + ); + try { + assertStillSafeDestinationParent(outDir, destinationPath); + let offset = 0; + while (offset < sourceBytes.length) { + const bytesWritten = fs.writeSync(fd, sourceBytes, offset, sourceBytes.length - offset); + if (bytesWritten <= 0) { + throw new Error( + `[assembleStandalone] failed to write tls-client native asset: ${destinationPath}` + ); + } + offset += bytesWritten; + } + if (process.platform !== "win32") fs.fchmodSync(fd, 0o555); + assertStillSafeDestinationParent(outDir, destinationPath); + } finally { + fs.closeSync(fd); + } + + assertStillSafeDestinationParent(outDir, destinationPath); + const destinationBytes = readBoundedRegularAsset(destinationPath); + assertStillSafeDestinationParent(outDir, destinationPath); + if (!destinationBytes) { + throw new Error( + `[assembleStandalone] copied tls-client native asset disappeared: ${destinationPath}` + ); + } + assertDigest(destinationBytes, expectedSha256, destinationPath); + if (!sourceBytes.equals(destinationBytes)) { + throw new Error( + `[assembleStandalone] copied tls-client native asset differs from source: ${destinationPath}` + ); + } + assertStillSafeDestinationParent(outDir, destinationPath); + return true; + } catch (error) { + removeDirectDestinationSafely(outDir, destinationPath); + throw error; + } +} diff --git a/scripts/check/check-licenses.mjs b/scripts/check/check-licenses.mjs index 3c265144bcf..f5ccd57747f 100644 --- a/scripts/check/check-licenses.mjs +++ b/scripts/check/check-licenses.mjs @@ -35,7 +35,7 @@ const PRINT_JSON = process.argv.includes("--json"); /** * Loads and returns the license allowlist from .license-allowlist.json. * - * @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record }} + * @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record }} */ export function loadAllowlist() { if (!fs.existsSync(ALLOWLIST_PATH)) { @@ -59,34 +59,116 @@ export function loadAllowlist() { /** * Classifies a package+license against the allowlist. * - * @param {string} packageName - Package name without version, e.g. "lightningcss" + * @param {string} packageName - Package key including version when available, e.g. "tls-client-node@0.2.0" * @param {string} license - License string from license-checker, e.g. "MPL-2.0" * @param {{ allowed: string[], allowedExpressions: string[], exceptions: Record }} allowlist + * @param {{ now?: Date }} [options] * @returns {{ status: "allowed" | "exception" | "denied", reason: string }} */ -export function classifyLicense(packageName, license, allowlist) { +export function classifyLicense(packageName, license, allowlist, { now = new Date() } = {}) { const { allowed, allowedExpressions, exceptions } = allowlist; - - // 1. Direct SPDX match - if (allowed.includes(license)) { - return { status: "allowed", reason: `SPDX match: ${license}` }; - } - - // 2. Expression match (e.g. "(MIT OR Apache-2.0)") - if (allowedExpressions.includes(license)) { - return { status: "allowed", reason: `allowed expression: ${license}` }; - } - - // 3. Per-package exception (strip version suffix for lookup) const baseName = stripVersion(packageName); + + // 1. A package-specific exception is an overlay on the global policy. It + // must be evaluated first so a newly detected globally allowed SPDX id + // cannot bypass the exception's exact-license, ownership, or expiry gates. if (exceptions[baseName]) { const exc = exceptions[baseName]; + if (typeof exc.license !== "string" || !exc.license.trim()) { + return { + status: "denied", + reason: `invalid exception license for '${baseName}': expected a non-empty string`, + }; + } + if (license !== exc.license) { + return { + status: "denied", + reason: `license '${license}' does not match exception license '${exc.license}' for '${baseName}'`, + }; + } + if (Object.prototype.hasOwnProperty.call(exc, "version")) { + if ( + typeof exc.version !== "string" || + !exc.version.trim() || + exc.version !== exc.version.trim() || + /[@\s]/.test(exc.version) + ) { + return { + status: "denied", + reason: `invalid exception version for '${baseName}': expected an exact non-empty package version`, + }; + } + + const versionSuffix = packageName.slice(baseName.length); + if (!versionSuffix) { + return { + status: "denied", + reason: `version-pinned exception for '${baseName}' requires package key '${baseName}@${exc.version}', but '${packageName}' has no version`, + }; + } + const detectedVersion = versionSuffix.startsWith("@") ? versionSuffix.slice(1) : ""; + if (!detectedVersion || /[@\s]/.test(detectedVersion)) { + return { + status: "denied", + reason: `version-pinned exception for '${baseName}' cannot evaluate malformed package key '${packageName}'`, + }; + } + if (detectedVersion !== exc.version) { + return { + status: "denied", + reason: `package version '${detectedVersion}' does not match exception version '${exc.version}' for '${baseName}'`, + }; + } + } + if (exc.temporary === true) { + if (typeof exc.owner !== "string" || !exc.owner.trim()) { + return { + status: "denied", + reason: `temporary exception for '${baseName}' has no owner`, + }; + } + const reviewBy = exc.reviewBy; + const isDateOnly = typeof reviewBy === "string" && /^\d{4}-\d{2}-\d{2}$/.test(reviewBy); + const deadline = isDateOnly ? new Date(`${reviewBy}T23:59:59.999Z`) : new Date(NaN); + const isRealCalendarDate = + isDateOnly && + !Number.isNaN(deadline.getTime()) && + deadline.toISOString().slice(0, 10) === reviewBy; + if (!isRealCalendarDate) { + return { + status: "denied", + reason: `temporary exception for '${baseName}' has invalid reviewBy metadata`, + }; + } + if (!(now instanceof Date) || Number.isNaN(now.getTime())) { + return { + status: "denied", + reason: `temporary exception for '${baseName}' cannot be evaluated with an invalid clock`, + }; + } + if (now.getTime() > deadline.getTime()) { + return { + status: "denied", + reason: `temporary exception for '${baseName}' expired at reviewBy=${reviewBy}`, + }; + } + } return { status: "exception", reason: `exception: ${exc.justification} [risk=${exc.risk}]`, }; } + // 2. Direct SPDX match + if (allowed.includes(license)) { + return { status: "allowed", reason: `SPDX match: ${license}` }; + } + + // 3. Expression match (e.g. "(MIT OR Apache-2.0)") + if (allowedExpressions.includes(license)) { + return { status: "allowed", reason: `allowed expression: ${license}` }; + } + // 4. Denied return { status: "denied", diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 77129ec05bf..4392e7b71ee 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { getProviderNodeById } from "@/models"; @@ -8,10 +10,10 @@ import { isAnthropicCompatibleProvider, } from "@/shared/constants/providers"; import { validateProviderApiKey } from "@/lib/providers/validation"; -import { getProxyForLevel, resolveProxyForProvider } from "@/lib/localDb"; +import { resolveProxyForProvider } from "@/lib/db/proxies"; +import { getProxyForLevel } from "@/lib/db/settings"; import { validateProviderApiKeySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts"; function sanitizeAuditUrl(url: string | null | undefined) { if (!url) return null; @@ -169,7 +171,7 @@ export async function POST(request) { providerSpecificData: result.providerSpecificData || null, }); } catch (error) { - console.log("Error validating API key:", error); + console.log("Error validating API key:", sanitizeErrorMessage(error) || "Validation failed"); return NextResponse.json({ error: "Validation failed" }, { status: 500 }); } } diff --git a/src/lib/logPayloads.ts b/src/lib/logPayloads.ts index f97e3382599..bdc732440b6 100644 --- a/src/lib/logPayloads.ts +++ b/src/lib/logPayloads.ts @@ -35,6 +35,21 @@ const SENSITIVE_KEYS = new Set([ "runtimeKey", ]); +const SENSITIVE_CHALLENGE_KEYS = new Set([ + "recaptchav3token", + "recaptchatoken", + "turnstiletoken", + "prooftoken", + "resumetoken", + "preparetoken", +]); + +function isSensitivePayloadKey(key: string): boolean { + if (SENSITIVE_KEYS.has(key)) return true; + const normalizedKey = key.replace(/[-_]/g, "").toLowerCase(); + return SENSITIVE_CHALLENGE_KEYS.has(normalizedKey); +} + type JsonRecord = Record; const ENCRYPTED_REASONING_KEY = "encrypted_content"; @@ -125,7 +140,7 @@ export function redactPayload(payload: unknown): unknown { const redacted: JsonRecord = {}; for (const [key, value] of Object.entries(payload)) { - if (SENSITIVE_KEYS.has(key)) { + if (isSensitivePayloadKey(key)) { redacted[key] = "[REDACTED]"; } else if (typeof value === "string" && value.startsWith("Bearer ")) { redacted[key] = "Bearer [REDACTED]"; diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index c486ff41fc7..63c35eff4b1 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -1,6 +1,8 @@ // Outbound fetch wrappers for provider validation: proxy-fallback, SSRF-aware proxy targeting, and -// error→result mapping. Extracted from validation.ts (god-file decomposition). Behavior is -// byte-identical to the original inline defs. +// error→result mapping. Extracted from validation.ts (god-file decomposition) and kept as the +// common boundary for sanitizing validation failures. +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts"; import { SAFE_OUTBOUND_FETCH_PRESETS, SafeOutboundFetchError, @@ -9,7 +11,22 @@ import { } from "@/shared/network/safeOutboundFetch"; import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; -import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts"; + +export function readProxyFallbackErrorState(error: unknown): { + isNetworkIssue: boolean; + isRetryable: boolean; +} { + try { + const fetchError = error as { code?: unknown; isRetryable?: unknown } | null | undefined; + return { + isNetworkIssue: fetchError?.code === "NETWORK_ERROR" || fetchError?.code === "TIMEOUT", + isRetryable: fetchError?.isRetryable !== false, + }; + } catch { + // Hostile accessors are not trustworthy enough to authorize a second outbound attempt. + return { isNetworkIssue: false, isRetryable: false }; + } +} /** * Wrapped fetch call that auto-retries with a proxy when the direct connection @@ -31,9 +48,7 @@ export async function fetchWithProxyFallback( } catch (err: unknown) { // Only attempt proxy fallback for retryable errors (network / timeout) // and only when the target is not a local / LAN address. - const fetchErr = err as SafeOutboundFetchError; - const isNetworkIssue = fetchErr?.code === "NETWORK_ERROR" || fetchErr?.code === "TIMEOUT"; - const isRetryable = fetchErr?.isRetryable !== false; + const { isNetworkIssue, isRetryable } = readProxyFallbackErrorState(err); const isValidTarget = !isLocal && isRetryableProxyTarget(url); if (isLocal || !isNetworkIssue || !isRetryable) throw err; @@ -80,15 +95,15 @@ export async function validationWrite(url: string, init: RequestInit, isLocal: b // (#3288 / #3758). Only treat a blocked redirect as a security event when its target is // a private/internal host. export function isSecurityBlockError(error: unknown): boolean { - if (!(error instanceof SafeOutboundFetchError)) return false; - if (error.code === "URL_GUARD_BLOCKED" || error.code === "INVALID_URL") return true; - if (error.code === "REDIRECT_BLOCKED") { - if (!error.location) return false; - try { + try { + if (!isSafeOutboundFetchError(error)) return false; + if (error.code === "URL_GUARD_BLOCKED" || error.code === "INVALID_URL") return true; + if (error.code === "REDIRECT_BLOCKED") { + if (!error.location) return false; return isPrivateHost(new URL(error.location, error.url).hostname); - } catch { - return false; } + } catch { + // Hostile error prototypes/accessors are never evidence of an SSRF block. } return false; } @@ -107,6 +122,15 @@ export function isSecurityBlockError(error: unknown): boolean { // #7542 plan-file, "Risks"). const WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE = new Set(["lmarena"]); +export function isSafeOutboundFetchError(error: unknown): error is SafeOutboundFetchError { + try { + return error instanceof SafeOutboundFetchError; + } catch { + // A rejected Proxy may throw while instanceof walks its prototype chain. + return false; + } +} + // #7857 — web-cookie providers whose registry `baseUrl` is a conversation/completion // endpoint, not a real API root (e.g. huggingchat's baseUrl is // "https://huggingface.co/chat/conversation", not "https://huggingface.co"). Appending @@ -130,32 +154,49 @@ export const WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API = new Set([ ]); export function toWebCookieValidationErrorResult(provider: string, error: unknown) { - if ( - error instanceof SafeOutboundFetchError && - error.code === "REDIRECT_BLOCKED" && - WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE.has(provider) - ) { - return { - valid: false, - error: "Provider validation not supported", - unsupported: true as const, - }; + try { + if ( + isSafeOutboundFetchError(error) && + error.code === "REDIRECT_BLOCKED" && + WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE.has(provider) + ) { + return { + valid: false, + error: "Provider validation not supported", + unsupported: true as const, + }; + } + } catch { + // Hostile error accessors must degrade to the generic validation result below. } return toValidationErrorResult(error); } export function toValidationErrorResult(error: unknown) { - const message = error instanceof Error ? error.message : String(error || "Validation failed"); - const statusCode = getSafeOutboundFetchErrorStatus(error); + let rawMessage: unknown = error || "Validation failed"; + try { + if (error instanceof Error) rawMessage = error.message; + } catch { + rawMessage = "Validation failed"; + } + const message = sanitizeErrorMessage(rawMessage); + let statusCode: number | null = null; + let timeout = false; + let securityBlocked = false; + try { + statusCode = getSafeOutboundFetchErrorStatus(error); + timeout = isSafeOutboundFetchError(error) && error.code === "TIMEOUT"; + securityBlocked = isSecurityBlockError(error); + } catch { + // Classification is advisory; hostile accessors must not escape the safe error boundary. + } return { valid: false, error: message || "Validation failed", unsupported: false as const, ...(statusCode ? { statusCode } : {}), - ...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT" - ? { timeout: true } - : {}), - ...(isSecurityBlockError(error) ? { securityBlocked: true } : {}), + ...(timeout ? { timeout: true } : {}), + ...(securityBlocked ? { securityBlocked: true } : {}), }; } diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts index 52e9aa4c0ef..0b30c334c60 100644 --- a/src/lib/providers/validation/webProvidersA.ts +++ b/src/lib/providers/validation/webProvidersA.ts @@ -1,9 +1,7 @@ // Web-cookie provider key validators (part A): deepseek-web, qwen-web, grok-web, chatgpt-web, // perplexity-web, blackbox-web. Extracted from validation.ts (god-file decomposition) — top-level -// functions with no dispatcher-state captures; behavior is byte-identical to the original inline defs. -import { addModelsSuffix } from "./urlHelpers"; -import { applyCustomUserAgent } from "./headers"; -import { toValidationErrorResult, validationRead, validationWrite } from "./transport"; +// functions with no dispatcher-state captures; behavior is regression-tested in this module. +import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "@omniroute/open-sse/utils/error.ts"; import { buildGrokCookieHeader, buildQwenCookieHeader, @@ -12,6 +10,34 @@ import { extractQwenToken, normalizeSessionCookieHeader, } from "@/lib/providers/webCookieAuth"; +import { applyCustomUserAgent } from "./headers"; +import { toValidationErrorResult, validationRead, validationWrite } from "./transport"; + +interface ErrorInstanceClassifier { + [Symbol.hasInstance](value: unknown): boolean; +} + +function isErrorInstance(error: unknown, classifier: ErrorInstanceClassifier): boolean { + try { + return classifier[Symbol.hasInstance](error); + } catch { + // A rejected Proxy may throw while the classifier walks its prototype chain. + return false; + } +} + +function sanitizeValidationThrownError(error: unknown): string { + let candidate = error; + try { + if (isErrorInstance(error, Error)) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") candidate = message; + } + } catch { + // Keep the unknown value for the canonical fail-closed sanitizer. + } + return sanitizeErrorMessage(candidate); +} // kimi-web uses the international (west-facing) `www.kimi.ai` Connect-RPC API by // default. `www.kimi.com` is the China-region endpoint — it serves China users but @@ -144,7 +170,7 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) { if (!bizData?.token) { return { valid: false, - error: `DeepSeek did not return an access token: ${json?.msg || "unknown error"}`, + error: `DeepSeek did not return an access token: ${sanitizeErrorMessage(json?.msg) || "unknown error"}`, }; } return { valid: true, error: null }; @@ -254,7 +280,7 @@ export async function validateQwenWebProvider({ apiKey }: any) { "Qwen session token is invalid or expired — re-login at https://chat.qwen.ai and paste a fresh full Cookie header", }; } - } catch (parseError) { + } catch { return { valid: false, error: "Qwen returned invalid JSON response", @@ -306,6 +332,68 @@ const GROK_IP_REPUTATION_GUIDANCE = "auth failure. cf_clearance is pinned to the IP + TLS fingerprint + User-Agent that earned " + "it and cannot be replayed from a different machine/IP. Retry from a residential IP or " + "configure a proxy for grok-web."; +const GROK_VALIDATION_RAW_DETAIL_BUDGET = 64 * 1024; +const GROK_REJECTED_DETAIL_DISPLAY_BUDGET = 160; +const GROK_GENERIC_DETAIL_DISPLAY_BUDGET = 240; + +function decodeGrokValidationUnicodeEscapes(value: string): string { + let decoded = value; + for (let pass = 0; pass < 2; pass += 1) { + const next = decoded + .replace(/\\u([0-9a-f]{4})/gi, (_match, codeUnit: string) => + String.fromCharCode(Number.parseInt(codeUnit, 16)) + ) + .replace(/\\\//g, "/"); + if (next === decoded) break; + decoded = next; + } + return decoded; +} + +function normalizeGrokValidationString(value: string): string { + return decodeGrokValidationUnicodeEscapes(value) + .replace(/\\r\\n|\\n|\\r/g, "\n") + .replace(/[\u2028\u2029]/g, "\n") + .replace(/\r\n?/g, "\n"); +} + +function normalizeGrokValidationJsonValue(value: unknown): unknown { + if (typeof value === "string") return normalizeGrokValidationString(value); + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + + return Object.fromEntries( + Object.entries(value as Record).map(([key, nestedValue]) => [ + normalizeGrokValidationString(key), + nestedValue, + ]) + ); +} + +function sanitizeGrokValidationErrorDetail(errorDetail: string): string { + if (!errorDetail) return ""; + + try { + const parsed = JSON.parse(errorDetail, (_key, value: unknown) => + normalizeGrokValidationJsonValue(value) + ); + const sanitized = sanitizeUpstreamDetails(parsed); + return sanitized === null ? "" : (JSON.stringify(sanitized) ?? ""); + } catch { + // Invalid or truncated upstream JSON still needs the bounded text sanitizer fallback. + return sanitizeErrorMessage(normalizeGrokValidationString(errorDetail)); + } +} + +function isGrokAntiBotBlockWithinBudget(errorDetail: string, wasTruncated: boolean): boolean { + if (!wasTruncated) return isGrokAntiBotBlock(errorDetail); + + const text = errorDetail.trimStart(); + if (/anti-bot|forbidden|access denied|blocked|rate.?limit/i.test(text)) return true; + // A JSON-shaped body may be incomplete only because our defensive parse budget + // cut it. Do not turn that bounded-read condition into a false IP-reputation verdict. + if (text.startsWith("{") || text.startsWith("[")) return false; + return isGrokAntiBotBlock(text); +} export async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: any) { try { @@ -389,19 +477,22 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = { }), timeoutMs: 15_000, }); - } catch (err: any) { - if (err instanceof TlsClientUnavailableError) { + } catch (err: unknown) { + if (isErrorInstance(err, TlsClientUnavailableError)) { return { valid: false, - error: `TLS impersonation client unavailable: ${err.message}`, + error: `TLS impersonation client unavailable: ${sanitizeValidationThrownError(err)}`, }; } throw err; } let errorDetail = ""; + let errorDetailWasTruncated = false; try { - errorDetail = (response.text || "").slice(0, 240); + const rawErrorDetail = response.text || ""; + errorDetailWasTruncated = rawErrorDetail.length > GROK_VALIDATION_RAW_DETAIL_BUDGET; + errorDetail = rawErrorDetail.slice(0, GROK_VALIDATION_RAW_DETAIL_BUDGET); } catch {} // Detect Cloudflare challenge pages even with a 200 status from tls-client-node @@ -457,7 +548,10 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = { // not code-fixable: the datacenter/VPS IP is flagged. A Cloudflare // challenge body, Grok's "anti-bot rules" rejection, or a bare/non-JSON // forbidden body (no structured upstream `error.message`) all map here. - if (isCloudflareChallenge(errorDetail) || isGrokAntiBotBlock(errorDetail)) { + if ( + isCloudflareChallenge(errorDetail) || + isGrokAntiBotBlockWithinBudget(errorDetail, errorDetailWasTruncated) + ) { return { valid: false, error: `Grok returned 403 (anti-bot/Cloudflare block). ${GROK_IP_REPUTATION_GUIDANCE}`, @@ -465,9 +559,13 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = { } // 3. Structured upstream error (e.g. probe model renamed) → surface the body // so the user/maintainer sees the real cause instead of a wrong verdict. + const safeErrorDetail = sanitizeGrokValidationErrorDetail(errorDetail).slice( + 0, + GROK_REJECTED_DETAIL_DISPLAY_BUDGET + ); return { valid: false, - error: `Grok rejected validation (403)${errorDetail ? `: ${errorDetail.slice(0, 160)}` : ""}`, + error: `Grok rejected validation (403)${safeErrorDetail ? `: ${safeErrorDetail}` : ""}`, }; } @@ -479,9 +577,13 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = { return { valid: false, error: `Grok unavailable (${response.status})` }; } + const safeErrorDetail = sanitizeGrokValidationErrorDetail(errorDetail).slice( + 0, + GROK_GENERIC_DETAIL_DISPLAY_BUDGET + ); return { valid: false, - error: `Grok validation failed (${response.status})${errorDetail ? `: ${errorDetail}` : ""}`, + error: `Grok validation failed (${response.status})${safeErrorDetail ? `: ${safeErrorDetail}` : ""}`, }; } catch (error: any) { return toValidationErrorResult(error); @@ -529,11 +631,11 @@ export async function validateChatGptWebProvider({ apiKey, providerSpecificData ), timeoutMs: 30_000, }); - } catch (err: any) { - if (err instanceof TlsClientUnavailableError) { + } catch (err: unknown) { + if (isErrorInstance(err, TlsClientUnavailableError)) { return { valid: false, - error: `${err.message} (chatgpt-web requires this — without it, Cloudflare blocks every request)`, + error: `${sanitizeValidationThrownError(err)} (chatgpt-web requires this — without it, Cloudflare blocks every request)`, }; } throw err; @@ -568,9 +670,12 @@ export async function validateChatGptWebProvider({ apiKey, providerSpecificData } if (!contentType.includes("json")) { + const safeContentType = sanitizeErrorMessage(contentType) || "no content-type"; + const safeCfRay = cfRay ? sanitizeErrorMessage(cfRay) : ""; + const safeResponseMetadata = `${safeContentType}${safeCfRay ? `, cf-ray=${safeCfRay}` : ""}`; return { valid: false, - error: `ChatGPT returned non-JSON (${contentType || "no content-type"}${cfRay ? `, cf-ray=${cfRay}` : ""}) — paste the FULL Cookie line including cf_clearance, __cf_bm, _cfuvid alongside the session-token chunks.`, + error: `ChatGPT returned non-JSON (${safeResponseMetadata}) — paste the FULL Cookie line including cf_clearance, __cf_bm, _cfuvid alongside the session-token chunks.`, }; } @@ -664,11 +769,11 @@ export async function validatePerplexityWebProvider({ apiKey, providerSpecificDa }), timeoutMs: 30_000, }); - } catch (err) { - if (err instanceof TlsClientUnavailableError) { + } catch (err: unknown) { + if (isErrorInstance(err, TlsClientUnavailableError)) { return { valid: false, - error: `${err.message} perplexity-web requires it — without it Cloudflare blocks every request.`, + error: `${sanitizeValidationThrownError(err)} perplexity-web requires it — without it Cloudflare blocks every request.`, }; } throw err; diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts index 1349a2d11cf..c1cdb4ba1fb 100644 --- a/src/lib/providers/validation/webProvidersB.ts +++ b/src/lib/providers/validation/webProvidersB.ts @@ -1,17 +1,11 @@ // Web-cookie provider key validators (part B): muse-spark-web, adapta-web, claude-web, gemini-web, // copilot-web, t3-web, jules, devin (cloud-agent), inner-ai. Extracted from validation.ts (god-file -// decomposition) — top-level functions with no dispatcher-state captures; behavior is byte-identical -// to the inline defs. -import { applyCustomUserAgent } from "./headers"; -import { - isSecurityBlockError, - toValidationErrorResult, - validationRead, - validationWrite, -} from "./transport"; -import { SafeOutboundFetchError } from "@/shared/network/safeOutboundFetch"; -import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; +// decomposition) — top-level functions with no dispatcher-state captures; behavior is +// regression-tested in this module. +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; import { buildJulesApiUrl } from "@/lib/cloudAgent/julesApi.ts"; +import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; +import { applyCustomUserAgent } from "./headers"; import { META_AI_ASBD_ID, META_AI_FRIENDLY_NAME, @@ -19,6 +13,39 @@ import { META_AI_USER_AGENT, buildMetaAiValidationBody, } from "./metaAi"; +import { + isSafeOutboundFetchError, + isSecurityBlockError, + toValidationErrorResult, + validationRead, + validationWrite, +} from "./transport"; + +interface ErrorInstanceClassifier { + [Symbol.hasInstance](value: unknown): boolean; +} + +function isErrorInstance(error: unknown, classifier: ErrorInstanceClassifier): boolean { + try { + return classifier[Symbol.hasInstance](error); + } catch { + // A rejected Proxy may throw while the classifier walks its prototype chain. + return false; + } +} + +function sanitizeValidationThrownError(error: unknown): string { + let candidate = error; + try { + if (isErrorInstance(error, Error)) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") candidate = message; + } + } catch { + // Keep the unknown value for the canonical fail-closed sanitizer. + } + return sanitizeErrorMessage(candidate); +} export async function validateMuseSparkWebProvider({ apiKey, providerSpecificData = {} }: any) { try { @@ -166,11 +193,11 @@ export async function validateClaudeWebProvider({ apiKey, providerSpecificData = ), timeoutMs: 30_000, }); - } catch (err: any) { - if (err instanceof TlsClientUnavailableError) { + } catch (err: unknown) { + if (isErrorInstance(err, TlsClientUnavailableError)) { return { valid: false, - error: `${err.message} (claude-web requires this — without it, Cloudflare blocks every request)`, + error: `${sanitizeValidationThrownError(err)} (claude-web requires this — without it, Cloudflare blocks every request)`, }; } throw err; @@ -261,12 +288,22 @@ export async function validateGeminiWebProvider({ apiKey, providerSpecificData = // - accounts.google.com/ServiceLogin — expired session → valid:false // - other accounts.google.com paths — ambiguous, warn but treat as valid // - non-Google redirects (e.g. gemini.google.com redirect loop) — valid - if ( - error instanceof SafeOutboundFetchError && - error.code === "REDIRECT_BLOCKED" && - !isSecurityBlockError(error) - ) { - const location = error.location ?? ""; + let publicRedirect: { location: string } | null = null; + try { + if ( + isSafeOutboundFetchError(error) && + error.code === "REDIRECT_BLOCKED" && + !isSecurityBlockError(error) + ) { + publicRedirect = { + location: typeof error.location === "string" ? error.location : "", + }; + } + } catch { + // Hostile redirect metadata must degrade to the generic validation failure below. + } + if (publicRedirect) { + const { location } = publicRedirect; if (/accounts\.google\.com\/.*ServiceLogin/i.test(location)) { return { valid: false, @@ -509,7 +546,7 @@ export async function validateJulesProvider({ apiKey }: { apiKey: string }) { const errorText = await response.text().catch(() => ""); return { valid: false, - error: errorText.trim() || `Jules API returned ${response.status}`, + error: sanitizeErrorMessage(errorText.trim()) || `Jules API returned ${response.status}`, }; } catch (error: unknown) { return toValidationErrorResult(error); @@ -541,7 +578,7 @@ export async function validateDevinCloudAgentProvider({ apiKey }: { apiKey: stri const errorText = await response.text().catch(() => ""); return { valid: false, - error: errorText.trim() || `Devin API returned ${response.status}`, + error: sanitizeErrorMessage(errorText.trim()) || `Devin API returned ${response.status}`, }; } catch (error: unknown) { return toValidationErrorResult(error); @@ -599,7 +636,10 @@ export async function validateNotionWebProvider({ apiKey, providerSpecificData = } } -export async function validateInnerAiProvider({ apiKey, providerSpecificData = {} }: any) { +export async function validateInnerAiProvider({ + apiKey, + providerSpecificData: _providerData = {}, +}: any) { try { const raw = typeof apiKey === "string" ? apiKey.trim() : ""; if (!raw) { diff --git a/tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts b/tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts new file mode 100644 index 00000000000..082c17c505c --- /dev/null +++ b/tests/unit/build/build-next-isolated-assembly-fail-closed.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; + +const BUILD_SCRIPT = resolve("scripts/build/build-next-isolated.mjs"); + +test("isolated build verifies the assembled TLS seed before continuing", async () => { + const projectRoot = mkdtempSync(join(tmpdir(), "omniroute-assembly-tls-gate-")); + const standaloneDir = join(projectRoot, ".build", "next", "standalone"); + const seedPath = join( + standaloneDir, + "runtime-assets", + "tls-client", + "bin", + "tls-client-linux-x64" + ); + const moduleUrl = `${pathToFileURL(BUILD_SCRIPT).href}?tls-gate=${Date.now()}`; + const buildModule = (await import(moduleUrl)) as { + assembleAndVerifyStandalone?: (options: { + rootDir: string; + buildDistDir: string; + standaloneDir: string; + assembleImpl: (options: Record) => void; + verifyImpl: (options: Record) => Promise; + }) => Promise; + }; + + try { + assert.equal( + typeof buildModule.assembleAndVerifyStandalone, + "function", + "build-next-isolated must expose its post-assembly TLS verification composition" + ); + + let assembled = false; + await assert.rejects( + buildModule.assembleAndVerifyStandalone?.({ + rootDir: projectRoot, + buildDistDir: join(projectRoot, ".build", "next"), + standaloneDir, + assembleImpl: () => { + mkdirSync(join(seedPath, ".."), { recursive: true }); + writeFileSync(seedPath, "TAMPERED_AFTER_ASSEMBLY"); + assembled = true; + }, + verifyImpl: async (options) => { + assert.equal(assembled, true, "verification must run after standalone assembly"); + assert.equal(options.rootDir, projectRoot); + assert.equal(options.standaloneDir, standaloneDir); + assert.equal(options.strict, true); + assert.equal(options.requireStandalone, true); + assert.equal(readFileSync(seedPath, "utf8"), "TAMPERED_AFTER_ASSEMBLY"); + throw new Error("TLS seed digest mismatch"); + }, + }), + /TLS seed digest mismatch/ + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test("isolated build fails closed when standalone assembly fails", () => { + const projectRoot = mkdtempSync(join(tmpdir(), "omniroute-assembly-fail-closed-")); + const nextBin = join(projectRoot, "node_modules", "next", "dist", "bin", "next"); + const sentinelPath = join(projectRoot, "build-base-path-sentinel-ran"); + + try { + mkdirSync(join(projectRoot, "node_modules", "next", "dist", "bin"), { + recursive: true, + }); + mkdirSync(join(projectRoot, "scripts", "build"), { recursive: true }); + + // A successful fake Next build leaves an invalid standalone FILE. The real + // assembler must throw when it tries to treat that path as a directory. + writeFileSync( + nextBin, + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const distDir = path.resolve(process.env.NEXT_DIST_DIR || ".build/next");', + "fs.mkdirSync(distDir, { recursive: true });", + 'fs.writeFileSync(path.join(distDir, "standalone"), "not-a-directory");', + 'console.log("FAKE_NEXT_BUILD_SUCCEEDED");', + ].join("\n") + ); + writeFileSync( + join(projectRoot, "scripts", "build", "write-build-base-path.mjs"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(sentinelPath)}, "ran");`, + ].join("\n") + ); + + const result = spawnSync(process.execPath, [BUILD_SCRIPT], { + cwd: projectRoot, + encoding: "utf8", + env: { + ...process.env, + NEXT_DIST_DIR: ".build/next", + OMNIROUTE_BUILD_BACKEND_ONLY: "0", + OMNIROUTE_BUILD_PROFILE: "full", + }, + timeout: 60_000, + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + + assert.equal(result.error, undefined, output); + assert.match(output, /FAKE_NEXT_BUILD_SUCCEEDED/, output); + assert.equal(result.signal, null, output); + assert.equal(result.status, 1, `assembly failure must be fatal\n${output}`); + assert.match(output, /\[build-next-isolated\] Build failed:/, output); + assert.doesNotMatch(output, /Non-fatal error assembling standalone/, output); + assert.equal( + existsSync(sentinelPath), + false, + "post-assembly steps must not run after the assembler throws" + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test("isolated build fails closed when Next exits successfully without standalone output", () => { + const projectRoot = mkdtempSync(join(tmpdir(), "omniroute-standalone-missing-")); + const nextBin = join(projectRoot, "node_modules", "next", "dist", "bin", "next"); + const sentinelPath = join(projectRoot, "build-base-path-sentinel-ran"); + + try { + mkdirSync(join(projectRoot, "node_modules", "next", "dist", "bin"), { + recursive: true, + }); + mkdirSync(join(projectRoot, "scripts", "build"), { recursive: true }); + + // Next reports success but produces no standalone directory. This is the + // failure mode seen when a worker aborts after Next has already decided its + // process exit code; the wrapper must not silently skip assembly/verification. + writeFileSync( + nextBin, + ['console.log("FAKE_NEXT_BUILD_SUCCEEDED_WITHOUT_STANDALONE");'].join("\n") + ); + writeFileSync( + join(projectRoot, "scripts", "build", "write-build-base-path.mjs"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(sentinelPath)}, "ran");`, + ].join("\n") + ); + + const result = spawnSync(process.execPath, [BUILD_SCRIPT], { + cwd: projectRoot, + encoding: "utf8", + env: { + ...process.env, + NEXT_DIST_DIR: ".build/next", + OMNIROUTE_BUILD_BACKEND_ONLY: "0", + OMNIROUTE_BUILD_PROFILE: "full", + }, + timeout: 60_000, + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + + assert.equal(result.error, undefined, output); + assert.match(output, /FAKE_NEXT_BUILD_SUCCEEDED_WITHOUT_STANDALONE/, output); + assert.equal(result.signal, null, output); + assert.equal(result.status, 1, `missing standalone output must be fatal\n${output}`); + assert.match( + output, + /Next\.js build exited successfully but did not produce a standalone directory/, + output + ); + assert.doesNotMatch(output, /Assembling standalone bundle/, output); + assert.equal( + existsSync(sentinelPath), + false, + "post-assembly steps must not run when Next omits standalone output" + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/build/check-licenses.test.ts b/tests/unit/build/check-licenses.test.ts index ace523a3b46..0d5eea2ff7e 100644 --- a/tests/unit/build/check-licenses.test.ts +++ b/tests/unit/build/check-licenses.test.ts @@ -26,7 +26,19 @@ function makeAllowlist( overrides: Partial<{ allowed: string[]; allowedExpressions: string[]; - exceptions: Record; + exceptions: Record< + string, + { + license?: unknown; + version?: unknown; + justification: string; + risk: string; + temporary?: boolean; + owner?: string; + reviewBy?: string; + classification?: string; + } + >; }> = {} ) { return { @@ -205,7 +217,7 @@ 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: exception applies when detected license exactly matches", () => { const allowlist = makeAllowlist({ exceptions: { "tls-client-node": { @@ -219,6 +231,278 @@ test("classifyLicense: exception with risk=medium still returns 'exception' (not assert.equal(result.status, "exception"); }); +test("classifyLicense: version-pinned exception denies a different package version", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + version: "0.2.0", + justification: "Only the provenance-audited release is temporarily authorized.", + risk: "medium", + }, + }, + }); + + const result = classifyLicense("tls-client-node@0.2.1", "Custom: LICENSE", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /0\.2\.1/); + assert.match(result.reason, /0\.2\.0/); +}); + +test("classifyLicense: version-pinned exception applies to the exact package version", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + version: "0.2.0", + justification: "Only the provenance-audited release is temporarily authorized.", + risk: "medium", + }, + }, + }); + + const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist); + assert.equal(result.status, "exception"); +}); + +test("classifyLicense: version-pinned exception denies an unversioned package key", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + version: "0.2.0", + justification: "Only the provenance-audited release is temporarily authorized.", + risk: "medium", + }, + }, + }); + + const result = classifyLicense("tls-client-node", "Custom: LICENSE", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /no version/i); + assert.match(result.reason, /tls-client-node@0\.2\.0/); +}); + +test("classifyLicense: version-pinned exception denies malformed package keys", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + version: "0.2.0", + justification: "Only the provenance-audited release is temporarily authorized.", + risk: "medium", + }, + }, + }); + + for (const packageKey of ["tls-client-node@", "tls-client-node@@0.2.0"] as const) { + const result = classifyLicense(packageKey, "Custom: LICENSE", allowlist); + assert.equal(result.status, "denied", packageKey); + assert.match(result.reason, /malformed package key/i); + assert.match(result.reason, new RegExp(packageKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } +}); + +test("classifyLicense: malformed exception version metadata fails closed", () => { + for (const declaredVersion of [undefined, "", " 0.2.0", ["0.2.0"]]) { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + version: declaredVersion, + justification: "Malformed version metadata must never authorize a package.", + risk: "medium", + }, + }, + }); + + const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /invalid exception version/i); + assert.match(result.reason, /tls-client-node/); + } +}); + +test("classifyLicense: scoped version-pinned exception uses the version after the package name", () => { + const allowlist = makeAllowlist({ + exceptions: { + "@scope/native-transport": { + license: "Custom: LICENSE", + version: "1.2.3", + justification: "Only the provenance-audited scoped package release is authorized.", + risk: "medium", + }, + }, + }); + + const exact = classifyLicense("@scope/native-transport@1.2.3", "Custom: LICENSE", allowlist); + assert.equal(exact.status, "exception"); + + const changed = classifyLicense("@scope/native-transport@1.2.4", "Custom: LICENSE", allowlist); + assert.equal(changed.status, "denied"); + assert.match(changed.reason, /1\.2\.4/); + assert.match(changed.reason, /1\.2\.3/); +}); + +test("classifyLicense: package exception overrides a globally allowed license", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + justification: "A package-specific review must not be bypassed by the global allowlist.", + risk: "medium", + }, + }, + }); + + const result = classifyLicense("tls-client-node@0.2.0", "Apache-2.0", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /Apache-2\.0/); + assert.match(result.reason, /Custom: LICENSE/); + assert.match(result.reason, /match/i); +}); + +test("classifyLicense: an expired package exception cannot fall through to the global allowlist", () => { + const allowlist = makeAllowlist({ + exceptions: { + "reviewed-apache-package": { + license: "Apache-2.0", + justification: "Temporary package-specific review despite a globally allowed SPDX id.", + risk: "medium", + temporary: true, + owner: "@owner", + reviewBy: "2026-09-30", + }, + }, + }); + + const result = classifyLicense("reviewed-apache-package@1.0.0", "Apache-2.0", allowlist, { + now: new Date("2026-10-01T00:00:00.000Z"), + }); + assert.equal(result.status, "denied"); + assert.match(result.reason, /expired|reviewBy/i); +}); + +test("classifyLicense: exception denies a GPL license that does not match its declaration", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + justification: "Exception applies only to the detected custom license.", + risk: "medium", + }, + }, + }); + const result = classifyLicense("tls-client-node@0.2.0", "GPL-3.0-only", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /GPL-3\.0-only/); + assert.match(result.reason, /Custom: LICENSE/); + assert.match(result.reason, /match/i); +}); + +test("classifyLicense: exception denies UNKNOWN when its declared license is specific", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + justification: "Exception applies only to the detected custom license.", + risk: "medium", + }, + }, + }); + const result = classifyLicense("tls-client-node@0.2.0", "UNKNOWN", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /UNKNOWN/); + assert.match(result.reason, /Custom: LICENSE/); + assert.match(result.reason, /match/i); +}); + +test("classifyLicense: exception with a missing or malformed declared license fails closed", () => { + for (const declaredLicense of [undefined, ["Custom: LICENSE"]]) { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: declaredLicense, + justification: "Malformed test exception must never authorize a detected license.", + risk: "medium", + }, + }, + }); + const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /invalid exception license/i); + assert.match(result.reason, /tls-client-node/); + } +}); + +test("classifyLicense: temporary exception is denied after its reviewBy date", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + justification: "Temporary non-OSI source-available bridge with an owner.", + risk: "medium", + temporary: true, + owner: "@owner", + reviewBy: "2026-09-30", + classification: "non-OSI source-available", + }, + }, + }); + + const beforeExpiry = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, { + now: new Date("2026-09-30T23:59:59.999Z"), + }); + assert.equal(beforeExpiry.status, "exception"); + + const expired = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, { + now: new Date("2026-10-01T00:00:00.000Z"), + }); + assert.equal(expired.status, "denied"); + assert.match(expired.reason, /expired|reviewBy/i); +}); + +test("classifyLicense: malformed temporary exception metadata fails closed", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + justification: "Temporary exception whose deadline is intentionally invalid.", + risk: "medium", + temporary: true, + owner: "@owner", + reviewBy: "2026-02-30", + classification: "non-OSI source-available", + }, + }, + }); + const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, { + now: new Date("2026-08-27T00:00:00.000Z"), + }); + assert.equal(result.status, "denied"); + assert.match(result.reason, /invalid|reviewBy/i); +}); + +test("classifyLicense: ownerless temporary exception fails closed", () => { + const allowlist = makeAllowlist({ + exceptions: { + "tls-client-node": { + license: "Custom: LICENSE", + justification: "Temporary exception deliberately missing an accountable owner.", + risk: "medium", + temporary: true, + reviewBy: "2026-09-30", + classification: "non-OSI source-available", + }, + }, + }); + const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist, { + now: new Date("2026-08-27T00:00:00.000Z"), + }); + assert.equal(result.status, "denied"); + assert.match(result.reason, /owner/i); +}); + // --------------------------------------------------------------------------- // classifyLicense — reason field content // --------------------------------------------------------------------------- @@ -294,8 +578,11 @@ test("loadAllowlist: tls-client-node exception is temporary, owned, and covers a assert.equal(exc.owner, "@diegosouzapw"); assert.equal(exc.reviewBy, "2026-09-30"); assert.equal(exc.reviewAt, "v3.9.0"); + assert.equal(exc.version, "0.2.0", "exception must cover only the provenance-audited release"); + assert.equal(exc.classification, "Apache-2.0 with Commons Clause; non-OSI source-available"); assert.match(exc.justification, /source-available/i); assert.match(exc.justification, /commercial deployment/i); + assert.match(exc.justification, /PR #11742/, "temporary exception must link its tracker"); for (const provider of [ "chatgpt-web", "claude-web", @@ -348,6 +635,14 @@ test("integration: classifyLicense passes tls-client-node as exception against r assert.equal(result.status, "exception"); }); +test("integration: real tls-client-node exception denies an unaudited future version", () => { + const allowlist = loadAllowlist(); + const result = classifyLicense("tls-client-node@0.2.1", "Custom: LICENSE", allowlist); + assert.equal(result.status, "denied"); + assert.match(result.reason, /0\.2\.1/); + assert.match(result.reason, /0\.2\.0/); +}); + 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); diff --git a/tests/unit/build/electron-tls-client-seed.test.ts b/tests/unit/build/electron-tls-client-seed.test.ts new file mode 100644 index 00000000000..575287f85e3 --- /dev/null +++ b/tests/unit/build/electron-tls-client-seed.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +const ROOT = join(import.meta.dirname, "..", "..", ".."); +const prepareScript = readFileSync( + join(ROOT, "scripts", "build", "prepare-electron-standalone.mjs"), + "utf8" +); +const electronReleaseWorkflow = readFileSync( + join(ROOT, ".github", "workflows", "electron-release.yml"), + "utf8" +); +const fixerScript = readFileSync( + join(ROOT, "scripts", "build", "fixTlsClientNodeBinary.mjs"), + "utf8" +); +const rootPackage = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + scripts: Record; +}; +const electronPackage = JSON.parse( + readFileSync(join(ROOT, "electron", "package.json"), "utf8") +) as { + scripts: Record; + build: { + win: { target: Array<{ arch: string[] }> }; + linux: { target: Array<{ arch: string[] }> }; + }; +}; + +function getFixerCalls(source: string): RegExpMatchArray[] { + return [...source.matchAll(/await\s+fixTlsClientNodeBinary\(\{([\s\S]*?)\}\);/g)]; +} + +function assertStrictStandaloneCall(call: RegExpMatchArray, standaloneDir: string): void { + const options = call[1]; + assert.match(options, /\brootDir:\s*ROOT\b/); + assert.match(options, /\bstrict:\s*true\b/); + assert.match(options, /\bplatform:\s*ELECTRON_TARGET_PLATFORM\b/); + assert.match(options, /\barches:\s*ELECTRON_TARGET_ARCHES\b/); + assert.match(options, new RegExp(`\\bstandaloneDir:\\s*${standaloneDir}\\b`)); + assert.match(options, /\brequireStandalone:\s*true\b/); +} + +test("Electron staging verifies the source and final TLS client runtime seeds in release order", () => { + assert.match( + prepareScript, + /import\s*\{\s*fixTlsClientNodeBinary\s*\}\s*from\s*"\.\/fixTlsClientNodeBinary\.mjs";/ + ); + assert.match(prepareScript, /const STANDALONE_DIR = join\(DIST_DIR, "standalone"\);/); + assert.match( + prepareScript, + /const ELECTRON_STANDALONE_DIR = join\(ROOT, "\.build", "electron-standalone"\);/ + ); + assert.match(prepareScript, /process\.env\.OMNIROUTE_ELECTRON_TARGET_PLATFORM/); + assert.match(prepareScript, /process\.env\.OMNIROUTE_ELECTRON_TARGET_ARCHES/); + assert.match( + prepareScript, + /ELECTRON_TARGET_PLATFORM\s*===\s*"linux"[\s\S]{0,160}\["x64",\s*"arm64"\][\s\S]{0,160}\[process\.arch\]/, + "Linux packaging must safely default to both electron-builder target arches" + ); + assert.match( + fixerScript, + /for\s*\(const targetArch of targetArches\)/, + "the fixer must verify every requested target arch" + ); + assert.match(fixerScript, /readCliOptionValues\(cliArgs, \["--platform"\]\)/); + assert.match( + fixerScript, + /readCliOptionValues\(cliArgs, \["--arch", "--arches"\]\)/, + "the CLI must accept one or more target arches" + ); + + const fixerCalls = getFixerCalls(prepareScript); + assert.equal(fixerCalls.length, 2, "Electron staging must verify exactly both bundle boundaries"); + assertStrictStandaloneCall(fixerCalls[0], "STANDALONE_DIR"); + assertStrictStandaloneCall(fixerCalls[1], "ELECTRON_STANDALONE_DIR"); + + const sourceFixIndex = fixerCalls[0].index ?? -1; + const assembleIndex = prepareScript.indexOf("assembleStandalone({"); + const optionalPackIndex = prepareScript.indexOf("await stageOptionalPacks({"); + const finalFixIndex = fixerCalls[1].index ?? -1; + const successIndex = prepareScript.indexOf("[electron] prepared standalone bundle:"); + + assert.ok(sourceFixIndex >= 0 && sourceFixIndex < assembleIndex, "verify source before assembly"); + assert.ok( + assembleIndex < optionalPackIndex && optionalPackIndex < finalFixIndex, + "verify the final Electron bundle after all staging mutations" + ); + assert.ok( + finalFixIndex < successIndex, + "verify the final Electron bundle before reporting success" + ); +}); + +test("shared Electron web build seeds the TLS client binary before packing the artifact", () => { + const webBuildStart = electronReleaseWorkflow.indexOf("\n web-build:"); + const buildStart = electronReleaseWorkflow.indexOf("\n build:", webBuildStart + 1); + assert.ok(webBuildStart >= 0 && buildStart > webBuildStart, "locate the shared web-build job"); + + const webBuildJob = electronReleaseWorkflow.slice(webBuildStart, buildStart); + const buildIndex = webBuildJob.indexOf("run: npm run build"); + const fixerIndex = webBuildJob.indexOf( + "run: node scripts/build/fixTlsClientNodeBinary.mjs --strict --standalone-dir .build/next/standalone" + ); + const packIndex = webBuildJob.indexOf( + "run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz" + ); + + assert.ok(buildIndex >= 0, "shared web-build job must build the Next standalone bundle"); + assert.ok(fixerIndex > buildIndex, "strict TLS client seed verification must follow Next build"); + assert.ok(packIndex > fixerIndex, "strict TLS client seed verification must precede packing"); +}); + +test("Electron matrix passes platform and every packaging arch to bundle preparation via env", () => { + const buildStepStart = electronReleaseWorkflow.indexOf( + "- name: Build Electron for ${{ matrix.platform }}" + ); + const smokeStepStart = electronReleaseWorkflow.indexOf( + "- name: Smoke packaged Electron app", + buildStepStart + 1 + ); + assert.ok(buildStepStart >= 0 && smokeStepStart > buildStepStart, "locate Electron build step"); + + const buildStep = electronReleaseWorkflow.slice(buildStepStart, smokeStepStart); + assert.match(buildStep, /OMNIROUTE_ELECTRON_TARGET_PLATFORM:\s*\$\{\{ matrix\.os \}\}/); + assert.match(buildStep, /OMNIROUTE_ELECTRON_TARGET_ARCHES:\s*\$\{\{ matrix\.arch \}\}/); + assert.match( + electronReleaseWorkflow, + /platform:\s*linux[\s\S]{0,180}os:\s*linux[\s\S]{0,80}arch:\s*x64,arm64/, + "the Linux matrix must continue declaring both packaged architectures" + ); +}); + +test("root Electron build scripts pass each packaged platform and arch to bundle preparation", () => { + assert.deepEqual( + [...new Set(electronPackage.build.win.target.flatMap((target) => target.arch))], + ["x64"], + "Windows packages x64 artifacts" + ); + assert.deepEqual( + [...new Set(electronPackage.build.linux.target.flatMap((target) => target.arch))], + ["x64", "arm64"], + "Linux packages x64 and arm64 artifacts" + ); + assert.match(electronPackage.scripts["build:mac-x64"], /electron-builder --mac --x64$/); + assert.match(electronPackage.scripts["build:mac-arm64"], /electron-builder --mac --arm64$/); + + assert.match( + rootPackage.scripts["electron:build:win"], + /cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=win32 OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:win$/, + "Windows preparation must verify the win32/x64 DLL seed" + ); + assert.match( + rootPackage.scripts["electron:build:mac"], + /cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=x64 npm run build:mac-x64 && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=darwin OMNIROUTE_ELECTRON_TARGET_ARCHES=arm64 npm run build:mac-arm64$/, + "macOS preparation must verify the exact Intel and Apple Silicon seeds it packages" + ); + assert.match( + rootPackage.scripts["electron:build:linux"], + /cd electron && cross-env OMNIROUTE_ELECTRON_TARGET_PLATFORM=linux OMNIROUTE_ELECTRON_TARGET_ARCHES=x64,arm64 npm run build:linux$/, + "Linux preparation must verify both configured electron-builder target arches" + ); +}); diff --git a/tests/unit/build/tls-client-assembly-digest.test.ts b/tests/unit/build/tls-client-assembly-digest.test.ts new file mode 100644 index 00000000000..ace13fcc04e --- /dev/null +++ b/tests/unit/build/tls-client-assembly-digest.test.ts @@ -0,0 +1,445 @@ +import assert from "node:assert/strict"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assembleStandalone, + syncStandaloneNativeAssets, +} from "../../../scripts/build/assembleStandalone.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + +test("async standalone assembly rejects a manifest-named TLS seed with the wrong digest", async () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8") + ) as { assets: Record }; + const asset = Object.entries(manifest.assets).find( + ([target]) => target !== `${process.platform}-${process.arch}` + )?.[1]; + assert.ok(asset, "the pinned TLS manifest must contain at least one native asset"); + + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-digest-project-")); + const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-digest-output-")); + const source = join(projectRoot, "node_modules", "tls-client-node", "bin", asset.file); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file); + + try { + mkdirSync(dirname(source), { recursive: true }); + writeFileSync(source, "manifest filename, deliberately untrusted bytes"); + + await assert.rejects( + syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir), + /SHA-256|digest|integrity/i + ); + assert.equal( + existsSync(destination), + false, + "an unverified TLS seed must never be emitted into the standalone output" + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("async standalone assembly copies a regular TLS seed whose fixture digest is pinned", async () => { + const fixture = "verified TLS fixture\n"; + const fixtureAsset = { + file: "tls-client-test-fixture.so", + sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c", + }; + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-valid-project-")); + const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-valid-output-")); + const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file); + + try { + mkdirSync(dirname(source), { recursive: true }); + writeFileSync(source, fixture); + + const changed = await syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, { + tlsClientNativeAssets: { "test-fixture": fixtureAsset }, + }); + + assert.equal(changed, true); + assert.equal(readFileSync(destination, "utf8"), fixture); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("sync standalone assembly rejects a manifest-named TLS seed with the wrong digest", () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8") + ) as { assets: Record }; + const asset = Object.entries(manifest.assets).find( + ([target]) => target !== `${process.platform}-${process.arch}` + )?.[1]; + assert.ok(asset, "the pinned TLS manifest must contain at least one native asset"); + + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-sync-digest-project-")); + const distDir = join(projectRoot, ".build", "next"); + const outDir = join(projectRoot, "dist"); + const source = join(projectRoot, "node_modules", "tls-client-node", "bin", asset.file); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file); + const staleStandaloneSeed = join( + distDir, + "standalone", + "runtime-assets", + "tls-client", + "bin", + asset.file + ); + + try { + mkdirSync(join(distDir, "standalone"), { recursive: true }); + writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n"); + mkdirSync(dirname(staleStandaloneSeed), { recursive: true }); + writeFileSync(staleStandaloneSeed, "stale unverified seed from an earlier assembly"); + mkdirSync(dirname(source), { recursive: true }); + writeFileSync(source, "manifest filename, deliberately untrusted sync bytes"); + + assert.throws( + () => + assembleStandalone({ + distDir, + outDir, + projectRoot, + copyNatives: true, + }), + /SHA-256|digest|integrity/i + ); + assert.equal( + existsSync(destination), + false, + "a failed sync assembly must not leave an unverified TLS seed in its output" + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test("sync assembly rejects a stale unverified TLS seed when the source install is absent", () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8") + ) as { assets: Record }; + const asset = Object.values(manifest.assets)[0]; + assert.ok(asset, "the pinned TLS manifest must contain at least one native asset"); + + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-stale-project-")); + const distDir = join(projectRoot, ".build", "next"); + const outDir = join(projectRoot, "dist"); + const staleStandaloneSeed = join( + distDir, + "standalone", + "runtime-assets", + "tls-client", + "bin", + asset.file + ); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file); + + try { + mkdirSync(dirname(staleStandaloneSeed), { recursive: true }); + writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n"); + writeFileSync(staleStandaloneSeed, "stale bytes with no corresponding source install"); + + assert.throws( + () => assembleStandalone({ distDir, outDir, projectRoot, copyNatives: true }), + /SHA-256|digest|integrity/i + ); + assert.equal( + existsSync(destination), + false, + "a stale unverified seed copied by the bulk standalone pass must be removed" + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test("sync assembly rejects TLS tamper copied through every standalone node_modules root", () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8") + ) as { assets: Record }; + const asset = Object.values(manifest.assets)[0]; + assert.ok(asset, "the pinned TLS manifest must contain at least one native asset"); + + const layouts = [ + (_projectRoot: string) => ["node_modules", "tls-client-node", "bin"], + (_projectRoot: string) => [".build", "next", "node_modules", "tls-client-node", "bin"], + (_projectRoot: string) => ["projects", "OmniRoute", "node_modules", "tls-client-node", "bin"], + (projectRoot: string) => [basename(projectRoot), "node_modules", "tls-client-node", "bin"], + ]; + + for (const layout of layouts) { + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-bulk-root-")); + const distDir = join(projectRoot, ".build", "next"); + const outDir = join(projectRoot, "dist"); + const relativeBin = layout(projectRoot); + const staleStandaloneSeed = join(distDir, "standalone", ...relativeBin, asset.file); + const destination = join(outDir, ...relativeBin, asset.file); + + try { + mkdirSync(dirname(staleStandaloneSeed), { recursive: true }); + writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n"); + writeFileSync(staleStandaloneSeed, "manifest-named bytes with an invalid digest"); + + assert.throws( + () => assembleStandalone({ distDir, outDir, projectRoot, copyNatives: true }), + /SHA-256|digest|integrity/i, + `bulk-copied TLS seed must be audited at ${relativeBin.join("/")}` + ); + assert.equal( + existsSync(destination), + false, + `tampered TLS seed must be removed from ${relativeBin.join("/")}` + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + } +}); + +test( + "sync assembly rejects a writable stale TLS seed even when its digest is valid", + { skip: process.platform === "win32" }, + () => { + const fixture = "sync verified TLS fixture\n"; + const fixtureAsset = { + file: "tls-client-stale-mode-fixture.so", + sha256: "7729ee26e4baf77e5c5ad8289778943a4412621d275d41b8cff8fe8daa3a496e", + }; + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-stale-mode-project-")); + const distDir = join(projectRoot, ".build", "next"); + const outDir = join(projectRoot, "dist"); + const staleStandaloneSeed = join( + distDir, + "standalone", + "runtime-assets", + "tls-client", + "bin", + fixtureAsset.file + ); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file); + + try { + mkdirSync(dirname(staleStandaloneSeed), { recursive: true }); + writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n"); + writeFileSync(staleStandaloneSeed, fixture); + chmodSync(staleStandaloneSeed, 0o644); + + assert.throws( + () => + assembleStandalone({ + distDir, + outDir, + projectRoot, + copyNatives: true, + tlsClientNativeAssets: { "stale-mode-fixture": fixtureAsset }, + }), + /mode|permission|writable/i + ); + assert.equal( + existsSync(destination), + false, + "a writable stale seed must be removed instead of being trusted by digest alone" + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + } +); + +test("sync standalone assembly copies a regular TLS seed whose fixture digest is pinned", () => { + const fixture = "sync verified TLS fixture\n"; + const fixtureAsset = { + file: "tls-client-sync-test-fixture.so", + sha256: "7729ee26e4baf77e5c5ad8289778943a4412621d275d41b8cff8fe8daa3a496e", + }; + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-sync-valid-project-")); + const distDir = join(projectRoot, ".build", "next"); + const outDir = join(projectRoot, "dist"); + const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file); + + try { + mkdirSync(join(distDir, "standalone"), { recursive: true }); + writeFileSync(join(distDir, "standalone", "server.js"), "// synthetic standalone\n"); + mkdirSync(dirname(source), { recursive: true }); + writeFileSync(source, fixture); + + assembleStandalone({ + distDir, + outDir, + projectRoot, + copyNatives: true, + tlsClientNativeAssets: { "sync-test-fixture": fixtureAsset }, + }); + + assert.equal(readFileSync(destination, "utf8"), fixture); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test("standalone assembly rejects symlink and oversized TLS seed sources", async () => { + const manifest = JSON.parse( + readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8") + ) as { assets: Record }; + const asset = Object.values(manifest.assets)[0]; + assert.ok(asset, "the pinned TLS manifest must contain at least one native asset"); + + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-unsafe-project-")); + const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-unsafe-output-")); + const source = join(projectRoot, "node_modules", "tls-client-node", "bin", asset.file); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", asset.file); + const symlinkTarget = join(projectRoot, "untrusted-native-bytes"); + + try { + mkdirSync(dirname(source), { recursive: true }); + writeFileSync(symlinkTarget, "untrusted symlink target"); + symlinkSync(symlinkTarget, source); + await assert.rejects( + syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir), + /symlink\/non-regular file/i + ); + assert.equal(existsSync(destination), false); + + rmSync(source, { force: true }); + writeFileSync(source, ""); + truncateSync(source, 64 * 1024 * 1024 + 1); + await assert.rejects( + syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir), + /64 MiB limit/i + ); + assert.equal(existsSync(destination), false); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("standalone assembly rejects a TLS seed beneath a symlink source ancestor", async () => { + const fixture = "verified TLS fixture\n"; + const fixtureAsset = { + file: "tls-client-source-ancestor-symlink-fixture.so", + sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c", + }; + const temporaryRoot = mkdtempSync(join(tmpdir(), "tls-assembly-source-ancestor-")); + const projectRoot = join(temporaryRoot, "project"); + const outsideBin = join(temporaryRoot, "outside-bin"); + const sourceBin = join(projectRoot, "node_modules", "tls-client-node", "bin"); + const outsideSource = join(outsideBin, fixtureAsset.file); + const outDir = join(temporaryRoot, "standalone"); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file); + + try { + mkdirSync(dirname(sourceBin), { recursive: true }); + mkdirSync(outsideBin, { recursive: true }); + writeFileSync(outsideSource, fixture); + symlinkSync(outsideBin, sourceBin, "dir"); + + await assert.rejects( + syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, { + tlsClientNativeAssets: { "source-ancestor-symlink-fixture": fixtureAsset }, + }), + /source ancestor|symlink/i + ); + assert.equal( + existsSync(destination), + false, + "a TLS seed reached through a source ancestor symlink must not be distributed" + ); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("standalone assembly rejects a symlink destination without modifying its target", async () => { + const fixture = "verified TLS fixture\n"; + const fixtureAsset = { + file: "tls-client-destination-symlink-fixture.so", + sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c", + }; + const projectRoot = mkdtempSync(join(tmpdir(), "tls-assembly-dest-project-")); + const outDir = mkdtempSync(join(tmpdir(), "tls-assembly-dest-output-")); + const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file); + const destination = join(outDir, "runtime-assets", "tls-client", "bin", fixtureAsset.file); + const outsideTarget = join(projectRoot, "must-remain-untouched"); + + try { + mkdirSync(dirname(source), { recursive: true }); + writeFileSync(source, fixture); + writeFileSync(outsideTarget, "outside sentinel"); + mkdirSync(dirname(destination), { recursive: true }); + symlinkSync(outsideTarget, destination); + + await assert.rejects( + syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, { + tlsClientNativeAssets: { "destination-symlink-fixture": fixtureAsset }, + }), + /destination \(symlink\/non-regular file\)/i + ); + assert.equal(existsSync(destination), false); + assert.equal(readFileSync(outsideTarget, "utf8"), "outside sentinel"); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("standalone assembly rejects an output root symlink without writing outside it", async () => { + const fixture = "verified TLS fixture\n"; + const fixtureAsset = { + file: "tls-client-output-root-symlink-fixture.so", + sha256: "3242ea2a8eb1fb8a714e682bba5a62652b33180f76e6a95aea701d7b8b77139c", + }; + const temporaryRoot = mkdtempSync(join(tmpdir(), "tls-assembly-root-symlink-")); + const projectRoot = join(temporaryRoot, "project"); + const outsideDir = join(temporaryRoot, "outside"); + const outDir = join(temporaryRoot, "standalone-link"); + const source = join(projectRoot, "node_modules", "tls-client-node", "bin", fixtureAsset.file); + const escapedDestination = join( + outsideDir, + "runtime-assets", + "tls-client", + "bin", + fixtureAsset.file + ); + + try { + mkdirSync(dirname(source), { recursive: true }); + writeFileSync(source, fixture); + mkdirSync(outsideDir, { recursive: true }); + symlinkSync(outsideDir, outDir, "dir"); + + await assert.rejects( + syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, { + tlsClientNativeAssets: { "root-symlink-fixture": fixtureAsset }, + }), + /destination.*(?:root|ancestor)|symlink/i + ); + assert.equal( + existsSync(escapedDestination), + false, + "a symlinked output root must not redirect an authorized filename outside the bundle" + ); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/build/tls-client-license-provenance.test.ts b/tests/unit/build/tls-client-license-provenance.test.ts index 83ef564966d..3732b3caab2 100644 --- a/tests/unit/build/tls-client-license-provenance.test.ts +++ b/tests/unit/build/tls-client-license-provenance.test.ts @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { 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 { fileURLToPath } from "node:url"; -import { syncStandaloneExtraModules } from "../../../scripts/build/assembleStandalone.mjs"; +import { + syncStandaloneExtraModules, + syncStandaloneNativeAssets, +} from "../../../scripts/build/assembleStandalone.mjs"; import { PACK_ARTIFACT_REQUIRED_PATHS } from "../../../scripts/build/pack-artifact-policy.ts"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); @@ -37,6 +40,8 @@ function extractVerbatimBlock(document: string, label: keyof typeof PRIMARY_SOUR test("distributed tls-client notices reproduce every primary license and NOTICE verbatim", () => { const notices = readFileSync(join(ROOT, "THIRD_PARTY_NOTICES.md"), "utf8"); + assert.match(notices, /not OSI-approved/i); + for (const [label, expectedHash] of Object.entries(PRIMARY_SOURCE_HASHES)) { const text = extractVerbatimBlock(notices, label as keyof typeof PRIMARY_SOURCE_HASHES); assert.equal( @@ -47,6 +52,155 @@ test("distributed tls-client notices reproduce every primary license and NOTICE } }); +test("public comparison qualifies MIT as the project's own code license", () => { + const comparison = readFileSync(join(ROOT, "docs", "diagrams", "comparison-table.svg"), "utf8"); + assert.doesNotMatch(comparison, /100% MIT/i); + assert.match(comparison, /own code license/i); + assert.match(comparison, /optional (?:third-party )?dependencies retain their licenses/i); +}); + +test("localized public comparisons use the canonical provider count", () => { + const comparison = readFileSync(join(ROOT, "docs", "diagrams", "comparison-table.svg"), "utf8"); + const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8"); + const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8"); + const canonicalCount = comparison.match(/full set: (\d+) providers/i)?.[1]; + + assert.ok(canonicalCount, "comparison SVG must expose the docs-counts-verified provider count"); + assert.match(italian, new RegExp(`OmniRoute: ${canonicalCount} provider\\b`)); + assert.match(turkish, new RegExp(`OmniRoute: ${canonicalCount} providers\\b`)); +}); + +test("public privacy claims qualify MIT as the project's own code license", () => { + const privacyDiagram = readFileSync(join(ROOT, "docs", "diagrams", "privacy-local.svg"), "utf8"); + const localizedReadmes = [ + readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8"), + readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8"), + readFileSync(join(ROOT, "docs", "i18n", "ru", "README.md"), "utf8"), + ].join("\n"); + + assert.doesNotMatch(privacyDiagram, /MIT licensed (?:and|&) fully open-source/i); + assert.doesNotMatch(privacyDiagram, /never leak stack traces, paths or internals/i); + assert.doesNotMatch(localizedReadmes, /MIT-licensed fully open-source/i); + assert.doesNotMatch(localizedReadmes, /codice completamente open source con licenza MIT/i); + assert.doesNotMatch(localizedReadmes, /MIT, fully open-source/i); + assert.doesNotMatch(localizedReadmes, /sanitized errors that never leak internals/i); + assert.doesNotMatch(localizedReadmes, /errori sanitizzati che non espongono dettagli interni/i); + assert.match(privacyDiagram, /own code.*MIT/i); + assert.match(privacyDiagram, /third-party components retain their own licenses/i); + assert.match(privacyDiagram, /recognized filesystem paths/i); + assert.match(localizedReadmes, /third-party components retain their own licenses/i); + assert.match(localizedReadmes, /codice proprio di OmniRoute.*licenza MIT/i); + assert.match(localizedReadmes, /сторонние компоненты.*лицензии/i); + assert.match(localizedReadmes, /recognized filesystem paths/i); + assert.match(localizedReadmes, /percorsi filesystem riconosciuti/i); +}); + +test("public privacy copy avoids absolute no-network and no-cloud promises", () => { + const privacyDiagram = readFileSync(join(ROOT, "docs", "diagrams", "privacy-local.svg"), "utf8"); + const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8"); + const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8"); + const russian = readFileSync(join(ROOT, "docs", "i18n", "ru", "README.md"), "utf8"); + + assert.doesNotMatch(privacyDiagram, /never phones home/i); + assert.doesNotMatch(privacyDiagram, /\b0 cloud hops\b/i); + assert.doesNotMatch(privacyDiagram, /no OmniRoute cloud in the request path/i); + assert.doesNotMatch(privacyDiagram, /prompts go only to the providers you choose, nowhere else/i); + assert.doesNotMatch(italian, /non comunica autonomamente con servizi cloud/i); + assert.doesNotMatch(italian, /\b0 passaggi cloud\b/i); + assert.doesNotMatch(turkish, /never phones home/i); + assert.doesNotMatch(turkish, /\b0 cloud hops\b/i); + assert.doesNotMatch(russian, /без «звонков домой»/i); + assert.doesNotMatch(russian, /100% на вашем железе/i); + assert.doesNotMatch(russian, /Нет cloud-hop OmniRoute/i); + assert.doesNotMatch(russian, /промпты уходят только выбранным провайдерам/i); + + assert.match(privacyDiagram, /adds no OmniRoute-hosted prompt-processing hop/i); + assert.match(privacyDiagram, /telemetry is disabled by default/i); + assert.match( + italian, + /non aggiunge un passaggio di elaborazione dei prompt ospitato da OmniRoute/i + ); + assert.match(italian, /telemetria (?:è )?disattivata per impostazione predefinita/i); + assert.match(turkish, /adds no OmniRoute-hosted prompt-processing hop/i); + assert.match(turkish, /telemetry is disabled by default/i); + assert.match( + russian, + /не добавляет этап обработки промптов, размещённый на инфраструктуре OmniRoute/i + ); + assert.match(russian, /телеметрия по умолчанию отключена/i); + assert.match( + russian, + /Роутинг выполняется локально.*провайдеры остаются внешними upstream-сервисами/i + ); +}); + +test("public privacy copy qualifies dashboard identity and opt-in redactions", () => { + const privacyDiagram = readFileSync(join(ROOT, "docs", "diagrams", "privacy-local.svg"), "utf8"); + + assert.doesNotMatch(privacyDiagram, /No account(?: and|,) no sign-up/i); + assert.doesNotMatch(privacyDiagram, /OmniRoute never asks who you are/i); + assert.doesNotMatch(privacyDiagram, /payloads are never mutated by default/i); + assert.match(privacyDiagram, /No OmniRoute-hosted account service/i); + assert.match( + privacyDiagram, + /operator controls dashboard identity.*local password.*optional OIDC/i + ); + assert.match(privacyDiagram, /These redactions run only when enabled/i); + assert.match( + privacyDiagram, + /MCP tool calls & admin actions logged in your SQLite, not ours/i + ); +}); + +test("localized privacy copy qualifies dashboard identity", () => { + const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8"); + const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8"); + + assert.doesNotMatch(italian, /nessun account o registrazione/i); + assert.doesNotMatch(turkish, /no account or sign-up/i); + assert.match( + italian, + /nessun servizio di account ospitato da OmniRoute.*l'operatore controlla l'identità della dashboard.*password locale.*OIDC opzionale/i + ); + assert.match( + turkish, + /OmniRoute tarafından barındırılan bir hesap hizmeti yoktur.*operatör pano kimliğini.*yerel parola.*isteğe bağlı OIDC/i + ); +}); + +test("root README does not present third-party components as MIT-licensed", () => { + const readme = readFileSync(join(ROOT, "README.md"), "utf8"); + + assert.doesNotMatch(readme, /OmniRoute is MIT-licensed and self-hostable/i); + assert.doesNotMatch(readme, /OmniRoute is MIT-licensed and maintained in the open/i); + assert.match(readme, /OmniRoute's own code is MIT-licensed/i); + assert.match(readme, /third-party (?:components|dependencies) retain their own licenses/i); +}); + +test("localized support copy qualifies MIT as the project's own code license", () => { + const italian = readFileSync(join(ROOT, "docs", "i18n", "it", "README.md"), "utf8"); + const turkish = readFileSync(join(ROOT, "docs", "i18n", "tr", "README.md"), "utf8"); + + assert.doesNotMatch(italian, /(?:^|\n)OmniRoute è distribuito con licenza MIT/i); + assert.match(italian, /codice proprio di OmniRoute.*licenza MIT/i); + assert.match(italian, /componenti di terze parti.*rispettive licenze/i); + + assert.doesNotMatch(turkish, /OmniRoute, MIT lisanslıdır/i); + assert.match(turkish, /OmniRoute'un kendi kodu.*MIT lisanslıdır/i); + assert.match(turkish, /üçüncü taraf bileşenler.*kendi lisanslarını korur/i); +}); + +test("TLS seed documentation distinguishes fallback from unsafe-entry failure", () => { + const environmentReference = readFileSync( + join(ROOT, "docs", "reference", "ENVIRONMENT.md"), + "utf8" + ); + + assert.match(environmentReference, /absent file or SHA-256 mismatch falls through/i); + assert.match(environmentReference, /symlink, non-regular file, or file above 64 MiB/i); + assert.match(environmentReference, /unsafe entry and aborts resolution/i); +}); + test("the distributed wrapper is pinned to the exact audited tls-client-node release", () => { const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); const packageLock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8")); @@ -56,9 +210,14 @@ test("the distributed wrapper is pinned to the exact audited tls-client-node rel assert.equal(packageLock.packages["node_modules/tls-client-node"].version, "0.2.0"); }); -test("npm pack, standalone, and Docker all transport THIRD_PARTY_NOTICES.md", async () => { +test("npm pack, standalone, and Docker transport notices, manifest, and the runtime seed", async () => { const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); + assert.ok(packageJson.files.includes("LICENSE")); assert.ok(packageJson.files.includes("THIRD_PARTY_NOTICES.md")); + assert.ok( + PACK_ARTIFACT_REQUIRED_PATHS.includes("dist/LICENSE"), + "check:pack-artifact must require the project license inside standalone artifacts" + ); assert.ok( PACK_ARTIFACT_REQUIRED_PATHS.includes("THIRD_PARTY_NOTICES.md"), "check:pack-artifact must fail when the distributed notices are absent" @@ -68,18 +227,78 @@ test("npm pack, standalone, and Docker all transport THIRD_PARTY_NOTICES.md", as const outDir = mkdtempSync(join(tmpdir(), "tls-client-notices-standalone-")); try { const expected = "legal-notice-sentinel\n"; + const projectLicense = "omniroute-license-sentinel\n"; + const manifest = readFileSync( + join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), + "utf8" + ); + const parsedManifest = JSON.parse(manifest) as { + assets: Record; + }; + const nativeAsset = Object.values(parsedManifest.assets)[0]; + assert.ok(nativeAsset, "the pinned TLS manifest must contain at least one asset"); + const nativeBinary = "verified-native-seed-sentinel"; writeFileSync(join(projectRoot, "THIRD_PARTY_NOTICES.md"), expected); + writeFileSync(join(projectRoot, "LICENSE"), projectLicense); + mkdirSync(join(projectRoot, "open-sse", "config"), { recursive: true }); + writeFileSync( + join(projectRoot, "open-sse", "config", "tlsClientNativeManifest.json"), + manifest + ); + mkdirSync(join(projectRoot, "node_modules", "tls-client-node", "bin"), { + recursive: true, + }); + writeFileSync( + join(projectRoot, "node_modules", "tls-client-node", "bin", nativeAsset.file), + nativeBinary + ); + writeFileSync( + join(projectRoot, "node_modules", "tls-client-node", "bin", "untracked-extra.so"), + "must-not-be-distributed" + ); await syncStandaloneExtraModules(projectRoot, undefined, { log() {} }, outDir); + await syncStandaloneNativeAssets(projectRoot, undefined, { log() {} }, outDir, { + tlsClientNativeAssets: { + "provenance-fixture": { + file: nativeAsset.file, + sha256: "5637d0a3bf3174ac2be169c507151090fb0a4b6acc9917df8d4d2f904d5b6e81", + }, + }, + }); assert.equal(readFileSync(join(outDir, "THIRD_PARTY_NOTICES.md"), "utf8"), expected); + assert.equal(readFileSync(join(outDir, "LICENSE"), "utf8"), projectLicense); + assert.equal( + readFileSync(join(outDir, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8"), + manifest + ); + assert.equal( + readFileSync(join(outDir, "runtime-assets", "tls-client", "bin", nativeAsset.file), "utf8"), + nativeBinary + ); + assert.throws( + () => + readFileSync( + join(outDir, "runtime-assets", "tls-client", "bin", "untracked-extra.so"), + "utf8" + ), + /ENOENT/, + "standalone assembly must not distribute non-manifest TLS native siblings" + ); } finally { rmSync(projectRoot, { recursive: true, force: true }); rmSync(outDir, { recursive: true, force: true }); } const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8"); + const bunDockerfile = readFileSync(join(ROOT, "Dockerfile.bun"), "utf8"); assert.match( dockerfile, /COPY --from=builder \/app\/\.build\/next\/standalone \.\//, "Docker runner must consume the standalone tree that carries THIRD_PARTY_NOTICES.md" ); + assert.match( + bunDockerfile, + /COPY --from=builder(?: --chown=bun:bun)? \/app\/\.build\/next\/standalone \.\//, + "Bun runner must consume the standalone tree that carries LICENSE and notices" + ); }); diff --git a/tests/unit/build/tls-client-pack-seed.test.ts b/tests/unit/build/tls-client-pack-seed.test.ts new file mode 100644 index 00000000000..0fd9e2d6f58 --- /dev/null +++ b/tests/unit/build/tls-client-pack-seed.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import * as packPolicy from "../../../scripts/build/pack-artifact-policy.ts"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + +type RuntimeSeedPathResolver = (platform?: NodeJS.Platform, arch?: string) => string; + +function allRuntimeSeedPaths(): string[] { + const manifest = JSON.parse( + readFileSync(join(ROOT, "open-sse", "config", "tlsClientNativeManifest.json"), "utf8") + ) as { assets: Record }; + return Object.values(manifest.assets) + .map((asset) => `runtime-assets/tls-client/bin/${asset.file}`) + .sort(); +} + +test("npm staging keeps and requires every exact manifest-backed TLS client runtime seed", () => { + const runtimeSeedPaths = allRuntimeSeedPaths(); + assert.equal(runtimeSeedPaths.length, 6, "the pinned manifest currently supports six targets"); + + assert.deepEqual( + packPolicy.findUnexpectedArtifactPaths(runtimeSeedPaths, { + exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS, + prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES, + neverAllowedSegments: [], + }), + [], + "prepublish staging must not prune any officially supported runtime seed" + ); + assert.deepEqual( + packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS.filter((path) => + path.startsWith("runtime-assets/tls-client/bin/") + ).sort(), + runtimeSeedPaths, + "the staging allowlist must enumerate exactly the manifest assets" + ); + assert.equal( + packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES.some((path) => + path.startsWith("runtime-assets/tls-client") + ), + false, + "TLS native assets must never be authorized through a broad prefix" + ); + assert.deepEqual( + packPolicy.findUnexpectedArtifactPaths(["runtime-assets/unrelated/surprise.bin"], { + exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS, + prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES, + neverAllowedSegments: [], + }), + ["runtime-assets/unrelated/surprise.bin"], + "the staging exception must stay scoped to the TLS client binary directory" + ); + assert.deepEqual( + packPolicy.findUnexpectedArtifactPaths( + ["runtime-assets/tls-client/bin/untracked-extra-native.so"], + { + exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS, + prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES, + neverAllowedSegments: [], + } + ), + ["runtime-assets/tls-client/bin/untracked-extra-native.so"], + "the staging exception must not distribute an untracked native beside the pinned seed" + ); + + const requiredSeedPaths = runtimeSeedPaths.map((path) => `dist/${path}`); + assert.deepEqual( + packPolicy.PACK_ARTIFACT_REQUIRED_PATHS.filter((path) => + path.startsWith("dist/runtime-assets/tls-client/bin/") + ).sort(), + requiredSeedPaths, + "check:pack-artifact must require every manifest asset and no untracked native" + ); + for (const requiredSeedPath of requiredSeedPaths) { + assert.deepEqual( + packPolicy.findMissingArtifactPaths( + packPolicy.PACK_ARTIFACT_REQUIRED_PATHS.filter((path) => path !== requiredSeedPath), + packPolicy.PACK_ARTIFACT_REQUIRED_PATHS + ), + [requiredSeedPath], + `the pack gate must report ${requiredSeedPath} when it is absent` + ); + } + + const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + files: string[]; + }; + assert.ok(packageJson.files.includes("dist/"), "npm files must include the staged dist tree"); + assert.equal( + packageJson.files.some( + (entry) => entry.startsWith("!") && /runtime-assets|tls-client|\.(?:so|dylib|dll)/.test(entry) + ), + false, + "npm files exclusions must not remove the exact native seeds after policy validation" + ); +}); + +test("npm staging retains the TLS manifest and notices inside dist", () => { + const standaloneLegalPaths = [ + "THIRD_PARTY_NOTICES.md", + "open-sse/config/tlsClientNativeManifest.json", + ]; + + assert.deepEqual( + packPolicy.findUnexpectedArtifactPaths(standaloneLegalPaths, { + exactPaths: packPolicy.APP_STAGING_ALLOWED_EXACT_PATHS, + prefixPaths: packPolicy.APP_STAGING_ALLOWED_PATH_PREFIXES, + neverAllowedSegments: [], + }), + [], + "prepublish must not prune legal provenance copied into the standalone" + ); + for (const filePath of standaloneLegalPaths) { + assert.ok( + packPolicy.PACK_ARTIFACT_REQUIRED_PATHS.includes(`dist/${filePath}`), + `check:pack-artifact must require dist/${filePath}` + ); + } +}); + +test("TLS client pack seed resolution fails explicitly on unsupported platforms", () => { + const resolver = ( + packPolicy as typeof packPolicy & { + resolveTlsClientRuntimeSeedPath?: RuntimeSeedPathResolver; + } + ).resolveTlsClientRuntimeSeedPath; + + assert.equal(typeof resolver, "function", "pack policy must expose manifest-backed resolution"); + assert.throws( + () => resolver?.("aix", "ppc64"), + /Unsupported platform for tls-client-node native asset: aix\/ppc64/ + ); +}); + +test("build:cli verifies all TLS client targets before assembly and after final pruning", () => { + const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + scripts: Record; + }; + assert.match( + packageJson.scripts["build:cli"], + /(?:^|\s)scripts\/build\/prepublish\.ts(?:\s|$)/, + "the published build must execute scripts/build/prepublish.ts" + ); + + const prepublish = readFileSync(join(ROOT, "scripts", "build", "prepublish.ts"), "utf8"); + assert.match( + prepublish, + /import\s*\{[\s\S]*?fixTlsClientNodeBinary[\s\S]*?TLS_CLIENT_NATIVE_ASSETS[\s\S]*?\}\s*from\s*"\.\/fixTlsClientNodeBinary\.mjs";/, + "prepublish must use the audited TLS client binary verifier" + ); + assert.match(prepublish, /Object\.keys\(TLS_CLIENT_NATIVE_ASSETS\)/); + const fixerCall = prepublish.match(/await\s+fixTlsClientNodeBinary\(\{([\s\S]*?)\}\);/); + assert.ok(fixerCall, "prepublish must await TLS client seed verification for each platform"); + assert.match(fixerCall[1], /\bplatform\b/); + assert.match(fixerCall[1], /\barches\b/); + assert.match(fixerCall[1], /\bstrict:\s*true\b/); + assert.match(fixerCall[1], /\brequireStandalone:\s*true\b/); + + const allTargetCalls = [ + ...prepublish.matchAll(/await\s+verifyAllTlsClientRuntimeSeeds\(([^)]+)\);/g), + ]; + assert.equal(allTargetCalls.length, 2, "verify all targets at both npm artifact boundaries"); + assert.equal(allTargetCalls[0][1].trim(), "standaloneDir"); + assert.equal(allTargetCalls[1][1].trim(), "DIST_DIR"); + + const sourceFixerIndex = allTargetCalls[0].index ?? -1; + const assembleIndex = prepublish.indexOf("assembleStandalone({"); + const finalPruneIndex = prepublish.indexOf("const remainingUnexpectedFiles"); + const finalFixerIndex = allTargetCalls[1].index ?? -1; + const doneIndex = prepublish.indexOf("// ── Done"); + assert.ok( + sourceFixerIndex >= 0 && sourceFixerIndex < assembleIndex, + "all-target source verification must precede assembly" + ); + assert.ok( + finalPruneIndex < finalFixerIndex && finalFixerIndex < doneIndex, + "strict all-target digest verification must follow final pruning and precede success" + ); +}); diff --git a/tests/unit/chatcore-stream-error-result.test.ts b/tests/unit/chatcore-stream-error-result.test.ts index 352877046a8..06868f71970 100644 --- a/tests/unit/chatcore-stream-error-result.test.ts +++ b/tests/unit/chatcore-stream-error-result.test.ts @@ -7,8 +7,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + createSafeAbortError, + formatStreamRecoveryRetryWarning, isSemaphoreCapacityError, createStreamingErrorResult, + getSafeErrorMetadata, getUpstreamErrorIdentifier, } from "../../open-sse/handlers/chatCore/streamErrorResult.ts"; @@ -20,6 +23,35 @@ test("isSemaphoreCapacityError matches the two semaphore codes only", () => { assert.equal(isSemaphoreCapacityError("SEMAPHORE_TIMEOUT"), false); }); +test("formatStreamRecoveryRetryWarning sanitizes hostile error names", () => { + const secret = "STREAM_RECOVERY_NAME_SECRET"; + const credentialName = new Proxy( + {}, + { + get(_target, key) { + if (key === "name") return `password=${secret} /home/alice/recovery.ts`; + throw new Error("hostile recovery metadata"); + }, + } + ); + const credentialWarning = formatStreamRecoveryRetryWarning(1, 4, credentialName); + assert.doesNotMatch(credentialWarning, /STREAM_RECOVERY_NAME_SECRET|\/home\/alice/); + assert.match(credentialWarning, /\[REDACTED\]/); + + const hostileGetter = new Proxy( + {}, + { + get() { + throw new Error("hostile name getter"); + }, + } + ); + assert.equal( + formatStreamRecoveryRetryWarning(2, 4, hostileGetter), + "transparent early-retry 2/4 after truncation" + ); +}); + test("createStreamingErrorResult builds an SSE error envelope with [DONE] terminator", async () => { const result = createStreamingErrorResult(503, "boom"); assert.equal(result.success, false); @@ -43,10 +75,64 @@ test("createStreamingErrorResult attaches optional code and type", async () => { assert.equal(json.error.type, "rate_limit_error"); }); +test("createStreamingErrorResult sanitizes message, code, and type at the SSE boundary", async () => { + const secret = "STREAM_RESULT_SECRET"; + const result = createStreamingErrorResult( + 502, + `upstream password=${secret} at /home/alice/stream.ts:10:2`, + "password_hunter2", + "authorization_BearerSecret" + ); + const body = await result.response.text(); + const json = JSON.parse(body.slice("data: ".length, body.indexOf("\n\n"))); + + assert.equal(json.error.message, "upstream password=[REDACTED]"); + assert.equal(json.error.code, "bad_gateway"); + assert.equal(json.error.type, "server_error"); + assert.doesNotMatch(body, new RegExp(`${secret}|/home/alice|\\bat \\S`)); + assert.equal(result.error, `upstream password=${secret} at /home/alice/stream.ts:10:2`); +}); + test("getUpstreamErrorIdentifier returns a non-empty string code or undefined", () => { assert.equal(getUpstreamErrorIdentifier({ code: "ECONNRESET" }), "ECONNRESET"); assert.equal(getUpstreamErrorIdentifier({ code: "" }), undefined); assert.equal(getUpstreamErrorIdentifier({ code: 123 }), undefined); assert.equal(getUpstreamErrorIdentifier(null), undefined); assert.equal(getUpstreamErrorIdentifier("ECONNRESET"), undefined); + const hostile = new Proxy( + {}, + { + get() { + throw new Error("hostile code getter"); + }, + } + ); + assert.doesNotThrow(() => isSemaphoreCapacityError(hostile)); + assert.equal(getUpstreamErrorIdentifier(hostile), undefined); + const hostileAbort = new Proxy( + {}, + { + get(_target, key) { + if (key === "name") return "AbortError"; + throw new Error("hostile abort metadata"); + }, + } + ); + assert.equal(getSafeErrorMetadata(hostileAbort).name, "AbortError"); + let codeReads = 0; + const mutableCode = new Proxy( + {}, + { + get(_target, key) { + if (key !== "code") return undefined; + codeReads += 1; + return codeReads === 1 ? "SEMAPHORE_TIMEOUT" : "OTHER"; + }, + } + ); + assert.equal(isSemaphoreCapacityError(mutableCode), true); + assert.equal(codeReads, 1); + const safeAbort = createSafeAbortError(); + assert.equal(safeAbort.name, "AbortError"); + assert.equal(safeAbort.message, "Request aborted"); }); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 6ecb0487889..30b88aaaf85 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -16,7 +16,7 @@ const { invalidateCacheControlSettingsCache } = const { clearCache, getCachedResponse, generateSignature } = await import("../../src/lib/semanticCache.ts"); const { clearIdempotency } = await import("../../src/lib/idempotencyLayer.ts"); -const { getPendingRequests, clearPendingRequests } = +const { getPendingRequests, clearPendingRequests, getUsageHistory } = await import("../../src/lib/usage/usageHistory.ts"); const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); const { @@ -29,7 +29,6 @@ const { clearModelLock, isModelLocked } = await import("../../open-sse/services/accountFallback.ts"); const { saveModelsDevCapabilities, clearModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts"); -// Dynamic import is required after TEST_DATA_DIR is initialized above. const { clearReasoningCacheAll } = await import("../../open-sse/services/reasoningCache.ts"); const { getBackgroundDegradationConfig, @@ -326,9 +325,7 @@ async function resetStorage() { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -// 30s ceiling: c8 instrumentation plus --test-concurrency=8 can stall CI workers -// well past the upstream timeout budget. Green runs return as soon as the condition -// holds, so the ceiling only bounds the failure case. +// The 30s ceiling bounds c8/CI stalls; green runs return as soon as the condition holds. async function waitFor(fn, timeoutMs = 30000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -340,7 +337,6 @@ async function waitFor(fn, timeoutMs = 30000) { } async function flushAsyncSideEffects() { - // setImmediate rounds drain the event loop more reliably than setTimeout under CI load. for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); } @@ -372,6 +368,7 @@ async function invokeChatCore({ reasoningTransportFallback = "drop", managedLease = null, cachedSettings = null, + log = noopLog(), }: any = {}) { const calls: any[] = []; @@ -406,7 +403,7 @@ async function invokeChatCore({ apiKey: "sk-test", providerSpecificData: {}, }, - log: noopLog(), + log, clientRawRequest: { endpoint, body: structuredClone(body), @@ -451,10 +448,7 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); test("chatCore times out upstream execution before provider response headers", async () => { - // This test asserts pendingDetail.providerRequest — only attached when the - // call-log pipeline capture is enabled. Declare the dependency explicitly - // (fresh-DB default leaves it off → the waitFor below would never resolve; - // failed deterministically on CI and on an isolated run, incl. at v3.8.18). + // pendingDetail.providerRequest exists only when pipeline capture is enabled. await settingsDb.updateSettings({ call_log_pipeline_enabled: true }); const executor = getExecutor("openai"); const originalGetTimeoutMs = executor.getTimeoutMs?.bind(executor); @@ -493,9 +487,7 @@ test("chatCore times out upstream execution before provider response headers", a } as any); const pendingDetail = (await waitFor(() => - // details[connectionId] is Record — - // the original predicate tested each ARRAY's .providerRequest (always - // undefined), so the waitFor could never resolve. Flatten to the details. + // Flatten the model-keyed detail arrays before matching providerRequest. Object.values(getPendingRequests().details[connectionId] || {}) .flat() .find((detail: any) => detail?.providerRequest?.model === "gpt-4o-mini") @@ -528,7 +520,6 @@ test("chatCore can disable pipeline stream chunk capture through environment", a messages: [{ role: "user", content: "stream without chunk logging" }], }, }); - assert.equal(result.success, true); await result.response.text(); await flushAsyncSideEffects(); @@ -936,8 +927,7 @@ test("chatCore replays streamed DeepSeek Responses reasoning across a Chat tool }); test("chatCore replays no-tool reasoning across public Responses turns", async () => { - // Direct DeepSeek now speaks Responses upstream. Keep this regression on a - // Chat-compatible DeepSeek host so it continues to exercise the Responses-to-Chat replay path. + // Use a Chat-compatible DeepSeek host to exercise Responses-to-Chat replay. saveModelsDevCapabilities({ siliconflow: { "deepseek-v4-pro": { @@ -1284,11 +1274,7 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers" assert.equal(call.body.messages[0].content[0].text, "Ping"); }); -// Fix #2468: normalizeClaudeUpstreamMessages() now runs on the pure Claude passthrough -// path too. It extracts role:"system" messages into the top-level system parameter, -// strips empty text blocks, converts inline document blocks (no url/data) to text, and -// drops unknown block types (e.g. future_block). tool_result blocks are preserved via -// preserveToolResultBlocks:true. +// #2468: native Claude passthrough normalizes system/messages while preserving tool results. test("chatCore normalizes native Claude Code messages for native Claude OAuth passthrough", async () => { const clientMessages = [ { @@ -1334,10 +1320,8 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa assert.equal(result.success, true); assert.equal(call.body.model, "claude-sonnet-4-6"); - // After normalization: role:"system" msg extracted → top-level system (3 msgs remain, not 4) assert.equal(call.body.messages.length, 3); - // system-role block appended to top-level system array assert.equal( call.body.system.some( (block: { text?: string }) => block.text === "system-message-that-should-stay-in-messages" @@ -1345,8 +1329,6 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa true ); - // user msg[0] (was clientMessages[1]): empty text, document and future_block are preserved - // since it is a semantic passthrough request assert.equal(call.body.messages[0].content.length, 4); assert.equal(call.body.messages[0].content[0].type, "text"); assert.equal(call.body.messages[0].content[0].text, ""); @@ -1354,10 +1336,8 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa assert.equal(call.body.messages[0].content[2].type, "document"); assert.equal(call.body.messages[0].content[3].type, "future_block"); - // assistant msg[1] (was clientMessages[2]): tool_use unchanged assert.equal(call.body.messages[1].content[0].type, "tool_use"); - // user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true) assert.equal(call.body.messages[2].content[0].type, "tool_result"); }); test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", async () => { @@ -1457,9 +1437,7 @@ test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough ]); }); -// Fix #2468: normalizeClaudeUpstreamMessages() runs on the CC-compatible bridge path too -// (preserveClaudeMessages=true). Same normalization: system-role → top-level system, -// empty text stripped, document→text, future_block dropped, tool_result preserved. +// #2468: the CC-compatible bridge applies the same native-message normalization. test("chatCore normalizes native Claude Code messages before CC-compatible relay transforms", async () => { const clientMessages = [ { @@ -1512,11 +1490,8 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay assert.match(call.url, /\/v1\/messages\?beta=true$/); assert.equal(call.body.stream, true); - // After normalization: role:"system" msg extracted → top-level system (3 msgs remain, not 4) assert.equal(call.body.messages.length, 3); - // CC bridge prepends its dynamic billing/fingerprint blocks; the SDK identity and - // extracted system block must both remain present regardless of their exact position. assert.equal( call.body.system.some( (block: { text?: string }) => @@ -1531,8 +1506,6 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay true ); - // user msg[0] (was clientMessages[1]): empty text, document and future_block are preserved - // since it is a semantic passthrough request assert.equal(call.body.messages[0].content.length, 4); assert.equal(call.body.messages[0].content[0].type, "text"); assert.equal(call.body.messages[0].content[0].text, ""); @@ -1540,10 +1513,8 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay assert.equal(call.body.messages[0].content[2].type, "document"); assert.equal(call.body.messages[0].content[3].type, "future_block"); - // assistant msg[1] (was clientMessages[2]): tool_use unchanged assert.equal(call.body.messages[1].content[0].type, "tool_use"); - // user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true) assert.equal(call.body.messages[2].content[0].type, "tool_result"); }); test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => { @@ -1736,9 +1707,7 @@ test("chatCore disables raw Claude passthrough when cache preservation is off an ), true ); - // Cache preservation is on for native Claude, so cache markers are intact. This PR: - // an omitted TTL now defaults to "5m" once a "5m" boundary breakpoint (the system - // block above) has already appeared, instead of always defaulting to "1h". + // An omitted TTL inherits the prior 5m cache boundary. assert.deepEqual(call.body.messages[0].content[0].cache_control, { type: "ephemeral", ttl: "5m", @@ -1860,7 +1829,9 @@ test("chatCore restores prefixed Claude passthrough tool names in upstream respo }, }); - const payload = (await result.response.json()) as any; + const payload = (await result.response.json()) as { + error: { code?: string; message: string; type?: string }; + }; assert.equal(result.success, true); assert.equal(payload.content[0].name, "Bash"); }); @@ -1953,19 +1924,21 @@ test("chatCore surfaces translation errors with explicit status codes", async () input: "hello", }, }); - assert.equal(result.success, false); assert.equal(result.status, 409); assert.equal(result.error, "responses translator rejected the payload"); }); -test("chatCore surfaces typed translation errors with the declared error type", async () => { +test("chatCore sanitizes typed translation failures in the response and warning log", async () => { + const secret = "TRANSLATION_BRANCH_SECRET"; + const rawType = "access_token_SECRET123"; + const warnings: string[] = []; register( FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, () => { - const error = new Error("typed translator failure"); + const error = new Error(`typed password=${secret} at /home/alice/translate.ts:8:3`); error.statusCode = 422; - error.errorType = "unsupported_feature"; + error.errorType = rawType; throw error; }, null @@ -1979,15 +1952,120 @@ test("chatCore surfaces typed translation errors with the declared error type", model: "gpt-4o-mini", input: "hello", }, + log: { + ...noopLog(), + warn(_scope: string, message: string) { + warnings.push(message); + }, + }, }); assert.equal(result.success, false); assert.equal(result.status, 422); - const payload = (await result.response.json()) as any; + const payload = (await result.response.json()) as { + error: { code?: string; message: string; type?: string }; + }; + assert.equal(payload.error.message, "typed password=[REDACTED]"); + assert.equal(payload.error.type, "invalid_request_error"); + assert.equal(payload.error.code, "error"); + assert.equal(result.rawMessage, `typed password=${secret} at /home/alice/translate.ts:8:3`); + assert.equal(result.errorType, rawType); + assert.equal(result.errorCode, rawType); + assert.doesNotMatch(JSON.stringify(payload), new RegExp(`${secret}|/home/alice|\\bat \\S`)); + assert.equal(warnings.length, 1); + assert.doesNotMatch(warnings[0], new RegExp(`${secret}|/home/alice|\\bat \\S`)); +}); +test("chatCore preserves safe typed translation error identifiers", async () => { + register( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI, + () => { + const error = new Error("typed translator failure"); + error.statusCode = 422; + error.errorType = "unsupported_feature"; + throw error; + }, + null + ); + + const { result } = await invokeChatCore({ + provider: "openai", + model: "gpt-4o-mini", + endpoint: "/v1/responses", + body: { model: "gpt-4o-mini", input: "hello" }, + }); + const payload = (await result.response.json()) as { + error: { code?: string; type?: string }; + }; + + assert.equal(result.status, 422); assert.equal(payload.error.type, "unsupported_feature"); assert.equal(payload.error.code, "unsupported_feature"); }); +test("chatCore projects non-string classifications and sanitizes provider failure logs", async () => { + const secret = "CHATCORE_SINK_SECRET_42"; + const hostileMessage = + `upstream access_token=${secret}\n` + " at dispatch (/home/alice/transport.ts:10:4)"; + const capturedConsole: string[] = []; + const originalConsoleLog = console.log; + await settingsDb.updateSettings({ call_log_pipeline_enabled: true }); + console.log = (...args: unknown[]) => capturedConsole.push(args.map(String).join(" ")); + let result; + try { + ({ result } = await invokeChatCore({ + provider: "openai", + model: "gpt-4o-mini", + body: { + model: "gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: "trigger a provider error" }], + }, + responseFactory() { + return new Response( + JSON.stringify({ + error: { + message: hostileMessage, + code: 401, + type: { credential: "OPAQUE_TYPE_VALUE" }, + stack: "Error: failed\n at dispatch (/home/alice/provider.ts:7:3)", + }, + diagnostic: { message: hostileMessage }, + safe: { attempt: 2 }, + sessionId: "OPAQUE_SESSION_VALUE", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + }, + })); + } finally { + console.log = originalConsoleLog; + } + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.equal(result.errorCode, undefined); + assert.equal(result.errorType, undefined); + const responsePayload = (await result.response.json()) as { + error: { code?: string; message: string; type?: string }; + }; + assert.equal(responsePayload.error.code, "bad_request"); + assert.equal(responsePayload.error.type, "invalid_request_error"); + + const detail = await waitFor(() => getLatestCallLog()); + assert.ok(detail?.pipelinePayloads); + const pipeline = detail.pipelinePayloads as Record; + const serializedSinks = JSON.stringify({ + console: capturedConsole.filter((line) => line.includes("[ERROR]")), + callLogError: detail.error, + pipelineError: pipeline.error, + providerResponse: pipeline.providerResponse, + responsePayload, + }); + assert.doesNotMatch(serializedSinks, /CHATCORE_SINK_SECRET_42|OPAQUE_TYPE_VALUE/); + assert.doesNotMatch(serializedSinks, /OPAQUE_SESSION_VALUE|\/home\/alice|\bat dispatch\b/); + assert.match(serializedSinks, /\[REDACTED\]/); + assert.match(serializedSinks, /\"attempt\":2/); +}); test("chatCore returns 500 when translation throws a generic error", async () => { register( FORMATS.OPENAI_RESPONSES, @@ -2012,6 +2090,77 @@ test("chatCore returns 500 when translation throws a generic error", async () => assert.equal(result.status, 500); assert.equal(result.error, "unexpected translator crash"); }); +test("chatCore fails closed when a thrown translation message cannot be coerced", async () => { + register( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI, + () => { + throw { + statusCode: 422, + message: { + toString(): string { + throw new Error("hostile translation coercion"); + }, + }, + }; + }, + null + ); + + const { result } = await invokeChatCore({ + provider: "openai", + model: "gpt-4o-mini", + endpoint: "/v1/responses", + body: { model: "gpt-4o-mini", input: "hello" }, + }); + const payload = (await result.response.json()) as { error: { message: string } }; + + assert.equal(result.success, false); + assert.equal(result.status, 422); + assert.equal(result.rawMessage, "Invalid request"); + assert.equal(payload.error.message, "Invalid request"); +}); +test("chatCore fails closed over hostile translation status and type accessors", async () => { + const hostileValues = [ + { + statusCode: Symbol("hostile-status"), + errorType: "unsupported_feature", + message: "symbol status failure", + }, + new Proxy( + { message: "proxy accessor failure" }, + { + get(target, property, receiver) { + if (property === "statusCode" || property === "errorType") { + throw new Error("hostile classification accessor"); + } + return Reflect.get(target, property, receiver); + }, + } + ), + ]; + + for (const hostile of hostileValues) { + register( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI, + () => { + throw hostile; + }, + null + ); + const { result } = await invokeChatCore({ + provider: "openai", + model: "gpt-4o-mini", + endpoint: "/v1/responses", + body: { model: "gpt-4o-mini", input: "hello" }, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 500); + assert.equal(typeof result.error, "string"); + } +}); test("chatCore refreshes GitHub credentials after 401 and retries with the refreshed Copilot token", async () => { let refreshedCredentials = null; const { calls, result } = await invokeChatCore({ @@ -2580,12 +2729,10 @@ test("chatCore 429 lets account fallback apply the configured resilience cooldow }); }, }); - const afterCore = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.success, false); assert.equal(result.status, 429); assert.equal((afterCore as any).rateLimitedUntil, undefined); - const fallback = await auth.markAccountUnavailable( (connection as any).id, result.status, @@ -2596,7 +2743,6 @@ test("chatCore 429 lets account fallback apply the configured resilience cooldow const afterFallback = await providersDb.getProviderConnectionById((connection as any).id); const cooldownRemaining = new Date((afterFallback as any).rateLimitedUntil).getTime() - Date.now(); - assert.equal(fallback.shouldFallback, true); assert.equal(fallback.cooldownMs, 1000); assert.equal((afterFallback as any).testStatus, "unavailable"); @@ -2634,7 +2780,6 @@ test("chatCore does not substitute an OpenAI model after context overflow", asyn "gpt-4o": capabilityEntry(256_000), }, }); - const { calls, result } = await invokeChatCore({ provider: "openai", model: "gpt-5", @@ -2653,7 +2798,6 @@ test("chatCore does not substitute an OpenAI model after context overflow", asyn return buildOpenAIResponse(false, "unexpected fallback"); }, }); - assert.equal(result.success, false); assert.equal(result.status, 400); assert.equal(calls.length, 1); @@ -2671,7 +2815,6 @@ test("chatCore parses upstream SSE payloads for non-streaming requests", async ( return buildOpenAIResponse(true, "sse json"); }, }); - const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(payload.choices[0].message.content, "sse json"); @@ -2692,12 +2835,13 @@ test("chatCore rejects malformed non-streaming SSE payloads", async () => { }); }, }); - assert.equal(result.success, false); assert.equal(result.status, 502); assert.match(result.error, /Invalid SSE response/); }); test("chatCore rejects malformed non-streaming JSON payloads", async () => { + const secret = "MALFORMED_JSON_SINK_SECRET"; + await settingsDb.updateSettings({ call_log_pipeline_enabled: true }); const { result } = await invokeChatCore({ provider: "openai", model: "gpt-4o-mini", @@ -2707,18 +2851,27 @@ test("chatCore rejects malformed non-streaming JSON payloads", async () => { messages: [{ role: "user", content: "return valid json" }], }, responseFactory() { - return new Response("{oops", { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + `not-json access_token=${secret} /home/alice/raw.ts\n at parse (/home/alice/parser.ts:1:2)`, + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); }, }); - assert.equal(result.success, false); assert.equal(result.status, 502); assert.equal(result.error, "Invalid JSON response from provider"); + const detail = await waitFor(() => getLatestCallLog()); + assert.ok(detail?.pipelinePayloads); + const sinks = JSON.stringify({ error: detail.error, pipeline: detail.pipelinePayloads }); + assert.doesNotMatch(sinks, /MALFORMED_JSON_SINK_SECRET|\/home\/alice|\bat parse\b/); + assert.match(sinks, /\[REDACTED\]|/); }); test("chatCore does not substitute an OpenAI model after empty content", async () => { + const secret = "EMPTY_CONTENT_SINK_SECRET"; + await settingsDb.updateSettings({ call_log_pipeline_enabled: true }); const { calls, result } = await invokeChatCore({ provider: "openai", model: "gpt-5.1", @@ -2727,34 +2880,87 @@ test("chatCore does not substitute an OpenAI model after empty content", async ( stream: false, messages: [{ role: "user", content: "recover from empty content" }], }, - responseFactory(_captured, seenCalls) { - if (seenCalls.length === 1) { - return new Response( - JSON.stringify({ - id: "chatcmpl-empty", - object: "chat.completion", - model: "gpt-5.1", - choices: [ - { - index: 0, - message: { role: "assistant", content: "" }, - finish_reason: "stop", - }, - ], - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - } - return buildOpenAIResponse(false, "unexpected fallback"); + responseFactory() { + return new Response( + JSON.stringify({ + id: "chatcmpl-empty", + choices: [{ message: { content: "" }, finish_reason: "stop" }], + diagnostic: `access_token=${secret} /home/alice/empty.ts\n at inspect (/home/alice/trace.ts:1:2)`, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); }, }); - assert.equal(result.success, false); assert.equal(result.status, 502); assert.equal(calls.length, 1); + const detail = await waitFor(() => getLatestCallLog()); + assert.ok(detail?.pipelinePayloads); + const sinks = JSON.stringify({ error: detail.error, pipeline: detail.pipelinePayloads }); + assert.doesNotMatch(sinks, /EMPTY_CONTENT_SINK_SECRET|\/home\/alice|\bat inspect\b/); + assert.match(sinks, /\[REDACTED\]|/); + const { result: malformedResult } = await invokeChatCore({ + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "translate" }] }, + responseFactory() { + return new Response( + JSON.stringify({ + object: "response", + output: [{ diagnostic: `token_v2=${secret} /home/alice/translated.ts` }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }, + }); + assert.equal(malformedResult.status, 502); + const malformedDetail = await waitFor(() => getLatestCallLog()); + const malformedSinks = JSON.stringify({ + responseBody: malformedDetail?.responseBody, + pipeline: malformedDetail?.pipelinePayloads, + }); + assert.doesNotMatch(malformedSinks, /EMPTY_CONTENT_SINK_SECRET|\/home\/alice/); +}); +test("chatCore sanitizes hostile ClinePass retry failures in warning logs", async () => { + const secret = "CLINEPASS_RETRY_SECRET"; + const warnings: string[] = []; + let attempts = 0; + let prototypeReads = 0; + const retryError = new Proxy(new Error(`password=${secret} /home/alice/clinepass.ts`), { + get(target, key, receiver) { + if (key === "errorCode") throw new Error("hostile retry getter"); + return Reflect.get(target, key, receiver); + }, + getPrototypeOf(target) { + prototypeReads += 1; + if (prototypeReads > 1) throw new Error("hostile retry prototype"); + return Reflect.getPrototypeOf(target); + }, + }); + const { result } = await invokeChatCore({ + provider: "clinepass", + model: "cline-pass/glm-5.2", + body: { model: "cline-pass/glm-5.2", messages: [{ role: "user", content: "retry" }] }, + log: { + ...noopLog(), + warn(_scope, message) { + warnings.push(message); + }, + }, + responseFactory() { + attempts += 1; + if (attempts === 1) { + return new Response(JSON.stringify({ success: false, error: "empty response" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw retryError; + }, + }); + assert.equal(result.status, 502); + assert.ok(prototypeReads >= 1); + const retryWarning = warnings.find((message) => message.includes("retry failed")) || ""; + assert.doesNotMatch(retryWarning, /CLINEPASS_RETRY_SECRET|\/home\/alice/); + assert.match(retryWarning, /\[REDACTED\]/); }); test("chatCore returns a gateway error without probing another OpenAI model", async () => { const { result, calls } = await invokeChatCore({ @@ -2786,14 +2992,12 @@ test("chatCore returns a gateway error without probing another OpenAI model", as } ); } - return new Response("{invalid-json", { status: 200, headers: { "Content-Type": "application/json" }, }); }, }); - assert.equal(result.success, false); assert.equal(result.status, 502); assert.equal(result.error, "Provider returned empty content"); @@ -2802,7 +3006,6 @@ test("chatCore returns a gateway error without probing another OpenAI model", as test("chatCore records Claude prompt cache and cache usage metadata in call logs", async () => { await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" }); invalidateCacheControlSettingsCache(); - const { result } = await invokeChatCore({ provider: "claude", model: "claude-sonnet-4-6", @@ -2859,9 +3062,7 @@ test("chatCore records Claude prompt cache and cache usage metadata in call logs ); }, }); - const detail = await waitFor(() => getLatestCallLog()); - assert.equal(result.success, true); assert.ok(detail); assert.equal(detail.requestBody._omniroute.claudePromptCache.applied, true); @@ -2877,12 +3078,7 @@ test("chatCore records Claude prompt cache and cache usage metadata in call logs }); }); test("chatCore propagates budget errors without an executor-level emergency hop", async () => { - // The emergency budget fallback is orchestrated by the routing layer - // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency - // provider through account selection. The old executor-level hop here re-sent - // the FAILING provider's credentials to the emergency provider's endpoint - // (cross-provider credential leak) — the engine must now surface the budget - // error as-is, with no extra upstream call. + // Routing owns emergency fallback; this layer must not reuse credentials across providers. const { calls, result } = await invokeChatCore({ provider: "openai", model: "gpt-4o-mini", @@ -2904,7 +3100,6 @@ test("chatCore propagates budget errors without an executor-level emergency hop" ); }, }); - assert.equal(result.success, false); assert.equal(result.status, 402); assert.equal(calls.length, 1, "no executor-level emergency hop may fire"); @@ -2930,7 +3125,6 @@ test("chatCore injects progress events into streaming responses when requested", return buildOpenAIResponse(true, "streamed"); }, }); - const streamText = await result.response.text(); assert.equal(result.success, true); assert.equal(result.response.headers.get("X-OmniRoute-Progress"), "enabled"); @@ -2950,22 +3144,12 @@ test("chatCore keeps the SSE stream comment-free by default and still ends with return buildOpenAIResponse(true, "streamed"); }, }); - const streamText = await result.response.text(); - assert.equal(result.success, true); - // The per-request metadata reaches the client through these headers regardless - // of the comment setting — that is what makes the trailer optional. assert.equal(result.response.headers.get("X-OmniRoute-Provider"), "openai"); assert.equal(result.response.headers.get("X-OmniRoute-Model"), "gpt-4o-mini"); - // #10524 flipped OMNIROUTE_SSE_COMMENTS to off-by-default: strict SSE clients - // JSON.parse every line and crash on `: x-omniroute-*` comments. This test used - // to assert the opposite and went red on the release branch when that default - // landed. The opt-in half — trailer present, after the finish chunk and before - // [DONE] — is owned by sse-comments-optout-9305.test.ts, which drives the env - // var through all three states; enabling it here instead leaks process.env into - // the sibling call-log tests in this file. + // Strict SSE clients require comments off by default; the opt-in path has its own test. assert.doesNotMatch(streamText, /: x-omniroute-/); assert.match(streamText, /data: \[DONE\]/); }); @@ -3032,29 +3216,155 @@ test("chatCore strips upstream compression and length headers from streaming res await result.response.text(); }); test("chatCore maps upstream aborts to request-aborted errors", async () => { + const abortError = new Error("request aborted by client"); + abortError.name = "AbortError"; const { result } = await invokeChatCore({ - provider: "openai", - model: "gpt-4o-mini", - body: { - model: "gpt-4o-mini", - stream: false, - messages: [{ role: "user", content: "abort me" }], - }, + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "abort me" }] }, responseFactory() { - const error = new Error("request aborted by client"); - error.name = "AbortError"; - throw error; + throw abortError; }, }); - - assert.equal(result.success, false); assert.equal(result.status, 499); assert.equal(result.error, "Request aborted"); }); +test("chatCore sanitizes transport failure message and cause at logging sinks", async () => { + const secret = "TRANSPORT_SINK_SECRET_42"; + const hostileCode = `access_token_${secret}_/home/alice/code.ts`; + const originalConsoleLog = console.log; + const capturedConsole: string[] = []; + await settingsDb.updateSettings({ call_log_pipeline_enabled: true }); + console.log = (...args: unknown[]) => capturedConsole.push(args.map(String).join(" ")); + + let result; + try { + ({ result } = await invokeChatCore({ + provider: "openai", + model: "gpt-4o-mini", + body: { + model: "gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: "trigger a transport failure" }], + }, + responseFactory() { + const error = new Error( + `socket password=${secret}\n at connect (/home/alice/socket.ts:1:2)` + ); + error.cause = { + code: "ECONNRESET", + message: `cause access_token=${secret} at /home/alice/cause.ts:3:4`, + }; + error.code = hostileCode; + throw error; + }, + })); + } finally { + console.log = originalConsoleLog; + } + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal(result.errorCode, hostileCode, "raw code remains available to internal callers"); + assert.match(result.rawMessage, new RegExp(secret)); + const responsePayload = (await result.response.json()) as { + error: { message: string }; + }; + const detail = await waitFor(() => getLatestCallLog()); + assert.ok(detail?.pipelinePayloads); + const serializedSinks = JSON.stringify({ + console: capturedConsole.filter((line) => line.includes("[ERROR]")), + callLogError: detail.error, + pipelineError: (detail.pipelinePayloads as Record).error, + responsePayload, + }); + assert.doesNotMatch(serializedSinks, /TRANSPORT_SINK_SECRET_42|\/home\/alice/); + assert.doesNotMatch(serializedSinks, /\bat (?:connect|cause)\b/); + assert.match(serializedSinks, /\[REDACTED\]/); + const usage = await waitFor(async () => { + const entries = await getUsageHistory({ provider: "openai", model: "gpt-4o-mini" }); + return entries.at(-1); + }); + assert.ok(usage); + assert.equal(usage.errorCode, "upstream_error"); + const proxySecret = "PROXY_METADATA_SECRET"; + const semaphoreFailure = () => + Object.assign(new Error(`password=${secret}\n at wait (/home/alice/semaphore.ts:1:2)`), { + code: "SEMAPHORE_TIMEOUT", + }); + const hostileFailures = [ + { + status: 502, + error: new Proxy( + {}, + { + get(_target, key) { + if (key === "message") return `password=${proxySecret} /home/alice/proxy.ts`; + throw new Error("hostile metadata getter"); + }, + getPrototypeOf() { + throw new Error("hostile prototype"); + }, + } + ), + }, + { + status: 499, + error: new Proxy( + {}, + { + get(_target, key) { + if (key === "name") return "AbortError"; + throw new Error("hostile abort getter"); + }, + getPrototypeOf() { + throw new Error("hostile abort prototype"); + }, + } + ), + }, + { status: 429, stream: true, error: semaphoreFailure() }, + { status: 429, stream: false, error: semaphoreFailure() }, + ]; + const executor = getExecutor("openai"); + const originalExecute = executor.execute; + try { + for (const failure of hostileFailures) { + const previousLogId = failure.status === 429 ? (await getLatestCallLog())?.id : null; + executor.execute = async () => { + throw failure.error; + }; + const { result: proxyResult } = await invokeChatCore({ + accept: failure.stream ? "text/event-stream" : "application/json", + body: { + model: "gpt-4o-mini", + stream: failure.stream === true, + messages: [{ role: "user", content: "proxy" }], + }, + }); + assert.equal(proxyResult.status, failure.status); + const publicBody = await proxyResult.response.text(); + assert.doesNotMatch(publicBody, /TRANSPORT_SINK_SECRET_42|\/home\/alice|hostile /); + if (failure.status === 429) { + assert.equal(proxyResult.errorCode, "SEMAPHORE_TIMEOUT"); + assert.match(publicBody, failure.stream ? /"code":"SEMAPHORE_TIMEOUT"/ : /rate_limit/); + const semaphoreDetail = await waitFor(async () => { + const candidate = await getLatestCallLog(); + return candidate?.id !== previousLogId ? candidate : null; + }); + assert.equal(semaphoreDetail?.status, 429); + const semaphoreSinks = JSON.stringify({ + error: semaphoreDetail?.error, + pipeline: semaphoreDetail?.pipelinePayloads, + }); + assert.doesNotMatch(semaphoreSinks, /TRANSPORT_SINK_SECRET_42|\/home\/alice|\bat wait\b/); + assert.match(semaphoreSinks, /\[REDACTED\]/); + } + } + } finally { + executor.execute = originalExecute; + } +}); test("chatCore maps raw string abort reasons to 499, not 502 (#7907)", async () => { - // abort(reason) rejects the upstream fetch with the raw reason — often a - // bare string with no `name`/`status`. It must map to 499 like a named - // AbortError, not fall through to the 502 provider-failure default. + // Raw abort reasons must map to 499 like a named AbortError. const { result } = await invokeChatCore({ provider: "openai", model: "gpt-4o-mini", @@ -3073,18 +3383,9 @@ test("chatCore maps raw string abort reasons to 499, not 502 (#7907)", async () assert.equal(result.error, "Request aborted"); }); -// Live incident territory (dashboard log id 1784504040241-6f8b9a): the client had -// ALREADY disconnected before this synthetic error body was ever computed — nothing -// was actually delivered to it. Persisting that body as `clientResponse` (the -// dashboard's "what the client received" field) is misleading, since it implies a -// response was sent when the client never got one. `error` above already records -// the failure reason; `clientResponse`/`responseBody` should stay empty for an abort. +// A disconnected client received no body, so its call log must not synthesize one. test("chatCore does not log a synthetic clientResponse body for a client abort", async () => { - // clientResponse only ever lands in the persisted pipeline payloads when detailed - // call-log capture is on (attemptLogging.ts's detailedLoggingEnabled gate) — this is - // exactly the setting a real "detailed logging" connection/request has enabled, which - // is why the live incident's artifact JSON had a full pipeline (including the - // misleading clientResponse) to begin with. + // Detailed capture is required to assert the persisted pipeline field. await settingsDb.updateSettings({ call_log_pipeline_enabled: true }); await invokeChatCore({ @@ -3160,12 +3461,7 @@ test("chatCore returns streaming responses without waiting for upstream completi const raceResult = await Promise.race([ invocation.then(() => "returned"), - // 10s ceiling: a non-buffering streaming impl resolves the invocation as soon - // as the Response is returned (upstream still open), but on a starved CI event - // loop that legitimate early return can exceed a 1s wall-clock budget (flake - // repro: 5/8 runs returned at 1.3–2.2s under CPU contention → false "blocked"). - // The ceiling only bounds the buffered-failure case: a buffering impl never - // resolves until closeUpstream() fires below, so it still trips "blocked". + // The 10s ceiling tolerates CI starvation while still detecting buffered responses. new Promise((resolve) => setTimeout(() => resolve("blocked"), 10000)), ]); @@ -3301,8 +3597,7 @@ test("chatCore caches streaming response and serves cache HIT on repeat", async assert.equal(second.calls.length, 0, "second request should not reach upstream"); assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "HIT"); - // #2952 — a streaming client receives the cache HIT as an SSE stream (not a - // raw JSON body), so content + reasoning_content arrive in the streaming shape. + // #2952: streaming cache hits remain SSE-framed. assert.equal( second.result.response.headers.get("Content-Type"), "text/event-stream", @@ -3431,9 +3726,7 @@ test("chatCore returns cache HIT as SSE when the client requests streaming", asy assert.equal(second.calls.length, 0, "cached response should prevent upstream call"); assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "HIT"); - // #2952 — even though the cache was populated by a non-streaming request, a - // later streaming request gets the cached completion SSE-wrapped, so streaming - // clients keep their streaming shape (and reasoning_content) on cache hits. + // #2952: a JSON-populated cache still serves later streaming hits as SSE. assert.equal( second.result.response.headers.get("Content-Type"), "text/event-stream", diff --git a/tests/unit/chatgpt-web-handoff-resume.test.ts b/tests/unit/chatgpt-web-handoff-resume.test.ts index bb309e99526..3dcabc1711e 100644 --- a/tests/unit/chatgpt-web-handoff-resume.test.ts +++ b/tests/unit/chatgpt-web-handoff-resume.test.ts @@ -5,6 +5,7 @@ import type { TlsFetchOptions } from "../../open-sse/services/chatgptTlsClient.t const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import("../../open-sse/executors/chatgpt-web.ts"); +const { resumeChatGptHandoff } = await import("../../open-sse/executors/chatgpt-web/handoff.ts"); const { __setTlsFetchOverrideForTesting } = await import("../../open-sse/services/chatgptTlsClient.ts"); @@ -258,3 +259,101 @@ test("ChatGPT Web streaming appends the native resumed Pro answer", async () => mock.restore(); } }); + +test("ChatGPT Web handoff logs sanitize upstream response and transport details", async () => { + const cases = [ + { + label: "response body", + run: async () => ({ + status: 502, + headers: makeHeaders({ "Content-Type": "text/plain" }), + text: + "Cannot read download_url_https://files.oaiusercontent.com/private?sig=OPAQUE-HANDOFF-URL " + + "and '/srv/private/handoff.pem' access_token=sk-handoff-body\n" + + " at /srv/private/handoff.ts:1", + body: null, + }), + expected: + "conversation resume 502: Cannot read download_url_ and '' access_token=[REDACTED]", + }, + { + label: "transport error", + run: async () => { + throw new Error( + "Cannot open '/srv/private/handoff.sock' access_token=sk-handoff-error\n" + + " at /srv/private/handoff.ts:2" + ); + }, + expected: "conversation resume failed: Cannot open '' access_token=[REDACTED]", + }, + { + label: "stack-only transport error", + run: async () => { + throw new Error("\n at /srv/private/handoff.ts:3"); + }, + expected: "conversation resume failed: upstream error unavailable", + }, + { + label: "hostile prototype transport error", + run: async () => { + throw new Proxy( + {}, + { + getPrototypeOf() { + throw new Error( + "access_token=handoff-prototype-secret at /srv/private/handoff-prototype.ts:1:2" + ); + }, + get(_target, property) { + if (property === "toString") { + return () => { + throw new Error( + "access_token=handoff-coercion-secret at /srv/private/handoff-coercion.ts:1:2" + ); + }; + } + return undefined; + }, + } + ); + }, + expected: "conversation resume failed: upstream error unavailable", + }, + { + label: "conversation id", + conversationId: "conversation-opaque-01J9YQ8Z4K7M6N5P3R2T", + run: async () => ({ + status: 404, + headers: makeHeaders({ "Content-Type": "text/plain" }), + text: "not ready", + body: null, + }), + expected: "conversation resume returned no assistant text for ", + }, + ]; + + for (const { label, conversationId, run, expected } of cases) { + const warnings: string[] = []; + __setTlsFetchOverrideForTesting(run); + try { + const answer = await resumeChatGptHandoff({ + conversationId: conversationId ?? `conversation-${label}`, + resumeToken: "resume-token", + headers: {}, + timeoutMs: 1_000, + log: { warn: (_tag, message) => warnings.push(message) }, + readContent: async function* () {}, + }); + + assert.equal(answer, null); + assert.deepEqual(warnings, [expected]); + assert.doesNotMatch( + warnings.join("\n"), + /\/srv\/private|sk-handoff|handoff(?:-prototype|-coercion)?\.ts|handoff-(?:prototype|coercion)-secret|conversation-opaque|files\.oaiusercontent|OPAQUE-HANDOFF/, + `${label} must not expose upstream paths, tokens, or stack frames` + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + } +}); diff --git a/tests/unit/chatgpt-web-tools-5240.test.ts b/tests/unit/chatgpt-web-tools-5240.test.ts index ac6bc9046af..69b4fe77a0b 100644 --- a/tests/unit/chatgpt-web-tools-5240.test.ts +++ b/tests/unit/chatgpt-web-tools-5240.test.ts @@ -8,8 +8,12 @@ import test from "node:test"; import assert from "node:assert/strict"; +import type { ExecuteInput } from "../../open-sse/executors/base.ts"; +import type { TlsFetchOptions } from "../../open-sse/services/tlsClientBase.ts"; + const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import("../../open-sse/executors/chatgpt-web.ts"); +const { buildToolModeResponse } = await import("../../open-sse/executors/chatgptWebTools.ts"); const { __setTlsFetchOverrideForTesting } = await import("../../open-sse/services/chatgptTlsClient.ts"); @@ -55,7 +59,7 @@ function convWithAssistantText(parts: string) { function installMockFetch(convEvents: unknown[]) { const calls = { urls: [] as string[], bodies: [] as unknown[] }; - __setTlsFetchOverrideForTesting(async (url: string, opts: any = {}) => { + __setTlsFetchOverrideForTesting(async (url: string, opts: TlsFetchOptions = {}) => { const u = String(url); calls.urls.push(u); calls.bodies.push(opts.body); @@ -126,9 +130,11 @@ const WEATHER_TOOL = { const TOOL_CALL_TEXT = '{"name":"get_weather","arguments":{"location":"Tokyo"}}'; -function baseOpts(extra: Record) { +function baseOpts(extra: Partial): ExecuteInput { return { model: "gpt-5.5", + body: {}, + stream: false, credentials: { apiKey: "test" }, signal: AbortSignal.timeout(10_000), log: null, @@ -136,6 +142,68 @@ function baseOpts(extra: Record) { }; } +test("Tool response helper preserves upstream errors before any SSE replay", async () => { + const body = JSON.stringify({ + error: { + message: "Rate limited: access_token=[REDACTED] at ", + type: "upstream_error", + code: "HTTP_429", + }, + }); + const upstream = new Response(body, { + status: 429, + headers: { "Content-Type": "application/json", "Retry-After": "17" }, + }); + + const response = await buildToolModeResponse(upstream, [WEATHER_TOOL], true, { + cid: "chatcmpl-error", + created: 1, + model: "gpt-5.5", + }); + + assert.equal(response, upstream, "the original error Response must pass through unchanged"); + assert.equal(response.status, 429); + assert.equal(response.headers.get("Retry-After"), "17"); + assert.equal(response.headers.get("Content-Type"), "application/json"); + assert.equal(await response.text(), body); +}); + +test("Tools stream: ChatGPT chunk.error remains a sanitized HTTP error", async () => { + __resetChatGptWebCachesForTesting(); + const m = installMockFetch([ + { + error: + "Cannot stream /srv/private/tool-error.json access_token=sk-tool-stream\n" + + " at /srv/private/tool-error.ts:1", + }, + ]); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute( + baseOpts({ + body: { + messages: [{ role: "user", content: "What is the weather in Tokyo?" }], + tools: [WEATHER_TOOL], + stream: true, + }, + stream: true, + }) + ); + + assert.equal(result.response.status, 502); + assert.equal(result.response.headers.get("Content-Type"), "application/json"); + const body = await result.response.json(); + assert.deepEqual(body.error, { + message: "Cannot stream ", + type: "upstream_error", + code: "CHATGPT_ERROR", + }); + assert.doesNotMatch(JSON.stringify(body), /\/srv\/private|sk-tool-stream|tool-error\.ts/); + } finally { + m.restore(); + } +}); + test("Tools request-side: contract is serialized into the upstream system message (#5240)", async () => { __resetChatGptWebCachesForTesting(); const m = installMockFetch(convWithAssistantText("ok")); @@ -148,13 +216,18 @@ test("Tools request-side: contract is serialized into the upstream system tools: [WEATHER_TOOL], }, stream: false, - }) as any + }) ); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); assert.ok(convIdx >= 0, "conversation endpoint was hit"); - const convBody = JSON.parse(m.calls.bodies[convIdx] as string); - const systemMsg = convBody.messages.find((mm: any) => mm.author.role === "system"); + const convBody = JSON.parse(m.calls.bodies[convIdx] as string) as { + messages: Array<{ + author: { role: string }; + content: { parts: string[] }; + }>; + }; + const systemMsg = convBody.messages.find((message) => message.author.role === "system"); assert.ok(systemMsg, "a system message carrying the tool contract was sent"); const systemText = systemMsg.content.parts.join(""); assert.match(systemText, //, "system prompt instructs the model to emit blocks"); @@ -176,7 +249,7 @@ test("Tools non-stream: {...} text becomes OpenAI tool_calls + fini tools: [WEATHER_TOOL], }, stream: false, - }) as any + }) ); assert.equal(result.response.status, 200); @@ -207,7 +280,7 @@ test("Tools stream: terminal chunk carries delta.tool_calls + finish_reason tool stream: true, }, stream: true, - }) as any + }) ); assert.equal(result.response.status, 200); @@ -242,7 +315,7 @@ test("Tools regression: no-tools request still streams plain content with finish baseOpts({ body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, - }) as any + }) ); const text = await result.response.text(); diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index 267c0eb6dc3..00a31142328 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -34,6 +34,27 @@ function makeHeaders(map = {}) { return h; } +function hostilePrototypeFailure(label: string): unknown { + return new Proxy( + {}, + { + getPrototypeOf() { + throw new Error(`access_token=${label}-prototype-secret at /srv/private/${label}.ts:1:2`); + }, + get(_target, property) { + if (property === "toString") { + return () => { + throw new Error( + `access_token=${label}-coercion-secret at /srv/private/${label}-coercion.ts:1:2` + ); + }; + } + return undefined; + }, + } + ); +} + async function withEnv(overrides, fn) { const keys = [ "OMNIROUTE_PUBLIC_BASE_URL", @@ -41,6 +62,9 @@ async function withEnv(overrides, fn) { "NEXT_PUBLIC_BASE_URL", "BASE_URL", "PORT", + "OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS", + "OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS", + "OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS", ]; const previous = new Map(keys.map((key) => [key, process.env[key]])); @@ -69,7 +93,9 @@ type MockTlsConfig = { body?: unknown; setCookie?: string; error?: unknown; + requestError?: unknown; events?: unknown[]; + streamError?: unknown; }; type MockFetchOptions = { @@ -77,26 +103,31 @@ type MockFetchOptions = { sentinel?: MockTlsConfig; conv?: MockTlsConfig; dpl?: MockTlsConfig; + warmup?: MockTlsConfig; fileDownload?: MockTlsConfig; attachmentDownload?: MockTlsConfig; conversationDetail?: MockTlsConfig | MockTlsConfig[]; signedDownload?: MockTlsConfig; + webSocketRegister?: MockTlsConfig | MockTlsConfig[]; onSession?: (opts: TlsFetchOptions) => void; onSentinel?: (opts: TlsFetchOptions) => void; onConv?: (opts: TlsFetchOptions) => void; onFileDownload?: (opts: TlsFetchOptions, fileId: string) => void; onAttachmentDownload?: (opts: TlsFetchOptions, fileId: string) => void; + onWebSocketRegister?: (opts: TlsFetchOptions, call: number) => void; }; type MockFetchCalls = { session: number; dpl: number; + warmup: number; sentinel: number; conv: number; fileDownload: number; attachmentDownload: number; conversationDetail: number; signedDownload: number; + webSocketRegister: number; urls: string[]; headers: Array | undefined>; bodies: Array; @@ -109,25 +140,30 @@ function installMockFetch({ sentinel, conv, dpl, + warmup, fileDownload, attachmentDownload, conversationDetail, signedDownload, + webSocketRegister, onSession, onSentinel, onConv, onFileDownload, onAttachmentDownload, + onWebSocketRegister, }: MockFetchOptions = {}) { const calls: MockFetchCalls = { session: 0, dpl: 0, + warmup: 0, sentinel: 0, conv: 0, fileDownload: 0, attachmentDownload: 0, conversationDetail: 0, signedDownload: 0, + webSocketRegister: 0, urls: [], headers: [], bodies: [], @@ -149,6 +185,7 @@ function installMockFetch({ status: 200, body: '', }; + if (cfg.error) throw cfg.error; return { status: cfg.status, headers: makeHeaders({ "Content-Type": "text/html" }), @@ -157,6 +194,22 @@ function installMockFetch({ }; } + if ( + warmup && + (u.includes("/backend-api/me") || + u.includes("/backend-api/conversations?") || + u.includes("/backend-api/models?")) + ) { + calls.warmup++; + if (warmup.error) throw warmup.error; + return { + status: warmup.status, + headers: makeHeaders({ "Content-Type": "application/json" }), + text: typeof warmup.body === "string" ? warmup.body : JSON.stringify(warmup.body || {}), + body: null, + }; + } + if (u.includes("/api/auth/session")) { calls.session++; if (onSession) onSession(opts); @@ -168,6 +221,7 @@ function installMockFetch({ user: { id: "user-1" }, }, }; + if (cfg.error) throw cfg.error; const headers = makeHeaders({ "Content-Type": "application/json" }); if (cfg.setCookie) headers.set("set-cookie", cfg.setCookie); return { @@ -185,6 +239,7 @@ function installMockFetch({ status: 200, body: { token: "req-token", proofofwork: { required: false } }, }; + if (cfg.error) throw cfg.error; return { status: cfg.status, headers: makeHeaders({ "Content-Type": "application/json" }), @@ -205,6 +260,7 @@ function installMockFetch({ status: 200, body: { download_url: `https://files.oaiusercontent.com/${m1[1]}?sig=mock` }, }; + if (cfg.error) throw cfg.error; return { status: cfg.status, headers: makeHeaders({ "Content-Type": "application/json" }), @@ -224,6 +280,7 @@ function installMockFetch({ status: 200, body: { download_url: `https://files.oaiusercontent.com/${m1[1]}?sig=mock` }, }; + if (cfg.error) throw cfg.error; return { status: cfg.status, headers: makeHeaders({ "Content-Type": "application/json" }), @@ -240,6 +297,7 @@ function installMockFetch({ if (/^https:\/\/files\.oaiusercontent\.com\//.test(u)) { calls.signedDownload++; const cfg = signedDownload ?? { status: 200 }; + if (cfg.error) throw cfg.error; if (cfg.status >= 400) { return { status: cfg.status, @@ -262,6 +320,22 @@ function installMockFetch({ }; } + if (u.includes("/backend-api/celsius/ws/user") || u.includes("/register-websocket")) { + calls.webSocketRegister++; + if (onWebSocketRegister) onWebSocketRegister(opts, calls.webSocketRegister); + const cfg = Array.isArray(webSocketRegister) + ? (webSocketRegister[Math.min(calls.webSocketRegister - 1, webSocketRegister.length - 1)] ?? + webSocketRegister[webSocketRegister.length - 1]) + : (webSocketRegister ?? { status: 404, body: "not mocked" }); + if (cfg.error) throw cfg.error; + return { + status: cfg.status, + headers: makeHeaders({ "Content-Type": "application/json" }), + text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}), + body: null, + }; + } + // /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff. { const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/); @@ -289,6 +363,7 @@ function installMockFetch({ }, }, }); + if (cfg.error) throw cfg.error; const text = typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}); return { status: cfg.status, @@ -332,6 +407,7 @@ function installMockFetch({ }, ], }; + if (cfg.requestError) throw cfg.requestError; if (cfg.error) { return { status: cfg.status, @@ -340,6 +416,18 @@ function installMockFetch({ body: null, }; } + if (cfg.streamError) { + return { + status: cfg.status, + headers: makeHeaders({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.error(cfg.streamError); + }, + }), + }; + } return { status: cfg.status, headers: makeHeaders({ "Content-Type": "text/event-stream" }), @@ -546,6 +634,47 @@ test("Refreshed cookie: surfaced via onCredentialsRefreshed callback", async () } }); +test("Refreshed cookie: persistence warning sanitizes external error details", async () => { + reset(); + const sensitiveError = new Error( + "Cannot persist '/home/alice/My Project/private/cookie.pem' access_token=sk-super-secret" + ); + const m = installMockFetch({ + session: { + status: 200, + body: { + accessToken: "jwt-abc", + expires: new Date(Date.now() + 3600_000).toISOString(), + user: { id: "user-1" }, + }, + setCookie: "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure", + }, + }); + const warningLogs: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "old-cookie-warning" }, + signal: AbortSignal.timeout(10_000), + log: { warn: (_tag, message) => warningLogs.push(message) }, + onCredentialsRefreshed: async () => { + throw sensitiveError; + }, + }); + + assert.equal(result.response.status, 200, "persistence failure must remain non-fatal"); + assert.deepEqual(warningLogs, [ + "Failed to persist refreshed cookie: Cannot persist '' access_token=[REDACTED]", + ]); + assert.doesNotMatch(warningLogs.join("\n"), /\/home\/alice|My Project|sk-super-secret/); + } finally { + m.restore(); + } +}); + // ─── Sentinel + PoW ───────────────────────────────────────────────────────── test("Sentinel: chat-requirements is hit before /backend-api/conversation", async () => { @@ -742,6 +871,258 @@ test("Streaming: produces valid SSE chunks ending with [DONE]", async () => { } }); +test("Streaming: reader errors sanitize the public SSE delta", async () => { + reset(); + const secret = "sk-stream-secret"; + const m = installMockFetch({ + conv: { + status: 200, + streamError: new Error( + "read failed /srv/private/key.pem access_token=" + secret + "\n at /srv/stack.ts:1" + ), + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }], stream: true }, + stream: true, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.match(text, /\[Stream error: read failed \]/); + assert.ok(!text.includes("/srv/private/key.pem")); + assert.ok(!text.includes(secret)); + assert.ok(!text.includes("stack.ts")); + assert.match(text, /data: \[DONE\]/); + } finally { + m.restore(); + } +}); + +test("Stack-only stream and fetch failures use a stable public fallback", async () => { + reset(); + let m = installMockFetch({ + conv: { + status: 200, + streamError: new Error("\n at /srv/private/stack-only-stream.ts:1"), + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }], stream: true }, + stream: true, + credentials: { apiKey: "stack-only-stream" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + const text = await result.response.text(); + assert.match(text, /\[Stream error: upstream error unavailable\]/); + assert.doesNotMatch(text, /\/srv\/private|stack-only-stream\.ts/); + } finally { + m.restore(); + } + + reset(); + m = installMockFetch({ + conv: { + status: 0, + requestError: new Error("\n at /srv/private/stack-only-fetch.ts:1"), + }, + }); + const errors: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "stack-only-fetch" }, + signal: AbortSignal.timeout(10_000), + log: { error: (_tag, message) => errors.push(message) }, + }); + assert.equal(result.response.status, 502); + const body = await result.response.json(); + assert.equal(body.error.message, "ChatGPT connection failed: upstream error unavailable"); + assert.deepEqual(errors, ["Fetch failed: upstream error unavailable"]); + assert.doesNotMatch( + JSON.stringify(body) + errors.join("\n"), + /\/srv\/private|stack-only-fetch\.ts/ + ); + } finally { + m.restore(); + } +}); + +test("Streaming: upstream error chunks sanitize the public SSE delta", async () => { + reset(); + const secret = "sk-upstream-stream-secret"; + const m = installMockFetch({ + conv: { + status: 200, + events: [ + { + error: + "upstream failed /srv/private/key.pem access_token=" + + secret + + "\n at /srv/stack.ts:1", + }, + ], + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }], stream: true }, + stream: true, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.match(text, /\[Error: upstream failed \]/); + assert.ok(!text.includes("/srv/private/key.pem")); + assert.ok(!text.includes(secret)); + assert.ok(!text.includes("stack.ts")); + } finally { + m.restore(); + } +}); + +test("Non-streaming: upstream error chunks sanitize the public JSON error", async () => { + reset(); + const secret = "sk-upstream-json-secret"; + const m = installMockFetch({ + conv: { + status: 200, + events: [ + { + error: + "upstream failed /srv/private/key.pem access_token=" + + secret + + "\n at /srv/stack.ts:1", + }, + ], + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + assert.equal(result.response.status, 502); + const body = await result.response.json(); + assert.equal(body.error.message, "upstream failed "); + assert.ok(!JSON.stringify(body).includes("/srv/private/key.pem")); + assert.ok(!JSON.stringify(body).includes(secret)); + assert.ok(!JSON.stringify(body).includes("stack.ts")); + } finally { + m.restore(); + } +}); + +test("Upstream error chunks use the stable fallback after sanitization", async () => { + for (const stream of [true, false]) { + reset(); + const m = installMockFetch({ + conv: { + status: 200, + events: [{ error: " \n at /srv/private/chunk-stack-only.ts:1" }], + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }], stream }, + stream, + credentials: { apiKey: `chunk-fallback-${stream}` }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + if (stream) { + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.match(text, /\[Error: upstream error unavailable\]/); + assert.doesNotMatch(text, /chunk-stack-only|\/srv\/private/); + } else { + assert.equal(result.response.status, 502); + const body = await result.response.json(); + assert.deepEqual(body.error, { + message: "upstream error unavailable", + type: "upstream_error", + code: "CHATGPT_ERROR", + }); + } + } finally { + m.restore(); + } + } +}); + +for (const stream of [true, false]) { + test(`Upstream error chunks remove boundaryless URLs from ${stream ? "SSE" : "JSON"}`, async () => { + reset(); + const m = installMockFetch({ + conv: { + status: 200, + events: [ + { + error: + "upstream download_url_https://files.oaiusercontent.com/private/item" + + "?sig=OPAQUE-CHUNK-URL", + }, + ], + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }], stream }, + stream, + credentials: { apiKey: `chunk-url-${stream}` }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + + if (stream) { + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.match(text, /\[Error: upstream download_url_\]/); + assert.doesNotMatch(text, /files\.oaiusercontent\.com|OPAQUE-CHUNK/); + } else { + assert.equal(result.response.status, 502); + const body = await result.response.json(); + assert.deepEqual(body.error, { + message: "upstream download_url_", + type: "upstream_error", + code: "CHATGPT_ERROR", + }); + } + } finally { + m.restore(); + } + }); +} + test("Streaming: cumulative parts are diffed into non-overlapping deltas", async () => { reset(); const m = installMockFetch({ @@ -938,6 +1319,290 @@ test("Error: 403 from sentinel returns 403 SENTINEL_BLOCKED", async () => { } }); +test("Error: session and sentinel failures sanitize error logs", async () => { + const sensitiveError = new Error( + "Cannot load '/home/alice/My Project/private/secret.pem' access_token=sk-super-secret" + ); + const cases = [ + { + options: { session: { status: 0, error: sensitiveError } }, + expected: "Session exchange failed: Cannot load '' access_token=[REDACTED]", + }, + { + options: { sentinel: { status: 0, error: sensitiveError } }, + expected: "Sentinel failed: Cannot load '' access_token=[REDACTED]", + }, + ]; + + for (const { options, expected } of cases) { + reset(); + const m = installMockFetch(options); + const errorLogs: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: { error: (_tag, message) => errorLogs.push(message) }, + }); + assert.equal(result.response.status, 502); + assert.deepEqual(errorLogs, [expected]); + assert.doesNotMatch(errorLogs.join("\n"), /\/home\/alice|My Project|sk-super-secret/); + } finally { + m.restore(); + } + } +}); + +test("Error: hostile session and sentinel prototypes degrade to sanitized 502s", async () => { + const cases = [ + { + label: "session", + options: { session: { status: 0, error: hostilePrototypeFailure("session-proxy") } }, + expectedMessage: "ChatGPT session exchange failed: upstream error unavailable", + expectedLog: "Session exchange failed: upstream error unavailable", + }, + { + label: "sentinel", + options: { sentinel: { status: 0, error: hostilePrototypeFailure("sentinel-proxy") } }, + expectedMessage: "ChatGPT sentinel failed: upstream error unavailable", + expectedLog: "Sentinel failed: upstream error unavailable", + }, + ] as const; + + for (const testCase of cases) { + reset(); + const errorLogs: string[] = []; + const m = installMockFetch(testCase.options); + try { + const result = await new ChatGptWebExecutor().execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: { error: (_tag, message) => errorLogs.push(message) }, + }); + + assert.equal(result.response.status, 502, testCase.label); + const responseText = await result.response.text(); + assert.equal(JSON.parse(responseText).error.message, testCase.expectedMessage); + assert.deepEqual(errorLogs, [testCase.expectedLog]); + assert.doesNotMatch( + responseText + errorLogs.join("\n"), + /session-proxy|sentinel-proxy|prototype-secret|coercion-secret|\/srv\/private/ + ); + } finally { + m.restore(); + } + } +}); + +test("Error: DPL fallback warning sanitizes details and request continues", async () => { + reset(); + const sensitiveError = new Error( + "Cannot load '/home/alice/My Project/private/dpl.pem' access_token=sk-super-secret" + ); + const m = installMockFetch({ dpl: { status: 0, error: sensitiveError } }); + const warningLogs: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "dpl-warning-cookie" }, + signal: AbortSignal.timeout(10_000), + log: { warn: (_tag, message) => warningLogs.push(message) }, + }); + + assert.equal(result.response.status, 200, "DPL fallback must remain non-fatal"); + assert.deepEqual(warningLogs, [ + "DPL warmup failed (continuing with fallback): Cannot load '' access_token=[REDACTED]", + ]); + assert.doesNotMatch(warningLogs.join("\n"), /\/home\/alice|My Project|sk-super-secret/); + } finally { + m.restore(); + } +}); + +test("Error: browser warmup debug logs sanitize external failures", async () => { + const cases = [ + { + error: new Error( + "Cannot open '/srv/private/warmup.sock' access_token=sk-warmup-secret\n" + + " at /srv/private/warmup.ts:1" + ), + detail: "Cannot open '' access_token=[REDACTED]", + }, + { + error: new Error("\n at /srv/private/warmup.ts:2"), + detail: "upstream error unavailable", + }, + ]; + + for (const [index, { error, detail }] of cases.entries()) { + reset(); + const m = installMockFetch({ warmup: { status: 0, error } }); + const debugLogs: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: `warmup-log-cookie-${index}` }, + signal: AbortSignal.timeout(10_000), + log: { debug: (_tag, message) => debugLogs.push(message) }, + }); + + assert.equal(result.response.status, 200, "warmup failure must remain non-fatal"); + assert.equal(m.calls.warmup, 3); + assert.equal(debugLogs.filter((message) => message.includes("warmup ")).length, 3); + assert.ok( + debugLogs.every((message) => !message.includes("warmup ") || message.endsWith(detail)) + ); + assert.doesNotMatch(debugLogs.join("\n"), /\/srv\/private|sk-warmup|warmup\.ts/); + } finally { + m.restore(); + } + } +}); + +test("Error: conversation poll logs sanitize response bodies and transport failures", async () => { + const secretBody = "access_token=sk-conversation-body /srv/private/poll-body.json"; + const cases = [ + { + detail: { status: 403, body: secretBody }, + expected: "conversation poll 403: [REDACTED_DATA_URL]", + }, + { + detail: [ + { + status: 0, + error: new Error( + "Cannot poll '/srv/private/poll.sock' access_token=sk-conversation-error\n" + + " at /srv/private/poll.ts:1" + ), + }, + { status: 404, body: "stop" }, + ], + expected: "conversation poll failed: Cannot poll '' access_token=[REDACTED]", + }, + ]; + + for (const [index, { detail, expected }] of cases.entries()) { + reset(); + const conversationId = `conv-poll-opaque-01J9YQ8Z4K7M6N5P3R2T-${index}`; + const m = installMockFetch({ + conv: { + status: 200, + events: [ + { + conversation_id: conversationId, + message: { + id: `progress-${index}`, + author: { role: "assistant" }, + content: { content_type: "text", parts: ["working"] }, + status: "in_progress", + }, + }, + { __event: "stream_handoff", conversation_id: conversationId }, + ], + }, + conversationDetail: detail, + }); + const warnings: string[] = []; + try { + await withEnv( + { + OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS: "100", + OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS: "1", + }, + async () => { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.6-sol-pro", + body: { messages: [{ role: "user", content: "hard problem" }] }, + stream: false, + credentials: { apiKey: `poll-log-cookie-${index}` }, + signal: AbortSignal.timeout(10_000), + log: { warn: (_tag, message) => warnings.push(message) }, + }); + assert.equal(result.response.status, 200); + } + ); + + assert.ok(warnings.includes(expected), `missing sanitized log: ${expected}`); + assert.doesNotMatch( + warnings.join("\n"), + /\/srv\/private|sk-conversation|poll\.ts|c2stY29udmVyc2F0aW9u|conv-poll-opaque/, + "conversation polling must not expose raw or base64-encoded upstream secrets" + ); + } finally { + m.restore(); + } + } +}); + +test("Error: upstream error-body warning sanitizes details", async () => { + reset(); + const sensitiveMessage = + "Cannot load download_url_https://files.oaiusercontent.com/private/item?sig=OPAQUE-SIGNED-URL-123 " + + "and '/home/alice/My Project/private/upstream.pem' access_token=sk-super-secret"; + const m = installMockFetch({ conv: { status: 500, error: sensitiveMessage } }); + const warningLogs: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "upstream-warning-cookie" }, + signal: AbortSignal.timeout(10_000), + log: { warn: (_tag, message) => warningLogs.push(message) }, + }); + + assert.equal(result.response.status, 500); + assert.match( + warningLogs[0], + /conv 500: .*download_url_.*.*access_token=\[REDACTED\]/ + ); + assert.doesNotMatch( + warningLogs.join("\n"), + /\/home\/alice|My Project|sk-super-secret|files\.oaiusercontent\.com|OPAQUE-SIGNED/ + ); + } finally { + m.restore(); + } +}); + +test("Error: upstream log details are bounded", async () => { + reset(); + const m = installMockFetch({ conv: { status: 500, error: "x".repeat(1_000) } }); + const warningLogs: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "bounded-upstream-warning" }, + signal: AbortSignal.timeout(10_000), + log: { warn: (_tag, message) => warningLogs.push(message) }, + }); + + assert.equal(result.response.status, 500); + assert.ok(warningLogs[0].startsWith("conv 500: ")); + assert.ok(warningLogs[0].length <= "conv 500: ".length + 300); + } finally { + m.restore(); + } +}); + test("Error: 429 from conversation returns 429 with rate-limit message", async () => { reset(); const m = installMockFetch({ conv: { status: 429, error: "rate" } }); @@ -1817,6 +2482,7 @@ test("Error: TlsClientUnavailableError returns 502 with TLS_UNAVAILABLE code", a // the error and surfaces TLS_UNAVAILABLE so operators can identify missing // native binary issues quickly. let convAttempted = false; + const errorLogs: string[] = []; __setTlsFetchOverrideForTesting(async (url) => { if (url === "https://chatgpt.com/" || url === "https://chatgpt.com") { return { @@ -1848,7 +2514,12 @@ test("Error: TlsClientUnavailableError returns 502 with TLS_UNAVAILABLE code", a } if (url.endsWith("/backend-api/f/conversation")) { convAttempted = true; - throw new TlsClientUnavailableError("native binary not loaded"); + throw new TlsClientUnavailableError( + "Cannot find module '/home/alice/My Project/tls-client-node/linux-x64/tls-client.node' " + + "access_token=sk-super-secret\n" + + "Require stack:\n" + + "- /home/alice/omniroute/open-sse/services/tlsClientBase.ts:598:17" + ); } return { status: 200, @@ -1865,17 +2536,65 @@ test("Error: TlsClientUnavailableError returns 502 with TLS_UNAVAILABLE code", a stream: false, credentials: { apiKey: "test" }, signal: AbortSignal.timeout(10_000), - log: null, + log: { error: (_tag, message) => errorLogs.push(message) }, }); assert.ok(convAttempted); assert.equal(result.response.status, 502); const json = await result.response.json(); assert.equal(json.error.code, "TLS_UNAVAILABLE"); + assert.equal( + json.error.message, + "ChatGPT connection failed: Cannot find module '' access_token=[REDACTED]", + "the 502 message must retain useful context while redacting the path and token" + ); + assert.doesNotMatch( + json.error.message, + /\/home\/alice|My Project|tls-client\.node|sk-super-secret|Require stack|tlsClientBase\.ts/ + ); + assert.deepEqual(errorLogs, [ + "Fetch failed: Cannot find module '' access_token=[REDACTED]", + ]); + assert.doesNotMatch( + errorLogs.join("\n"), + /\/home\/alice|My Project|tls-client\.node|sk-super-secret|Require stack|tlsClientBase\.ts/ + ); } finally { __setTlsFetchOverrideForTesting(null); } }); +test("Error: hostile prototype rejection returns a sanitized 502", async () => { + reset(); + const hostileFailure = hostilePrototypeFailure("conversation-proxy"); + const errorLogs: string[] = []; + const m = installMockFetch({ conv: { status: 0, requestError: hostileFailure } }); + + try { + const result = await new ChatGptWebExecutor().execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10_000), + log: { error: (_tag, message) => errorLogs.push(message) }, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + assert.deepEqual(JSON.parse(responseText).error, { + message: "ChatGPT connection failed: upstream error unavailable", + type: "upstream_error", + }); + assert.deepEqual(errorLogs, ["Fetch failed: upstream error unavailable"]); + assert.doesNotMatch( + responseText + errorLogs.join("\n"), + /prototype-secret|coercion-secret|\/srv\/private/ + ); + } finally { + m.restore(); + } +}); + // ─── looksLikeSse heuristic ───────────────────────────────────────────────── test("looksLikeSse: detects SSE bodies", () => { @@ -2629,6 +3348,287 @@ test("Image gen: bytes-fetch failure drops markdown (no signed-URL fallback)", a } }); +test("Image logging sanitizes upstream pointers, response bodies, and transport errors", async () => { + const cases: Array<{ + label: string; + pointer: string; + options?: MockFetchOptions; + expected: string; + }> = [ + { + label: "resolver transport failure", + pointer: "file-service://file-access_token=sk-image-pointer", + options: { + fileDownload: { + status: 0, + error: new Error( + "Cannot resolve '/srv/private/image.sock' access_token=sk-image-resolver\n" + + " at /srv/private/image.ts:1" + ), + }, + }, + expected: "Image resolve failed (file-service)", + }, + { + label: "signed image transport failure", + pointer: "file-service://file-signed-error", + options: { + signedDownload: { + status: 0, + error: new Error( + "Cannot fetch '/srv/private/image.bin' access_token=sk-image-fetch\n" + + " at /srv/private/image.ts:2" + ), + }, + }, + expected: "Image fetch failed: Cannot fetch '' access_token=[REDACTED]", + }, + { + label: "signed image error body", + pointer: "file-service://file-signed-body", + options: { + signedDownload: { + status: 502, + body: + "Cannot fetch '/srv/private/image-body.bin' access_token=sk-image-body\n" + + " at /srv/private/image.ts:3", + }, + }, + expected: "Image fetch returned HTTP 502 (Cannot fetch '' access_token=[REDACTED])", + }, + { + label: "download endpoint", + pointer: "file-service://file-access_token=sk-image-endpoint", + options: { + fileDownload: { status: 503, body: "unavailable" }, + attachmentDownload: { status: 503, body: "unavailable" }, + }, + expected: "Image download URL fetch failed (503)", + }, + { + label: "unknown pointer", + pointer: "mystery-access_token=sk-image-unknown", + expected: "Unknown asset_pointer scheme: unknown", + }, + { + label: "resolved pointer debug", + pointer: "file-service://file-access_token=sk-image-resolved", + expected: "Resolved file-service asset → ", + }, + ]; + + for (const [index, { label, pointer, options, expected }] of cases.entries()) { + reset(); + const m = installMockFetch({ + ...options, + conv: { status: 200, events: imageGenEvents({ pointer }) }, + }); + const logs: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "draw a private image" }] }, + stream: false, + credentials: { apiKey: `image-log-cookie-${index}` }, + signal: AbortSignal.timeout(10_000), + log: { + warn: (_tag, message) => logs.push(message), + debug: (_tag, message) => logs.push(message), + }, + }); + + assert.equal(result.response.status, 200, `${label} must preserve executor behavior`); + assert.ok( + logs.some((message) => message.includes(expected)), + `${label} missing sanitized log ${JSON.stringify(expected)} in ${JSON.stringify(logs)}` + ); + assert.doesNotMatch( + logs.join("\n"), + /\/srv\/private|sk-image-|image\.ts|files\.oaiusercontent\.com|sig=mock|\/v1\/chatgpt-web\/image/, + `${label} must not expose upstream paths, tokens, or stack frames` + ); + } finally { + m.restore(); + } + } +}); + +test("Async image logs sanitize poll and WebSocket failures", async () => { + const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, "WebSocket"); + const asyncEvents = [ + { + type: "server_ste_metadata", + conversation_id: "conv-async-image", + metadata: { turn_use_case: "image gen" }, + }, + { + conversation_id: "conv-async-image", + message: { + id: "msg-async-image", + author: { role: "assistant" }, + content: { content_type: "text", parts: ["Processing image"] }, + status: "finished_successfully", + }, + }, + ]; + + const restoreWebSocket = () => { + if (originalWebSocket) Object.defineProperty(globalThis, "WebSocket", originalWebSocket); + else delete (globalThis as { WebSocket?: unknown }).WebSocket; + }; + + for (const stream of [false, true]) { + reset(); + const m = installMockFetch({ + conv: { status: 200, events: asyncEvents }, + webSocketRegister: { status: 200, body: { websocket_url: "wss://chatgpt.test/events" } }, + }); + const warnings: string[] = []; + Object.defineProperty(globalThis, "WebSocket", { + configurable: true, + value: class { + constructor() { + throw new Error( + "Cannot connect '/srv/private/image-ws.sock' access_token=sk-image-async\n" + + " at /srv/private/image-ws.ts:1" + ); + } + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "draw a queued image" }], stream }, + stream, + credentials: { apiKey: `async-image-log-${stream}` }, + signal: AbortSignal.timeout(10_000), + log: { warn: (_tag, message) => warnings.push(message) }, + }); + await result.response.text(); + + assert.ok( + warnings.includes( + "Async image poll failed: Cannot connect '' access_token=[REDACTED]" + ) + ); + assert.doesNotMatch(warnings.join("\n"), /\/srv\/private|sk-image-async|image-ws\.ts/); + } finally { + restoreWebSocket(); + m.restore(); + } + } + + const registrationCases = [ + { + config: { + status: 0, + error: new Error( + "Cannot register '/srv/private/register.sock' access_token=sk-image-register\n" + + " at /srv/private/register.ts:1" + ), + }, + expected: "Cannot register '' access_token=[REDACTED]", + }, + { + config: { + status: 503, + body: + "Cannot register '/srv/private/register.json' access_token=sk-image-register-body\n" + + " at /srv/private/register.ts:2", + }, + expected: "Cannot register '' access_token=[REDACTED]", + }, + ]; + + for (const [index, { config, expected }] of registrationCases.entries()) { + reset(); + const controller = new AbortController(); + const m = installMockFetch({ + conv: { status: 200, events: asyncEvents }, + webSocketRegister: config, + onWebSocketRegister: (_opts, call) => { + if (call === 4) controller.abort(); + }, + }); + const warnings: string[] = []; + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "draw a queued image" }] }, + stream: false, + credentials: { apiKey: `register-log-${index}` }, + signal: controller.signal, + log: { warn: (_tag, message) => warnings.push(message) }, + }); + await result.response.text(); + + assert.ok(warnings.some((message) => message.includes(expected))); + assert.doesNotMatch( + warnings.join("\n"), + /\/srv\/private|sk-image-register|register\.ts/, + "registration logs must sanitize URL, response body, and transport error details" + ); + } finally { + m.restore(); + } + } + + reset(); + const controller = new AbortController(); + let sockets = 0; + const m = installMockFetch({ + conv: { status: 200, events: asyncEvents }, + webSocketRegister: { status: 200, body: { websocket_url: "wss://chatgpt.test/events" } }, + }); + const warnings: string[] = []; + Object.defineProperty(globalThis, "WebSocket", { + configurable: true, + value: class { + onerror: ((event: { message: string }) => void) | null = null; + onclose: (() => void) | null = null; + + constructor() { + sockets++; + queueMicrotask(() => { + this.onerror?.({ + message: + "Socket failed /srv/private/events.sock access_token=sk-image-event\n" + + " at /srv/private/events.ts:1", + }); + if (sockets === 2) controller.abort(); + this.onclose?.(); + }); + } + + close() {} + }, + }); + try { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model: "gpt-5.5", + body: { messages: [{ role: "user", content: "draw a queued image" }] }, + stream: false, + credentials: { apiKey: "websocket-event-log" }, + signal: controller.signal, + log: { warn: (_tag, message) => warnings.push(message) }, + }); + await result.response.text(); + + assert.ok( + warnings.includes("WebSocket error: Socket failed "), + `missing sanitized WebSocket event log in ${JSON.stringify(warnings)}` + ); + assert.doesNotMatch(warnings.join("\n"), /\/srv\/private|sk-image-event|events\.ts/); + } finally { + restoreWebSocket(); + m.restore(); + } +}); + test("Image cache: byte cap evicts oldest before count cap kicks in", async () => { reset(); const cacheMod = await import("../../open-sse/services/chatgptImageCache.ts"); diff --git a/tests/unit/combo-diagnostics-trace.test.ts b/tests/unit/combo-diagnostics-trace.test.ts index fe8bb546a49..e42b6d49145 100644 --- a/tests/unit/combo-diagnostics-trace.test.ts +++ b/tests/unit/combo-diagnostics-trace.test.ts @@ -9,9 +9,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } = await import( - "../../open-sse/utils/error.ts" -); +const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } = + await import("../../open-sse/utils/error.ts"); test("combo diagnostics: headers + body carry the sanitized trace (code override preserved)", async () => { const res = errorResponseWithComboDiagnostics( @@ -80,6 +79,50 @@ test("combo diagnostics: secret containment — non-whitelisted fields never sur assert.ok(!serialized.includes("token"), "no token KEY survives"); }); +test("combo diagnostics: canonical sanitizer protects every public string and header", async () => { + const res = errorResponseWithComboDiagnostics(502, "combo failed", { + poolSize: 2, + attempted: 1, + excluded: [ + { + provider: "provider access_token=DIAG_SECRET /home/alice/provider.ts", + model: "C:\\Users\\alice\\private-model.ts:1:2", + reason: "reason password=REASON_SECRET", + }, + ], + attemptOrder: [ + { + provider: "provider secret=ORDER_SECRET", + model: "model\n at /home/alice/model.ts:1:2", + }, + ], + terminalReason: "terminal secret=TERM_SECRET /home/alice/terminal.ts", + recovery: { + action: "retry", + next_step: "next password=NEXT_SECRET /home/alice/next.ts", + }, + }); + const body = await res.json(); + const publicText = [ + JSON.stringify(body), + res.headers.get("x-omniroute-combo-excluded") || "", + res.headers.get("x-omniroute-combo-terminal-reason") || "", + res.headers.get("x-omniroute-recovery-next-step") || "", + ].join("\n"); + + for (const leak of [ + "DIAG_SECRET", + "REASON_SECRET", + "ORDER_SECRET", + "TERM_SECRET", + "NEXT_SECRET", + "/home/alice", + "C:\\Users\\alice", + ]) { + assert.ok(!publicText.includes(leak), leak); + } +}); + test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must not crash Response construction (#6612)", () => { const terminalReason = "reasoning consumed 5/5 tokens — no content output"; assert.doesNotThrow(() => { @@ -89,7 +132,9 @@ test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must no { poolSize: 4, attempted: 1, - excluded: [{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }], + excluded: [ + { provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }, + ], attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }], terminalReason, } @@ -112,8 +157,38 @@ test("combo diagnostics: JSON body keeps the original non-Latin1 text even thoug } ); // Header value must be a valid Latin1 ByteString — em dash (U+2014) replaced. - assert.equal(res.headers.get("x-omniroute-combo-terminal-reason"), terminalReason.replace("—", "?")); + assert.equal( + res.headers.get("x-omniroute-combo-terminal-reason"), + terminalReason.replace("—", "?") + ); const body = await res.json(); - // JSON body keeps the original, readable (unsanitized) em dash. + // JSON body keeps the original, safe em dash after canonical sanitization. assert.equal(body.diagnostics.terminalReason, terminalReason); }); + +test("combo diagnostics: every C0/DEL byte is printable in headers without corrupting JSON", async () => { + for (const code of [0x00, 0x01, 0x08, 0x09, 0x0b, 0x0c, 0x1f, 0x7f]) { + const control = String.fromCharCode(code); + const diagnostic = `safe${control}tail`; + const res = errorResponseWithComboDiagnostics(503, "combo failed", { + poolSize: 1, + attempted: 0, + excluded: [{ provider: diagnostic, reason: diagnostic }], + attemptOrder: [{ provider: diagnostic, model: diagnostic }], + terminalReason: diagnostic, + recovery: { action: "retry", next_step: diagnostic }, + }); + + assert.equal(res.status, 503, `control 0x${code.toString(16)}`); + for (const header of [ + "x-omniroute-combo-excluded", + "x-omniroute-combo-terminal-reason", + "x-omniroute-recovery-next-step", + ]) { + assert.doesNotMatch(res.headers.get(header) || "", /[\x00-\x1f\x7f]/); + } + const body = await res.json(); + assert.equal(body.diagnostics.terminalReason, diagnostic); + assert.equal(body.recovery_hint.next_step, diagnostic); + } +}); diff --git a/tests/unit/error-message-sanitization-credentials.test.ts b/tests/unit/error-message-sanitization-credentials.test.ts new file mode 100644 index 00000000000..f1e9b64eff0 --- /dev/null +++ b/tests/unit/error-message-sanitization-credentials.test.ts @@ -0,0 +1,989 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-err-boundaries-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { + buildErrorBody, + createErrorResult, + projectPublicErrorIdentifier, + providerCircuitOpenResponse, + sanitizeErrorMessage, + sanitizeUpstreamDetails, + unavailableResponse, +} = await import("../../open-sse/utils/error.ts"); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("sanitizeErrorMessage redacts the reported credential forms", () => { + const cases = [ + ["auth failed access token: sk-raw-secret", "auth failed access token: [REDACTED]"], + ["auth failed token sk-raw-secret", "auth failed token [REDACTED]"], + ["refresh_token=rt-private-value", "refresh_token=[REDACTED]"], + ["token=sk-private-value", "token=[REDACTED]"], + ["password=hunter2", "password=[REDACTED]"], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } +}); + +test("sanitizeErrorMessage redacts labeled credential syntax across delimiters and quotes", () => { + const labels = [ + "api_key", + "api-key", + "api key", + "access_token", + "access-token", + "access token", + "refresh_token", + "refresh-token", + "refresh token", + "authorization", + "cookie", + "password", + "secret", + "token", + ]; + const delimiters = [": ", "="]; + const quotePairs = [ + ["", ""], + ["'", "'"], + ['"', '"'], + ] as const; + + for (const label of labels) { + for (const delimiter of delimiters) { + for (const [openQuote, closeQuote] of quotePairs) { + const input = `${label}${delimiter}${openQuote}sk-private.value_123/+${closeQuote}`; + const output = sanitizeErrorMessage(input); + assert.ok(!output.includes("sk-private"), input); + assert.ok(output.includes("[REDACTED]"), input); + } + } + } +}); + +test("sanitizeErrorMessage preserves token metrics, status codes, and ordinary numbers", () => { + const safeCases = [ + "token budget is 16384", + "input token count: 16000", + "status code 401; retry in 30 seconds", + "request failed at status 503", + "retry at 12:30 UTC", + ]; + + for (const input of safeCases) { + assert.equal(sanitizeErrorMessage(input), input, input); + } +}); + +test("sanitizeErrorMessage redacts assignment-shaped numeric tokens", () => { + const cases = [ + ["token: 16000", "token: [REDACTED]"], + ["token=401", "token=[REDACTED]"], + ['"token": "16000"', '"token": "[REDACTED]"'], + ["authToken=123456", "authToken=[REDACTED]"], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } +}); + +test("sanitizeErrorMessage redacts bare service keys without matching ordinary sk words", () => { + assert.equal( + sanitizeErrorMessage("upstream rejected sk-SUPERSECRET123"), + "upstream rejected [REDACTED]" + ); + assert.equal( + sanitizeErrorMessage("upstream rejected sk_SUPERSECRET123"), + "upstream rejected [REDACTED]" + ); + for (const safe of ["sk-ui", "sk-learn", "sk-board", "ask-SUPERSECRET123"]) { + assert.equal(sanitizeErrorMessage(safe), safe, safe); + } +}); + +test("sanitizeErrorMessage redacts strong bare GitHub personal access tokens", () => { + const token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"; + for (const input of [token, `upstream rejected ${token}`]) { + const output = sanitizeErrorMessage(input); + assert.ok(!output.includes(token), input); + assert.ok(output.includes("[REDACTED]"), input); + assert.ok(!JSON.stringify(buildErrorBody(500, input)).includes(token), input); + } + + for (const safe of ["ghp_status", "paragraphp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"]) { + assert.equal(sanitizeErrorMessage(safe), safe, safe); + } +}); + +test("sanitizeErrorMessage redacts strong bare JWTs without matching ordinary dotted codes", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signatureSECRET123"; + for (const input of [jwt, `upstream rejected ${jwt}`]) { + const output = sanitizeErrorMessage(input); + assert.ok(!output.includes(jwt), input); + assert.ok(output.includes("[REDACTED]"), input); + assert.ok(!JSON.stringify(buildErrorBody(500, input)).includes(jwt), input); + } + + for (const safe of ["com.example.error", "eyJshort.payload.signature"]) { + assert.equal(sanitizeErrorMessage(safe), safe, safe); + } +}); + +test("sanitizeErrorMessage fails closed over unquoted high-risk credential values", () => { + const cases = [ + ["password: correct horse battery staple", "password: [REDACTED]"], + ["secret: multi word private value", "secret: [REDACTED]"], + ["cookie=foo=abc; bar=SUPERSECRET", "cookie=[REDACTED]"], + ["authorization: Basic first second", "authorization: [REDACTED]"], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + assert.equal(buildErrorBody(500, input).error.message, expected, input); + } +}); + +test("sanitizeErrorMessage redacts credential labels embedded in composite keys", () => { + const cases = [ + ["client_secret=SUPERSECRET", "client_secret=[REDACTED]"], + ['{"clientSecret":"SUPERSECRET"}', '{"clientSecret":"[REDACTED]"}'], + ["db_password=correct horse battery staple", "db_password=[REDACTED]"], + ["sessionCookie=foo=abc; bar=SUPERSECRET", "sessionCookie=[REDACTED]"], + ["authToken=sk-private-value", "authToken=[REDACTED]"], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } +}); + +test("sanitizeErrorMessage redacts explicit high-risk credential labels", () => { + const cases = [ + [ + "private_key=-----BEGIN PRIVATE KEY-----\nPEM_PRIVATE_SECRET\n-----END PRIVATE KEY-----", + "private_key=[REDACTED]", + ], + ["private-key='PRIVATE_SECRET'", "private-key='[REDACTED]'"], + ["private key=PRIVATE_SECRET", "private key=[REDACTED]"], + ['{"privateKey":"PRIVATE_SECRET"}', '{"privateKey":"[REDACTED]"}'], + ["session_key=multi word SESSION_SECRET", "session_key=[REDACTED]"], + ['{"session-key":"SESSION_SECRET"}', '{"session-key":"[REDACTED]"}'], + ["session key=SESSION_SECRET", "session key=[REDACTED]"], + ["sessionKey: SESSION_SECRET", "sessionKey: [REDACTED]"], + ["encryption key: multi word ENCRYPTION_SECRET", "encryption key: [REDACTED]"], + ["encryption-key=ENCRYPTION_SECRET", "encryption-key=[REDACTED]"], + ["encryption_key=ENCRYPTION_SECRET", "encryption_key=[REDACTED]"], + ['{"encryptionKey":"ENCRYPTION_SECRET"}', '{"encryptionKey":"[REDACTED]"}'], + ["secret_key=multi word SECRET_KEY_VALUE", "secret_key=[REDACTED]"], + ["secret-key='SECRET_KEY_VALUE'", "secret-key='[REDACTED]'"], + ['{"secret key":"SECRET_KEY_VALUE"}', '{"secret key":"[REDACTED]"}'], + ["secretKey: SECRET_KEY_VALUE", "secretKey: [REDACTED]"], + ["signing_key=multi word SIGNING_KEY_VALUE", "signing_key=[REDACTED]"], + ["signing-key='SIGNING_KEY_VALUE'", "signing-key='[REDACTED]'"], + ['{"signing key":"SIGNING_KEY_VALUE"}', '{"signing key":"[REDACTED]"}'], + ["signingKey: SIGNING_KEY_VALUE", "signingKey: [REDACTED]"], + ["credential=OPAQUE_CREDENTIAL", "credential=[REDACTED]"], + ["credentials: multi word private value", "credentials: [REDACTED]"], + ["session=OPAQUE_SESSION", "session=[REDACTED]"], + ["session_id=OPAQUE_SESSION_ID", "session_id=[REDACTED]"], + ['{"sessionId":"OPAQUE_SESSION_ID"}', '{"sessionId":"[REDACTED]"}'], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + assert.equal(buildErrorBody(500, input).error.message, expected, input); + } + + for (const safe of [ + "private key rotation is scheduled", + "session key count: 2", + "encryption key status: active", + "private_key_id=kid", + "sessionKeyCount: 2", + "encryption_key_version=v1", + "secret_key_name=webhook", + "signing_key_id=kid", + "signing key rotation is scheduled", + "credentials are missing", + "credential validation failed", + "session count: 3", + "session_count=2", + "sessionStatus=active", + "public_key=PUBLIC", + "key=value", + ]) { + assert.equal(sanitizeErrorMessage(safe), safe, safe); + } +}); + +test("sanitizeErrorMessage redacts repository credential aliases", () => { + const cases = [ + ["token_v2=NOTION_TOKEN_SECRET", "token_v2=[REDACTED]"], + ["tokenV2=NOTION_CAMEL_TOKEN_SECRET", "tokenV2=[REDACTED]"], + ['{"access_token_v2":"ACCESS_TOKEN_SECRET"}', '{"access_token_v2":"[REDACTED]"}'], + ["sso=GROK_SESSION_SECRET", "sso=[REDACTED]"], + ["sso-rw='COOKIE_SECRET'", "sso-rw='[REDACTED]'"], + ['{"cf_clearance":"CLOUDFLARE_COOKIE_SECRET"}', '{"cf_clearance":"[REDACTED]"}'], + ["__cf_bm=CLOUDFLARE_BOT_SECRET", "__cf_bm=[REDACTED]"], + ['{"_cfuvid":"CLOUDFLARE_VISITOR_SECRET"}', '{"_cfuvid":"[REDACTED]"}'], + ["_puid=CHATGPT_COOKIE_SECRET", "_puid=[REDACTED]"], + [ + "__Secure-next-auth.session-token.0=CHUNKED_SESSION_SECRET", + "__Secure-next-auth.session-token.0=[REDACTED]", + ], + ["arena-auth-prod-v1=ARENA_COOKIE_SECRET", "arena-auth-prod-v1=[REDACTED]"], + ['{"arena-auth-prod-v1.1":"ARENA_CHUNK_SECRET"}', '{"arena-auth-prod-v1.1":"[REDACTED]"}'], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + assert.equal(buildErrorBody(500, input).error.message, expected, input); + } + + for (const safe of [ + "token budget: 4096", + "token_count=3", + "access token version is v2", + "sso status: unavailable", + "key_id=kid", + ]) { + assert.equal(sanitizeErrorMessage(safe), safe, safe); + } +}); + +test("sanitizeErrorMessage fails closed when string coercion throws", () => { + const hostile = { + toString(): string { + throw new Error("coercion must not cross the public boundary"); + }, + }; + + assert.equal(sanitizeErrorMessage(hostile), ""); + assert.equal( + buildErrorBody(500, hostile as unknown as string).error.message, + "Internal server error" + ); +}); + +test("sanitizer and public identifiers reject strong provider token formats", () => { + const strongTokens = [ + "github_pat_SYNTHETICVALUE1234567890", + "glpat-SYNTHETICVALUE1234567890", + "xoxb-SYNTHETICVALUE1234567890", + "AKIAABCDEFGHIJKLMNOP", + ]; + + for (const token of strongTokens) { + const input = `provider rejected ${token}`; + assert.equal(sanitizeErrorMessage(input), "provider rejected [REDACTED]", token); + assert.equal( + projectPublicErrorIdentifier(`prefix_${token}_suffix`, "bad_gateway"), + "bad_gateway" + ); + assert.ok(!JSON.stringify(buildErrorBody(500, input)).includes(token), token); + } + + for (const safe of ["github_path_error", "glpat_status", "xoxo_error", "AKIA_STATUS"]) { + assert.equal(sanitizeErrorMessage(safe), safe, safe); + assert.equal(projectPublicErrorIdentifier(safe, "error"), safe, safe); + } +}); + +test("sanitizeErrorMessage redacts bare private-key PEM blocks", () => { + const cases = [ + [ + "upstream returned -----BEGIN PRIVATE KEY-----\nMII_BARE_PRIVATE_SECRET\n-----END PRIVATE KEY----- after", + "upstream returned [REDACTED] after", + ], + ["-----BEGIN RSA PRIVATE KEY-----\nMII_UNCLOSED_PRIVATE_SECRET", "[REDACTED]"], + [ + "ß -----BEGIN PRIVATE KEY-----\nMII_UNICODE_PREFIX_SECRET\n-----END PRIVATE KEY----- after", + "ß [REDACTED] after", + ], + [ + "ffi -----BEGIN EC PRIVATE KEY-----\nMII_UNICODE_BODY_ffi_SECRET\n-----END EC PRIVATE KEY----- after", + "ffi [REDACTED] after", + ], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + assert.equal(buildErrorBody(500, input).error.message, expected, input); + } + + const safeCases = [ + [ + "-----BEGIN PUBLIC KEY-----\nPUBLIC_MATERIAL\n-----END PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----", + ], + [ + "-----BEGIN CERTIFICATE-----\nPUBLIC_CERTIFICATE\n-----END CERTIFICATE-----", + "-----BEGIN CERTIFICATE-----", + ], + ["private key rotation is scheduled", "private key rotation is scheduled"], + ] as const; + for (const [input, expected] of safeCases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } +}); + +test("sanitizeErrorMessage consumes escaped quotes inside quoted credentials", () => { + const cases = [ + [String.raw`password="first\"SECOND_SECRET"`, 'password="[REDACTED]"'], + [String.raw`secret='first\'SECOND_SECRET'`, "secret='[REDACTED]'"], + [String.raw`{"access_token":"first\"SECOND_SECRET"}`, '{"access_token":"[REDACTED]"}'], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } +}); + +test("sanitizeErrorMessage fails closed when a quoted credential is truncated", () => { + const cases = [ + ['password="SUPERSECRET', 'password="[REDACTED]'], + ["secret='SUPERSECRET", "secret='[REDACTED]"], + ['{"access_token":"SUPERSECRET', '{"access_token":"[REDACTED]'], + ['cookie="session=SUPERSECRET', 'cookie="[REDACTED]'], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } +}); + +test("sanitizeErrorMessage preserves non-assignment credential prose", () => { + const cases = [ + "password policy requires 12 characters", + "secret count: 2", + "cookie count: 3", + "authorization status: missing", + ]; + + for (const input of cases) { + assert.equal(sanitizeErrorMessage(input), input, input); + } +}); + +test("sanitizeErrorMessage decodes bounded ASCII escapes before credential scanning", () => { + const cases = [ + [String.raw`api\u005fkey=SUPERSECRET`, "api_key=[REDACTED]"], + [String.raw`password\u003dSUPERSECRET`, "password=[REDACTED]"], + [String.raw`\u0061\U0070\u0069\u005F\u006B\u0065\u0079=SUPERSECRET`, "api_key=[REDACTED]"], + [String.raw`api\\u005fkey=SUPERSECRET`, "api_key=[REDACTED]"], + [ + String.raw`\\u0061\\u0070\\u0069\\u005f\\u006b\\u0065\\u0079=SUPERSECRET`, + "api_key=[REDACTED]", + ], + [String.raw`{"api\\u005fkey":"SUPERSECRET"}`, '{"api_key":"[REDACTED]"}'], + [String.raw`api_key=\"SUPERSECRET\"`, "api_key=[REDACTED]"], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + assert.equal(buildErrorBody(500, input).error.message, expected, input); + } +}); + +test("encoded labels cannot erase quoted-credential escape provenance", () => { + const cases = [ + [String.raw`p\u0061ssword="first\"SECOND_SECRET"`, 'password="[REDACTED]"'], + [String.raw`p\u0061ssword="first\u0022SECOND_SECRET"`, 'password="[REDACTED]"'], + [String.raw`api\u005fkey="first\"SECOND_SECRET"`, 'api_key="[REDACTED]"'], + [String.raw`api\u005fkey="first\u0022SECOND_SECRET"`, 'api_key="[REDACTED]"'], + ] as const; + + for (const [input, expected] of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + assert.equal(buildErrorBody(500, input).error.message, expected, input); + } +}); + +test("deeply serialized credential-label escapes fail closed without a depth bypass", () => { + for (const slashCount of [3, 5, 6, 7, 8, 9, 16]) { + const input = `api${"\\".repeat(slashCount)}u005fkey=DEPTH_SECRET`; + const output = sanitizeErrorMessage(input); + + assert.ok(!output.includes("DEPTH_SECRET"), input); + assert.ok(output.includes("[REDACTED]"), input); + assert.ok(!JSON.stringify(buildErrorBody(500, input)).includes("DEPTH_SECRET"), input); + } +}); + +test("sanitizeErrorMessage decodes bounded ASCII escapes before path scanning", () => { + const cases = [ + String.raw`failed at \u002fhome\u002falice\u002fsecret.ts:1:2`, + String.raw`failed at \\u002fhome\\u002falice\\u002fsecret.ts:1:2`, + String.raw`failed at C\u003a\u005cUsers\u005calice\u005csecret.ts:1:2`, + String.raw`failed at C:\\Users\\alice\\secret.ts:1:2`, + ]; + + for (const input of cases) { + assert.equal(sanitizeErrorMessage(input), "failed at ", input); + assert.equal(buildErrorBody(500, input).error.message, "failed at ", input); + } +}); + +test("ASCII escape normalization preserves and redacts UNC path evidence", () => { + const cases = [ + String.raw`failed at \\node\share\TOP_SECRET.ts:1:2`, + String.raw`failed at \\router\share\TOP_SECRET.ts:1:2`, + String.raw`failed at \\u005fserver\share\TOP_SECRET.ts:1:2`, + String.raw`failed at \\u0061\u0070\TOP_SECRET.ts:1:2`, + ]; + + for (const input of cases) { + assert.equal(sanitizeErrorMessage(input), "failed at ", input); + assert.equal(buildErrorBody(500, input).error.message, "failed at ", input); + } + assert.equal( + sanitizeErrorMessage("network node and router remain healthy"), + "network node and router remain healthy" + ); +}); + +test("ASCII escape normalization preserves safe URLs, prose, and non-ASCII escapes", () => { + const safeCases = [ + [ + String.raw`request GET https:\u002f\u002fapi.example.com\u002fv1\u002fstatus`, + "request GET https://api.example.com/v1/status", + ], + [String.raw`\u0074\u006f\u006b\u0065\u006e budget is 16384`, "token budget is 16384"], + [String.raw`provider returned \u263a`, String.raw`provider returned \u263a`], + [String.raw`control \u001f and delete \u007f`, String.raw`control \u001f and delete \u007f`], + ] as const; + + for (const [input, expected] of safeCases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } + + const pathological = String.raw`\u0061`.repeat(4096); + assert.ok(sanitizeErrorMessage(pathological).length <= 4096); +}); + +test("upstream details reject escaped unsafe keys and sanitize escaped values", () => { + const input = Object.create(null) as Record; + input[String.raw`p\u0061ssword`] = "SINGLE_ESCAPED_KEY_SECRET"; + input[String.raw`\u0061\u0070\u0069\u005f\u006b\u0065\u0079`] = "FULLY_ESCAPED_KEY_SECRET"; + input[String.raw`__pr\u006fto__`] = "PROTO_CONTROL_SECRET"; + input[String.raw`constr\u0075ctor`] = "CONSTRUCTOR_CONTROL_SECRET"; + input[String.raw`prot\u006ftype`] = "PROTOTYPE_CONTROL_SECRET"; + input.safeCollision = "SAFE_KEEP"; + input[String.raw`s\u0061feCollision`] = "COLLISION_SECRET"; + input.safe = String.raw`password\u003dVALUE_SECRET`; + input.nested = { + [String.raw`\u002f\u0068\u006f\u006d\u0065\u002f\u0061\u006c\u0069\u0063\u0065\u002f\u006e\u006f\u0074\u0065\u002e\u0074\u0073`]: + "PATH_KEY_SECRET", + message: String.raw`failed at \u002fhome\u002falice\u002fsecret.ts:1:2`, + }; + + const sanitized = sanitizeUpstreamDetails(input) as Record; + const serialized = JSON.stringify(sanitized); + + assert.equal(sanitized.safe, "password=[REDACTED]"); + assert.equal(sanitized.safeCollision, "SAFE_KEEP"); + assert.equal((sanitized.nested as Record).message, "failed at "); + for (const secret of [ + "SINGLE_ESCAPED_KEY_SECRET", + "FULLY_ESCAPED_KEY_SECRET", + "PROTO_CONTROL_SECRET", + "CONSTRUCTOR_CONTROL_SECRET", + "PROTOTYPE_CONTROL_SECRET", + "COLLISION_SECRET", + "PATH_KEY_SECRET", + "VALUE_SECRET", + "/home/alice", + ]) { + assert.ok(!serialized.includes(secret), secret); + } +}); + +test("upstream details drop unsafe keys recursively without key collisions", () => { + const input = { + "/home/alice/private.ts": "POSIX_KEY_SECRET", + "C:\\Users\\alice\\private.ts": "WINDOWS_KEY_SECRET", + credential: "OPAQUE_CREDENTIAL_SECRET", + credentials: "OPAQUE_CREDENTIALS_SECRET", + session: "OPAQUE_SESSION_SECRET", + sessionId: "OPAQUE_SESSION_ID_SECRET", + session_id: "OPAQUE_SESSION_SNAKE_SECRET", + session_count: 2, + session_status: "ready", + sessionStatus: "active", + token_v2: "NOTION_ALIAS_SECRET", + cf_clearance: "CLEARANCE_ALIAS_SECRET", + __cf_bm: "BOT_ALIAS_SECRET", + _cfuvid: "VISITOR_ALIAS_SECRET", + _puid: "PUID_ALIAS_SECRET", + sso: "SSO_ALIAS_SECRET", + "sso-rw": "SSO_RW_ALIAS_SECRET", + "arena-auth-prod-v1": "ARENA_ALIAS_SECRET", + "arena-auth-prod-v1.3": "ARENA_CHUNK_ALIAS_SECRET", + safe: { + ok: "kept", + token_v2: "NESTED_NOTION_ALIAS_SECRET", + cf_clearance: "NESTED_CLEARANCE_ALIAS_SECRET", + credential: "NESTED_CREDENTIAL_SECRET", + sessionId: "NESTED_SESSION_SECRET", + "/srv/private/location.ts": "NESTED_KEY_SECRET", + "at handler (/home/alice/private.ts:1:2)": "STACK_SHAPED_KEY_SECRET", + "authorization=Bearer SUPERSECRET": "CREDENTIAL_KEY_SECRET", + stack: "STACK_SECRET", + password: "PASSWORD_SECRET", + }, + }; + const body = buildErrorBody(500, "upstream failed", input); + const details = body.upstream_details as Record; + const serialized = JSON.stringify(details); + + assert.equal((details.safe as Record).ok, "kept"); + assert.equal(details.session_count, 2); + assert.equal(details.session_status, "ready"); + assert.equal(details.sessionStatus, "active"); + for (const secret of [ + "/home/alice/private.ts", + "C:\\Users\\alice\\private.ts", + "/srv/private/location.ts", + "POSIX_KEY_SECRET", + "WINDOWS_KEY_SECRET", + "NESTED_KEY_SECRET", + "STACK_SHAPED_KEY_SECRET", + "CREDENTIAL_KEY_SECRET", + "STACK_SECRET", + "PASSWORD_SECRET", + "OPAQUE_CREDENTIAL_SECRET", + "OPAQUE_CREDENTIALS_SECRET", + "OPAQUE_SESSION_SECRET", + "OPAQUE_SESSION_ID_SECRET", + "OPAQUE_SESSION_SNAKE_SECRET", + "NESTED_CREDENTIAL_SECRET", + "NESTED_SESSION_SECRET", + "NOTION_ALIAS_SECRET", + "CLEARANCE_ALIAS_SECRET", + "BOT_ALIAS_SECRET", + "VISITOR_ALIAS_SECRET", + "PUID_ALIAS_SECRET", + "SSO_ALIAS_SECRET", + "SSO_RW_ALIAS_SECRET", + "ARENA_ALIAS_SECRET", + "ARENA_CHUNK_ALIAS_SECRET", + "NESTED_NOTION_ALIAS_SECRET", + "NESTED_CLEARANCE_ALIAS_SECRET", + ]) { + assert.ok(!serialized.includes(secret), secret); + } +}); + +test("upstream details reject prototype-control keys from null-prototype input", () => { + const hostile = Object.create(null) as Record; + hostile.safe = "kept"; + hostile.__proto__ = { polluted: "PROTO_SECRET" }; + hostile.constructor = "CONSTRUCTOR_SECRET"; + hostile.prototype = "PROTOTYPE_SECRET"; + + const sanitized = sanitizeUpstreamDetails(hostile) as Record; + const serialized = JSON.stringify(sanitized); + + assert.equal(sanitized.safe, "kept"); + assert.equal(({} as { polluted?: string }).polluted, undefined); + assert.ok(!serialized.includes("PROTO_SECRET")); + assert.ok(!serialized.includes("CONSTRUCTOR_SECRET")); + assert.ok(!serialized.includes("PROTOTYPE_SECRET")); +}); + +test("public error classification rejects unsafe identifiers with status fallbacks", async () => { + const unsafeCode = "bad access_token=TOP_SECRET /home/alice/code.ts"; + const unsafeType = "bad_type\n at /home/alice/type.ts:1:2"; + const body = buildErrorBody(502, "upstream failed", undefined, { + code: unsafeCode, + type: unsafeType, + }); + const result = createErrorResult(502, "upstream failed", null, unsafeCode, unsafeType); + const responseBody = (await result.response.json()) as { + error: { code?: string; type?: string }; + }; + + assert.equal(body.error.code, "bad_gateway"); + assert.equal(body.error.type, "server_error"); + assert.equal(responseBody.error.code, "bad_gateway"); + assert.equal(responseBody.error.type, "server_error"); + assert.equal(result.errorCode, unsafeCode, "internal classification stays available"); + assert.equal(result.errorType, unsafeType, "internal classification stays available"); + assert.ok(!JSON.stringify(responseBody).includes("TOP_SECRET")); + assert.ok(!JSON.stringify(responseBody).includes("/home/alice")); +}); + +test("public error classification preserves bounded identifiers", () => { + const body = buildErrorBody(429, "rate limited", undefined, { + code: "usage_limit_reached", + type: "rate_limit_error", + }); + + assert.equal(body.error.code, "usage_limit_reached"); + assert.equal(body.error.type, "rate_limit_error"); +}); + +test("public error classification rejects bare service keys that look like identifiers", () => { + const body = buildErrorBody(502, "upstream failed", undefined, { + code: "sk-SUPERSECRET123", + type: "server_error", + }); + + assert.equal(body.error.code, "bad_gateway"); + assert.equal(body.error.type, "server_error"); + assert.ok(!JSON.stringify(body).includes("SUPERSECRET123")); +}); + +test("public error classification rejects credential-shaped identifier payloads", () => { + for (const unsafe of [ + "access_token_SECRET123", + "password_hunter2", + "authorization_BearerSecret", + "UPSTREAM_access_token_SECRET123", + "provider-password-hunter2", + "X.authorization_BearerSecret", + "bearer_SUPERSECRET123", + "provider_bearer_SUPERSECRET123", + "hunter2_password", + "passwordhunter2", + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "UPSTREAM_ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "UPSTREAM_sk-SUPERSECRET123", + ]) { + const body = buildErrorBody(502, "upstream failed", undefined, { + code: unsafe, + type: unsafe, + }); + assert.equal(body.error.code, "bad_gateway", unsafe); + assert.equal(body.error.type, "server_error", unsafe); + assert.ok(!JSON.stringify(body).includes(unsafe), unsafe); + } + + for (const safe of [ + "invalid_api_key", + "invalid_token", + "token_expired", + "provider_token_expired", + "bearer_invalid", + "bearer_required", + "bearer_expired", + "provider_bearer_error", + "invalid_password", + "password_required", + "passwordless", + "passwordless_error", + "unsupported_feature", + "RATE_LIMIT", + ]) { + const body = buildErrorBody(502, "upstream failed", undefined, { code: safe, type: safe }); + assert.equal(body.error.code, safe, safe); + assert.equal(body.error.type, safe, safe); + } +}); + +test("public error classification rejects credentials masked by safe-looking segments", () => { + const unsafeIdentifiers = [ + "hunter2_password_expired", + "SECRET123_access_token_missing", + "hunter2_token_expired", + "SECRET123_bearer_invalid", + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456_expired", + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456_token_expired", + "letmein_password", + "qwerty_password", + "passwordsecret", + "secretSECRET123", + "authorizationBearerSecret", + "bearerSecret123", + "api_keySECRET123", + "access_tokenSECRET123", + "access_token_SECRET", + "password_provider", + "password_secret", + "secret_token", + "token_secret", + "access_token_github", + "SECRET_access_token_missing", + "provider_secret_token", + "github_bearer_token", + "client_password_session", + "authorization_bearer_secret", + "prefixAccessTokenSUPERSECRET123", + "prefixPasswordSUPERSECRET123", + "prefixPrivateKeySUPERSECRET123", + "prefixSessionKeySUPERSECRET123", + "prefixEncryptionKeySUPERSECRET123", + "prefixSigningKeySUPERSECRET123", + "prefixSecretKeySUPERSECRET123", + "prefixCredentialSUPERSECRET123", + "prefixSessionIdSUPERSECRET123", + "session_SUPERSECRET123", + "prefixghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + "prefixsk-SUPERSECRET123", + "prefixeyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signatureSECRET123", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signatureSECRET123", + "access_token", + "refresh_token", + "api_key", + "accesstoken", + "refreshtoken", + "apikey", + "authorization", + "bearer", + "cookie", + "password", + "secret", + "token", + ]; + + for (const unsafe of unsafeIdentifiers) { + const body = buildErrorBody(502, "upstream failed", undefined, { + code: unsafe, + type: unsafe, + }); + assert.equal(body.error.code, "bad_gateway", unsafe); + assert.equal(body.error.type, "server_error", unsafe); + assert.ok(!JSON.stringify(body).includes(unsafe), unsafe); + } + + for (const safe of [ + "authorization_code", + "authorization_code_pkce", + "authorization_endpoint", + "authorization_failed", + "authorization_pending", + "client_secret_missing", + "codex_access_token_missing", + "codex_oauth_token_missing", + "access_token_required", + "access_token_missing", + "expired_token", + "github_access_token_invalid", + "invalid_token", + "invalid_token_response", + "LEASE_API_KEY_INVALID", + "LEASE_AUTHORIZATION_MISMATCH", + "missing_access_token", + "missing_api_key", + "missing_authorization", + "missing_cookie", + "missing_id_token", + "missing_refresh_token", + "no_refresh_token", + "no_access_token", + "oauth_invalid_token", + "PASSWORD_MISMATCH", + "PASSWORD_REQUIRED", + "refresh_token_invalid", + "refresh_token_invalidated", + "refresh_token_reused", + "TOKEN_LIMIT_EXCEEDED", + "token_required", + "token_limit_exceeded", + "token_health_check", + "token_refresh_failed", + "token_refresh_transient", + "token_type", + "token_usage", + "github_token_expired", + "no_credentials", + "passwordless_auth_required", + "tokenizer_error", + "tokenization_error", + "SESSION_EXPIRED", + "missing_session_id", + "TLS_SESSION_CAPACITY", + ]) { + assert.equal(projectPublicErrorIdentifier(safe, "error"), safe, safe); + } +}); + +test("public error identifier rejects non-string candidates without throwing", () => { + for (const candidate of [401, true, null, { code: "safe_code" }, ["safe_code"]]) { + assert.equal(projectPublicErrorIdentifier(candidate, "bad_gateway"), "bad_gateway"); + } +}); + +test("public error identifier applies the same policy to hostile fallbacks", () => { + assert.equal(projectPublicErrorIdentifier(undefined, "access_token_SECRET123"), "error"); + assert.equal(projectPublicErrorIdentifier("bad value", "password_hunter2"), "error"); + assert.equal(projectPublicErrorIdentifier("bad value", "sk-SUPERSECRET123"), "error"); + assert.equal( + projectPublicErrorIdentifier("bad value", { + toString(): string { + throw new Error("hostile fallback coercion"); + }, + } as unknown as string), + "error" + ); + assert.equal(projectPublicErrorIdentifier(undefined, "bad_gateway"), "bad_gateway"); +}); + +test("unavailableResponse sanitizes the message and retry hint", async () => { + const response = unavailableResponse( + 429, + "failed at /home/alice/private.ts:1:2", + null, + "reset after 30s password=SUPERSECRET" + ); + const body = (await response.json()) as { error: { message: string } }; + + assert.equal(body.error.message, "failed at (reset after 30s password=[REDACTED])"); + assert.ok(!JSON.stringify(body).includes("SUPERSECRET")); + assert.ok(!JSON.stringify(body).includes("/home/alice")); +}); + +test("provider circuit responses project hostile provider labels", async () => { + const provider = "provider access_token=CIRCUIT_SECRET /home/alice/provider.ts"; + const response = providerCircuitOpenResponse(provider, 30); + const body = await response.json(); + const serialized = JSON.stringify(body); + + assert.equal(response.status, 503); + assert.equal(response.headers.get("Retry-After"), "30"); + assert.equal(response.headers.get("X-OmniRoute-Provider-Breaker"), "open"); + assert.equal(body.error.provider, "unknown"); + assert.equal(body.error.message, "Provider unknown circuit breaker is open"); + assert.ok(!serialized.includes("CIRCUIT_SECRET")); + assert.ok(!serialized.includes("/home/alice")); + + const safe = await providerCircuitOpenResponse("openai", 5).json(); + assert.equal(safe.error.provider, "openai"); +}); + +test("sanitizeErrorMessage removes physical and serialized stack-frame tails", () => { + const physicalSeparators = ["\n", "\r", "\r\n", "\u2028", "\u2029"]; + const serializedSeparators = ["\\n", "\\r", "\\r\\n", "\\u2028", "\\u2029"]; + const frameLabels = [ + "handler", + "async handler", + "Object.handler", + "Object.handler [as run]", + "new Handler", + "", + ]; + const framePaths = [ + "/srv/private/file.ts:1:2", + "C:\\private\\file.ts:1:2", + "file:///srv/private/file.mjs:1:2", + "/custom/private/file.ts:1:2", + ]; + + for (const separator of [...physicalSeparators, ...serializedSeparators]) { + for (const label of frameLabels) { + for (const filePath of framePaths) { + const input = `boom${separator} at ${label} (${filePath})`; + assert.equal(sanitizeErrorMessage(input), "boom", input); + } + } + } +}); + +test("serialized Unicode control escapes never expose stack frames", () => { + const cases = [ + "boom\\u000a at /home/alice/secret.ts:10:2", + "boom\\U000A at handler (src/private/secret.ts:10:2)", + "boom\\u000d at /home/alice/secret.ts:10:2", + "boom\\u000D at handler (src/private/secret.ts:10:2)", + ]; + + for (const input of cases) { + assert.equal(sanitizeErrorMessage(input), "boom", input); + assert.equal(buildErrorBody(500, input).error.message, "boom", input); + } +}); + +test("serialized stack separators remove every escape prefix and preserve fallback", () => { + for (const slashCount of [1, 2, 3, 4, 8]) { + const separator = `${"\\".repeat(slashCount)}n`; + const framed = `failed${separator} at Secret (/home/alice/secret.ts:1:2)`; + const stackOnly = `${separator} at Secret (/home/alice/secret.ts:1:2)`; + + assert.equal(sanitizeErrorMessage(framed), "failed", framed); + assert.equal(sanitizeErrorMessage(stackOnly), "", stackOnly); + assert.equal(buildErrorBody(500, stackOnly).error.message, "Internal server error", stackOnly); + } +}); + +test("serialized stack tails remove async, Node, eval, and aggregate frames", () => { + const cases = [ + "boom\n at async /home/alice/app.ts:1:2", + "boom\n at processTicksAndRejections (node:internal/process/task_queues:95:5)", + "boom\\u000a at async /home/alice/app.ts:1:2", + "boom\\u000d at async src/app.ts:1:2", + "boom\\n at async Promise.all (index 0)", + "boom\\u000A at [eval]:1:1", + "boom\\u000a at processTicksAndRejections (node:internal/process/task_queues:95:5)", + "boom\\u000d at node:events:1:2", + "boom at processTicksAndRejections (node:internal/process/task_queues:95:5)", + ]; + + for (const input of cases) { + assert.equal(sanitizeErrorMessage(input), "boom", input); + assert.equal(buildErrorBody(500, input).error.message, "boom", input); + } +}); + +test("stack-tail detection preserves URLs, routes, and stack-like prose", () => { + const cases = [ + "request stopped at handler (https://api.example.com/status)", + "request stopped at handler (/v1/status)", + "Retry at processTicksAndRejections (node:internal/process/task_queues)", + "retry at handler (node:internal/process/task_queues:soon:later)", + "completed at async Promise.all (index zero)", + "boom\\u000a at handler (https://api.example.com/status)", + "boom\\u000a at handler (/v1/status)", + "boom\\u000a at async https://api.example.com/status", + "boom\\u000a at Promise.all (index many)", + ]; + + for (const input of cases) { + assert.equal(sanitizeErrorMessage(input), input, input); + } +}); + +test("sanitizeErrorMessage removes an unambiguous inline named stack frame", () => { + const input = "single-line stack at SecretFunction (/srv/private/file.ts:1:2)"; + assert.equal(sanitizeErrorMessage(input), "single-line stack"); +}); + +test("sanitizeErrorMessage keeps useful prose and the documented unknown-root limitation", () => { + const safeCases = [ + "Failed at /srv/private/file.ts:1:2", + "Cannot open /custom/private/token", + "message at handler (/v1/status)", + "request stopped at handler (https://api.example.com/status)", + ]; + const expected = [ + "Failed at ", + "Cannot open /custom/private/token", + "message at handler (/v1/status)", + "request stopped at handler (https://api.example.com/status)", + ]; + + for (let index = 0; index < safeCases.length; index++) { + assert.equal(sanitizeErrorMessage(safeCases[index]), expected[index], safeCases[index]); + } +}); + +test("buildErrorBody applies credential and inline-stack sanitization", () => { + const credentialBody = buildErrorBody(500, "upstream token=sk-private-value"); + const stackBody = buildErrorBody( + 500, + "upstream failed at SecretFunction (/srv/private/file.ts:1:2)" + ); + + assert.equal(credentialBody.error.message, "upstream token=[REDACTED]"); + assert.equal(stackBody.error.message, "upstream failed"); +}); diff --git a/tests/unit/error-message-sanitization.test.ts b/tests/unit/error-message-sanitization.test.ts index 4c726771643..ac8eff6d3ca 100644 --- a/tests/unit/error-message-sanitization.test.ts +++ b/tests/unit/error-message-sanitization.test.ts @@ -223,6 +223,545 @@ test("sanitizeErrorMessage replaces absolute paths with ", async () => { const out2 = sanitizeErrorMessage("Module not found: C:\\Users\\admin\\app\\index.js:1:1"); assert.ok(!out2.includes("C:\\Users\\admin")); assert.ok(out2.includes("")); + + const out3 = sanitizeErrorMessage("Native binary missing at /srv/private/tls-client"); + assert.ok(!out3.includes("/srv/private/tls-client")); + assert.ok(out3.includes("")); +}); + +test("sanitizeErrorMessage redacts wrapped filesystem paths without stripping punctuation", async () => { + const { sanitizeErrorMessage } = await import("../../open-sse/utils/error.ts"); + const cases = [ + { + input: "Cannot load '/home/alice/omniroute/index.js:12:7'.", + expected: "Cannot load ''.", + }, + { + input: 'Cannot load "C:\\Users\\alice\\omniroute\\index.js:12:7",', + expected: 'Cannot load "",', + }, + { + input: "Loader failed (/opt/omniroute/native/tls-client.node:42:9).", + expected: "Loader failed ().", + }, + { + input: "Loader failed (C:\\Users\\alice\\omniroute\\tls-client.node:42:9);", + expected: "Loader failed ();", + }, + { + input: "Import failed for file:///home/alice/omniroute/index.mjs:8:3;", + expected: "Import failed for ;", + }, + { + input: "Import failed for file:///C:/Users/alice/omniroute/index.cjs:8:3!", + expected: "Import failed for !", + }, + { + input: "Cannot import file://server/share/private/secret.js", + expected: "Cannot import ", + }, + { + input: "Cannot import 'file://localhost/home/alice/private/secret.js'", + expected: "Cannot import ''", + }, + { + input: "Cannot load //server/share/My Project/private/secret.js", + expected: "Cannot load ", + }, + { + input: "dlopen failed at /Users/alice/private/native.dylib:12:4", + expected: "dlopen failed at ", + }, + { + input: "open '/Users/alice/private/native.dylib'", + expected: "open ''", + }, + { + input: "dlopen failed at /nix/store/abc/private/native.so", + expected: "dlopen failed at ", + }, + { + input: "open '/nix/store/abc/private/native.so'", + expected: "open ''", + }, + { + input: "dlopen failed at /custom/private/native.dll", + expected: "dlopen failed at ", + }, + { + input: "Failed at /proc/self/fd/17", + expected: "Failed at ", + }, + { + input: "Failed at /dev/shm/private-token", + expected: "Failed at ", + }, + { + input: "Failed at /sys/kernel/private /boot/private /media/alice/private", + expected: "Failed at ", + }, + { + input: "open '/custom/private/no-extension'", + expected: "open ''", + }, + { + input: "Couldn't open '/home/alice/My Project/tls-client/bin/native.so'", + expected: "Couldn't open ''", + }, + { + input: "dlopen failed at /home/alice/My Project/native.so: denied", + expected: "dlopen failed at : denied", + }, + { + input: "dlopen failed at /custom/alice/My Project/native.so: denied", + expected: "dlopen failed at : denied", + }, + { + input: "dlopen failed at /home/alice/My Project/native.so. Retry", + expected: "dlopen failed at . Retry", + }, + { + input: "Cannot read /home/alice/My Project/config.json: denied", + expected: "Cannot read : denied", + }, + { + input: "Cannot read /custom/alice/My Project/secret.pem: denied", + expected: "Cannot read : denied", + }, + { + input: "Cannot import file:///home/alice/My Project/native.so: denied", + expected: "Cannot import : denied", + }, + { + input: 'EACCES: open "C:\\Users\\Alice Smith\\tls-client\\bin\\native.dll": denied', + expected: 'EACCES: open "": denied', + }, + { + input: "dlopen failed at \\\\server\\share\\secret\\native.dll", + expected: "dlopen failed at ", + }, + { + input: 'dlopen failed at "\\\\server\\share\\secret folder\\native.dll"', + expected: 'dlopen failed at ""', + }, + { + input: "dlopen failed at \\\\?\\C:\\secret\\native.dll", + expected: "dlopen failed at ", + }, + { + input: "dlopen failed at C:\\Users\\John Smith\\private\\native.dll", + expected: "dlopen failed at ", + }, + { + input: "Route ('C:\\Users\\alice\\private\\native.dll') failed", + expected: "Route ('') failed", + }, + { + input: "Cannot open '/v1/private/secret'", + expected: "Cannot open ''", + }, + { + input: "Cannot open '/v1/private/secret.js'", + expected: "Cannot open ''", + }, + { + input: "Cannot open /home/alice/My Project/archive.tar.gz: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open file:///home/alice/My Project/file.js.map: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /home/alice/My Project/archive.tar.gz/child: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /archive.tar.gz/secret", + expected: "Cannot open ", + }, + { + input: "Cannot open file:///home/alice/My Project/archive.tar.gz/child: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open C:\\Users\\alice\\My Project\\archive.tar.gz\\child: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot get '/home/alice/private/secret.pem'", + expected: "Cannot get ''", + }, + { + input: "Please delete '/Users/alice/private/token.txt'", + expected: "Please delete ''", + }, + { + input: "GET '/home/alice/private/secret.pem'", + expected: "GET ''", + }, + { + input: "Route: /Users/alice/private/token.txt", + expected: "Route: ", + }, + { + input: "Cannot route '/custom/alice/private/secret.pem'", + expected: "Cannot route ''", + }, + { + input: "Cannot load '/home/alice/O'Connor/private/secret.js'", + expected: "Cannot load ''", + }, + { + input: "Cannot load '/home/alice/John' Doe/private/secret.js'", + expected: "Cannot load ''", + }, + { + input: "Cannot load '/home/alice/O'.config/private/secret.js'", + expected: "Cannot load ''", + }, + { + input: "Route ('/app/status') failed at '/home/alice/private/secret.pem'", + expected: "Route ('/app/status') failed at ''", + }, + { + input: "Cannot load /home/alice/My.Project Files/native.so", + expected: "Cannot load ", + }, + { + input: "Cannot load file:///home/alice/My.Project Files/native.so", + expected: "Cannot load ", + }, + { + input: "Cannot load file://server/share/My.Project Files/native.so", + expected: "Cannot load ", + }, + { + input: "Cannot load C:\\Users\\alice\\My.Project Files\\native.dll", + expected: "Cannot load ", + }, + { + input: "Cannot load \\\\server\\share\\My.Project Files\\native.dll", + expected: "Cannot load ", + }, + { + input: "Cannot load \\\\?\\C:\\My.Project Files\\native.dll", + expected: "Cannot load ", + }, + { + input: "Cannot load C:\\My, Project\\native.dll", + expected: "Cannot load ", + }, + { + input: "Cannot load file:///home/My, Project/native.so", + expected: "Cannot load ", + }, + { + input: "Cannot open /v1/private/My very secret.js: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /v1/private/My very secret.js because permission failed", + expected: "Cannot open because permission failed", + }, + { + input: "Cannot open /custom/file.js because https://api.example.com/v1 failed", + expected: "Cannot open because https://api.example.com/v1 failed", + }, + { + input: "Cannot open /home/alice/file.js because https://api.example.com/v1 failed", + expected: "Cannot open because https://api.example.com/v1 failed", + }, + { + input: "Cannot open C:\\Users\\alice\\file.js because https://api.example.com/v1 failed", + expected: "Cannot open because https://api.example.com/v1 failed", + }, + { + input: "Cannot open file:///home/alice/file.js because https://api.example.com/v1 failed", + expected: "Cannot open because https://api.example.com/v1 failed", + }, + { + input: "Cannot open /custom/file.js because Route /v1/config.js failed", + expected: "Cannot open because Route /v1/config.js failed", + }, + { + input: "Cannot open /home/alice/file.js because Route ('/v1/config.js') failed", + expected: "Cannot open because Route ('/v1/config.js') failed", + }, + { + input: "Cannot open /home/alice/file.js because Route('/v1/config.js') failed", + expected: "Cannot open because Route('/v1/config.js') failed", + }, + { + input: "Cannot open C:\\Users\\alice\\file.js because GET /v1/config.js failed", + expected: "Cannot open because GET /v1/config.js failed", + }, + { + input: "Cannot open file:///home/alice/file.js because TRACE ('/v1/config.js') failed", + expected: "Cannot open because TRACE ('/v1/config.js') failed", + }, + { + input: "Cannot open /home/alice/My Project.js Files/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /custom/My Project.js Files/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open file:///home/alice/My Project.js Files/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open C:\\Users\\alice\\My Project.dll Files\\private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /home/alice/My archive.tar.gz Child/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /home/alice/My Project.js Secret Files/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open file:///home/alice/My Project.js Secret Files/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open C:\\Users\\alice\\My Project.dll Secret Files\\private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /home/alice/My Folder/Another Deep secret.pem: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open file:///home/alice/My Folder/Another Deep secret.pem: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open C:\\Users\\alice\\My Folder\\Another Deep secret.pem: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open /home/alice/My Folder/Another because Secret/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open file:///home/alice/My Folder/Another because Secret/private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open C:\\Users\\alice\\My Folder\\Another because Secret\\private: denied", + expected: "Cannot open : denied", + }, + { + input: "Cannot open \\\\server\\share\\My Folder\\Another because Secret\\private: denied", + expected: "Cannot open : denied", + }, + { + input: "Route ('//server/share/private/secret.pem') failed", + expected: "Route ('') failed", + }, + { + input: "GET '//server/share/private/secret.pem' failed", + expected: "GET '' failed", + }, + { + input: "Route ('//?/C:/private/secret.pem') failed", + expected: "Route ('') failed", + }, + { + input: "Failure path:/home/alice/private/secret.ts", + expected: "Failure path:", + }, + { + input: "Failure path:/Users/alice/My Project/secret.js", + expected: "Failure path:", + }, + { + input: "Failure path=>/home/alice/private/secret.pem", + expected: "Failure path=>", + }, + { + input: "Failure path->/home/alice/private/secret.pem", + expected: "Failure path->", + }, + { + input: "Failure path|/home/alice/private/secret.pem", + expected: "Failure path|", + }, + { + input: "Failure path=>C:\\Users\\alice\\private\\secret.pem", + expected: "Failure path=>", + }, + { + input: "Failure path=>file:///home/alice/private/secret.pem", + expected: "Failure path=>", + }, + { + input: "Failure://server/share/LeakBoundary.pem tail", + expected: "Failure:", + }, + { + input: "Error,/home/alice/private/a.ts", + expected: "Error,", + }, + { + input: "Error;/home/alice/private/a.ts", + expected: "Error;", + }, + { + input: "Error./home/alice/private/a.ts", + expected: "Error.", + }, + { + input: "Error,C:\\Users\\alice\\secret.js", + expected: "Error,", + }, + { + input: "Error;file:///home/alice/secret.js", + expected: "Error;", + }, + { + input: "Error /home,denied", + expected: "Error ", + }, + { + input: "Error /home:denied", + expected: "Error ", + }, + { + input: "Cannot read /secret.pem", + expected: "Cannot read ", + }, + { + input: "Cannot read /config.json", + expected: "Cannot read ", + }, + { + input: "Cannot open /home/alice/My Project", + expected: "Cannot open ", + }, + { + input: "Cannot open /home/alice/My project", + expected: "Cannot open ", + }, + { + input: "Cannot open file:///home/alice/My Project", + expected: "Cannot open ", + }, + { + input: "Cannot open file://server/share/My Project", + expected: "Cannot open ", + }, + { + input: "Cannot open '/home/alice/a.pem' then '/home/bob/b.pem' failed", + expected: "Cannot open '' then '' failed", + }, + { + input: "Cannot open '/home/alice/a.pem' because token 'abc' expired", + expected: "Cannot open '' expired", + }, + { + input: "Cannot open /v1/private/My secret.js: denied", + expected: "Cannot open : denied", + }, + ]; + + for (const { input, expected } of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } + + for (const boundary of [ + "after", + "because", + "before", + "but", + "crashed", + "denied", + "eacces", + "enoent", + "expired", + "failed", + "rejected", + "retry", + "then", + "when", + "while", + ]) { + const input = "/home/alice/My Folder/Another " + boundary + " Secret/private: denied"; + assert.equal(sanitizeErrorMessage(input), ": denied", input); + } +}); + +test("sanitizeErrorMessage preserves HTTPS URLs and ordinary slash text", async () => { + const { sanitizeErrorMessage } = await import("../../open-sse/utils/error.ts"); + const cases = [ + "Request to https://api.example.com/path failed", + "Request url=>https://api.example.com/path failed", + "Route /v1/chat/completions rejected input/output text", + "Route ('/v1/chat/completions') rejected input/output text", + "Route('/v1/chat/completions') rejected input/output text", + "Route ('/health') rejected input/output text", + "Route ('/api/models') rejected input/output text", + "Route ('/v1/chat/completions') reported native.node", + "Route ('/health') reported native.so", + "Route ('/api/models') reported native.dll", + "Route ('/app/status') rejected input/output text", + "Route('/app/status') rejected input/output text", + "Route ('/v1/config.js') rejected input/output text", + "GET /app/status", + "Message /health, try again", + "Endpoint /v1/chat/completions, rejected", + "Request /foo and then input/output text", + "Route: ('/app/config.js')", + "Route: /app/config.js", + "GET: ('/app/config.js')", + "GET: /app/config.js", + "TRACE ('/v1/config.js') failed", + "TRACE /v1/config.js failed", + "CONNECT ('/app/status') failed", + "CONNECT /app/status failed", + "Endpoint /v1/chat/completions rejected input/output text", + "Message /api/models rejected input/output text", + ]; + + for (const input of cases) { + assert.equal(sanitizeErrorMessage(input), input); + } +}); + +test("sanitizeErrorMessage stops path spans before unrelated slash prose", async () => { + const { sanitizeErrorMessage } = await import("../../open-sse/utils/error.ts"); + const cases = [ + { + input: "Failed at /home/alice because input/output validation failed", + expected: "Failed at ", + }, + { + input: "Failed at /proc/self/fd/17 then input/output validation failed", + expected: "Failed at ", + }, + ]; + + for (const { input, expected } of cases) { + assert.equal(sanitizeErrorMessage(input), expected, input); + } +}); + +test("sanitizeErrorMessage preserves context after ambiguous unquoted paths", async () => { + const { sanitizeErrorMessage } = await import("../../open-sse/utils/error.ts"); + const determinable = sanitizeErrorMessage( + "Failed at C:\\Program Files\\tls-client because loader.js crashed" + ); + const ambiguous = sanitizeErrorMessage( + "Failed at C:\\Program Files because native loader failed" + ); + + assert.equal(determinable, "Failed at because loader.js crashed"); + assert.equal(ambiguous, "Failed at "); }); test("sanitizeErrorMessage handles non-string inputs safely", async () => { diff --git a/tests/unit/executor-notion-web.test.ts b/tests/unit/executor-notion-web.test.ts index ca2da88ab4f..83f0a241d54 100644 --- a/tests/unit/executor-notion-web.test.ts +++ b/tests/unit/executor-notion-web.test.ts @@ -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; body?: string }) => Promise<{ + handler: ( + url: string, + opts: { headers?: Record; body?: string } + ) => Promise<{ status: number; text: string; }> @@ -35,6 +37,27 @@ function installNotionTlsMock( return () => __setTlsFetchOverrideForTesting(null); } +function hostilePrototypeFailure(label: string): unknown { + return new Proxy( + {}, + { + getPrototypeOf() { + throw new Error(`access_token=${label}-prototype-secret at /srv/private/${label}.ts:1:2`); + }, + get(_target, property) { + if (property === "toString") { + return () => { + throw new Error( + `access_token=${label}-coercion-secret at /srv/private/${label}-coercion.ts:1:2` + ); + }; + } + return undefined; + }, + } + ); +} + describe("NotionWebExecutor — registry consistency", () => { it("is present in WEB_COOKIE_PROVIDERS with the expected shape", () => { const entry = (WEB_COOKIE_PROVIDERS as Record>)["notion-web"]; @@ -389,6 +412,145 @@ describe("NotionWebExecutor — upstream translation (mocked TLS fetch)", () => } }); + it("sanitizes credentials, private paths, and stack frames from TLS transport errors", async () => { + const executor = new mod.NotionWebExecutor(); + const restore = installNotionTlsMock(async () => { + throw new Error( + "TLS transport failed; access token: notion-tls-secret while reading " + + "/srv/private/notion/request.ts:42:7\n at send (/srv/private/notion/request.ts:42:7)" + ); + }); + 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(result.url, "https://app.notion.com/api/v3/runInferenceTranscript"); + assert.equal( + (result.transformedBody as { spaceId?: string }).spaceId, + "space-1", + "the executor must preserve the upstream request body on transport failure" + ); + const errBody = (await result.response.json()) as { + error: { message: string; type: string; code: string }; + }; + assert.match(errBody.error.message, /Notion fetch failed: TLS transport failed/); + assert.equal(errBody.error.type, "upstream_error"); + assert.equal(errBody.error.code, "HTTP_502"); + assert.ok(!errBody.error.message.includes("notion-tls-secret")); + assert.ok(!errBody.error.message.includes("/srv/private")); + assert.ok(!errBody.error.message.includes("at send")); + } finally { + restore(); + } + }); + + it("fails closed when a TLS rejection has a hostile prototype", async () => { + const executor = new mod.NotionWebExecutor(); + const restoreTls = installNotionTlsMock(async () => { + throw hostilePrototypeFailure("notion-tls-proxy"); + }); + + 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); + const responseText = await result.response.text(); + assert.match(responseText, /Notion fetch failed: unknown error/); + assert.doesNotMatch(responseText, /prototype-secret|coercion-secret|\/srv\/private/); + } finally { + restoreTls(); + } + }); + + it("sanitizes fallback fetch errors when the native TLS client is unavailable", async () => { + const executor = new mod.NotionWebExecutor(); + const originalFetch = globalThis.fetch; + let fallbackCalls = 0; + const restoreTls = installNotionTlsMock(async () => { + throw new TlsClientUnavailableError("native TLS client unavailable"); + }); + globalThis.fetch = (async () => { + fallbackCalls += 1; + throw new Error( + "Fallback transport failed; access token: notion-fallback-secret while reading " + + "/opt/private/notion/fallback.ts:18:3\n at fetchFallback " + + "(/opt/private/notion/fallback.ts:18:3)" + ); + }) as typeof globalThis.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.ok(fallbackCalls >= 1, "plain fetch must be attempted after TLS unavailability"); + assert.equal(result.response.status, 502); + assert.equal(result.url, "https://app.notion.com/api/v3/runInferenceTranscript"); + assert.equal( + (result.transformedBody as { spaceId?: string }).spaceId, + "space-1", + "the executor must preserve the upstream request body on fallback failure" + ); + const errBody = (await result.response.json()) as { + error: { message: string; type: string; code: string }; + }; + assert.match(errBody.error.message, /Notion fetch failed: Fallback transport failed/); + assert.equal(errBody.error.type, "upstream_error"); + assert.equal(errBody.error.code, "HTTP_502"); + assert.ok(!errBody.error.message.includes("notion-fallback-secret")); + assert.ok(!errBody.error.message.includes("/opt/private")); + assert.ok(!errBody.error.message.includes("at fetchFallback")); + } finally { + globalThis.fetch = originalFetch; + restoreTls(); + } + }); + + it("fails closed when the fallback fetch rejects with a hostile prototype", async () => { + const executor = new mod.NotionWebExecutor(); + const originalFetch = globalThis.fetch; + const restoreTls = installNotionTlsMock(async () => { + throw new TlsClientUnavailableError("native TLS client unavailable"); + }); + globalThis.fetch = (async () => { + throw hostilePrototypeFailure("notion-fallback-proxy"); + }) as typeof globalThis.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); + const responseText = await result.response.text(); + assert.match(responseText, /Notion fetch failed: unknown error/); + assert.doesNotMatch(responseText, /prototype-secret|coercion-secret|\/srv\/private/); + } finally { + globalThis.fetch = originalFetch; + restoreTls(); + } + }); + 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 +691,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" } diff --git a/tests/unit/fix-tls-client-node-binary-7802.test.ts b/tests/unit/fix-tls-client-node-binary-7802.test.ts index 705c377f84a..54af328ffee 100644 --- a/tests/unit/fix-tls-client-node-binary-7802.test.ts +++ b/tests/unit/fix-tls-client-node-binary-7802.test.ts @@ -3,11 +3,14 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { existsSync, + lstatSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, + symlinkSync, + truncateSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -71,18 +74,8 @@ test("replaces a tampered binary with the pinned version and copies only verifie }; const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); const rootBin = join(tlsClientDir, "bin"); - const scriptsDir = join(tlsClientDir, "scripts"); mkdirSync(rootBin, { recursive: true }); - mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(rootBin, asset.file), "tampered"); - writeFileSync( - join(scriptsDir, "postinstall.js"), - `const fs = require("fs"); - const path = require("path"); - if (process.env.TLS_CLIENT_VERSION !== "1.15.1") process.exit(9); - fs.writeFileSync(path.join(__dirname, "..", ".observed-version"), process.env.TLS_CLIENT_VERSION); - fs.writeFileSync(path.join(__dirname, "..", "bin", ${JSON.stringify(asset.file)}), ${JSON.stringify(goodBytes)});` - ); mkdirSync(join(rootDir, "dist", "node_modules", "tls-client-node"), { recursive: true }); await fixTlsClientNodeBinary({ @@ -90,10 +83,11 @@ test("replaces a tampered binary with the pinned version and copies only verifie asset, strict: true, retryDelaysMs: [], + fetchImpl: async () => new Response(goodBytes, { status: 200 }), log() {}, }); - assert.equal(readFileSync(join(tlsClientDir, ".observed-version"), "utf8"), "1.15.1"); + assert.equal(readFileSync(join(rootBin, asset.file), "utf8"), goodBytes); assert.equal( readFileSync( join(rootDir, "dist", "node_modules", "tls-client-node", "bin", asset.file), @@ -106,6 +100,55 @@ test("replaces a tampered binary with the pinned version and copies only verifie } }); +test("host recovery downloads the pinned asset directly and never executes package postinstall", async () => { + const rootDir = makeRoot(); + try { + const bytes = Buffer.from("verified-host-direct-download"); + const asset = { + file: "tls-client-host-direct-test.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + const scriptsDir = join(tlsClientDir, "scripts"); + const binaryPath = join(rootBin, asset.file); + const marker = join(tlsClientDir, ".postinstall-ran"); + const danglingTarget = join(rootDir, "postinstall-must-not-write-here.so"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync( + join(scriptsDir, "postinstall.js"), + `require("fs").writeFileSync(${JSON.stringify(marker)}, "ran"); + require("fs").symlinkSync(${JSON.stringify(danglingTarget)}, ${JSON.stringify(binaryPath)});` + ); + + const requests: string[] = []; + let observedSignal = false; + await fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + fetchImpl: async (input, init) => { + requests.push(String(input)); + observedSignal = init?.signal instanceof AbortSignal; + return new Response(bytes, { status: 200 }); + }, + log() {}, + }); + + assert.deepEqual(requests, [ + "https://github.com/bogdanfinn/tls-client/releases/download/v1.15.1/" + asset.file, + ]); + assert.equal(observedSignal, true); + assert.equal(existsSync(marker), false); + assert.equal(existsSync(danglingTarget), false); + assert.deepEqual(readFileSync(binaryPath), bytes); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + test("no-ops when node_modules/tls-client-node is absent (module not installed)", async () => { const rootDir = makeRoot(); try { @@ -151,29 +194,25 @@ test("retries the download when root bin/ is empty, and stops once a file appear file: "tls-client-linux-ubuntu-amd64-1.15.1.so", sha256: createHash("sha256").update(binary).digest("hex"), }; - const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); - const rootBin = join(tlsClientDir, "bin"); + const rootBin = join(rootDir, "node_modules", "tls-client-node", "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", ${JSON.stringify(asset.file)}), ${JSON.stringify(binary)}); - }` - ); - + let attempts = 0; const { logs, log } = collectLogs(); - await fixTlsClientNodeBinary({ rootDir, asset, log, retryDelaysMs: [1, 1, 1] }); + await fixTlsClientNodeBinary({ + rootDir, + asset, + log, + retryDelaysMs: [1, 1, 1], + fetchImpl: async () => { + attempts += 1; + return attempts === 1 + ? new Response(null, { status: 503 }) + : new Response(binary, { status: 200 }); + }, + }); + assert.equal(attempts, 2); assert.ok(existsSync(join(rootBin, asset.file))); assert.ok( logs.some((m) => m.includes("fetched successfully")), @@ -189,17 +228,20 @@ test("warns without throwing when every retry leaves bin/ empty (still rate-limi 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] })); + await assert.doesNotReject( + fixTlsClientNodeBinary({ + rootDir, + log, + retryDelaysMs: [1, 1], + fetchImpl: async () => new Response(null, { status: 429 }), + }) + ); } finally { console.warn = originalWarn; } @@ -221,12 +263,161 @@ test("strict mode rejects an unverified download instead of shipping it", async sha256: createHash("sha256").update("expected").digest("hex"), }; const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); - const scriptsDir = join(tlsClientDir, "scripts"); mkdirSync(join(tlsClientDir, "bin"), { recursive: true }); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + fetchImpl: async () => new Response("tampered", { status: 200 }), + log() {}, + }), + /Could not fetch tls-client-node v1\.15\.1 verified native binary/ + ); + assert.equal(existsSync(join(tlsClientDir, "bin", asset.file)), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict mode fails closed when the recovered root binary disappears before post-verification", async () => { + const rootDir = makeRoot(); + try { + const binary = "verified-then-removed-native-binary"; + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + const standaloneDir = join(rootDir, ".build", "next", "standalone"); + const rootBinaryPath = join(rootBin, asset.file); + const runtimeBinaryPath = join( + standaloneDir, + "runtime-assets", + "tls-client", + "bin", + asset.file + ); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(standaloneDir, { recursive: true }); + + const logs: string[] = []; + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + fetchImpl: async () => new Response(binary, { status: 200 }), + log(message) { + logs.push(message); + if (message.includes("fetched successfully")) rmSync(rootBinaryPath); + }, + }), + /post-recovery verification|recovered.*unverified|no longer verified/i + ); + + assert.ok(logs.some((message) => message.includes("fetched successfully"))); + assert.equal(existsSync(runtimeBinaryPath), false); + assert.equal( + logs.some((message) => message.includes("runtime seed")), + false + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("best-effort mode warns and does not seed standalone after root post-verification fails", async () => { + const rootDir = makeRoot(); + try { + const binary = "verified-then-replaced-native-binary"; + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + const standaloneDir = join(rootDir, ".build", "next", "standalone"); + const rootBinaryPath = join(rootBin, asset.file); + const runtimeBinaryPath = join( + standaloneDir, + "runtime-assets", + "tls-client", + "bin", + asset.file + ); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(standaloneDir, { recursive: true }); + + const logs: string[] = []; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (message: string) => warnings.push(message); + try { + await assert.doesNotReject( + fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + retryDelaysMs: [], + fetchImpl: async () => new Response(binary, { status: 200 }), + log(message) { + logs.push(message); + if (message.includes("fetched successfully")) { + rmSync(rootBinaryPath); + writeFileSync(rootBinaryPath, "tampered-after-recovery"); + } + }, + }) + ); + } finally { + console.warn = originalWarn; + } + + assert.ok( + warnings.some( + (message) => + message.includes("failed post-recovery verification") && + message.includes("refusing to copy or seed standalone artifacts") + ) + ); + assert.equal(existsSync(runtimeBinaryPath), false); + assert.equal( + logs.some((message) => message.includes("runtime seed")), + false + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict mode rejects a dangling native symlink before attempting recovery", async () => { + const rootDir = makeRoot(); + try { + const bytes = "verified-native-binary"; + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + const scriptsDir = join(tlsClientDir, "scripts"); + const danglingTarget = join(rootDir, "must-not-be-created.so"); + const marker = join(tlsClientDir, ".postinstall-ran"); + mkdirSync(rootBin, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); + const binaryPath = join(rootBin, asset.file); + symlinkSync(danglingTarget, binaryPath); writeFileSync( join(scriptsDir, "postinstall.js"), - `require("fs").writeFileSync(require("path").join(__dirname, "..", "bin", ${JSON.stringify(asset.file)}), "tampered");` + `require("fs").writeFileSync(${JSON.stringify(marker)}, "ran"); + require("fs").writeFileSync(${JSON.stringify(binaryPath)}, ${JSON.stringify(bytes)});` ); await assert.rejects( @@ -237,9 +428,481 @@ test("strict mode rejects an unverified download instead of shipping it", async retryDelaysMs: [], log() {}, }), - /Could not fetch tls-client-node v1\.15\.1 verified native binary/ + /unsafe|symlink|regular file/i ); - assert.equal(existsSync(join(tlsClientDir, "bin", asset.file)), false); + assert.ok(lstatSync(binaryPath).isSymbolicLink()); + assert.equal(existsSync(marker), false); + assert.equal(existsSync(danglingTarget), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict mode rejects an oversized local native file before attempting recovery", async () => { + const rootDir = makeRoot(); + try { + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update("unreachable").digest("hex"), + }; + const tlsClientDir = join(rootDir, "node_modules", "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + const scriptsDir = join(tlsClientDir, "scripts"); + const marker = join(tlsClientDir, ".postinstall-ran"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + const binaryPath = join(rootBin, asset.file); + writeFileSync(binaryPath, "x"); + truncateSync(binaryPath, 64 * 1024 * 1024 + 1); + writeFileSync( + join(scriptsDir, "postinstall.js"), + `require("fs").writeFileSync(${JSON.stringify(marker)}, "ran");` + ); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /exceeds.*64 MiB|too large/i + ); + assert.equal(existsSync(marker), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict mode bounds a source that grows beyond 64 MiB after its initial fstat", async () => { + const rootDir = makeRoot(); + try { + const bytes = Buffer.from("small-before-fstat-race"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const binaryPath = join(rootBin, asset.file); + mkdirSync(rootBin, { recursive: true }); + writeFileSync(binaryPath, bytes); + + let hookCalls = 0; + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + afterSourceStat(observedPath) { + hookCalls += 1; + assert.equal(observedPath, binaryPath); + truncateSync(binaryPath, 64 * 1024 * 1024 + 1); + }, + log() {}, + }), + /exceeds.*64 MiB|too large/i + ); + + assert.equal(hookCalls, 1); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict standalone verification seeds a public read-execute path outside DATA_DIR", async () => { + const rootDir = makeRoot(); + try { + const binary = Buffer.from("verified-standalone-runtime-binary"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const standaloneDir = join(rootDir, ".build", "next", "standalone"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(standaloneDir, { recursive: true }); + writeFileSync(join(rootBin, asset.file), binary); + + await fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + log() {}, + }); + + const runtimePath = join(standaloneDir, "runtime-assets", "tls-client", "bin", asset.file); + assert.deepEqual(readFileSync(runtimePath), binary); + if (process.platform !== "win32") { + assert.equal(lstatSync(runtimePath).mode & 0o777, 0o555); + } + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict standalone verification rejects a runtime-assets symlink outside the artifact", async () => { + const rootDir = makeRoot(); + try { + const binary = Buffer.from("verified-native-must-stay-inside-artifact"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const standaloneDir = join(rootDir, ".build", "electron-standalone"); + const outsideDir = join(rootDir, "outside-artifact"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(standaloneDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(rootBin, asset.file), binary); + symlinkSync(outsideDir, join(standaloneDir, "runtime-assets"), "dir"); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /unsafe.*(ancestor|destination|symbolic)|symlink/i + ); + + assert.equal( + existsSync(join(outsideDir, "tls-client", "bin", asset.file)), + false, + "the verifier must not write through an artifact ancestor symlink" + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict standalone verification rejects a symlinked artifact root", async () => { + const rootDir = makeRoot(); + try { + const binary = Buffer.from("verified-native-must-not-follow-artifact-root"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const standaloneDir = join(rootDir, ".build", "electron-standalone"); + const outsideDir = join(rootDir, "outside-artifact-root"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(join(rootDir, ".build"), { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(rootBin, asset.file), binary); + symlinkSync(outsideDir, standaloneDir, "dir"); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /unsafe.*(ancestor|destination|symbolic)|symlink/i + ); + + assert.equal(existsSync(join(outsideDir, "runtime-assets")), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict standalone verification rejects an intermediate ancestor that escapes root", async () => { + const rootDir = makeRoot(); + const outsideDir = makeRoot(); + try { + const binary = Buffer.from("verified-native-must-not-follow-build-ancestor"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const outsideBuild = join(outsideDir, "build-target"); + const standaloneDir = join(rootDir, ".build", "next", "standalone"); + const escapedStandaloneDir = join(outsideBuild, "next", "standalone"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(escapedStandaloneDir, { recursive: true }); + writeFileSync(join(rootBin, asset.file), binary); + symlinkSync(outsideBuild, join(rootDir, ".build"), "dir"); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /unsafe.*(ancestor|outside)|symlink/i + ); + + assert.equal(existsSync(join(escapedStandaloneDir, "runtime-assets")), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } +}); + +test("strict final npm and Electron gate repairs a present seed by digest, not presence", async () => { + const rootDir = makeRoot(); + try { + const binary = Buffer.from("verified-public-artifact-seed"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const standaloneDir = join(rootDir, ".build", "electron-standalone"); + const runtimeBin = join(standaloneDir, "runtime-assets", "tls-client", "bin"); + const runtimePath = join(runtimeBin, asset.file); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(runtimeBin, { recursive: true }); + writeFileSync(join(rootBin, asset.file), binary); + writeFileSync(runtimePath, "present-but-tampered"); + + await fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + log() {}, + }); + + assert.deepEqual(readFileSync(runtimePath), binary); + if (process.platform !== "win32") { + assert.equal(lstatSync(runtimePath).mode & 0o777, 0o555); + } + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict standalone verification fails when no final artifact was produced", async () => { + const rootDir = makeRoot(); + try { + const binary = Buffer.from("verified-root-only-binary"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + mkdirSync(rootBin, { recursive: true }); + writeFileSync(join(rootBin, asset.file), binary); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir: join(rootDir, ".build", "next", "standalone"), + requireStandalone: true, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /standalone.*not found|final.*artifact|runtime.*seed/i + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict build helper rejects an asset path that escapes its bin directory", async () => { + const rootDir = makeRoot(); + try { + mkdirSync(join(rootDir, "node_modules", "tls-client-node", "bin"), { recursive: true }); + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset: { + file: "../outside.so", + sha256: createHash("sha256").update("outside").digest("hex"), + }, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /invalid.*asset.*path/i + ); + assert.equal(existsSync(join(rootDir, "node_modules", "tls-client-node", "outside.so")), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("multi-target repair downloads and verifies a missing pinned linux-arm64 runtime seed", async () => { + const rootDir = makeRoot(); + try { + const x64Bytes = Buffer.from("synthetic-linux-x64-native"); + const arm64Bytes = Buffer.from("synthetic-linux-arm64-native"); + const nativeAssets = { + "linux-x64": { + file: "tls-client-linux-x64-test.so", + sha256: createHash("sha256").update(x64Bytes).digest("hex"), + }, + "linux-arm64": { + file: "tls-client-linux-arm64-test.so", + sha256: createHash("sha256").update(arm64Bytes).digest("hex"), + }, + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const standaloneDir = join(rootDir, ".build", "electron-standalone"); + const finalBin = join(standaloneDir, "runtime-assets", "tls-client", "bin"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(standaloneDir, { recursive: true }); + writeFileSync(join(rootBin, nativeAssets["linux-x64"].file), x64Bytes); + + const arm64Seed = join(finalBin, nativeAssets["linux-arm64"].file); + assert.equal(existsSync(arm64Seed), false, "arm64 must start absent on the x64 host"); + + const requests: string[] = []; + let observedSignal = false; + const fetchImpl: typeof fetch = async (input, init) => { + requests.push(String(input)); + observedSignal = init?.signal instanceof AbortSignal; + return new Response(arm64Bytes, { + status: 200, + headers: { "content-length": String(arm64Bytes.byteLength) }, + }); + }; + + await fixTlsClientNodeBinary({ + rootDir, + platform: "linux", + arches: ["x64", "arm64"], + nativeAssets, + fetchImpl, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + log() {}, + }); + + assert.deepEqual(requests, [ + "https://github.com/bogdanfinn/tls-client/releases/download/v1.15.1/" + + nativeAssets["linux-arm64"].file, + ]); + assert.equal(observedSignal, true, "the direct download must be timeout-abortable"); + assert.doesNotMatch(requests[0], /latest/i); + assert.equal( + createHash("sha256").update(readFileSync(arm64Seed)).digest("hex"), + nativeAssets["linux-arm64"].sha256 + ); + const x64Seed = join(finalBin, nativeAssets["linux-x64"].file); + assert.deepEqual(readFileSync(x64Seed), x64Bytes); + if (process.platform !== "win32") { + assert.equal(lstatSync(x64Seed).mode & 0o777, 0o555); + assert.equal(lstatSync(arm64Seed).mode & 0o777, 0o555); + } + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("non-host pinned download times out, retries, and then seeds verified bytes", async () => { + const rootDir = makeRoot(); + try { + const bytes = Buffer.from("verified-after-timeout-retry"); + const asset = { + file: "tls-client-non-host-timeout-test.bin", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const targetArch = process.arch === "arm64" ? "x64" : "arm64"; + const standaloneDir = join(rootDir, ".build", "electron-standalone"); + mkdirSync(join(rootDir, "node_modules", "tls-client-node", "bin"), { recursive: true }); + mkdirSync(standaloneDir, { recursive: true }); + + let attempts = 0; + const fetchImpl: typeof fetch = async (_input, init) => { + attempts += 1; + if (attempts === 1) { + return await new Promise((_resolve, reject) => { + assert.ok(init?.signal, "download attempt must receive an abort signal"); + init.signal.addEventListener("abort", () => reject(new Error("timed out")), { + once: true, + }); + }); + } + return new Response(bytes, { status: 200 }); + }; + + await fixTlsClientNodeBinary({ + rootDir, + asset, + platform: process.platform, + arches: [targetArch], + fetchImpl, + downloadTimeoutMs: 5, + retryDelaysMs: [0], + standaloneDir, + requireStandalone: true, + strict: true, + log() {}, + }); + + assert.equal(attempts, 2); + assert.deepEqual( + readFileSync(join(standaloneDir, "runtime-assets", "tls-client", "bin", asset.file)), + bytes + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("non-host pinned download rejects a declared body above the shared 64 MiB bound", async () => { + const rootDir = makeRoot(); + try { + const bytes = Buffer.from("must-not-be-written"); + const asset = { + file: "tls-client-non-host-oversize-test.bin", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const targetArch = process.arch === "arm64" ? "x64" : "arm64"; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + mkdirSync(rootBin, { recursive: true }); + + const logs: string[] = []; + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + platform: process.platform, + arches: [targetArch], + fetchImpl: async () => + new Response(bytes, { + status: 200, + headers: { "content-length": String(64 * 1024 * 1024 + 1) }, + }), + retryDelaysMs: [], + strict: true, + log(message) { + logs.push(message); + }, + }), + /Could not fetch tls-client-node.*after retries/ + ); + + assert.equal(existsSync(join(rootBin, asset.file)), false); + assert.ok(logs.some((message) => /exceeds the 64 MiB limit/.test(message))); } finally { rmSync(rootDir, { recursive: true, force: true }); } diff --git a/tests/unit/fix-tls-client-node-binary-security.test.ts b/tests/unit/fix-tls-client-node-binary-security.test.ts new file mode 100644 index 00000000000..df1b2d0f6d4 --- /dev/null +++ b/tests/unit/fix-tls-client-node-binary-security.test.ts @@ -0,0 +1,493 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +import { fixTlsClientNodeBinary } from "../../scripts/build/fixTlsClientNodeBinary.mjs"; + +function makeRoot() { + return mkdtempSync(join(tmpdir(), "fix-tls-client-node-binary-security-")); +} + +test("strict mode rejects a symlinked node_modules ancestor before recovery writes outside root", async () => { + const rootDir = makeRoot(); + const outsideDir = makeRoot(); + try { + const bytes = "verified-native-must-stay-inside-root"; + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const outsideNodeModules = join(outsideDir, "node_modules"); + const tlsClientDir = join(outsideNodeModules, "tls-client-node"); + const rootBin = join(tlsClientDir, "bin"); + const scriptsDir = join(tlsClientDir, "scripts"); + const outsideBinary = join(rootBin, asset.file); + const marker = join(tlsClientDir, ".postinstall-ran"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + symlinkSync(outsideNodeModules, join(rootDir, "node_modules"), "dir"); + writeFileSync( + join(scriptsDir, "postinstall.js"), + `require("fs").writeFileSync(${JSON.stringify(marker)}, "ran"); + require("fs").writeFileSync(${JSON.stringify(outsideBinary)}, ${JSON.stringify(bytes)});` + ); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /unsafe.*(ancestor|outside)|symlink/i + ); + assert.equal(existsSync(marker), false); + assert.equal(existsSync(outsideBinary), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } +}); + +test("strict mode revalidates root ancestors after fetch before writing recovered bytes", async () => { + const rootDir = makeRoot(); + const outsideDir = makeRoot(); + try { + const bytes = Buffer.from("verified-native-after-ancestor-race"); + const asset = { + file: "tls-client-race-test.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const rootNodeModules = join(rootDir, "node_modules"); + const originalNodeModules = join(rootDir, "node_modules-before-race"); + const outsideNodeModules = join(outsideDir, "node_modules"); + const outsideBin = join(outsideNodeModules, "tls-client-node", "bin"); + const outsideBinary = join(outsideBin, asset.file); + mkdirSync(join(rootNodeModules, "tls-client-node", "bin"), { recursive: true }); + mkdirSync(outsideBin, { recursive: true }); + + const targetArch = process.arch === "arm64" ? "x64" : "arm64"; + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + platform: process.platform, + arches: [targetArch], + fetchImpl: async () => { + renameSync(rootNodeModules, originalNodeModules); + symlinkSync(outsideNodeModules, rootNodeModules, "dir"); + return new Response(bytes, { status: 200 }); + }, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /unsafe.*(ancestor|outside)|symlink/i + ); + + assert.equal(existsSync(outsideBinary), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } +}); + +test("strict mode revalidates root ancestors before recovery can remove an external file", async () => { + const rootDir = makeRoot(); + const outsideDir = makeRoot(); + try { + const bytes = Buffer.from("verified-native-after-pre-recovery-check"); + const asset = { + file: "tls-client-pre-recovery-race-test.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const rootNodeModules = join(rootDir, "node_modules"); + const originalNodeModules = join(rootDir, "node_modules-before-pre-recovery-race"); + const rootBin = join(rootNodeModules, "tls-client-node", "bin"); + const outsideNodeModules = join(outsideDir, "node_modules"); + const outsideBin = join(outsideNodeModules, "tls-client-node", "bin"); + const outsideBinary = join(outsideBin, asset.file); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(outsideBin, { recursive: true }); + writeFileSync(join(rootBin, asset.file), "tampered-root-binary"); + writeFileSync(outsideBinary, "external-file-must-not-be-removed"); + + let swappedAncestor = false; + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + fetchImpl: async () => new Response(bytes, { status: 200 }), + strict: true, + retryDelaysMs: [], + log(message) { + if (!swappedAncestor && message.includes("missing or unverified")) { + renameSync(rootNodeModules, originalNodeModules); + symlinkSync(outsideNodeModules, rootNodeModules, "dir"); + swappedAncestor = true; + } + }, + }), + /unsafe.*(ancestor|outside)|symlink/i + ); + assert.equal(swappedAncestor, true); + assert.equal(readFileSync(outsideBinary, "utf8"), "external-file-must-not-be-removed"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } +}); + +test("strict mode revalidates root ancestors after recovery before post-verification", async () => { + const rootDir = makeRoot(); + const outsideDir = makeRoot(); + try { + const bytes = Buffer.from("verified-native-before-post-recovery-race"); + const asset = { + file: "tls-client-post-recovery-race-test.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const rootNodeModules = join(rootDir, "node_modules"); + const originalNodeModules = join(rootDir, "node_modules-before-post-recovery-race"); + const outsideNodeModules = join(outsideDir, "node_modules"); + const outsideBin = join(outsideNodeModules, "tls-client-node", "bin"); + mkdirSync(join(rootNodeModules, "tls-client-node", "bin"), { recursive: true }); + mkdirSync(outsideBin, { recursive: true }); + writeFileSync(join(outsideBin, asset.file), bytes); + + const targetArch = process.arch === "arm64" ? "x64" : "arm64"; + let swappedAncestor = false; + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + platform: process.platform, + arches: [targetArch], + fetchImpl: async () => new Response(bytes, { status: 200 }), + strict: true, + retryDelaysMs: [], + log(message) { + if (message.includes("fetched successfully")) { + renameSync(rootNodeModules, originalNodeModules); + symlinkSync(outsideNodeModules, rootNodeModules, "dir"); + swappedAncestor = true; + } + }, + }), + /unsafe.*(ancestor|outside)|symlink/i + ); + assert.equal(swappedAncestor, true); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } +}); + +test("strict mode revalidates dist ancestors before copying verified bytes", async () => { + const rootDir = makeRoot(); + const outsideDir = makeRoot(); + try { + const bytes = Buffer.from("verified-native-before-dist-race"); + const asset = { + file: "tls-client-dist-race-test.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const rootDist = join(rootDir, "dist"); + const originalDist = join(rootDir, "dist-before-race"); + const outsideDist = join(outsideDir, "dist-target"); + const outsideTlsClientDir = join(outsideDist, "node_modules", "tls-client-node"); + const outsideBinary = join(outsideTlsClientDir, "bin", asset.file); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(join(rootDist, "node_modules", "tls-client-node"), { recursive: true }); + mkdirSync(outsideTlsClientDir, { recursive: true }); + writeFileSync(join(rootBin, asset.file), bytes); + + let swappedAncestor = false; + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + strict: true, + retryDelaysMs: [], + afterSourceStat() { + if (!swappedAncestor) { + renameSync(rootDist, originalDist); + symlinkSync(outsideDist, rootDist, "dir"); + swappedAncestor = true; + } + }, + log() {}, + }), + /unsafe.*(ancestor|outside)|symlink/i + ); + assert.equal(swappedAncestor, true); + assert.equal(existsSync(outsideBinary), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } +}); + +test("strict mode rejects unmanifested native siblings in every packaged bin root", async () => { + const locations: Array<{ + label: string; + binDir: (rootDir: string, standaloneDir: string) => string; + nextDistDir?: string | null; + }> = [ + { + label: "root node_modules", + binDir: (rootDir: string, _standaloneDir: string) => + join(rootDir, "node_modules", "tls-client-node", "bin"), + }, + { + label: "dist node_modules", + binDir: (rootDir: string, _standaloneDir: string) => + join(rootDir, "dist", "node_modules", "tls-client-node", "bin"), + }, + { + label: "standalone node_modules", + binDir: (_rootDir: string, standaloneDir: string) => + join(standaloneDir, "node_modules", "tls-client-node", "bin"), + }, + { + label: "projects OmniRoute nested standalone node_modules", + binDir: (_rootDir: string, standaloneDir: string) => + join(standaloneDir, "projects", "OmniRoute", "node_modules", "tls-client-node", "bin"), + }, + { + label: "root-basename nested standalone node_modules", + binDir: (rootDir: string, standaloneDir: string) => + join(standaloneDir, basename(rootDir), "node_modules", "tls-client-node", "bin"), + }, + { + label: "standalone runtime assets", + binDir: (_rootDir: string, standaloneDir: string) => + join(standaloneDir, "runtime-assets", "tls-client", "bin"), + }, + { + label: "isolated next build node_modules", + nextDistDir: null, + binDir: (rootDir: string, _standaloneDir: string) => + join(rootDir, ".build", "next", "node_modules", "tls-client-node", "bin"), + }, + { + label: "configured isolated next build node_modules", + nextDistDir: ".custom-next", + binDir: (rootDir: string, _standaloneDir: string) => + join(rootDir, ".custom-next", "node_modules", "tls-client-node", "bin"), + }, + { + label: "nested standalone next build node_modules", + nextDistDir: null, + binDir: (_rootDir: string, standaloneDir: string) => + join(standaloneDir, ".build", "next", "node_modules", "tls-client-node", "bin"), + }, + { + label: "configured nested standalone next build node_modules", + nextDistDir: ".custom-next", + binDir: (_rootDir: string, standaloneDir: string) => + join(standaloneDir, ".custom-next", "node_modules", "tls-client-node", "bin"), + }, + ]; + + for (const location of locations) { + const rootDir = makeRoot(); + const hadNextDistDir = Object.prototype.hasOwnProperty.call(process.env, "NEXT_DIST_DIR"); + const previousNextDistDir = process.env.NEXT_DIST_DIR; + try { + if (location.nextDistDir === null) delete process.env.NEXT_DIST_DIR; + else if (location.nextDistDir) process.env.NEXT_DIST_DIR = location.nextDistDir; + const binary = Buffer.from(`verified-target-for-${location.label}`); + const asset = { + file: "tls-client-target-test.so", + sha256: createHash("sha256").update(binary).digest("hex"), + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const standaloneDir = join(rootDir, ".build", "next", "standalone"); + mkdirSync(rootBin, { recursive: true }); + mkdirSync(standaloneDir, { recursive: true }); + writeFileSync(join(rootBin, asset.file), binary); + const candidateBin = location.binDir(rootDir, standaloneDir); + mkdirSync(candidateBin, { recursive: true }); + writeFileSync(join(candidateBin, "unlisted-native.so"), "must-not-ship"); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + asset, + standaloneDir, + requireStandalone: true, + strict: true, + retryDelaysMs: [], + fetchImpl: async () => { + throw new Error("verified root target must not trigger recovery"); + }, + log() {}, + }), + /unlisted|not.*manifest|unexpected native sibling/i, + location.label + ); + } finally { + if (hadNextDistDir) process.env.NEXT_DIST_DIR = previousNextDistDir; + else delete process.env.NEXT_DIST_DIR; + rmSync(rootDir, { recursive: true, force: true }); + } + } +}); + +test("strict mode rejects a manifest-known non-target sibling with an invalid digest", async () => { + const rootDir = makeRoot(); + try { + const targetBytes = Buffer.from("verified-linux-x64-target"); + const siblingBytes = Buffer.from("verified-linux-arm64-sibling"); + const targetAsset = { + file: "tls-client-linux-x64-test.so", + sha256: createHash("sha256").update(targetBytes).digest("hex"), + }; + const siblingAsset = { + file: "tls-client-linux-arm64-test.so", + sha256: createHash("sha256").update(siblingBytes).digest("hex"), + }; + const nativeAssets = { + "linux-x64": targetAsset, + "linux-arm64": siblingAsset, + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + mkdirSync(rootBin, { recursive: true }); + writeFileSync(join(rootBin, targetAsset.file), targetBytes); + writeFileSync(join(rootBin, siblingAsset.file), "tampered-known-sibling"); + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + platform: "linux", + arches: ["x64"], + nativeAssets, + strict: true, + retryDelaysMs: [], + fetchImpl: async () => { + throw new Error("verified target must not trigger recovery"); + }, + log() {}, + }), + /unverified|sha-?256|digest/i + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict mode allows a verified subset of manifest-known native siblings", async () => { + const rootDir = makeRoot(); + try { + const targetBytes = Buffer.from("verified-subset-linux-x64"); + const siblingBytes = Buffer.from("verified-subset-linux-arm64"); + const targetAsset = { + file: "tls-client-subset-linux-x64.so", + sha256: createHash("sha256").update(targetBytes).digest("hex"), + }; + const siblingAsset = { + file: "tls-client-subset-linux-arm64.so", + sha256: createHash("sha256").update(siblingBytes).digest("hex"), + }; + const nativeAssets = { + "linux-x64": targetAsset, + "linux-arm64": siblingAsset, + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const targetPath = join(rootBin, targetAsset.file); + const siblingPath = join(rootBin, siblingAsset.file); + mkdirSync(rootBin, { recursive: true }); + writeFileSync(targetPath, targetBytes); + writeFileSync(siblingPath, siblingBytes); + + let fetchCalls = 0; + await fixTlsClientNodeBinary({ + rootDir, + platform: "linux", + arches: ["x64"], + nativeAssets, + strict: true, + retryDelaysMs: [], + fetchImpl: async () => { + fetchCalls += 1; + throw new Error("verified subset must not trigger recovery"); + }, + log() {}, + }); + + assert.equal(fetchCalls, 0); + assert.deepEqual(readFileSync(targetPath), targetBytes); + assert.deepEqual(readFileSync(siblingPath), siblingBytes); + if (process.platform !== "win32") { + assert.equal(lstatSync(targetPath).mode & 0o777, 0o555); + assert.equal(lstatSync(siblingPath).mode & 0o777, 0o555); + } + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("strict mode rejects manifest-known siblings that are symlinks or non-files", async () => { + for (const variant of ["symlink", "directory"] as const) { + const rootDir = makeRoot(); + try { + const targetBytes = Buffer.from(`verified-target-for-${variant}`); + const siblingBytes = Buffer.from(`verified-sibling-for-${variant}`); + const targetAsset = { + file: `tls-client-target-${variant}.so`, + sha256: createHash("sha256").update(targetBytes).digest("hex"), + }; + const siblingAsset = { + file: `tls-client-sibling-${variant}.so`, + sha256: createHash("sha256").update(siblingBytes).digest("hex"), + }; + const nativeAssets = { + "linux-x64": targetAsset, + "linux-arm64": siblingAsset, + }; + const rootBin = join(rootDir, "node_modules", "tls-client-node", "bin"); + const siblingPath = join(rootBin, siblingAsset.file); + mkdirSync(rootBin, { recursive: true }); + writeFileSync(join(rootBin, targetAsset.file), targetBytes); + if (variant === "symlink") { + const outsideFile = join(rootDir, "outside-native.so"); + writeFileSync(outsideFile, siblingBytes); + symlinkSync(outsideFile, siblingPath); + } else { + mkdirSync(siblingPath); + } + + await assert.rejects( + fixTlsClientNodeBinary({ + rootDir, + platform: "linux", + arches: ["x64"], + nativeAssets, + strict: true, + retryDelaysMs: [], + log() {}, + }), + /unsafe.*sibling|symlink|non-regular file/i, + variant + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + } +}); diff --git a/tests/unit/grok-web.test.ts b/tests/unit/grok-web.test.ts index e87d9c74a5a..58ea3041db6 100644 --- a/tests/unit/grok-web.test.ts +++ b/tests/unit/grok-web.test.ts @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/grokTlsClient.ts"; +import { + __setTlsFetchOverrideForTesting, + TlsClientUnavailableError, +} from "../../open-sse/services/grokTlsClient.ts"; const { GrokWebExecutor } = await import("../../open-sse/executors/grok-web.ts"); const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); @@ -40,6 +43,42 @@ function mockFetch(status: number, events: unknown[]) { }; } +function mockFetchError(error: unknown) { + __setTlsFetchOverrideForTesting(async () => { + throw error; + }); + return () => { + __setTlsFetchOverrideForTesting(null); + }; +} + +function mockFetchLateStreamError(error: unknown) { + __setTlsFetchOverrideForTesting(async () => { + const encoder = new TextEncoder(); + let sentPartial = false; + return { + status: 200, + headers: new Headers({ "Content-Type": "application/x-ndjson" }), + text: null, + body: new ReadableStream({ + pull(controller) { + if (!sentPartial) { + sentPartial = true; + controller.enqueue( + encoder.encode(`${JSON.stringify({ result: { response: { token: "partial" } } })}\n`) + ); + return; + } + controller.error(error); + }, + }), + }; + }); + return () => { + __setTlsFetchOverrideForTesting(null); + }; +} + function mockFetchCapture(events: unknown[]) { let capturedUrl: string | null = null; let capturedHeaders: Record = {}; @@ -77,6 +116,11 @@ const SIMPLE_RESPONSE = [ { result: { response: { modelResponse: { message: "Hello world!", responseId: "resp-123" } } } }, ]; +const SENSITIVE_GROK_UPSTREAM_ERROR = + "Grok event failed at /srv/private/grok-secret.ts:41:9; " + + "access_token=grok-upstream-secret\n" + + " at SecretGrokFrame (/srv/private/grok-stack.ts:3:4)"; + test.afterEach(() => { __setTlsFetchOverrideForTesting(null); }); @@ -2223,6 +2267,275 @@ test("Error: Grok stream error returns 502", async () => { } }); +test("Error: non-streaming sanitizes upstream event errors before JSON output", async () => { + const restore = mockFetch(200, [ + { error: { message: SENSITIVE_GROK_UPSTREAM_ERROR, code: "500" } }, + ]); + try { + const executor = new GrokWebExecutor(); + const result = await executor.execute({ + model: "grok-4", + body: { messages: [{ role: "user", content: "test" }], stream: false }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 502); + const payloadText = await result.response.text(); + const json = JSON.parse(payloadText); + assert.equal(json.error?.type, "upstream_error"); + assert.equal(json.error?.code, "GROK_ERROR"); + assert.match(String(json.error?.message || ""), /Grok event failed/); + assert.doesNotMatch(payloadText, /\/srv\/private\/grok-(?:secret|stack)\.ts/); + assert.doesNotMatch(payloadText, /grok-upstream-secret|SecretGrokFrame/); + } finally { + restore(); + } +}); + +test("Error: streaming sanitizes upstream event errors before SSE output", async () => { + const restore = mockFetch(200, [ + { error: { message: SENSITIVE_GROK_UPSTREAM_ERROR, code: "500" } }, + ]); + try { + const executor = new GrokWebExecutor(); + const result = await executor.execute({ + model: "grok-4", + body: { messages: [{ role: "user", content: "test" }], stream: true }, + stream: true, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + const text = await result.response.text(); + assert.match(text, /\[Error:/); + assert.match(text, /"finish_reason":"stop"/); + assert.match(text, /data: \[DONE\]/); + assert.doesNotMatch(text, /\/srv\/private\/grok-(?:secret|stack)\.ts/); + assert.doesNotMatch(text, /grok-upstream-secret|SecretGrokFrame/); + } finally { + restore(); + } +}); + +test("Error: blank upstream event errors use a stable public fallback", async (t) => { + for (const stream of [false, true]) { + await t.test(stream ? "streaming SSE" : "non-streaming JSON", async () => { + const restore = mockFetch(200, [ + { + error: { + message: "\n at SecretOnlyFrame (/srv/private/grok-stack-only.ts:3:4)", + code: "500", + }, + }, + ]); + try { + const executor = new GrokWebExecutor(); + const result = await executor.execute({ + model: "grok-4", + body: { messages: [{ role: "user", content: "test" }], stream }, + stream, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + if (stream) { + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.match(text, /\[Error: Grok upstream error\]/); + assert.match(text, /data: \[DONE\]/); + assert.doesNotMatch(text, /SecretOnlyFrame|grok-stack-only/); + } else { + assert.equal(result.response.status, 502); + const json = JSON.parse(await result.response.text()); + assert.equal(json.error?.message, "Grok upstream error"); + assert.equal(json.error?.type, "upstream_error"); + assert.equal(json.error?.code, "GROK_ERROR"); + } + } finally { + restore(); + } + }); + } +}); + +test("Error: late reader failures are sanitized before the SSE stream-error delta", async (t) => { + const cases = [ + { + name: "Error rejection", + error: new Error(SENSITIVE_GROK_UPSTREAM_ERROR), + forbidden: ["/srv/private/grok-secret.ts", "grok-upstream-secret", "SecretGrokFrame"], + }, + { + name: "non-Error rejection", + error: SENSITIVE_GROK_UPSTREAM_ERROR, + forbidden: ["/srv/private/grok-secret.ts", "grok-upstream-secret", "SecretGrokFrame"], + }, + { + name: "stack-only rejection", + error: "\n at SecretOnlyFrame (/srv/private/grok-late-stack-only.ts:2:3)", + forbidden: ["/srv/private/grok-late-stack-only.ts", "SecretOnlyFrame"], + expectedContent: "[Stream error: Grok upstream error]", + }, + { + name: "hostile toString rejection", + error: { + toString() { + throw new Error("access_token=grok-hostile-secret at /srv/private/grok-hostile.ts:1:1"); + }, + }, + forbidden: ["grok-hostile-secret", "grok-hostile"], + expectedContent: "[Stream error: Grok upstream error]", + }, + ]; + + for (const testCase of cases) { + await t.test(testCase.name, async () => { + const restore = mockFetchLateStreamError(testCase.error); + try { + const executor = new GrokWebExecutor(); + const result = await executor.execute({ + model: "grok-4", + body: { messages: [{ role: "user", content: "test" }], stream: true }, + stream: true, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 200); + const text = await result.response.text(); + const dataLines = text.split("\n").filter((line) => line.startsWith("data: ")); + const payloads = dataLines + .filter((line) => line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6))); + const errorChunk = payloads.find((payload) => + String(payload.choices?.[0]?.delta?.content || "").startsWith("[Stream error:") + ); + + assert.ok(errorChunk, "must emit a structured stream-error delta"); + assert.equal(errorChunk.choices[0].finish_reason, "stop"); + if (testCase.expectedContent) { + assert.equal(errorChunk.choices[0].delta.content, testCase.expectedContent); + } + assert.equal(dataLines.at(-1), "data: [DONE]"); + for (const leakedValue of testCase.forbidden) { + assert.ok(!text.includes(leakedValue), `must not expose ${leakedValue}`); + } + } finally { + restore(); + } + }); + } +}); + +test("Error: fetch failures sanitize sensitive details in logs and JSON", async (t) => { + const cases = [ + { + name: "generic fetch rejection", + error: new Proxy(new Error(SENSITIVE_GROK_UPSTREAM_ERROR), { + getPrototypeOf() { + throw new Error("grok-prototype-secret"); + }, + }), + expectedMessage: /Grok connection failed/, + expectedCode: undefined, + }, + { + name: "TLS client unavailable", + error: new TlsClientUnavailableError(SENSITIVE_GROK_UPSTREAM_ERROR), + expectedMessage: /Grok TLS client unavailable/, + expectedCode: "TLS_CLIENT_UNAVAILABLE", + }, + ]; + + for (const testCase of cases) { + await t.test(testCase.name, async () => { + const errorLogs: string[] = []; + const restore = mockFetchError(testCase.error); + try { + const executor = new GrokWebExecutor(); + const result = await executor.execute({ + model: "grok-4", + body: { messages: [{ role: "user", content: "test" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10000), + log: { + error: (_tag, message) => errorLogs.push(String(message)), + }, + }); + + assert.equal(result.response.status, 502); + assert.equal(errorLogs.length, 1); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.equal(json.error?.type, "upstream_error"); + assert.match(String(json.error?.message || ""), testCase.expectedMessage); + assert.equal(json.error?.code, testCase.expectedCode); + const publicOutput = `${errorLogs.join("\n")}\n${responseText}`; + assert.doesNotMatch(publicOutput, /\/srv\/private\/grok-(?:secret|stack)\.ts/); + assert.doesNotMatch(publicOutput, /grok-(?:upstream|prototype)-secret|SecretGrokFrame/); + assert.doesNotMatch(responseText, /"(?:stack|cause)"\s*:/i); + } finally { + restore(); + } + }); + } +}); + +test("Error: blank fetch failures use a stable fallback in logs and JSON", async (t) => { + const cases = [ + { + name: "generic fetch rejection", + error: new Error("\n at SecretOnlyFrame (/srv/private/grok-fetch-stack-only.ts:2:3)"), + expectedLog: "Fetch failed: Grok upstream error", + expectedMessage: "Grok connection failed: Grok upstream error", + }, + { + name: "TLS client unavailable", + error: new TlsClientUnavailableError( + "\n at SecretOnlyFrame (/srv/private/grok-tls-stack-only.ts:2:3)" + ), + expectedLog: "TLS client unavailable: Grok upstream error", + expectedMessage: "Grok TLS client unavailable: Grok upstream error", + }, + ]; + + for (const testCase of cases) { + await t.test(testCase.name, async () => { + const errorLogs: string[] = []; + const restore = mockFetchError(testCase.error); + try { + const executor = new GrokWebExecutor(); + const result = await executor.execute({ + model: "grok-4", + body: { messages: [{ role: "user", content: "test" }] }, + stream: false, + credentials: { apiKey: "test" }, + signal: AbortSignal.timeout(10000), + log: { + error: (_tag, message) => errorLogs.push(String(message)), + }, + }); + + assert.equal(result.response.status, 502); + assert.deepEqual(errorLogs, [testCase.expectedLog]); + const json = JSON.parse(await result.response.text()); + assert.equal(json.error?.message, testCase.expectedMessage); + } finally { + restore(); + } + }); + } +}); + // ─── Auth headers ─────────────────────────────────────────────────────────── test("Auth: cookie sends sso= header", async () => { diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index e778e8821b0..dfbe01ae5af 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -20,8 +20,14 @@ import { parseLMArenaInitialModels, pickLMArenaModelId, } from "../../open-sse/executors/lmarena.ts"; -import { clearLMArenaDeadCatalogModels } from "../../open-sse/executors/lmarena/models.ts"; -import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/lmarenaTlsClient.ts"; +import { + clearLMArenaDeadCatalogModels, + resolveLMArenaModelId, +} from "../../open-sse/executors/lmarena/models.ts"; +import { + __setTlsFetchOverrideForTesting, + TlsClientUnavailableError, +} from "../../open-sse/services/lmarenaTlsClient.ts"; const TEST_ARENA_MODEL_ID = "019e080d-c29d-7d9a-aa54-faed41da0763"; const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -30,7 +36,11 @@ const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9 type LMArenaExecutorTestAccess = { provider: string; buildUrl: (model: string, credentials: unknown) => string; - buildRequestHeaders: (model: string, credentials: unknown, body: unknown) => Record; + buildRequestHeaders: ( + model: string, + credentials: unknown, + body: unknown + ) => Record; transformRequest: ( body: unknown, model: string, @@ -159,7 +169,11 @@ describe("LMArena Executor", () => { assert.equal(headers.Cookie, "session=def"); // providerSpecificData.cookie - headers = ex.buildRequestHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {}); + headers = ex.buildRequestHeaders( + "gpt-4", + { providerSpecificData: { cookie: "session=ghi" } }, + {} + ); assert.equal(headers.Cookie, "session=ghi"); // Priority: direct > apiKey > providerSpecificData @@ -474,6 +488,44 @@ describe("LMArena Executor", () => { assert.equal(pickLMArenaModelId(TEST_ARENA_MODEL_ID, []), TEST_ARENA_MODEL_ID); }); + it("sanitizes static catalog lookup failures before warning", async () => { + const warnings: string[] = []; + const resolved = await resolveLMArenaModelId("unknown-model-for-log-test", { + debug: () => { + throw new Error( + "Catalog lookup failed at /srv/private/lmarena-catalog.ts:17:5; " + + "access_token=lmarena-catalog-secret\n" + + " at SecretCatalogFrame (/srv/private/lmarena-catalog-stack.ts:2:3)" + ); + }, + warn: (_scope, message) => warnings.push(String(message)), + }); + + assert.equal(resolved, "unknown-model-for-log-test"); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /Using raw model id after static catalog lookup failed/); + assert.match(warnings[0], /Catalog lookup failed/); + assert.doesNotMatch(warnings[0], /\/srv\/private\/lmarena-catalog(?:-stack)?\.ts/); + assert.doesNotMatch(warnings[0], /lmarena-catalog-secret|SecretCatalogFrame/); + }); + + it("uses a stable fallback when the catalog failure sanitizes to blank", async () => { + const warnings: string[] = []; + const resolved = await resolveLMArenaModelId("unknown-model-for-blank-log-test", { + debug: () => { + throw new Error( + "\n at SecretOnlyFrame (/srv/private/lmarena-catalog-stack-only.ts:2:3)" + ); + }, + warn: (_scope, message) => warnings.push(String(message)), + }); + + assert.equal(resolved, "unknown-model-for-blank-log-test"); + assert.deepEqual(warnings, [ + "Using raw model id after static catalog lookup failed: Arena catalog lookup error", + ]); + }); + it("resolves catalog public names via static Direct-chat allowlist (no arena.ai fetch)", async () => { const executor = new LMArenaExecutor(); let arenaHomeFetches = 0; @@ -595,6 +647,237 @@ describe("LMArena Executor", () => { } }); + it("sanitizes network failure details before logging or responding", async () => { + const errorLogs: string[] = []; + __setTlsFetchOverrideForTesting(async () => { + throw new Error( + "Arena request failed at /srv/private/lmarena-request.ts:17:5; " + + "access_token=lmarena-network-secret\n" + + " at SecretArenaFrame (/srv/private/lmarena-stack.ts:2:3)" + ); + }); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: { error: (_scope, message) => errorLogs.push(String(message)) }, + }); + + assert.equal(result.response.status, 502); + assert.equal(errorLogs.length, 1); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.equal(json.error?.type, "network_error"); + assert.equal(json.error?.code, "request_failed"); + const publicOutput = `${errorLogs.join("\n")}\n${responseText}`; + assert.match(publicOutput, /Arena request failed/); + assert.doesNotMatch(publicOutput, /\/srv\/private\/lmarena-(?:request|stack)\.ts/); + assert.doesNotMatch(publicOutput, /lmarena-network-secret|SecretArenaFrame/); + assert.doesNotMatch(responseText, /"(?:stack|cause)"\s*:/i); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("fails closed when a network rejection refuses string coercion", async () => { + const errorLogs: string[] = []; + __setTlsFetchOverrideForTesting(async () => { + throw { + toString() { + throw new Error("access_token=hostile-secret at /srv/private/lmarena.ts:1:2"); + }, + }; + }); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: { error: (_scope, message) => errorLogs.push(String(message)) }, + }); + + assert.equal(result.response.status, 502); + assert.deepEqual(errorLogs, ["Request failed: Arena upstream error"]); + const json = await result.response.json(); + assert.deepEqual(json.error, { + message: "Arena upstream error", + type: "network_error", + code: "request_failed", + }); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("fails closed when network rejection prototype inspection throws", async () => { + const hostileFailure = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("access_token=prototype-secret at /srv/private/prototype.ts:1:2"); + }, + get(_target, property) { + if (property === "toString") { + return () => { + throw new Error("access_token=coercion-secret at /srv/private/coercion.ts:1:2"); + }; + } + return undefined; + }, + } + ); + const errorLogs: string[] = []; + __setTlsFetchOverrideForTesting(async () => { + throw hostileFailure; + }); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: { error: (_scope, message) => errorLogs.push(String(message)) }, + }); + + assert.equal(result.response.status, 502); + assert.deepEqual(errorLogs, ["Request failed: Arena upstream error"]); + const responseText = await result.response.text(); + assert.doesNotMatch(responseText, /prototype-secret|coercion-secret|\/srv\/private/); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("uses a stable fallback for blank TLS-unavailable errors", async () => { + const errorLogs: string[] = []; + __setTlsFetchOverrideForTesting(async () => { + throw new TlsClientUnavailableError( + "\n at SecretOnlyFrame (/srv/private/lmarena-tls-stack-only.ts:2:3)" + ); + }); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: { error: (_scope, message) => errorLogs.push(String(message)) }, + }); + + assert.equal(result.response.status, 502); + assert.deepEqual(errorLogs, ["TLS client unavailable: Arena upstream error"]); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.equal(json.error?.type, "upstream_error"); + assert.equal(json.error?.code, "TLS_CLIENT_UNAVAILABLE"); + assert.equal( + json.error?.message, + "Arena TLS impersonation unavailable: Arena upstream error. Install/repair tls-client-node native binary." + ); + assert.doesNotMatch(responseText, /SecretOnlyFrame|lmarena-tls-stack-only/); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("uses a stable public fallback for blank network and upstream event errors", async (t) => { + const stackOnly = "\n at SecretOnlyFrame (/srv/private/lmarena-stack-only.ts:2:3)"; + const cases = [ + { + name: "network rejection", + setup: () => + __setTlsFetchOverrideForTesting(async () => { + throw stackOnly; + }), + expectedType: "network_error", + expectedCode: "request_failed", + }, + { + name: "non-streaming upstream event", + setup: () => + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: `3:${JSON.stringify(stackOnly)}\n`, + body: null, + })), + expectedType: "api_error", + expectedCode: "lmarena_error", + }, + ]; + + for (const testCase of cases) { + await t.test(testCase.name, async () => { + testCase.setup(); + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.equal(json.error?.message, "Arena upstream error"); + assert.equal(json.error?.type, testCase.expectedType); + assert.equal(json.error?.code, testCase.expectedCode); + assert.doesNotMatch(responseText, /SecretOnlyFrame|lmarena-stack-only/); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + } + }); + + it("uses a stable public fallback for blank streaming event errors", async () => { + const stackOnly = "\n at SecretOnlyFrame (/srv/private/lmarena-stream-stack-only.ts:2:3)"; + const encoded = new TextEncoder().encode(`data: 3:${JSON.stringify(stackOnly)}\n\n`); + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.close(); + }, + }), + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }], stream: true }, + stream: true, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 200); + const responseText = await result.response.text(); + const payload = responseText + .split("\n") + .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6))) + .find((chunk) => chunk.error); + assert.equal(payload?.error?.message, "Arena upstream error"); + assert.doesNotMatch(responseText, /SecretOnlyFrame|lmarena-stream-stack-only/); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("forwards optional browser reCAPTCHA token from credentials", () => { const executor = new LMArenaExecutor(); const body = access(executor).transformRequest( diff --git a/tests/unit/lmarena-stream-readiness-repro-9306.test.ts b/tests/unit/lmarena-stream-readiness-repro-9306.test.ts index 5cf942d730e..45db50722bf 100644 --- a/tests/unit/lmarena-stream-readiness-repro-9306.test.ts +++ b/tests/unit/lmarena-stream-readiness-repro-9306.test.ts @@ -17,9 +17,7 @@ describe("Arena AI stream readiness (#9306)", () => { const upstreamReader = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('a0:{"text":"Hello"}\n')); - controller.enqueue( - new TextEncoder().encode('a0:{"text":", world!"}\nad:{}\n') - ); + controller.enqueue(new TextEncoder().encode('a0:{"text":", world!"}\nad:{}\n')); controller.close(); }, }).getReader(); @@ -91,4 +89,162 @@ describe("Arena AI stream readiness (#9306)", () => { assert.ok(chunk instanceof Uint8Array, "Each chunk must be Uint8Array, not string"); } }); + + it("cancels and unlocks the upstream reader after an early done event", async () => { + let upstreamCancelled = false; + const upstream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("ad:{}\n")); + }, + cancel() { + upstreamCancelled = true; + }, + }); + const upstreamReader = upstream.getReader(); + const stream = createOpenAIArenaStream({ reader: upstreamReader, model: "test-model" }); + + const responseText = await new Response(stream).text(); + + assert.match(responseText, /\[DONE\]/); + assert.equal(upstreamCancelled, true); + assert.equal(upstream.locked, false); + }); + + it("projects reader failures as sanitized stackless stream errors", async () => { + const upstreamError = new Error( + "Arena reader failed at /srv/private/lmarena-reader.ts:17:5; " + + "access_token=lmarena-reader-secret\n" + + " at SecretReaderFrame (/srv/private/lmarena-reader-stack.ts:2:3)" + ); + const upstreamReader = new ReadableStream({ + start(controller) { + controller.error(upstreamError); + }, + }).getReader(); + const errorLogs: string[] = []; + const stream = createOpenAIArenaStream({ + reader: upstreamReader, + model: "test-model", + log: { error: (_scope, message) => errorLogs.push(String(message)) }, + }); + + let projectedError: unknown; + try { + await stream.getReader().read(); + assert.fail("reader must reject when the Arena reader fails"); + } catch (error) { + projectedError = error; + } + + assert.ok(projectedError instanceof Error); + assert.equal(projectedError.stack, undefined, "public stream error must not carry a stack"); + assert.equal( + (projectedError as Error & { cause?: unknown }).cause, + undefined, + "public stream error must not carry the upstream cause" + ); + assert.equal(errorLogs.length, 1); + const publicOutput = `${errorLogs.join("\n")}\n${projectedError.message}`; + assert.match(publicOutput, /Arena reader failed/); + assert.doesNotMatch(publicOutput, /\/srv\/private\/lmarena-reader(?:-stack)?\.ts/); + assert.doesNotMatch(publicOutput, /lmarena-reader-secret|SecretReaderFrame/); + }); + + it("uses a stable fallback for stack-only reader failures", async () => { + const upstreamReader = new ReadableStream({ + start(controller) { + controller.error( + new Error("\n at SecretOnlyFrame (/srv/private/lmarena-reader-stack-only.ts:2:3)") + ); + }, + }).getReader(); + const errorLogs: string[] = []; + const stream = createOpenAIArenaStream({ + reader: upstreamReader, + model: "test-model", + log: { error: (_scope, message) => errorLogs.push(String(message)) }, + }); + + let projectedError: unknown; + try { + await stream.getReader().read(); + assert.fail("reader must reject when the Arena reader fails"); + } catch (error) { + projectedError = error; + } + + assert.ok(projectedError instanceof Error); + assert.equal(projectedError.message, "Arena upstream stream error"); + assert.equal(projectedError.stack, undefined); + assert.equal((projectedError as Error & { cause?: unknown }).cause, undefined); + assert.deepEqual(errorLogs, ["Streaming error: Arena upstream stream error"]); + }); + + it("fails closed when reader failure coercion and metadata access throw", async () => { + const hostileFailure = { + get name() { + throw new Error("access_token=name-secret at /srv/private/name.ts:1:2"); + }, + get statusCode() { + throw new Error("access_token=status-secret at /srv/private/status.ts:1:2"); + }, + toString() { + throw new Error("access_token=coercion-secret at /srv/private/coercion.ts:1:2"); + }, + }; + const upstreamReader = new ReadableStream({ + start(controller) { + controller.error(hostileFailure); + }, + }).getReader(); + const errorLogs: string[] = []; + const stream = createOpenAIArenaStream({ + reader: upstreamReader, + model: "test-model", + log: { error: (_scope, message) => errorLogs.push(String(message)) }, + }); + + let projectedError: unknown; + try { + await stream.getReader().read(); + assert.fail("reader must reject when the Arena reader fails"); + } catch (error) { + projectedError = error; + } + + assert.ok(projectedError instanceof Error); + assert.equal(projectedError.message, "Arena upstream stream error"); + assert.equal(projectedError.stack, undefined); + assert.deepEqual(errorLogs, ["Streaming error: Arena upstream stream error"]); + }); + + it("preserves safe disconnect classification without retaining upstream metadata", async () => { + const upstreamError = new Error( + "Abort failed at /srv/private/lmarena-abort.ts:17:5; " + "access_token=lmarena-abort-secret" + ) as Error & { cause?: unknown; statusCode?: number }; + upstreamError.name = "AbortError"; + upstreamError.cause = new Error("private abort cause"); + upstreamError.statusCode = 499; + const upstreamReader = new ReadableStream({ + start(controller) { + controller.error(upstreamError); + }, + }).getReader(); + const stream = createOpenAIArenaStream({ reader: upstreamReader, model: "test-model" }); + + let projectedError: unknown; + try { + await stream.getReader().read(); + assert.fail("reader must reject when the Arena reader aborts"); + } catch (error) { + projectedError = error; + } + + assert.ok(projectedError instanceof Error); + assert.equal(projectedError.name, "AbortError"); + assert.equal(projectedError.stack, undefined); + assert.equal((projectedError as Error & { cause?: unknown }).cause, undefined); + assert.equal((projectedError as Error & { statusCode?: number }).statusCode, 499); + assert.doesNotMatch(projectedError.message, /lmarena-abort-secret|\/srv\/private/); + }); }); diff --git a/tests/unit/moderations-handler.test.ts b/tests/unit/moderations-handler.test.ts index 68ec32847a4..5330576c95b 100644 --- a/tests/unit/moderations-handler.test.ts +++ b/tests/unit/moderations-handler.test.ts @@ -2,9 +2,8 @@ import test from "node:test"; import assert from "node:assert/strict"; const { handleModeration } = await import("../../open-sse/handlers/moderations.ts"); -const { MODERATION_PROVIDERS, getModerationProvider, parseModerationModel } = await import( - "../../open-sse/config/moderationRegistry.ts" -); +const { MODERATION_PROVIDERS, getModerationProvider, parseModerationModel } = + await import("../../open-sse/config/moderationRegistry.ts"); const originalFetch = globalThis.fetch; @@ -136,6 +135,112 @@ test("handleModeration returns upstream error payloads with CORS headers", async assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/); }); +test("handleModeration removes Unicode-escaped credential fields while preserving safe fields", async () => { + const upstreamBody = String.raw`{"\u0061pi_key":"credential-value-12345","message":"quota busy"}`; + globalThis.fetch = async () => + new Response(upstreamBody, { + status: 429, + headers: { "content-type": "application/json" }, + }); + + const response = await handleModeration({ + body: { model: "openai/text-moderation-latest", input: "check this" }, + credentials: { apiKey: "sk-test" }, + }); + const text = await response.text(); + const payload = JSON.parse(text) as { api_key?: string; message: string }; + + assert.equal(response.status, 429); + assert.equal(response.headers.get("content-type"), "application/json"); + assert.equal(payload.api_key, undefined); + assert.equal(payload.message, "quota busy"); + assert.doesNotMatch(text, /credential-value-12345|\\u0061pi_key/i); +}); + +test("handleModeration preserves valid pretty-printed JSON while sanitizing its fields", async () => { + const upstreamBody = JSON.stringify( + { + error: { + message: "quota metadata at /srv/provider/moderations.ts:12:3", + api_key: "credential-value-12345", + }, + }, + null, + 2 + ); + globalThis.fetch = async () => + new Response(upstreamBody, { + status: 429, + headers: { "content-type": "application/json" }, + }); + + const response = await handleModeration({ + body: { model: "openai/text-moderation-latest", input: "check this" }, + credentials: { apiKey: "sk-test" }, + }); + const text = await response.text(); + const payload = JSON.parse(text) as { + error: { message: string; api_key?: string }; + }; + + assert.equal(response.status, 429); + assert.equal(payload.error.api_key, undefined); + assert.equal(payload.error.message, "quota metadata at "); + assert.doesNotMatch(text, /credential-value-12345|\/srv\/provider/i); +}); + +test("handleModeration removes paths and stacks and falls back for stack-only or blank errors", async () => { + const cases = [ + { + name: "POSIX and Windows paths", + upstreamBody: String.raw`failed reading /home/service/private/moderations.ts and C:\Users\alice\private\moderations.ts; retry later`, + expectedBody: /^failed reading $/i, + }, + { + name: "physical stack", + upstreamBody: + "Moderation upstream busy\n at handler (/home/service/private/moderations.ts:12:3)", + expectedBody: /^Moderation upstream busy$/, + }, + { + name: "serialized stack", + upstreamBody: String.raw`Moderation upstream busy\n at handler (C:\Users\alice\private\moderations.ts:12:3)`, + expectedBody: /^Moderation upstream busy$/, + }, + { + name: "stack only", + upstreamBody: " at handler (/home/service/private/moderations.ts:12:3)", + expectedBody: /^Moderation provider returned HTTP 503$/, + }, + { + name: "whitespace only", + upstreamBody: " \n\t ", + expectedBody: /^Moderation provider returned HTTP 503$/, + }, + ]; + + for (const fixture of cases) { + globalThis.fetch = async () => + new Response(fixture.upstreamBody, { + status: 503, + headers: { "content-type": "application/json" }, + }); + + const response = await handleModeration({ + body: { model: "openai/text-moderation-latest", input: "check this" }, + credentials: { apiKey: "sk-test" }, + }); + const text = await response.text(); + const payload = JSON.parse(text) as { error: { message: string } }; + + assert.equal(response.status, 503, fixture.name); + assert.equal(response.headers.get("content-type"), "application/json", fixture.name); + assert.match(payload.error.message, fixture.expectedBody, fixture.name); + assert.doesNotMatch(text, /\/home\/service\/private|C:\\Users\\alice/i, fixture.name); + assert.doesNotMatch(text, /(?:^|\\n)\s*at\s/i, fixture.name); + } +}); + test("handleModeration returns a 500 when the upstream request throws", async () => { globalThis.fetch = async () => { throw new Error("socket closed"); @@ -150,3 +255,26 @@ test("handleModeration returns a 500 when the upstream request throws", async () assert.equal(response.status, 500); assert.match(payload.error.message, /Moderation request failed: socket closed/); }); + +test("handleModeration fails closed when a thrown value rejects string coercion", async () => { + globalThis.fetch = async () => { + throw { + get message() { + throw new Error("access_token=hostile-secret at /srv/private/moderation.ts:1:2"); + }, + toString() { + throw new Error("access_token=hostile-secret at /srv/private/moderation.ts:1:2"); + }, + }; + }; + + const response = await handleModeration({ + body: { model: "openai/text-moderation-latest", input: "check this" }, + credentials: { apiKey: "sk-test" }, + }); + const payload = (await response.json()) as { error: { message: string } }; + + assert.equal(response.status, 500); + assert.equal(payload.error.message, "Moderation request failed: unknown upstream failure"); + assert.doesNotMatch(payload.error.message, /hostile-secret|srv\/private/i); +}); diff --git a/tests/unit/ocr-handler-dispatch.test.ts b/tests/unit/ocr-handler-dispatch.test.ts index 2474f6b0b29..60845950802 100644 --- a/tests/unit/ocr-handler-dispatch.test.ts +++ b/tests/unit/ocr-handler-dispatch.test.ts @@ -38,6 +38,195 @@ test("mistral path posts once and returns the upstream body", async () => { assert.equal(data.pages[0].markdown, "ok"); }); +test("OCR removes Unicode-escaped credential fields while preserving safe fields", async () => { + const upstreamBody = String.raw`{"\u0061pi_key":"credential-value-12345","message":"quota busy"}`; + const res = await handleOcr({ + body: { + model: "mistral/mistral-ocr-latest", + document: { type: "image_url", image_url: "https://x/y.png" }, + }, + credentials: { apiKey: "sk" }, + fetchImpl: async () => + new Response(upstreamBody, { + status: 429, + headers: { "Content-Type": "application/json" }, + }), + sleepImpl: noSleep, + }); + const text = await res.text(); + const payload = JSON.parse(text) as { api_key?: string; message: string }; + + assert.equal(res.status, 429); + assert.equal(res.headers.get("content-type"), "application/json"); + assert.equal(payload.api_key, undefined); + assert.equal(payload.message, "quota busy"); + assert.doesNotMatch(text, /credential-value-12345|\\u0061pi_key/i); +}); + +test("OCR preserves valid pretty-printed JSON while sanitizing its fields", async () => { + const upstreamBody = JSON.stringify( + { + error: { + message: "quota metadata at /srv/provider/ocr.ts:12:3", + api_key: "credential-value-12345", + }, + }, + null, + 2 + ); + const res = await handleOcr({ + body: { + model: "mistral/mistral-ocr-latest", + document: { type: "image_url", image_url: "https://x/y.png" }, + }, + credentials: { apiKey: "sk" }, + fetchImpl: async () => + new Response(upstreamBody, { + status: 429, + headers: { "Content-Type": "application/json" }, + }), + sleepImpl: noSleep, + }); + const text = await res.text(); + const payload = JSON.parse(text) as { + error: { message: string; api_key?: string }; + }; + + assert.equal(res.status, 429); + assert.equal(payload.error.api_key, undefined); + assert.equal(payload.error.message, "quota metadata at "); + assert.doesNotMatch(text, /credential-value-12345|\/srv\/provider/i); +}); + +test("OCR removes paths and stacks and falls back for stack-only or blank errors", async () => { + const cases = [ + { + name: "POSIX and Windows paths", + upstreamBody: String.raw`failed reading /home/service/private/ocr.ts and C:\Users\alice\private\ocr.ts; retry later`, + expectedBody: /^failed reading $/i, + }, + { + name: "physical stack", + upstreamBody: "OCR upstream busy\n at handler (/home/service/private/ocr.ts:12:3)", + expectedBody: /^OCR upstream busy$/, + }, + { + name: "serialized stack", + upstreamBody: String.raw`OCR upstream busy\n at handler (C:\Users\alice\private\ocr.ts:12:3)`, + expectedBody: /^OCR upstream busy$/, + }, + { + name: "stack only", + upstreamBody: " at handler (/home/service/private/ocr.ts:12:3)", + expectedBody: /^OCR provider returned HTTP 503$/, + }, + { + name: "whitespace only", + upstreamBody: " \n\t ", + expectedBody: /^OCR provider returned HTTP 503$/, + }, + ]; + + for (const fixture of cases) { + const res = await handleOcr({ + body: { + model: "mistral/mistral-ocr-latest", + document: { type: "image_url", image_url: "https://x/y.png" }, + }, + credentials: { apiKey: "sk" }, + fetchImpl: async () => + new Response(fixture.upstreamBody, { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + sleepImpl: noSleep, + }); + const text = await res.text(); + const payload = JSON.parse(text) as { error: { message: string } }; + + assert.equal(res.status, 503, fixture.name); + assert.equal(res.headers.get("content-type"), "application/json", fixture.name); + assert.match(payload.error.message, fixture.expectedBody, fixture.name); + assert.doesNotMatch(text, /\/home\/service\/private|C:\\Users\\alice/i, fixture.name); + assert.doesNotMatch(text, /(?:^|\\n)\s*at\s/i, fixture.name); + } +}); + +test("OCR catch logs only a canonical-sanitized message and keeps the public 500 static", async () => { + const originalConsoleError = console.error; + const logged: unknown[][] = []; + console.error = (...args: unknown[]) => { + logged.push(args); + }; + const upstreamError = new Error( + String.raw`OCR transport failed; \u0061pi_key\u003dcredential-value-12345; path /home/service/private/ocr.ts` + ); + upstreamError.stack = String.raw`Error: credential-value-12345\n at handler (C:\Users\alice\private\ocr.ts:12:3)`; + + try { + const res = await handleOcr({ + body: { + model: "mistral/mistral-ocr-latest", + document: { type: "image_url", image_url: "https://x/y.png" }, + }, + credentials: { apiKey: "sk" }, + fetchImpl: async () => { + throw upstreamError; + }, + sleepImpl: noSleep, + }); + const body = await res.json(); + + assert.equal(res.status, 500); + assert.equal(body.error.message, "OCR request failed"); + assert.equal(logged.length, 1); + assert.equal(logged[0][0], "[OCR]"); + assert.equal(typeof logged[0][1], "string"); + const publicLog = logged.flat().join(" "); + assert.match(publicLog, /OCR transport failed/i); + assert.match(publicLog, /\[REDACTED\]/); + assert.doesNotMatch( + publicLog, + /credential-value-12345|\/home\/service\/private|C:\\Users\\alice|(?:^|\\n)\s*at\s/i + ); + } finally { + console.error = originalConsoleError; + } +}); + +test("OCR catch fails closed when a thrown value rejects string coercion", async () => { + const originalConsoleError = console.error; + const logged: unknown[][] = []; + console.error = (...args: unknown[]) => { + logged.push(args); + }; + + try { + const res = await handleOcr({ + body: { + model: "mistral/mistral-ocr-latest", + document: { type: "image_url", image_url: "https://x/y.png" }, + }, + credentials: { apiKey: "sk" }, + fetchImpl: async () => { + throw { + toString() { + throw new Error("access_token=hostile-secret at /srv/private/ocr.ts:1:2"); + }, + }; + }, + sleepImpl: noSleep, + }); + const body = await res.json(); + + assert.equal(res.status, 500); + assert.equal(body.error.message, "OCR request failed"); + assert.deepEqual(logged, [["[OCR]", "OCR request failed"]]); + } finally { + console.error = originalConsoleError; + } +}); + test("azure DI path polls Operation-Location until succeeded", async () => { const { impl, calls } = fetchStub([ { status: 202, headers: { "Operation-Location": "https://poll/op/1" } }, diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index 026b5782eba..8ca8403fc28 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -8,6 +8,7 @@ import { PACK_ARTIFACT_ALLOWED_EXACT_PATHS, PACK_ARTIFACT_ALLOWED_PATH_PREFIXES, PACK_ARTIFACT_REQUIRED_PATHS, + TLS_CLIENT_RUNTIME_SEED_PATHS, findMissingArtifactPaths, findUnexpectedArtifactPaths, normalizeArtifactPath, @@ -15,6 +16,15 @@ import { parseJsonValuesOutput, } from "../../scripts/build/pack-artifact-policy.ts"; +const EXPECTED_TLS_CLIENT_RUNTIME_SEED_PATHS = [ + "runtime-assets/tls-client/bin/tls-client-darwin-amd64-1.15.1.dylib", + "runtime-assets/tls-client/bin/tls-client-darwin-arm64-1.15.1.dylib", + "runtime-assets/tls-client/bin/tls-client-linux-arm64-1.15.1.so", + "runtime-assets/tls-client/bin/tls-client-linux-ubuntu-amd64-1.15.1.so", + "runtime-assets/tls-client/bin/tls-client-windows-32-1.15.1.dll", + "runtime-assets/tls-client/bin/tls-client-windows-64-1.15.1.dll", +] as const; + test("normalizeArtifactPath normalizes slashes and leading relative markers", () => { assert.equal( normalizeArtifactPath("./app\\scripts\\ad-hoc\\test.js"), @@ -186,6 +196,31 @@ test("build-next-isolated sibling imports are allowed in the published package", assert.deepEqual(unexpectedPaths, []); }); +test("assembleStandalone helper imports are published, allowed, and required", () => { + const packageFiles: string[] = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8") + ).files; + const helperPaths = [ + "scripts/build/standaloneSidecarCopy.mjs", + "scripts/build/tlsClientAssetCopy.mjs", + ]; + + for (const helperPath of helperPaths) { + assert.ok( + packageFiles.includes(helperPath), + `${helperPath} must ship via package.json files[]` + ); + assert.ok( + PACK_ARTIFACT_ALLOWED_EXACT_PATHS.includes(helperPath), + `${helperPath} must be authorized by the tarball allowlist` + ); + assert.ok( + PACK_ARTIFACT_REQUIRED_PATHS.includes(helperPath), + `${helperPath} must be required so npm packing fails loudly if it disappears` + ); + } +}); + test("webdav-handler.mjs is allowed in staging dist/ (server-ws.mjs dependency, missed in 3.8.22 build)", () => { const unexpectedPaths = findUnexpectedArtifactPaths(["webdav-handler.mjs"], { exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS, @@ -226,6 +261,11 @@ test("setupPolyfill.ts is allowed in the tarball (bin/omniroute.mjs imports it a }); test("findMissingArtifactPaths flags missing root runtime files in the tarball", () => { + assert.deepEqual( + [...TLS_CLIENT_RUNTIME_SEED_PATHS].sort(), + EXPECTED_TLS_CLIENT_RUNTIME_SEED_PATHS + ); + const missingPaths = findMissingArtifactPaths( [ "dist/server.js", @@ -253,14 +293,18 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", "bin/mcp-server.mjs", "bin/mcpStdioConsoleGuard.mjs", "bin/nodeRuntimeSupport.mjs", + "dist/LICENSE", + "dist/THIRD_PARTY_NOTICES.md", "dist/head-response-guard.cjs", "dist/http-method-guard.cjs", "dist/main-server-timeouts.mjs", + "dist/open-sse/config/tlsClientNativeManifest.json", "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", "dist/open-sse/services/compression/rules/en/filler.json", "dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", "dist/peer-stamp.mjs", "dist/responses-ws-proxy.mjs", + ...EXPECTED_TLS_CLIENT_RUNTIME_SEED_PATHS.map((seedPath) => `dist/${seedPath}`), "dist/server-ws.mjs", "dist/src/lib/usage/callLogArtifactWorker.js", "dist/systemd-notify.mjs", @@ -271,6 +315,8 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", "scripts/build/fixTlsClientNodeBinary.mjs", "scripts/build/native-binary-compat.mjs", "scripts/build/runtime-env.mjs", + "scripts/build/standaloneSidecarCopy.mjs", + "scripts/build/tlsClientAssetCopy.mjs", "scripts/packs/optionalPackInstaller.mjs", "scripts/packs/optionalPackManifest.mjs", "src/shared/utils/nodeRuntimeSupport.ts", diff --git a/tests/unit/perplexity-web.test.ts b/tests/unit/perplexity-web.test.ts index f6f4b7f7a79..d7fe919bf43 100644 --- a/tests/unit/perplexity-web.test.ts +++ b/tests/unit/perplexity-web.test.ts @@ -2,8 +2,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -// ─── Response JSON shapes (real types derived from OpenAI-compatible bodies) ─ - interface PplxChatCompletionJson { id: string; object: string; @@ -18,13 +16,17 @@ interface PplxErrorJson { error: { message: string }; } -// ─── Import the executor and its dependencies ────────────────────────────── - -const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts"); +const { PerplexityWebExecutor, toPublicPerplexityErrorCode } = + await import("../../open-sse/executors/perplexity-web.ts"); const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); const { __setTlsFetchOverrideForTesting, TlsClientUnavailableError } = await import("../../open-sse/services/perplexityTlsClient.ts"); +const PPLX_THINKING_EVENT = { + blocks: [ + { intended_usage: "plan", plan_block: { goals: [{ description: "preflight thinking" }] } }, + ], +}; // #2459: the executor now routes through tlsFetchPerplexity (Firefox TLS) instead of // global fetch. Install one persistent bridge so the tests below can keep stubbing // globalThis.fetch (returning a Response) and have it surface as a TlsFetchResult. @@ -38,8 +40,6 @@ __setTlsFetchOverrideForTesting(async (url, opts) => { }; }); -// ─── Helper: Build a mock SSE stream from Perplexity events ───────────────── - function mockPplxStream(events) { const encoder = new TextEncoder(); const chunks = []; @@ -56,15 +56,15 @@ function mockPplxStream(events) { }); } -// ─── Helper: stub globalThis.fetch for testing ────────────────────────────── // The persistent bridge above forwards tlsFetchPerplexity calls to globalThis.fetch, // so stubbing fetch is still the way to mock Perplexity's upstream response. -function mockFetch(status, streamEvents, bodyText) { +function mockFetch(status, events, bodyText) { const original = globalThis.fetch; globalThis.fetch = async () => { if (status === 200) { - return new Response(mockPplxStream(streamEvents), { + const stream = events instanceof ReadableStream ? events : mockPplxStream(events); + return new Response(stream, { status, headers: { "Content-Type": "text/event-stream" }, }); @@ -89,7 +89,56 @@ function mockFetchError(error) { }; } -// ─── Test: Executor registration ──────────────────────────────────────────── +function mockFetchLateStreamError(error) { + const original = globalThis.fetch; + globalThis.fetch = async () => { + const encoder = new TextEncoder(); + const firstEvent = { + blocks: [ + { + intended_usage: "markdown", + markdown_block: { chunks: ["partial answer"], progress: "IN_PROGRESS" }, + }, + ], + }; + let sentFirstEvent = false; + + return new Response( + new ReadableStream({ + pull(controller) { + if (!sentFirstEvent) { + sentFirstEvent = true; + controller.enqueue( + encoder.encode(`event: message\r\ndata: ${JSON.stringify(firstEvent)}\r\n\r\n`) + ); + return; + } + controller.error(error); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + }; + return () => { + globalThis.fetch = original; + }; +} + +function executePerplexity(stream, log = null, bodyOverrides = {}) { + return new PerplexityWebExecutor().execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hello" }], stream, ...bodyOverrides }, + stream, + credentials: { apiKey: "test-cookie" }, + signal: AbortSignal.timeout(10000), + log, + }); +} + +function executePerplexityWithEvents(events, stream = false, bodyOverrides = {}, log = null) { + const restore = mockFetch(200, events); + return executePerplexity(stream, log, bodyOverrides).finally(restore); +} test("PerplexityWebExecutor is registered in executor index", () => { assert.ok(hasSpecializedExecutor("perplexity-web")); @@ -105,55 +154,54 @@ test("PerplexityWebExecutor alias resolves to same type", () => { assert.ok(b instanceof PerplexityWebExecutor); }); -// ─── Test: Constructor ────────────────────────────────────────────────────── - test("PerplexityWebExecutor sets correct provider name", () => { const executor = new PerplexityWebExecutor(); assert.equal(executor.getProvider(), "perplexity-web"); }); -// ─── Test: Non-streaming response ─────────────────────────────────────────── - test("Non-streaming: simple text response", async () => { + const opaqueSessionId = "opaque-session-secret-uuid-123456"; const pplxEvents = [ { - backend_uuid: "test-uuid-123", + backend_uuid: opaqueSessionId, blocks: [ { intended_usage: "markdown", - markdown_block: { - chunks: ["Hello, world!"], - progress: "DONE", - }, + markdown_block: { chunks: ["Hello, world!"], progress: "DONE" }, }, ], status: "COMPLETED", }, ]; - const restore = mockFetch(200, pplxEvents); - try { - const executor = new PerplexityWebExecutor(); - const result = await executor.execute({ - model: "pplx-auto", - body: { messages: [{ role: "user", content: "hi" }], stream: false }, - stream: false, - credentials: { apiKey: "test-cookie-value" }, - signal: AbortSignal.timeout(10000), - log: null, - }); + const result = await executePerplexityWithEvents(pplxEvents, false, { + messages: [{ role: "user", content: "hi" }], + }); - assert.equal(result.response.status, 200); - const json = (await result.response.json()) as PplxChatCompletionJson; - assert.equal(json.object, "chat.completion"); - assert.equal(json.choices[0].message.role, "assistant"); - assert.equal(json.choices[0].message.content, "Hello, world!"); - assert.equal(json.choices[0].finish_reason, "stop"); - assert.ok(json.id.startsWith("chatcmpl-pplx-")); - assert.ok(json.usage.total_tokens > 0); - } finally { - restore(); - } + assert.equal(result.response.status, 200); + const json = (await result.response.json()) as PplxChatCompletionJson; + assert.equal(json.object, "chat.completion"); + assert.equal(json.choices[0].message.role, "assistant"); + assert.equal(json.choices[0].message.content, "Hello, world!"); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.ok(json.id.startsWith("chatcmpl-pplx-")); + assert.ok(json.usage.total_tokens > 0); + const infoLogs: string[] = []; + const followUp = await executePerplexityWithEvents( + pplxEvents, + false, + { + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello, world!" }, + { role: "user", content: "follow-up" }, + ], + }, + { info: (_tag, message) => infoLogs.push(String(message)) } + ); + assert.equal(followUp.transformedBody.params.last_backend_uuid, opaqueSessionId); + assert.doesNotMatch(infoLogs.join("\n"), new RegExp(opaqueSessionId.slice(0, 12))); + assert.match(infoLogs.join("\n"), /Continuing existing session/); }); test("Non-streaming: strips citations from response", async () => { @@ -194,7 +242,59 @@ test("Non-streaming: strips citations from response", async () => { } }); -// ─── Test: Streaming response ─────────────────────────────────────────────── +test("Non-streaming: sanitizes upstream event errors and exposes only a safe code", async () => { + const upstreamError = + "Perplexity event failed at /srv/private/perplexity-secret.ts:41:9; " + + "access_token=pplx-upstream-secret\n" + + " at SecretUpstreamFrame (/srv/private/perplexity-stack.ts:3:4)"; + const unsafeErrorCode = "UPSTREAM_FAILURE access_token=pplx-code-secret"; + const result = await executePerplexityWithEvents([ + { error_code: unsafeErrorCode, error_message: upstreamError }, + ]); + assert.equal(result.response.status, 502); + assert.equal(result.response.headers.get("Retry-After"), null); + const payloadText = await result.response.text(); + const json = JSON.parse(payloadText); + assert.equal(json.error?.type, "upstream_error"); + assert.equal(json.error?.code, "PPLX_ERROR"); + assert.match(String(json.error?.message || ""), /Perplexity event failed/); + const privateDetails = + /\/srv\/private\/perplexity-(?:secret|stack)\.ts|pplx-upstream-secret|pplx-code-secret|SecretUpstreamFrame/; + assert.doesNotMatch(payloadText, privateDetails); + assert.ok(!payloadText.includes(unsafeErrorCode), "must not expose the raw upstream code"); +}); + +test("Non-streaming: preserves bounded upstream codes and rejects untrusted codes", () => { + assert.equal(toPublicPerplexityErrorCode("RATE_LIMIT", false), "RATE_LIMIT"); + assert.equal(toPublicPerplexityErrorCode("token_expired", false), "token_expired"); + assert.equal(toPublicPerplexityErrorCode("access_token_SECRET", false), "PPLX_ERROR"); + assert.equal(toPublicPerplexityErrorCode("password_hunter2", false), "PPLX_ERROR"); + assert.equal( + toPublicPerplexityErrorCode("UPSTREAM_FAILURE access_token=pplx-code-secret", false), + "PPLX_ERROR" + ); + assert.equal(toPublicPerplexityErrorCode(`X${"A".repeat(64)}`, false), "PPLX_ERROR"); + assert.equal(toPublicPerplexityErrorCode("RATE_LIMIT", true), "quota_exhausted"); +}); + +test("Non-streaming: uses a stable fallback when sanitization removes the whole error", async (t) => { + const failures = [ + ["stack-only", "\n at SecretOnlyFrame (/srv/private/perplexity-stack-only.ts:3:4)"], + ["whitespace-only", " \t "], + ]; + for (const [name, message] of failures) { + await t.test(name, async () => { + const result = await executePerplexityWithEvents([ + { error_code: "UPSTREAM_FAILURE", error_message: message }, + ]); + assert.equal(result.response.status, 502); + const json = JSON.parse(await result.response.text()); + assert.equal(json.error?.message, "Perplexity upstream error"); + assert.equal(json.error?.type, "upstream_error"); + assert.equal(json.error?.code, "UPSTREAM_FAILURE"); + }); + } +}); test("Streaming: produces valid SSE chunks", async () => { const pplxEvents = [ @@ -258,6 +358,179 @@ test("Streaming: produces valid SSE chunks", async () => { } }); +test("Streaming: sanitizes late Error and non-Error failures before emitting SSE", async (t) => { + const failures = [ + { + name: "Error rejection", + reason: new Error( + "Late read failed at /srv/omniroute/private/perplexity.ts:44:9; " + + "access token: pplx-secret-error; access_token=pplx-query-error\n" + + " at SecretFunction (/srv/omniroute/private/perplexity.ts:44:9)" + ), + forbidden: + /\/srv\/omniroute\/private\/perplexity\.ts|pplx-secret-error|pplx-query-error|SecretFunction/, + }, + { + name: "non-Error rejection", + reason: + "Late read failed at C:\\Users\\runner\\OmniRoute\\perplexity.ts:8:2; " + + "access token: pplx-secret-string; access_token=pplx-query-string\n" + + " at NonErrorFrame (C:\\Users\\runner\\OmniRoute\\perplexity.ts:8:2)", + forbidden: + /C:\\Users\\runner\\OmniRoute\\perplexity\.ts|pplx-secret-string|pplx-query-string|NonErrorFrame/, + }, + { + name: "stack-only rejection", + reason: "\n at StackOnlyFrame (/srv/private/perplexity-late-stack-only.ts:8:2)", + forbidden: /\/srv\/private\/perplexity-late-stack-only\.ts|StackOnlyFrame/, + expectedContent: "[Stream error: Perplexity upstream error]", + }, + { + name: "hostile toString rejection", + reason: { + toString() { + throw new Error( + "access_token=pplx-hostile-secret at /srv/private/perplexity-hostile.ts:1:1" + ); + }, + }, + forbidden: /pplx-hostile-secret|perplexity-hostile/, + expectedContent: "[Stream error: Perplexity upstream error]", + }, + ]; + for (const failure of failures) { + await t.test(failure.name, async () => { + const restore = mockFetchLateStreamError(failure.reason); + try { + const result = await executePerplexity(true); + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + const text = await result.response.text(); + const dataLines = text.split("\n").filter((line) => line.startsWith("data: ")); + const payloads = dataLines + .filter((line) => line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6))); + const errorChunk = payloads.find((payload) => + String(payload.choices?.[0]?.delta?.content || "").startsWith("[Stream error:") + ); + assert.ok(errorChunk, "must emit a structured stream-error delta"); + assert.equal(errorChunk.choices[0].finish_reason, "stop"); + if (failure.expectedContent) { + assert.equal(errorChunk.choices[0].delta.content, failure.expectedContent); + } + assert.equal(dataLines.at(-1), "data: [DONE]"); + assert.doesNotMatch(text, failure.forbidden); + } finally { + restore(); + } + }); + } +}); + +test("Streaming: sanitizes upstream event errors before emitting SSE", async () => { + const upstreamError = + "Perplexity event failed at /srv/private/perplexity-secret.ts:41:9; " + + "access_token=pplx-upstream-secret\n" + + " at SecretUpstreamFrame (/srv/private/perplexity-stack.ts:3:4)"; + const upstream = mockPplxStream([ + { error_code: "UPSTREAM_FAILURE", error_message: upstreamError }, + ]); + const result = await executePerplexityWithEvents(upstream, true); + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.match(text, /\[Error:/); + assert.match(text, /data: \[DONE\]/); + assert.doesNotMatch( + text, + /\/srv\/private\/perplexity-(?:secret|stack)\.ts|pplx-upstream-secret|SecretUpstreamFrame/ + ); + assert.equal(upstream.locked, false); +}); + +test("Streaming: cancellation releases a pending upstream reader", async () => { + let cancelled = false; + const upstream = new ReadableStream({ + start: (controller) => + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(PPLX_THINKING_EVENT)}\n\n`) + ), + cancel() { + cancelled = true; + }, + }); + const result = await executePerplexityWithEvents(upstream, true); + await result.response.body.getReader().cancel(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(cancelled, true); + assert.equal(upstream.locked, false); +}); + +test("Tools + streaming preserves a sanitized upstream error response", async () => { + const result = await executePerplexityWithEvents( + [ + { + error_code: "UPSTREAM_FAILURE", + error_message: + "Tool error at /srv/private/pplx-tool.ts:4:2; access_token=pplx-tool-secret\n" + + " at SecretToolFrame (/srv/private/pplx-tool-stack.ts:5:6)", + }, + ], + true, + { tools: [{ type: "function", function: { name: "lookup", parameters: { type: "object" } } }] } + ); + assert.equal(result.response.status, 502); + const payloadText = await result.response.text(); + const json = JSON.parse(payloadText); + assert.equal(json.error?.type, "upstream_error"); + assert.equal(json.error?.code, "UPSTREAM_FAILURE"); + assert.doesNotMatch(payloadText, /pplx-tool-secret|SecretToolFrame|\/srv\/private/); +}); + +test("Tools + streaming preserves sanitized quota metadata", async () => { + const result = await executePerplexityWithEvents( + [ + { + upsell_information: { + name: "advanced_models_quota_low", + title: "No uses at /srv/private/pplx-quota.ts:4:2; access_token=pplx-quota-secret", + description: + "Upgrade now\n at SecretQuotaFrame (/srv/private/pplx-quota-stack.ts:5:6)", + }, + }, + { final: true, text_completed: true }, + ], + true, + { tools: [{ type: "function", function: { name: "lookup", parameters: { type: "object" } } }] } + ); + assert.equal(result.response.status, 429); + const payloadText = await result.response.text(); + const json = JSON.parse(payloadText); + assert.equal(json.error?.code, "quota_exhausted"); + assert.ok(json.error?.reset_seconds >= 3600); + assert.equal(result.response.headers.get("Retry-After"), String(json.error.reset_seconds)); + assert.doesNotMatch(payloadText, /pplx-quota-secret|SecretQuotaFrame|\/srv\/private/); +}); + +test("Streaming: uses a stable fallback when sanitization removes the whole error", async (t) => { + const failures = [ + ["stack-only", "\n at SecretOnlyFrame (/srv/private/perplexity-stack-only.ts:3:4)"], + ["whitespace-only", " \t "], + ]; + for (const [name, message] of failures) { + await t.test(name, async () => { + const result = await executePerplexityWithEvents( + [{ error_code: "UPSTREAM_FAILURE", error_message: message }], + true + ); + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.match(text, /\[Error: Perplexity upstream error\]/); + assert.match(text, /data: \[DONE\]/); + assert.doesNotMatch(text, /SecretOnlyFrame|perplexity-stack-only/); + }); + } +}); + // ─── Test: Schematized diff_block streaming (use_schematized_api) ─────────── test("Schematized API: diff_block chunks reconstruct answer (non-streaming)", async () => { @@ -417,24 +690,9 @@ test("Schematized API: diff_block streams incremental deltas", async () => { } }); -// ─── Test: Thinking/reasoning content ─────────────────────────────────────── test("Streaming: thinking content emitted as reasoning_content", async () => { const pplxEvents = [ - { - blocks: [ - { - intended_usage: "pro_search_steps", - plan_block: { - steps: [ - { - step_type: "SEARCH_WEB", - search_web_content: { queries: [{ query: "test query" }] }, - }, - ], - }, - }, - ], - }, + PPLX_THINKING_EVENT, { blocks: [ { @@ -459,23 +717,20 @@ test("Streaming: thinking content emitted as reasoning_content", async () => { }); const text = await result.response.text(); - const dataLines = text + const deltas = text .split("\n") - .filter((l) => l.startsWith("data: ") && l !== "data: [DONE]"); - - // Should have a reasoning_content delta - const hasReasoning = dataLines.some((l) => { - const json = JSON.parse(l.slice(6)); - return json.choices?.[0]?.delta?.reasoning_content != null; - }); - assert.ok(hasReasoning, "Should have reasoning_content delta for thinking steps"); + .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6)).choices?.[0]?.delta ?? {}); + assert.deepEqual( + deltas.flatMap((delta) => (delta.reasoning_content ? [delta.reasoning_content] : [])), + ["preflight thinking\n"] + ); + assert.equal(deltas.map((delta) => delta.content || "").join(""), "The answer."); } finally { restore(); } }); -// ─── Test: Error handling ─────────────────────────────────────────────────── - test("Error: 401 returns auth error message", async () => { const restore = mockFetch(401, []); try { @@ -540,6 +795,116 @@ test("Error: fetch failure returns 502", async () => { } }); +test("Error: fetch failure sanitizes sensitive details in logs", async () => { + const upstreamError = new Proxy( + new Error("access_token=pplx-fetch-secret\n at SecretFetchFrame (/srv/pplx.ts:2:3)"), + { getPrototypeOf: () => 42 } + ); + const errorLogs: string[] = []; + const restore = mockFetchError(upstreamError); + try { + const result = await executePerplexity(false, { + error: (_tag, message) => errorLogs.push(String(message)), + }); + assert.equal(result.response.status, 502); + assert.equal(errorLogs.length, 1); + const publicOutput = `${errorLogs.join("\n")}\n${await result.response.text()}`; + assert.match(publicOutput, /Fetch failed/); + assert.doesNotMatch(publicOutput, /pplx-fetch-secret|SecretFetchFrame|\/srv\/pplx\.ts/); + } finally { + restore(); + } +}); + +test("Error: fetch stack-only failure uses a stable fallback in logs and response", async () => { + const errorLogs: string[] = []; + const restore = mockFetchError( + new Error("\n at SecretOnlyFrame (/srv/private/perplexity-fetch-stack-only.ts:2:3)") + ); + try { + const result = await executePerplexity(false, { + error: (_tag, message) => errorLogs.push(String(message)), + }); + assert.equal(result.response.status, 502); + assert.deepEqual(errorLogs, ["Fetch failed: Perplexity upstream error"]); + const json = JSON.parse(await result.response.text()); + assert.equal(json.error?.message, "Perplexity connection failed: Perplexity upstream error"); + } finally { + restore(); + } +}); + +test("Error: whitespace-only fetch failures use the stable fallback", async () => { + const errorLogs: string[] = []; + const restore = mockFetchError(new Error(" \t ")); + try { + const result = await executePerplexity(false, { + error: (_tag, message) => errorLogs.push(String(message)), + }); + assert.equal(result.response.status, 502); + assert.deepEqual(errorLogs, ["Fetch failed: Perplexity upstream error"]); + const json = JSON.parse(await result.response.text()); + assert.equal(json.error?.message, "Perplexity connection failed: Perplexity upstream error"); + } finally { + restore(); + } +}); + +test("Error: refreshed-cookie persistence failure sanitizes sensitive details in logs", async () => { + const original = globalThis.fetch; + const warningLogs: string[] = []; + globalThis.fetch = async () => + new Response( + mockPplxStream([ + { + blocks: [ + { + intended_usage: "markdown", + markdown_block: { chunks: ["ok"], progress: "DONE" }, + }, + ], + status: "COMPLETED", + }, + ]), + { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "set-cookie": "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure", + }, + } + ); + try { + const executor = new PerplexityWebExecutor(); + const result = await executor.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "old-cookie-value" }, + signal: AbortSignal.timeout(10000), + log: { + warn: (_tag, message) => warningLogs.push(String(message)), + }, + onCredentialsRefreshed: async () => { + throw new Error( + "Persistence failed at /srv/private/perplexity-cookie.ts:23:7; " + + "access_token=pplx-cookie-secret\n" + + " at SecretCookieFrame (/srv/private/perplexity-cookie-stack.ts:4:5)" + ); + }, + }); + + assert.equal(result.response.status, 200, "persistence failure remains non-fatal"); + assert.equal(warningLogs.length, 1); + const publicLog = warningLogs.join("\n"); + assert.match(publicLog, /Failed to persist refreshed cookie/); + assert.doesNotMatch(publicLog, /\/srv\/private\/perplexity-cookie(?:-stack)?\.ts/); + assert.doesNotMatch(publicLog, /pplx-cookie-secret|SecretCookieFrame/); + } finally { + globalThis.fetch = original; + } +}); + test("Error: empty messages returns 400", async () => { const executor = new PerplexityWebExecutor(); const result = await executor.execute({ @@ -570,8 +935,6 @@ test("Error: missing messages returns 400", async () => { assert.equal(result.response.status, 400); }); -// ─── Test: Perplexity SSE error in stream ─────────────────────────────────── - test("Non-streaming: Perplexity stream error returns 502", async () => { const pplxEvents = [{ error_code: "RATE_LIMIT", error_message: "Too many requests" }]; @@ -589,7 +952,7 @@ test("Non-streaming: Perplexity stream error returns 502", async () => { assert.equal(result.response.status, 502); const json = (await result.response.json()) as PplxErrorJson; - assert.ok(json.error.message.includes("Too many requests")); + assert.equal(json.error.code, "RATE_LIMIT"); } finally { restore(); } @@ -597,6 +960,19 @@ test("Non-streaming: Perplexity stream error returns 502", async () => { // ─── Test: Message parsing ────────────────────────────────────────────────── +test("Streaming and non-streaming preserve a quota wire error code", async () => { + const events = [PPLX_THINKING_EVENT, { error_code: "quota_exhausted", error_message: "limit" }]; + for (const stream of [false, true]) { + const upstream = mockPplxStream(events); + const result = await executePerplexityWithEvents(upstream, stream); + const json = await result.response.json(); + assert.equal(result.response.status, 429); + assert.equal(json.error.code, "quota_exhausted"); + assert.equal(json.error.reset_seconds, 6 * 60 * 60); + assert.equal(result.response.headers.get("Retry-After"), String(6 * 60 * 60)); + assert.equal(upstream.locked, false); + } +}); test("Message parsing: system + user + assistant history", async () => { let capturedBody = null; const original = globalThis.fetch; @@ -1269,9 +1645,8 @@ test("Schematized API: dual ask_text tracks do not double-count", async () => { // Unit: extractAnswerFromFinalText pure helper test("extractAnswerFromFinalText: double-encoded FINAL step blob", async () => { - const { extractAnswerFromFinalText } = await import( - "../../open-sse/executors/perplexity-web/protocol.ts" - ); + const { extractAnswerFromFinalText } = + await import("../../open-sse/executors/perplexity-web/protocol.ts"); const text = JSON.stringify([ { step_type: "INITIAL_QUERY", content: { query: "hello" } }, { diff --git a/tests/unit/provider-validation-error-sanitization.test.ts b/tests/unit/provider-validation-error-sanitization.test.ts new file mode 100644 index 00000000000..5314a9ea42e --- /dev/null +++ b/tests/unit/provider-validation-error-sanitization.test.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-provider-validation-errors-") +); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +process.env.DATA_DIR = TEST_DATA_DIR; + +const { isSecurityBlockError, readProxyFallbackErrorState, toValidationErrorResult } = + await import("../../src/lib/providers/validation/transport.ts"); +const { SafeOutboundFetchError } = await import("../../src/shared/network/safeOutboundFetch.ts"); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; +}); + +test("provider validation sanitizes generic thrown error details", () => { + const result = toValidationErrorResult( + new Error( + "Provider probe failed at /srv/private/provider-key.json access_token=provider-secret\n" + + " at validate (/srv/private/validator.ts:42:7)" + ) + ); + + assert.equal(result.valid, false); + assert.match(result.error, /Provider probe failed/i); + assert.doesNotMatch(result.error, /srv\/private|provider-secret|validator\.ts/); + assert.doesNotMatch(result.error, /[\r\n]|\bat validate\b/i); + assert.equal(result.unsupported, false); +}); + +test("provider validation sanitizes non-Error string failures", () => { + const result = toValidationErrorResult( + "String probe failed at /srv/private/string-secret.pem access_token=string-secret\n" + + " at stringProbe (/srv/private/probe.ts:1:1)" + ); + + assert.equal(result.valid, false); + assert.match(result.error, /String probe failed/i); + assert.doesNotMatch(result.error, /srv\/private|string-secret|probe\.ts/); + assert.doesNotMatch(result.error, /[\r\n]|\bat stringProbe\b/i); + assert.equal(result.unsupported, false); +}); + +test("provider validation fails closed when a thrown value rejects string coercion", () => { + const hostile = { + toString() { + throw new Error("access_token=hostile-secret at /srv/private/hostile.ts:1:2"); + }, + }; + + const result = toValidationErrorResult(hostile); + + assert.deepEqual(result, { + valid: false, + error: "Validation failed", + unsupported: false, + }); +}); + +test("provider validation fails closed when prototype inspection throws", () => { + const hostile = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("access_token=proxy-secret at /srv/private/proxy.ts:1:2"); + }, + get(_target, property) { + if (property === "code" || property === "isRetryable") { + throw new Error("access_token=metadata-secret at /srv/private/metadata.ts:1:2"); + } + if (property === "toString") { + return () => { + throw new Error("access_token=coercion-secret at /srv/private/coercion.ts:1:2"); + }; + } + return undefined; + }, + } + ); + + assert.deepEqual(toValidationErrorResult(hostile), { + valid: false, + error: "Validation failed", + unsupported: false, + }); + assert.equal(isSecurityBlockError(hostile), false); + assert.deepEqual(readProxyFallbackErrorState(hostile), { + isNetworkIssue: false, + isRetryable: false, + }); +}); + +test("provider validation preserves safe generic messages", () => { + const result = toValidationErrorResult(new Error("Provider temporarily unavailable")); + + assert.equal(result.error, "Provider temporarily unavailable"); + assert.equal(result.statusCode, undefined); + assert.equal(result.timeout, undefined); + assert.equal(result.securityBlocked, undefined); +}); + +test("provider validation preserves timeout and security classifications", () => { + const timeout = toValidationErrorResult( + new SafeOutboundFetchError( + "Timed out at /srv/private/provider.json access_token=timeout-secret", + { + code: "TIMEOUT", + url: "https://api.example.com/models", + method: "GET", + attempts: 1, + isRetryable: true, + timeoutMs: 1000, + } + ) + ); + const securityBlock = toValidationErrorResult( + new SafeOutboundFetchError("Blocked private host", { + code: "URL_GUARD_BLOCKED", + url: "http://169.254.169.254/latest/meta-data/", + method: "GET", + attempts: 1, + isRetryable: false, + }) + ); + + assert.equal(timeout.statusCode, 504); + assert.equal(timeout.timeout, true); + assert.equal(timeout.securityBlocked, undefined); + assert.doesNotMatch(timeout.error, /srv\/private|timeout-secret/); + + assert.equal(securityBlock.statusCode, 503); + assert.equal(securityBlock.timeout, undefined); + assert.equal(securityBlock.securityBlocked, true); + assert.equal(securityBlock.error, "Blocked private host"); +}); + +test("provider validation route sanitizes unexpected failures before logging", () => { + const routeSource = fs.readFileSync( + new URL("../../src/app/api/providers/validate/route.ts", import.meta.url), + "utf8" + ); + + assert.match( + routeSource, + /console\.log\(\s*"Error validating API key:",\s*sanitizeErrorMessage\(error\) \|\| "Validation failed"\s*\)/ + ); + assert.doesNotMatch(routeSource, /console\.log\(\s*"Error validating API key:",\s*error\s*\)/); +}); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 9e0fb923663..87e66a468ff 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -16,9 +16,29 @@ const { __setTlsFetchOverrideForTesting: __setPplxTlsFetchOverride } = const { __setTlsFetchOverrideForTesting: __setGrokTlsFetchOverride } = await import("../../open-sse/services/grokTlsClient.ts"); - const originalFetch = globalThis.fetch; +function hostilePrototypeFailure(label: string): unknown { + return new Proxy( + {}, + { + getPrototypeOf() { + throw new Error(`access_token=${label}-prototype-secret at /srv/private/${label}.ts:1:2`); + }, + get(_target, property) { + if (property === "toString") { + return () => { + throw new Error( + `access_token=${label}-coercion-secret at /srv/private/${label}-coercion.ts:1:2` + ); + }; + } + return undefined; + }, + } + ); +} + test.afterEach(() => { globalThis.fetch = originalFetch; __setPplxTlsFetchOverride(null); @@ -56,6 +76,88 @@ data: `; } +test("deepseek-web validator sanitizes application error details without changing classification", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: 49999, + msg: + "Temporary DeepSeek condition at /srv/private/deepseek.json " + + "access_token=deepseek-secret\n at DeepSeekSecretFrame (/srv/private/deepseek.ts:1:1)", + data: { biz_data: null }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + const result = await validateProviderApiKey({ + provider: "deepseek-web", + apiKey: "synthetic-user-token", + }); + const error = result.error || ""; + + assert.equal(result.valid, false); + assert.notEqual(result.unsupported, true); + assert.match(error, /DeepSeek did not return an access token/i); + assert.match(error, /Temporary DeepSeek condition/i); + assert.doesNotMatch( + error, + /srv\/private|deepseek-secret|deepseek\.ts|DeepSeekSecretFrame|[\r\n]/i + ); + + globalThis.fetch = async () => + new Response(JSON.stringify({ code: 49999, msg: "", data: { biz_data: null } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + const emptyDetail = await validateProviderApiKey({ + provider: "deepseek-web", + apiKey: "synthetic-user-token", + }); + assert.match(emptyDetail.error || "", /unknown error/i); +}); + +test("jules validator sanitizes rejected upstream bodies and preserves its fallback", async () => { + globalThis.fetch = async () => + new Response( + "Jules upstream rejected the request at /srv/private/jules.json " + + "api_key=jules-secret\n at JulesSecretFrame (/srv/private/jules.ts:2:3)", + { status: 400 } + ); + + const result = await validateProviderApiKey({ provider: "jules", apiKey: "jules-key" }); + const error = result.error || ""; + + assert.equal(result.valid, false); + assert.notEqual(result.unsupported, true); + assert.match(error, /Jules upstream rejected the request/i); + assert.doesNotMatch(error, /srv\/private|jules-secret|jules\.ts|JulesSecretFrame|[\r\n]/i); + + globalThis.fetch = async () => new Response("", { status: 400 }); + const emptyBody = await validateProviderApiKey({ provider: "jules", apiKey: "jules-key" }); + assert.equal(emptyBody.error, "Jules API returned 400"); +}); + +test("devin validator sanitizes rejected upstream bodies and preserves its fallback", async () => { + globalThis.fetch = async () => + new Response( + "Devin upstream rejected the request at /srv/private/devin.json " + + "access_token=devin-secret\n at DevinSecretFrame (/srv/private/devin.ts:4:5)", + { status: 422 } + ); + + const result = await validateProviderApiKey({ provider: "devin", apiKey: "devin-key" }); + const error = result.error || ""; + + assert.equal(result.valid, false); + assert.notEqual(result.unsupported, true); + assert.match(error, /Devin upstream rejected the request/i); + assert.doesNotMatch(error, /srv\/private|devin-secret|devin\.ts|DevinSecretFrame|[\r\n]/i); + + globalThis.fetch = async () => new Response("", { status: 422 }); + const emptyBody = await validateProviderApiKey({ provider: "devin", apiKey: "devin-key" }); + assert.equal(emptyBody.error, "Devin API returned 422"); +}); + test("Kiro API key validator resolves profiles with bearer auth", async () => { const calls: Array<{ url: string; headers: Record }> = []; globalThis.fetch = async (url, init = {}) => { @@ -180,7 +282,7 @@ test("Kiro API key validator fails as invalid instead of unsupported", async () }); test("specialty provider validators cover Deepgram, AssemblyAI, ElevenLabs and Inworld branches", async () => { - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (_url, init = {}) => { const target = String(url); const headers = init.headers || {}; @@ -737,6 +839,271 @@ test("grok-web validator: structured non-auth 403 (resource error) still surface assert.doesNotMatch(result.error || "", /residential IP|proxy/i); }); +test("grok-web validator classifies a structured 403 from the complete raw body before display capping", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + error: { code: 7, message: "Model is not found", details: [] }, + diagnosticPadding: "X".repeat(300), + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok rejected validation \(403\)/); + assert.match(result.error || "", /Model is not found/); + assert.doesNotMatch(result.error || "", /residential IP|proxy/i); +}); + +test("grok-web validator keeps oversized JSON-shaped 403 details bounded without false anti-bot guidance", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + error: { code: 7, message: "Model is not found", details: [] }, + diagnosticPadding: "X".repeat(70_000), + tail: "access_token=fake-tail-secret", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + const error = result.error || ""; + + assert.equal(result.valid, false); + assert.match(error, /Grok rejected validation \(403\)/); + assert.match(error, /Model is not found/); + assert.doesNotMatch(error, /residential IP|proxy|fake-tail-secret/); + assert.ok(error.length <= "Grok rejected validation (403): ".length + 160); +}); + +test("grok-web validator classifies auth details beyond the old display cap from raw input", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + diagnosticPadding: "X".repeat(300), + error: { code: 16, message: "unauthorized: invalid-credentials" }, + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "bad-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Invalid SSO cookie/i); + assert.doesNotMatch(result.error || "", /residential IP|proxy/i); +}); + +test("grok-web validator sanitizes sensitive details in structured non-auth 403 errors", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + error: + "/srv/private/key.pem access_token=secret\n" + " at SecretFunction (/srv/stack.ts:1)", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok rejected validation \(403\)/); + assert.doesNotMatch(result.error || "", /srv\/private|access_token=secret|stack\.ts/); + assert.doesNotMatch(result.error || "", /SecretFunction|\\n\s+at/); +}); + +test("grok-web validator strips JSON-escaped stack frames from structured errors", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + error: "Operation failed\n at SecretFunction (/srv/private/key.pem:1)", + context: "access_token=secret", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok rejected validation \(403\)/); + assert.match(result.error || "", /Operation failed/); + assert.doesNotMatch( + result.error || "", + /srv\/private|access_token=secret|key\.pem|SecretFunction|\\n\s+at/ + ); +}); + +test("grok-web validator strips double-escaped stack frames from structured errors", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + error: "Operation failed\\n at DoubleEscapedFrame (/srv/private/key.pem:1)", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok rejected validation \(403\)/); + assert.match(result.error || "", /Operation failed/); + assert.doesNotMatch(result.error || "", /DoubleEscapedFrame|\\n\s+at|srv\/private|key\.pem/); +}); + +test("grok-web validator strips double-escaped Unicode stack separators", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 400, + headers: new Headers(), + text: JSON.stringify({ + error: "Operation failed\\u000a at UnicodeEscapedFrame (/srv/private/key.pem:1)", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok validation failed \(400\)/); + assert.match(result.error || "", /Operation failed/); + assert.doesNotMatch(result.error || "", /UnicodeEscapedFrame|\\u000a\s+at|srv\/private/); +}); + +test("grok-web validator strips decoded Unicode line-separator stack frames", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 400, + headers: new Headers(), + text: JSON.stringify({ + error: "Operation failed\u2028 at LineSeparatorFrame (/srv/private/key.pem:1)", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok validation failed \(400\)/); + assert.match(result.error || "", /Operation failed/); + assert.doesNotMatch(result.error || "", /LineSeparatorFrame|srv\/private/); +}); + +test("grok-web validator strips double-escaped paths, credentials and credential keys", async () => { + const encodedCredentialKey = + "\\u0061\\u0063\\u0063\\u0065\\u0073\\u0073\\u005f\\u0074\\u006f\\u006b\\u0065\\u006e"; + __setGrokTlsFetchOverride(async () => { + return { + status: 400, + headers: new Headers(), + text: JSON.stringify({ + error: + "Operation failed at \\u002fsrv\\u002fprivate\\u002fkey.pem " + + "access\\u005ftoken\\u003dfake-double-secret", + [encodedCredentialKey]: "fake-opaque-credential", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok validation failed \(400\)/); + assert.match(result.error || "", /Operation failed/); + assert.doesNotMatch( + result.error || "", + /srv|private|key\.pem|fake-double-secret|fake-opaque-credential|\\u00(?:2f|3d|5f)/i + ); +}); + +test("grok-web validator sanitizes structured 403 details before applying the length cap", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + error: `${"A".repeat(120)}\n` + " at SecretFunction (/srv/private/key.pem:1)", + context: "access_token=secret", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok rejected validation \(403\)/); + assert.doesNotMatch(result.error || "", /SecretFunction|\\n\s+at|srv\/private|key\.pem/); +}); + +test("grok-web validator strips escaped stack frames from capped generic JSON", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 400, + headers: new Headers(), + text: JSON.stringify({ + error: "Operation failed\n at SecretFunction (/srv/private/key.pem:1)", + padding: "X".repeat(300), + context: "access_token=secret", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok validation failed \(400\)/); + assert.match(result.error || "", /Operation failed/); + assert.doesNotMatch(result.error || "", /SecretFunction|\\n\s+at|srv\/private|key\.pem/); + assert.ok((result.error || "").length <= "Grok validation failed (400): ".length + 240); +}); + +test("grok-web validator sanitizes sensitive details in other upstream errors", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 400, + headers: new Headers(), + text: JSON.stringify({ + error: "Operation failed\n at SecretFunction (/srv/private/key.pem:1)", + context: "access_token=secret", + }), + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Grok validation failed \(400\)/); + assert.match(result.error || "", /Operation failed/); + assert.doesNotMatch( + result.error || "", + /srv\/private|access_token=secret|key\.pem|SecretFunction|\\n\s+at/ + ); +}); + test("grok-web validator: auth-shaped 401 keeps the re-paste/re-authenticate guidance (no regression) (#3474)", async () => { __setGrokTlsFetchOverride(async () => { return { status: 401, headers: new Headers(), text: "Unauthorized", body: null }; @@ -811,13 +1178,65 @@ test("grok-web validator: invalid-credentials 403 WITH a cf_clearance maps to IP test("grok-web validator: TLS client unavailable surfaces actionable error", async () => { __setGrokTlsFetchOverride(async () => { const { TlsClientUnavailableError } = await import("../../open-sse/services/grokTlsClient.ts"); - throw new TlsClientUnavailableError("native binary not found"); + throw new TlsClientUnavailableError( + "native binary not found at /srv/private/grok api_key=sk-grok-secret\n at /srv/stack.ts:1" + ); }); const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "sso=abc" }); assert.equal(result.valid, false); assert.match(result.error || "", /TLS impersonation client unavailable/i); assert.match(result.error || "", /native binary not found/i); + assert.doesNotMatch(result.error || "", /srv\/private|sk-grok-secret|stack\.ts/); +}); + +test("perplexity-web validator sanitizes TLS client failure details", async () => { + __setPplxTlsFetchOverride(async () => { + const { TlsClientUnavailableError } = + await import("../../open-sse/services/perplexityTlsClient.ts"); + throw new TlsClientUnavailableError( + "native binary missing at /srv/private/pplx access_token=pplx-secret\n at /srv/stack.ts:1" + ); + }); + + const result = await validateProviderApiKey({ + provider: "perplexity-web", + apiKey: "pplx.session=valid-looking-cookie", + }); + assert.equal(result.valid, false); + assert.match(result.error || "", /perplexity-web requires it/i); + assert.doesNotMatch(result.error || "", /srv\/private|pplx-secret|stack\.ts/); +}); + +test("grok-web and perplexity-web validators fail closed on hostile TLS prototypes", async () => { + const cases = [ + { + provider: "grok-web", + apiKey: "sso=abc", + setOverride: __setGrokTlsFetchOverride, + }, + { + provider: "perplexity-web", + apiKey: "pplx.session=valid-looking-cookie", + setOverride: __setPplxTlsFetchOverride, + }, + ] as const; + + for (const testCase of cases) { + testCase.setOverride(async () => { + throw hostilePrototypeFailure(`${testCase.provider}-tls`); + }); + try { + const result = await validateProviderApiKey({ + provider: testCase.provider, + apiKey: testCase.apiKey, + }); + assert.equal(result.valid, false, testCase.provider); + assert.equal(result.error, "Validation failed", testCase.provider); + } finally { + testCase.setOverride(null); + } + } }); test("grok-web validator: Cloudflare challenge page is detected and reported", async () => { @@ -966,8 +1385,9 @@ test("chatgpt-web validator: 5xx → ChatGPT unavailable", async () => { test("chatgpt-web validator: 200 non-JSON content-type surfaces a cookie hint", async () => { __setTlsFetchOverrideForTesting(async () => makeTlsResponse(200, "blocked", { - "content-type": "text/html", - "cf-ray": "ray-123", + "content-type": + "text/html; report=/srv/private/chatgpt-header.txt; access_token=chatgpt-header-secret", + "cf-ray": "ray-123 api_key=chatgpt-ray-secret path=/srv/private/cf-ray.log", }) ); @@ -977,12 +1397,19 @@ test("chatgpt-web validator: 200 non-JSON content-type surfaces a cookie hint", }); assert.equal(result.valid, false); assert.match(result.error || "", /non-JSON.*text\/html.*cf-ray=ray-123/i); + assert.match(result.error || "", /paste the FULL Cookie line/i); + assert.doesNotMatch( + result.error || "", + /srv\/private|chatgpt-header-secret|chatgpt-ray-secret|chatgpt-header\.txt|cf-ray\.log/i + ); }); test("chatgpt-web validator: TlsClientUnavailableError surfaces a clear message", async () => { const { TlsClientUnavailableError } = await import("../../open-sse/services/chatgptTlsClient.ts"); __setTlsFetchOverrideForTesting(async () => { - throw new TlsClientUnavailableError("native binding failed to load"); + throw new TlsClientUnavailableError( + "native binding failed at /srv/private/chatgpt api_key=sk-chatgpt-secret\n at /srv/stack.ts:1" + ); }); const result = await validateProviderApiKey({ @@ -991,6 +1418,20 @@ test("chatgpt-web validator: TlsClientUnavailableError surfaces a clear message" }); assert.equal(result.valid, false); assert.match(result.error || "", /chatgpt-web requires this/i); + assert.doesNotMatch(result.error || "", /srv\/private|sk-chatgpt-secret|stack\.ts/); +}); + +test("chatgpt-web validator fails closed on a hostile TLS prototype", async () => { + __setTlsFetchOverrideForTesting(async () => { + throw hostilePrototypeFailure("chatgpt-validator-tls"); + }); + + const result = await validateProviderApiKey({ + provider: "chatgpt-web", + apiKey: "any-token", + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Validation failed"); }); test("search provider validators cover success, client errors, server errors and custom user agent injection", async () => { @@ -1318,7 +1759,7 @@ test("Anthropic-compatible and Claude Code compatible validators cover direct su }, }); - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (_url, init = {}) => { if (init.method === "GET") { return new Response(JSON.stringify({ error: "bridge unavailable" }), { status: 500 }); } @@ -1346,7 +1787,7 @@ test("Claude Code compatible validator rejects missing base URL and bridge auth providerSpecificData: {}, }); - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (_url, init = {}) => { if (init.method === "GET") { throw new Error("models offline"); } @@ -2458,7 +2899,9 @@ 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( + "tls-client-node not installed at /srv/private/claude api_key=sk-claude-secret\n at /srv/stack.ts:1" + ); }); const result = await validateProviderApiKey({ @@ -2468,9 +2911,27 @@ 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.doesNotMatch(result.error || "", /srv\/private|sk-claude-secret|stack\.ts/); __setClaudeTlsFetchOverride(null); }); +test("claude-web validator fails closed on a hostile TLS prototype", async () => { + __setClaudeTlsFetchOverride(async () => { + throw hostilePrototypeFailure("claude-validator-tls"); + }); + + try { + const result = await validateProviderApiKey({ + provider: "claude-web", + apiKey: "sessionKey=sk-ant-sid02-any-key", + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Validation failed"); + } finally { + __setClaudeTlsFetchOverride(null); + } +}); + test("claude-web validator: bare sessionKey value gets prefixed", async () => { let capturedCookie = ""; __setClaudeTlsFetchOverride(async (_url, opts) => { @@ -2656,7 +3117,7 @@ test("copilot-m365-web validator: requires chathubPath", async () => { // ─── t3-web validator ──────────────────────────────────────────────────────── test("t3-web validator: valid cookies → valid", async () => { - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, _init = {}) => { if (String(url).includes("t3.chat")) { return new Response("ok", { status: 200 }); } @@ -2685,7 +3146,7 @@ test("t3-web validator: 500 → unavailable", async () => { }); test("t3-web validator: valid cookies → passes through", async () => { - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, _init = {}) => { if (String(url).includes("t3.chat")) { return new Response("ok", { status: 200 }); } diff --git a/tests/unit/repro-9406-claude-web-429-valid.test.ts b/tests/unit/repro-9406-claude-web-429-valid.test.ts index e500d2c41a4..c7a47a900b6 100644 --- a/tests/unit/repro-9406-claude-web-429-valid.test.ts +++ b/tests/unit/repro-9406-claude-web-429-valid.test.ts @@ -22,7 +22,10 @@ const EXECUTOR_PATH = "../../open-sse/executors/claude-web.ts"; /** Calls __setTlsFetchOverrideForTesting with the given mock, resets on finish. */ async function withTlsMock( - mock: (url: string, options: Record) => Promise<{ + mock: ( + url: string, + options: Record + ) => Promise<{ status: number; headers: Headers; text: string | null; @@ -68,14 +71,20 @@ test("validateClaudeWebProvider returns valid:false for 429", async () => { test("validateMuseSparkWebProvider returns valid:false for 429", async () => { const { validateMuseSparkWebProvider } = await import(VALIDATION_PATH); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + assert.equal(String(input), "https://www.meta.ai/api/graphql"); + assert.equal(init?.method, "POST"); + return new Response("Too Many Requests", { status: 429 }); + }; - // validateMuseSparkWebProvider uses validationWrite() internally. We cannot - // mock that here, but we can at least characterise the function's structure. - // The actual 429-branch fix changes lines 64-69 from valid:true to valid:false, - // and the integration-level exercise happens via the production proxy. - // This test proves the validator exports and the function accepts input. - const fn = validateMuseSparkWebProvider; - assert.equal(typeof fn, "function"); + try { + const result = await validateMuseSparkWebProvider({ apiKey: "ecto_1_sess=test-session" }); + assert.equal(result.valid, false, "expected valid:false for 429"); + assert.match(result.error ?? "", /429|rate limit/i); + } finally { + globalThis.fetch = originalFetch; + } }); // ── Test 3: errorResponseForTransport forwards Retry-After ── @@ -109,3 +118,55 @@ test("errorResponseForTransport forwards upstream Retry-After on 429", async () const retryAfter = result.response.headers.get("Retry-After"); assert.equal(retryAfter, "120", "expected forwarded Retry-After header"); }); + +test("errorResponseForTransport only forwards bounded canonical Retry-After values", async () => { + const { ClaudeWebExecutor } = await import(EXECUTOR_PATH); + const validDate = new Date(Date.now() + 60_000).toUTCString(); + const cases = [ + { label: "normalized delta-seconds", value: "000120", expected: "120" }, + { label: "canonical HTTP-date", value: validDate, expected: validDate }, + { + label: "credential and path payload", + value: "120 access_token=sk-claude-retry /srv/private/retry.txt", + expected: null, + }, + { label: "over the one-day bound", value: "86401", expected: null }, + { label: "invalid HTTP-date", value: "Fri, 99 Foo 9999 99:99:99 GMT", expected: null }, + { label: "missing", value: null, expected: null }, + ]; + + for (const { label, value, expected } of cases) { + const headers = new Headers({ "content-type": "application/json" }); + if (value !== null) headers.set("retry-after", value); + const executor = new ClaudeWebExecutor({ + sendDirect: async () => ({ + status: 429, + headers, + body: null, + bodyText: '{"error":"rate_limited"}', + }), + }); + + const result = await executor.execute({ + model: "claude-sonnet-4-6", + body: { messages: [{ role: "user", content: "Hello" }] }, + stream: false, + credentials: { + apiKey: `sessionKey=test-${label}`, + orgId: "test-org-id", + deviceId: "test-device-id", + }, + log: null, + }); + + assert.equal(result.response.status, 429, `${label}: status`); + assert.equal(result.response.headers.get("Retry-After"), expected, `${label}: header`); + const body = await result.response.json(); + assert.deepEqual(body.error, { + message: "Rate limited by Claude Web API", + type: "rate_limit_error", + code: "rate_limit_exceeded", + }); + assert.doesNotMatch(JSON.stringify(body), /sk-claude-retry|\/srv\/private/); + } +}); diff --git a/tests/unit/request-log-payloads.test.ts b/tests/unit/request-log-payloads.test.ts index 46aa84792d5..11f960024db 100644 --- a/tests/unit/request-log-payloads.test.ts +++ b/tests/unit/request-log-payloads.test.ts @@ -65,6 +65,34 @@ test("redacts web-impersonation body credentials but preserves non-secret 'capab }); }); +test("redacts challenge and handoff credentials from provider request log payloads", () => { + const protectedPayload = protectPipelinePayloads({ + providerRequest: { + model: "arena-model", + recaptchaV3Token: "recaptcha-secret", + nested: { + recaptchaToken: "recaptcha-alias-secret", + turnstileToken: "turnstile-secret", + proofToken: "proof-secret", + resumeToken: "resume-secret", + prepare_token: "prepare-secret", + }, + }, + }); + + assert.deepEqual(protectedPayload?.providerRequest, { + model: "arena-model", + recaptchaV3Token: "[REDACTED]", + nested: { + recaptchaToken: "[REDACTED]", + turnstileToken: "[REDACTED]", + proofToken: "[REDACTED]", + resumeToken: "[REDACTED]", + prepare_token: "[REDACTED]", + }, + }); +}); + test("omits encrypted reasoning values from structured log payloads", () => { const encryptedContent = "encrypted".repeat(128); const payload = { diff --git a/tests/unit/stream-failure-499-classification.test.ts b/tests/unit/stream-failure-499-classification.test.ts index 4c4e66906c2..edfc008a5d3 100644 --- a/tests/unit/stream-failure-499-classification.test.ts +++ b/tests/unit/stream-failure-499-classification.test.ts @@ -1,13 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { createStreamFailureFinalizers } = await import( - "../../open-sse/utils/streamFailureFinalization.ts" -); +const { createStreamFailureFinalizers } = + await import("../../open-sse/utils/streamFailureFinalization.ts"); +const { formatTranslatedStreamError } = await import("../../open-sse/utils/streamErrorFormat.ts"); test("createStreamFailureFinalizers: 499 client disconnect body is client_disconnected", () => { - let captured: { status: number; responseBody: unknown; errorCode?: string | null } | null = - null; + let captured: { status: number; responseBody: unknown; errorCode?: string | null } | null = null; const { onPipelineStreamError } = createStreamFailureFinalizers({ isFailureCompletionRecorded: () => false, @@ -60,3 +59,45 @@ test("createStreamFailureFinalizers: caller classification survives into respons assert.equal(body.error.type, "stream_error"); assert.equal(body.error.code, "stream_pipeline_error"); }); + +test("formatTranslatedStreamError projects unsafe upstream classification fields", () => { + const unsafeCode = "bad access_token=TOP_SECRET /home/alice/code.ts"; + const unsafeType = "bad_type\n at /home/alice/type.ts:1:2"; + const text = formatTranslatedStreamError({ + error: { + status: 502, + message: "Upstream failed", + code: unsafeCode, + type: unsafeType, + }, + }); + const dataLine = text.split("\n").find((line) => line.startsWith("data: {")); + assert.ok(dataLine, "OpenAI SSE must contain a JSON data line"); + const payload = JSON.parse(dataLine.slice(6)) as { + error: { code?: string; type?: string }; + }; + + assert.equal(payload.error.code, "bad_gateway"); + assert.equal(payload.error.type, "server_error"); + assert.ok(!text.includes("TOP_SECRET")); + assert.ok(!text.includes("/home/alice")); +}); + +test("formatTranslatedStreamError preserves safe rate-limit classification", () => { + const text = formatTranslatedStreamError({ + error: { + status: 429, + message: "Weekly quota reached", + code: "usage_limit_reached", + type: "rate_limit_error", + }, + }); + const dataLine = text.split("\n").find((line) => line.startsWith("data: {")); + assert.ok(dataLine, "OpenAI SSE must contain a JSON data line"); + const payload = JSON.parse(dataLine.slice(6)) as { + error: { code?: string; type?: string }; + }; + + assert.equal(payload.error.code, "usage_limit_reached"); + assert.equal(payload.error.type, "rate_limit_error"); +}); diff --git a/tests/unit/tls-client-download-dir-8579.test.ts b/tests/unit/tls-client-download-dir-8579.test.ts index 67351c5f0d0..0bdf640d366 100644 --- a/tests/unit/tls-client-download-dir-8579.test.ts +++ b/tests/unit/tls-client-download-dir-8579.test.ts @@ -1,14 +1,29 @@ import { test, afterEach } from "node:test"; import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; import { dirname } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", ".."); +const execFileAsync = promisify(execFile); const TLS_CLIENT_WRAPPERS = [ "open-sse/services/chatgptTlsClient.ts", @@ -29,6 +44,30 @@ afterEach(() => { } }); +async function createTestTlsFetchStreaming({ + providerName, + tempDirPrefix, + tailFileVariant = "A", +}: { + providerName: string; + tempDirPrefix: string; + tailFileVariant?: "A" | "B1" | "B2"; +}) { + const { createTlsClientModule } = await import("../../open-sse/services/tlsClientBase.ts"); + const tlsFetchStreaming = createTlsClientModule({ + providerName, + tlsProfile: "chrome_146", + domain: "https://example.test", + tempDirPrefix, + tailFileVariant, + responseValidation: tailFileVariant === "A" ? "sse" : "cf", + exportCloudflareCheck: false, + exposeStreamingForTesting: true, + }).__tlsFetchStreamingForTesting; + assert.ok(tlsFetchStreaming); + return tlsFetchStreaming; +} + 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; @@ -108,11 +147,812 @@ test("runtime downloader rejects bytes that do not match the official digest", a } }); +test("runtime downloader rejects an oversized response before buffering it", async () => { + const downloadDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-oversized-")); + try { + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update("unused").digest("hex"), + }; + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + await assert.rejects( + resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [], + fetchImpl: async () => + new Response("not-read", { + status: 200, + headers: { "content-length": String(64 * 1024 * 1024 + 1) }, + }), + }), + /exceeds.*64 MiB|too large/i + ); + assert.deepEqual(readdirSync(downloadDir), []); + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } +}); + +test("runtime resolver rejects an oversized local cache before reading or reaching the network", async () => { + const downloadDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-local-oversized-")); + try { + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update("unreachable").digest("hex"), + }; + const oversizedPath = join(downloadDir, asset.file); + writeFileSync(oversizedPath, "x"); + truncateSync(oversizedPath, 64 * 1024 * 1024 + 1); + let networkReached = false; + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + await assert.rejects( + resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [], + fetchImpl: async () => { + networkReached = true; + throw new Error("network must not be reached for an oversized local cache"); + }, + }), + /exceeds.*64 MiB|too large/i + ); + assert.equal(networkReached, false); + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } +}); + +test("runtime resolver rejects a local cache that grows beyond the cap after fstat", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-growing-cache-")); + const downloadDir = join(rootDir, "cache"); + const seedDir = join(rootDir, "seed"); + try { + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + // SHA-256 of a sparse, all-zero file exactly 64 MiB + 1 byte long. + sha256: "91990977345985aaf03af1358f4f989d7eaf985b58529efb72f613c588f6599a", + }; + mkdirSync(downloadDir, { recursive: true }); + mkdirSync(join(seedDir, asset.file), { recursive: true }); + const cachedPath = join(downloadDir, asset.file); + writeFileSync(cachedPath, ""); + let cacheStatHooks = 0; + let networkReached = false; + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + await assert.rejects( + resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [seedDir], + fetchImpl: async () => { + networkReached = true; + throw new Error("network must not be reached for a growing local cache"); + }, + testHooks: { + afterOpenedFileStat: (openedPath) => { + assert.equal(openedPath, cachedPath); + cacheStatHooks++; + truncateSync(cachedPath, 64 * 1024 * 1024 + 1); + }, + }, + }), + /exceeds.*64 MiB|too large/i + ); + + assert.equal(cacheStatHooks, 1); + assert.equal(networkReached, false); + assert.deepEqual(readdirSync(downloadDir), [asset.file]); + assert.ok(lstatSync(join(seedDir, asset.file)).isDirectory()); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test( + "runtime resolver normalizes an existing verified cache file to owner read-execute only", + { skip: process.platform === "win32" }, + async () => { + const downloadDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-cache-mode-")); + try { + const bytes = Buffer.from("verified-cache-binary-with-a-legacy-mode"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const cachedPath = join(downloadDir, asset.file); + writeFileSync(cachedPath, bytes); + chmodSync(cachedPath, 0o755); + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + const resolved = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [], + fetchImpl: async () => { + throw new Error("verified cache must avoid the network"); + }, + }); + + assert.equal(resolved, cachedPath); + assert.equal(lstatSync(cachedPath).mode & 0o777, 0o500); + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } + } +); + +test("runtime resolver rejects traversal and a checksum-valid symlink before any network access", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-path-safety-")); + const downloadDir = join(rootDir, "cache"); + const outsidePath = join(rootDir, "outside.so"); + try { + const bytes = Buffer.from("checksum-valid-but-outside-the-cache"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + await assert.rejects( + resolveVerifiedTlsClientNativeLibrary({ + asset: { file: "../outside.so", sha256 }, + downloadDir, + fetchImpl: async () => { + throw new Error("network must not be reached for an invalid asset path"); + }, + }), + /invalid.*asset|asset.*path|traversal/i + ); + + writeFileSync(outsidePath, bytes); + const safeAsset = { file: "tls-client-test.so", sha256 }; + const symlinkPath = join(downloadDir, safeAsset.file); + await import("node:fs/promises").then(({ mkdir }) => mkdir(downloadDir, { recursive: true })); + symlinkSync(outsidePath, symlinkPath); + + await assert.rejects( + resolveVerifiedTlsClientNativeLibrary({ + asset: safeAsset, + downloadDir, + fetchImpl: async () => { + throw new Error("network must not be reached for an unsafe cached symlink"); + }, + }), + /symlink|regular file|unsafe/i + ); + assert.ok(lstatSync(symlinkPath).isSymbolicLink(), "resolver must not follow or replace it"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("runtime resolver retries when a completed owner removes the install lock before lstat", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-lock-race-")); + const downloadDir = join(rootDir, "cache"); + const seedDir = join(rootDir, "seed"); + try { + const bytes = Buffer.from("verified-binary-after-lock-owner-completes"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const { mkdir, rmdir } = await import("node:fs/promises"); + await mkdir(downloadDir, { recursive: true }); + await mkdir(seedDir, { recursive: true }); + writeFileSync(join(seedDir, asset.file), bytes); + const lockPath = join(downloadDir, `${asset.file}.lock`); + await mkdir(lockPath); + + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + let contentionHooks = 0; + const resolved = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [seedDir], + fetchImpl: async () => { + throw new Error("verified seed should avoid the network"); + }, + testHooks: { + afterInstallLockExists: async (observedLockPath) => { + contentionHooks++; + assert.equal(observedLockPath, lockPath); + await rmdir(lockPath); + }, + }, + }); + + assert.equal(contentionHooks, 1); + assert.equal(resolved, join(downloadDir, asset.file)); + assert.deepEqual(readFileSync(resolved), bytes); + assert.deepEqual(readdirSync(downloadDir), [asset.file]); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("default runtime cache rejects a symlink below the trusted DATA_DIR", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-parent-symlink-")); + const dataDir = join(rootDir, "data"); + const outsideDir = join(rootDir, "outside"); + const seedDir = join(rootDir, "seed"); + try { + const bytes = Buffer.from("verified-binary-that-must-stay-inside-data-dir"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const { mkdir } = await import("node:fs/promises"); + await mkdir(dataDir, { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await mkdir(seedDir, { recursive: true }); + writeFileSync(join(seedDir, asset.file), bytes); + symlinkSync(outsideDir, join(dataDir, "tls-client"), "dir"); + process.env.DATA_DIR = dataDir; + + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + await assert.rejects( + resolveVerifiedTlsClientNativeLibrary({ + asset, + seedDirs: [seedDir], + fetchImpl: async () => { + throw new Error("verified seed should avoid the network"); + }, + }), + /unsafe|symlink|trusted.*directory/i + ); + assert.equal(existsSync(join(outsideDir, "bin", asset.file)), false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("parallel first-use installs are atomic and every caller receives the verified file", async () => { + const downloadDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-concurrent-")); + try { + const bytes = Buffer.from("one-verified-binary-shared-by-concurrent-providers"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + const outcomes = await Promise.allSettled( + Array.from({ length: 100 }, () => + resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + fetchImpl: async () => { + await new Promise((resolve) => setImmediate(resolve)); + return new Response(bytes, { status: 200 }); + }, + }) + ) + ); + + assert.equal( + outcomes.filter((outcome) => outcome.status === "rejected").length, + 0, + outcomes + .filter((outcome) => outcome.status === "rejected") + .map((outcome) => String((outcome as PromiseRejectedResult).reason)) + .join("\n") + ); + const expectedPath = join(downloadDir, asset.file); + assert.deepEqual( + [...new Set(outcomes.map((outcome) => (outcome as PromiseFulfilledResult).value))], + [expectedPath] + ); + assert.ok(lstatSync(expectedPath).isFile()); + assert.deepEqual(readFileSync(expectedPath), bytes); + assert.deepEqual(readdirSync(downloadDir), [asset.file], "no lock or temporary may remain"); + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } +}); + +test("parallel processes share one verified install and leave no lock or temporary", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-cross-process-")); + const seedDir = join(rootDir, "seed"); + const downloadDir = join(rootDir, "cache"); + try { + const bytes = Buffer.from("verified-cross-process-native-binary"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + await import("node:fs/promises").then(({ mkdir }) => mkdir(seedDir, { recursive: true })); + writeFileSync(join(seedDir, asset.file), bytes); + + const moduleUrl = pathToFileURL( + join(ROOT, "open-sse", "services", "tlsClientDownloadDir.ts") + ).href; + const childSource = ` + import { resolveVerifiedTlsClientNativeLibrary } from ${JSON.stringify(moduleUrl)}; + const resolved = await resolveVerifiedTlsClientNativeLibrary({ + asset: ${JSON.stringify(asset)}, + downloadDir: ${JSON.stringify(downloadDir)}, + seedDirs: [${JSON.stringify(seedDir)}], + fetchImpl: async () => { throw new Error("network forbidden"); }, + }); + process.stdout.write(resolved); + `; + + const children = await Promise.all( + Array.from({ length: 12 }, () => + execFileAsync( + process.execPath, + ["--import", "tsx/esm", "--input-type=module", "--eval", childSource], + { cwd: ROOT, timeout: 90_000, maxBuffer: 1024 * 1024 } + ) + ) + ); + const expectedPath = join(downloadDir, asset.file); + assert.deepEqual([...new Set(children.map(({ stdout }) => stdout))], [expectedPath]); + assert.deepEqual(readdirSync(downloadDir), [asset.file]); + assert.ok(lstatSync(expectedPath).isFile()); + assert.deepEqual(readFileSync(expectedPath), bytes); + assert.equal(lstatSync(downloadDir).mode & 0o077, 0, "cache directory must be private"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("stream fallback sanitizes a request rejection before the first byte", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-stream-first-byte-error-")); + process.env.DATA_DIR = dataDir; + try { + const tlsFetchStreaming = await createTestTlsFetchStreaming({ + providerName: "test-provider", + tempDirPrefix: "tls-stream-first-byte-error-", + }); + + const result = await tlsFetchStreaming( + { + request: async () => { + throw new Error( + "TLS request rejected at /srv/omniroute/private/tls-client.ts:77:9; " + + "access_token=first-byte-secret\n" + + " at /srv/omniroute/private/stack-frame.ts:88:10" + ); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ); + + assert.equal(result.status, 502); + assert.equal(result.body, null); + assert.deepEqual([...result.headers], []); + assert.match(result.text ?? "", /TLS request rejected/); + assert.match(result.text ?? "", //); + assert.doesNotMatch(result.text ?? "", /srv\/omniroute|first-byte-secret|stack-frame\.ts/); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("stream fallback sanitizes a request rejection after invalid SSE bytes", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-stream-invalid-sse-error-")); + process.env.DATA_DIR = dataDir; + try { + const tlsFetchStreaming = await createTestTlsFetchStreaming({ + providerName: "test-provider", + tempDirPrefix: "tls-stream-invalid-sse-error-", + }); + + const result = await tlsFetchStreaming( + { + request: (_url, options) => { + const streamOutputPath = options.streamOutputPath; + assert.equal(typeof streamOutputPath, "string"); + writeFileSync(streamOutputPath, "invalid buffered payload"); + return Promise.reject( + new Error( + "TLS request rejected at C:\\OmniRoute\\private\\tls-client.ts:77:9; " + + "access_token=invalid-sse-secret\n" + + " at C:\\OmniRoute\\private\\stack-frame.ts:88:10" + ) + ); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ); + + assert.equal(result.status, 502); + assert.equal(result.body, null); + assert.deepEqual([...result.headers], []); + assert.match(result.text ?? "", /TLS request rejected/); + assert.match(result.text ?? "", //); + assert.doesNotMatch(result.text ?? "", /C:\\OmniRoute|invalid-sse-secret|stack-frame\.ts/); + + for (const emptyRejection of ["", null, undefined]) { + const emptyRejectionResult = await tlsFetchStreaming( + { + request: (_url, options) => { + const streamOutputPath = options.streamOutputPath; + assert.equal(typeof streamOutputPath, "string"); + writeFileSync( + streamOutputPath, + "invalid at /srv/private/invalid-sse.ts; access_token=file-secret" + ); + return Promise.reject(emptyRejection); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ); + + assert.equal(emptyRejectionResult.status, 502); + assert.equal(emptyRejectionResult.body, null); + assert.deepEqual([...emptyRejectionResult.headers], []); + assert.equal(emptyRejectionResult.text, "TLS client request failed"); + assert.doesNotMatch( + emptyRejectionResult.text, + /srv\/private|invalid-sse\.ts|file-secret|access_token/ + ); + } + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("variant B1 closes cleanly when a native request completes without an EOF marker", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-stream-b1-clean-close-")); + process.env.DATA_DIR = dataDir; + try { + const tlsFetchStreaming = await createTestTlsFetchStreaming({ + providerName: "test-provider-B1-clean-close", + tempDirPrefix: "tls-stream-b1-clean-close-", + tailFileVariant: "B1", + }); + + const result = await tlsFetchStreaming( + { + request: (_url, options) => { + const streamOutputPath = options.streamOutputPath; + assert.equal(typeof streamOutputPath, "string"); + writeFileSync(streamOutputPath, '{"delta":"ok"}\n'); + return Promise.resolve({ status: 204, headers: { "x-upstream": "present" }, body: "" }); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ); + + assert.equal(result.status, 200); + assert.equal(result.text, null); + assert.equal(result.headers.get("content-type"), "application/x-ndjson"); + assert.equal(result.headers.get("cache-control"), "no-cache"); + assert.ok(result.body); + assert.equal(await new Response(result.body).text(), '{"delta":"ok"}\n'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("stream boundary sanitizes a synchronous native request throw and cleans its temp path", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-stream-sync-throw-")); + process.env.DATA_DIR = dataDir; + let streamOutputPath = ""; + try { + const tlsFetchStreaming = await createTestTlsFetchStreaming({ + providerName: "test-provider-sync-throw", + tempDirPrefix: "tls-stream-sync-throw-", + }); + + const nativeError = new Error( + "native sync throw at /srv/private/native.ts:7; access_token=sync-secret" + ); + nativeError.name = "NativeTlsError"; + + await assert.rejects( + tlsFetchStreaming( + { + request: (_url, options) => { + assert.equal(typeof options.streamOutputPath, "string"); + streamOutputPath = options.streamOutputPath; + throw nativeError; + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.notEqual(err, nativeError); + assert.equal(err.name, "NativeTlsError"); + assert.match(err.message, /native sync throw/); + assert.match(err.message, //); + assert.doesNotMatch(err.message, /srv\/private|sync-secret|access_token/); + assert.equal(err.stack, `NativeTlsError: ${err.message}`); + assert.equal(err.cause, undefined); + return true; + } + ); + + assert.ok(streamOutputPath); + assert.equal(existsSync(streamOutputPath), false); + assert.equal(existsSync(dirname(streamOutputPath)), false); + } finally { + if (streamOutputPath) rmSync(dirname(streamOutputPath), { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("stream boundary sanitizes a pre-adapter read failure and cleans its temp root", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-stream-peek-error-")); + process.env.DATA_DIR = dataDir; + let streamOutputPath = ""; + try { + const tlsFetchStreaming = await createTestTlsFetchStreaming({ + providerName: "test-provider-peek-error", + tempDirPrefix: "tls-stream-peek-error-", + }); + + await assert.rejects( + tlsFetchStreaming( + { + request: (_url, options) => { + assert.equal(typeof options.streamOutputPath, "string"); + streamOutputPath = options.streamOutputPath; + mkdirSync(streamOutputPath); + return Promise.resolve({ status: 200, headers: {}, body: "" }); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "Error"); + assert.match(err.message, /EISDIR/); + assert.doesNotMatch(err.message, /tls-stream-peek-error|tlsClientBase\.ts|worktrees/); + assert.equal(err.stack, `Error: ${err.message}`); + assert.equal(err.cause, undefined); + return true; + } + ); + + assert.ok(streamOutputPath); + assert.equal(existsSync(streamOutputPath), false); + assert.equal(existsSync(dirname(streamOutputPath)), false); + } finally { + if (streamOutputPath) rmSync(dirname(streamOutputPath), { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("valid SSE streams sanitize terminal request rejections for every tail variant", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-stream-terminal-error-")); + process.env.DATA_DIR = dataDir; + try { + for (const tailFileVariant of ["A", "B1", "B2"] as const) { + const tlsFetchStreaming = await createTestTlsFetchStreaming({ + providerName: `test-provider-${tailFileVariant}`, + tempDirPrefix: `tls-stream-terminal-error-${tailFileVariant}-`, + tailFileVariant, + }); + + const nativeError = new Error( + "TLS request rejected at /srv/omniroute/private/tls-client.ts:77:9; " + + "access_token=stream-secret\n" + + " at /srv/omniroute/private/stack-frame.ts:88:10" + ); + nativeError.name = "NativeTlsError"; + + const result = await tlsFetchStreaming( + { + request: (_url, options) => { + const streamOutputPath = options.streamOutputPath; + assert.equal(typeof streamOutputPath, "string"); + writeFileSync(streamOutputPath, 'data: {"delta":"ok"}\n\n'); + return Promise.reject(nativeError); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ); + + assert.equal(result.status, 200, tailFileVariant); + assert.equal(result.text, null, tailFileVariant); + assert.ok(result.body, tailFileVariant); + const reader = result.body.getReader(); + const firstChunk = await reader.read(); + assert.equal(firstChunk.done, false, tailFileVariant); + assert.match(Buffer.from(firstChunk.value).toString("utf8"), /^data:/, tailFileVariant); + + await assert.rejects(reader.read(), (err: unknown) => { + assert.ok(err instanceof Error, tailFileVariant); + assert.notEqual(err, nativeError, tailFileVariant); + assert.equal(err.name, "NativeTlsError", tailFileVariant); + assert.match(err.message, /TLS request rejected/, tailFileVariant); + assert.doesNotMatch(err.message, /^NativeTlsError:/, tailFileVariant); + assert.match(err.message, //, tailFileVariant); + assert.doesNotMatch( + err.message, + /srv\/omniroute|stream-secret|stack-frame\.ts/, + tailFileVariant + ); + assert.doesNotMatch( + err.stack ?? "", + /srv\/omniroute|stream-secret|stack-frame\.ts|tlsClientBase\.ts/, + tailFileVariant + ); + assert.equal(err.cause, undefined, tailFileVariant); + return true; + }); + } + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("valid SSE streams use a stable terminal error for empty rejection values", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-stream-empty-error-")); + process.env.DATA_DIR = dataDir; + try { + const maliciousNamedEmptyError = new Error(""); + maliciousNamedEmptyError.name = + "NativeTlsError\naccess_token=name-secret /srv/private/error-name.ts"; + for (const tailFileVariant of ["A", "B1", "B2"] as const) { + for (const emptyRejection of [maliciousNamedEmptyError, "", null, undefined]) { + const tlsFetchStreaming = await createTestTlsFetchStreaming({ + providerName: `test-provider-${tailFileVariant}`, + tempDirPrefix: `tls-stream-empty-error-${tailFileVariant}-`, + tailFileVariant, + }); + + const result = await tlsFetchStreaming( + { + request: (_url, options) => { + const streamOutputPath = options.streamOutputPath; + assert.equal(typeof streamOutputPath, "string"); + writeFileSync(streamOutputPath, 'data: {"delta":"ok"}\n\n'); + return Promise.reject(emptyRejection); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ); + + assert.equal(result.status, 200, tailFileVariant); + assert.ok(result.body, tailFileVariant); + const reader = result.body.getReader(); + assert.equal((await reader.read()).done, false, tailFileVariant); + await assert.rejects(reader.read(), (err: unknown) => { + assert.ok(err instanceof Error, tailFileVariant); + assert.equal(err.name, "Error", tailFileVariant); + assert.equal(err.message, "TLS client request failed", tailFileVariant); + assert.equal(err.stack, "Error: TLS client request failed", tailFileVariant); + assert.doesNotMatch(err.stack, /name-secret|srv\/private|access_token/, tailFileVariant); + assert.equal(err.cause, undefined, tailFileVariant); + return true; + }); + } + } + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("runtime materializes a verified bundled seed without network access", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-offline-seed-")); + const seedDir = join(rootDir, "standalone", "runtime-assets", "tls-client", "bin"); + const downloadDir = join(rootDir, "writable-data", "tls-client", "bin"); + try { + const bytes = Buffer.from("verified-binary-bundled-in-the-standalone-artifact"); + const asset = { + file: "tls-client-linux-ubuntu-amd64-1.15.1.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + await import("node:fs/promises").then(({ mkdir }) => mkdir(seedDir, { recursive: true })); + writeFileSync(join(seedDir, asset.file), bytes); + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + + const resolved = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [seedDir], + fetchImpl: async () => { + throw new Error("offline runtime must use the bundled verified seed"); + }, + }); + + assert.equal(resolved, join(downloadDir, asset.file)); + assert.deepEqual(readFileSync(resolved), bytes); + assert.ok(lstatSync(resolved).isFile()); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("default seed search follows the installed tls-client-node package outside the CLI cwd", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-package-seed-")); + const packageDir = join(rootDir, "package", "node_modules", "tls-client-node"); + const seedDir = join(packageDir, "bin"); + const downloadDir = join(rootDir, "writable-data", "tls-client", "bin"); + try { + const bytes = Buffer.from("verified-binary-installed-by-the-optional-dependency"); + const asset = { + file: "tls-client-test-resolved-package.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + mkdirSync(seedDir, { recursive: true }); + writeFileSync(join(packageDir, "package.json"), '{"name":"tls-client-node"}\n'); + writeFileSync(join(seedDir, asset.file), bytes); + const { resolveVerifiedTlsClientNativeLibrary } = + await import("../../open-sse/services/tlsClientDownloadDir.ts"); + let networkReached = false; + + const resolved = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + fetchImpl: async () => { + networkReached = true; + throw new Error("offline CLI runtime must use the package-resolved verified seed"); + }, + testHooks: { + resolveTlsClientPackageJson: () => join(packageDir, "package.json"), + }, + }); + + assert.equal(networkReached, false); + assert.equal(resolved, join(downloadDir, asset.file)); + assert.deepEqual(readFileSync(resolved), bytes); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + test("all web-provider tls clients wire downloadDir through buildNativeTlsClientOptions (#8579)", () => { const base = readFileSync(join(ROOT, "open-sse/services/tlsClientBase.ts"), "utf8"); assert.match( base, - /resolveVerifiedTlsClientNativeLibrary\(\)/, + /dependencies\.resolveNativeLibrary\s*\?\?\s*resolveVerifiedTlsClientNativeLibrary/, "tlsClientBase.ts must verify the pinned native library before TLSClient loads it" ); assert.match( diff --git a/tests/unit/tls-client-install-lock-race.test.ts b/tests/unit/tls-client-install-lock-race.test.ts new file mode 100644 index 00000000000..721c290877f --- /dev/null +++ b/tests/unit/tls-client-install-lock-race.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { lstat, open, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +import { resolveVerifiedTlsClientNativeLibrary } from "../../open-sse/services/tlsClientDownloadDir"; + +const REPLACEMENT_LOCK_TOKEN = "00000000-0000-4000-8000-0000000000b2"; +const execFileAsync = promisify(execFile); + +test("runtime resolver retries when its newly-created install lock disappears before lstat", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-created-lock-race-")); + + try { + const bytes = Buffer.from("verified-binary-after-created-lock-disappears"); + const asset = { + file: "tls-client-created-lock-race.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const downloadDir = join(rootDir, "tls-client", "bin"); + let fetchCalls = 0; + let createdHookCalls = 0; + + const resolvedPath = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [], + fetchImpl: async () => { + fetchCalls += 1; + return new Response(bytes, { status: 200 }); + }, + testHooks: { + afterInstallLockCreated: async (lockPath) => { + createdHookCalls += 1; + await rm(lockPath, { recursive: true }); + }, + }, + }); + + assert.equal(resolvedPath, join(downloadDir, asset.file)); + assert.deepEqual(readFileSync(resolvedPath), bytes); + assert.equal(createdHookCalls, 1, "the scheduling hook must be consumed after one use"); + assert.equal(fetchCalls, 1); + assert.deepEqual(readdirSync(downloadDir), [asset.file], "no install lock may remain"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("runtime resolver never claims a replacement install lock created by another owner", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-replaced-lock-race-")); + + try { + const bytes = Buffer.from("verified-binary-after-replacement-owner-completes"); + const asset = { + file: "tls-client-replaced-lock-race.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const downloadDir = join(rootDir, "tls-client", "bin"); + let fetchCalls = 0; + let createdHookCalls = 0; + let contentionHookCalls = 0; + let replacementHandle: Awaited> | undefined; + + try { + const resolvedPath = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [], + fetchImpl: async () => { + fetchCalls += 1; + return new Response(bytes, { status: 200 }); + }, + testHooks: { + afterInstallLockCreated: async (lockPath) => { + createdHookCalls += 1; + await rm(lockPath); + replacementHandle = await open(lockPath, "wx", 0o600); + await replacementHandle.writeFile(REPLACEMENT_LOCK_TOKEN); + await replacementHandle.sync(); + }, + afterInstallLockExists: async (lockPath) => { + contentionHookCalls += 1; + assert.equal(fetchCalls, 0, "the displaced owner must not run the install operation"); + assert.equal(readFileSync(lockPath, "utf8"), REPLACEMENT_LOCK_TOKEN); + await replacementHandle?.close(); + replacementHandle = undefined; + await rm(lockPath); + }, + }, + }); + + assert.equal(resolvedPath, join(downloadDir, asset.file)); + assert.deepEqual(readFileSync(resolvedPath), bytes); + assert.equal(createdHookCalls, 1, "the scheduling hook must be consumed after one use"); + assert.equal(contentionHookCalls, 1, "the replacement lock must be observed as contended"); + assert.equal(fetchCalls, 1); + assert.deepEqual(readdirSync(downloadDir), [asset.file], "no install lock may remain"); + } finally { + await replacementHandle?.close(); + } + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("runtime resolver preserves another owner's same-inode lock claim during cleanup", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-live-lock-race-")); + + try { + const bytes = Buffer.from("verified-binary-while-another-owner-holds-the-lock"); + const asset = { + file: "tls-client-live-lock-race.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const downloadDir = join(rootDir, "tls-client", "bin"); + const lockPath = join(downloadDir, `${asset.file}.lock`); + let replacementIdentity: { dev: number | bigint; ino: number | bigint } | undefined; + let replacementHandle: Awaited> | undefined; + let fetchCalls = 0; + + try { + const resolvedPath = await resolveVerifiedTlsClientNativeLibrary({ + asset, + downloadDir, + seedDirs: [], + fetchImpl: async () => { + fetchCalls += 1; + replacementHandle = await open(lockPath, "r+"); + await replacementHandle.truncate(0); + await replacementHandle.writeFile(REPLACEMENT_LOCK_TOKEN); + await replacementHandle.sync(); + replacementIdentity = await replacementHandle.stat({ bigint: true }); + + return new Response(bytes, { status: 200 }); + }, + testHooks: { + afterInstallLockCreated: async (observedLockPath) => { + assert.equal(observedLockPath, lockPath); + }, + }, + }); + + assert.equal(resolvedPath, join(downloadDir, asset.file)); + assert.deepEqual(readFileSync(resolvedPath), bytes); + assert.equal(fetchCalls, 1, "the displaced owner must not duplicate the install operation"); + assert.ok(replacementIdentity, "the replacement owner must create a regular lockfile"); + const survivingStats = await lstat(lockPath, { bigint: true }); + assert.equal(survivingStats.isFile(), true); + assert.equal(String(survivingStats.dev), String(replacementIdentity.dev)); + assert.equal(String(survivingStats.ino), String(replacementIdentity.ino)); + assert.equal(readFileSync(lockPath, "utf8"), REPLACEMENT_LOCK_TOKEN); + } finally { + await replacementHandle?.close(); + await rm(lockPath, { force: true }); + } + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test( + "runtime resolver fails closed instead of blocking on a replacement FIFO lock", + { skip: process.platform === "win32" }, + async () => { + const rootDir = mkdtempSync(join(tmpdir(), "omniroute-tls-client-fifo-lock-race-")); + + try { + const bytes = Buffer.from("verified-binary-that-must-not-follow-a-fifo-lock"); + const asset = { + file: "tls-client-fifo-lock-race.so", + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + const downloadDir = join(rootDir, "tls-client", "bin"); + const moduleUrl = pathToFileURL( + join(process.cwd(), "open-sse", "services", "tlsClientDownloadDir.ts") + ).href; + const childSource = ` + import { execFileSync } from "node:child_process"; + import { rm } from "node:fs/promises"; + import { resolveVerifiedTlsClientNativeLibrary } from ${JSON.stringify(moduleUrl)}; + + try { + await resolveVerifiedTlsClientNativeLibrary({ + asset: ${JSON.stringify(asset)}, + downloadDir: ${JSON.stringify(downloadDir)}, + seedDirs: [], + fetchImpl: async () => { throw new Error("network must not be reached"); }, + testHooks: { + afterInstallLockCreated: async (lockPath) => { + await rm(lockPath); + execFileSync("mkfifo", [lockPath]); + }, + }, + }); + process.stdout.write("unexpected success"); + process.exitCode = 2; + } catch (err) { + process.stdout.write(err instanceof Error ? err.message : String(err)); + } + `; + + const { stdout } = await execFileAsync( + process.execPath, + ["--import", "tsx/esm", "--input-type=module", "--eval", childSource], + { cwd: process.cwd(), timeout: 8_000, maxBuffer: 1024 * 1024 } + ); + + assert.match(stdout, /Unsafe tls-client install lock/); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + } +); diff --git a/tests/unit/tls-client-lifecycle.test.ts b/tests/unit/tls-client-lifecycle.test.ts new file mode 100644 index 00000000000..69a13201d62 --- /dev/null +++ b/tests/unit/tls-client-lifecycle.test.ts @@ -0,0 +1,873 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const originalDataDir = process.env.DATA_DIR; +const lifecycleDataDir = mkdtempSync(join(tmpdir(), "omniroute-tls-lifecycle-")); +const processExitHooks: Array<() => void> = []; +const installProcessExitHook = (hook: () => void): void => { + processExitHooks.push(hook); +}; +process.env.DATA_DIR = lifecycleDataDir; + +function hostileTlsError(): Error { + return new Proxy( + new Error("native failure at /srv/private/tls-client.ts:7; access_token=tls-hostile-secret"), + { + getPrototypeOf() { + throw new Error("tls-prototype-secret"); + }, + } + ); +} + +after(() => { + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + rmSync(lifecycleDataDir, { recursive: true, force: true }); +}); + +test("client initialization retries after a transient verifier failure without exposing details", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let verificationAttempts = 0; + let starts = 0; + + class FakeTlsClient { + constructor(_options: Record) {} + async start() { + starts++; + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: "ok" }; + } + async stop() {} + } + + const getClient = createGetClient( + { providerName: "test-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + installExitHook: installProcessExitHook, + resolveNativeLibrary: async () => { + verificationAttempts++; + if (verificationAttempts === 1) { + throw { + toString() { + throw new Error( + "EACCES /srv/private/tls/cache; api_key=sk-super-secret\n at /srv/private/stack.ts:1" + ); + }, + }; + } + return "/verified/retry-verifier.so"; + }, + } + ); + + await assert.rejects(getClient(), (err: unknown) => { + assert.ok(err instanceof TlsClientUnavailableError); + assert.match(err.message, /verification failed for test-provider/); + assert.doesNotMatch(err.message, /srv\/private|sk-super-secret|stack\.ts/); + return true; + }); + + const client = await getClient(); + assert.equal((await client.request("https://example.test", {})).status, 200); + assert.equal(verificationAttempts, 2); + assert.equal(starts, 1); +}); + +test("client initialization sanitizes start failures, stops the partial client, and retries", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let starts = 0; + let stops = 0; + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + starts++; + if (this.instance === 1) { + throw hostileTlsError(); + } + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: "ok" }; + } + async stop() { + stops++; + } + } + + const getClient = createGetClient( + { providerName: "start-failure-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + installExitHook: installProcessExitHook, + resolveNativeLibrary: async () => "/verified/start-failure.so", + } + ); + + await assert.rejects(getClient(), (err: unknown) => { + assert.ok(err instanceof TlsClientUnavailableError); + assert.match(err.message, /initialization failed for start-failure-provider/i); + assert.doesNotMatch(err.message, /srv\/private|sk-super-secret|stack\.ts/); + return true; + }); + assert.equal(stops, 1, "a partially started client must be stopped before retry"); + + const client = await getClient(); + assert.equal((await client.request("https://example.test", {})).status, 200); + assert.equal(instances, 2); + assert.equal(starts, 2); + assert.equal(stops, 1); +}); + +test("client initialization bounds a hung native start and remains retryable", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let starts = 0; + let stops = 0; + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + starts++; + if (this.instance === 1) { + await new Promise(() => {}); + } + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: "ok" }; + } + async stop() { + stops++; + } + } + + const getClient = createGetClient( + { providerName: "hung-start-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + installExitHook: installProcessExitHook, + resolveNativeLibrary: async () => "/verified/hung-start.so", + startTimeoutMs: 10, + cleanupTimeoutMs: 10, + } + ); + + let watchdog: ReturnType | undefined; + try { + await assert.rejects( + Promise.race([ + getClient(), + new Promise((_, reject) => { + watchdog = setTimeout( + () => reject(new Error("getClient exceeded the native start budget")), + 250 + ); + }), + ]), + (err: unknown) => { + assert.ok(err instanceof TlsClientUnavailableError); + assert.match(err.message, /initialization failed for hung-start-provider/i); + return true; + } + ); + } finally { + if (watchdog) clearTimeout(watchdog); + } + + assert.equal(stops, 0, "a still-pending start must retain safe backend ownership"); + const client = await getClient(); + assert.equal((await client.request("https://example.test", {})).status, 200); + assert.equal(instances, 2); + assert.equal(starts, 2); + assert.equal(stops, 0); +}); + +test("a late discarded start cannot destroy the shared backend of its active successor", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let resolveLateStart: (() => void) | undefined; + let destroyAllCalls = 0; + const backendSessions = new Set(); + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + if (this.instance === 1) { + await new Promise((resolve) => { + resolveLateStart = resolve; + }); + } + backendSessions.add(this.instance); + } + async request(_url: string, _options: Record) { + if (!backendSessions.has(this.instance)) throw new Error("shared backend was destroyed"); + return { status: 200, headers: {}, body: `ok-${this.instance}` }; + } + async stop() { + destroyAllCalls++; + backendSessions.clear(); + } + } + + const getClient = createGetClient( + { providerName: "late-start-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + installExitHook: installProcessExitHook, + resolveNativeLibrary: async () => "/verified/late-start-after-successor.so", + startTimeoutMs: 10, + cleanupTimeoutMs: 50, + } + ); + + await assert.rejects(getClient(), TlsClientUnavailableError); + assert.equal(destroyAllCalls, 0); + + const secondClient = await getClient(); + assert.equal(secondClient.instance, 2); + assert.equal((await secondClient.request("https://example.test", {})).body, "ok-2"); + + assert.ok(resolveLateStart); + resolveLateStart(); + for (let attempt = 0; attempt < 10 && !backendSessions.has(1); attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + } + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(destroyAllCalls, 0, "late cleanup must transfer ownership to the successor"); + assert.equal((await secondClient.request("https://example.test", {})).body, "ok-2"); + assert.equal(await getClient(), secondClient); + + await getClient.invalidate(secondClient); + assert.equal(destroyAllCalls, 1, "the final owner must clean the shared backend exactly once"); +}); + +test("a late start settled before retry is cleaned before the successor starts", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let resolveLateStart: (() => void) | undefined; + let destroyAllCalls = 0; + const events: string[] = []; + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + events.push(`start-${this.instance}`); + if (this.instance === 1) { + await new Promise((resolve) => { + resolveLateStart = resolve; + }); + } + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: `ok-${this.instance}` }; + } + async stop() { + events.push(`stop-${this.instance}`); + destroyAllCalls++; + } + } + + const getClient = createGetClient( + { providerName: "late-start-before-retry-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + installExitHook: installProcessExitHook, + resolveNativeLibrary: async () => "/verified/late-start-before-retry.so", + startTimeoutMs: 10, + cleanupTimeoutMs: 50, + } + ); + + await assert.rejects(getClient(), TlsClientUnavailableError); + assert.equal(destroyAllCalls, 0); + assert.ok(resolveLateStart); + resolveLateStart(); + for (let attempt = 0; attempt < 10 && destroyAllCalls !== 1; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(destroyAllCalls, 1); + + const secondClient = await getClient(); + assert.deepEqual(events.slice(0, 3), ["start-1", "stop-1", "start-2"]); + assert.equal((await secondClient.request("https://example.test", {})).body, "ok-2"); + await getClient.invalidate(secondClient); + assert.equal(destroyAllCalls, 2); +}); + +test("client initialization fails closed while a raw native cleanup remains hung", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let starts = 0; + let stops = 0; + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + starts++; + if (this.instance === 1) { + throw new Error( + "dlopen EACCES /Users/private/tls cache/native.dylib; api_key=sk-super-secret\n" + + " at /Users/private/stack.ts:1" + ); + } + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: "ok" }; + } + async stop() { + stops++; + if (this.instance === 1) { + await new Promise(() => {}); + } + } + } + + const getClient = createGetClient( + { providerName: "hung-cleanup-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + installExitHook: installProcessExitHook, + resolveNativeLibrary: async () => "/verified/tls-client.dylib", + cleanupTimeoutMs: 10, + } + ); + + let watchdog: ReturnType | undefined; + try { + await assert.rejects( + Promise.race([ + getClient(), + new Promise((_, reject) => { + watchdog = setTimeout( + () => reject(new Error("getClient exceeded the bounded cleanup budget")), + 250 + ); + }), + ]), + (err: unknown) => { + assert.ok(err instanceof TlsClientUnavailableError); + assert.match(err.message, /initialization failed for hung-cleanup-provider/i); + assert.doesNotMatch(err.message, /Users\/private|sk-super-secret|stack\.ts|native\.dylib/); + return true; + } + ); + } finally { + if (watchdog) clearTimeout(watchdog); + } + + await assert.rejects(getClient(), (err: unknown) => { + assert.ok(err instanceof TlsClientUnavailableError); + assert.equal(err.message, "tls-client native initialization failed for hung-cleanup-provider"); + return true; + }); + assert.equal(instances, 2); + assert.equal(starts, 1, "a new native owner must not start across an unknown cleanup"); + assert.equal(stops, 1); +}); + +test("managed client invalidation stops once, stays bounded, and preserves one exit hook", async () => { + const { createGetClient } = await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + const starts = new Map(); + const stops = new Map(); + let resolveFirstStop: (() => void) | undefined; + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + starts.set(this.instance, (starts.get(this.instance) ?? 0) + 1); + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: `ok-${this.instance}` }; + } + async stop() { + stops.set(this.instance, (stops.get(this.instance) ?? 0) + 1); + if (this.instance === 1) { + await new Promise((resolve) => { + resolveFirstStop = resolve; + }); + } + } + } + + const getClient = createGetClient( + { providerName: "managed-lifecycle-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + resolveNativeLibrary: async () => "/verified/managed-lifecycle.so", + cleanupTimeoutMs: 100, + installExitHook: installProcessExitHook, + } + ); + + const firstClient = await getClient(); + assert.equal(starts.get(1), 1); + assert.equal(processExitHooks.length, 1); + + let watchdog: ReturnType | undefined; + try { + await Promise.race([ + Promise.all([ + getClient.invalidate(firstClient), + getClient.invalidate(firstClient), + getClient.invalidate(firstClient), + ]), + new Promise((_, reject) => { + watchdog = setTimeout( + () => reject(new Error("managed invalidation exceeded the cleanup budget")), + 250 + ); + }), + ]); + } finally { + if (watchdog) clearTimeout(watchdog); + } + + assert.equal(stops.get(1), 1, "concurrent invalidations must share one bounded stop"); + const secondClientPromise = getClient(); + for (let attempt = 0; attempt < 10 && instances !== 2; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(instances, 2); + assert.equal(starts.get(2), undefined, "the successor must wait for the raw cleanup barrier"); + assert.ok(resolveFirstStop); + resolveFirstStop(); + const secondClient = await secondClientPromise; + assert.notEqual(secondClient, firstClient); + assert.equal(starts.get(2), 1); + assert.equal(processExitHooks.length, 1, "reinitialization must reuse the process exit hook"); + + await getClient.invalidate(firstClient); + assert.equal( + await getClient(), + secondClient, + "a stale invalidation must not evict the new client" + ); + assert.equal(stops.get(1), 1); + assert.equal(stops.get(2), undefined); + + await getClient.invalidate(secondClient); + assert.equal(stops.get(2), 1, "the final owner must be stopped once"); +}); + +test("independent getters share backend ownership and destroy it only after the final release", async () => { + const { createGetClient } = await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let destroyAllCalls = 0; + const backendSessions = new Set(); + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + backendSessions.add(this.instance); + } + async request(_url: string, _options: Record) { + if (!backendSessions.has(this.instance)) throw new Error("shared backend was destroyed"); + return { status: 200, headers: {}, body: `ok-${this.instance}` }; + } + async stop() { + destroyAllCalls++; + backendSessions.clear(); + } + } + + const dependencies = { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + resolveNativeLibrary: async () => "/verified/shared-provider-backend.so", + }; + const getClientA = createGetClient( + { providerName: "shared-provider-a", tlsProfile: "chrome_146" }, + { ...dependencies, installExitHook: installProcessExitHook } + ); + const getClientB = createGetClient( + { providerName: "shared-provider-b", tlsProfile: "chrome_146" }, + { ...dependencies, installExitHook: installProcessExitHook } + ); + + const clientA1 = await getClientA(); + const clientB = await getClientB(); + await getClientA.invalidate(clientA1); + assert.equal(destroyAllCalls, 0, "releasing provider A must not destroy provider B"); + assert.equal((await clientB.request("https://example.test", {})).body, "ok-2"); + + const clientA2 = await getClientA(); + assert.equal(processExitHooks.length, 1); + await getClientA.invalidate(clientA2); + assert.equal(destroyAllCalls, 0, "releasing provider A must preserve the other owner"); + assert.equal((await clientB.request("https://example.test", {})).body, "ok-2"); + await getClientB.invalidate(clientB); + assert.equal(destroyAllCalls, 1, "the final owner release must destroy the backend once"); + + await getClientA.invalidate(clientA2); + await getClientB.invalidate(clientB); + assert.equal(destroyAllCalls, 1, "repeated invalidations must not destroy the backend again"); + assert.equal(clientA2.instance, 3); +}); + +test("the final pending rejection uses the preserved active cleanup candidate", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let destroyAllCalls = 0; + let pendingStopCalls = 0; + let rejectPendingStart: ((error: Error) => void) | undefined; + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + if (this.instance === 2) { + await new Promise((_resolve, reject) => { + rejectPendingStart = reject; + }); + } + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: `ok-${this.instance}` }; + } + async stop() { + if (this.instance === 1) destroyAllCalls++; + else pendingStopCalls++; + } + } + + const dependencies = { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + installExitHook: installProcessExitHook, + resolveNativeLibrary: async () => "/verified/preserved-cleanup-candidate.so", + }; + const getActiveClient = createGetClient( + { providerName: "cleanup-candidate-active", tlsProfile: "chrome_146" }, + dependencies + ); + const getPendingClient = createGetClient( + { providerName: "cleanup-candidate-pending", tlsProfile: "chrome_146" }, + { ...dependencies, startTimeoutMs: 10 } + ); + + const activeClient = await getActiveClient(); + await assert.rejects(getPendingClient(), TlsClientUnavailableError); + await getActiveClient.invalidate(activeClient); + assert.equal(destroyAllCalls, 0); + assert.equal(pendingStopCalls, 0); + + assert.ok(rejectPendingStart); + rejectPendingStart(new Error("late start rejection at /srv/private/native.ts")); + for (let attempt = 0; attempt < 10 && destroyAllCalls !== 1; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + } + + assert.equal(destroyAllCalls, 1, "the preserved active owner must perform destroyAll"); + assert.equal(pendingStopCalls, 0, "a never-active pending owner is not a cleanup candidate"); +}); + +test("a rejected raw cleanup poisons the native path and blocks successor starts", async () => { + const { createGetClient, TlsClientUnavailableError } = + await import("../../open-sse/services/tlsClientBase.ts"); + let instances = 0; + let starts = 0; + let stops = 0; + + class FakeTlsClient { + readonly instance = ++instances; + constructor(_options: Record) {} + async start() { + starts++; + } + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: "ok" }; + } + async stop() { + stops++; + throw new Error("destroyAll failed at /srv/private/native.ts; access_token=stop-secret"); + } + } + + const getClient = createGetClient( + { providerName: "poisoned-cleanup-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + resolveNativeLibrary: async () => "/verified/poisoned-cleanup.so", + installExitHook: installProcessExitHook, + } + ); + + const firstClient = await getClient(); + await getClient.invalidate(firstClient); + assert.equal(stops, 1); + + await assert.rejects(getClient(), (err: unknown) => { + assert.ok(err instanceof TlsClientUnavailableError); + assert.equal( + err.message, + "tls-client native initialization failed for poisoned-cleanup-provider" + ); + assert.doesNotMatch(err.message, /srv\/private|stop-secret|native\.ts/); + return true; + }); + assert.equal(instances, 2); + assert.equal(starts, 1, "a poisoned path must reject before the successor calls start"); + assert.equal(stops, 1); +}); + +test("owner reservation cannot cross a cleanup started in the former await window", async () => { + const { + acquireNativeTlsClientLease: _processAcquire, + activateNativeTlsClientLease, + createNativeTlsClientLeaseAcquirerForTesting, + releaseNativeTlsClientLease, + } = await import("../../open-sse/services/tlsClientLifecycleRegistry.ts"); + let acquisition = 0; + let destroyAllCalls = 0; + let successorStarts = 0; + let previousLease: Awaited> | undefined; + let resolveRawCleanup: (() => void) | undefined; + const releasePromises: Promise[] = []; + + const acquire = createNativeTlsClientLeaseAcquirerForTesting(async () => { + acquisition++; + if (acquisition !== 2) return; + assert.ok(previousLease); + releasePromises.push(releaseNativeTlsClientLease(previousLease, 1_000)); + assert.equal(destroyAllCalls, 1, "the former last owner must begin raw destroyAll"); + }); + + previousLease = await acquire( + "/verified/atomic-owner-reservation.so", + async () => { + destroyAllCalls++; + await new Promise((resolve) => { + resolveRawCleanup = resolve; + }); + }, + 1_000 + ); + activateNativeTlsClientLease(previousLease); + + const successorPromise = acquire( + "/verified/atomic-owner-reservation.so", + async () => {}, + 1_000 + ).then((lease) => { + successorStarts++; + activateNativeTlsClientLease(lease); + return lease; + }); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(successorStarts, 0, "a successor must not start inside raw destroyAll"); + assert.ok(resolveRawCleanup); + resolveRawCleanup(); + + const successorLease = await successorPromise; + await Promise.all(releasePromises); + assert.equal(successorStarts, 1); + await releaseNativeTlsClientLease(successorLease, 1_000); +}); + +test("the exit hook invokes stop synchronously and absorbs a synchronous native throw", async () => { + const { createGetClient } = await import("../../open-sse/services/tlsClientBase.ts"); + let stopCalls = 0; + + class FakeTlsClient { + constructor(_options: Record) {} + async start() {} + async request(_url: string, _options: Record) { + return { status: 200, headers: {}, body: "ok" }; + } + stop(): Promise { + stopCalls++; + throw new Error("native stop failed at /srv/private/native.ts; access_token=exit-secret"); + } + } + + const getClient = createGetClient( + { providerName: "exit-hook-provider", tlsProfile: "chrome_146" }, + { + loadTlsClient: async () => ({ TLSClient: FakeTlsClient }), + resolveNativeLibrary: async () => "/verified/exit-hook.so", + installExitHook: installProcessExitHook, + } + ); + + await getClient(); + assert.equal(processExitHooks.length, 1); + assert.doesNotThrow(() => processExitHooks[0]?.()); + assert.equal(stopCalls, 1, "stop must be invoked before the exit hook returns"); + assert.doesNotThrow(() => processExitHooks[0]?.()); + assert.equal(stopCalls, 1, "the process exit hook must release each path at most once"); +}); + +test("streaming and non-streaming requests tolerate hostile prototype inspection", async (t) => { + const { createTlsClientModule } = await import("../../open-sse/services/tlsClientBase.ts"); + const module = createTlsClientModule({ + providerName: "hostile-request-provider", + tlsProfile: "chrome_146", + domain: "https://example.test", + tempDirPrefix: "tls-hostile-request-", + tailFileVariant: "A", + responseValidation: "sse", + exportCloudflareCheck: false, + exposeStreamingForTesting: true, + }); + + await t.test("streaming fallback is sanitized and cleaned", async () => { + let streamPath = ""; + const result = await module.__tlsFetchStreamingForTesting( + { + request: (_url, options) => { + streamPath = String(options.streamOutputPath); + return Promise.reject(hostileTlsError()); + }, + }, + "https://example.test/stream", + {}, + "[DONE]", + null, + 1_000, + 100 + ); + assert.equal(result.status, 502); + assert.equal(result.text, "TLS client request failed"); + assert.doesNotMatch(result.text, /tls-(?:hostile|prototype)-secret|srv\/private/); + assert.equal(existsSync(streamPath), false); + assert.equal(existsSync(dirname(streamPath)), false); + }); + + await t.test( + "non-streaming rejection preserves the unknown for the public boundary", + async () => { + const hostile = hostileTlsError(); + assert.ok(module.__tlsFetchNonStreamingForTesting); + await assert.rejects( + module.__tlsFetchNonStreamingForTesting( + { request: async () => Promise.reject(hostile) }, + "https://example.test/non-stream", + {}, + null, + 1_000 + ), + (err: unknown) => err === hostile + ); + } + ); +}); + +test("TLS stream error projection fails closed for hostile prototype and metadata", async () => { + const { + createSanitizedTlsStreamError, + createTlsClientTailStream, + sanitizeTlsClientErrorMessage, + } = await import("../../open-sse/services/tlsClientStream.ts"); + const hostileMetadata = new Error(""); + Object.defineProperties(hostileMetadata, { + message: { + get() { + throw new Error("tls-message-secret"); + }, + }, + name: { + get() { + throw new Error("tls-name-secret"); + }, + }, + }); + + for (const hostile of [hostileTlsError(), hostileMetadata]) { + assert.equal(sanitizeTlsClientErrorMessage(hostile), "TLS client request failed"); + const projected = createSanitizedTlsStreamError(hostile); + assert.equal(projected.name, "Error"); + assert.equal(projected.message, "TLS client request failed"); + assert.equal(projected.stack, "Error: TLS client request failed"); + } + + const alternatingMetadata = new Error("safe message"); + let nameReads = 0; + Object.defineProperty(alternatingMetadata, "name", { + get() { + nameReads += 1; + return nameReads === 1 ? "Error" : "access_token=alternating-secret /srv/private/x.ts"; + }, + }); + const alternatingProjection = createSanitizedTlsStreamError(alternatingMetadata); + assert.equal(alternatingProjection.name, "Error"); + assert.equal(alternatingProjection.stack, "Error: safe message"); + + const credentialName = new Error("safe message"); + credentialName.name = "sk_abcdefghijklmnopqrstuvwxyz"; + const credentialProjection = createSanitizedTlsStreamError(credentialName); + assert.equal(credentialProjection.name, "Error"); + assert.equal(credentialProjection.stack, "Error: safe message"); + + const internalName = new Error("safe message"); + internalName.name = "InternalUpstreamError"; + const internalProjection = createSanitizedTlsStreamError(internalName); + assert.equal(internalProjection.name, "Error"); + assert.equal(internalProjection.stack, "Error: safe message"); + + for (const safeName of [ + "Error", + "AbortError", + "TimeoutError", + "BodyTimeoutError", + "TlsClientHangError", + "NativeTlsError", + ]) { + const namedError = new Error("safe message"); + namedError.name = safeName; + assert.equal(createSanitizedTlsStreamError(namedError).name, safeName); + } + + for (const variant of ["A", "B1", "B2"] as const) { + const dir = mkdtempSync(join(tmpdir(), `tls-hostile-done-${variant}-`)); + const path = join(dir, "body.sse"); + writeFileSync(path, 'data: {"delta":"ok"}\n\n'); + let rejectDone: ((reason: unknown) => void) | undefined; + const done = new Promise((_resolve, reject) => { + rejectDone = reject; + }); + const reader = createTlsClientTailStream({ + variant, + path, + eofSymbol: "[DONE]", + done, + signal: null, + }).getReader(); + assert.equal((await reader.read()).done, false, variant); + assert.ok(rejectDone); + rejectDone(hostileTlsError()); + await assert.rejects(reader.read(), (err: unknown) => { + assert.ok(err instanceof Error, variant); + assert.equal(err.name, "Error", variant); + assert.equal(err.message, "TLS client request failed", variant); + assert.equal(err.stack, "Error: TLS client request failed", variant); + assert.doesNotMatch(err.stack, /tls-(?:hostile|prototype)-secret|srv\/private/, variant); + return true; + }); + reader.releaseLock(); + for (let attempt = 0; attempt < 50 && existsSync(dir); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(existsSync(dir), false, variant); + } +}); diff --git a/tests/unit/tls-client-node-docker-binary-7802.test.ts b/tests/unit/tls-client-node-docker-binary-7802.test.ts index 6a1cb171f51..83c07fa096f 100644 --- a/tests/unit/tls-client-node-docker-binary-7802.test.ts +++ b/tests/unit/tls-client-node-docker-binary-7802.test.ts @@ -7,8 +7,52 @@ 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)", () => { +function getDockerStage(dockerfile: string, stageName: string): string { + const stageStartPattern = new RegExp(`^FROM\\s+.+\\s+AS\\s+${stageName}\\s*$`, "m"); + const stageStart = dockerfile.search(stageStartPattern); + + assert.notEqual(stageStart, -1, `Dockerfile.bun must define the ${stageName} stage`); + + const nextStageOffset = dockerfile.slice(stageStart + 1).search(/^FROM\s+/m); + const stageEnd = nextStageOffset === -1 ? dockerfile.length : stageStart + 1 + nextStageOffset; + + return dockerfile.slice(stageStart, stageEnd); +} + +function assertStrictTlsClientBinaryChain( + dockerfile: string, + dockerfileName: string, + buildCommand: string +) { + assert.doesNotMatch( + dockerfile, + /(?:node|bun)\s+node_modules\/tls-client-node\/scripts\/postinstall\.js/, + `${dockerfileName} must not bypass checksum verification by invoking the upstream downloader directly` + ); + + const rootRepair = dockerfile.indexOf("scripts/build/fixTlsClientNodeBinary.mjs --strict"); + const build = dockerfile.indexOf(buildCommand); + const standaloneRepair = dockerfile.search( + /scripts\/build\/fixTlsClientNodeBinary\.mjs --strict\s*\\?\s*--standalone-dir \.build\/next\/standalone/ + ); + + assert.notEqual(rootRepair, -1, `${dockerfileName} must strictly repair the root native binary`); + assert.notEqual(build, -1, `${dockerfileName} must contain its expected build command`); + assert.notEqual( + standaloneRepair, + -1, + `${dockerfileName} must strictly verify the post-build standalone native binary` + ); + assert.ok(rootRepair < build, `${dockerfileName} must repair the root binary before build`); + assert.ok( + build < standaloneRepair, + `${dockerfileName} must verify the standalone binary after build` + ); +} + +test("Docker images fail closed around tls-client-node's native binary (#7802)", () => { const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8"); + const bunDockerfile = readFileSync(join(ROOT, "Dockerfile.bun"), "utf8"); const postinstall = readFileSync(join(ROOT, "scripts/build/postinstall.mjs"), "utf8"); assert.match( @@ -42,17 +86,35 @@ test("Dockerfile's --ignore-scripts npm ci is compensated for tls-client-node's /COPY open-sse\/config\/tlsClientNativeManifest\.json \.\/open-sse\/config\/tlsClientNativeManifest\.json/, "Docker builder must copy the pinned version and official SHA-256 manifest" ); + assertStrictTlsClientBinaryChain(dockerfile, "Dockerfile", "npm run build"); + assertStrictTlsClientBinaryChain(bunDockerfile, "Dockerfile.bun", "bun run --quiet build"); + + const bunStandaloneCopy = bunDockerfile + .split(/\r?\n/) + .find((line) => line.includes("/app/.build/next/standalone")); + assert.ok(bunStandaloneCopy, "Dockerfile.bun must copy the verified standalone runtime"); assert.match( - dockerfile, - /node scripts\/build\/fixTlsClientNodeBinary\.mjs --strict/, - "Docker build must fail closed when the pinned native binary is absent or unverified" + bunStandaloneCopy, + /\s--chown=bun:bun(?:\s|$)/, + "Dockerfile.bun must make the verified TLS seed owned by USER bun" ); - assert.doesNotMatch( - dockerfile, - /node node_modules\/tls-client-node\/scripts\/postinstall\.js/, - "Docker must not bypass checksum verification by invoking the upstream downloader directly" + assert.match( + bunDockerfile, + /RUN mkdir -p \/app\/data\s*\\?\s*&& chown -R bun:bun \/app\/data/, + "Dockerfile.bun must let USER bun materialize the verified seed under DATA_DIR" ); + for (const [dockerfileName, contents] of [ + ["Dockerfile", dockerfile], + ["Dockerfile.bun", bunDockerfile], + ]) { + assert.doesNotMatch( + contents, + /org\.opencontainers\.image\.licenses\s*=/, + `${dockerfileName} must not claim a single SPDX license for its multi-license image` + ); + } + const dockerfileHandlesIt = /tls-client-node[\s\S]{0,200}(postinstall|rebuild|download)/i.test( dockerfile ); @@ -67,3 +129,32 @@ test("Dockerfile's --ignore-scripts npm ci is compensated for tls-client-node's "all fail with TlsClientUnavailableError at first request (#7802)" ); }); + +test("Dockerfile.bun production stages run the application as USER bun", () => { + const bunDockerfile = readFileSync(join(ROOT, "Dockerfile.bun"), "utf8"); + const runnerBase = getDockerStage(bunDockerfile, "runner-base"); + const runnerWeb = getDockerStage(bunDockerfile, "runner-web"); + + const baseAptInstall = runnerBase.indexOf("RUN apt-get update"); + const baseLastCopy = runnerBase.lastIndexOf("COPY "); + const baseUserBun = runnerBase.search(/^USER\s+bun\s*$/m); + const baseHealthcheck = runnerBase.indexOf("HEALTHCHECK"); + const baseEntrypoint = runnerBase.indexOf("ENTRYPOINT"); + + assert.notEqual(baseAptInstall, -1, "runner-base must install its runtime packages"); + assert.notEqual(baseLastCopy, -1, "runner-base must copy its runtime artifacts"); + assert.notEqual(baseUserBun, -1, "runner-base must explicitly drop privileges to USER bun"); + assert.notEqual(baseHealthcheck, -1, "runner-base must define its healthcheck"); + assert.notEqual(baseEntrypoint, -1, "runner-base must define its entrypoint"); + assert.ok(baseAptInstall < baseUserBun, "runner-base must drop privileges after apt install"); + assert.ok(baseLastCopy < baseUserBun, "runner-base must drop privileges after artifact copies"); + assert.ok(baseUserBun < baseHealthcheck, "runner-base must be USER bun before HEALTHCHECK"); + assert.ok(baseUserBun < baseEntrypoint, "runner-base must be USER bun before ENTRYPOINT"); + + const webAptInstall = runnerWeb.indexOf("RUN apt-get update"); + const webUserBun = runnerWeb.search(/^USER\s+bun\s*$/m); + + assert.notEqual(webAptInstall, -1, "runner-web must install its browser packages"); + assert.notEqual(webUserBun, -1, "runner-web must return to USER bun"); + assert.ok(webAptInstall < webUserBun, "runner-web must return to USER bun after apt install"); +}); diff --git a/tests/unit/tls-client-timeout.test.ts b/tests/unit/tls-client-timeout.test.ts new file mode 100644 index 00000000000..9cab90fda71 --- /dev/null +++ b/tests/unit/tls-client-timeout.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; + +import { raceWithTimeout, TlsClientHangError } from "../../open-sse/services/tlsClientTimeout.ts"; + +const TIMEOUT_MODULE_URL = pathToFileURL( + resolve(process.cwd(), "open-sse/services/tlsClientTimeout.ts") +).href; + +function runExitProbe(body: string) { + return spawnSync( + process.execPath, + [ + "--import", + "tsx/esm", + "--input-type=module", + "--eval", + `const { raceWithTimeout } = await import(${JSON.stringify(TIMEOUT_MODULE_URL)}); ${body}`, + ], + { + cwd: process.cwd(), + encoding: "utf8", + timeout: 10_000, + } + ); +} + +test("raceWithTimeout clears its timer after a no-signal promise settles", () => { + const result = runExitProbe( + 'await raceWithTimeout(Promise.resolve("ok"), 30_000, null); console.log("settled");' + ); + + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /settled/); +}); + +test("raceWithTimeout clears its timer when passed an already-aborted signal", () => { + const result = runExitProbe( + [ + "const controller = new AbortController();", + 'controller.abort(new Error("stop"));', + "await raceWithTimeout(new Promise(() => {}), 30_000, controller.signal).catch(() => {});", + 'console.log("aborted");', + ].join(" ") + ); + + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /aborted/); +}); + +test("raceWithTimeout returns a safe actionable timeout error", async () => { + await assert.rejects(raceWithTimeout(new Promise(() => {}), 1, null), (error: unknown) => { + assert.ok(error instanceof TlsClientHangError); + assert.match(error.message, /timed out/i); + return true; + }); +}); + +test("raceWithTimeout rejects safely when abort reason prototype inspection throws", async () => { + const controller = new AbortController(); + const hostileReason = new Proxy(new Error("access_token=abort-secret at /srv/abort.ts"), { + getPrototypeOf() { + throw new Error("abort-prototype-secret"); + }, + }); + const raced = raceWithTimeout(new Promise(() => {}), 30_000, controller.signal); + controller.abort(hostileReason); + await assert.rejects( + Promise.race([ + raced, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("abort did not settle")), 250) + ), + ]), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.name, "AbortError"); + assert.equal(error.message, "The operation was aborted"); + assert.doesNotMatch(error.stack ?? "", /abort-(?:secret|prototype)|srv\/abort/); + return true; + } + ); +}); diff --git a/tests/unit/types-barrel-model-cooldown.test.ts b/tests/unit/types-barrel-model-cooldown.test.ts index d1f156925ba..76b78f8b0df 100644 --- a/tests/unit/types-barrel-model-cooldown.test.ts +++ b/tests/unit/types-barrel-model-cooldown.test.ts @@ -62,6 +62,26 @@ test("model cooldown body omits retry_after / credentials_cooling when absent or ); }); +test("model cooldown body projects hostile model and retry timestamp fields", async () => { + const { buildModelCooldownBody } = await import("../../open-sse/utils/error.ts"); + const secret = "COOLDOWN_SECRET"; + const body = buildModelCooldownBody({ + model: `model access_token=${secret} /home/alice/model.ts`, + retryAfterSec: Number.NaN, + retryAfterAt: `2026-07-07T12:34:56.000Z password=${secret}`, + credentialsCoolingCount: 2, + }); + const serialized = JSON.stringify(body); + + assert.equal(body.error.message, "All credentials for the requested model are cooling down"); + assert.equal(body.error.model, undefined); + assert.equal(body.error.retry_after, undefined); + assert.equal(body.error.reset_seconds, 1); + assert.equal(body.error.credentials_cooling, 2); + assert.ok(!serialized.includes(secret)); + assert.ok(!serialized.includes("/home/alice")); +}); + test("modelCooldownResponse emits HTTP 429 with Retry-After header and retry_after ISO in body (#6460)", async () => { const { modelCooldownResponse } = await import("../../open-sse/utils/error.ts"); diff --git a/tests/unit/upstream-error-passthrough.test.ts b/tests/unit/upstream-error-passthrough.test.ts index 84458df5f70..19fdd1c1f53 100644 --- a/tests/unit/upstream-error-passthrough.test.ts +++ b/tests/unit/upstream-error-passthrough.test.ts @@ -58,8 +58,8 @@ test("upstream error passthrough", async (t) => { await t.test( "corpo de capacidade/quota sem segredo continua elegível (contrato Claude Code preservado)", () => { - // The common case must still relay verbatim so Claude Code can match the - // wording to auto-disable capabilities. + // Safe wording/shape must survive canonical sanitization so Claude Code + // can still match the response and auto-disable capabilities. assert.equal( shouldPassthroughUpstreamError(400, { error: { message: "thinking.type: adaptive is not supported" }, @@ -74,7 +74,7 @@ test("upstream error passthrough", async (t) => { ); } ); - await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => { + await t.test("buildPassthroughErrorResponse preserva wording e shape seguros", async () => { const body = { type: "error", error: { type: "invalid_request_error", message: "thinking.type: nope" }, @@ -84,14 +84,66 @@ test("upstream error passthrough", async (t) => { assert.equal(res.status, 400); assert.deepEqual(await res.json(), body); }); + await t.test( + "buildPassthroughErrorResponse sanitiza recursivamente corpo elegível e preserva metadados", + async () => { + const body = { + type: "error", + error: { + type: "invalid_request_error", + message: "password=hunter2", + details: [ + "failed opening /home/alice/private.ts:1:2", + { + trace: "Error\r\n at C:\\Users\\alice\\private.ts:1:2", + credential: "PASSTHROUGH_CREDENTIAL_SECRET", + sessionId: "PASSTHROUGH_SESSION_SECRET", + session_count: 2, + }, + ], + }, + }; + + assert.equal(shouldPassthroughUpstreamError(422, body), true); + const res = buildPassthroughErrorResponse(422, body, { "X-Test": "preserved" }); + assert.ok(res, "eligible safe-shape body remains passthrough-capable"); + assert.equal(res.status, 422); + assert.equal(res.headers.get("X-Test"), "preserved"); + const sanitized = await res.json(); + const serialized = JSON.stringify(sanitized); + assert.equal(sanitized.type, "error"); + assert.equal(sanitized.error.type, "invalid_request_error"); + assert.ok(!serialized.includes("hunter2")); + assert.ok(!serialized.includes("/home/alice")); + assert.ok(!serialized.includes("C:\\Users\\alice")); + assert.ok(!serialized.includes(" at ")); + assert.ok(!serialized.includes("PASSTHROUGH_CREDENTIAL_SECRET")); + assert.ok(!serialized.includes("PASSTHROUGH_SESSION_SECRET")); + assert.ok(serialized.includes('"session_count":2')); + } + ); await t.test("retorna null quando inelegível", () => { assert.equal(buildPassthroughErrorResponse(500, {}), null); }); + await t.test("corpos não serializáveis falham fechados sem lançar", () => { + const cyclic: Record = { error: { message: "safe" } }; + cyclic.self = cyclic; + const throwing = { + toJSON(): never { + throw new Error("hostile toJSON"); + }, + }; + + for (const body of [cyclic, { value: 1n }, throwing]) { + assert.equal(shouldPassthroughUpstreamError(400, body), false); + assert.equal(buildPassthroughErrorResponse(400, body), null); + } + }); }); test("createErrorResult opt-in passthrough (opts.passthrough)", async (t) => { await t.test( - "com opts.passthrough e corpo elegível, result.response é o corpo upstream verbatim", + "com opts.passthrough, wording e shape seguros sobrevivem à sanitização", async () => { const { createErrorResult } = await import("../../open-sse/utils/error.ts"); const upstreamBody = { From e5d1bbc9a982f3d8938822a2886af8482fef7fb4 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:56:00 -0300 Subject: [PATCH 4/6] security(runtime): resolve CodeQL error-boundary findings --- open-sse/executors/lmarena/response.ts | 15 +-- open-sse/utils/errorSanitization.ts | 97 ++++++++++++++++++- .../unit/error-data-url-sanitization.test.ts | 34 +++++++ tests/unit/lmarena-provider.test.ts | 79 +++++++++++++++ 4 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 tests/unit/error-data-url-sanitization.test.ts diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts index f99218fd47c..78575722b52 100644 --- a/open-sse/executors/lmarena/response.ts +++ b/open-sse/executors/lmarena/response.ts @@ -68,17 +68,6 @@ export function missingCookieResult( }; } -function parseArenaErrorBody(text: string | null | undefined, status: number): string { - const fallback = `Arena API error: ${status}`; - if (!text) return fallback; - try { - const errorJson = JSON.parse(text) as { error?: { message?: string }; message?: string }; - return errorJson.error?.message || errorJson.message || fallback; - } catch { - return text.slice(0, 500) || fallback; - } -} - function isBotOrChallenge(status: number, text: string | null | undefined): boolean { if (status === 403) return true; if (isCloudflareChallenge(text)) return true; @@ -124,8 +113,10 @@ export function mapFailedTlsResult(opts: { markLMArenaCatalogModelDead(model); markLMArenaCatalogModelDead(arenaModelId); } + // Fail closed: TLS error bodies can contain upstream stacks, causes, or internal identifiers. + // Preserve the HTTP classification without projecting any body-derived text to the caller. return { - response: errorResponse(status, parseArenaErrorBody(text, status), "api_error", String(status)), + response: errorResponse(status, `Arena API error: ${status}`, "api_error", String(status)), url, headers, transformedBody, diff --git a/open-sse/utils/errorSanitization.ts b/open-sse/utils/errorSanitization.ts index 5b101e32a11..e1805bcf3a6 100644 --- a/open-sse/utils/errorSanitization.ts +++ b/open-sse/utils/errorSanitization.ts @@ -358,9 +358,102 @@ function redactPrivateKeyPemBlocks(value: string): string { return parts.join(""); } +const DATA_URL_PREFIX = "data:"; +const BASE64_DATA_URL_MARKER = ";base64"; +const REDACTED_DATA_URL = "[REDACTED_DATA_URL]"; + +function matchesAsciiCaseInsensitiveAt(value: string, start: number, expected: string): boolean { + if (start < 0 || start + expected.length > value.length) return false; + for (let offset = 0; offset < expected.length; offset++) { + const code = value.charCodeAt(start + offset); + const foldedCode = code >= 0x41 && code <= 0x5a ? code + 0x20 : code; + if (foldedCode !== expected.charCodeAt(offset)) return false; + } + return true; +} + +function isBase64DataUrlPayloadCode(code: number): boolean { + return ( + isAsciiAlphaNumericCode(code) || + code === 0x2b || + code === 0x2f || + code === 0x3d || + code === 0x5f || + code === 0x2d + ); +} + +function isEcmaScriptWhitespaceCode(code: number): boolean { + return ( + (code >= 0x09 && code <= 0x0d) || + code === 0x20 || + code === 0xa0 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 || + code === 0xfeff + ); +} + +/** Redact base64 data URLs in one pass, including input with many repeated `data:` prefixes. */ +function redactBase64DataUrls(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + if (!matchesAsciiCaseInsensitiveAt(value, index, DATA_URL_PREFIX)) { + index++; + continue; + } + + const dataUrlStart = index; + const mediaTypeStart = dataUrlStart + DATA_URL_PREFIX.length; + let delimiter = mediaTypeStart; + while ( + delimiter < value.length && + value[delimiter] !== "," && + !isEcmaScriptWhitespaceCode(value.charCodeAt(delimiter)) + ) { + delimiter++; + } + + const markerStart = delimiter - BASE64_DATA_URL_MARKER.length; + const hasBase64Marker = + delimiter < value.length && + value[delimiter] === "," && + markerStart >= mediaTypeStart && + matchesAsciiCaseInsensitiveAt(value, markerStart, BASE64_DATA_URL_MARKER); + if (!hasBase64Marker) { + index = delimiter < value.length ? delimiter + 1 : value.length; + continue; + } + + let payloadEnd = delimiter + 1; + while (payloadEnd < value.length && isBase64DataUrlPayloadCode(value.charCodeAt(payloadEnd))) { + payloadEnd++; + } + if (payloadEnd === delimiter + 1) { + index = delimiter + 1; + continue; + } + + parts.push(value.slice(copyStart, dataUrlStart), REDACTED_DATA_URL); + copyStart = payloadEnd; + index = payloadEnd; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + export function redactSensitiveErrorText(value: string): string { - const commonCredentialsRedacted = redactPrivateKeyPemBlocks(value) - .replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]") + const commonCredentialsRedacted = redactBase64DataUrls(redactPrivateKeyPemBlocks(value)) .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") .replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]"); return redactLabeledCredentialAssignments(commonCredentialsRedacted); diff --git a/tests/unit/error-data-url-sanitization.test.ts b/tests/unit/error-data-url-sanitization.test.ts new file mode 100644 index 00000000000..34fe7d439eb --- /dev/null +++ b/tests/unit/error-data-url-sanitization.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import test from "node:test"; + +import { redactSensitiveErrorText } from "../../open-sse/utils/errorSanitization.ts"; + +test("redacts base64 data URLs without changing their surrounding text", () => { + const input = + "before DATA:image/svg+xml;charset=utf-8;BaSe64,PHN2Zz48L3N2Zz4= after " + + "data:text/plain;base64,SGVsbG8! and data:;base64,U0VDUkVU."; + + assert.equal( + redactSensitiveErrorText(input), + "before [REDACTED_DATA_URL] after [REDACTED_DATA_URL]! and [REDACTED_DATA_URL]." + ); +}); + +test( + "bounds work for adversarial repeated data prefixes while preserving an incomplete URL", + { timeout: 20_000 }, + () => { + const input = `${"data:".repeat(30_000)}image/png;base64`; + const startedAt = performance.now(); + + const output = redactSensitiveErrorText(input); + const elapsedMs = performance.now() - startedAt; + + assert.equal(output, input, "an incomplete data URL must remain unchanged"); + assert.ok( + elapsedMs < 6_000, + `repeated data prefixes must be processed in bounded time (took ${elapsedMs.toFixed(1)}ms)` + ); + } +); diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index dfbe01ae5af..7afa04187e8 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -647,6 +647,85 @@ describe("LMArena Executor", () => { } }); + it("does not expose structured upstream error details while preserving classification", async () => { + const executor = new LMArenaExecutor(); + __setTlsFetchOverrideForTesting(async () => ({ + status: 500, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify({ + error: { + message: + "SensitiveDatabaseAdapter failed\n" + + " at loadSecret (/srv/private/lmarena/database.ts:46:7)", + stack: "Error: database failure at /srv/private/lmarena/database.ts:46:7", + cause: "postgresql://private-user:private-password@internal-db/arena", + }, + }), + body: null, + })); + + try { + const result = await executor.execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console, + }); + + assert.equal(result.response.status, 500); + const responseText = await result.response.text(); + const errorBody = JSON.parse(responseText); + assert.deepEqual(errorBody.error, { + message: "Arena API error: 500", + type: "api_error", + code: "500", + }); + assert.doesNotMatch( + responseText, + /SensitiveDatabaseAdapter|loadSecret|database\.ts|private-password|stack|cause/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("does not expose plaintext upstream error details while preserving classification", async () => { + __setTlsFetchOverrideForTesting(async () => ({ + status: 500, + headers: new Headers({ "Content-Type": "text/plain" }), + text: + "SensitivePlaintextFailure: internal adapter failed\n" + + " at loadSecret (/srv/private/lmarena/plaintext.ts:71:9)", + body: null, + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console, + }); + + assert.equal(result.response.status, 500); + const responseText = await result.response.text(); + const errorBody = JSON.parse(responseText); + assert.deepEqual(errorBody.error, { + message: "Arena API error: 500", + type: "api_error", + code: "500", + }); + assert.doesNotMatch( + responseText, + /SensitivePlaintextFailure|internal adapter|loadSecret|plaintext\.ts/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("sanitizes network failure details before logging or responding", async () => { const errorLogs: string[] = []; __setTlsFetchOverrideForTesting(async () => { From d66748406fdb93db8193f064ebdd8d9af1c25a9a Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:32:02 -0300 Subject: [PATCH 5/6] security(lmarena): close public stream error boundary --- open-sse/executors/lmarena.ts | 8 +- open-sse/executors/lmarena/response.ts | 229 +++++++++++++++++++------ tests/unit/lmarena-provider.test.ts | 211 ++++++++++++++++++++++- 3 files changed, 391 insertions(+), 57 deletions(-) diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 77772b49077..e57383d9111 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -158,11 +158,11 @@ export class LMArenaExecutor extends BaseExecutor { } catch (error) { if (isTlsClientUnavailableError(error)) { log?.error?.("LMArenaExecutor", `TLS client unavailable: ${sanitizeLMArenaError(error)}`); - return mapTlsUnavailable(error, url, headers, transformedBody); + return mapTlsUnavailable(url, headers, transformedBody); } - const publicMessage = sanitizeLMArenaError(error); - log?.error?.("LMArenaExecutor", `Request failed: ${publicMessage}`); - return mapNetworkError(error, url, headers, transformedBody); + const logMessage = sanitizeLMArenaError(error); + log?.error?.("LMArenaExecutor", `Request failed: ${logMessage}`); + return mapNetworkError(url, headers, transformedBody); } } diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts index 78575722b52..801a339fdd9 100644 --- a/open-sse/executors/lmarena/response.ts +++ b/open-sse/executors/lmarena/response.ts @@ -3,6 +3,7 @@ * the executor methods stay under complexity / max-lines gates. */ import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts"; +import { buildErrorBody } from "../../utils/error.ts"; import { sanitizeLMArenaError } from "./error.ts"; import { markLMArenaCatalogModelDead } from "./models.ts"; import { parseArenaSSE } from "./stream.ts"; @@ -15,9 +16,133 @@ const SAFE_ARENA_STREAM_ERROR_NAMES = new Set([ "BodyTimeoutError", ]); -function projectArenaStreamError(error: unknown, publicMessage: string): Error { - const projected = new Error(publicMessage) as Error & { statusCode?: number }; +type ArenaPublicFailure = + | { kind: "missing-cookie" } + | { + kind: "bot-block"; + status: number; + reason: "cloudflare" | "token-present" | "token-needed"; + } + | { kind: "http-status"; status: number } + | { kind: "tls-unavailable" } + | { kind: "network" } + | { kind: "upstream-event" } + | { kind: "stream-transport" }; + +interface ArenaProjectedPublicFailure { + status: number; + message: string; + type: string; + code: string; +} + +type ArenaProjectedStreamError = Error & { + statusCode?: number; + type?: string; + code?: string; +}; + +const ARENA_PUBLIC_MESSAGES = { + missingCookie: + "Arena requires a session cookie. Paste the full Cookie header from arena.ai (include arena-auth-prod-v1.* chunks and ideally cf_clearance).", + cloudflareBot: + "Arena blocked by Cloudflare bot management. Use a residential/browser-grade network if needed, paste a fresh full Cookie header (include cf_clearance / __cf_bm when present), and optionally set providerSpecificData.recaptchaV3Token from a live browser session.", + botTokenNeeded: + "If this persists, supply a browser reCAPTCHA v3 token via credentials.providerSpecificData.recaptchaV3Token (in addition to the session cookie).", + tlsUnavailable: + "Arena TLS impersonation unavailable: Arena upstream error. Install/repair tls-client-node native binary.", + upstream: "Arena upstream error", + upstreamStream: "Arena upstream stream error", +} as const; + +function normalizeArenaStatus(status: number, fallback: number): number { + return Number.isInteger(status) && status >= 200 && status <= 599 ? status : fallback; +} + +function projectArenaPublicFailure(failure: ArenaPublicFailure): ArenaProjectedPublicFailure { + switch (failure.kind) { + case "missing-cookie": + return { + status: 401, + message: ARENA_PUBLIC_MESSAGES.missingCookie, + type: "authentication_error", + code: "missing_cookie", + }; + case "bot-block": { + const status = normalizeArenaStatus(failure.status, 403); + let message: string; + switch (failure.reason) { + case "cloudflare": + message = ARENA_PUBLIC_MESSAGES.cloudflareBot; + break; + case "token-present": + message = `Arena API error: ${status}`; + break; + case "token-needed": + message = `Arena API error: ${status}. ${ARENA_PUBLIC_MESSAGES.botTokenNeeded}`; + break; + default: { + const exhaustiveReason: never = failure.reason; + return exhaustiveReason; + } + } + return { + status, + message, + type: "api_error", + code: "cloudflare_or_bot", + }; + } + case "http-status": { + const status = normalizeArenaStatus(failure.status, 502); + return { + status, + message: `Arena API error: ${status}`, + type: "api_error", + code: String(status), + }; + } + case "tls-unavailable": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.tlsUnavailable, + type: "upstream_error", + code: "TLS_CLIENT_UNAVAILABLE", + }; + case "network": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.upstream, + type: "network_error", + code: "request_failed", + }; + case "upstream-event": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.upstream, + type: "api_error", + code: "lmarena_error", + }; + case "stream-transport": + return { + status: 502, + message: ARENA_PUBLIC_MESSAGES.upstreamStream, + type: "upstream_error", + code: "lmarena_stream_error", + }; + default: { + const exhaustiveFailure: never = failure; + return exhaustiveFailure; + } + } +} + +function projectArenaStreamError(error: unknown): Error { + const publicError = buildArenaPublicError({ kind: "stream-transport" }).body.error; + const projected = new Error(publicError.message) as ArenaProjectedStreamError; projected.stack = undefined; + projected.type = publicError.type; + projected.code = publicError.code; if (!error || typeof error !== "object") return projected; try { @@ -25,9 +150,14 @@ function projectArenaStreamError(error: unknown, publicMessage: string): Error { if (typeof name === "string" && SAFE_ARENA_STREAM_ERROR_NAMES.has(name)) { projected.name = name; } - const statusCode = Number((error as { statusCode?: unknown }).statusCode); - if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) { - projected.statusCode = statusCode; + const rawStatusCode = (error as { statusCode?: unknown }).statusCode; + if ( + typeof rawStatusCode === "number" && + Number.isInteger(rawStatusCode) && + rawStatusCode >= 400 && + rawStatusCode <= 599 + ) { + projected.statusCode = rawStatusCode; } } catch { // Hostile thrown values must not escape through coercing metadata accessors. @@ -35,19 +165,23 @@ function projectArenaStreamError(error: unknown, publicMessage: string): Error { return projected; } -export function errorResponse( - status: number, - message: string, - type: string, - code: string -): Response { - const publicMessage = sanitizeLMArenaError(message); - return new Response( - JSON.stringify({ - error: { message: publicMessage, type, code }, +function buildArenaPublicError(failure: ArenaPublicFailure) { + const projected = projectArenaPublicFailure(failure); + return { + status: projected.status, + body: buildErrorBody(projected.status, projected.message, undefined, { + type: projected.type, + code: projected.code, }), - { status, headers: { "Content-Type": "application/json" } } - ); + }; +} + +function errorResponse(failure: ArenaPublicFailure): Response { + const projected = buildArenaPublicError(failure); + return new Response(JSON.stringify(projected.body), { + status: projected.status, + headers: { "Content-Type": "application/json" }, + }); } export function missingCookieResult( @@ -56,12 +190,7 @@ export function missingCookieResult( transformedBody: unknown ) { return { - response: errorResponse( - 401, - "Arena requires a session cookie. Paste the full Cookie header from arena.ai (include arena-auth-prod-v1.* chunks and ideally cf_clearance).", - "authentication_error", - "missing_cookie" - ), + response: errorResponse({ kind: "missing-cookie" }), url, headers, transformedBody, @@ -74,12 +203,17 @@ function isBotOrChallenge(status: number, text: string | null | undefined): bool return Boolean(text && text.trimStart().startsWith(", transformedBody: unknown ) { return { - response: errorResponse( - 502, - `Arena TLS impersonation unavailable: ${sanitizeLMArenaError(error)}. Install/repair tls-client-node native binary.`, - "upstream_error", - "TLS_CLIENT_UNAVAILABLE" - ), + response: errorResponse({ kind: "tls-unavailable" }), url, headers, transformedBody, @@ -143,13 +266,12 @@ export function mapTlsUnavailable( } export function mapNetworkError( - message: unknown, url: string, headers: Record, transformedBody: unknown ) { return { - response: errorResponse(502, sanitizeLMArenaError(message), "network_error", "request_failed"), + response: errorResponse({ kind: "network" }), url, headers, transformedBody, @@ -214,10 +336,11 @@ function handleArenaEventLine( return false; } if (event.type === "error") { + const upstreamError = buildArenaPublicError({ kind: "upstream-event" }); enqueueSse(controller, { ...baseChunk(model), choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - error: { message: sanitizeLMArenaError(event.content) }, + error: upstreamError.body.error, }); controller.close(); return true; @@ -290,9 +413,9 @@ export function createOpenAIArenaStream(opts: { } emitStopAndDone(controller, model); } catch (error) { - const publicMessage = sanitizeLMArenaError(error, "Arena upstream stream error"); - log?.error?.("LMArenaExecutor", `Streaming error: ${publicMessage}`); - controller.error(projectArenaStreamError(error, publicMessage)); + const logMessage = sanitizeLMArenaError(error, "Arena upstream stream error"); + log?.error?.("LMArenaExecutor", `Streaming error: ${logMessage}`); + controller.error(projectArenaStreamError(error)); } finally { await cleanupReader(); if (signal) signal.removeEventListener("abort", onAbort); @@ -312,7 +435,7 @@ export async function handleNonStreamingArenaResponse( ): Promise { const text = await response.text(); let fullText = ""; - let error: string | null = null; + let hasUpstreamError = false; for (const line of text.split("\n")) { if (!line.trim()) continue; @@ -321,12 +444,14 @@ export async function handleNonStreamingArenaResponse( if (!event) continue; if (event.type === "text" && event.content) fullText += event.content; else if (event.type === "error") { - error = event.content || "Unknown error"; + hasUpstreamError = true; break; } else if (event.type === "done") break; } - if (error) return errorResponse(502, error, "api_error", "lmarena_error"); + if (hasUpstreamError) { + return errorResponse({ kind: "upstream-event" }); + } return new Response( JSON.stringify({ diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index 7afa04187e8..b4578ae2d92 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -647,6 +647,36 @@ describe("LMArena Executor", () => { } }); + it("normalizes an invalid upstream status before building the public error", async () => { + __setTlsFetchOverrideForTesting(async () => ({ + status: 700, + headers: new Headers({ "Content-Type": "text/plain" }), + text: "InvalidStatusInternalFailure secret-status-id", + body: null, + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + assert.deepEqual(JSON.parse(responseText).error, { + message: "Arena API error: 502", + type: "api_error", + code: "502", + }); + assert.doesNotMatch(responseText, /InvalidStatusInternalFailure|secret-status-id/i); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("does not expose structured upstream error details while preserving classification", async () => { const executor = new LMArenaExecutor(); __setTlsFetchOverrideForTesting(async () => ({ @@ -749,6 +779,7 @@ describe("LMArena Executor", () => { assert.equal(errorLogs.length, 1); const responseText = await result.response.text(); const json = JSON.parse(responseText); + assert.equal(json.error?.message, "Arena upstream error"); assert.equal(json.error?.type, "network_error"); assert.equal(json.error?.code, "request_failed"); const publicOutput = `${errorLogs.join("\n")}\n${responseText}`; @@ -866,6 +897,42 @@ describe("LMArena Executor", () => { } }); + it("does not expose TLS-client failure details in the public response", async () => { + const tlsFailure = new TlsClientUnavailableError( + "NativeTlsBridgeFailure secret-adapter-id at /srv/private/lmarena-native.ts:22:4" + ) as TlsClientUnavailableError & { cause?: unknown }; + tlsFailure.cause = new Error("NativeTlsCause secret-cause-id"); + __setTlsFetchOverrideForTesting(async () => { + throw tlsFailure; + }); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.deepEqual(json.error, { + message: + "Arena TLS impersonation unavailable: Arena upstream error. Install/repair tls-client-node native binary.", + type: "upstream_error", + code: "TLS_CLIENT_UNAVAILABLE", + }); + assert.doesNotMatch( + responseText, + /NativeTlsBridgeFailure|secret-adapter-id|lmarena-native|NativeTlsCause|secret-cause-id/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("uses a stable public fallback for blank network and upstream event errors", async (t) => { const stackOnly = "\n at SecretOnlyFrame (/srv/private/lmarena-stack-only.ts:2:3)"; const cases = [ @@ -918,6 +985,43 @@ describe("LMArena Executor", () => { } }); + it("does not expose non-streaming upstream event details", async () => { + const upstreamFailure = + "ArenaEventInternalFailure secret-event-id\n" + + " at ArenaEventAdapter (/srv/private/lmarena-event.ts:31:8)"; + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: `3:${JSON.stringify(upstreamFailure)}\n`, + body: null, + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + const json = JSON.parse(responseText); + assert.deepEqual(json.error, { + message: "Arena upstream error", + type: "api_error", + code: "lmarena_error", + }); + assert.doesNotMatch( + responseText, + /ArenaEventInternalFailure|secret-event-id|ArenaEventAdapter|lmarena-event/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("uses a stable public fallback for blank streaming event errors", async () => { const stackOnly = "\n at SecretOnlyFrame (/srv/private/lmarena-stream-stack-only.ts:2:3)"; const encoded = new TextEncoder().encode(`data: 3:${JSON.stringify(stackOnly)}\n\n`); @@ -950,13 +1054,118 @@ describe("LMArena Executor", () => { .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") .map((line) => JSON.parse(line.slice(6))) .find((chunk) => chunk.error); - assert.equal(payload?.error?.message, "Arena upstream error"); + assert.deepEqual(payload?.error, { + message: "Arena upstream error", + type: "api_error", + code: "lmarena_error", + }); assert.doesNotMatch(responseText, /SecretOnlyFrame|lmarena-stream-stack-only/); } finally { __setTlsFetchOverrideForTesting(null); } }); + it("does not expose streaming upstream event details", async () => { + const upstreamFailure = + "StreamingArenaInternalFailure secret-stream-id\n" + + " at StreamingArenaAdapter (/srv/private/lmarena-stream-event.ts:44:9)"; + const encoded = new TextEncoder().encode(`data: 3:${JSON.stringify(upstreamFailure)}\n\n`); + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.close(); + }, + }), + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }], stream: true }, + stream: true, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 200); + const responseText = await result.response.text(); + const payload = responseText + .split("\n") + .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6))) + .find((chunk) => chunk.error); + assert.deepEqual(payload?.error, { + message: "Arena upstream error", + type: "api_error", + code: "lmarena_error", + }); + assert.doesNotMatch( + responseText, + /StreamingArenaInternalFailure|secret-stream-id|StreamingArenaAdapter|lmarena-stream-event/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("does not expose transport errors rejected by the upstream stream", async () => { + const streamFailure = new Error( + "ArenaStreamTransportFailure secret-transport-id at /srv/private/lmarena-reader.ts:52:6" + ) as Error & { cause?: unknown; statusCode?: number }; + streamFailure.cause = new Error("ArenaStreamCause secret-stream-cause-id"); + streamFailure.statusCode = 502; + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.error(streamFailure); + }, + }), + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }], stream: true }, + stream: true, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 200); + await assert.rejects(result.response.text(), (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "Arena upstream stream error"); + assert.equal(error.stack, undefined); + const projected = error as Error & { + cause?: unknown; + statusCode?: number; + type?: string; + code?: string; + }; + assert.equal(projected.cause, undefined); + assert.equal(projected.statusCode, 502); + assert.equal(projected.type, "upstream_error"); + assert.equal(projected.code, "lmarena_stream_error"); + assert.doesNotMatch( + error.message, + /ArenaStreamTransportFailure|secret-transport-id|lmarena-reader|ArenaStreamCause|secret-stream-cause-id/i + ); + return true; + }); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("forwards optional browser reCAPTCHA token from credentials", () => { const executor = new LMArenaExecutor(); const body = access(executor).transformRequest( From 29dc5427b6abd5a4ff5198759f8cfc2d500ce1aa Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:42:00 -0300 Subject: [PATCH 6/6] fix(lmarena): normalize public error statuses --- open-sse/executors/lmarena/response.ts | 8 +- tests/unit/lmarena-provider.test.ts | 106 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts index 801a339fdd9..d367988b74e 100644 --- a/open-sse/executors/lmarena/response.ts +++ b/open-sse/executors/lmarena/response.ts @@ -55,8 +55,8 @@ const ARENA_PUBLIC_MESSAGES = { upstreamStream: "Arena upstream stream error", } as const; -function normalizeArenaStatus(status: number, fallback: number): number { - return Number.isInteger(status) && status >= 200 && status <= 599 ? status : fallback; +function normalizeArenaErrorStatus(status: number, fallback: number): number { + return Number.isInteger(status) && status >= 400 && status <= 599 ? status : fallback; } function projectArenaPublicFailure(failure: ArenaPublicFailure): ArenaProjectedPublicFailure { @@ -69,7 +69,7 @@ function projectArenaPublicFailure(failure: ArenaPublicFailure): ArenaProjectedP code: "missing_cookie", }; case "bot-block": { - const status = normalizeArenaStatus(failure.status, 403); + const status = normalizeArenaErrorStatus(failure.status, 403); let message: string; switch (failure.reason) { case "cloudflare": @@ -94,7 +94,7 @@ function projectArenaPublicFailure(failure: ArenaPublicFailure): ArenaProjectedP }; } case "http-status": { - const status = normalizeArenaStatus(failure.status, 502); + const status = normalizeArenaErrorStatus(failure.status, 502); return { status, message: `Arena API error: ${status}`, diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index b4578ae2d92..16e534e484f 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -677,6 +677,39 @@ describe("LMArena Executor", () => { } }); + it("normalizes an upstream redirect before building the public error", async () => { + __setTlsFetchOverrideForTesting(async () => ({ + status: 302, + headers: new Headers({ Location: "https://internal.arena.invalid/login" }), + text: "RedirectInternalFailure secret-redirect-id", + body: null, + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 502); + const responseText = await result.response.text(); + assert.deepEqual(JSON.parse(responseText).error, { + message: "Arena API error: 502", + type: "api_error", + code: "502", + }); + assert.doesNotMatch( + responseText, + /RedirectInternalFailure|secret-redirect-id|internal\.arena/i + ); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("does not expose structured upstream error details while preserving classification", async () => { const executor = new LMArenaExecutor(); __setTlsFetchOverrideForTesting(async () => ({ @@ -1166,6 +1199,50 @@ describe("LMArena Executor", () => { } }); + it("does not coerce transport status metadata from the upstream stream", async () => { + let coercionCalls = 0; + const hostileStatus = { + [Symbol.toPrimitive]() { + coercionCalls += 1; + throw new Error("StatusCoercionFailure secret-coercion-id"); + }, + }; + const streamFailure = Object.assign(new Error("upstream transport failure"), { + statusCode: hostileStatus, + }); + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.error(streamFailure); + }, + }), + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }], stream: true }, + stream: true, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + await assert.rejects(result.response.text(), (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as Error & { statusCode?: unknown }).statusCode, undefined); + assert.equal(error.message, "Arena upstream stream error"); + return true; + }); + assert.equal(coercionCalls, 0); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + it("forwards optional browser reCAPTCHA token from credentials", () => { const executor = new LMArenaExecutor(); const body = access(executor).transformRequest( @@ -1201,4 +1278,33 @@ describe("LMArena Executor", () => { __setTlsFetchOverrideForTesting(null); } }); + + it("uses an error status for a Cloudflare challenge returned with HTTP 200", async () => { + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/html" }), + text: "Just a moment... challenges.cloudflare.com", + body: null, + })); + + try { + const result = await new LMArenaExecutor().execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result.response.status, 403); + assert.deepEqual((await result.response.json()).error, { + message: + "Arena blocked by Cloudflare bot management. Use a residential/browser-grade network if needed, paste a fresh full Cookie header (include cf_clearance / __cf_bm when present), and optionally set providerSpecificData.recaptchaV3Token from a live browser session.", + type: "api_error", + code: "cloudflare_or_bot", + }); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); });
Depoya yıldız verinÜcretsizdir — görünürlüğe gerçekten yardımcı olurOmniRoute'a Yıldız Verin