From 779fd085914fe747af0025489c96cb5e67b474f3 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 5 Oct 2026 23:03:53 +0600 Subject: [PATCH 1/3] fix(launcher): tolerate missing bin helpers on distro installs (#2255) --- bin/openclaude | 222 +++++++++++++++++- scripts/openclaude-bin-heap.test.ts | 11 +- .../openclaude-bin-missing-helpers.test.ts | 168 +++++++++++++ 3 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 scripts/openclaude-bin-missing-helpers.test.ts diff --git a/bin/openclaude b/bin/openclaude index 704143142a..c0e01bc345 100755 --- a/bin/openclaude +++ b/bin/openclaude @@ -11,17 +11,217 @@ import { existsSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath, pathToFileURL } from 'url' import { spawnSync } from 'child_process' -import { enableNodeCompileCacheIfAvailable } from './node-compile-cache.mjs' -import { - HEAP_SIZE_ENV, - HEAP_SIZE_FLAG, - formatPercentageUnavailableStderr, - hasHeapLimitFlag, - hasNodeFlag, - nodeOptionArgs, - resolveHeapSizeMb, - stripLauncherHeapArgs, -} from './heap-limit.mjs' +import os from 'node:os' +import * as nodeModule from 'node:module' + +// Sibling helpers (./node-compile-cache.mjs, ./heap-limit.mjs) ship inside +// bin/ for npm installs, but non-npm layouts (e.g. distro packages that copy +// only the launcher file to /usr/lib/openclaude/bin/) may omit them. A static +// import would then fail with ERR_MODULE_NOT_FOUND before any code runs +// (issue #2255). Load them dynamically so a missing sibling degrades to a +// safe inline fallback instead of a hard boot crash. The fallbacks below are +// intentionally self-contained; keep them in sync with the canonical helpers +// in bin/node-compile-cache.mjs and bin/heap-limit.mjs. +const FALLBACK_DEFAULT_HEAP_SIZE_MB = 8192 +const FALLBACK_HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB' +const FALLBACK_HEAP_PERCENTAGE_ENV = + 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE' +const FALLBACK_HEAP_SIZE_FLAG = '--max-old-space-size' +const FALLBACK_HEAP_PERCENTAGE_FLAG = '--max-old-space-size-percentage' +const FALLBACK_MAX_MEMORY_FLAG = '--max-memory' + +function fallbackHasNodeFlag(args, flag) { + return args.some(arg => arg === flag || arg.startsWith(`${flag}=`)) +} + +function fallbackNodeOptionArgs(nodeOptions) { + return (nodeOptions || '').split(/\s+/).filter(Boolean) +} + +function fallbackHasHeapLimitFlag(args) { + return ( + fallbackHasNodeFlag(args, FALLBACK_HEAP_SIZE_FLAG) || + fallbackHasNodeFlag(args, FALLBACK_HEAP_PERCENTAGE_FLAG) + ) +} + +function fallbackParsePositiveIntegerMb(raw) { + if (raw == null || raw === '') return null + const parsed = Number.parseInt(String(raw), 10) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + +function fallbackParsePercentage(raw) { + if (raw == null) return null + const trimmed = String(raw).trim() + if (!trimmed) return null + const withoutPercent = trimmed.endsWith('%') + ? trimmed.slice(0, -1) + : trimmed + if (withoutPercent.trim() === '') return null + const parsed = Number(withoutPercent) + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) return null + return parsed +} + +function fallbackGetAvailableMemoryBytes() { + try { + const constrained = + typeof process.constrainedMemory === 'function' + ? process.constrainedMemory() + : undefined + if ( + typeof constrained === 'number' && + Number.isSafeInteger(constrained) && + constrained > 0 + ) { + return constrained + } + } catch { + // Fall through to host total memory. + } + try { + const total = os.totalmem() + if (typeof total === 'number' && Number.isFinite(total) && total > 0) { + return total + } + } catch { + // Memory size unknown; callers fall back to the default heap. + } + return 0 +} + +function fallbackFindEqualsFlagValue(args, flag) { + const prefix = `${flag}=` + for (const arg of args) { + if (arg.startsWith(prefix)) return arg.slice(prefix.length) + } + return null +} + +function fallbackFindEqualsOrNextFlagValue(args, flag) { + const prefix = `${flag}=` + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith(prefix)) return arg.slice(prefix.length) + if (arg === flag) { + const next = args[i + 1] + if (next && !next.startsWith('-')) return next + return '' + } + } + return null +} + +function fallbackStripLauncherHeapArgs(args) { + const stripped = [] + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if ( + arg === FALLBACK_MAX_MEMORY_FLAG || + arg.startsWith(`${FALLBACK_MAX_MEMORY_FLAG}=`) + ) + continue + if (arg.startsWith(`${FALLBACK_HEAP_PERCENTAGE_FLAG}=`)) continue + if (arg === FALLBACK_HEAP_PERCENTAGE_FLAG) { + const next = args[i + 1] + if (next && !next.startsWith('-') && fallbackParsePercentage(next) != null) + i += 1 + continue + } + stripped.push(arg) + } + return stripped +} + +function fallbackResolveHeapSizeMb({ argv = [], env = {} } = {}) { + const availableBytes = fallbackGetAvailableMemoryBytes() + const maxMem = fallbackParsePositiveIntegerMb( + fallbackFindEqualsFlagValue(argv, FALLBACK_MAX_MEMORY_FLAG), + ) + if (maxMem != null) { + return { mb: maxMem, source: 'max-memory', setMaxMemoryEnv: true } + } + const argvPercentage = fallbackParsePercentage( + fallbackFindEqualsOrNextFlagValue(argv, FALLBACK_HEAP_PERCENTAGE_FLAG), + ) + const envPercentage = fallbackParsePercentage(env[FALLBACK_HEAP_PERCENTAGE_ENV]) + const percentage = argvPercentage ?? envPercentage + if (percentage != null) { + if (availableBytes > 0) { + const mb = Math.max( + 1, + Math.floor((availableBytes * (percentage / 100)) / (1024 * 1024)), + ) + return { + mb, + source: argvPercentage != null ? 'argv-percentage' : 'env-percentage', + percentage, + } + } + return { + mb: FALLBACK_DEFAULT_HEAP_SIZE_MB, + source: 'percentage-unavailable', + percentage, + } + } + const envMb = fallbackParsePositiveIntegerMb(env[FALLBACK_HEAP_SIZE_ENV]) + if (envMb != null) return { mb: envMb, source: 'env-mb' } + return { mb: FALLBACK_DEFAULT_HEAP_SIZE_MB, source: 'default' } +} + +function fallbackFormatPercentageUnavailableStderr( + percentage, + fallbackMb = FALLBACK_DEFAULT_HEAP_SIZE_MB, +) { + return `openclaude: could not convert heap percentage ${percentage} to megabytes because available memory is unknown; using ${fallbackMb}` +} + +function fallbackEnableNodeCompileCacheIfAvailable() { + // Mirrors bin/node-compile-cache.mjs without the sibling file so a missing + // helper still warms the compile cache instead of losing the optimization. + const enable = nodeModule.enableCompileCache + if (typeof enable !== 'function') return + try { + enable() + } catch { + // Compile caching is optional and must never block startup. + } +} + +async function loadSiblingModule(specifier) { + try { + return await import(new URL(specifier, import.meta.url).href) + } catch { + // Any load failure (missing file, unreadable, corrupt) falls back to the + // inline implementations above: booting the CLI outranks surfacing a + // packaging defect, and the fallback keeps stderr silent. + return null + } +} + +const compileCacheModule = await loadSiblingModule('./node-compile-cache.mjs') +const heapLimitModule = await loadSiblingModule('./heap-limit.mjs') + +const enableNodeCompileCacheIfAvailable = + compileCacheModule?.enableNodeCompileCacheIfAvailable ?? + fallbackEnableNodeCompileCacheIfAvailable +const HEAP_SIZE_ENV = + heapLimitModule?.HEAP_SIZE_ENV ?? FALLBACK_HEAP_SIZE_ENV +const HEAP_SIZE_FLAG = + heapLimitModule?.HEAP_SIZE_FLAG ?? FALLBACK_HEAP_SIZE_FLAG +const formatPercentageUnavailableStderr = + heapLimitModule?.formatPercentageUnavailableStderr ?? + fallbackFormatPercentageUnavailableStderr +const hasHeapLimitFlag = + heapLimitModule?.hasHeapLimitFlag ?? fallbackHasHeapLimitFlag +const hasNodeFlag = heapLimitModule?.hasNodeFlag ?? fallbackHasNodeFlag +const nodeOptionArgs = + heapLimitModule?.nodeOptionArgs ?? fallbackNodeOptionArgs +const resolveHeapSizeMb = + heapLimitModule?.resolveHeapSizeMb ?? fallbackResolveHeapSizeMb +const stripLauncherHeapArgs = + heapLimitModule?.stripLauncherHeapArgs ?? fallbackStripLauncherHeapArgs const __dirname = dirname(fileURLToPath(import.meta.url)) const distPath = join(__dirname, '..', 'dist', 'cli.mjs') diff --git a/scripts/openclaude-bin-heap.test.ts b/scripts/openclaude-bin-heap.test.ts index 51a0084553..68cbd012e9 100644 --- a/scripts/openclaude-bin-heap.test.ts +++ b/scripts/openclaude-bin-heap.test.ts @@ -41,7 +41,16 @@ describe('openclaude launcher heap guard', () => { expect(source).toContain("resolved.source === 'percentage-unavailable'") expect(source).toContain('--expose-gc') expect(source).toContain('spawnSync(process.execPath') - expect(source).toContain("from './heap-limit.mjs'") + // The launcher must not statically import its bin/*.mjs siblings: distro + // layouts that copy only the launcher file (issue #2255) would otherwise + // crash with ERR_MODULE_NOT_FOUND before any code runs. Siblings load + // dynamically with inline fallbacks instead. + expect(source).not.toContain("from './heap-limit.mjs'") + expect(source).not.toContain("from './node-compile-cache.mjs'") + expect(source).toContain("'./heap-limit.mjs'") + expect(source).toContain("'./node-compile-cache.mjs'") + expect(source).toContain('fallbackResolveHeapSizeMb') + expect(source).toContain('fallbackEnableNodeCompileCacheIfAvailable') const importingBranch = source.slice(source.indexOf('if (existsSync(distPath))')) const relaunchIndex = importingBranch.indexOf('relaunchWithLongSessionHeapIfNeeded()') const compileCacheIndex = importingBranch.indexOf('enableNodeCompileCacheIfAvailable()') diff --git a/scripts/openclaude-bin-missing-helpers.test.ts b/scripts/openclaude-bin-missing-helpers.test.ts new file mode 100644 index 0000000000..38d438afde --- /dev/null +++ b/scripts/openclaude-bin-missing-helpers.test.ts @@ -0,0 +1,168 @@ +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' + +const REPO_ROOT = join(import.meta.dir, '..') +const BIN_PATH = join(REPO_ROOT, 'bin', 'openclaude') +const PACKAGE_VERSION = JSON.parse( + readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'), +).version +const EXPECTED_VERSION_OUTPUT = `${PACKAGE_VERSION} (OpenClaude)\n` + +type LauncherResult = { + status: number | null + stdout: string + stderr: string +} + +// Simulates distro layouts (e.g. Arch AUR /usr/lib/openclaude) that install +// only the launcher file without its bin/*.mjs siblings (issue #2255). The +// launcher must still boot via its inline fallbacks instead of crashing with +// ERR_MODULE_NOT_FOUND before any code runs. +function makeSiblinglessLayout(): string { + const root = mkdtempSync(join(tmpdir(), 'openclaude-siblingless-')) + const binDir = join(root, 'bin') + mkdirSync(binDir, { recursive: true }) + copyFileSync(BIN_PATH, join(binDir, 'openclaude')) + symlinkSync(join(REPO_ROOT, 'dist'), join(root, 'dist')) + symlinkSync(join(REPO_ROOT, 'node_modules'), join(root, 'node_modules')) + return root +} + +function runSiblingless( + root: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): LauncherResult { + const result = spawnSync('node', [join(root, 'bin', 'openclaude'), ...args], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + CI: '1', + NO_COLOR: '1', + OPENCLAUDE_CONFIG_DIR: join(root, 'config'), + ...extraEnv, + }, + timeout: 30_000, + }) + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + } +} + +function withSiblinglessLayout(fn: (root: string) => void): void { + const root = makeSiblinglessLayout() + try { + fn(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +describe('openclaude launcher without bin/*.mjs siblings', () => { + test('boots --version with silent stderr', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, ['--version']) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(result.stderr).toBe('') + expect(`${result.stdout}${result.stderr}`).not.toContain( + 'ERR_MODULE_NOT_FOUND', + ) + }) + }) + + test('boots --help with silent stderr', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, ['--help']) + expect(result.status).toBe(0) + expect(result.stdout).toMatch(/usage/i) + expect(result.stderr).toBe('') + }) + }) + + test('strips launcher-only percentage flags without siblings', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, [ + '--max-old-space-size-percentage=50', + '--version', + ]) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(`${result.stdout}${result.stderr}`).not.toContain( + "unknown option '--max-old-space-size-percentage=50'", + ) + }) + }) + + test('strips launcher-only flags without siblings when relaunch is disabled', () => { + withSiblinglessLayout(root => { + const result = runSiblingless( + root, + ['--max-old-space-size-percentage=50', '--version'], + { OPENCLAUDE_DISABLE_HEAP_RELAUNCH: '1' }, + ) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(`${result.stdout}${result.stderr}`).not.toContain( + "unknown option '--max-old-space-size-percentage=50'", + ) + }) + }) + + test('accepts --max-memory without siblings', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, ['--max-memory=1024', '--version']) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(result.stderr).toBe('') + }) + }) + + test('honors heap env overrides without siblings', () => { + withSiblinglessLayout(root => { + const mbResult = runSiblingless( + root, + ['--version'], + { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' }, + ) + expect(mbResult.status).toBe(0) + expect(mbResult.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(mbResult.stderr).toBe('') + + const percentageResult = runSiblingless( + root, + ['--version'], + { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '50' }, + ) + expect(percentageResult.status).toBe(0) + expect(percentageResult.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(percentageResult.stderr).toBe('') + }) + }) + + test('strips spaced launcher-only percentage flags without siblings', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, [ + '--max-old-space-size-percentage', + '50', + '--version', + ]) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(`${result.stdout}${result.stderr}`).not.toContain('unknown option') + }) + }) +}) From 2fa3ffb948f1ef939faf79d005d0eb3765b2f026 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sat, 10 Oct 2026 00:30:27 +0600 Subject: [PATCH 2/3] test(launcher): add heap fallback/canonical parity plus robust siblingless fixture --- .../openclaude-bin-missing-helpers.test.ts | 144 +++++++++++++++++- 1 file changed, 141 insertions(+), 3 deletions(-) diff --git a/scripts/openclaude-bin-missing-helpers.test.ts b/scripts/openclaude-bin-missing-helpers.test.ts index 38d438afde..f4394a05bc 100644 --- a/scripts/openclaude-bin-missing-helpers.test.ts +++ b/scripts/openclaude-bin-missing-helpers.test.ts @@ -1,11 +1,13 @@ import { spawnSync } from 'node:child_process' import { copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, + writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -28,17 +30,33 @@ type LauncherResult = { // only the launcher file without its bin/*.mjs siblings (issue #2255). The // launcher must still boot via its inline fallbacks instead of crashing with // ERR_MODULE_NOT_FOUND before any code runs. +function assertBuiltCli(): void { + const builtCli = join(REPO_ROOT, 'dist', 'cli.mjs') + if (!existsSync(builtCli)) { + throw new Error( + `dist/cli.mjs not found at ${builtCli} — run \`bun run build\` before these tests.`, + ) + } +} + function makeSiblinglessLayout(): string { + assertBuiltCli() const root = mkdtempSync(join(tmpdir(), 'openclaude-siblingless-')) const binDir = join(root, 'bin') mkdirSync(binDir, { recursive: true }) copyFileSync(BIN_PATH, join(binDir, 'openclaude')) - symlinkSync(join(REPO_ROOT, 'dist'), join(root, 'dist')) - symlinkSync(join(REPO_ROOT, 'node_modules'), join(root, 'node_modules')) + // 'junction' is ignored on POSIX and lets Windows create directory links + // without elevated privileges. + symlinkSync(join(REPO_ROOT, 'dist'), join(root, 'dist'), 'junction') + symlinkSync( + join(REPO_ROOT, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ) return root } -function runSiblingless( +function runLauncher( root: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}, @@ -62,6 +80,14 @@ function runSiblingless( } } +function runSiblingless( + root: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): LauncherResult { + return runLauncher(root, args, extraEnv) +} + function withSiblinglessLayout(fn: (root: string) => void): void { const root = makeSiblinglessLayout() try { @@ -71,6 +97,82 @@ function withSiblinglessLayout(fn: (root: string) => void): void { } } +// The inline fallbacks in bin/openclaude duplicate bin/heap-limit.mjs, so the +// same inputs must resolve to the same heap through both paths. The stub +// bundle below reports what the launcher resolved instead of starting the CLI, +// letting a full-sibling layout and a siblingless layout be compared +// observably without changing resolver behavior. +const HEAP_OBSERVER_STUB = `console.log(JSON.stringify({ heapMb: process.env.OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB ?? null, maxMemoryMb: process.env.OPENCLAUDE_MAX_MEMORY_MB ?? null, heapFlag: process.execArgv.find(a => a.startsWith('--max-old-space-size=')) ?? null }))\n` + +function makeHeapObserverLayout(withSiblings: boolean): string { + assertBuiltCli() + const tag = withSiblings ? 'full' : 'siblingless' + const root = mkdtempSync(join(tmpdir(), `openclaude-heap-parity-${tag}-`)) + const binDir = join(root, 'bin') + const distDir = join(root, 'dist') + mkdirSync(binDir, { recursive: true }) + mkdirSync(distDir, { recursive: true }) + copyFileSync(BIN_PATH, join(binDir, 'openclaude')) + if (withSiblings) { + for (const helper of ['heap-limit.mjs', 'node-compile-cache.mjs']) { + copyFileSync(join(REPO_ROOT, 'bin', helper), join(binDir, helper)) + } + } + writeFileSync(join(distDir, 'cli.mjs'), HEAP_OBSERVER_STUB) + return root +} + +type HeapParityCase = { + name: string + args: string[] + env: NodeJS.ProcessEnv + expectedMb: string | null + expectedMaxMemoryMb?: string +} + +const HEAP_PARITY_CASES: HeapParityCase[] = [ + { name: 'default heap', args: [], env: {}, expectedMb: '8192' }, + { + name: 'env MB override', + args: [], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' }, + expectedMb: '4096', + }, + { + name: '--max-memory wins over percentage', + args: ['--max-memory=1536'], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '90' }, + expectedMb: '1536', + expectedMaxMemoryMb: '1536', + }, + // Percentage outcomes depend on host RAM, so these cases assert fallback / + // canonical equality rather than an exact value. + { + name: 'argv percentage', + args: ['--max-old-space-size-percentage=50'], + env: {}, + expectedMb: null, + }, + { + name: 'spaced argv percentage', + args: ['--max-old-space-size-percentage', '25'], + env: {}, + expectedMb: null, + }, + { + name: 'env percentage', + args: [], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '50' }, + expectedMb: null, + }, + { + name: 'invalid percentage falls back to env MB', + args: ['--max-old-space-size-percentage=0'], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' }, + expectedMb: '4096', + }, +] + describe('openclaude launcher without bin/*.mjs siblings', () => { test('boots --version with silent stderr', () => { withSiblinglessLayout(root => { @@ -166,3 +268,39 @@ describe('openclaude launcher without bin/*.mjs siblings', () => { }) }) }) + +describe('openclaude launcher heap fallback/canonical parity', () => { + for (const parityCase of HEAP_PARITY_CASES) { + test(`resolves the same heap with and without siblings: ${parityCase.name}`, () => { + const full = makeHeapObserverLayout(true) + const siblingless = makeHeapObserverLayout(false) + try { + const canonical = runLauncher(full, parityCase.args, parityCase.env) + const fallback = runLauncher( + siblingless, + parityCase.args, + parityCase.env, + ) + expect(canonical.status).toBe(0) + expect(fallback.status).toBe(0) + expect(fallback.stdout).toBe(canonical.stdout) + expect(fallback.stderr).toBe('') + if (parityCase.expectedMb !== null) { + const observed = JSON.parse(canonical.stdout) + expect(observed.heapMb).toBe(parityCase.expectedMb) + expect(observed.heapFlag).toBe( + `--max-old-space-size=${parityCase.expectedMb}`, + ) + } + if (parityCase.expectedMaxMemoryMb !== undefined) { + expect(JSON.parse(canonical.stdout).maxMemoryMb).toBe( + parityCase.expectedMaxMemoryMb, + ) + } + } finally { + rmSync(full, { recursive: true, force: true }) + rmSync(siblingless, { recursive: true, force: true }) + } + }) + } +}) From 788df35bd421e3e06cbd65a2140b46031aac9f25 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sat, 10 Oct 2026 01:19:19 +0600 Subject: [PATCH 3/3] test(launcher): isolate heap env in launcher fixture --- .../openclaude-bin-missing-helpers.test.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/scripts/openclaude-bin-missing-helpers.test.ts b/scripts/openclaude-bin-missing-helpers.test.ts index f4394a05bc..c6155179b1 100644 --- a/scripts/openclaude-bin-missing-helpers.test.ts +++ b/scripts/openclaude-bin-missing-helpers.test.ts @@ -61,16 +61,31 @@ function runLauncher( args: string[], extraEnv: NodeJS.ProcessEnv = {}, ): LauncherResult { + const env: NodeJS.ProcessEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + OPENCLAUDE_CONFIG_DIR: join(root, 'config'), + } + // Heap-related env must not leak in from the outer environment: the parity + // cases assert exact resolved values, and an inherited override (or a + // relaunch toggle) would silently change what the launcher resolves. Each + // case's env is applied after this cleanup, so explicit overrides still win. + for (const key of [ + 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB', + 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE', + 'OPENCLAUDE_MAX_MEMORY_MB', + 'OPENCLAUDE_HEAP_RELAUNCHED', + 'OPENCLAUDE_DISABLE_HEAP_RELAUNCH', + ]) { + delete env[key] + } + if (!Object.hasOwn(extraEnv, 'NODE_OPTIONS')) delete env.NODE_OPTIONS + Object.assign(env, extraEnv) const result = spawnSync('node', [join(root, 'bin', 'openclaude'), ...args], { cwd: root, encoding: 'utf8', - env: { - ...process.env, - CI: '1', - NO_COLOR: '1', - OPENCLAUDE_CONFIG_DIR: join(root, 'config'), - ...extraEnv, - }, + env, timeout: 30_000, }) return {