import { spawn, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' import { delimiter, join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { terminateRelaySubprocessTree } from './subprocess-tree-termination' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 const MAX_OUTPUT_BYTES = 4 * 1024 * 1024 const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' function getCmdExePath(): string { return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe` } function isWindowsBatchScript(commandPath: string): boolean { return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) } function hasUnsafeWindowsBatchSyntax(value: string): boolean { return /[&|<>^"%!\r\n]/.test(value) } function quoteWindowsBatchToken(value: string): string { if (hasUnsafeWindowsBatchSyntax(value)) { throw new Error(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) } return `"${value}"` } function resolveWindowsCommand(binary: string, env: NodeJS.ProcessEnv): string { if (process.platform !== 'win32') { return binary } if (/[\\/]/.test(binary) || /\.[a-z0-9]+$/i.test(binary)) { return binary } const pathEnv = env.PATH ?? env.Path if (!pathEnv) { return binary } const names = [`${binary}.cmd`, `${binary}.exe`, `${binary}.bat`, binary] for (const directory of pathEnv.split(delimiter).filter(Boolean)) { for (const name of names) { const candidate = join(directory, name) if (existsSync(candidate)) { return candidate } } } return binary } function getWindowsSafeSpawn( binary: string, args: string[], env: NodeJS.ProcessEnv ): { spawnCmd: string; spawnArgs: string[] } { const resolvedBinary = resolveWindowsCommand(binary, env) if (!isWindowsBatchScript(resolvedBinary)) { return { spawnCmd: resolvedBinary, spawnArgs: args } } const commandLine = [resolvedBinary, ...args].map(quoteWindowsBatchToken).join(' ') return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] } } type ExecParams = { binary: unknown args: unknown cwd: unknown stdin: unknown timeoutMs: unknown env: unknown operation: unknown } type CancelParams = { cwd: unknown operation: unknown } function laneKeyFor(cwd: string, operation: unknown): string { const op = typeof operation === 'string' && operation ? operation : 'default' return JSON.stringify([op, cwd]) } type InFlightExec = { child: ChildProcess; cancel: () => void } type ExecResult = { stdout: string stderr: string exitCode: number | null timedOut: boolean /** Set when the user canceled the exec via `agent.cancelExec`. */ canceled?: boolean /** Set when the binary could not be spawned (e.g. ENOENT). */ spawnError?: string } /** * Non-interactive subprocess exec on the remote host. Used by the AI commit * message generator to spawn agent CLIs (claude, codex, …) with the staged * diff piped via stdin and the output captured to stdout. Distinct from * `pty.spawn` because we want no terminal allocation, no escape sequences, * and a clean exit code instead of an interactive session. */ export class AgentExecHandler { // Why: commit-message and PR-field generation can run together for one cwd; // operation lanes let cancel target only the user-visible job that stopped. private inFlightByLane = new Map() private laneKey(cwd: string, operation: unknown): string { return laneKeyFor(cwd, operation) } constructor(dispatcher: RelayDispatcher) { dispatcher.onRequest('agent.execNonInteractive', (p, context) => this.exec(p as ExecParams, context) ) dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams)) } private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { const cwd = typeof params.cwd === 'string' ? params.cwd : '' const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation)) if (!entry) { return { canceled: false } } entry.cancel() return { canceled: true } } private async exec(params: ExecParams, context?: RequestContext): Promise { const binary = typeof params.binary === 'string' ? params.binary : '' if (!binary) { throw new Error('agent.execNonInteractive: binary is required') } const args = Array.isArray(params.args) ? params.args.map((a) => String(a)) : [] const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : undefined const stdinPayload = typeof params.stdin === 'string' ? params.stdin : null const requestedTimeout = typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_TIMEOUT_MS const timeoutMs = Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout)) const extraEnv = params.env && typeof params.env === 'object' && !Array.isArray(params.env) ? (params.env as Record) : null const spawnEnv = mergeGitConfigEnvProtocol(process.env, extraEnv ?? undefined) as Record< string, string > // Why: this RPC has no interactive terminal, regardless of which wrapper // launches the agent or hook command. applyTerminalGitCredentialPromptGuard(spawnEnv, { isUnattended: true, platform: process.platform }) return new Promise((resolve) => { let child try { const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv) child = spawn(spawnCmd, spawnArgs, { cwd, env: spawnEnv, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) } catch (error) { resolve({ stdout: '', stderr: '', exitCode: null, timedOut: false, spawnError: error instanceof Error ? error.message : String(error) }) return } let stdout = '' let stderr = '' let stdoutBytes = 0 let stderrBytes = 0 let timedOut = false let canceled = false let settled = false const laneKey = typeof cwd === 'string' ? this.laneKey(cwd, params.operation) : '' let entry: InFlightExec | null = null let timer: ReturnType | null = null let detachChildListeners = (): void => {} let detachRequestAbortListener = (): void => {} const finish = (result: ExecResult): void => { if (settled) { return } settled = true if (timer) { clearTimeout(timer) timer = null } detachRequestAbortListener() detachChildListeners() if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) { this.inFlightByLane.delete(laneKey) } resolve(result) } const cancelCurrent = (): void => { canceled = true terminateRelaySubprocessTree(child) } if (laneKey) { // Why: the relay owns one visible non-interactive job per cwd+operation. // Replacing the lane without canceling the prior child would orphan // that process until timeout because future cancelExec calls reach only // the newest map entry. this.inFlightByLane.get(laneKey)?.cancel() entry = { child, cancel: cancelCurrent } this.inFlightByLane.set(laneKey, entry) } timer = setTimeout(() => { timedOut = true // Why: tree-kill because some CLIs trap SIGTERM and continue streaming; // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child // is not the real node.exe process. terminateRelaySubprocessTree(child) finish({ stdout, stderr, exitCode: null, timedOut, canceled }) }, timeoutMs) const onStdoutData = (chunk: Buffer): void => { stdoutBytes += chunk.byteLength if (stdoutBytes > MAX_OUTPUT_BYTES) { terminateRelaySubprocessTree(child) return } stdout += chunk.toString('utf-8') } const onStderrData = (chunk: Buffer): void => { stderrBytes += chunk.byteLength if (stderrBytes > MAX_OUTPUT_BYTES) { terminateRelaySubprocessTree(child) return } stderr += chunk.toString('utf-8') } const onError = (error: Error): void => { finish({ stdout, stderr, exitCode: null, timedOut, spawnError: error.message }) } const onClose = (code: number | null): void => { finish({ stdout, stderr, exitCode: code, timedOut, canceled }) } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) child.on('error', onError) child.on('close', onClose) detachChildListeners = () => { child.stdout?.off('data', onStdoutData) child.stderr?.off('data', onStderrData) child.off('error', onError) child.off('close', onClose) } if (context?.signal) { if (context.signal.aborted) { cancelCurrent() } else { context.signal.addEventListener('abort', cancelCurrent, { once: true }) detachRequestAbortListener = () => { context.signal?.removeEventListener('abort', cancelCurrent) } } } if (stdinPayload !== null) { child.stdin?.end(stdinPayload) } else { child.stdin?.end() } }) } }