diff --git a/bin/gstack-claude-code b/bin/gstack-claude-code new file mode 100755 index 000000000..ac206b69d --- /dev/null +++ b/bin/gstack-claude-code @@ -0,0 +1,5 @@ +#!/usr/bin/env bun +// Claude-only outside-review runner. Prompts arrive on stdin, never argv. +import { claudeCodeMain } from '../lib/claude-code'; + +process.exit(await claudeCodeMain(process.argv.slice(2))); diff --git a/lib/claude-code-windows-job.ts b/lib/claude-code-windows-job.ts new file mode 100644 index 000000000..9c3907f2f --- /dev/null +++ b/lib/claude-code-windows-job.ts @@ -0,0 +1,79 @@ +/** Windows lifetime containment for the dedicated gstack-claude-code process. */ + +export class WindowsReviewSupervisionError extends Error { + constructor(message: string) { + super(`Claude Code Windows process supervision could not initialize: ${message}. No reviewer was started. Update Bun or check the host's process policy, then retry.`); + this.name = 'WindowsReviewSupervisionError'; + } +} + +// This handle is intentionally never closed in JavaScript: closing it would +// terminate this runner too. The OS closes it when the dedicated CLI exits, +// after its JSON has flushed, and kills every remaining descendant with it. +// Keep the native library alive for the same lifetime. +let lifetime: { handle: number | bigint; library: unknown } | undefined; + +/** + * Join an unnamed, non-inheritable job BEFORE spawning the provider. Children + * inherit membership, not the handle. Unlike taskkill /T, job membership still + * contains a descendant after its immediate parent has exited. + * + * Call only from claudeCodeMain in its dedicated CLI process, never from the + * reusable runClaudeCode API or a host/test process that owns other work. + * https://learn.microsoft.com/windows/win32/procthread/job-objects + */ +export async function initializeWindowsReviewJob(): Promise { + if (process.platform !== 'win32' || lifetime) return; + let library: Awaited> | undefined; + let job: number | bigint = 0n; + let currentProcess: number | bigint = 0n; + try { + library = await openKernel(); + const api = library.symbols; + // NULL security attributes make this handle non-inheritable; NULL name + // makes the job private to this invocation. + job = api.CreateJobObjectW(null, null); + if (!job) throw new Error(`CreateJobObjectW failed (${api.GetLastError()})`); + + // JOBOBJECT_EXTENDED_LIMIT_INFORMATION on Windows' 64-bit ABI: + // basic limits (64), IO_COUNTERS (48), then four SIZE_T fields (32). + // LimitFlags is the DWORD at byte 16 in the basic-limit structure. + const limits = Buffer.alloc(144); + limits.writeUInt32LE(0x00002000, 16); // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + const { ptr } = await import('bun:ffi'); + if (!api.SetInformationJobObject(job, 9, ptr(limits), limits.byteLength)) { + throw new Error(`SetInformationJobObject failed (${api.GetLastError()})`); + } + // AssignProcessToJobObject requires PROCESS_SET_QUOTA | PROCESS_TERMINATE. + currentProcess = api.OpenProcess(0x0101, 0, process.pid); + if (!currentProcess) throw new Error(`OpenProcess failed (${api.GetLastError()})`); + if (!api.AssignProcessToJobObject(job, currentProcess)) { + throw new Error(`AssignProcessToJobObject failed (${api.GetLastError()})`); + } + lifetime = { handle: job, library }; + api.CloseHandle(currentProcess); + } catch (error) { + // These failures occur before assignment. Never close a job that already + // contains this process: preserve its handle until the CLI reports failure. + if (library && !lifetime) { + if (currentProcess) library.symbols.CloseHandle(currentProcess); + if (job) library.symbols.CloseHandle(job); + library.close(); + } + throw new WindowsReviewSupervisionError(error instanceof Error ? error.message : String(error)); + } +} + +async function openKernel() { + const { dlopen, FFIType } = await import('bun:ffi'); + // HANDLE is an integer token, not an address. Bun explicitly requires an + // integer FFI type here; ptr is reserved for the actual buffer parameters. + return dlopen('kernel32.dll', { + CreateJobObjectW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.u64 }, + SetInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 }, + OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 }, + AssignProcessToJobObject: { args: [FFIType.u64, FFIType.u64], returns: FFIType.i32 }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, + GetLastError: { args: [], returns: FFIType.u32 }, + }); +} diff --git a/lib/claude-code.ts b/lib/claude-code.ts new file mode 100644 index 000000000..a5ae3c04c --- /dev/null +++ b/lib/claude-code.ts @@ -0,0 +1,247 @@ +/** Restricted, supervised Claude Code invocation for outside reviews. */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { resolveClaudeCommand, type ClaudeCommand } from './claude-bin'; +import { initializeWindowsReviewJob, WindowsReviewSupervisionError } from './claude-code-windows-job'; + +export const CLAUDE_CODE_OUTPUT_LIMIT = 32 * 1024 * 1024; +const DRAIN_TIMEOUT_MS = 500; + +export interface ClaudeCodeOptions { + cwd: string; + access: 'none' | 'read-only'; + timeoutMs: number; + prompt: string; + resume?: string; + env?: NodeJS.ProcessEnv; +} + +export interface ClaudeCodeResult { + status: 'completed' | 'unavailable' | 'error'; + provider: 'claude-code'; + result: string; + error?: { code: string; message: string }; + session_id?: string; + usage?: Record; + modelUsage?: Record; + model?: string; + exit_code?: number | null; + stderr?: string; +} + +function failure(code: string, message: string, unavailable = false): ClaudeCodeResult { + return { status: unavailable ? 'unavailable' : 'error', provider: 'claude-code', result: '', error: { code, message } }; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Keep argument construction separate so wrappers never interpolate a prompt. */ +export function claudeCodeArgs(options: Pick, command: ClaudeCommand): string[] { + const args = [ + ...command.argsPrefix, '-p', '--output-format', 'json', + '--disable-slash-commands', + '--tools', options.access === 'none' ? '' : 'Read,Grep,Glob', + '--disallowedTools', 'mcp__*', + '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', + '--settings', '{"disableAllHooks":true}', + '--permission-mode', 'default', + ]; + if (options.access === 'read-only') args.push('--allowedTools', 'Read,Grep,Glob'); + if (options.resume) args.push('--resume', options.resume); + return args; +} + +/** Kill only the process tree owned by this invocation, never other sessions. */ +function killTree(child: ChildProcess): void { + if (typeof child.pid !== 'number') return; + if (process.platform === 'win32') { + const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); + const timer = setTimeout(() => { killer.kill(); child.kill(); }, DRAIN_TIMEOUT_MS); + timer.unref(); + killer.once('error', () => { clearTimeout(timer); child.kill(); }); + killer.once('close', () => { clearTimeout(timer); child.kill(); }); + killer.unref(); + return; + } + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + try { child.kill('SIGKILL'); } catch { /* Already reaped. */ } + } + } +} + +/** Parse only a completed invocation; a valid JSON error must never pass a gate. */ +export function parseClaudeCodeResult(stdout: string, stderr: string, exitCode: number | null): ClaudeCodeResult { + const diagnostic = stderr.trim().slice(0, 16 * 1024); + let raw: unknown; + try { raw = JSON.parse(stdout); } catch { /* Classified below, after CLI failure. */ } + const obj = isObject(raw) ? raw : undefined; + const response = typeof obj?.result === 'string' ? obj.result : typeof obj?.response === 'string' ? obj.response : ''; + const actualFailure = exitCode !== 0 || !obj || Boolean(obj.is_error) || !response.trim(); + let result: ClaudeCodeResult; + if (actualFailure && /\b(?:authentication(?:[_ ](?:failed|error))?|unauthorized|not authenticated|invalid (?:x-)?api[- ]key|login required|please (?:run .*login|log in)|not logged in)\b/i.test(`${diagnostic}\n${response}\n${stdout.slice(0, 4096)}`)) { + result = failure('authentication', 'Claude Code authentication failed. Run claude interactively in this execution context to authenticate.', true); + } else if (exitCode !== 0) { + result = failure('exit', `Claude Code exited with ${exitCode === null ? 'a signal' : `code ${exitCode}`}.${response ? ` ${response.slice(0, 4096)}` : ''}`); + } else if (raw === undefined) { + result = failure('invalid-json', 'Claude Code returned invalid JSON. Check the CLI installation and diagnostic output.'); + } else if (!obj) { + result = failure('invalid-response', 'Claude Code returned JSON that is not an object.'); + } else if (obj.is_error || (typeof obj.subtype === 'string' && obj.subtype.startsWith('error'))) { + result = failure('provider-error', `Claude Code reported an error.${response ? ` ${response.slice(0, 4096)}` : ''}`); + } else if (!response.trim()) { + result = failure('empty-response', 'Claude Code returned no response text.'); + } else { + result = { status: 'completed', provider: 'claude-code', result: response }; + } + if (obj) { + if (typeof obj.session_id === 'string' && obj.session_id) result.session_id = obj.session_id; + if (isObject(obj.usage)) result.usage = obj.usage; + if (isObject(obj.modelUsage)) result.modelUsage = obj.modelUsage; + // Do not choose one model from modelUsage: fallback/multi-model sessions + // must retain all their attribution, and absent identity stays unknown. + if (typeof obj.model === 'string' && obj.model) result.model = obj.model; + } + result.exit_code = exitCode; + if (diagnostic) result.stderr = diagnostic; + return result; +} + +/** Prompt stdin, direct argv, bounded output and process-group supervision. */ +export async function runClaudeCode(options: ClaudeCodeOptions): Promise { + if (!options.cwd || !['none', 'read-only'].includes(options.access) || + !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2_147_483_647 || + !options.prompt.trim() || (options.resume !== undefined && !options.resume.trim())) { + return failure('arguments', 'Provide --cwd, --access none|read-only, a positive --timeout-ms, and a nonempty prompt on stdin.'); + } + const env = options.env ?? process.env; + const command = resolveClaudeCommand(env); + if (!command) return failure('not-found', 'Claude Code CLI not found. Install Claude Code or set GSTACK_CLAUDE_BIN, then retry.', true); + + let child: ChildProcess; + try { + child = spawn(command.command, claudeCodeArgs(options, command), { + cwd: options.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32', windowsHide: true, + }); + } catch (error) { + return failure('spawn', `Claude Code could not start: ${(error as Error).message}`, true); + } + + return await new Promise((resolve) => { + let settled = false; + let stopped: ClaudeCodeResult | undefined; + let exitCode: number | null = null; + let bytes = 0; + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let drainTimer: ReturnType | undefined; + + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeoutTimer); + clearTimeout(drainTimer); + process.off('SIGINT', onInterrupt); + process.off('SIGTERM', onTerminate); + process.off('exit', onParentExit); + killTree(child); + child.stdin?.destroy(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + const err = Buffer.concat(stderr).toString('utf8'); + if (stopped) { + stopped.exit_code = exitCode; + if (err.trim()) stopped.stderr = err.trim().slice(0, 16 * 1024); + resolve(stopped); + } else { + resolve(parseClaudeCodeResult(Buffer.concat(stdout).toString('utf8'), err, exitCode)); + } + }; + const boundDrain = () => { + drainTimer ??= setTimeout(() => { + stopped ??= failure('output-drain', 'Claude Code output pipes did not close after execution. Outside coverage is unavailable.', true); + finish(); + }, DRAIN_TIMEOUT_MS); + }; + const stop = (result: ClaudeCodeResult) => { + stopped ??= result; + killTree(child); + boundDrain(); + }; + const collect = (chunks: Buffer[], chunk: Buffer | string) => { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = CLAUDE_CODE_OUTPUT_LIMIT - bytes; + bytes += data.byteLength; + if (remaining > 0) chunks.push(data.subarray(0, remaining)); + if (bytes > CLAUDE_CODE_OUTPUT_LIMIT) stop(failure('output-limit', 'Claude Code output exceeded the 32 MiB limit.')); + }; + const onInterrupt = () => stop(failure('interrupted', 'Claude Code outside review was interrupted (SIGINT).', true)); + const onTerminate = () => stop(failure('interrupted', 'Claude Code outside review was interrupted (SIGTERM).', true)); + const onParentExit = () => killTree(child); + const timeoutTimer = setTimeout(() => stop(failure('timeout', `Claude Code timed out after ${options.timeoutMs}ms.`, true)), options.timeoutMs); + process.on('SIGINT', onInterrupt); + process.on('SIGTERM', onTerminate); + process.on('exit', onParentExit); + child.stdout?.on('data', (chunk) => collect(stdout, chunk)); + child.stderr?.on('data', (chunk) => collect(stderr, chunk)); + child.once('error', (error) => { + stopped = failure('spawn', `Claude Code could not start: ${error.message}`, true); + finish(); + }); + child.once('exit', (code) => { + exitCode = code; + // Allow already-written output to drain naturally. A descendant holding + // the pipes past this bound is unavailable coverage, even if killing it + // would make an otherwise valid response look like a clean completion. + boundDrain(); + }); + child.once('close', (code) => { exitCode = code; finish(); }); + child.stdin?.on('error', (error: NodeJS.ErrnoException) => { + // A rejected/auth-failed CLI may exit before reading its whole prompt; + // retain that actual provider error instead of replacing it with EPIPE. + if (error.code !== 'EPIPE' && error.code !== 'ERR_STREAM_DESTROYED') { + stop(failure('stdin', `Claude Code could not read its prompt: ${error.message}`, true)); + } + }); + child.stdin?.end(options.prompt); + }); +} + +export async function claudeCodeMain(argv: string[]): Promise { + let result: ClaudeCodeResult; + try { + const values = new Map(); + for (let i = 0; i < argv.length; i += 2) { + if (!['--cwd', '--access', '--timeout-ms', '--resume'].includes(argv[i]) || values.has(argv[i]) || argv[i + 1] === undefined) { + throw new Error('Usage: gstack-claude-code --cwd --access none|read-only --timeout-ms [--resume ] (prompt on stdin)'); + } + values.set(argv[i], argv[i + 1]); + } + const cwd = values.get('--cwd') ?? ''; + const access = values.get('--access') as ClaudeCodeOptions['access']; + const timeoutMs = Number(values.get('--timeout-ms')); + if (!cwd || !['none', 'read-only'].includes(access) || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new Error('Provide --cwd, --access none|read-only, and a positive --timeout-ms.'); + } + // This entry runs in a dedicated process. Its Windows job owns the CLI and + // descendants even after the immediate provider process exits; the final + // process.exit happens only after the result below has flushed to stdout. + await initializeWindowsReviewJob(); + result = await runClaudeCode({ cwd, access, timeoutMs, resume: values.get('--resume'), prompt: await Bun.stdin.text() }); + } catch (error) { + result = error instanceof WindowsReviewSupervisionError + ? failure('supervision', error.message, true) + : failure('arguments', (error as Error).message); + } + // The CLI shim exits immediately after this promise. Wait for backpressure: + // otherwise a valid multi-megabyte review is truncated in a pipe at exit. + await new Promise((resolve, reject) => { + process.stdout.write(`${JSON.stringify(result)}\n`, (error) => error ? reject(error) : resolve()); + }); + return result.status === 'completed' ? 0 : 1; +} diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 9f5daeb0f..0dfb18397 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -259,6 +259,16 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> // pattern hit is a false positive — the point of these files is Windows // coverage, so auto-excluding them defeats the regression tests they carry. const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ + { + file: 'test/claude-code-windows-job.test.ts', + reason: 'invokes Bun directly; verifies Windows job containment at the standalone CLI boundary', + }, + { + file: 'test/claude-code-runner.test.ts', + // The bin/ path is launched through process.execPath (Bun), never as a + // shebang executable. Keep taskkill tree supervision in the Windows lane. + reason: 'invokes the runner via Bun argv; fake CLI and timeout descendant assertions cover native Windows taskkill', + }, { file: 'test/setup-windows-rerun-refresh.test.ts', // Trips the "spawns bin/ shebang script" pattern via path.join(..., 'bin', diff --git a/test/claude-code-runner.test.ts b/test/claude-code-runner.test.ts new file mode 100644 index 000000000..22ee367e9 --- /dev/null +++ b/test/claude-code-runner.test.ts @@ -0,0 +1,222 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { runClaudeCode } from '../lib/claude-code'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const DIR = mkdtempSync(path.join(tmpdir(), 'claude-code-runner-')); +const FAKE = path.join(DIR, 'fake claude.ts'); +const CAPTURE = path.join(DIR, 'capture.json'); +const PID = path.join(DIR, 'descendant.pid'); + +writeFileSync(FAKE, ` +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +const prompt = await Bun.stdin.text(); +writeFileSync(process.env.CAPTURE!, JSON.stringify({args:process.argv.slice(2),prompt,cwd:process.cwd(),model:process.env.ANTHROPIC_MODEL,auth:process.env.ANTHROPIC_API_KEY})); +const mode = process.env.FAKE_MODE; +if (mode === 'timeout' || mode === 'descendant' || mode === 'escaped') { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:['ignore','inherit','inherit'], detached:mode === 'escaped'}); + writeFileSync(process.env.PID_FILE!, String(child.pid)); + if (mode === 'timeout') await new Promise(() => {}); +} +if (mode === 'auth') { process.stderr.write('Not logged in. Please run claude /login.'); process.exit(1); } +if (mode === 'overflow') { + const block = Buffer.alloc(1024 * 1024, 120); + for(let i=0;i<18;i++) { process.stdout.write(block); process.stderr.write(block); } + await new Promise(() => {}); +} +if (mode === 'malformed') { process.stdout.write('broken JSON'); process.exit(0); } +if (mode === 'array') { process.stdout.write('[]'); process.exit(0); } +if (mode === 'null') { process.stdout.write('null'); process.exit(0); } +const result = { + result: mode === 'empty' ? ' ' : mode === 'large-success' ? 'x'.repeat(2 * 1024 * 1024) : '[P1] Seeded defect in changed.ts', + is_error:mode === 'error', + session_id:'session-123', + usage:{input_tokens:12,output_tokens:34,cache_read_input_tokens:5}, + modelUsage:{'claude-first':{inputTokens:4},'claude-fallback':{inputTokens:8}}, +}; +await new Promise(resolve => process.stdout.write(JSON.stringify(result),resolve)); +process.exit(mode === 'nonzero' ? 7 : 0); +`); + +afterAll(() => rmSync(DIR, { recursive: true, force: true })); + +function env(mode = 'success'): NodeJS.ProcessEnv { + return { ...process.env, GSTACK_CLAUDE_BIN: process.execPath, GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([FAKE]), + FAKE_MODE: mode, CAPTURE, PID_FILE: PID, ANTHROPIC_MODEL: 'configured-model', ANTHROPIC_API_KEY: 'fake-test-credential' }; +} + +function run(mode = 'success', extra: Partial[0]> = {}) { + return runClaudeCode({cwd:DIR, access:'none', timeoutMs:5000, prompt:'review this', env:env(mode), ...extra}); +} + +function capture() { return JSON.parse(readFileSync(CAPTURE, 'utf8')); } + +function running(pid: number): boolean { + try { + process.kill(pid, 0); + // Linux can leave a killed orphan briefly as a zombie before init reaps it. + if (process.platform === 'linux') return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] !== 'Z'; + return true; + } catch { return false; } +} + +async function expectDescendantDead() { + const pid = Number(readFileSync(PID, 'utf8')); + try { + for (let i = 0; i < 20 && running(pid); i++) await Bun.sleep(25); + expect(running(pid)).toBe(false); + } finally { + if (running(pid)) process.kill(pid, 'SIGKILL'); + } +} + +describe('Claude Code restricted execution', () => { + test('stdin stays literal; explicit tools, MCP and hook restrictions preserve configured auth/model', async () => { + const prompt = 'quotes "\' `touch /never` $(touch /never)\nEOF\n--dangerously-skip-permissions'; + const result = await run('success', {prompt}); + expect(result.status).toBe('completed'); + expect(capture().prompt).toBe(prompt); + expect(capture().cwd).toBe(DIR); + expect(capture().model).toBe('configured-model'); + expect(capture().auth).toBe('fake-test-credential'); + const args = capture().args; + expect(args[args.indexOf('--tools') + 1]).toBe(''); + expect(args).toContain('--disable-slash-commands'); + expect(args).toContain('--strict-mcp-config'); + expect(JSON.parse(args[args.indexOf('--mcp-config') + 1])).toEqual({mcpServers:{}}); + expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual({disableAllHooks:true}); + expect(args[args.indexOf('--disallowedTools') + 1]).toBe('mcp__*'); + expect(args).not.toContain('--model'); + expect(args).not.toContain('--resume'); + expect(args).not.toContain('--dangerously-skip-permissions'); + expect(args).not.toContain(prompt); + expect(result.modelUsage).toEqual({'claude-first':{inputTokens:4},'claude-fallback':{inputTokens:8}}); + expect(result.usage).toEqual({input_tokens:12,output_tokens:34,cache_read_input_tokens:5}); + expect(result.model).toBeUndefined(); + expect(result.session_id).toBe('session-123'); + }); + + test('consult exposes only file reading tools and preserves a literal resume argument', async () => { + const resume = 'session " with spaces $(touch /never)'; + expect((await run('success', {access:'read-only', resume})).status).toBe('completed'); + const args = capture().args; + expect(args[args.indexOf('--tools') + 1]).toBe('Read,Grep,Glob'); + expect(args[args.indexOf('--allowedTools') + 1]).toBe('Read,Grep,Glob'); + expect(args[args.indexOf('--resume') + 1]).toBe(resume); + expect(args).not.toContain('--no-session-persistence'); + }); + + test('PATH command overrides and argument prefixes resolve in the invocation environment', async () => { + const cliEnv = env(); + cliEnv.GSTACK_CLAUDE_BIN = path.basename(process.execPath); + cliEnv.PATH = `${path.dirname(process.execPath)}${path.delimiter}${cliEnv.PATH}`; + expect((await run('success', {env:cliEnv})).status).toBe('completed'); + expect(capture().args[0]).toBe('-p'); + }); + + test('WSL-style launcher prefixes remain ordered literal argv entries', async () => { + const cliEnv = env(); + cliEnv.GSTACK_CLAUDE_BIN_ARGS = JSON.stringify([FAKE,'claude','--configured-option','a value with spaces']); + expect((await run('success',{env:cliEnv})).status).toBe('completed'); + expect(capture().args.slice(0,4)).toEqual(['claude','--configured-option','a value with spaces','-p']); + }); + + test('missing CLI and broken absolute overrides have distinct actionable failures', async () => { + const missing = await run('success', {env:{PATH:DIR}}); + expect(missing.status).toBe('unavailable'); + expect(missing.error?.code).toBe('not-found'); + expect(missing.error?.message).toContain('GSTACK_CLAUDE_BIN'); + const broken = await run('success', {env:{GSTACK_CLAUDE_BIN:path.join(DIR,'missing-cli')}}); + expect(broken.status).toBe('unavailable'); + expect(broken.error?.code).toBe('spawn'); + }); + + for (const [mode, code] of [ + ['auth','authentication'], ['nonzero','exit'], ['error','provider-error'], + ['malformed','invalid-json'], ['array','invalid-response'], ['null','invalid-response'], ['empty','empty-response'], + ]) { + test(`${mode} cannot become a successful review`, async () => { + const result = await run(mode); + expect(result.status).not.toBe('completed'); + expect(result.result).toBe(''); + expect(result.error?.code).toBe(code); + if (mode === 'auth') expect(result.error?.message).toContain('interactively'); + }); + } + + test('the combined stdout/stderr limit terminates a noisy CLI', async () => { + const result = await run('overflow'); + expect(result.status).toBe('error'); + expect(result.error?.code).toBe('output-limit'); + expect(result.stderr!.length).toBeLessThanOrEqual(16 * 1024); + }); + + test('timeout kills its descendants and clears process signal listeners', async () => { + const before = ['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name)); + const start = Date.now(); + const result = await run('timeout', {timeoutMs:500}); + expect(result.status).toBe('unavailable'); + expect(result.error?.code).toBe('timeout'); + expect(Date.now() - start).toBeLessThan(2000); + expect(['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name))).toEqual(before); + await expectDescendantDead(); + }); + + test('a child exiting with inherited pipes is unavailable within the drain deadline', async () => { + const start = Date.now(); + const result = await run('descendant'); + expect(result.status).toBe('unavailable'); + expect(result.error?.code).toBe('output-drain'); + expect(Date.now() - start).toBeLessThan(2000); + if (process.platform !== 'win32') { + await expectDescendantDead(); + } else { + // taskkill /T cannot discover an orphan after its parent has exited. + // Bound-return/unavailable behavior remains covered here; the timeout + // case above verifies descendant termination while the parent is alive. + const pid = Number(readFileSync(PID, 'utf8')); + if (running(pid)) process.kill(pid, 'SIGKILL'); + } + }); + + test.skipIf(process.platform === 'win32')('an escaped pipe holder cannot wedge draining or count as coverage', async () => { + const start = Date.now(); + try { + const result = await run('escaped'); + expect(result.status).toBe('unavailable'); + expect(result.error?.code).toBe('output-drain'); + expect(Date.now() - start).toBeLessThan(2000); + } finally { + const pid = Number(readFileSync(PID, 'utf8')); + if (running(pid)) process.kill(pid, 'SIGKILL'); + } + }); + + test('CLI exits nonzero for malformed responses and argument failures', () => { + const cli = path.join(ROOT, 'bin/gstack-claude-code'); + for (const mode of ['success','malformed','error','auth']) { + const result = spawnSync(process.execPath, [cli,'--cwd',DIR,'--access','none','--timeout-ms','5000'], { + env:env(mode), input:'a prompt', encoding:'utf8', timeout:10000, + }); + expect(result.status).toBe(mode === 'success' ? 0 : 1); + expect(JSON.parse(result.stdout).status === 'completed').toBe(mode === 'success'); + } + const invalid = spawnSync(process.execPath, [cli,'--access','write'], {encoding:'utf8', timeout:5000}); + expect(invalid.status).toBe(1); + expect(JSON.parse(invalid.stdout).error.code).toBe('arguments'); + }); + + test('CLI flushes a large successful JSON response before exiting', () => { + const result = spawnSync(process.execPath, [path.join(ROOT,'bin/gstack-claude-code'),'--cwd',DIR,'--access','none','--timeout-ms','5000'], { + env:env('large-success'), input:'prompt', encoding:'utf8', timeout:10000, maxBuffer:8 * 1024 * 1024, + }); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.status).toBe('completed'); + expect(parsed.result.length).toBe(2 * 1024 * 1024); + }); +}); diff --git a/test/claude-code-windows-job.test.ts b/test/claude-code-windows-job.test.ts new file mode 100644 index 000000000..455cbd3c7 --- /dev/null +++ b/test/claude-code-windows-job.test.ts @@ -0,0 +1,123 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const DIR = mkdtempSync(path.join(tmpdir(), 'claude-windows-job-')); +const FAKE = path.join(DIR, 'fake claude.ts'); +const PID_FILE = path.join(DIR, 'descendant.pid'); +const CLI = path.join(ROOT, 'bin/gstack-claude-code'); +const FAILED_JOB = path.join(DIR, 'failed job.ts'); + +// Exercise the initialization failure at the actual CLI boundary on every OS, +// without adding a production bypass flag for this safety requirement. +writeFileSync(FAILED_JOB, ` +Object.defineProperty(process, 'platform', { value: 'win32' }); +// OpenProcess(0) is invalid on Windows. Other OSes fail earlier opening the +// Windows DLL; both must fail closed before spawning the configured reviewer. +Object.defineProperty(process, 'pid', { value: 0 }); +`); + +writeFileSync(FAKE, ` +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +await Bun.stdin.text(); +const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'inherit', 'inherit'], +}); +writeFileSync(process.env.PID_FILE!, String(child.pid)); +if (process.env.FAKE_MODE === 'timeout') await new Promise(() => {}); +await new Promise(resolve => process.stdout.write(JSON.stringify({ result: 'NO_FINDINGS' }), resolve)); +process.exit(0); +`); + +afterAll(() => rmSync(DIR, { recursive: true, force: true })); + +function environment(mode: string): NodeJS.ProcessEnv { + return { + ...process.env, + GSTACK_CLAUDE_BIN: process.execPath, + GSTACK_CLAUDE_BIN_ARGS: JSON.stringify([FAKE]), + FAKE_MODE: mode, + PID_FILE, + }; +} + +function alive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +async function expectDead(pid: number) { + for (let i = 0; i < 40 && alive(pid); i++) await Bun.sleep(25); + expect(alive(pid)).toBe(false); +} + +function cleanupDescendant() { + try { + const pid = Number(readFileSync(PID_FILE, 'utf8')); + if (alive(pid)) process.kill(pid, 'SIGKILL'); + } catch { /* No descendant was created. */ } +} + +// These exercise the actual standalone CLI boundary. A job must never be +// attached to the test runner process, which owns unrelated concurrent work. +describe('Windows Claude CLI job containment', () => { + test('job initialization failure stops before reviewer dispatch and emits a named error', () => { + rmSync(PID_FILE, { force: true }); + const result = spawnSync(process.execPath, ['--preload', FAILED_JOB, CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '2000'], { + env: environment('descendant'), input: 'review', encoding: 'utf8', timeout: 10_000, + }); + expect(result.status).toBe(1); + const parsed = JSON.parse(result.stdout); + expect(parsed.provider).toBe('claude-code'); + expect(parsed.status).toBe('unavailable'); + expect(parsed.error.code).toBe('supervision'); + expect(parsed.error.message).toContain('Claude Code Windows process supervision could not initialize'); + expect(() => readFileSync(PID_FILE)).toThrow(); + }); + + for (const [mode, expected] of [['descendant', 'output-drain'], ['timeout', 'timeout']]) { + test.skipIf(process.platform !== 'win32')(`${mode} kills owned descendants and preserves a sibling process`, async () => { + const sibling = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + const started = Date.now(); + try { + const result = spawnSync(process.execPath, [CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '2000'], { + env: environment(mode), input: 'review', encoding: 'utf8', timeout: 10_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + const parsed = JSON.parse(result.stdout); + expect(parsed.status).toBe('unavailable'); + expect(parsed.error.code).toBe(expected); + expect(Date.now() - started).toBeLessThan(6000); + await expectDead(Number(readFileSync(PID_FILE, 'utf8'))); + expect(alive(sibling.pid!)).toBe(true); + } finally { + cleanupDescendant(); + sibling.kill('SIGKILL'); + } + }); + } + + test.skipIf(process.platform !== 'win32')('abrupt runner exit closes the job and reaps its descendants', async () => { + rmSync(PID_FILE, { force: true }); + const runner = spawn(process.execPath, [CLI, '--cwd', DIR, '--access', 'none', '--timeout-ms', '10000'], { + env: environment('timeout'), stdio: ['pipe', 'ignore', 'ignore'], + }); + runner.stdin!.end('review'); + try { + let descendant = 0; + for (let i = 0; i < 200 && !descendant; i++) { + try { descendant = Number(readFileSync(PID_FILE, 'utf8')); } catch { await Bun.sleep(25); } + } + expect(descendant).toBeGreaterThan(0); + runner.kill('SIGKILL'); + await expectDead(descendant); + } finally { + runner.kill('SIGKILL'); + cleanupDescendant(); + } + }); +}); diff --git a/test/test-free-shards.test.ts b/test/test-free-shards.test.ts index c807c9b0e..30c8ee0ac 100644 --- a/test/test-free-shards.test.ts +++ b/test/test-free-shards.test.ts @@ -112,6 +112,10 @@ describe('test-free-shards: Windows curation', () => { const files = collectFreeTestFiles(ROOT); const result = curateWindowsSafe(files, ROOT); expect(result.safe.length + result.excluded.length).toBe(files.length); + // Its bin/ reference is passed to Bun argv, so it must exercise native + // Windows taskkill supervision instead of disappearing behind curation. + expect(result.safe).toContain('test/claude-code-runner.test.ts'); + expect(result.safe).toContain('test/claude-code-windows-job.test.ts'); // Sanity: at least one excluded entry, since we know test/ship-version-sync.test.ts uses /bin/bash expect(result.excluded.length).toBeGreaterThan(0); // Every excluded entry has a non-empty reason