mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-28 02:34:20 -05:00
* fix(cli): mirror programmatic args onto process.argv (hardened) Address CodeRabbit's Critical finding: cliMain() parses process.argv directly, so a programmatic main(args) call must reflect args there or it silently runs the host's argv. Restores the args->argv sync but hardened against the three bugs the multi-agent review confirmed in the earlier scoped/serialized version: - no restore (was F3: a finally-restore flipped argv out from under the SIGINT handler and bypass-safety notice while the session was live) - length-guarded exec/script slots (was F4: <2 host argv entries, e.g. node -e, dropped the flag past commander's argv.slice(2)) - no serialization chain (was F2: overlapping calls hung indefinitely) For the normal binary launch (args defaulted from process.argv.slice(2)) the assignment is a value-identical no-op. Tests: programmatic args reach cliMain, argv is not restored, and the exec/script slots are padded under a short host argv. Verified: 48 entrypoint+safety tests, tsc, build, and e2e re-probes of all former --yolo bug scenarios on the built binary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): ssh strip-all dangerous-skip tokens; docs+tests for native alias Address the latest Copilot + CodeRabbit findings on the native-alias PR: - ssh argv pre-scan removed only the first dangerous-skip token, so `ssh --yolo --dangerously-skip-permissions host` (or a repeat) left a survivor that re-enabled bypass after the ssh rewrite. Strip every matching token. (connect already used the filter helper.) [Copilot] - web/src/data/cliFlags.ts: list the flag as '--yolo, --dangerously-skip-permissions' to match the commander registration, not just the description. [Copilot] - Replace the source-string-count registration test with (a) a behavioral test that the built CLI lists the alias in --help — proving the main-command registration is live, not dead code or the wrong command — and (b) a structured .option() assertion plus a check that the ssh pre-scan handles the alias (ssh --help renders root help, so the ssh --yolo path is the pre-scan, not the commander option). [CodeRabbit] - PR description rewritten to describe the native alias instead of the removed argv rewrite. [Copilot] Verified: 23 cli + 10 safety tests, tsc, build, e2e (--yolo --help lists the alias; ssh --help; mcp add --yolo names the typed flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(cli): runtime coverage for dangerous-skip strip; share one helper CodeRabbit (approved, follow-up): the strip-all fix was only source- checked, so the strip loop could regress unnoticed. Extract the argv scanners into src/utils/dangerousSkipFlags.ts (isDangerousSkipFlag / hasDangerousSkipFlag / stripDangerousSkipFlags) and unit-test them at runtime: both spellings detected, every token stripped (canonical + --yolo + repeats), input not mutated. Both the direct-connect and ssh rewrites in main.tsx now use the shared stripDangerousSkipFlags — the ssh path's bespoke single-splice while loop (the original survivor bug) is gone, replaced by an in-place splice(0, len, ...strip). One tested code path instead of two. Verified: helper + 23 cli + 10 safety tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(cli): semicolon + strip both spellings in safety-test reset Copilot review nits: - statusNoticeDefinitions.tsx: add the trailing semicolon to the parenthesized return to match the file's semicolon style. - safety.test.tsx: the beforeEach argv reset filtered only --dangerously-skip-permissions; strip --yolo too so the 'without the flag' cases can't go order-dependent if the runner is invoked with --yolo in argv. - PR description re-synced to the native-alias approach (the earlier edit had reverted to the old 'normalize via argv rewrite' wording). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): notice names --yolo; move import out of boot-critical block Both bots on 50772d28: - The bypass-safety notice fires for --yolo but its text named only '--dangerously-skip-permissions is active'. Reword to '... (alias --yolo) is active' so the message matches what the user typed. Add a rendered-notice regression assertion for the --yolo case. [CodeRabbit + Copilot] - Move the dangerousSkipFlags import out of the boot-critical header block (it ran before profileCheckpoint('main_tsx_entry'), adding pre-checkpoint work in the order-preserving bundle) down to the regular internal-import group. [Copilot] Verified: 39 cli+safety+helper tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): honor -- end-of-options in the raw-argv dangerous-skip scanners Copilot (3x on 5cd0e304): the pre-commander scanners matched --yolo / --dangerously-skip-permissions anywhere in argv, so a positional after the -- marker (openclaude -p -- --yolo, cc://… -- --yolo, ssh host -- --yolo) was misread as the bypass flag — enabling bypass / stripping the token / false-firing the safety notice, even though commander treats it as positional. Pre-existing for the canonical flag, but the short alias makes it far likelier. Make the shared helpers --aware in one place: hasDangerousSkipFlag and stripDangerousSkipFlags only consider option-position tokens (before the first --) and preserve everything from -- onward. Route the safety notice's hasDangerouslySkipPermissionsArg through the same helper. Regression tests: helper ignores/preserves post-- tokens; the notice does not fire for -p -- --yolo. Verified: 41 helper+safety+cli tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(cli): don't leak args via process.argv; stop simulating commander in the scanner Address jatmn's two P1 findings, both by removing a band-aid rather than adding another: - P1a: main() no longer mirrors its args onto the process-global process.argv. Verified there is no production or SDK caller that passes custom args (the sole entry is the no-arg auto-run), so the mirror only risked leaking a programmatic invocation's args (including a bypass flag) into an overlapping call or the host process. Back to the baseline signature; cliMain parses the real process.argv. - P1b: revert the end-of-options ("--") handling in the dangerous-skip scanner. As jatmn notes, correctly classifying "--yolo" (it can be a required option value like "--system-prompt --yolo", or follow a "--" consumed as a variadic value) requires commander's option-arity state machine, the exact simulation this feature was reworked to delete. The scanner now mirrors the canonical --dangerously-skip-permissions presence check exactly: both spellings behave identically, and the approximation is documented as a pre-existing limitation of pre-commander scanning. Net: every difference between --yolo and the canonical flag is now either native-commander-correct (the registration) or an identical approximation (the raw scanners). No new argv mutation, no parser simulation. Verified: 38 cli+helper+safety tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(cli): clarify main() argv comment — skills/--update do rewrite argv Copilot: the "does not mirror args onto process.argv" comment was misleading — the skills and --update fast-paths reassign process.argv to re-route to their subcommand. Note the exception; the no-mirror rule is about not injecting the caller's args into the general cliMain flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup): name the --yolo alias in the root/sudo bypass error Copilot: the root/sudo safety error printed only --dangerously-skip-permissions; a user who typed --yolo saw a flag they didn't use. Mention both spellings, matching the ssh help and cliFlags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(cli): import Command from @commander-js/extra-typings, matching prod Copilot: the alias behavioral test built its probe Command from 'commander', but production registers options via @commander-js/extra-typings. Use the same package so the test exercises the exact parser prod uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ssh): don't treat an option value as the permission-bypass alias jatmn P1: `ssh host --permission-mode --yolo` stripped --yolo as a bypass flag (enabling bypass) and left --permission-mode valueless, whereas commander parses --yolo as the (invalid) mode value and rejects it — a silent privilege escalation. Same class affected --model / --resume / --fallback-model. Reorder the ssh pre-parser so value-taking flags consume their value — including a dangerous-skip token in the value slot — BEFORE the dangerous-skip strip runs. Extract the whole flag pre-parse into a pure, unit-tested helper (parseSshFlags) so the security-sensitive arity handling has regression coverage: escalation guards for --permission-mode/--model + a value of --yolo, plus genuine standalone --yolo still enabling bypass. Verified: 6 ssh + dangerousSkipFlags + cli tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(cli): update ssh-path source assertion for parseSshFlags extraction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(cli): revert unrelated --bare doc; drop stray blank line CodeRabbit/Copilot on the rebased branch: - Revert the --bare help rewrite (background prefetches / CLAUDE_CODE_SIMPLE / expanded flag list) in main.tsx and web/src/data/cliFlags.ts — it is unrelated to the --yolo alias and broadens scope. cliFlags.ts now changes only the --yolo alias line. - Remove the stray double blank line before cliMain. Skipped CodeRabbit's "alias order breaks property naming" (Major): verified against @commander-js/extra-typings that '--yolo, --dangerously-skip- permissions' maps BOTH spellings to opts().dangerouslySkipPermissions (commander keys off the last long flag); opts().yolo is undefined. Bypass is not broken. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ssh): honor the -- end-of-options marker in the ssh pre-parser CodeRabbit Critical + jatmn P1 ("respect option arity/end-of-options semantics"): `ssh host -- --yolo` (or `-- --local`, `-- --permission-mode x`) parsed the post-`--` tokens as flags, so a positional --yolo escalated to bypass. parseSshFlags now parses only the prefix before `--` and keeps everything from `--` on positional. This is unambiguous here because the ssh subcommand registers no variadic options that could consume `--` as a value. The connect (cc://) path is deliberately left plain and documented: it rewrites to the main command, which HAS variadic options (--add-dir …) that commander lets consume `--` as a value, so a naive `--` split there would be the incomplete simulation flagged in P1b. That false-positive is pre-existing for the canonical flag. Skipped (both pre-existing, ported verbatim / out of scope): extractFlag mixed `--flag=x --flag y` precedence, and the setup.ts root/sudo message not naming --allow-dangerously-skip-permissions. Verified: 8 ssh + cli tests, tsc, build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: make --yolo/bypass Commander-authoritative on cc:// connect + notice * fixup! address CodeRabbit/Copilot review findings on #2098 - Restore parseMaxTurnsCommanderArgument for --max-turns validation. - Generalize root/sudo error message for all bypass modes/flags. - Clarify cli.tsx comment about main() not leaking args into process.argv. - Detect -p/--print in all commander-accepted forms (including --print= and -p<value>). - Correct dangerousSkipFlags.ts doc: SSH strips, cc:// preserves for Commander. - Extend live help test to ssh and open subcommands. * fixup! extract hasPrintFlag helper + regression tests - Move print-flag detection to src/utils/printFlag.ts so it is unit-testable. - Add regression tests for -p, --print, --print=prompt, -pprompt, and -- separator. - Import the helper in main.tsx and drop the local copy. * fixup! use hasPrintFlag in every startup print-mode check - Replace exact-string includes in the SSH headless rejection and the main print-mode gate with the shared hasPrintFlag predicate. - Add source assertions confirming cc:// rewrite, SSH rejection, and main print-mode gate all use the same helper. * fixup! align dangerously-skip notice comment with commander-authoritative mode - Remove stale 'reads from process.argv' text; the notice now keys off the resolved permissionMode. * fixup! add SIGINT handler to hasPrintFlag source assertions - Include the SIGINT print-mode gate in the source-level consistency check. * fixup! restore maxTurns forwarding dropped during rebase - Re-add options.maxTurns to sessionConfig and the interactive REPL props for direct-connect, SSH, remote viewer, and remote creation paths (matches main). * fixup! address Copilot suppressed comments on #2098 - parseSshFlags now consumes required-arg values unconditionally, matching commander and preventing flag-like values from leaking into later guards. - Drop the now-unused isDangerousSkipFlag import from sshPreParse.ts. - Make the dangerously-skip notice text mode-agnostic so settings-driven bypassPermissions is not mislabeled as a CLI flag. * fixup: left-to-right SSH parse and fullAccess sandbox warning - Rewrite parseSshFlags as a single left-to-right arity-aware scan. Value-taking flags now consume every occurrence (including equals forms) and always consume the next token as their value, even if it resembles a flag (e.g. --permission-mode --local or --model --yolo value). - Cover fullAccess with the dangerously-skip-permissions sandbox warning and add focused regression tests for bypassPermissions/fullAccess rendering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fixup: preserve missing SSH option values and embedded equals - Keep value-taking SSH flags in remaining when they have no available value (last token before -- or trailing), letting commander report the missing required argument. - Use slice after the prefix for equals-form values so embedded '=' characters are preserved (e.g. --model=provider=model). - Add regression tests for last-token, before--, and embedded-equals cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fixup: arity-aware print detection and optional --resume for SSH - hasPrintFlag now skips tokens consumed as values by preceding value-taking options (required, optional, and variadic), so --system-prompt --print=custom is no longer misclassified as print mode. - parseSshFlags treats --resume as an optional-value option: a bare --resume is forwarded, a non-option value is consumed, and following flags (e.g. --yolo) remain available for their own parsing. - Added focused regression tests for both fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fixup: handle inline values and -- in hasPrintFlag - Do not advance past a following flag when a required/optional/variadic option already provided its value inline via =. - Stop the scan when a value-taking option is immediately followed by --. - Added regression tests for --model=foo --print and --model -- --print. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fixup! address jatmn review: arity-aware print/SSH parsing + fullAccess notice Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fixup! correct bypassPermissions notice wording Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address remaining CodeRabbit findings on #2098 - Sync hasPrintFlag with all root-command required/variadic/optional options, including hidden/feature-gated flags (--agent-id, --sdk-url, --channels, etc.). - Treat the SDK 'full-access' spelling as fullAccess in the dangerous-skip-permissions status notice so the stronger warning is shown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(cli): make hasPrintFlag the single pre-Commander print classifier - Replace all startup .includes('-p')/.includes('--print') checks with the arity-aware hasPrintFlag() predicate so value-consumed tokens are not misclassified as print mode. - Centralize SSH headless detection in sshArgvImpliesHeadless(), covering both tail argv after host/cwd and flags forwarded via extraCliArgs (e.g. --resume=--print). - Soften the fullAccess status-notice wording to match runtime behavior: most consent checks are bypassed, but hard deny rules and user-interaction prompts still apply. - Add/extend tests for interactivity, SSH flag pre-parsing, status notices, and a regression check that prevents naive print-token checks from re-entering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ssh): preserve --resume= values and run headless guard before host extraction - Keep the inline value attached when forwarding --resume=... extraCliArgs so optional-value semantics are preserved (e.g. --resume=--print resumes a conversation named "--print", it does not enable print mode). - Move the SSH headless guard before host/cwd extraction in main.tsx so print flags that appear before the host are rejected. - Update tests: --resume=--print is no longer headless, required-value options like --model -p remain non-headless, and add coverage for print flags before the host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(cli): remove repeatable single-value options from VARIADIC_OPTIONS --plugin-dir and --provider-env-file are registered as repeatable single-value options, not variadic. Keeping them in VARIADIC_OPTIONS made the arity model wrong and could consume extra tokens if check order changed. They are already covered by REQUIRED_VALUE_OPTIONS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
760 lines
27 KiB
TypeScript
760 lines
27 KiB
TypeScript
/**
|
|
* Regression tests for issue #402 — NODE_OPTIONS heap cap
|
|
* Closes: Gitlawb/openclaude#402 — JavaScript heap OOM during large tasks
|
|
*/
|
|
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
mock,
|
|
} from 'bun:test'
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Command } from '@commander-js/extra-typings'
|
|
import {
|
|
BACKGROUND_SESSION_ID_ENV,
|
|
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
|
|
} from '../cli/bgRouting.js'
|
|
import {
|
|
applyLoadedEnvFileValues,
|
|
loadEnvFile,
|
|
} from '../utils/envFile.js'
|
|
import {
|
|
applyProviderFlagFromArgs,
|
|
clearRememberedProviderFlagForTests,
|
|
reapplyRememberedProviderFlag,
|
|
} from '../utils/providerFlag.js'
|
|
import { applyProfileEnvToProcessEnv } from '../utils/providerProfile.js'
|
|
|
|
type CliMain = typeof import('./cli.js')['main']
|
|
|
|
let runCliEntrypoint: CliMain
|
|
|
|
const mockProfileCheckpoint = mock((_checkpoint: string) => {})
|
|
const mockPsHandler = mock(async (_args: string[]) => {})
|
|
const mockLogsHandler = mock(async (_args: string[]) => {})
|
|
const mockAttachHandler = mock(async (_args: string[]) => {})
|
|
const mockKillHandler = mock(async (_args: string[]) => {})
|
|
const mockHandleBgFlag = mock(async (_args: string[]) => {})
|
|
const mockPrepareBackgroundSessionFinalizer = mock(async () => 'installed')
|
|
const mockLoadEnvFile = mock((_filePath: string) => ({}))
|
|
const mockParseProviderEnvFileArgs = mock((_args: string[]) => ({ paths: [] }))
|
|
const mockReapplyRememberedEnvFileValues = mock(() => {})
|
|
const mockRememberLoadedEnvFileValues = mock(
|
|
(_values: Record<string, string>) => {},
|
|
)
|
|
const mockEnableConfigs = mock(() => {})
|
|
const mockApplySafeConfigEnvironmentVariables = mock(() => {})
|
|
const mockApplyStartupEnvFromProfile = mock(
|
|
async (_input: {
|
|
processEnv: NodeJS.ProcessEnv
|
|
onValidationError: (message: string) => void
|
|
}) => {},
|
|
)
|
|
const mockGetProviderValidationError = mock(
|
|
async (_env: NodeJS.ProcessEnv) => undefined,
|
|
)
|
|
const mockEagerLoadSettingsFromArgs = mock((_args: string[]) => ({ ok: true }))
|
|
const mockResolveOutOfProcessTeammateProviderFromCliArgs = mock(
|
|
(_args: string[], _settings: unknown) => undefined,
|
|
)
|
|
const mockApplyAgentProviderOverrideToEnv = mock((_override: unknown) => {})
|
|
const mockGetInitialSettings = mock(() => ({}))
|
|
const mockRefreshGithubModelsTokenIfNeeded = mock(async () => {})
|
|
const mockHydrateGithubModelsTokenFromSecureStorage = mock(() => {})
|
|
const mockValidateProviderEnvForStartupOrExit = mock(async () => {})
|
|
const mockPrintStartupScreen = mock((_model: string | undefined) => {})
|
|
const mockStartCapturingEarlyInput = mock(() => {})
|
|
const mockCliMain = mock(async () => {})
|
|
|
|
const runtimeMocks = [
|
|
mockProfileCheckpoint,
|
|
mockPsHandler,
|
|
mockLogsHandler,
|
|
mockAttachHandler,
|
|
mockKillHandler,
|
|
mockHandleBgFlag,
|
|
mockPrepareBackgroundSessionFinalizer,
|
|
mockLoadEnvFile,
|
|
mockParseProviderEnvFileArgs,
|
|
mockReapplyRememberedEnvFileValues,
|
|
mockRememberLoadedEnvFileValues,
|
|
mockEnableConfigs,
|
|
mockApplySafeConfigEnvironmentVariables,
|
|
mockApplyStartupEnvFromProfile,
|
|
mockGetProviderValidationError,
|
|
mockEagerLoadSettingsFromArgs,
|
|
mockResolveOutOfProcessTeammateProviderFromCliArgs,
|
|
mockApplyAgentProviderOverrideToEnv,
|
|
mockGetInitialSettings,
|
|
mockRefreshGithubModelsTokenIfNeeded,
|
|
mockHydrateGithubModelsTokenFromSecureStorage,
|
|
mockValidateProviderEnvForStartupOrExit,
|
|
mockPrintStartupScreen,
|
|
mockStartCapturingEarlyInput,
|
|
mockCliMain,
|
|
]
|
|
|
|
function clearRuntimeMocks() {
|
|
for (const fn of runtimeMocks) {
|
|
fn.mockClear()
|
|
}
|
|
}
|
|
|
|
describe('cli.tsx — NODE_OPTIONS --max-old-space-size (issue #402)', () => {
|
|
const originalNodeOptions = process.env.NODE_OPTIONS
|
|
|
|
beforeEach(() => {
|
|
delete process.env.NODE_OPTIONS
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (originalNodeOptions !== undefined) {
|
|
process.env.NODE_OPTIONS = originalNodeOptions
|
|
} else {
|
|
delete process.env.NODE_OPTIONS
|
|
}
|
|
})
|
|
|
|
it('sets --max-old-space-size=8192 when NODE_OPTIONS is not set', () => {
|
|
// Guard predicate: fires when the flag is absent
|
|
const shouldSetHeapCap = !process.env.NODE_OPTIONS?.includes('--max-old-space-size')
|
|
expect(shouldSetHeapCap).toBe(true)
|
|
})
|
|
|
|
it('does not override existing --max-old-space-size=4096', () => {
|
|
process.env.NODE_OPTIONS = '--max-old-space-size=4096 --experimental-vm-modules'
|
|
|
|
const shouldSetHeapCap = !process.env.NODE_OPTIONS.includes('--max-old-space-size')
|
|
expect(shouldSetHeapCap).toBe(false)
|
|
expect(process.env.NODE_OPTIONS).toContain('4096')
|
|
})
|
|
|
|
it('does not override existing --max-old-space-size=8192', () => {
|
|
process.env.NODE_OPTIONS = '--max-old-space-size=8192'
|
|
|
|
const shouldSetHeapCap = !process.env.NODE_OPTIONS.includes('--max-old-space-size')
|
|
expect(shouldSetHeapCap).toBe(false)
|
|
expect(process.env.NODE_OPTIONS).toBe('--max-old-space-size=8192')
|
|
})
|
|
|
|
it('appends --max-old-space-size when NODE_OPTIONS has other flags', () => {
|
|
process.env.NODE_OPTIONS = '--inspect=9229'
|
|
|
|
const result = `${process.env.NODE_OPTIONS} --max-old-space-size=8192`
|
|
expect(result).toBe('--inspect=9229 --max-old-space-size=8192')
|
|
})
|
|
})
|
|
|
|
describe('cli.tsx — --provider startup ordering', () => {
|
|
const providerEnvKeys = [
|
|
'CLAUDE_CODE_USE_OPENAI',
|
|
'CLAUDE_CODE_USE_GEMINI',
|
|
'OPENAI_API_KEY',
|
|
'OPENAI_BASE_URL',
|
|
'OPENAI_MODEL',
|
|
'GEMINI_MODEL',
|
|
]
|
|
const originalEnv = new Map<string, string | undefined>()
|
|
let tempDir: string
|
|
|
|
beforeEach(() => {
|
|
clearRememberedProviderFlagForTests()
|
|
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-cli-env-file-test-'))
|
|
for (const key of providerEnvKeys) {
|
|
originalEnv.set(key, process.env[key])
|
|
delete process.env[key]
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true })
|
|
for (const key of providerEnvKeys) {
|
|
const originalValue = originalEnv.get(key)
|
|
if (originalValue === undefined) {
|
|
delete process.env[key]
|
|
} else {
|
|
process.env[key] = originalValue
|
|
}
|
|
}
|
|
originalEnv.clear()
|
|
clearRememberedProviderFlagForTests()
|
|
})
|
|
|
|
function writeProviderEnvFile(content: string): string {
|
|
const filePath = join(tempDir, '.env')
|
|
writeFileSync(filePath, content, 'utf-8')
|
|
return filePath
|
|
}
|
|
|
|
it('remembers --provider so settings.env reloads cannot clobber it', async () => {
|
|
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
|
|
|
|
const earlyProviderApplyIndex = src.indexOf('applyProviderFlagFromArgs(args')
|
|
const rememberOptionIndex = src.indexOf(
|
|
'rememberForSettingsEnv: true',
|
|
earlyProviderApplyIndex,
|
|
)
|
|
const settingsEnvApplyIndex = src.indexOf(
|
|
'applySafeConfigEnvironmentVariables()',
|
|
)
|
|
|
|
expect(earlyProviderApplyIndex).toBeGreaterThanOrEqual(0)
|
|
expect(rememberOptionIndex).toBeGreaterThan(earlyProviderApplyIndex)
|
|
expect(settingsEnvApplyIndex).toBeGreaterThan(earlyProviderApplyIndex)
|
|
})
|
|
|
|
it('reapplies remembered --provider after every managed settings env merge', async () => {
|
|
const src = await Bun.file(`${import.meta.dir}/../utils/managedEnv.ts`).text()
|
|
const safeApplyIndex = src.indexOf('export function applySafeConfigEnvironmentVariables')
|
|
const configApplyIndex = src.indexOf('export function applyConfigEnvironmentVariables')
|
|
const safeReapplyIndex = src.indexOf(
|
|
'reapplyRememberedProviderFlag()',
|
|
safeApplyIndex,
|
|
)
|
|
const configReapplyIndex = src.indexOf(
|
|
'reapplyRememberedProviderFlag()',
|
|
configApplyIndex,
|
|
)
|
|
|
|
expect(safeReapplyIndex).toBeGreaterThan(safeApplyIndex)
|
|
expect(safeReapplyIndex).toBeLessThan(configApplyIndex)
|
|
expect(configReapplyIndex).toBeGreaterThan(configApplyIndex)
|
|
})
|
|
|
|
it('remembers provider env-file values so later managed settings env merges can restore them', async () => {
|
|
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
|
|
const envFileImportIndex = src.indexOf('rememberLoadedEnvFileValues')
|
|
const rememberLoadedFileIndex = src.indexOf(
|
|
'rememberLoadedEnvFileValues(loadEnvFile(filePath))',
|
|
)
|
|
|
|
expect(envFileImportIndex).toBeGreaterThanOrEqual(0)
|
|
expect(rememberLoadedFileIndex).toBeGreaterThan(envFileImportIndex)
|
|
})
|
|
|
|
it('preserves explicit --provider-env-file values through settings and startup profile env merges', () => {
|
|
const filePath = writeProviderEnvFile([
|
|
'CLAUDE_CODE_USE_OPENAI=1',
|
|
'OPENAI_API_KEY=file-key',
|
|
'OPENAI_BASE_URL=https://file.example/v1',
|
|
'OPENAI_MODEL=file-model',
|
|
].join('\n'))
|
|
|
|
const loaded = loadEnvFile(filePath)
|
|
|
|
Object.assign(process.env, {
|
|
OPENAI_API_KEY: 'settings-key',
|
|
OPENAI_BASE_URL: 'https://settings.example/v1',
|
|
OPENAI_MODEL: 'settings-model',
|
|
})
|
|
applyLoadedEnvFileValues(loaded)
|
|
|
|
applyProfileEnvToProcessEnv(process.env, {
|
|
CLAUDE_CODE_USE_OPENAI: '1',
|
|
OPENAI_API_KEY: 'profile-key',
|
|
OPENAI_BASE_URL: 'https://profile.example/v1',
|
|
OPENAI_MODEL: 'profile-model',
|
|
})
|
|
applyLoadedEnvFileValues(loaded)
|
|
|
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
|
|
expect(process.env.OPENAI_API_KEY).toBe('file-key')
|
|
expect(process.env.OPENAI_BASE_URL).toBe('https://file.example/v1')
|
|
expect(process.env.OPENAI_MODEL).toBe('file-model')
|
|
})
|
|
|
|
it('keeps explicit --provider values ahead of provider env-file reapply checkpoints', () => {
|
|
const filePath = writeProviderEnvFile([
|
|
'CLAUDE_CODE_USE_OPENAI=1',
|
|
'OPENAI_API_KEY=file-key',
|
|
'OPENAI_BASE_URL=https://file.example/v1',
|
|
'OPENAI_MODEL=file-model',
|
|
].join('\n'))
|
|
|
|
const loaded = loadEnvFile(filePath)
|
|
const result = applyProviderFlagFromArgs(
|
|
['--provider', 'gemini', '--model', 'gemini-2.0-flash'],
|
|
{ rememberForSettingsEnv: true },
|
|
)
|
|
expect(result?.error).toBeUndefined()
|
|
|
|
applyLoadedEnvFileValues(loaded)
|
|
reapplyRememberedProviderFlag()
|
|
applyLoadedEnvFileValues(loaded)
|
|
reapplyRememberedProviderFlag()
|
|
|
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
|
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBe('1')
|
|
expect(process.env.GEMINI_MODEL).toBe('gemini-2.0-flash')
|
|
})
|
|
|
|
it('dispatches background session management before config and provider validation', async () => {
|
|
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
|
|
const bgManagementIndex = src.indexOf("args[0] === 'ps'")
|
|
const configEnableIndex = src.indexOf('enableConfigs()')
|
|
const providerValidationIndex = src.indexOf(
|
|
'await validateProviderEnvForStartupOrExit()',
|
|
)
|
|
|
|
expect(bgManagementIndex).toBeGreaterThanOrEqual(0)
|
|
expect(configEnableIndex).toBeGreaterThanOrEqual(0)
|
|
expect(providerValidationIndex).toBeGreaterThanOrEqual(0)
|
|
expect(bgManagementIndex).toBeLessThan(configEnableIndex)
|
|
expect(bgManagementIndex).toBeLessThan(providerValidationIndex)
|
|
})
|
|
|
|
it('keeps background spawn after profile routing but before provider validation', async () => {
|
|
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
|
|
const profileApplyIndex = src.indexOf('await applyStartupEnvFromProfile')
|
|
const bgFlagIndex = src.indexOf("optionArgs.includes('--bg')")
|
|
const providerValidationIndex = src.indexOf(
|
|
'await validateProviderEnvForStartupOrExit()',
|
|
)
|
|
|
|
expect(profileApplyIndex).toBeGreaterThanOrEqual(0)
|
|
expect(bgFlagIndex).toBeGreaterThanOrEqual(0)
|
|
expect(providerValidationIndex).toBeGreaterThanOrEqual(0)
|
|
expect(bgFlagIndex).toBeGreaterThan(profileApplyIndex)
|
|
expect(bgFlagIndex).toBeLessThan(providerValidationIndex)
|
|
})
|
|
|
|
})
|
|
|
|
const mockImporters = {
|
|
startupProfiler: async () => ({
|
|
profileCheckpoint: mockProfileCheckpoint,
|
|
}),
|
|
bg: async () => ({
|
|
psHandler: mockPsHandler,
|
|
logsHandler: mockLogsHandler,
|
|
attachHandler: mockAttachHandler,
|
|
killHandler: mockKillHandler,
|
|
handleBgFlag: mockHandleBgFlag,
|
|
}),
|
|
bgFinalizer: async () => ({
|
|
prepareBackgroundSessionFinalizer: mockPrepareBackgroundSessionFinalizer,
|
|
}),
|
|
envFile: async () => ({
|
|
loadEnvFile: mockLoadEnvFile,
|
|
parseProviderEnvFileArgs: mockParseProviderEnvFileArgs,
|
|
reapplyRememberedEnvFileValues: mockReapplyRememberedEnvFileValues,
|
|
rememberLoadedEnvFileValues: mockRememberLoadedEnvFileValues,
|
|
}),
|
|
config: async () => ({
|
|
enableConfigs: mockEnableConfigs,
|
|
}),
|
|
managedEnv: async () => ({
|
|
applySafeConfigEnvironmentVariables:
|
|
mockApplySafeConfigEnvironmentVariables,
|
|
}),
|
|
providerProfile: async () => ({
|
|
applyStartupEnvFromProfile: mockApplyStartupEnvFromProfile,
|
|
}),
|
|
providerValidation: async () => ({
|
|
getProviderValidationError: mockGetProviderValidationError,
|
|
validateProviderEnvForStartupOrExit:
|
|
mockValidateProviderEnvForStartupOrExit,
|
|
}),
|
|
flagSettings: async () => ({
|
|
eagerLoadSettingsFromArgs: mockEagerLoadSettingsFromArgs,
|
|
}),
|
|
agentRouting: async () => ({
|
|
applyAgentProviderOverrideToEnv: mockApplyAgentProviderOverrideToEnv,
|
|
resolveOutOfProcessTeammateProviderFromCliArgs:
|
|
mockResolveOutOfProcessTeammateProviderFromCliArgs,
|
|
}),
|
|
settings: async () => ({
|
|
getInitialSettings: mockGetInitialSettings,
|
|
}),
|
|
githubModelsCredentials: async () => ({
|
|
hydrateGithubModelsTokenFromSecureStorage:
|
|
mockHydrateGithubModelsTokenFromSecureStorage,
|
|
refreshGithubModelsTokenIfNeeded: mockRefreshGithubModelsTokenIfNeeded,
|
|
}),
|
|
startupScreen: async () => ({
|
|
printStartupScreen: mockPrintStartupScreen,
|
|
}),
|
|
earlyInput: async () => ({
|
|
startCapturingEarlyInput: mockStartCapturingEarlyInput,
|
|
}),
|
|
main: async () => ({
|
|
main: mockCliMain,
|
|
}),
|
|
}
|
|
|
|
describe('cli.tsx — background routing behavior', () => {
|
|
const bgOptions = {
|
|
bgSessionsEnabled: true,
|
|
importers: mockImporters,
|
|
} as unknown as Parameters<CliMain>[1]
|
|
const originalAutoRunGuard =
|
|
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
|
|
const savedArgv = [...process.argv]
|
|
|
|
beforeAll(async () => {
|
|
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = '1'
|
|
|
|
const entrypoint = await import('./cli.js')
|
|
runCliEntrypoint = entrypoint.main
|
|
})
|
|
|
|
afterAll(() => {
|
|
if (originalAutoRunGuard === undefined) {
|
|
delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
|
|
} else {
|
|
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN =
|
|
originalAutoRunGuard
|
|
}
|
|
})
|
|
|
|
beforeEach(() => {
|
|
clearRuntimeMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.argv = [...savedArgv]
|
|
})
|
|
|
|
it('dispatches background management commands before startup work', async () => {
|
|
const cases: Array<[string, typeof mockPsHandler, string[]]> = [
|
|
['ps', mockPsHandler, ['--json']],
|
|
['logs', mockLogsHandler, ['session-1', '-f']],
|
|
['attach', mockAttachHandler, ['session-1']],
|
|
['kill', mockKillHandler, ['session-1']],
|
|
]
|
|
|
|
for (const [command, handler, tail] of cases) {
|
|
clearRuntimeMocks()
|
|
|
|
await runCliEntrypoint([command, ...tail], bgOptions)
|
|
|
|
expect(handler.mock.calls).toEqual([[tail]])
|
|
expect(mockParseProviderEnvFileArgs).not.toHaveBeenCalled()
|
|
expect(mockHandleBgFlag).not.toHaveBeenCalled()
|
|
expect(mockEnableConfigs).not.toHaveBeenCalled()
|
|
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
|
|
expect(mockCliMain).not.toHaveBeenCalled()
|
|
}
|
|
})
|
|
|
|
it('establishes background finalizer ownership before any command path', async () => {
|
|
process.env[BACKGROUND_SESSION_ID_ENV] = 'bg-entrypoint'
|
|
mockPrepareBackgroundSessionFinalizer.mockImplementationOnce(async () => {
|
|
throw new Error('finalizer ownership not ready')
|
|
})
|
|
try {
|
|
await expect(runCliEntrypoint(['ps'], bgOptions)).rejects.toThrow(
|
|
'finalizer ownership not ready',
|
|
)
|
|
} finally {
|
|
delete process.env[BACKGROUND_SESSION_ID_ENV]
|
|
}
|
|
|
|
expect(mockPrepareBackgroundSessionFinalizer).toHaveBeenCalledTimes(1)
|
|
expect(mockPsHandler).not.toHaveBeenCalled()
|
|
expect(mockEnableConfigs).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('routes partial background metadata through the finalizer before dispatch', async () => {
|
|
process.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV] = '123'
|
|
try {
|
|
await runCliEntrypoint(['ps'], bgOptions)
|
|
} finally {
|
|
delete process.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]
|
|
}
|
|
|
|
expect(mockPrepareBackgroundSessionFinalizer).toHaveBeenCalledTimes(1)
|
|
expect(mockPsHandler).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps management commands on the management path even with --bg arguments', async () => {
|
|
const cases: Array<[string, typeof mockPsHandler]> = [
|
|
['ps', mockPsHandler],
|
|
['logs', mockLogsHandler],
|
|
['attach', mockAttachHandler],
|
|
['kill', mockKillHandler],
|
|
]
|
|
|
|
for (const [command, handler] of cases) {
|
|
clearRuntimeMocks()
|
|
|
|
await runCliEntrypoint([command, '--bg', 'session-1'], bgOptions)
|
|
|
|
expect(handler.mock.calls).toEqual([[['--bg', 'session-1']]])
|
|
expect(mockParseProviderEnvFileArgs).not.toHaveBeenCalled()
|
|
expect(mockHandleBgFlag).not.toHaveBeenCalled()
|
|
expect(mockEnableConfigs).not.toHaveBeenCalled()
|
|
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
|
|
expect(mockCliMain).not.toHaveBeenCalled()
|
|
}
|
|
})
|
|
|
|
it('routes real background flags after profile routing without provider validation', async () => {
|
|
const args = ['--background', '--', '--print']
|
|
|
|
await runCliEntrypoint(args, bgOptions)
|
|
|
|
expect(mockEnableConfigs).toHaveBeenCalledTimes(1)
|
|
expect(mockParseProviderEnvFileArgs.mock.calls).toEqual([[args]])
|
|
expect(mockReapplyRememberedEnvFileValues).toHaveBeenCalledTimes(2)
|
|
expect(mockApplySafeConfigEnvironmentVariables).toHaveBeenCalledTimes(1)
|
|
expect(mockApplyStartupEnvFromProfile).toHaveBeenCalledTimes(1)
|
|
expect(mockEagerLoadSettingsFromArgs.mock.calls).toEqual([[args]])
|
|
expect(mockHandleBgFlag.mock.calls).toEqual([[args]])
|
|
expect(mockRefreshGithubModelsTokenIfNeeded).not.toHaveBeenCalled()
|
|
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
|
|
expect(mockCliMain).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('treats --bg after -- as positional text, not a background flag', async () => {
|
|
const args = ['--', '--bg']
|
|
|
|
await runCliEntrypoint(args, bgOptions)
|
|
|
|
expect(mockHandleBgFlag).not.toHaveBeenCalled()
|
|
expect(mockRefreshGithubModelsTokenIfNeeded).toHaveBeenCalledTimes(1)
|
|
expect(mockHydrateGithubModelsTokenFromSecureStorage).toHaveBeenCalledTimes(
|
|
1,
|
|
)
|
|
expect(mockValidateProviderEnvForStartupOrExit).toHaveBeenCalledTimes(1)
|
|
expect(mockPrintStartupScreen).toHaveBeenCalledTimes(1)
|
|
expect(mockCliMain).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('Node 24 premature exit regression (issue #1678)', () => {
|
|
it('built CLI stays alive during initialization in interactive mode without premature exit', async () => {
|
|
const os = await import('node:os')
|
|
const path = await import('node:path')
|
|
const fs = await import('node:fs/promises')
|
|
const url = await import('node:url')
|
|
|
|
const scriptPath = path.join(os.tmpdir(), `test-cli-startup-${Date.now()}.mjs`)
|
|
const cliUrl = url.pathToFileURL(path.resolve(import.meta.dir, '../../dist/cli.mjs')).href
|
|
let proc
|
|
|
|
try {
|
|
await Bun.write(scriptPath, `
|
|
// Mock TTY so the CLI thinks it's interactive and starts the TUI
|
|
process.stdout.isTTY = true;
|
|
process.stdin.isTTY = true;
|
|
process.stdin.setRawMode = () => {};
|
|
process.env.OPENCLAUDE_DISABLE_TELEMETRY = '1';
|
|
process.env.OPENGATEWAY_API_KEY = 'dummy';
|
|
|
|
// Ensure the CLI auto-runs even if the test runner disabled it globally
|
|
delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN;
|
|
|
|
// Use absolute import to work from os.tmpdir()
|
|
// If the entrypoint uses void main(), this promise resolves immediately.
|
|
// If it correctly uses await main(), it stays pending while the CLI runs.
|
|
import('${cliUrl}').then(() => {
|
|
console.log('---PREMATURE_EVAL_END---');
|
|
process.exit(0);
|
|
});
|
|
`)
|
|
|
|
proc = Bun.spawn(['node', scriptPath], { stdout: 'pipe' })
|
|
const reader = proc.stdout.getReader()
|
|
|
|
let gotOutput = false
|
|
let evaluationEndedPrematurely = false
|
|
|
|
async function readStdout() {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
const text = new TextDecoder().decode(value)
|
|
if (text.includes('---PREMATURE_EVAL_END---')) {
|
|
evaluationEndedPrematurely = true
|
|
} else if (text.trim().length > 0) {
|
|
gotOutput = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start reading without awaiting it yet
|
|
const readPromise = readStdout()
|
|
|
|
// Wait until we get startup output or detect premature evaluation end
|
|
const start = Date.now()
|
|
while (!gotOutput && !evaluationEndedPrematurely && Date.now() - start < 5000) {
|
|
await new Promise(r => setTimeout(r, 10))
|
|
}
|
|
|
|
expect(gotOutput).toBe(true)
|
|
|
|
// The critical regression window: wait 500ms *after* output.
|
|
// With void main(), Node 24 will exit during the subsequent async imports because the event loop empties,
|
|
// which allows the import() promise above to resolve and emit the signal.
|
|
await new Promise(r => setTimeout(r, 500))
|
|
|
|
expect(evaluationEndedPrematurely).toBe(false)
|
|
expect(proc.exitCode).toBe(null)
|
|
expect(proc.killed).toBe(false)
|
|
} finally {
|
|
if (proc && proc.exitCode === null && !proc.killed) {
|
|
proc.kill()
|
|
}
|
|
await fs.unlink(scriptPath).catch(() => {})
|
|
}
|
|
})
|
|
|
|
it('cli.tsx uses top-level await for main() to prevent premature exit', async () => {
|
|
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
|
|
expect(src).toMatch(/await main\(\)/)
|
|
expect(src).not.toMatch(/^\s*void main\(\)/m)
|
|
})
|
|
})
|
|
|
|
describe('cli.tsx — --yolo alias (PR #1939)', () => {
|
|
const options = {
|
|
importers: mockImporters,
|
|
} as unknown as Parameters<CliMain>[1]
|
|
const originalAutoRunGuard =
|
|
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
|
|
const savedArgv = [...process.argv]
|
|
|
|
beforeAll(async () => {
|
|
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = '1'
|
|
const entrypoint = await import('./cli.js')
|
|
runCliEntrypoint = entrypoint.main
|
|
})
|
|
|
|
afterAll(() => {
|
|
if (originalAutoRunGuard === undefined) {
|
|
delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
|
|
} else {
|
|
process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN =
|
|
originalAutoRunGuard
|
|
}
|
|
})
|
|
|
|
beforeEach(() => {
|
|
clearRuntimeMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.argv = [...savedArgv]
|
|
})
|
|
|
|
// Mirrors the registration in main.tsx. commander derives the option's
|
|
// attribute from the LAST long flag, so both spellings set the same
|
|
// dangerouslySkipPermissions key — the whole reason a native alias works.
|
|
const buildProgram = () =>
|
|
new Command()
|
|
.option(
|
|
'--yolo, --dangerously-skip-permissions',
|
|
'bypass',
|
|
() => true,
|
|
)
|
|
.allowExcessArguments()
|
|
.exitOverride()
|
|
|
|
it('commander resolves --yolo to dangerouslySkipPermissions', () => {
|
|
expect(
|
|
buildProgram().parse(['node', 'x', '--yolo']).opts()
|
|
.dangerouslySkipPermissions,
|
|
).toBe(true)
|
|
expect(
|
|
buildProgram().parse(['node', 'x', '--dangerously-skip-permissions']).opts()
|
|
.dangerouslySkipPermissions,
|
|
).toBe(true)
|
|
expect(
|
|
buildProgram().parse(['node', 'x']).opts().dangerouslySkipPermissions,
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('passes args through to cliMain verbatim — no per-token --yolo rewrite', async () => {
|
|
// Regression guard for the six correctness bugs the old pre-parse argv
|
|
// rewrite caused: --yolo must reach commander untouched, whatever position
|
|
// it sits in (after a value flag, after `--`, or on a subcommand), so
|
|
// commander — not a hand-rolled scanner — resolves it.
|
|
const cases = [
|
|
['--yolo', '-p', 'hi'],
|
|
['--system-prompt', '--yolo'],
|
|
['-p', '--', '--yolo'],
|
|
['mcp', 'add', '--yolo', 'srv', 'cmd'],
|
|
]
|
|
for (const argv of cases) {
|
|
clearRuntimeMocks()
|
|
process.argv = ['node', 'openclaude', ...argv]
|
|
let argvSeenByCliMain: string[] | undefined
|
|
mockCliMain.mockImplementationOnce(async () => {
|
|
argvSeenByCliMain = [...process.argv]
|
|
})
|
|
|
|
await runCliEntrypoint(argv, options)
|
|
|
|
expect(argvSeenByCliMain).toEqual(['node', 'openclaude', ...argv])
|
|
}
|
|
})
|
|
|
|
it('does not mutate the host process.argv (no leak of a caller args array)', async () => {
|
|
// main() must not push an explicit args array into the process-global argv:
|
|
// cliMain reads the real process.argv, and leaking a caller's args (e.g. a
|
|
// bypass flag) into it would corrupt an overlapping call or the host.
|
|
const hostArgv = ['node', 'openclaude', 'host-arg']
|
|
process.argv = [...hostArgv]
|
|
await runCliEntrypoint(['--yolo', '-p', 'hi'], options)
|
|
expect(process.argv).toEqual(hostArgv)
|
|
})
|
|
|
|
it('the built CLI lists --yolo on the main, ssh, and open command help (live registration)', async () => {
|
|
// Behavioral proof the alias is registered on the real commands — not dead
|
|
// code or the wrong command: commander only prints an option in --help if it
|
|
// is actually registered. --help short-circuits before any startup.
|
|
const fs = await import('node:fs')
|
|
const path = await import('node:path')
|
|
const cliPath = path.resolve(import.meta.dir, '../../dist/cli.mjs')
|
|
if (!fs.existsSync(cliPath)) return // needs `bun run build`; always present in CI
|
|
// The describe's beforeAll sets OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN=1
|
|
// to keep main() from auto-running in-process; the child must NOT inherit it
|
|
// or the entrypoint never runs and prints nothing.
|
|
const childEnv: Record<string, string | undefined> = {
|
|
...process.env,
|
|
OPENCLAUDE_DISABLE_TELEMETRY: '1',
|
|
}
|
|
delete childEnv.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
|
|
for (const argv of [
|
|
['--yolo', '--help'],
|
|
['ssh', '--yolo', '--help'],
|
|
['open', '--yolo', '--help'],
|
|
]) {
|
|
const out = Bun.spawnSync(['node', cliPath, ...argv], { env: childEnv })
|
|
const text = `${out.stdout.toString()}${out.stderr.toString()}`
|
|
expect(out.exitCode).toBe(0)
|
|
expect(text).not.toContain('unknown option')
|
|
expect(text).toContain('--yolo, --dangerously-skip-permissions')
|
|
}
|
|
}, { timeout: 20000 })
|
|
|
|
it('has no production startup gates using naive includes print checks', async () => {
|
|
// All pre-Commander print-mode decisions must go through the shared
|
|
// arity-aware predicate. A naive .includes('-p') / .includes('--print')
|
|
// disagrees with Commander on value-consumed tokens such as
|
|
// `--system-prompt --print` or `--model -p`.
|
|
const files = [
|
|
'src/utils/interactivity.ts',
|
|
'src/utils/earlyInput.ts',
|
|
'src/utils/gracefulShutdown.ts',
|
|
'src/utils/providerValidation.ts',
|
|
'src/services/api/logging.ts',
|
|
'src/cli/bg.ts',
|
|
'src/main.tsx',
|
|
]
|
|
for (const file of files) {
|
|
const src = await Bun.file(`${import.meta.dir}/../../${file}`).text()
|
|
expect(src).not.toMatch(/\.includes\(['"]-p['"]\)/)
|
|
expect(src).not.toMatch(/\.includes\(['"]--print['"]\)/)
|
|
}
|
|
})
|
|
})
|