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..50544fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,11 +83,19 @@ 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 +(`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 bug above was invisible everywhere else. diff --git a/README.md b/README.md index 24d3b32..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 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+. 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/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..8adb568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@withone/cli", - "version": "2.9.1", + "version": "2.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@withone/cli", - "version": "2.9.1", + "version": "2.9.2", "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..7528caf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "2.9.1", + "version": "2.9.2", "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..1a4fff4 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 `"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 - Always use `--agent` flag for structured JSON output 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 { 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 c2c09aa..8321784 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 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 new file mode 100644 index 0000000..1a2128f --- /dev/null +++ b/src/node-check.test.ts @@ -0,0 +1,71 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { nodeHasTlsFix, unpatchedNodeMessage, upgradeCommand } 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('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\+/); + }); +}); diff --git a/src/node-check.ts b/src/node-check.ts new file mode 100644 index 0000000..9ec8e00 --- /dev/null +++ b/src/node-check.ts @@ -0,0 +1,114 @@ +/** + * 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; + +/** 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] = 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 >= 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 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 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 (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); +} 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,