fix(utils): model Node's NODE_OPTIONS grammar in hasNodeOption (#2188)

* fix(utils): make hasNodeOption handle --flag=value and quoted NODE_OPTIONS

- Match exact flag or flag with =value suffix to support --max-old-space-size=4096, --inspect=0.0.0.0:9229 etc.
- Strip surrounding single/double quotes for quoted NODE_OPTIONS tokens
- Avoid false positives where --inspect incorrectly matched --inspect-brk
- Filter empty tokens from whitespace split

Fixes edge case where hasNodeOption('--max-old-space-size') returned false when NODE_OPTIONS contained '--max-old-space-size=4096'

* fix(utils): align hasNodeOption with Node lexer, add regression tests

- Only strip double quotes (Node's NODE_OPTIONS parser treats single quotes as literal)
  so NODE_OPTIONS="'--use-system-ca'" correctly returns false and does not
  incorrectly enable system CA roots (covers 145 vs 302 cert divergence reported
  in review)
- Keep exact / --flag=value matching, double-quoted positives, prefix boundary
  (--inspect vs --inspect-brk), whitespace filtering
- Add table-driven tests for both CA flags (--use-system-ca, --use-openssl-ca)
  covering exact, equals, double-quoted positive, single-quoted negative,
  repeated whitespace, and --inspect boundary; restores process.env.NODE_OPTIONS
  after each case

Fixes CodeRabbit docstring coverage and jatmn P2 quote-grammar issue in PR #2188

* fix(utils): quote-aware tokenization for hasNodeOption

Parse NODE_OPTIONS respecting double quotes so option-like text inside a quoted
value does not create spurious flags (e.g. --conditions "foo --use-system-ca bar"
must not match --use-system-ca). Verified on Node 22.23.2 that --expose-gc
inside a quoted --conditions value does not enable GC while an explicit flag
does. Add table-driven cases for this grammar and keep previous single-quote
literal + double-quote handling and prefix boundary fixes.

Addresses CodeRabbit quote-grammar issue on c93a679

* fix(utils): precise NODE_OPTIONS parsing with escapes and value boundaries

- Mirror Node's ParseNodeOptionsEnvVar: handle backslash escapes only inside double quotes, strip quotes, treat single quotes as literal, and correctly handle unterminated strings
- Prevent false positives where option values look like flags: --conditions "--use-system-ca" and --conditions "foo \" --use-system-ca bar" no longer count as --use-system-ca
- ALWAYS_CONSUMES_NEXT for --conditions/-C (value may be flag-like), VALUE_TAKING for other string options only when next token does not look like flag to avoid --inspect --use-system-ca false negatives
- Add table-driven cases for exact --use-system-ca value, escaped-quote value, and equals form; 43 tests now pass

Addresses CodeRabbit probe for quoted condition values and escaped quotes

* fix(utils): fail closed on malformed NODE_OPTIONS and order CA negations

* fix(utils): match Node tab grammar and fail closed on flag-like values

* fix(utils): finish required-value validation and audit boolean option roles

- Fail closed on missing-at-EOF, empty inline (=), and flag-like
  separate values for all modeled required-value options, before any
  CA matching, in both target-before-error and error-before-target
  directions (Node rejects the whole NODE_OPTIONS stream).
- Add missing required-value aliases --loader, --inspect-port,
  --inspect-publish-uid; --conditions/-C now also fails closed on
  flag-like values (verified: --conditions -x exits 9).
- Remove --experimental-import-meta-resolve from VALUE_TAKING: it is
  a no-value boolean in Node 22, so a following CA flag stays visible.
- Update tests: 99 hasNodeOption cases + CA cache-rebuild coverage
  for EOF/empty/alias/boolean in both directions.

* fix(utils): model Node's NODE_OPTIONS grammar in hasNodeOption

Give each modeled option the value role Node gives it, and derive the CA
state from one scoped parse instead of three loosely coupled passes.

- Option roles are now explicit (boolean / value / inline value): the
  V8-owned heap flags accept an empty inline value and reject a separated
  one, while `--stack-trace-limit` requires a non-empty inline value.
- The inspector family resolves through its aliases: `--inspect-port` and
  `--debug-port` take a value, the bare `--inspect`/`--inspect-brk`/
  `--inspect-wait` forms are booleans, and the `=`-forms require a
  non-empty inline value.
- Option processing stops at the first argument that is not an option, so
  an ignored suffix neither enables a CA flag nor creates a required-value
  error.
- `--` and the negation of a non-boolean option are rejected, matching the
  streams Node refuses.

Adds a parity script that derives the expected result from the Node 22
runtime instead of from the implementation's own assumptions.

* test(utils): cover accepted heap and inspector forms through the CA selector

* fix(utils): harden the NODE_OPTIONS parity check and negated queries

- `hasNodeOption('--no-<flag>')` now reports the negation only when the
  option is actually present. Previously an unrelated option in
  NODE_OPTIONS made the negated query report `true`.
- The parity script no longer accepts a `--flag` value it cannot measure:
  the Node probe and the matrix both observe `--use-openssl-ca`.
- The parity script reports a usage error when an operand is missing or is
  another flag, instead of silently falling back to a default.
- The parity script unsets NODE_EXTRA_CA_CERTS and NODE_USE_SYSTEM_CA in
  the child environment: both add certificates to the 'default' store and
  would make the observation signal unusable.
- The parity script verifies that the runtime exposes
  `tls.getCACertificates` (Node >= 22.15) before running, counts timeouts
  separately, and fails when no case was checked, so a run that measures
  nothing can no longer report success.

* fix(utils): preserve CA flags after supported test option values

* fix(utils): resolve test-isolation alias through its value role

---------

Co-authored-by: jatmn <the@jat.mn>
This commit is contained in:
Felix-Ayush
2026-10-25 13:49:07 -07:00
committed by GitHub
co-authored by jatmn
parent cdb28e0e3e
commit e3c9a873dc
4 changed files with 1228 additions and 2 deletions
+1
View File
@@ -62,6 +62,7 @@
"test:coverage:ui": "bun run scripts/render-coverage-heatmap.ts",
"security:pr-scan": "bun run scripts/pr-intent-scan.ts",
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
"test:node-options-parity": "bun run scripts/check-node-options-parity.ts",
"typecheck": "tsc --noEmit",
"typecheck:fast": "bun run scripts/typecheck-fast.ts",
"typecheck:type-tests": "bun run scripts/typecheck-type-tests.ts",
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env bun
/**
* Parity check for `hasNodeOption()` against the Node runtime.
*
* `hasNodeOption()` models scoped NODE_OPTIONS syntax and flag state.
* This script compares that answer with the Node runtime
* itself instead of from the implementation's own assumptions: it runs a
* matrix of NODE_OPTIONS values through `node` and compares the observed
* CA-store selection with the helper's result.
*
* Usage:
* bun run scripts/check-node-options-parity.ts [--node <path>]
*
* The CA store is observed through `tls.getCACertificates('default')`: with
* `--use-openssl-ca` the default store is empty, without it the bundled roots
* are returned. A stream Node rejects leaves no option active, so the helper
* must fail closed and report the flag as disabled.
*
* `tls.getCACertificates()` and `--use-system-ca` both landed in Node
* v22.15.0, so that is the minimum runtime this script can measure against;
* it exits with an error on anything older.
*
* Known divergence: Node rejects options it does not know ("--bogus is not
* allowed in NODE_OPTIONS"). This helper models a curated set of options and
* treats every other option as a boolean, so a stream containing an unknown
* option is reported as accepted. Validating unknown or disallowed options
* is out of scope for the helper.
*/
import { spawnSync } from 'child_process'
import { hasNodeOption } from '../src/utils/envUtils.js'
const args = process.argv.slice(2)
/**
* Read an operand for an option. An absent option returns undefined; a
* present option without a usable operand is a usage error, so it must not
* silently fall back to a default.
*/
function argValue(name: string): string | undefined {
const index = args.indexOf(name)
if (index === -1) return undefined
const value = args[index + 1]
if (value === undefined || value.startsWith('-')) {
console.error(`${name} requires a value`)
process.exit(2)
}
return value
}
// The Node probe and the matrix both observe `--use-openssl-ca`, so the flag
// is fixed: making it configurable would compare the runtime's answer for one
// flag against the helper's answer for another.
const flag = '--use-openssl-ca'
// The binary is passed as argv[0] with an argument array, never through a
// shell, so the path cannot be interpreted as a command.
const nodePath = argValue('--node') ?? process.env.NODE_BIN ?? 'node'
const nodeVersion = spawnSync(nodePath, ['--version'], { encoding: 'utf8' })
if (nodeVersion.status !== 0) {
console.error(`Cannot run ${nodePath}. Pass --node <path> to a Node binary.`)
process.exit(2)
}
const version = (nodeVersion.stdout ?? '').trim()
if (Number(version.match(/^v(\d+)/)?.[1] ?? 0) < 24) {
console.warn(
`Warning: ${nodePath} is ${version}; OpenClaude requires Node >= 24.22.1.`,
)
}
// The observation method needs `tls.getCACertificates()`, which landed in
// Node v22.15.0 together with `--use-system-ca`. Without it every probe
// throws and the run would check nothing while still exiting successfully.
const probeSupport = spawnSync(
nodePath,
['-p', "typeof require('tls').getCACertificates"],
{ encoding: 'utf8' },
)
if ((probeSupport.stdout ?? '').trim() !== 'function') {
console.error(
`${nodePath} (${version}) has no tls.getCACertificates(); the parity check needs Node >= 22.15.`,
)
process.exit(2)
}
type NodeOutcome =
| 'rejected'
| 'enabled'
| 'disabled'
| 'timeout'
| 'runtime-error'
/**
* Node rejects the NODE_OPTIONS *stream* for these diagnostics. Other
* failures (a missing --loader module, an invalid enum value) happen after
* the options were accepted, so they say nothing about the parser contract.
*/
const PARSE_ERROR_PATTERNS = [
'requires an argument',
'is not allowed in NODE_OPTIONS',
'invalid value for NODE_OPTIONS',
'bad option',
'illegal value for flag',
'out of bounds',
'not allowed',
]
/** Ask Node what the CA flag would be for a given NODE_OPTIONS value. */
function nodeOutcome(nodeOptions: string): NodeOutcome {
// NODE_EXTRA_CA_CERTS and NODE_USE_SYSTEM_CA add certificates to the
// 'default' store, which would make the "empty store" signal unusable.
const env = { ...process.env, NODE_OPTIONS: nodeOptions }
delete env.NODE_EXTRA_CA_CERTS
delete env.NODE_USE_SYSTEM_CA
const result = spawnSync(
nodePath,
[
'-p',
"require('tls').getCACertificates('default').length === 0 ? 'enabled' : 'disabled'",
],
{
env,
timeout: 10_000,
encoding: 'utf8',
},
)
if (result.error && (result.error as { code?: string }).code === 'ETIMEDOUT')
return 'timeout'
if (result.status !== 0) {
const stderr = result.stderr ?? ''
return PARSE_ERROR_PATTERNS.some((pattern) => stderr.includes(pattern))
? 'rejected'
: 'runtime-error'
}
const stdout = (result.stdout ?? '').trim()
if (stdout === 'enabled') return 'enabled'
if (stdout === 'disabled') return 'disabled'
return 'rejected'
}
/** What the helper reports for the same value. */
function helperOutcome(nodeOptions: string, target: string): NodeOutcome {
const previous = process.env.NODE_OPTIONS
process.env.NODE_OPTIONS = nodeOptions
try {
return hasNodeOption(target) ? 'enabled' : 'disabled'
} finally {
if (previous === undefined) delete process.env.NODE_OPTIONS
else process.env.NODE_OPTIONS = previous
}
}
const CA_FORMS = [
'--use-openssl-ca',
'--use-openssl-ca=1',
'--use-openssl-ca=0',
'--no-use-openssl-ca',
'--use-openssl-ca --no-use-openssl-ca',
'--no-use-openssl-ca --use-openssl-ca',
'--use-openssl-ca=1 --no-use-openssl-ca=1',
'--no-use-openssl-ca=1 --use-openssl-ca=1',
]
const CONSTRUCTS = [
'--title',
'--title=',
'--title=foo',
'--title --use-openssl-ca',
'--title=--use-openssl-ca',
'--conditions',
'--conditions=',
'--conditions=foo',
'--conditions foo',
'--conditions -x',
'--test-isolation none',
'--test-isolation=',
'--test-isolation',
'--test-global-setup ./setup.js',
'--test-global-setup=',
'--test-global-setup',
'--test-rerun-failures ./failures.json',
'--test-rerun-failures=',
'--test-rerun-failures',
'-C foo',
'-C',
'--loader',
'--loader=',
'--loader=foo',
'--inspect-port',
'--inspect-port=',
'--inspect-port=9229',
'--debug-port',
'--debug-port=',
'--debug-port=9229',
'--inspect=',
'--inspect=9229',
'--inspect-brk=',
'--inspect-brk=9229',
'--inspect-wait=',
'--inspect-wait=9229',
'--inspect-brk-node=',
'--inspect-publish-uid',
'--inspect-publish-uid=stderr',
'--max-old-space-size',
'--max-old-space-size=',
'--max-old-space-size=4096',
'--max-old-space-size 4096',
'--max-semi-space-size',
'--max-semi-space-size=',
'--max-semi-space-size=16',
'--max-semi-space-size 16',
'--max-old-space-size-percentage',
'--max-old-space-size-percentage=',
'--max-old-space-size-percentage=50',
'--max-old-space-size-percentage 50',
'--stack-trace-limit',
'--stack-trace-limit=',
'--stack-trace-limit=10',
'--stack-trace-limit 10',
'--experimental-import-meta-resolve',
'foo',
'-',
'--',
'"foo --use-openssl-ca bar"',
'"--use-openssl-ca"',
"'--use-openssl-ca'",
'--use-openssl-ca\t',
'\t--use-openssl-ca',
'--title="foo --use-openssl-ca bar"',
'--conditions="foo --use-openssl-ca bar"',
'--conditions "foo \\" --use-openssl-ca bar"',
'--conditions=--use-openssl-ca',
'--title openclaude',
'--experimental-vm-modules',
'--enable-source-maps',
'--enable-source-maps=',
'--expose-gc',
'--no-expose-gc',
'--throw-deprecation',
'--trace-warnings',
'--no-warnings',
'--max-http-header-size=16384',
'--dns-result-order=verbatim',
'--dns-result-order=bogus',
'--unhandled-rejections=strict',
'--unhandled-rejections=bogus',
'--report-directory=out',
]
const matrix = new Set<string>(CA_FORMS)
for (const construct of CONSTRUCTS) {
matrix.add(`${construct} --use-openssl-ca`)
matrix.add(`--use-openssl-ca ${construct}`)
matrix.add(`--use-openssl-ca=1 ${construct}`)
matrix.add(`${construct} --use-openssl-ca=1`)
}
let failures = 0
let checked = 0
let runtimeErrors = 0
let timeouts = 0
for (const nodeOptions of matrix) {
const expected = nodeOutcome(nodeOptions)
if (expected === 'timeout') {
// Node started and is waiting for a debugger (`--inspect-brk`,
// `--inspect-wait`), so the option stream was accepted but the CA state
// cannot be observed.
timeouts++
continue
}
if (expected === 'runtime-error') {
runtimeErrors++
continue
}
checked++
// A stream Node rejects leaves no option active, so the helper must fail
// closed and report the flag as disabled.
const expectedState = expected === 'rejected' ? 'disabled' : expected
const actual = helperOutcome(nodeOptions, flag)
if (expectedState !== actual) {
failures++
console.log(
`MISMATCH node=${expected.padEnd(9)} helper=${actual.padEnd(9)} ${JSON.stringify(nodeOptions)}`,
)
}
// The sibling CA flag shares the parser, so a rejected stream must make it
// fail closed as well.
if (expected === 'rejected') {
const sibling = '--use-system-ca'
if (helperOutcome(nodeOptions, sibling) !== 'disabled') {
failures++
console.log(
`MISMATCH sibling ${sibling} did not fail closed for ${JSON.stringify(nodeOptions)}`,
)
}
}
}
console.log(
`\nchecked=${checked} mismatches=${failures} runtime-errors=${runtimeErrors} timeouts=${timeouts} (node=${version}, flag=${flag})`,
)
// A run that checked nothing must not report success.
if (checked === 0) {
console.error('No case was checked; the parity result is meaningless.')
process.exit(1)
}
process.exit(failures === 0 ? 0 : 1)
+535
View File
@@ -0,0 +1,535 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { createRequire } from 'module'
import { tmpdir } from 'os'
import { join } from 'path'
import { clearCACertsCache, getCACertificates } from './caCerts.js'
import { hasNodeOption } from './envUtils.js'
// Required-value options accepted by the supported Node 24/26 runtimes.
const testValueOptions = [
['--test-isolation', 'none'],
['--experimental-test-isolation', 'none'],
['--test-global-setup', './setup.js'],
['--test-rerun-failures', './failures.json'],
] as const
describe('hasNodeOption', () => {
const originalNodeOptions = process.env.NODE_OPTIONS
afterEach(() => {
if (originalNodeOptions === undefined) {
delete process.env.NODE_OPTIONS
} else {
process.env.NODE_OPTIONS = originalNodeOptions
}
})
beforeEach(() => {
delete process.env.NODE_OPTIONS
})
// Table-driven regression coverage requested in PR #2188 review:
// - exact and equals positives for both CA flags
// - double-quoted positives
// - literal single-quoted negatives (Node keeps single quotes literal)
// - repeated whitespace / empty tokens
// - --inspect vs --inspect-brk boundary
// Each row restores NODE_OPTIONS after the case via afterEach.
const cases: Array<{
name: string
nodeOptions: string | undefined
flag: string
expected: boolean
}> = [
// Undefined / empty
{ name: 'undefined NODE_OPTIONS returns false', nodeOptions: undefined, flag: '--use-system-ca', expected: false },
{ name: 'empty string returns false', nodeOptions: '', flag: '--use-system-ca', expected: false },
{ name: 'whitespace only returns false', nodeOptions: ' \t ', flag: '--use-system-ca', expected: false },
// --use-system-ca exact / equals
{ name: '--use-system-ca exact match', nodeOptions: '--use-system-ca', flag: '--use-system-ca', expected: true },
{ name: '--use-system-ca with =value', nodeOptions: '--use-system-ca=1', flag: '--use-system-ca', expected: true },
{ name: '--use-system-ca double-quoted exact', nodeOptions: '"--use-system-ca"', flag: '--use-system-ca', expected: true },
{ name: '--use-system-ca double-quoted with =value', nodeOptions: '"--use-system-ca=1"', flag: '--use-system-ca', expected: true },
{ name: "--use-system-ca single-quoted is literal (negative)", nodeOptions: "'--use-system-ca'", flag: '--use-system-ca', expected: false },
{ name: "--use-system-ca single-quoted with =value is literal (negative)", nodeOptions: "'--use-system-ca=1'", flag: '--use-system-ca', expected: false },
// --use-openssl-ca exact / equals
{ name: '--use-openssl-ca exact match', nodeOptions: '--use-openssl-ca', flag: '--use-openssl-ca', expected: true },
{ name: '--use-openssl-ca with =value', nodeOptions: '--use-openssl-ca=1', flag: '--use-openssl-ca', expected: true },
{ name: '--use-openssl-ca double-quoted exact', nodeOptions: '"--use-openssl-ca"', flag: '--use-openssl-ca', expected: true },
{ name: "--use-openssl-ca single-quoted is literal (negative)", nodeOptions: "'--use-openssl-ca'", flag: '--use-openssl-ca', expected: false },
// Repeated whitespace / multiple tokens
{ name: 'repeated whitespace between flags', nodeOptions: ' --use-system-ca --use-openssl-ca ', flag: '--use-system-ca', expected: true },
{ name: 'repeated whitespace second flag', nodeOptions: ' --use-system-ca --use-openssl-ca ', flag: '--use-openssl-ca', expected: true },
{ name: 'multiple spaces and tabs', nodeOptions: '--use-system-ca\t --max-old-space-size=4096', flag: '--max-old-space-size', expected: true },
{ name: 'flag not present with repeated whitespace', nodeOptions: ' --use-system-ca ', flag: '--use-openssl-ca', expected: false },
// Node only treats ASCII spaces as delimiters: a tab with no spaces keeps
// the input as one token, so neither option is reported (verified on Node
// 22.23.2 where the tab-joined value is rejected as a bad option).
{ name: 'tab without spaces does not delimit (second flag)', nodeOptions: '--use-system-ca\t--max-old-space-size=4096', flag: '--max-old-space-size', expected: false },
{ name: 'tab without spaces does not delimit (first flag)', nodeOptions: '--use-system-ca\t--max-old-space-size=4096', flag: '--use-system-ca', expected: false },
// --inspect vs --inspect-brk boundary
{ name: '--inspect exact', nodeOptions: '--inspect', flag: '--inspect', expected: true },
{ name: '--inspect with =value', nodeOptions: '--inspect=0.0.0.0:9229', flag: '--inspect', expected: true },
{ name: '--inspect double-quoted with =value', nodeOptions: '"--inspect=0.0.0.0:9229"', flag: '--inspect', expected: true },
{ name: '--inspect-brk does not match --inspect (prefix without =)', nodeOptions: '--inspect-brk=9229', flag: '--inspect', expected: false },
{ name: '--inspect-brk exact matches itself', nodeOptions: '--inspect-brk=9229', flag: '--inspect-brk', expected: true },
{ name: '--inspect-brk exact', nodeOptions: '--inspect-brk', flag: '--inspect-brk', expected: true },
{ name: '--inspect does not match --inspect-brk', nodeOptions: '--inspect', flag: '--inspect-brk', expected: false },
{ name: '--inspect single-quoted is literal (negative)', nodeOptions: "'--inspect'", flag: '--inspect', expected: false },
{ name: '--inspect-brk single-quoted is literal (negative for --inspect)', nodeOptions: "'--inspect-brk=9229'", flag: '--inspect', expected: false },
// --max-old-space-size (original PR motivation)
{ name: '--max-old-space-size with =value', nodeOptions: '--max-old-space-size=4096', flag: '--max-old-space-size', expected: true },
{ name: '--max-old-space-size double-quoted with =value', nodeOptions: '"--max-old-space-size=4096"', flag: '--max-old-space-size', expected: true },
{ name: "--max-old-space-size single-quoted is literal (negative)", nodeOptions: "'--max-old-space-size=4096'", flag: '--max-old-space-size', expected: false },
{ name: '--max-old-space-size exact without value fails closed (Node requires an argument)', nodeOptions: '--max-old-space-size', flag: '--max-old-space-size', expected: false },
{ name: '--max-old-space-size not confused with prefix', nodeOptions: '--max-old-space-size-extra', flag: '--max-old-space-size', expected: false },
// Mixed flags
{ name: 'mixed flags contains target', nodeOptions: '--experimental-vm-modules --max-old-space-size=4096 --inspect=9229', flag: '--max-old-space-size', expected: true },
{ name: 'mixed flags does not contain absent', nodeOptions: '--experimental-vm-modules --max-old-space-size=4096', flag: '--use-system-ca', expected: false },
// Quoted option values must not create spurious flag tokens (Node quote grammar)
{ name: 'quoted value with option-like text is not a flag', nodeOptions: '--conditions "foo --use-system-ca bar"', flag: '--use-system-ca', expected: false },
{ name: 'quoted value with option-like text — explicit flag still matches', nodeOptions: '--conditions "foo --use-system-ca bar" --use-system-ca', flag: '--use-system-ca', expected: true },
{ name: 'equals-quoted value with option-like text is not a flag', nodeOptions: '--title="foo --use-system-ca bar"', flag: '--use-system-ca', expected: false },
{ name: 'quoted value preserves other flag detection', nodeOptions: '--title="foo bar" --use-system-ca', flag: '--use-system-ca', expected: true },
{ name: 'quoted value with --expose-gc inner text is not a flag', nodeOptions: '--conditions "foo --expose-gc bar"', flag: '--expose-gc', expected: false },
{ name: '--conditions value exactly --use-system-ca is not a flag', nodeOptions: '--conditions "--use-system-ca"', flag: '--use-system-ca', expected: false },
{ name: '--conditions escaped quote does not expose inner flag', nodeOptions: '--conditions "foo \\" --use-system-ca bar"', flag: '--use-system-ca', expected: false },
{ name: '--conditions escaped value still allows explicit flag after', nodeOptions: '--conditions "foo \\" --use-system-ca bar" --use-system-ca', flag: '--use-system-ca', expected: true },
{ name: '--conditions with equals value is not a flag', nodeOptions: '--conditions=--use-system-ca', flag: '--use-system-ca', expected: false },
// Required option value that looks flag-like: Node rejects the whole
// value (e.g. `--title requires an argument`), so nothing is active.
{ name: '--title with flag-like value fails closed', nodeOptions: '--title --use-system-ca', flag: '--use-system-ca', expected: false },
{ name: '--title with flag-like value fails closed for the option itself', nodeOptions: '--title --use-system-ca', flag: '--title', expected: false },
{ name: '--title with real value still detects later flag', nodeOptions: '--title openclaude --use-system-ca', flag: '--use-system-ca', expected: true },
// Malformed quoting fails closed (Node rejects the whole value)
{ name: 'unterminated double quote is not a flag', nodeOptions: '"--use-system-ca', flag: '--use-system-ca', expected: false },
{ name: 'unterminated double quote with trailing escape is not a flag', nodeOptions: '"--use-system-ca\\', flag: '--use-system-ca', expected: false },
{ name: 'unterminated double quote is not a flag (--use-openssl-ca)', nodeOptions: '"--use-openssl-ca', flag: '--use-openssl-ca', expected: false },
{ name: 'unterminated double quote with trailing escape is not a flag (--use-openssl-ca)', nodeOptions: '"--use-openssl-ca\\', flag: '--use-openssl-ca', expected: false },
{ name: 'malformed leading flag does not leak later flag', nodeOptions: '"--use-system-ca --use-openssl-ca', flag: '--use-openssl-ca', expected: false },
// Ordered CA state: later occurrences win (equals-positive forms)
{ name: '--use-system-ca=1 then --no-use-system-ca disables', nodeOptions: '--use-system-ca=1 --no-use-system-ca', flag: '--use-system-ca', expected: false },
{ name: '--no-use-system-ca then --use-system-ca=1 re-enables', nodeOptions: '--no-use-system-ca --use-system-ca=1', flag: '--use-system-ca', expected: true },
{ name: '--use-openssl-ca=1 then --no-use-openssl-ca disables', nodeOptions: '--use-openssl-ca=1 --no-use-openssl-ca', flag: '--use-openssl-ca', expected: false },
{ name: '--no-use-openssl-ca then --use-openssl-ca=1 re-enables', nodeOptions: '--no-use-openssl-ca --use-openssl-ca=1', flag: '--use-openssl-ca', expected: true },
// Exact-positive ordering baseline (last wins)
{ name: '--use-system-ca then --no-use-system-ca disables (exact)', nodeOptions: '--use-system-ca --no-use-system-ca', flag: '--use-system-ca', expected: false },
{ name: '--no-use-system-ca then --use-system-ca re-enables (exact)', nodeOptions: '--no-use-system-ca --use-system-ca', flag: '--use-system-ca', expected: true },
{ name: '--use-openssl-ca then --no-use-openssl-ca disables (exact)', nodeOptions: '--use-openssl-ca --no-use-openssl-ca', flag: '--use-openssl-ca', expected: false },
{ name: '--no-use-openssl-ca then --use-openssl-ca re-enables (exact)', nodeOptions: '--no-use-openssl-ca --use-openssl-ca', flag: '--use-openssl-ca', expected: true },
{ name: 'negation alone does not enable positive', nodeOptions: '--no-use-system-ca', flag: '--use-system-ca', expected: false },
{ name: 'negation with =value disables equals-positive', nodeOptions: '--use-system-ca=1 --no-use-system-ca=0', flag: '--use-system-ca', expected: false },
{ name: 'negated spelling is queryable', nodeOptions: '--no-use-system-ca', flag: '--no-use-system-ca', expected: true },
{ name: 'negated spelling is not active after a later positive', nodeOptions: '--no-use-system-ca --use-system-ca', flag: '--no-use-system-ca', expected: false },
{ name: 'negated spelling is active after a later negation', nodeOptions: '--use-system-ca --no-use-system-ca', flag: '--no-use-system-ca', expected: true },
{ name: 'negated spelling is absent when the option is not present', nodeOptions: '--max-old-space-size=4096', flag: '--no-use-system-ca', expected: false },
{ name: 'negated spelling is absent for an unrelated stream', nodeOptions: '--title foo', flag: '--no-use-system-ca', expected: false },
// Required-value failures invalidate the whole stream even when the CA
// target appears before the error (Node exits, so nothing is active).
{ name: '--use-system-ca=1 then --title at EOF fails closed', nodeOptions: '--use-system-ca=1 --title', flag: '--use-system-ca', expected: false },
{ name: '--use-openssl-ca=1 then --title at EOF fails closed', nodeOptions: '--use-openssl-ca=1 --title', flag: '--use-openssl-ca', expected: false },
{ name: '--use-system-ca=1 then --title= fails closed', nodeOptions: '--use-system-ca=1 --title=', flag: '--use-system-ca', expected: false },
{ name: '--use-openssl-ca=1 then --title= fails closed', nodeOptions: '--use-openssl-ca=1 --title=', flag: '--use-openssl-ca', expected: false },
{ name: '--use-system-ca=1 then --conditions at EOF fails closed', nodeOptions: '--use-system-ca=1 --conditions', flag: '--use-system-ca', expected: false },
{ name: '--use-system-ca=1 then --conditions= fails closed', nodeOptions: '--use-system-ca=1 --conditions=', flag: '--use-system-ca', expected: false },
{ name: '--use-system-ca=1 then --conditions flag-like fails closed', nodeOptions: '--use-system-ca=1 --conditions -x', flag: '--use-system-ca', expected: false },
{ name: '--conditions flag-like then CA fails closed', nodeOptions: '--conditions -x --use-system-ca=1', flag: '--use-system-ca', expected: false },
// Required-value aliases follow the same rule (error before and after target).
{ name: '--loader flag-like then CA fails closed', nodeOptions: '--loader --use-system-ca=1', flag: '--use-system-ca', expected: false },
{ name: 'CA then --loader at EOF fails closed', nodeOptions: '--use-system-ca=1 --loader', flag: '--use-system-ca', expected: false },
{ name: 'CA then --loader= fails closed', nodeOptions: '--use-system-ca=1 --loader=', flag: '--use-system-ca', expected: false },
{ name: '--loader flag-like then openssl CA fails closed', nodeOptions: '--loader --use-openssl-ca=1', flag: '--use-openssl-ca', expected: false },
{ name: 'openssl CA then --loader at EOF fails closed', nodeOptions: '--use-openssl-ca=1 --loader', flag: '--use-openssl-ca', expected: false },
{ name: '--inspect-port flag-like then CA fails closed', nodeOptions: '--inspect-port --use-system-ca=1', flag: '--use-system-ca', expected: false },
{ name: 'CA then --inspect-port at EOF fails closed', nodeOptions: '--use-system-ca=1 --inspect-port', flag: '--use-system-ca', expected: false },
{ name: 'CA then --inspect-port= fails closed', nodeOptions: '--use-system-ca=1 --inspect-port=', flag: '--use-system-ca', expected: false },
{ name: '--inspect-port flag-like then openssl CA fails closed', nodeOptions: '--inspect-port --use-openssl-ca=1', flag: '--use-openssl-ca', expected: false },
{ name: '--inspect-publish-uid flag-like then CA fails closed', nodeOptions: '--inspect-publish-uid --use-system-ca=1', flag: '--use-system-ca', expected: false },
{ name: 'CA then --inspect-publish-uid at EOF fails closed', nodeOptions: '--use-system-ca=1 --inspect-publish-uid', flag: '--use-system-ca', expected: false },
{ name: '--inspect-publish-uid flag-like then openssl CA fails closed', nodeOptions: '--inspect-publish-uid --use-openssl-ca=1', flag: '--use-openssl-ca', expected: false },
{ name: 'CA then --title flag-like value fails closed (openssl)', nodeOptions: '--use-openssl-ca=1 --title --use-system-ca', flag: '--use-openssl-ca', expected: false },
// Valid required-value forms preserve later CA detection.
{ name: '--title with real value preserves later CA', nodeOptions: '--title foo --use-system-ca=1', flag: '--use-system-ca', expected: true },
{ name: '--title=foo preserves later CA', nodeOptions: '--title=foo --use-system-ca=1', flag: '--use-system-ca', expected: true },
{ name: '--loader=foo preserves later CA', nodeOptions: '--loader=foo --use-system-ca=1', flag: '--use-system-ca', expected: true },
{ name: '--title=--use-system-ca inline flag-like value is valid (no CA)', nodeOptions: '--title=--use-system-ca', flag: '--use-system-ca', expected: false },
// --experimental-import-meta-resolve is boolean: following CA stays visible.
{ name: 'boolean import-meta-resolve then CA stays visible', nodeOptions: '--experimental-import-meta-resolve --use-system-ca', flag: '--use-system-ca', expected: true },
{ name: 'boolean import-meta-resolve then openssl CA stays visible', nodeOptions: '--experimental-import-meta-resolve --use-openssl-ca', flag: '--use-openssl-ca', expected: true },
{ name: 'boolean import-meta-resolve then equals CA stays visible', nodeOptions: '--experimental-import-meta-resolve --use-system-ca=1', flag: '--use-system-ca', expected: true },
// V8-owned heap flags have their own value grammar: the value must be
// attached with `=`, an empty inline value is tolerated by V8 (Node starts
// and only warns), and a separate value leaves the bare flag in front of
// V8, which rejects it.
{ name: 'empty inline max-old-space-size keeps CA enabled', nodeOptions: '--use-system-ca --max-old-space-size=', flag: '--use-system-ca', expected: true },
{ name: 'empty inline max-semi-space-size keeps CA enabled', nodeOptions: '--use-system-ca --max-semi-space-size=', flag: '--use-system-ca', expected: true },
{ name: 'empty inline max-old-space-size keeps openssl CA enabled', nodeOptions: '--use-openssl-ca --max-old-space-size=', flag: '--use-openssl-ca', expected: true },
{ name: 'empty inline max-semi-space-size keeps openssl CA enabled', nodeOptions: '--use-openssl-ca --max-semi-space-size=', flag: '--use-openssl-ca', expected: true },
{ name: 'separated max-old-space-size value fails closed', nodeOptions: '--use-system-ca=1 --max-old-space-size 4096', flag: '--use-system-ca', expected: false },
{ name: 'separated max-semi-space-size value fails closed', nodeOptions: '--use-system-ca=1 --max-semi-space-size 16', flag: '--use-system-ca', expected: false },
{ name: 'separated max-old-space-size value fails closed (openssl)', nodeOptions: '--use-openssl-ca=1 --max-old-space-size 4096', flag: '--use-openssl-ca', expected: false },
{ name: 'separated stack-trace-limit value fails closed', nodeOptions: '--use-system-ca=1 --stack-trace-limit 10', flag: '--use-system-ca', expected: false },
{ name: 'empty inline stack-trace-limit fails closed', nodeOptions: '--use-system-ca=1 --stack-trace-limit=', flag: '--use-system-ca', expected: false },
{ name: 'inline stack-trace-limit value keeps CA enabled', nodeOptions: '--use-system-ca=1 --stack-trace-limit=10', flag: '--use-system-ca', expected: true },
{ name: 'inline stack-trace-limit value keeps openssl CA enabled', nodeOptions: '--use-openssl-ca=1 --stack-trace-limit=10', flag: '--use-openssl-ca', expected: true },
{ name: 'empty inline max-old-space-size-percentage fails closed', nodeOptions: '--use-system-ca=1 --max-old-space-size-percentage=', flag: '--use-system-ca', expected: false },
{ name: 'separated max-old-space-size-percentage value keeps CA enabled', nodeOptions: '--use-system-ca=1 --max-old-space-size-percentage 50', flag: '--use-system-ca', expected: true },
{ name: 'inline max-old-space-size-percentage value keeps CA enabled', nodeOptions: '--use-system-ca=1 --max-old-space-size-percentage=50', flag: '--use-system-ca', expected: true },
// The inspector family: `--inspect-port` (and its `--debug-port` alias)
// require a value, while the bare `--inspect`/`--inspect-brk`/
// `--inspect-wait` forms are booleans. The `=`-forms expand through
// `--inspect-port`, so an empty inline value is rejected by Node.
{ name: '--inspect-port without value fails closed', nodeOptions: '--use-system-ca=1 --inspect-port', flag: '--use-system-ca', expected: false },
{ name: '--debug-port without value fails closed', nodeOptions: '--use-system-ca=1 --debug-port', flag: '--use-system-ca', expected: false },
{ name: '--inspect= without value fails closed', nodeOptions: '--use-system-ca=1 --inspect=', flag: '--use-system-ca', expected: false },
{ name: '--inspect-brk= without value fails closed', nodeOptions: '--use-system-ca=1 --inspect-brk=', flag: '--use-system-ca', expected: false },
{ name: '--inspect-wait= without value fails closed', nodeOptions: '--use-system-ca=1 --inspect-wait=', flag: '--use-system-ca', expected: false },
{ name: '--inspect-brk-node= without value fails closed', nodeOptions: '--use-system-ca=1 --inspect-brk-node=', flag: '--use-system-ca', expected: false },
{ name: '--inspect-publish-uid without value fails closed', nodeOptions: '--use-system-ca=1 --inspect-publish-uid', flag: '--use-system-ca', expected: false },
{ name: 'bare --inspect keeps CA enabled', nodeOptions: '--use-system-ca=1 --inspect', flag: '--use-system-ca', expected: true },
{ name: 'bare --inspect-brk keeps CA enabled', nodeOptions: '--use-system-ca=1 --inspect-brk', flag: '--use-system-ca', expected: true },
{ name: 'bare --inspect-wait keeps CA enabled', nodeOptions: '--use-system-ca=1 --inspect-wait', flag: '--use-system-ca', expected: true },
{ name: '--inspect= with value keeps CA enabled', nodeOptions: '--use-system-ca=1 --inspect=9229', flag: '--use-system-ca', expected: true },
{ name: '--inspect-brk= with value keeps CA enabled', nodeOptions: '--use-system-ca=1 --inspect-brk=9229', flag: '--use-system-ca', expected: true },
{ name: '--inspect-wait= with value keeps CA enabled', nodeOptions: '--use-system-ca=1 --inspect-wait=9229', flag: '--use-system-ca', expected: true },
{ name: '--inspect-port= with value keeps CA enabled', nodeOptions: '--use-system-ca=1 --inspect-port=9229', flag: '--use-system-ca', expected: true },
{ name: '--debug-port= with value keeps CA enabled', nodeOptions: '--use-system-ca=1 --debug-port=9229', flag: '--use-system-ca', expected: true },
{ name: '--debug-port without value fails closed (openssl)', nodeOptions: '--use-openssl-ca=1 --debug-port', flag: '--use-openssl-ca', expected: false },
{ name: '--inspect= without value fails closed (openssl)', nodeOptions: '--use-openssl-ca=1 --inspect=', flag: '--use-openssl-ca', expected: false },
// Node stops parsing options at the first argument that is not an option,
// so the ignored suffix neither enables a CA flag nor creates a
// required-value error, while the preceding CA state is preserved.
{ name: 'ignored suffix keeps preceding CA state', nodeOptions: '--use-system-ca foo --title', flag: '--use-system-ca', expected: true },
{ name: 'ignored suffix keeps preceding openssl CA state', nodeOptions: '--use-openssl-ca foo --title', flag: '--use-openssl-ca', expected: true },
{ name: 'ignored suffix keeps preceding CA state (equals)', nodeOptions: '--use-system-ca=1 foo', flag: '--use-system-ca', expected: true },
{ name: 'consumed value is not an ignored suffix', nodeOptions: '--title foo --use-system-ca', flag: '--use-system-ca', expected: true },
{ name: 'CA token inside the ignored suffix is not enabled', nodeOptions: 'foo --use-system-ca=1', flag: '--use-system-ca', expected: false },
{ name: 'CA token inside the ignored suffix is not enabled (openssl)', nodeOptions: 'foo --use-openssl-ca=1', flag: '--use-openssl-ca', expected: false },
{ name: 'lone hyphen stops option parsing', nodeOptions: '--use-system-ca -', flag: '--use-system-ca', expected: true },
{ name: 'lone hyphen stops option parsing before the CA flag', nodeOptions: '- --use-system-ca', flag: '--use-system-ca', expected: false },
{ name: '-- is not allowed in NODE_OPTIONS', nodeOptions: '--use-system-ca --', flag: '--use-system-ca', expected: false },
{ name: '-- before the CA flag is not allowed in NODE_OPTIONS', nodeOptions: '-- --use-system-ca', flag: '--use-system-ca', expected: false },
]
for (const { name, nodeOptions, flag, expected } of cases) {
test(name, () => {
if (nodeOptions === undefined) {
delete process.env.NODE_OPTIONS
} else {
process.env.NODE_OPTIONS = nodeOptions
}
expect(hasNodeOption(flag)).toBe(expected)
})
}
test('supported test options consume their values before later CA flags', () => {
for (const [option, value] of testValueOptions) {
for (const flag of ['--use-system-ca', '--use-openssl-ca']) {
for (const spelling of [`${option} ${value}`, `${option}=${value}`]) {
process.env.NODE_OPTIONS = `${spelling} ${flag}`
expect(hasNodeOption(flag)).toBe(true)
process.env.NODE_OPTIONS = `${flag} ${spelling}`
expect(hasNodeOption(flag)).toBe(true)
}
}
}
})
test('supported test options reject missing, empty, flag-like and negated values', () => {
for (const [option] of testValueOptions) {
for (const flag of ['--use-system-ca', '--use-openssl-ca']) {
for (const nodeOptions of [
`${flag}=1 ${option}`,
`${flag}=1 ${option}=`,
`${option}= ${flag}=1`,
`${option} ${flag}=1`,
`${flag}=1 --no-${option.slice(2)}`,
`--no-${option.slice(2)}=none ${flag}=1`,
]) {
process.env.NODE_OPTIONS = nodeOptions
expect(hasNodeOption(flag)).toBe(false)
}
}
}
})
test('does not leak NODE_OPTIONS between cases (sanity)', () => {
// This test runs after the table; afterEach should have restored original.
// Set a distinct value and verify it does not persist to next test via afterEach,
// but here we just verify the helper reads current env.
process.env.NODE_OPTIONS = '--use-system-ca'
expect(hasNodeOption('--use-system-ca')).toBe(true)
expect(hasNodeOption('--use-openssl-ca')).toBe(false)
})
})
describe('hasNodeOption CA selection via cache rebuild', () => {
const originalNodeOptions = process.env.NODE_OPTIONS
const originalExtraCerts = process.env.NODE_EXTRA_CA_CERTS
afterEach(() => {
if (originalNodeOptions === undefined) {
delete process.env.NODE_OPTIONS
} else {
process.env.NODE_OPTIONS = originalNodeOptions
}
if (originalExtraCerts === undefined) {
delete process.env.NODE_EXTRA_CA_CERTS
} else {
process.env.NODE_EXTRA_CA_CERTS = originalExtraCerts
}
clearCACertsCache()
})
beforeEach(() => {
delete process.env.NODE_OPTIONS
delete process.env.NODE_EXTRA_CA_CERTS
clearCACertsCache()
})
function getEffectiveCACerts(nodeOptions: string): string[] | undefined {
// Simulates the settings-reload path: applyConfigEnvironmentVariables()
// replaces process.env.NODE_OPTIONS, then clears CA/proxy/mTLS caches and
// rebuilds global agents. getCACertificates() must reflect the effective
// ordered CA state after the rebuild.
process.env.NODE_OPTIONS = nodeOptions
clearCACertsCache()
return getCACertificates()
}
test('malformed unterminated quote selects bundled roots (undefined)', () => {
expect(getEffectiveCACerts('"--use-system-ca')).toBeUndefined()
})
test('malformed trailing escape selects bundled roots (undefined)', () => {
expect(getEffectiveCACerts('"--use-system-ca\\')).toBeUndefined()
})
test('equals-positive then negation selects bundled roots (undefined)', () => {
expect(
getEffectiveCACerts('--use-system-ca=1 --no-use-system-ca'),
).toBeUndefined()
expect(
getEffectiveCACerts('--use-openssl-ca=1 --no-use-openssl-ca'),
).toBeUndefined()
})
test('required value with flag-like text selects bundled roots (undefined)', () => {
expect(getEffectiveCACerts('--title --use-system-ca')).toBeUndefined()
})
test('negation then equals-positive selects system roots (defined)', () => {
expect(
getEffectiveCACerts('--no-use-system-ca --use-system-ca=1'),
).toBeDefined()
expect(
getEffectiveCACerts('--no-use-openssl-ca --use-openssl-ca=1'),
).toBeDefined()
})
test('target-before-error selects bundled roots (undefined)', () => {
expect(getEffectiveCACerts('--use-system-ca=1 --title')).toBeUndefined()
expect(getEffectiveCACerts('--use-openssl-ca=1 --title')).toBeUndefined()
expect(getEffectiveCACerts('--use-system-ca=1 --title=')).toBeUndefined()
expect(getEffectiveCACerts('--use-system-ca=1 --loader')).toBeUndefined()
expect(
getEffectiveCACerts('--use-system-ca=1 --inspect-port'),
).toBeUndefined()
expect(
getEffectiveCACerts('--use-system-ca=1 --inspect-publish-uid'),
).toBeUndefined()
})
test('alias error-before-target selects bundled roots (undefined)', () => {
expect(getEffectiveCACerts('--loader --use-system-ca=1')).toBeUndefined()
expect(
getEffectiveCACerts('--inspect-port --use-system-ca=1'),
).toBeUndefined()
expect(
getEffectiveCACerts('--inspect-publish-uid --use-openssl-ca=1'),
).toBeUndefined()
})
test('boolean import-meta-resolve preserves system roots (defined)', () => {
expect(
getEffectiveCACerts('--experimental-import-meta-resolve --use-system-ca'),
).toBeDefined()
expect(
getEffectiveCACerts(
'--experimental-import-meta-resolve --use-openssl-ca',
),
).toBeDefined()
})
})
describe('hasNodeOption CA source selection', () => {
const originalNodeOptions = process.env.NODE_OPTIONS
const originalExtraCerts = process.env.NODE_EXTRA_CA_CERTS
const nodeRequire = createRequire(import.meta.url)
const tls = nodeRequire('tls') as typeof import('tls')
const originalGetCACertificates = tls.getCACertificates
// The bundled and system stores can hold the same certificates, so
// "defined" alone cannot prove which root source the selector picked.
// Stubbing the system store makes the choice observable.
const SYSTEM_SENTINEL = 'SYSTEM-CA-SENTINEL'
const EXTRA_CA_SENTINEL = 'EXTRA-CA-SENTINEL'
let extraCertsDir: string | undefined
beforeEach(() => {
delete process.env.NODE_OPTIONS
delete process.env.NODE_EXTRA_CA_CERTS
tls.getCACertificates = (type) =>
type === 'system' ? [SYSTEM_SENTINEL] : []
extraCertsDir = mkdtempSync(join(tmpdir(), 'openclaude-ca-'))
writeFileSync(join(extraCertsDir, 'extra.pem'), EXTRA_CA_SENTINEL)
clearCACertsCache()
})
afterEach(() => {
tls.getCACertificates = originalGetCACertificates
if (originalNodeOptions === undefined) {
delete process.env.NODE_OPTIONS
} else {
process.env.NODE_OPTIONS = originalNodeOptions
}
if (originalExtraCerts === undefined) {
delete process.env.NODE_EXTRA_CA_CERTS
} else {
process.env.NODE_EXTRA_CA_CERTS = originalExtraCerts
}
if (extraCertsDir) {
rmSync(extraCertsDir, { recursive: true, force: true })
extraCertsDir = undefined
}
clearCACertsCache()
})
function getEffectiveCACerts(nodeOptions: string): string[] | undefined {
// Simulates the settings-reload path: applyConfigEnvironmentVariables()
// replaces process.env.NODE_OPTIONS, then clears CA/proxy/mTLS caches and
// rebuilds global agents.
process.env.NODE_OPTIONS = nodeOptions
clearCACertsCache()
return getCACertificates()
}
test('active CA flag selects the system root store', () => {
expect(getEffectiveCACerts('--use-system-ca')).toEqual([SYSTEM_SENTINEL])
expect(getEffectiveCACerts('--use-openssl-ca')).toEqual([SYSTEM_SENTINEL])
expect(getEffectiveCACerts('--use-system-ca=1')).toEqual([SYSTEM_SENTINEL])
})
test('inactive CA flag keeps the runtime default (undefined)', () => {
expect(getEffectiveCACerts('--no-use-system-ca')).toBeUndefined()
expect(
getEffectiveCACerts('--use-system-ca=1 --no-use-system-ca'),
).toBeUndefined()
})
test('invalid streams keep the runtime default (undefined)', () => {
// Missing required value, flag-like value, malformed quoting, an invalid
// V8 value form and `--` all make Node reject the whole value.
expect(getEffectiveCACerts('--use-system-ca=1 --title')).toBeUndefined()
expect(getEffectiveCACerts('--title --use-system-ca')).toBeUndefined()
expect(getEffectiveCACerts('"--use-system-ca')).toBeUndefined()
expect(
getEffectiveCACerts('--use-system-ca=1 --max-old-space-size 4096'),
).toBeUndefined()
expect(getEffectiveCACerts('--use-system-ca=1 --inspect=')).toBeUndefined()
expect(getEffectiveCACerts('--use-system-ca --')).toBeUndefined()
})
test('accepted empty inline heap value keeps the system store', () => {
// V8 tolerates an empty inline value for its own heap flags, so Node
// starts with the CA flag enabled.
expect(
getEffectiveCACerts('--use-system-ca --max-old-space-size='),
).toEqual([SYSTEM_SENTINEL])
expect(
getEffectiveCACerts('--use-system-ca --max-semi-space-size='),
).toEqual([SYSTEM_SENTINEL])
})
test('valid inspector forms keep the system store', () => {
// Bare inspector booleans and populated inline endpoints are accepted by
// Node, so the CA flag stays enabled.
expect(getEffectiveCACerts('--use-system-ca=1 --inspect')).toEqual([
SYSTEM_SENTINEL,
])
expect(getEffectiveCACerts('--use-system-ca=1 --inspect=9229')).toEqual([
SYSTEM_SENTINEL,
])
expect(getEffectiveCACerts('--use-system-ca=1 --inspect-port=9229')).toEqual([
SYSTEM_SENTINEL,
])
})
test('active CA flag selects the system store plus the extra certificate', () => {
process.env.NODE_EXTRA_CA_CERTS = join(extraCertsDir!, 'extra.pem')
expect(getEffectiveCACerts('--use-system-ca')).toEqual([
SYSTEM_SENTINEL,
EXTRA_CA_SENTINEL,
])
expect(getEffectiveCACerts('--use-openssl-ca')).toEqual([
SYSTEM_SENTINEL,
EXTRA_CA_SENTINEL,
])
})
test('supported test option values preserve the explicit CA source with extra certificates', () => {
process.env.NODE_EXTRA_CA_CERTS = join(extraCertsDir!, 'extra.pem')
for (const [option, value] of testValueOptions) {
for (const flag of ['--use-system-ca', '--use-openssl-ca']) {
for (const spelling of [`${option} ${value}`, `${option}=${value}`]) {
for (const nodeOptions of [`${spelling} ${flag}`, `${flag} ${spelling}`]) {
expect(getEffectiveCACerts(nodeOptions)).toEqual([
SYSTEM_SENTINEL,
EXTRA_CA_SENTINEL,
])
}
}
expect(getEffectiveCACerts(`${flag}=1 ${option}=`)).toEqual([
...tls.rootCertificates,
EXTRA_CA_SENTINEL,
])
}
}
})
test('inactive CA flag selects the bundled roots plus the extra certificate', () => {
process.env.NODE_EXTRA_CA_CERTS = join(extraCertsDir!, 'extra.pem')
const bundled = tls.rootCertificates
const certs = getEffectiveCACerts('--use-system-ca --no-use-system-ca')
expect(certs).toBeDefined()
expect(certs![0]).toBe(bundled[0])
expect(certs!.length).toBe(bundled.length + 1)
expect(certs![certs!.length - 1]).toBe(EXTRA_CA_SENTINEL)
})
test('ignored suffix preserves the preceding CA state', () => {
// Node stops parsing options at `foo`, so the trailing `--title` never
// becomes a required-value error and the CA flag stays enabled.
expect(getEffectiveCACerts('--use-system-ca foo --title')).toEqual([
SYSTEM_SENTINEL,
])
})
})
+382 -2
View File
@@ -92,16 +92,396 @@ export function getProjectsDir(): string {
return join(getClaudeConfigHomeDir(), 'projects')
}
/**
* How a NODE_OPTIONS option consumes its value, mirroring Node's
* `OptionsParser::Parse` (src/node_options.cc).
*/
type NodeOptionRole =
/** The option takes no value (`--use-system-ca`). */
| 'boolean'
/**
* The option requires a value: either attached with `=` (and not empty) or
* supplied as the next argument, which must not start with `-`.
*/
| 'value'
/**
* The option requires its value to be attached with `=`. An empty inline
* value is accepted (V8-owned flags such as `--max-old-space-size`).
*/
| 'inline-value'
/**
* The option requires a non-empty inline value; a separate argument is not
* accepted (`--stack-trace-limit`).
*/
| 'inline-required'
/**
* NODE_OPTIONS options whose role differs from the default boolean.
* Originally audited against Node 22.23.2; newer required-value test options
* are also modeled for the supported Node 24/26 runtimes. Missing values fail
* the scoped parse, including the V8 flags with distinct attachment rules.
*
* Options that are not listed here are treated as booleans: they never
* consume the following argument. Unknown-option and disallowed-option
* validation is deliberately out of scope — Node rejects those streams
* outright, which this helper does not attempt to model.
*/
const NODE_OPTION_ROLES: ReadonlyMap<string, NodeOptionRole> = new Map<
string,
NodeOptionRole
>([
// V8-owned flags. The value must be attached with `=`; V8 tolerates an
// empty inline value (`--max-old-space-size=` starts and only warns) but
// rejects a bare flag and a separate value.
['--max-old-space-size', 'inline-value'],
['--max-semi-space-size', 'inline-value'],
// Node-owned flag that Node forwards to V8. The value must be attached
// with `=`: an empty inline value is rejected by Node itself, and a
// separate value is forwarded to V8 without its value, which V8 rejects.
['--stack-trace-limit', 'inline-required'],
// Node-owned options that take a value.
['--allow-fs-read', 'value'],
['--allow-fs-write', 'value'],
['--conditions', 'value'],
['-C', 'value'],
['--cpu-prof-dir', 'value'],
['--cpu-prof-interval', 'value'],
['--cpu-prof-name', 'value'],
['--debug-port', 'value'],
['--diagnostic-dir', 'value'],
['--disable-proto', 'value'],
['--disable-warning', 'value'],
['--dns-result-order', 'value'],
['--experimental-default-type', 'value'],
['--experimental-loader', 'value'],
['--heap-prof-dir', 'value'],
['--heap-prof-interval', 'value'],
['--heap-prof-name', 'value'],
['--heapsnapshot-near-heap-limit', 'value'],
['--heapsnapshot-signal', 'value'],
['--icu-data-dir', 'value'],
['--import', 'value'],
['--input-type', 'value'],
['--inspect-port', 'value'],
['--inspect-publish-uid', 'value'],
['--loader', 'value'],
['--localstorage-file', 'value'],
['--max-http-header-size', 'value'],
['--max-old-space-size-percentage', 'value'],
['--network-family-autoselection-attempt-timeout', 'value'],
['--openssl-config', 'value'],
['--redirect-warnings', 'value'],
['--report-dir', 'value'],
['--report-directory', 'value'],
['--report-filename', 'value'],
['--report-signal', 'value'],
['--require', 'value'],
['-r', 'value'],
['--secure-heap', 'value'],
['--secure-heap-min', 'value'],
['--snapshot-blob', 'value'],
['--test-coverage-branches', 'value'],
['--test-coverage-exclude', 'value'],
['--test-coverage-functions', 'value'],
['--test-coverage-include', 'value'],
['--test-coverage-lines', 'value'],
['--test-global-setup', 'value'],
['--test-isolation', 'value'],
['--test-name-pattern', 'value'],
['--test-reporter', 'value'],
['--test-reporter-destination', 'value'],
['--test-rerun-failures', 'value'],
['--test-shard', 'value'],
['--test-skip-pattern', 'value'],
['--title', 'value'],
['--tls-cipher-list', 'value'],
['--tls-keylog', 'value'],
['--trace-event-categories', 'value'],
['--trace-event-file-pattern', 'value'],
['--trace-require-module', 'value'],
['--unhandled-rejections', 'value'],
['--use-largepages', 'value'],
['--v8-pool-size', 'value'],
['--watch-kill-signal', 'value'],
['--watch-path', 'value'],
])
/**
* Node's alias expansions (src/node_options.cc `AddAlias`) for the options
* this helper models. The value lists the options an alias expands to; the
* first entry owns the value.
*/
const NODE_OPTION_ALIASES: ReadonlyMap<string, readonly string[]> = new Map([
['-C', ['--conditions']],
['-r', ['--require']],
['--debug-port', ['--inspect-port']],
['--loader', ['--experimental-loader']],
['--report-directory', ['--report-dir']],
['--experimental-test-isolation', ['--test-isolation']],
])
/**
* Aliases Node only matches in the `--name=value` form. They expand to
* `--inspect-port <value>` plus the matching boolean option, so the inline
* value is required and must not be empty.
*/
const NODE_INLINE_OPTION_ALIASES: ReadonlyMap<string, readonly string[]> =
new Map([
['--inspect=', ['--inspect-port', '--inspect']],
['--inspect-brk=', ['--inspect-port', '--inspect-brk']],
['--inspect-brk-node=', ['--inspect-port', '--inspect-brk-node']],
['--inspect-wait=', ['--inspect-port', '--inspect-wait']],
])
/** An option Node accepted, with its `--no-` prefix resolved. */
interface NodeOptionOccurrence {
/** The option name, without any `--no-` prefix. */
name: string
/** Whether the option was spelled as `--no-<name>`. */
negated: boolean
}
interface NodeOptionsParse {
/** False when Node would reject the whole NODE_OPTIONS value. */
valid: boolean
/** The options Node accepted, in the order they appear. */
options: NodeOptionOccurrence[]
}
/**
* Tokenize NODE_OPTIONS the way Node's `ParseNodeOptionsEnvVar` does
* (src/node_options.cc).
*
* - double quotes toggle `is_in_string` and are stripped
* - a backslash escapes the next character, but only inside a string
* - only ASCII spaces outside strings delimit arguments (tabs are literal)
* - single quotes are literal characters
*
* Returns null when Node would reject the value: an unterminated string or
* an incomplete escape.
*/
function tokenizeNodeOptions(nodeOptions: string): string[] | null {
const tokens: string[] = []
let isInString = false
let willStartNewArg = true
for (let i = 0; i < nodeOptions.length; i++) {
let c = nodeOptions[i]!
if (c === '\\' && isInString) {
if (i + 1 >= nodeOptions.length) {
// Trailing escape with nothing to escape: Node rejects the value.
return null
}
c = nodeOptions[++i]!
} else if (c === ' ' && !isInString) {
willStartNewArg = true
continue
} else if (c === '"') {
isInString = !isInString
continue
}
if (willStartNewArg) {
tokens.push(c)
willStartNewArg = false
} else {
tokens[tokens.length - 1] += c
}
}
// Node rejects unterminated strings at startup.
if (isInString) {
return null
}
return tokens
}
/**
* Resolve an option name to its role and to the option names Node records
* for it, expanding aliases the way Node does (aliases are resolved before
* the option lookup, and the `=`-only aliases only match that spelling).
*/
function resolveNodeOption(
name: string,
hasInlineValue: boolean,
): { role: NodeOptionRole; names: string[] } {
if (hasInlineValue) {
const inlineAlias = NODE_INLINE_OPTION_ALIASES.get(`${name}=`)
if (inlineAlias) {
return {
role: NODE_OPTION_ROLES.get(inlineAlias[0]!) ?? 'boolean',
names: [name, ...inlineAlias],
}
}
}
const alias = NODE_OPTION_ALIASES.get(name)
if (alias) {
return {
role: NODE_OPTION_ROLES.get(alias[0]!) ?? 'boolean',
names: [name, ...alias],
}
}
return { role: NODE_OPTION_ROLES.get(name) ?? 'boolean', names: [name] }
}
/**
* Parse NODE_OPTIONS into the options Node would accept.
*
* The result is either a valid option list or `valid: false` for the inputs
* Node rejects outright: malformed quoting, a missing or invalid required
* value, the negation of a non-boolean option, or `--`.
*/
function parseNodeOptions(nodeOptions: string): NodeOptionsParse {
const tokens = tokenizeNodeOptions(nodeOptions)
if (!tokens) {
return { valid: false, options: [] }
}
const options: NodeOptionOccurrence[] = []
let index = 0
while (index < tokens.length) {
const token = tokens[index]!
index += 1
// Node stops parsing options at the first argument that is not an option
// (src/node_options.cc: `if (args.first().size() <= 1 ||
// args.first()[0] != '-') break;`), so everything from there on is
// ignored — including options that would otherwise be seen.
if (token.length <= 1 || token[0] !== '-') {
break
}
// `--` is not allowed in NODE_OPTIONS.
if (token === '--') {
return { valid: false, options: [] }
}
// Only `--name=value` uses `=` as a separator.
const equalsIndex = token.startsWith('--') ? token.indexOf('=') : -1
const name = equalsIndex === -1 ? token : token.slice(0, equalsIndex)
const inlineValue =
equalsIndex === -1 ? undefined : token.slice(equalsIndex + 1)
// Node converts `--no-foo` to `--foo` and remembers the negation.
let optionName = name
let negated = false
if (optionName.startsWith('--no-')) {
optionName = `--${optionName.slice('--no-'.length)}`
negated = true
}
const { role, names } = resolveNodeOption(optionName, equalsIndex !== -1)
// Node rejects the negation of any option that is not a boolean.
if (negated && role !== 'boolean') {
return { valid: false, options: [] }
}
switch (role) {
case 'boolean':
break
case 'value': {
if (inlineValue !== undefined) {
// An empty inline value is rejected by Node
// (`--title= requires an argument`).
if (inlineValue === '') {
return { valid: false, options: [] }
}
break
}
// The value is the next argument. Node rejects it when it is missing
// or when it looks like another option (`--title --use-system-ca`).
const next = tokens[index]
if (next === undefined || next.startsWith('-')) {
return { valid: false, options: [] }
}
index += 1
break
}
case 'inline-value': {
// V8-owned flags must carry their value inline. V8 accepts an empty
// inline value (it warns but starts) and rejects a bare flag.
if (inlineValue === undefined) {
return { valid: false, options: [] }
}
break
}
case 'inline-required': {
if (inlineValue === undefined || inlineValue === '') {
return { valid: false, options: [] }
}
break
}
}
// An alias can expand to an option that is already part of the expansion
// (`--inspect=` expands to `--inspect-port` and `--inspect`); record each
// option once per token so repeated tokens keep their own occurrence.
const recorded = new Set<string>()
for (const occurrenceName of names) {
if (recorded.has(occurrenceName)) {
continue
}
recorded.add(occurrenceName)
options.push({ name: occurrenceName, negated })
}
}
return { valid: true, options }
}
/**
* Check if NODE_OPTIONS contains a specific flag.
* Splits on whitespace and checks for exact match to avoid false positives.
*
* Mirrors Node's `ParseNodeOptionsEnvVar` and `OptionsParser::Parse`
* (src/node_options.cc) closely enough to answer what the CA and telemetry
* callers ask: "would Node have enabled this option?".
*
* - double quotes toggle `is_in_string` and are stripped
* - a backslash escapes the next character, but only inside a string
* - only ASCII spaces outside strings delimit arguments (tabs are literal);
* single quotes are literal
* - `--flag=value` is supported and `--flag` never matches `--flag-other`
* - value-taking options own their value, so `--conditions "--use-system-ca"`
* does not enable `--use-system-ca`; `--experimental-import-meta-resolve`
* is a boolean and never consumes the following argument
* - Node stops parsing options at the first argument that is not an option,
* so options after it are ignored
* - malformed quoting, a missing or invalid required value, the negation of a
* non-boolean option, and `--` make Node reject the whole NODE_OPTIONS
* value; the helper then fails closed and reports no option as active
* - boolean options are applied in order, so a later `--no-<flag>` disables
* an earlier `--<flag>` and vice versa
*/
export function hasNodeOption(flag: string): boolean {
const nodeOptions = process.env.NODE_OPTIONS
if (!nodeOptions) {
return false
}
return nodeOptions.split(/\s+/).includes(flag)
// This helper can also run after bootstrap (settings re-applies
// NODE_OPTIONS, clears CA/proxy/mTLS caches, rebuilds agents), so an
// invalid option string must never be read as "the flag is enabled".
const parsed = parseNodeOptions(nodeOptions)
if (!parsed.valid) {
return false
}
// Node applies boolean options in order, so the last occurrence wins.
// A query for the negated spelling (`--no-<flag>`) asks whether the
// negation is the effective state, which requires the option to be present.
const negatedQuery = flag.startsWith('--no-')
const optionName = negatedQuery ? `--${flag.slice('--no-'.length)}` : flag
let enabled = false
let seen = false
for (const option of parsed.options) {
if (option.name === optionName) {
enabled = !option.negated
seen = true
}
}
return negatedQuery ? seen && !enabled : enabled
}
export function isEnvTruthy(envVar: string | boolean | undefined): boolean {