From fda14a27b2f066732e3eb94eb5c8b323bac6d4b1 Mon Sep 17 00:00:00 2001 From: "Paul K." Date: Wed, 21 Oct 2026 09:35:13 -0400 Subject: [PATCH 1/3] fix(security)!: require a Node.js with the eMudhra CA fix (v3.0.0) Eight eMudhra intermediate CAs were issued with 8-bit keys, so anyone can sign a certificate for api.withone.ai. Node.js blocks them in the TLS handshake from 22.23.4, 24.22.1 and 26.12.1. The CLI runs on the user's Node, so on anything older it would hand a network attacker the API key. bin/cli.js now runs src/node-check.ts (its own build entry) before loading the CLI or opening any connection, and exits 1 on an unpatched Node, with the usual {"error": ...} JSON in agent mode. engines.node becomes ^22.23.4 || ^24.22.1 || >=26.12.1, the CI matrix moves to 22/24/26, and the tsup target to node22. BREAKING CHANGE: Node.js 18, 20, 23, 25 and unpatched 22/24/26 are no longer supported. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 ++-- CLAUDE.md | 14 +++++++--- README.md | 8 +++--- bin/cli.js | 8 +++++- package-lock.json | 6 ++--- package.json | 4 +-- scripts/run-tests.mjs | 7 +++-- skills/one/SKILL.md | 2 ++ src/lib/guide-content.ts | 1 + src/node-check.test.ts | 33 +++++++++++++++++++++++ src/node-check.ts | 58 ++++++++++++++++++++++++++++++++++++++++ tsup.config.ts | 4 +-- 12 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 src/node-check.test.ts create mode 100644 src/node-check.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aea38dd..5ca69af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,10 @@ jobs: # Windows is not optional here: the bug this workflow was added for # (tests sandboxing $HOME, which os.homedir() ignores on win32) was # invisible on Linux and macOS while corrupting real user state on - # Windows. Node 18 is the floor declared in package.json engines. + # Windows. The node lines are the ones package.json engines accepts + # (22.23.4+, 24.22.1+, 26.12.1+: the eMudhra CA fix). os: [ubuntu-latest, windows-latest] - node: ['18', '20', '22'] + node: ['22', '24', '26'] steps: - uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 39f6d84..22e66b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,11 +83,17 @@ installed. `npm test` runs `scripts/run-tests.mjs`, which discovers test files itself. Do **not** change it back to `tsx --test "src/**/*.test.ts"` — runner-side glob -expansion only exists in Node 21+, so on Node 18/20 (both inside our declared -`engines.node: >=18`) that pattern matches nothing and exits 0 having run -nothing at all. +expansion only exists in Node 21+, so on an older Node that pattern matches +nothing and exits 0 having run nothing at all. -CI (`.github/workflows/ci.yml`) runs typecheck + tests on Node 18/20/22 across +### Supported Node versions + +`engines.node` is `^22.23.4 || ^24.22.1 || >=26.12.1`: the first releases that +block the compromised eMudhra intermediate CAs. `bin/cli.js` runs +`src/node-check.ts` before anything else and exits on an older Node. Don't +lower the floor below those patches. + +CI (`.github/workflows/ci.yml`) runs typecheck + tests on Node 22/24/26 across ubuntu and windows on every PR. Windows coverage is deliberate — the isolation bug above was invisible everywhere else. diff --git a/README.md b/README.md index 24d3b32..455f919 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,10 @@ one logout # Clear credentials (with scope picker and confirmation) The browser consent page names the key and tags it with where this CLI is installed (scope, project path, machine, OS user, CLI version, chosen harnesses, and the per-install device id unless telemetry is off) so you can find and revoke it from Settings → API keys. The terminal prints exactly what will be sent before the browser opens. -Requires Node.js 18+. `one sync` additionally needs Node 20+, because its -local SQLite engine (`better-sqlite3`, an optional dependency) ships prebuilt -binaries only for Node 20 and above. On Node 18 every other command works -normally; `one sync` reports that the engine isn't installed. +Requires Node.js 22.23.4, 24.22.1 or 26.12.1 and newer. Older Node versions +trust the compromised eMudhra intermediate CA certificates (October 2026), so a +network attacker could intercept the CLI's traffic and your API key; on them +the CLI refuses to run and asks you to update Node.js. ## Quick start diff --git a/bin/cli.js b/bin/cli.js index ea63586..ac00b47 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,5 +1,11 @@ #!/usr/bin/env node -import { program } from '../dist/index.js'; +import { requirePatchedNode } from '../dist/node-check.js'; + +// eMudhra CA compromise: refuse to run before any connection opens on a Node +// that still trusts the leaked intermediates (see src/node-check.ts). +requirePatchedNode(); + +const { program } = await import('../dist/index.js'); program.parse(); diff --git a/package-lock.json b/package-lock.json index b1d9187..906ecbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@withone/cli", - "version": "2.9.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@withone/cli", - "version": "2.9.1", + "version": "3.0.0", "dependencies": { "@clack/prompts": "^0.9.1", "commander": "^13.1.0", @@ -27,7 +27,7 @@ "typescript": "^5.7.3" }, "engines": { - "node": ">=18" + "node": "^22.23.4 || ^24.22.1 || >=26.12.1" }, "optionalDependencies": { "@electric-sql/pglite": "^0.2.17", diff --git a/package.json b/package.json index 616e6a2..f2b159b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "2.9.1", + "version": "3.0.0", "description": "CLI for managing One", "type": "module", "files": [ @@ -52,6 +52,6 @@ "url": "https://github.com/withoneai/cli" }, "engines": { - "node": ">=18" + "node": "^22.23.4 || ^24.22.1 || >=26.12.1" } } diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 9b45603..89f6258 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -5,10 +5,9 @@ * Why this exists instead of `tsx --test "src/**\/*.test.ts"`: * * Runner-side glob expansion in `node --test` only landed in Node 21. On - * Node 18 and 20 — both inside this package's declared `engines.node: >=18` - * range — the quoted pattern is passed through literally, matches nothing, - * and the runner exits 0 having run NOTHING. A contributor on Node 20 sees a - * green test run that executed zero assertions. + * Node 18 and 20 the quoted pattern is passed through literally, matches + * nothing, and the runner exits 0 having run NOTHING: a contributor on an old + * Node sees a green test run that executed zero assertions. * * Discovering the files here makes the run identical on every supported Node * and every OS (no shell globbing differences between sh, cmd, and diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 127272f..7d33606 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -171,6 +171,8 @@ All segments are validated before any execution. Failed actions don't block othe All errors return JSON: `{"error": "message"}`. Parse output as JSON and check for the `error` key. +The CLI requires Node.js 22.23.4, 24.22.1 or 26.12.1+ (older Node trusts the compromised eMudhra CA certificates). On an older Node every command exits 1 with an error naming the running version; don't retry, tell the user to update Node.js. + ## Important Rules - Always use `--agent` flag for structured JSON output diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index c2c09aa..a28adaa 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -132,6 +132,7 @@ Request specific sections: - **Always use \`--agent\` flag** for structured JSON output - Platform names are **lowercase**; multi-word names use dashes (e.g., \`hubspot\`, \`google-calendar\`) - Always use the **exact action ID** from a find answer — don't guess +- Requires Node.js 22.23.4, 24.22.1 or 26.12.1+. Older Node trusts the compromised eMudhra CA certificates, so every command refuses to run there with an error naming the running version; tell the user to update Node.js rather than retrying - Always read **knowledge** before executing any action - Connection keys come from \`one connection list\` — don't hardcode them - \`connection list\` also reports an \`access\` field per connection (\`full\` / \`methods\` / \`actions\`, plus a \`level\`: full, read-write, read-only, custom, none) — what One enforces for this key, narrowed by \`one config\`. Read it before planning so you don't propose an action that will be refused. \`accessSource: "local"\` means One did not report it, so it is unverified diff --git a/src/node-check.test.ts b/src/node-check.test.ts new file mode 100644 index 0000000..21dbad8 --- /dev/null +++ b/src/node-check.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { nodeHasTlsFix, unpatchedNodeMessage } from './node-check.js'; + +describe('nodeHasTlsFix', () => { + it('knows the first fixed patch of each supported line', () => { + assert.equal(nodeHasTlsFix('22.23.4'), true); + assert.equal(nodeHasTlsFix('22.23.3'), false); + assert.equal(nodeHasTlsFix('22.24.0'), true); + assert.equal(nodeHasTlsFix('24.22.1'), true); + assert.equal(nodeHasTlsFix('24.22.0'), false); + assert.equal(nodeHasTlsFix('24.14.0'), false); + assert.equal(nodeHasTlsFix('26.12.1'), true); + assert.equal(nodeHasTlsFix('26.12.0'), false); + }); + + it('treats end-of-life lines as unfixed and later lines as fixed', () => { + for (const v of ['18.20.8', '20.19.5', '23.11.1', '25.9.0']) { + assert.equal(nodeHasTlsFix(v), false, v); + } + assert.equal(nodeHasTlsFix('27.0.0'), true); + assert.equal(nodeHasTlsFix('garbage'), false); + }); +}); + +describe('unpatchedNodeMessage', () => { + it('names the running version and the versions to update to', () => { + const message = unpatchedNodeMessage('20.19.5'); + assert.match(message, /Node\.js v20\.19\.5/); + assert.match(message, /22\.23\.4, 24\.22\.1 or 26\.12\.1/); + }); +}); diff --git a/src/node-check.ts b/src/node-check.ts new file mode 100644 index 0000000..9d1af18 --- /dev/null +++ b/src/node-check.ts @@ -0,0 +1,58 @@ +/** + * Refuses to run on a Node.js that still trusts the compromised eMudhra + * intermediate CAs (October 2026, RUSTSEC-2026-0381). + * + * Eight eMudhra/emSign intermediates were created with 8-bit private keys, so + * anyone can compute them and sign a certificate for any domain, including + * api.withone.ai. Node.js blocks them inside the TLS handshake from 22.23.4, + * 24.22.1 and 26.12.1 (nodejs/node#66871). On any older Node, a network + * attacker can intercept the CLI's traffic and the user's API key, and nothing + * in userland can block the intermediates the way Node's fix does. + * + * Built as its own entry so bin/cli.js can run it before loading the rest of + * the CLI, and before any connection opens. + */ + +/** The first patch of each Node line that ships the blocklist. Odd lines (23, 25) are end-of-life and never got it. */ +const FIXED_IN: Readonly> = { + 22: [23, 4], + 24: [22, 1], + 26: [12, 1], +}; + +/** The first Node line released after the fix, so it has it from .0. */ +const FIRST_LINE_WITH_FIX = 27; + +export const NODE_FIX_VERSIONS = '22.23.4, 24.22.1 or 26.12.1'; + +/** True when this Node version rejects chains through the compromised eMudhra intermediates. */ +export function nodeHasTlsFix(version: string = process.versions.node): boolean { + const [major, minor, patch] = version.split('.').map((part) => Number.parseInt(part, 10)); + if (!Number.isFinite(major)) return false; + if (major >= FIRST_LINE_WITH_FIX) return true; + const fixed = FIXED_IN[major]; + if (!fixed) return false; + const [fixedMinor, fixedPatch] = fixed; + return minor > fixedMinor || (minor === fixedMinor && (patch ?? 0) >= fixedPatch); +} + +export function unpatchedNodeMessage(version: string = process.versions.node): string { + return ( + `Node.js v${version} trusts the compromised eMudhra certificates, so a network attacker could ` + + `intercept One's traffic and your API key. The One CLI will not run on it. ` + + `Update Node.js to ${NODE_FIX_VERSIONS} or newer.` + ); +} + +/** + * Exits with an error on an unpatched Node; returns on a patched one. In agent + * mode the error is the usual `{"error": ...}` JSON on stdout. + */ +export function requirePatchedNode(): void { + if (nodeHasTlsFix()) return; + const message = unpatchedNodeMessage(); + const agent = process.argv.includes('--agent') || process.env.ONE_AGENT === '1'; + if (agent) process.stdout.write(`${JSON.stringify({ error: message })}\n`); + else process.stderr.write(`Error: ${message}\n`); + process.exit(1); +} diff --git a/tsup.config.ts b/tsup.config.ts index e1cc8e7..45355a1 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,9 +1,9 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/node-check.ts'], format: ['esm'], - target: 'node18', + target: 'node22', clean: true, dts: true, shims: true, From 62827186a41fa5a69ed3a6b9d0c488016ca90174 Mon Sep 17 00:00:00 2001 From: "Paul K." Date: Wed, 21 Oct 2026 10:09:58 -0400 Subject: [PATCH 2/3] fix(security): ship the patched-Node requirement as 2.9.2, with upgrade help - Version 2.9.2 instead of 3.0.0, so ^2 dependents get the check too. - The error prints the command that updates Node for how it was installed (nvm, nvm-windows, Volta, fnm, Homebrew, else nodejs.org), staying on a line that got the fix and moving an end-of-life line to 24. - Agent mode adds code NODE_UPGRADE_REQUIRED, currentNode, requiredNode and upgrade to the JSON error. - ONE_ALLOW_UNPATCHED_NODE=1 runs anyway with a warning on every run (silent in agent mode so stderr can't corrupt the JSON). - nodeHasTlsFix is exported for library consumers. - Versions read 22.23.4+, 24.22.1+ or 26.12.1+ everywhere. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 6 ++-- README.md | 12 ++++--- package-lock.json | 4 +-- package.json | 2 +- skills/one/SKILL.md | 2 +- src/index.ts | 1 + src/lib/guide-content.ts | 2 +- src/node-check.test.ts | 42 +++++++++++++++++++++-- src/node-check.ts | 72 +++++++++++++++++++++++++++++++++++----- 9 files changed, 122 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 22e66b9..50544fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,8 +90,10 @@ nothing and exits 0 having run nothing at all. `engines.node` is `^22.23.4 || ^24.22.1 || >=26.12.1`: the first releases that block the compromised eMudhra intermediate CAs. `bin/cli.js` runs -`src/node-check.ts` before anything else and exits on an older Node. Don't -lower the floor below those patches. +`src/node-check.ts` before anything else and exits on an older Node +(`ONE_ALLOW_UNPATCHED_NODE=1` overrides it with a warning). Node 18, 20, 23 and +25 are end-of-life and never got the fix. Don't lower the floor below those +patches. CI (`.github/workflows/ci.yml`) runs typecheck + tests on Node 22/24/26 across ubuntu and windows on every PR. Windows coverage is deliberate — the isolation diff --git a/README.md b/README.md index 455f919..8ad5559 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,14 @@ one logout # Clear credentials (with scope picker and confirmation) The browser consent page names the key and tags it with where this CLI is installed (scope, project path, machine, OS user, CLI version, chosen harnesses, and the per-install device id unless telemetry is off) so you can find and revoke it from Settings → API keys. The terminal prints exactly what will be sent before the browser opens. -Requires Node.js 22.23.4, 24.22.1 or 26.12.1 and newer. Older Node versions -trust the compromised eMudhra intermediate CA certificates (October 2026), so a -network attacker could intercept the CLI's traffic and your API key; on them -the CLI refuses to run and asks you to update Node.js. +Requires Node.js 22.23.4+, 24.22.1+ or 26.12.1+. Older Node versions trust the +compromised eMudhra intermediate CA certificates (October 2026), so a network +attacker could intercept the CLI's traffic and your API key. On them the CLI +refuses to run and prints the command that updates Node for how you installed +it. Node 18, 20, 23 and 25 are end-of-life and never got the fix. + +If you can't update Node yet, `ONE_ALLOW_UNPATCHED_NODE=1` runs the CLI anyway, +with a security warning on every run. You remain exposed while it is set. ## Quick start diff --git a/package-lock.json b/package-lock.json index 906ecbe..8adb568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@withone/cli", - "version": "3.0.0", + "version": "2.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@withone/cli", - "version": "3.0.0", + "version": "2.9.2", "dependencies": { "@clack/prompts": "^0.9.1", "commander": "^13.1.0", diff --git a/package.json b/package.json index f2b159b..7528caf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "3.0.0", + "version": "2.9.2", "description": "CLI for managing One", "type": "module", "files": [ diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 7d33606..1a4fff4 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -171,7 +171,7 @@ All segments are validated before any execution. Failed actions don't block othe All errors return JSON: `{"error": "message"}`. Parse output as JSON and check for the `error` key. -The CLI requires Node.js 22.23.4, 24.22.1 or 26.12.1+ (older Node trusts the compromised eMudhra CA certificates). On an older Node every command exits 1 with an error naming the running version; don't retry, tell the user to update Node.js. +The CLI requires Node.js 22.23.4+, 24.22.1+ or 26.12.1+ (older Node trusts the compromised eMudhra CA certificates). On an older Node every command exits 1 with `"code": "NODE_UPGRADE_REQUIRED"` and an `upgrade` field holding the command for the user's setup. Don't retry: show the user that command and ask them to update Node.js. ## Important Rules diff --git a/src/index.ts b/src/index.ts index a7c76ac..2f71097 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ // (e.g. a private dev CLI) can `import { program } from '@withone/cli'`, add // its own commands, and parse — without this module running on import. export { program } from './cli.js'; +export { nodeHasTlsFix } from './node-check.js'; diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index a28adaa..8321784 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -132,7 +132,7 @@ Request specific sections: - **Always use \`--agent\` flag** for structured JSON output - Platform names are **lowercase**; multi-word names use dashes (e.g., \`hubspot\`, \`google-calendar\`) - Always use the **exact action ID** from a find answer — don't guess -- Requires Node.js 22.23.4, 24.22.1 or 26.12.1+. Older Node trusts the compromised eMudhra CA certificates, so every command refuses to run there with an error naming the running version; tell the user to update Node.js rather than retrying +- Requires Node.js 22.23.4+, 24.22.1+ or 26.12.1+. Older Node trusts the compromised eMudhra CA certificates, so every command exits 1 there with \`"code": "NODE_UPGRADE_REQUIRED"\` and an \`upgrade\` field holding the command for the user's setup. Don't retry: show the user that command and ask them to update Node.js - Always read **knowledge** before executing any action - Connection keys come from \`one connection list\` — don't hardcode them - \`connection list\` also reports an \`access\` field per connection (\`full\` / \`methods\` / \`actions\`, plus a \`level\`: full, read-write, read-only, custom, none) — what One enforces for this key, narrowed by \`one config\`. Read it before planning so you don't propose an action that will be refused. \`accessSource: "local"\` means One did not report it, so it is unverified diff --git a/src/node-check.test.ts b/src/node-check.test.ts index 21dbad8..1a2128f 100644 --- a/src/node-check.test.ts +++ b/src/node-check.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { nodeHasTlsFix, unpatchedNodeMessage } from './node-check.js'; +import { nodeHasTlsFix, unpatchedNodeMessage, upgradeCommand } from './node-check.js'; describe('nodeHasTlsFix', () => { it('knows the first fixed patch of each supported line', () => { @@ -24,10 +24,48 @@ describe('nodeHasTlsFix', () => { }); }); +describe('upgradeCommand', () => { + it('keeps a fixed line and moves an end-of-life one to 24', () => { + assert.equal( + upgradeCommand('22.10.0', '/Users/me/.nvm/versions/node/v22.10.0/bin/node'), + 'nvm install 22 && nvm alias default 22', + ); + assert.equal( + upgradeCommand('20.19.5', '/Users/me/.nvm/versions/node/v20.19.5/bin/node'), + 'nvm install 24 && nvm alias default 24', + ); + }); + + it('matches each version manager by the binary path', () => { + assert.equal( + upgradeCommand('20.19.5', 'C:\\Users\\me\\AppData\\Roaming\\nvm\\v20.19.5\\node.exe'), + 'nvm install 24 && nvm use 24', + ); + assert.equal( + upgradeCommand('20.19.5', '/Users/me/.volta/tools/image/node/20.19.5/bin/node'), + 'volta install node@24', + ); + assert.equal( + upgradeCommand('20.19.5', '/Users/me/.local/share/fnm/node-versions/v20.19.5/installation/bin/node'), + 'fnm install 24 && fnm default 24', + ); + }); + + it('upgrades a fixed Homebrew formula in place and replaces an end-of-life one', () => { + assert.equal(upgradeCommand('26.1.0', '/opt/homebrew/Cellar/node/26.1.0/bin/node'), 'brew upgrade node'); + assert.equal(upgradeCommand('22.10.0', '/opt/homebrew/Cellar/node@22/22.10.0/bin/node'), 'brew upgrade node@22'); + assert.equal(upgradeCommand('20.19.5', '/opt/homebrew/Cellar/node@20/20.19.5/bin/node'), 'brew install node@24'); + }); + + it('falls back to nodejs.org', () => { + assert.equal(upgradeCommand('20.19.5', '/usr/local/bin/node'), 'download Node.js 24 from https://nodejs.org'); + }); +}); + describe('unpatchedNodeMessage', () => { it('names the running version and the versions to update to', () => { const message = unpatchedNodeMessage('20.19.5'); assert.match(message, /Node\.js v20\.19\.5/); - assert.match(message, /22\.23\.4, 24\.22\.1 or 26\.12\.1/); + assert.match(message, /22\.23\.4\+, 24\.22\.1\+ or 26\.12\.1\+/); }); }); diff --git a/src/node-check.ts b/src/node-check.ts index 9d1af18..9ec8e00 100644 --- a/src/node-check.ts +++ b/src/node-check.ts @@ -23,36 +23,92 @@ const FIXED_IN: Readonly> = { /** The first Node line released after the fix, so it has it from .0. */ const FIRST_LINE_WITH_FIX = 27; -export const NODE_FIX_VERSIONS = '22.23.4, 24.22.1 or 26.12.1'; +/** The line to move to from a Node line that never got the fix. */ +const RECOMMENDED_LINE = 24; + +export const REQUIRED_NODE = '22.23.4+, 24.22.1+ or 26.12.1+'; +export const REQUIRED_NODE_RANGE = '^22.23.4 || ^24.22.1 || >=26.12.1'; + +/** Set to 1 to run on an unpatched Node anyway, with a warning on every run. */ +export const ALLOW_UNPATCHED_ENV = 'ONE_ALLOW_UNPATCHED_NODE'; + +function parseVersion(version: string): [number, number, number] { + const [major, minor, patch] = version.split('.').map((part) => Number.parseInt(part, 10)); + return [major, minor, patch ?? 0]; +} /** True when this Node version rejects chains through the compromised eMudhra intermediates. */ export function nodeHasTlsFix(version: string = process.versions.node): boolean { - const [major, minor, patch] = version.split('.').map((part) => Number.parseInt(part, 10)); + const [major, minor, patch] = parseVersion(version); if (!Number.isFinite(major)) return false; if (major >= FIRST_LINE_WITH_FIX) return true; const fixed = FIXED_IN[major]; if (!fixed) return false; const [fixedMinor, fixedPatch] = fixed; - return minor > fixedMinor || (minor === fixedMinor && (patch ?? 0) >= fixedPatch); + return minor > fixedMinor || (minor === fixedMinor && patch >= fixedPatch); +} + +/** + * The command that updates Node for however it was installed, judged from the + * path of the running binary. A line that got the fix stays on its line; any + * other moves to the recommended one. + */ +export function upgradeCommand( + version: string = process.versions.node, + execPath: string = process.execPath, +): string { + const [major] = parseVersion(version); + const line = FIXED_IN[major] ? major : RECOMMENDED_LINE; + const path = execPath.replace(/\\/g, '/'); + + if (/\/\.nvm\/versions\/node\//.test(path)) return `nvm install ${line} && nvm alias default ${line}`; + if (/\/AppData\/(Roaming|Local)\/nvm\//i.test(path)) return `nvm install ${line} && nvm use ${line}`; + if (/\/\.?volta\//i.test(path)) return `volta install node@${line}`; + if (/fnm/i.test(path)) return `fnm install ${line} && fnm default ${line}`; + const brew = path.match(/\/Cellar\/(node(?:@\d+)?)\//); + if (brew) { + const formula = brew[1] === 'node' || FIXED_IN[major] ? brew[1] : `node@${line}`; + return formula === brew[1] ? `brew upgrade ${formula}` : `brew install ${formula}`; + } + return `download Node.js ${line} from https://nodejs.org`; } export function unpatchedNodeMessage(version: string = process.versions.node): string { return ( `Node.js v${version} trusts the compromised eMudhra certificates, so a network attacker could ` + - `intercept One's traffic and your API key. The One CLI will not run on it. ` + - `Update Node.js to ${NODE_FIX_VERSIONS} or newer.` + `intercept One's traffic and your API key. The One CLI requires Node.js ${REQUIRED_NODE} ` + + `(Node 18, 20, 23 and 25 never got the fix).` ); } /** * Exits with an error on an unpatched Node; returns on a patched one. In agent - * mode the error is the usual `{"error": ...}` JSON on stdout. + * mode the error is JSON on stdout with a code agents can act on. With + * ONE_ALLOW_UNPATCHED_NODE=1 it warns and returns instead (silently in agent + * mode, where stderr would corrupt the JSON for harnesses that merge streams). */ export function requirePatchedNode(): void { if (nodeHasTlsFix()) return; const message = unpatchedNodeMessage(); + const upgrade = upgradeCommand(); const agent = process.argv.includes('--agent') || process.env.ONE_AGENT === '1'; - if (agent) process.stdout.write(`${JSON.stringify({ error: message })}\n`); - else process.stderr.write(`Error: ${message}\n`); + + if (process.env[ALLOW_UNPATCHED_ENV] === '1') { + if (!agent) process.stderr.write(`Warning: ${message} Running anyway because ${ALLOW_UNPATCHED_ENV}=1.\n`); + return; + } + + if (agent) { + const error = { + error: `${message} Ask the user to update Node.js: ${upgrade}`, + code: 'NODE_UPGRADE_REQUIRED', + currentNode: process.versions.node, + requiredNode: REQUIRED_NODE_RANGE, + upgrade, + }; + process.stdout.write(`${JSON.stringify(error)}\n`); + } else { + process.stderr.write(`Error: ${message}\nTo update: ${upgrade}\n`); + } process.exit(1); } From 5884672b65494f5e0d7bdc2c3832f080b44fbadd Mon Sep 17 00:00:00 2001 From: "Paul K." Date: Wed, 21 Oct 2026 10:13:50 -0400 Subject: [PATCH 3/3] test(mem): pass the test runner's binary stdout through when capturing output On Node 24+ the test runner reports through the same process.stdout the mem tests patch to capture agent JSON. find-by-key captured one of those binary chunks and failed to parse it; export-import swallowed them. Both now capture only string writes and forward everything else, as actions.preflight.test.ts already did. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/commands/mem/export-import.test.ts | 5 ++++- src/commands/mem/find-by-key.test.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/commands/mem/export-import.test.ts b/src/commands/mem/export-import.test.ts index 5e8dea5..931c69a 100644 --- a/src/commands/mem/export-import.test.ts +++ b/src/commands/mem/export-import.test.ts @@ -14,7 +14,10 @@ import { setHomeTo, snapshotHomeEnv, restoreHomeEnv } from '../../test-support/h /** Swallow the command's stdout report so the test output stays readable. */ async function quiet(fn: () => Promise): Promise { const orig = process.stdout.write.bind(process.stdout); - (process.stdout as unknown as { write: (c: string) => boolean }).write = () => true; + // Swallow the command's text, but pass binary chunks through: on Node 24+ + // the test runner reports through this same stdout. + (process.stdout as unknown as { write: (c: unknown, ...rest: unknown[]) => boolean }).write = (chunk, ...rest) => + typeof chunk === 'string' ? true : (orig as (...args: unknown[]) => boolean)(chunk, ...rest); try { await fn(); } finally { (process.stdout as unknown as { write: typeof orig }).write = orig; } } diff --git a/src/commands/mem/find-by-key.test.ts b/src/commands/mem/find-by-key.test.ts index 1d38755..fd428d7 100644 --- a/src/commands/mem/find-by-key.test.ts +++ b/src/commands/mem/find-by-key.test.ts @@ -15,13 +15,21 @@ import { setHomeTo, snapshotHomeEnv, restoreHomeEnv } from '../../test-support/h // (getBackend → findByKeys → group-by-type → agent JSON) against a real PGlite // backend seeded with identity_keys. -/** Force --agent mode and capture the JSON the command writes to stdout. */ +/** + * Force --agent mode and capture the JSON the command writes to stdout. Only + * string writes are captured: on Node 24+ the test runner reports through this + * same stdout in binary, and those chunks must pass through untouched. + */ async function runAgent(fn: () => Promise): Promise { const prev = process.env.ONE_AGENT; process.env.ONE_AGENT = '1'; const orig = process.stdout.write.bind(process.stdout); let buf = ''; - (process.stdout as unknown as { write: (c: string) => boolean }).write = (chunk: string) => { buf += chunk; return true; }; + (process.stdout as unknown as { write: (c: unknown, ...rest: unknown[]) => boolean }).write = (chunk, ...rest) => { + if (typeof chunk !== 'string') return (orig as (...args: unknown[]) => boolean)(chunk, ...rest); + buf += chunk; + return true; + }; try { await fn(); } finally {