diff --git a/src/bridge/bridgeMain.ts b/src/bridge/bridgeMain.ts index f7dbe4d03..feb3da8b8 100644 --- a/src/bridge/bridgeMain.ts +++ b/src/bridge/bridgeMain.ts @@ -12,6 +12,7 @@ import { logEventAsync, } from '../services/analytics/index.js' import { isInBundledMode } from '../utils/bundledMode.js' +import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js' import { logForDebugging } from '../utils/debug.js' import { rcLog } from './rcDebugLog.js' import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' @@ -111,17 +112,15 @@ function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number { /** * Returns the args that must precede CLI flags when spawning a child claude - * process. In compiled binaries, process.execPath is the claude binary itself - * and args go directly to it. In npm installs (node running cli.js), - * process.execPath is the node runtime — the child spawn must pass the script - * path as the first arg, otherwise node interprets --sdk-url as a node option - * and exits with "bad option: --sdk-url". See anthropics/claude-code#28334. + * process. Delegates to the centralized cliLaunch module which handles + * bundled-vs-script mode, execArgv sanitization, and the Bun execArgv leak + * quirk. See anthropics/claude-code#28334. */ function spawnScriptArgs(): string[] { - if (isInBundledMode() || !process.argv[1]) { - return [] - } - return [process.argv[1]] + const bootstrap = [...getBootstrapArgs()] + const script = getScriptPath() + if (script) bootstrap.push(script) + return bootstrap } /** Attempt to spawn a session; returns error string if spawn throws. */ diff --git a/src/cli/bg.ts b/src/cli/bg.ts index ee2fbbdc1..2f1125e7d 100644 --- a/src/cli/bg.ts +++ b/src/cli/bg.ts @@ -279,6 +279,32 @@ export async function killHandler(target: string | undefined): Promise { export async function handleBgStart(args: string[]): Promise { const engine = await selectEngine() + // Strip --bg/--background from args (for backward-compat shortcut) + const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background') + + // Engines without interactive TTY input (e.g. detached) require -p/--print + // or piped input. Tmux provides a virtual terminal so it works without -p. + if ( + !engine.supportsInteractiveInput && + !filteredArgs.some(a => a === '-p' || a === '--print' || a === '--pipe') + ) { + console.error( + 'Error: Background sessions with detached engine require -p/--print flag.\n' + + 'The detached engine has no terminal for interactive input.\n\n' + + 'Usage:\n' + + ' claude daemon bg -p "your prompt here"\n' + + ' echo "prompt" | claude daemon bg --pipe', + ) + if (process.platform !== 'win32') { + console.error( + '\nAlternatively, install tmux for interactive background sessions:\n' + + ` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`, + ) + } + process.exitCode = 1 + return + } + const sessionName = `claude-bg-${randomUUID().slice(0, 8)}` const logPath = join( getClaudeConfigHomeDir(), @@ -287,9 +313,6 @@ export async function handleBgStart(args: string[]): Promise { `${sessionName}.log`, ) - // Strip --bg/--background from args (for backward-compat shortcut) - const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background') - try { const result = await engine.start({ sessionName, diff --git a/src/cli/bg/engine.ts b/src/cli/bg/engine.ts index 6e79dfcb4..394184f24 100644 --- a/src/cli/bg/engine.ts +++ b/src/cli/bg/engine.ts @@ -41,6 +41,8 @@ export interface BgStartResult { export interface BgEngine { readonly name: 'tmux' | 'detached' + /** Whether the engine provides a TTY for interactive REPL input. */ + readonly supportsInteractiveInput: boolean available(): Promise start(opts: BgStartOptions): Promise attach(session: SessionEntry): Promise diff --git a/src/cli/bg/engines/detached.ts b/src/cli/bg/engines/detached.ts index d3eeeb22d..3525ad2df 100644 --- a/src/cli/bg/engines/detached.ts +++ b/src/cli/bg/engines/detached.ts @@ -1,6 +1,6 @@ -import { spawn } from 'child_process' -import { openSync, closeSync, mkdirSync } from 'fs' +import { closeSync, mkdirSync, openSync } from 'fs' import { dirname } from 'path' +import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js' import type { BgEngine, BgStartOptions, @@ -11,6 +11,7 @@ import { tailLog } from '../tail.js' export class DetachedEngine implements BgEngine { readonly name = 'detached' as const + readonly supportsInteractiveInput = false async available(): Promise { return true @@ -20,17 +21,19 @@ export class DetachedEngine implements BgEngine { mkdirSync(dirname(opts.logPath), { recursive: true }) const logFd = openSync(opts.logPath, 'a') - const entrypoint = process.argv[1]! - const child = spawn(process.execPath, [entrypoint, ...opts.args], { - detached: true, - stdio: ['ignore', logFd, logFd], + const launch = buildCliLaunch(opts.args, { env: { ...opts.env, CLAUDE_CODE_SESSION_KIND: 'bg', CLAUDE_CODE_SESSION_NAME: opts.sessionName, CLAUDE_CODE_SESSION_LOG: opts.logPath, - } as Record, + } as NodeJS.ProcessEnv, + }) + + const child = spawnCli(launch, { + detached: true, + stdio: ['ignore', logFd, logFd], cwd: opts.cwd, }) diff --git a/src/cli/bg/engines/tmux.ts b/src/cli/bg/engines/tmux.ts index 2d5486ef3..c937549ce 100644 --- a/src/cli/bg/engines/tmux.ts +++ b/src/cli/bg/engines/tmux.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'child_process' import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' -import { quote } from '../../../utils/bash/shellQuote.js' +import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js' import type { BgEngine, BgStartOptions, @@ -10,6 +10,7 @@ import type { export class TmuxEngine implements BgEngine { readonly name = 'tmux' as const + readonly supportsInteractiveInput = true async available(): Promise { const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false }) @@ -17,21 +18,22 @@ export class TmuxEngine implements BgEngine { } async start(opts: BgStartOptions): Promise { - const entrypoint = process.argv[1]! - const cmd = quote([process.execPath, entrypoint, ...opts.args]) + const launch = buildCliLaunch(opts.args, { + env: { + ...opts.env, + CLAUDE_CODE_SESSION_KIND: 'bg', + CLAUDE_CODE_SESSION_NAME: opts.sessionName, + CLAUDE_CODE_SESSION_LOG: opts.logPath, + CLAUDE_CODE_TMUX_SESSION: opts.sessionName, + } as NodeJS.ProcessEnv, + }) - const tmuxEnv: Record = { - ...opts.env, - CLAUDE_CODE_SESSION_KIND: 'bg', - CLAUDE_CODE_SESSION_NAME: opts.sessionName, - CLAUDE_CODE_SESSION_LOG: opts.logPath, - CLAUDE_CODE_TMUX_SESSION: opts.sessionName, - } + const cmd = quoteCliLaunch(launch) const result = spawnSync( 'tmux', ['new-session', '-d', '-s', opts.sessionName, cmd], - { stdio: 'inherit', env: tmuxEnv }, + { stdio: 'inherit', env: launch.env }, ) if (result.status !== 0) { diff --git a/src/commands/assistant/assistant.tsx b/src/commands/assistant/assistant.tsx index 3af87b0e3..91f75542a 100644 --- a/src/commands/assistant/assistant.tsx +++ b/src/commands/assistant/assistant.tsx @@ -1,4 +1,3 @@ -import { spawn } from 'child_process'; import * as React from 'react'; import { useEffect, useState } from 'react'; import { resolve } from 'path'; @@ -8,6 +7,7 @@ import { ListItem } from '../../components/design-system/ListItem.js'; import { useRegisterOverlay } from '../../context/overlayContext.js'; import { useKeybindings } from '../../keybindings/useKeybinding.js'; import { findGitRoot } from '../../utils/git.js'; +import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js'; import { getKairosActive, setKairosActive } from '../../bootstrap/state.js'; import type { LocalJSXCommandContext } from '../../commands.js'; import type { LocalJSXCommandOnDone } from '../../types/command.js'; @@ -65,9 +65,9 @@ export function NewInstallWizard({ defaultDir, onInstalled, onCancel, onError }: const dir = defaultDir || resolve('.'); try { - const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`]; + const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]); - const child = spawn(process.execPath, execArgs, { + const child = spawnCli(launch, { cwd: dir, stdio: 'ignore', detached: true, diff --git a/src/commands/remoteControlServer/remoteControlServer.tsx b/src/commands/remoteControlServer/remoteControlServer.tsx index dc08a9cbc..c586b9484 100644 --- a/src/commands/remoteControlServer/remoteControlServer.tsx +++ b/src/commands/remoteControlServer/remoteControlServer.tsx @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from 'child_process'; +import { type ChildProcess } from 'child_process'; import { resolve } from 'path'; import * as React from 'react'; import { useEffect, useState } from 'react'; @@ -10,6 +10,7 @@ import { ListItem } from '../../components/design-system/ListItem.js'; import { useRegisterOverlay } from '../../context/overlayContext.js'; import { Box, Text } from '@anthropic/ink'; import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js'; import type { ToolUseContext } from '../../Tool.js'; import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; import { errorMessage } from '../../utils/errors.js'; @@ -202,9 +203,9 @@ async function checkPrerequisites(): Promise { function startDaemon(): void { const dir = resolve('.'); - const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`]; + const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]); - const child = spawn(process.execPath, execArgs, { + const child = spawnCli(launch, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], detached: false, diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 0d4c9366c..513103e9a 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -1,5 +1,6 @@ -import { spawn, type ChildProcess } from 'child_process' +import { type ChildProcess } from 'child_process' import { resolve } from 'path' +import { buildCliLaunch, spawnCli } from '../utils/cliLaunch.js' import { errorMessage } from '../utils/errors.js' import { writeDaemonState, @@ -335,17 +336,11 @@ function spawnWorker( CLAUDE_CODE_SESSION_KIND: 'daemon-worker', } - // Build the worker command: reuse the same entrypoint with --daemon-worker flag - const execArgs = [ - ...process.execArgv, - process.argv[1]!, - `--daemon-worker=${worker.kind}`, - ] - console.log(`[daemon] spawning worker '${worker.kind}'`) - const child = spawn(process.execPath, execArgs, { - env, + const launch = buildCliLaunch([`--daemon-worker=${worker.kind}`], { env }) + + const child = spawnCli(launch, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], }) diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index ed283a547..0e9b4ac80 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -121,9 +121,10 @@ async function main(): Promise { // perf-sensitive. No enableConfigs(), no analytics sinks at this layer — // workers are lean. If a worker kind needs configs/auth (assistant will), // it calls them inside its run() fn. - if (feature('DAEMON') && args[0] === '--daemon-worker') { + if (feature('DAEMON') && (args[0] === '--daemon-worker' || args[0]?.startsWith('--daemon-worker='))) { + const kind = args[0] === '--daemon-worker' ? args[1] : args[0].split('=')[1]; const { runDaemonWorker } = await import('../daemon/workerRegistry.js'); - await runDaemonWorker(args[1]); + await runDaemonWorker(kind); return; } @@ -184,6 +185,8 @@ async function main(): Promise { profileCheckpoint('cli_daemon_path'); const { enableConfigs } = await import('../utils/config.js'); enableConfigs(); + const { setShellIfWindows } = await import('../utils/windowsPaths.js'); + setShellIfWindows(); const { initSinks } = await import('../utils/sinks.js'); initSinks(); const { daemonMain } = await import('../daemon/main.js'); @@ -196,6 +199,8 @@ async function main(): Promise { profileCheckpoint('cli_daemon_path'); const { enableConfigs } = await import('../utils/config.js'); enableConfigs(); + const { setShellIfWindows } = await import('../utils/windowsPaths.js'); + setShellIfWindows(); const bg = await import('../cli/bg.js'); await bg.handleBgStart(args.filter(a => a !== '--bg' && a !== '--background')); return; @@ -211,6 +216,8 @@ async function main(): Promise { profileCheckpoint('cli_daemon_path'); const { enableConfigs } = await import('../utils/config.js'); enableConfigs(); + const { setShellIfWindows } = await import('../utils/windowsPaths.js'); + setShellIfWindows(); const { initSinks } = await import('../utils/sinks.js'); initSinks(); const { daemonMain } = await import('../daemon/main.js'); diff --git a/src/utils/cliLaunch.ts b/src/utils/cliLaunch.ts new file mode 100644 index 000000000..02c3b403a --- /dev/null +++ b/src/utils/cliLaunch.ts @@ -0,0 +1,180 @@ +import { type ChildProcess, spawn, type SpawnOptions } from 'child_process' +import { isInBundledMode } from './bundledMode.js' +import { quote } from './bash/shellQuote.js' + +/** + * CliLaunchSpec — normalized descriptor for spawning a child CLI process. + * + * Every site that re-execs the CLI (daemon workers, bg sessions, bridge + * sessions, assistant/RCS daemon launchers) should use this instead of + * manually assembling `[...process.execArgv, process.argv[1]!, ...]`. + * + * Centralizing the bootstrap contract prevents the class of bugs where + * individual spawn sites forget execArgv, windowsHide, or env propagation. + */ +export interface CliLaunchSpec { + /** Runtime binary path (e.g. bun, node). */ + execPath: string + /** Full argument list including bootstrap args and CLI args. */ + args: string[] + /** Environment for the child process. */ + env: NodeJS.ProcessEnv + /** Whether to hide the console window on Windows. */ + windowsHide: boolean +} + +// --------------------------------------------------------------------------- +// Frozen bootstrap snapshot — computed once at module load time. +// +// Bun quirk (https://github.com/oven-sh/bun/issues/11673): in single-file +// executables, app arguments from process.argv can leak into process.execArgv. +// We snapshot and filter once, so every child gets a clean, stable set of +// runtime flags regardless of when buildCliLaunch is called. +// --------------------------------------------------------------------------- + +/** + * Filter out leaked application arguments from process.execArgv. + * Only keep known runtime flags: -d (defines), --feature, --inspect variants. + */ +function sanitizeExecArgv(raw: readonly string[]): string[] { + const result: string[] = [] + for (let i = 0; i < raw.length; i++) { + const arg = raw[i]! + // Bun define flags: -d KEY:VALUE or -dKEY:VALUE + if (arg === '-d' || arg.startsWith('-d ') || arg.startsWith('-d\t')) { + result.push(arg) + if (arg === '-d' && i + 1 < raw.length) { + result.push(raw[++i]!) + } + continue + } + if (arg.startsWith('-d') && arg.includes(':')) { + result.push(arg) + continue + } + // Bun feature flags: --feature NAME + if (arg === '--feature') { + result.push(arg) + if (i + 1 < raw.length) { + result.push(raw[++i]!) + } + continue + } + // Node/Bun inspect flags + if (/^--inspect(-brk)?(=|$)/.test(arg)) { + result.push(arg) + continue + } + // Keep other known runtime flags (e.g. --conditions, --experimental-*) + if (arg.startsWith('--') && !arg.includes('=') && i + 1 < raw.length) { + // Unknown two-part flag — skip conservatively in bundled mode only + if (isInBundledMode()) continue + result.push(arg) + result.push(raw[++i]!) + continue + } + if (arg.startsWith('-') && !isInBundledMode()) { + result.push(arg) + } + } + return result +} + +const BOOTSTRAP_ARGS: readonly string[] = Object.freeze( + sanitizeExecArgv(process.execArgv), +) +const SCRIPT_PATH: string | undefined = process.argv[1] +const EXEC_PATH: string = process.execPath +const IS_WINDOWS = process.platform === 'win32' + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Build a normalized launch spec for spawning a child CLI process. + * + * @param cliArgs Arguments to pass to the CLI entrypoint (e.g. ['daemon', 'start']) + * @param opts.env Override environment (defaults to process.env) + */ +export function buildCliLaunch( + cliArgs: string[], + opts?: { env?: NodeJS.ProcessEnv }, +): CliLaunchSpec { + const baseEnv = opts?.env ?? process.env + + // In bundled mode the execPath IS the CLI binary — no script path needed. + // In script mode (dev / npm) we need the script path between runtime flags + // and CLI args so the runtime knows which file to execute. + const args: string[] = + isInBundledMode() || !SCRIPT_PATH + ? [...BOOTSTRAP_ARGS, ...cliArgs] + : [...BOOTSTRAP_ARGS, SCRIPT_PATH, ...cliArgs] + + // Ensure Windows children can discover git-bash without shelling out + const env: NodeJS.ProcessEnv = { ...baseEnv } + if (IS_WINDOWS) { + if ( + process.env.CLAUDE_CODE_GIT_BASH_PATH && + !env.CLAUDE_CODE_GIT_BASH_PATH + ) { + env.CLAUDE_CODE_GIT_BASH_PATH = process.env.CLAUDE_CODE_GIT_BASH_PATH + } + if (process.env.SHELL && !env.SHELL) { + env.SHELL = process.env.SHELL + } + } + + return { + execPath: EXEC_PATH, + args, + env, + windowsHide: IS_WINDOWS, + } +} + +/** + * Spawn a child CLI process from a launch spec. + * + * Callers provide transport-level options (stdio, detached, cwd) while the + * spec handles bootstrap concerns (execPath, args, env, windowsHide). + * + * Windows note: `detached: true` on Windows creates a new console window + * (unlike Unix where it only creates a new process group). Node.js uses + * `windowsHide` to pass CREATE_NO_WINDOW, but Bun may not implement it. + * As a fallback, we always set both `windowsHide: true` and keep + * `detached` as-is — the child needs `detached` to outlive the parent. + */ +export function spawnCli( + spec: CliLaunchSpec, + spawnOpts: Omit, +): ChildProcess { + return spawn(spec.execPath, spec.args, { + ...spawnOpts, + env: { ...spec.env, ...(spawnOpts.env as NodeJS.ProcessEnv) }, + windowsHide: spec.windowsHide, + }) +} + +/** + * Quote a launch spec into a single shell command string (for tmux). + */ +export function quoteCliLaunch(spec: CliLaunchSpec): string { + return quote([spec.execPath, ...spec.args]) +} + +/** + * Get the frozen bootstrap args snapshot. + * Useful for call sites that need the raw args (e.g. bridgeMain deps). + */ +export function getBootstrapArgs(): readonly string[] { + return BOOTSTRAP_ARGS +} + +/** + * Get the script path (process.argv[1] at startup). + * Returns undefined in bundled mode. + */ +export function getScriptPath(): string | undefined { + return SCRIPT_PATH +} diff --git a/src/utils/windowsPaths.ts b/src/utils/windowsPaths.ts index 00e628beb..d610f69c5 100644 --- a/src/utils/windowsPaths.ts +++ b/src/utils/windowsPaths.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs' import memoize from 'lodash-es/memoize.js' import * as path from 'path' import * as pathWin32 from 'path/win32' @@ -8,17 +9,13 @@ import { memoizeWithLRU } from './memoize.js' import { getPlatform } from './platform.js' /** - * Check if a file or directory exists on Windows using the dir command - * @param path - The path to check - * @returns true if the path exists, false otherwise + * Check if a file or directory exists on Windows. + * Uses fs.existsSync instead of `dir` shell command to avoid spawning + * cmd.exe — which can cause brief console window flashes in detached + * or windowsHide child processes. */ -function checkPathExists(path: string): boolean { - try { - execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' }) - return true - } catch { - return false - } +function checkPathExists(filePath: string): boolean { + return existsSync(filePath) } /** @@ -88,6 +85,8 @@ export function setShellIfWindows(): void { if (getPlatform() === 'windows') { const gitBashPath = findGitBashPath() process.env.SHELL = gitBashPath + // Propagate to child processes so they skip filesystem probing + process.env.CLAUDE_CODE_GIT_BASH_PATH = gitBashPath logForDebugging(`Using bash path: "${gitBashPath}"`) } }