diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 506595933d..1d9c3b276f 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,5 @@ { + "codex-cli-windows-child.test.ts": "codex-integration", "codex-cli-windows-plan.test.ts": "codex-integration", "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", diff --git a/src/codex/windows-cli-child-native.ts b/src/codex/windows-cli-child-native.ts new file mode 100644 index 0000000000..1e53b77a0f --- /dev/null +++ b/src/codex/windows-cli-child-native.ts @@ -0,0 +1,113 @@ +import { dlopen, ptr, read, type Library, type Symbols } from "bun:ffi"; + +// Windows x64 ABI only. No native binary, compiler, worker URL, or shell dependency. +export const WIN64_CHILD_ABI = Object.freeze({ securityAttributes: 24, securityDescriptor: 8, inheritHandle: 16, + startupInfo: 104, startupInfoEx: 112, startupFlags: 60, stdin: 80, stdout: 88, stderr: 96, attributes: 104, + processInformation: 24, process: 0, thread: 8, pid: 16, tid: 20, + overlapped: 32, event: 24, jobAccounting: 48, activeProcesses: 40 }); +const KERNEL = { + GetLastError: { args: [], returns: "u32" }, + GetCurrentProcess: { args: [], returns: "u64" }, + CompareStringOrdinal: { args: ["ptr", "i32", "ptr", "i32", "i32"], returns: "i32" }, + CreateJobObjectW: { args: ["ptr", "ptr"], returns: "u64" }, + AssignProcessToJobObject: { args: ["u64", "u64"], returns: "i32" }, + QueryInformationJobObject: { args: ["u64", "i32", "ptr", "u32", "ptr"], returns: "i32" }, + TerminateJobObject: { args: ["u64", "u32"], returns: "i32" }, + TerminateProcess: { args: ["u64", "u32"], returns: "i32" }, + CreateProcessW: { args: ["ptr", "ptr", "ptr", "ptr", "i32", "u32", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + ResumeThread: { args: ["u64"], returns: "u32" }, + WaitForSingleObject: { args: ["u64", "u32"], returns: "u32" }, + GetExitCodeProcess: { args: ["u64", "ptr"], returns: "i32" }, + GetProcessTimes: { args: ["u64", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + InitializeProcThreadAttributeList: { args: ["ptr", "u32", "u32", "ptr"], returns: "i32" }, + UpdateProcThreadAttribute: { args: ["ptr", "u32", "u64", "ptr", "u64", "ptr", "ptr"], returns: "i32" }, + DeleteProcThreadAttributeList: { args: ["ptr"], returns: "void" }, + CreateNamedPipeW: { args: ["ptr", "u32", "u32", "u32", "u32", "u32", "u32", "ptr"], returns: "u64" }, + ConnectNamedPipe: { args: ["u64", "ptr"], returns: "i32" }, + CreateFileW: { args: ["ptr", "u32", "u32", "ptr", "u32", "u32", "u64"], returns: "u64" }, + CreateEventW: { args: ["ptr", "i32", "i32", "ptr"], returns: "u64" }, + ResetEvent: { args: ["u64"], returns: "i32" }, + ReadFile: { args: ["u64", "ptr", "u32", "ptr", "ptr"], returns: "i32" }, + GetOverlappedResult: { args: ["u64", "ptr", "ptr", "i32"], returns: "i32" }, + CancelIoEx: { args: ["u64", "ptr"], returns: "i32" }, + CloseHandle: { args: ["u64"], returns: "i32" }, + LocalFree: { args: ["ptr"], returns: "ptr" }, +} as const satisfies Symbols; +const ADVAPI = { + OpenProcessToken: { args: ["u64", "u32", "ptr"], returns: "i32" }, + GetTokenInformation: { args: ["u64", "i32", "ptr", "u32", "ptr"], returns: "i32" }, + ConvertSidToStringSidW: { args: ["ptr", "ptr"], returns: "i32" }, + ConvertStringSecurityDescriptorToSecurityDescriptorW: { args: ["ptr", "u32", "ptr", "ptr"], returns: "i32" }, +} as const satisfies Symbols; + +export const wide = (value: string): Buffer => Buffer.from(`${value}\0`, "utf16le"); +export function securityAttributes(inherit: boolean, descriptor = 0n): Buffer { + const bytes = Buffer.alloc(WIN64_CHILD_ABI.securityAttributes); + bytes.writeUInt32LE(bytes.length, 0); bytes.writeBigUInt64LE(descriptor, 8); bytes.writeUInt32LE(+inherit, 16); + return bytes; +} + +/** Owns only handles returned by this invocation, never PID-reopened handles. */ +export class WindowsChildNative { + readonly k: Library["symbols"]; + readonly a: Library["symbols"]; + readonly handles = new Set(); + /** Explicit GC roots for pointers stored inside native attribute lists. */ + readonly borrowedMemory = new Set(); + readonly closeErrors: bigint[] = []; + private readonly kernel: Library; + private readonly advapi: Library; + private closed = false; + constructor() { + if (process.platform !== "win32" || process.arch !== "x64") throw new Error("windows_child_unsupported_platform"); + this.kernel = dlopen("kernel32.dll", KERNEL); + try { this.advapi = dlopen("advapi32.dll", ADVAPI); } + catch (error) { this.kernel.close(); throw error; } + // Own mutable dispatch tables permit per-instance fault injection in native fixtures. + this.k = { ...this.kernel.symbols }; this.a = { ...this.advapi.symbols }; + } + keep(value: number | bigint): bigint { + const handle = BigInt(value); + if (!handle || handle === 0xffffffffffffffffn) throw new Error(`windows_child_handle_${this.k.GetLastError!()}`); + this.handles.add(handle); return handle; + } + close(handle: bigint): void { + if (!this.handles.has(handle)) return; + if (!this.k.CloseHandle!(handle)) { this.closeErrors.push(handle); return; } + this.handles.delete(handle); + } + dispose(): boolean { + if (this.handles.size || this.borrowedMemory.size) return false; + if (!this.closed) { this.closed = true; this.advapi.close(); this.kernel.close(); } + return true; + } + /** Protected DACL grants only the current token user. No default/world pipe ACL. */ + privateDescriptor(): bigint { + const output = Buffer.alloc(8), size = Buffer.alloc(4); + if (!this.a.OpenProcessToken!(this.k.GetCurrentProcess!(), 8, ptr(output))) throw new Error("windows_child_token"); + const token = this.keep(output.readBigUInt64LE()); + let sidText = 0n; + try { + this.a.GetTokenInformation!(token, 1, null, 0, ptr(size)); + const length = size.readUInt32LE(); + if (length < 8 || length > 65536) throw new Error("windows_child_token_size"); + const user = Buffer.alloc(length); + if (!this.a.GetTokenInformation!(token, 1, ptr(user), length, ptr(size))) throw new Error("windows_child_token_user"); + const sid = user.readBigUInt64LE(); + if (!this.a.ConvertSidToStringSidW!(sid, ptr(output))) throw new Error("windows_child_token_sid"); + sidText = output.readBigUInt64LE(); + let sidString = ""; + for (let index = 0; index < 184; index++) { + const code = read.u16(sidText, index * 2); + if (!code) break; + sidString += String.fromCharCode(code); + } + if (!/^S-1-(?:\d+-)+\d+$/.test(sidString)) throw new Error("windows_child_sid_shape"); + const sddl = wide(`D:P(A;;GA;;;${sidString})`); + if (!this.a.ConvertStringSecurityDescriptorToSecurityDescriptorW!(ptr(sddl), 1, ptr(output), null)) { + throw new Error("windows_child_pipe_acl"); + } + return output.readBigUInt64LE(); + } finally { if (sidText) this.k.LocalFree!(sidText); this.close(token); } + } +} diff --git a/src/codex/windows-cli-child-pipe.ts b/src/codex/windows-cli-child-pipe.ts new file mode 100644 index 0000000000..6bcc6069fe --- /dev/null +++ b/src/codex/windows-cli-child-pipe.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import { ptr } from "bun:ffi"; +import { securityAttributes, wide, WIN64_CHILD_ABI, WindowsChildNative } from "./windows-cli-child-native"; + +/** One overlapped operation and one retained buffer per pipe. Never PeekNamedPipe. */ +export class WindowsChildPipe { + readonly overlapped = Buffer.alloc(WIN64_CHILD_ABI.overlapped); + readonly buffer = Buffer.alloc(64 * 1024); + readonly count = Buffer.alloc(4); + handle = 0n; + child = 0n; + event = 0n; + pending = false; + eof = false; + error = false; + private connecting = false; + private disposed = false; + constructor(readonly native: WindowsChildNative) {} + open(): void { + const n = this.native, k = n.k; + const descriptor = n.privateDescriptor(); + try { + const sa = securityAttributes(false, descriptor), childSa = securityAttributes(true, descriptor); + const name = wide(`\\\\.\\pipe\\ocx-child-${randomUUID()}`); + // Inbound, OVERLAPPED, FIRST_PIPE_INSTANCE; byte stream, REJECT_REMOTE_CLIENTS. + this.handle = n.keep(k.CreateNamedPipeW!(ptr(name), 0x40080001, 8, 1, 0, this.buffer.length, 0, ptr(sa))); + this.event = n.keep(k.CreateEventW!(null, 1, 0, null)); + this.overlapped.writeBigUInt64LE(this.event, 24); + // A known local client connects before ConnectNamedPipe: ERROR_PIPE_CONNECTED is success. + this.child = n.keep(k.CreateFileW!(ptr(name), 0x40000000, 0, ptr(childSa), 3, 0, 0n)); + if (!k.ConnectNamedPipe!(this.handle, ptr(this.overlapped))) { + const error = Number(k.GetLastError!()); + if (error === 997) { this.pending = true; this.connecting = true; } + else if (error !== 535) throw new Error(`windows_child_pipe_connect_${error}`); + } + } finally { k.LocalFree!(descriptor); } + } + closeChild(): void { if (this.child) { this.native.close(this.child); this.child = 0n; } } + /** Bounded synchronous work; pending overlapped operations always return immediately. */ + pump(consume: (bytes: Buffer) => void): void { + if (this.disposed) return; + if (this.eof) return; + const k = this.native.k; + for (let i = 0; i < 4 && !this.eof; i++) { + let done: number; + if (this.pending) { + if (!k.GetOverlappedResult!(this.handle, ptr(this.overlapped), ptr(this.count), 0)) { + const error = Number(k.GetLastError!()); + if (error === 996) return; // ERROR_IO_INCOMPLETE: retain buffers and operation. + if (this.overlapped.readBigUInt64LE(0) === 0x103n) { this.error = true; return; } + this.pending = false; this.finish(error); return; + } + this.pending = false; + if (this.connecting) { this.connecting = false; continue; } + done = this.count.readUInt32LE(); + } else { + this.overlapped.fill(0); this.overlapped.writeBigUInt64LE(this.event, 24); + if (!k.ResetEvent!(this.event)) { this.finish(-1); return; } + if (!k.ReadFile!(this.handle, ptr(this.buffer), this.buffer.length, ptr(this.count), ptr(this.overlapped))) { + const error = Number(k.GetLastError!()); + if (error === 997) { this.pending = true; return; } + this.finish(error); return; + } + done = this.count.readUInt32LE(); + } + if (!done) { this.eof = true; return; } + consume(this.buffer.subarray(0, done)); + } + } + private finish(error: number): void { this.eof = true; this.error = error !== 109; } + /** Cancel is only a request. Keep the OVERLAPPED, event and bytes until settlement. */ + async dispose(): Promise { + if (this.disposed) return; + this.closeChild(); + if (this.pending) { + this.native.k.CancelIoEx!(this.handle, ptr(this.overlapped)); + while (this.pending) { + if (this.native.k.GetOverlappedResult!(this.handle, ptr(this.overlapped), ptr(this.count), 0) + || (Number(this.native.k.GetLastError!()) !== 996 && this.overlapped.readBigUInt64LE(0) !== 0x103n)) this.pending = false; + else await Bun.sleep(5); + } + } + if (this.handle) this.native.close(this.handle); + if (this.event) this.native.close(this.event); + this.disposed = true; + } +} diff --git a/src/codex/windows-cli-child.ts b/src/codex/windows-cli-child.ts new file mode 100644 index 0000000000..d147b681be --- /dev/null +++ b/src/codex/windows-cli-child.ts @@ -0,0 +1,228 @@ +import { win32 } from "node:path"; +import { ptr } from "bun:ffi"; +import { securityAttributes, wide, WIN64_CHILD_ABI, WindowsChildNative } from "./windows-cli-child-native"; +import { WindowsChildPipe } from "./windows-cli-child-pipe"; + +export type WindowsCliChildPolicy = "installer" | "pack" | "version"; +export function windowsCliChildPolicy(policy: WindowsCliChildPolicy) { + switch (policy) { + case "installer": return { captureBytes: 0, deadlineMs: null, reapMs: null, terminate: false } as const; + case "pack": return { captureBytes: 1024 * 1024, deadlineMs: null, reapMs: null, terminate: false } as const; + case "version": return { captureBytes: 16 * 1024, deadlineMs: 8000, reapMs: 1000, terminate: true } as const; + } +} +export interface WindowsCliChildInvocation { + readonly executable: string; + readonly args: readonly string[]; + readonly cwd: string; + readonly env: Readonly; + readonly policy: WindowsCliChildPolicy; +} +export interface WindowsCliChildResult { + readonly terminal: boolean; + readonly exitCode: number | null; + readonly stdout: string; + readonly overflow: boolean; + readonly failed: boolean; + readonly reason: string | null; +} +/** CRT argv quoting, with explicit lpApplicationName; never cmd.exe or PATH lookup. */ +export function quoteWindowsChildArg(arg: string): string { + return '"' + arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"'; +} +function validate(invocation: WindowsCliChildInvocation): void { + const localAbsolute = (value: string) => typeof value === "string" && /^[A-Za-z]:[\\/]/.test(value) + && win32.isAbsolute(value) && !value.includes("\0"); + if (!localAbsolute(invocation.executable) || !/\.exe$/i.test(invocation.executable) + || !localAbsolute(invocation.cwd) || !Array.isArray(invocation.args) + || invocation.args.some(arg => typeof arg !== "string" || arg.includes("\0")) + || !windowsCliChildPolicy(invocation.policy)) throw new Error("windows_child_invalid_invocation"); +} +function environmentBlock(env: Readonly, native: WindowsChildNative): Buffer { + const entries = Object.keys(env).filter(key => env[key] !== undefined).map(key => { + const value = env[key]!; + if (!key || /[=\0]/.test(key) || value.includes("\0")) throw new Error("windows_child_invalid_environment"); + return { key: wide(key), entry: `${key}=${value}` }; + }); + // Windows requires case-insensitive ordinal sorting, independent of JS/host locale. + const compare = (a: typeof entries[number], b: typeof entries[number]) => { + const result = Number(native.k.CompareStringOrdinal!(ptr(a.key), -1, ptr(b.key), -1, 1)); + if (!result) throw new Error("windows_child_environment_compare_failed"); + return result - 2; + }; + entries.sort(compare); + for (let i = 1; i < entries.length; i++) if (compare(entries[i - 1]!, entries[i]!) === 0) throw new Error("windows_child_invalid_environment"); + return Buffer.from(entries.map(item => item.entry).join("\0") + "\0\0", "utf16le"); +} + +/** + * Owns an unnamed non-inheritable Job and the original primary process handle. + * No KILL_ON_JOB_CLOSE, active-process limit, BREAKAWAY, PID reopen, or running abort. + * A caller must adopt lease/staging ownership BEFORE calling start, and retain this + * object and those resources whenever wait() returns terminal:false. + */ +export class WindowsCliChild { + readonly pipe: WindowsChildPipe; + readonly policy: ReturnType; + readonly startedAt = Date.now(); + private readonly clockStartedAt = performance.now(); + pid: number | null = null; + creationTime: string | null = null; + process = 0n; + job = 0n; + setupFailure: string | null = null; + private stopAt: number | null = null; + private terminationFailed = false; + private failed = false; + private reason: string | null = null; + private overflow = false; + private chunks: Buffer[] = []; + private captured = 0; + private waiting = false; + private finished: WindowsCliChildResult | undefined; + private observedTerminal: { terminal: true; exitCode: number; unknown: false } | undefined; + private prestartTerminationFailed = false; + constructor(readonly native: WindowsChildNative, policy: WindowsCliChildPolicy) { + this.policy = windowsCliChildPolicy(policy); this.pipe = new WindowsChildPipe(native); + } + private fail(reason: string): void { this.failed = true; this.reason ??= reason; } + private consume = (bytes: Buffer): void => { + const remaining = this.policy.captureBytes - this.captured; + if (bytes.length > remaining && this.policy.captureBytes) this.overflow = true; + if (remaining > 0) { const chunk = Buffer.from(bytes.subarray(0, remaining)); this.chunks.push(chunk); this.captured += chunk.length; } + // Overflow still drains/discards. Only the separate version policy may stop a Job. + if (this.overflow && this.policy.terminate) this.stop("output_limit"); + }; + private stop(reason: string): void { + this.fail(reason); + if (this.stopAt !== null || !this.policy.terminate) return; + this.stopAt = performance.now(); + if (!this.native.k.TerminateJobObject!(this.job, 1)) this.terminationFailed = true; + } + private result(terminal: boolean, exitCode: number | null): WindowsCliChildResult { + return { terminal, exitCode, stdout: Buffer.concat(this.chunks, this.captured).toString("utf8"), + overflow: this.overflow, failed: this.failed || !!this.setupFailure, reason: this.setupFailure ?? this.reason }; + } + /** A successful accounting query plus a signaled original process handle, never PID absence or STILL_ACTIVE. */ + observe(): { terminal: boolean; exitCode: number | null; unknown: boolean } { + if (this.observedTerminal) return this.observedTerminal; + const k = this.native.k, accounting = Buffer.alloc(WIN64_CHILD_ABI.jobAccounting), code = Buffer.alloc(4); + const wait = Number(k.WaitForSingleObject!(this.process, 0)); + if (wait !== 0 && wait !== 258) { this.fail("process_wait_failed"); return { terminal: false, exitCode: null, unknown: true }; } + if (!k.QueryInformationJobObject!(this.job, 1, ptr(accounting), accounting.length, null)) { + this.fail("job_query_failed"); return { terminal: false, exitCode: null, unknown: true }; + } + if (wait !== 0 || accounting.readUInt32LE(40) !== 0) return { terminal: false, exitCode: null, unknown: false }; + if (!k.GetExitCodeProcess!(this.process, ptr(code))) { + this.fail("process_exit_query_failed"); return { terminal: false, exitCode: null, unknown: true }; + } + return this.observedTerminal = { terminal: true, exitCode: code.readUInt32LE(), unknown: false }; + } + async wait(): Promise { + if (this.finished) return this.finished; + if (!this.process) throw new Error("windows_child_not_created_use_cleanup"); + if (this.waiting) throw new Error("windows_child_wait_already_owned"); + this.waiting = true; + try { + for (;;) { + this.pipe.pump(this.consume); + if (this.pipe.error) { this.fail("pipe_read_failed"); if (this.policy.terminate) this.stop("pipe_read_failed"); } + const observed = this.observe(); + if (observed.terminal && this.pipe.eof) { + await this.pipe.dispose(); + this.native.close(this.process); this.native.close(this.job); + const released = this.native.dispose(); + if (!released) this.fail("handle_close_failed"); + return this.finished = this.result(true, observed.exitCode); + } + if (observed.unknown || this.terminationFailed || this.prestartTerminationFailed + || (this.pipe.error && this.pipe.pending)) return this.result(false, null); + if (this.policy.deadlineMs !== null && performance.now() - this.clockStartedAt >= this.policy.deadlineMs) this.stop("deadline"); + if (this.stopAt !== null && performance.now() - this.stopAt >= this.policy.reapMs!) return this.result(false, null); + await Bun.sleep(5); + } + } finally { this.waiting = false; } + } + /** Not a running-child disposal API: only pre-user-code setup failures may call it. */ + reapUnstarted(): boolean { + if (!this.setupFailure || !this.process || this.finished || !this.native.handles.has(this.process)) return false; + this.prestartTerminationFailed = !this.native.k.TerminateProcess!(this.process, 1); + return !this.prestartTerminationFailed; + } +} + +export function startWindowsCliChild(invocation: WindowsCliChildInvocation): WindowsCliChild { + validate(invocation); + return startWindowsCliChildWithNative(invocation, new WindowsChildNative()); +} + +/** Internal native dispatch seam; fixtures use the real APIs with single-call faults. */ +export function startWindowsCliChildWithNative(invocation: WindowsCliChildInvocation, native: WindowsChildNative): WindowsCliChild { + validate(invocation); + const child = new WindowsCliChild(native, invocation.policy), k = native.k; + let stdin = 0n, stderr = 0n, thread = 0n, attributes: Buffer | undefined, initialized = false; + let inheritedHandles: Buffer | undefined; + try { + child.job = native.keep(k.CreateJobObjectW!(null, null)); + child.pipe.open(); + const inheritable = securityAttributes(true), nul = wide("NUL"); + stdin = native.keep(k.CreateFileW!(ptr(nul), 0x80000000, 3, ptr(inheritable), 3, 0, 0n)); + stderr = native.keep(k.CreateFileW!(ptr(nul), 0x40000000, 3, ptr(inheritable), 3, 0, 0n)); + const handles = inheritedHandles = Buffer.alloc(24); + [stdin, child.pipe.child, stderr].forEach((handle, index) => handles.writeBigUInt64LE(handle, index * 8)); + const size = Buffer.alloc(8); + k.InitializeProcThreadAttributeList!(null, 1, 0, ptr(size)); + const length = Number(size.readBigUInt64LE()); + if (length < 24 || length > 1024 * 1024) throw new Error("attribute_size"); + attributes = Buffer.alloc(length); + native.borrowedMemory.add(attributes); native.borrowedMemory.add(handles); + if (!k.InitializeProcThreadAttributeList!(ptr(attributes), 1, 0, ptr(size))) throw new Error("attribute_init"); + initialized = true; + if (!k.UpdateProcThreadAttribute!(ptr(attributes), 0, 0x20002n, ptr(handles), 24n, null, null)) throw new Error("attribute_handles"); + const startup = Buffer.alloc(WIN64_CHILD_ABI.startupInfoEx), info = Buffer.alloc(WIN64_CHILD_ABI.processInformation); + startup.writeUInt32LE(startup.length); startup.writeUInt32LE(0x100, 60); + [stdin, child.pipe.child, stderr].forEach((handle, index) => startup.writeBigUInt64LE(handle, 80 + index * 8)); + startup.writeBigUInt64LE(BigInt(ptr(attributes)), 104); + const executable = wide(invocation.executable), command = wide([invocation.executable, ...invocation.args].map(quoteWindowsChildArg).join(" ")); + const cwd = wide(invocation.cwd), env = environmentBlock(invocation.env, native); + if (command.length > 65534) throw new Error("command_length"); + // CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW. + // All buffers above remain strongly reachable through CreateProcessW and attribute deletion. + if (!k.CreateProcessW!(ptr(executable), ptr(command), null, null, 1, 0x08080404, ptr(env), ptr(cwd), ptr(startup), ptr(info))) { + throw new Error(`create_process_${k.GetLastError!()}`); + } + child.process = native.keep(info.readBigUInt64LE(0)); thread = native.keep(info.readBigUInt64LE(8)); + child.pid = info.readUInt32LE(16); + // Separate FILETIME buffers: creating Buffer subarrays while evaluating FFI + // arguments can materialize a new backing store in bundled Bun on Windows. + const creation = Buffer.alloc(8), exit = Buffer.alloc(8), kernel = Buffer.alloc(8), user = Buffer.alloc(8); + if (!k.GetProcessTimes!(child.process, ptr(creation), ptr(exit), ptr(kernel), ptr(user))) throw new Error("process_times"); + child.creationTime = creation.readBigUInt64LE().toString(); + if (child.creationTime === "0") throw new Error("process_creation_time_unknown"); + if (!k.AssignProcessToJobObject!(child.job, child.process)) throw new Error("job_assignment"); + if (Number(k.ResumeThread!(thread)) !== 1) throw new Error("resume_thread"); + } catch (error) { + child.setupFailure = error instanceof Error ? error.message : "setup_failed"; + if (child.process) child.reapUnstarted(); + } finally { + if (initialized) k.DeleteProcThreadAttributeList!(ptr(attributes!)); + if (attributes) native.borrowedMemory.delete(attributes); + if (inheritedHandles) native.borrowedMemory.delete(inheritedHandles); + child.pipe.closeChild(); + if (stdin) native.close(stdin); if (stderr) native.close(stderr); if (thread) native.close(thread); + } + // No await has occurred while any parent-owned inheritable stdio handle was open. + if (!child.process) { + // No I/O read is issued until wait(). The connect call is the only possible pending operation. + // Caller receives the owner for async cancellation/settlement via cleanupUnstartedWindowsChild. + return child; + } + return child; +} + +/** Setup never launched user code. Preserve owner until pending connect I/O is settled. */ +export async function cleanupUnstartedWindowsChild(child: WindowsCliChild): Promise { + if (child.process || !child.setupFailure) return false; + await child.pipe.dispose(); if (child.job) child.native.close(child.job); + return child.native.dispose(); +} diff --git a/structure/INDEX.md b/structure/INDEX.md index 2a4d9b9f92..bf7ea8e30a 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -228,6 +228,9 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/signed-in-identity.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | +| `src/codex/windows-cli-child-native.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/windows-cli-child-pipe.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/windows-cli-child.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 2ada58b5fe..73e8710954 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -102,6 +102,44 @@ A completed Windows read-only plan has a deterministic ID and `applicable: false `refusal: windows_apply_disabled`; active/unknown sessions still refuse earlier. The independent Windows apply entrypoint remains refused before inspection or lease creation. +## Independent Windows child lifetime primitive + +`src/codex/windows-cli-child.ts` supplies an opt-in Windows x64 child owner, with native ABI/handle +ownership in `src/codex/windows-cli-child-native.ts` and overlapped output in +`src/codex/windows-cli-child-pipe.ts`. No plan, installer, pack or version consumer imports it yet; +Windows apply remains disabled. ARM64 is unsupported. + +It starts only an explicit drive-absolute `.exe` plus argv: `CreateProcessW(CREATE_SUSPENDED)`, +assignment to an unnamed non-inheritable Job, then `ResumeThread`. There is no PID reopen, +shell, breakaway fallback, process-count limit or kill-on-close limit. Assignment/resume failures +reap the original suspended primary before user code; failed termination retains an unknown owner. +`STARTUPINFOEXW` and an explicitly rooted attribute buffer whitelist exactly the three owned, +inheritable stdio handles with `bInheritHandles=TRUE`. Parent read/event/Job/process/thread handles +are not inheritable, and parent copies of the stdio ends close before any await. This is forward +isolation from unrelated inheritable handles, not reverse protection from a foreign unrestricted +`CreateProcess(TRUE)` while those stdio handles are temporarily inheritable. + +The output pipe uses an unpredictable name, a protected current-token-user DACL, remote-client +rejection, and a real overlapped read buffer/event. Cancellation retains that memory until native +completion settles; neither `PeekNamedPipe` nor a Worker script URL is used. Captured bytes are +decoded together as UTF-8. Installer output is discarded; pack captures 1 MiB and continues draining +excess without termination. Neither policy adds an overall deadline or running-abort kill. Version +alone has the existing 16 KiB, 8 second deadline and 1 second reap policy and may terminate its Job. +A termination acknowledgment, timeout, I/O error, failed wait/query or PID absence is not terminal. +The original primary handle must signal and a successful Job accounting query must report zero +active processes; exit code 259 is valid once that handle signals. Output still drains after Job +emptiness. Normal `CreateProcess` descendants are covered; WMI-created processes are not claimed. +Unknown results retain the owner and all lease/staging obligations. Setup failures with no process +use `cleanupUnstartedWindowsChild` to settle any connect operation and release only owned handles. + +The current `packArtifact` reads `packed.exitCode` without adopting `packed.terminal`, and apply's +`started` flag is set only at the installer. Consumer integration therefore requires adopting lifetime +and lease/staging retention before **every** spawn, including pack, before enabling Windows apply. +The npm `--fetch-timeout=120000` remains a separate fetch policy, not a process lifetime deadline. +`tests/codex-integration/codex-cli-windows-child.test.ts` exercises actual owned native fixtures, +including overlapping whitelist isolation, stdout-free descendants, incompatible nested Jobs, +pre-user-code failures, byte caps, cancellation settlement, wait/query failures and version reaping. + ## Explicit POSIX apply `src/cli/codex-cli-update.ts` accepts `apply --plan-id <32 lowercase hex> --yes` with optional JSON output. diff --git a/structure/manifest.json b/structure/manifest.json index 8d04db9084..f05908e25c 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -176,6 +176,9 @@ "scope": "Content-bound planning and explicit POSIX apply with isolated npm, artifact validation, installation leases and readback.", "documents": [ "src/codex/cli-update-plan.ts", + "src/codex/windows-cli-child.ts", + "src/codex/windows-cli-child-native.ts", + "src/codex/windows-cli-child-pipe.ts", "src/codex/cli-update-apply.ts", "src/codex/cli-update-job.ts", "src/codex/cli-update-job-execution.ts", diff --git a/tests/codex-integration/codex-cli-windows-child.test.ts b/tests/codex-integration/codex-cli-windows-child.test.ts new file mode 100644 index 0000000000..58dea94aca --- /dev/null +++ b/tests/codex-integration/codex-cli-windows-child.test.ts @@ -0,0 +1,343 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { dlopen, ptr, read, toBuffer } from "bun:ffi"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fixturePath } from "../helpers/repo-root"; +import { queryWindowsFixtureObjectName } from "../fixtures/windows-cli-child-object-name"; +import { WindowsChildNative, securityAttributes, wide, WIN64_CHILD_ABI } from "../../src/codex/windows-cli-child-native"; +import { WindowsChildPipe } from "../../src/codex/windows-cli-child-pipe"; +import { cleanupUnstartedWindowsChild, startWindowsCliChildWithNative, windowsCliChildPolicy, + type WindowsCliChild, type WindowsCliChildInvocation, type WindowsCliChildPolicy } from "../../src/codex/windows-cli-child"; + +const windows = process.platform === "win32" && process.arch === "x64"; +const directory = mkdtempSync(join(tmpdir(), "ocx-native-child-")); +const owners: WindowsCliChild[] = []; +let sequence = 0; +const path = () => join(directory, `fixture-${++sequence}`); +const invocation = (args: string[], policy: WindowsCliChildPolicy = "pack"): WindowsCliChildInvocation => ({ + executable: process.execPath, args: [fixturePath("windows-cli-child.ts"), ...args], cwd: directory, env: process.env, policy, +}); +function launch(args: string[], policy: WindowsCliChildPolicy = "pack", native = new WindowsChildNative()) { + const child = startWindowsCliChildWithNative(invocation(args, policy), native); owners.push(child); return child; +} +async function until(predicate: () => boolean, timeout = 5000) { + const deadline = Date.now() + timeout; + while (!predicate()) { if (Date.now() > deadline) throw new Error("fixture condition deadline"); await Bun.sleep(5); } +} +async function finish(child: WindowsCliChild) { + const result = child.process ? await child.wait() : (await cleanupUnstartedWindowsChild(child), null); + expect(child.native.handles.size).toBe(0); expect(child.native.closeErrors).toEqual([]); + expect(child.native.borrowedMemory.size).toBe(0); + return result; +} +afterAll(async () => { + // These are task-owned gates and exact original process/Job handles only. + for (let i = 1; i <= sequence; i++) writeFileSync(join(directory, `fixture-${i}`), "release"); + for (const child of owners) { + if (!child.native.handles.size) continue; + if (child.setupFailure) child.reapUnstarted(); + const result = child.process ? await child.wait() : (await cleanupUnstartedWindowsChild(child), { terminal: true }); + if (!result.terminal || child.native.handles.size) throw new Error(`owned fixture not reaped: pid=${child.pid} start=${child.creationTime} handles=${child.native.handles.size}`); + } + rmSync(directory, { recursive: true }); +}); + +describe("Windows child policy", () => { + test("installer/pack have no deadline, active-process cap, abort kill, or kill-on-close", () => { + for (const kind of ["installer", "pack"] as const) { + expect(windowsCliChildPolicy(kind)).toEqual({ captureBytes: kind === "pack" ? 1048576 : 0, + deadlineMs: null, reapMs: null, terminate: false }); + } + expect(windowsCliChildPolicy("version")).toEqual({ captureBytes: 16384, deadlineMs: 8000, reapMs: 1000, terminate: true }); + // Accounting is a count, not a bounded PID array. No 32-process fanout fixture. + const accounting = Buffer.alloc(WIN64_CHILD_ABI.jobAccounting); + accounting.writeUInt32LE(4096, WIN64_CHILD_ABI.activeProcesses); + expect(accounting.readUInt32LE(40)).toBe(4096); + }); +}); + +describe.skipIf(!windows)("Windows x64 actual owned native child", () => { + test("ABI layouts feed real native calls, preserve argv/UTF-8, original handles and exit 259", async () => { + expect(WIN64_CHILD_ABI).toMatchObject({ startupInfo: 104, startupInfoEx: 112, attributes: 104, + securityAttributes: 24, processInformation: 24, overlapped: 32, event: 24, jobAccounting: 48, activeProcesses: 40 }); + const args = ["space value", 'quoted"value', "trailing\\", "", "ν•œκΈ€πŸ™‚", "a\\\\\"b"]; + const argv = launch(["args", ...args]); + expect(argv.setupFailure).toBeNull(); expect(BigInt(argv.creationTime!)).toBeGreaterThan(0n); + const extra = dlopen("kernel32.dll", { GetProcessId: { args: ["u64"], returns: "u32" } }); + expect(extra.symbols.GetProcessId!(argv.process)).toBe(argv.pid); extra.close(); + expect(JSON.parse((await finish(argv))!.stdout)).toEqual(args); + expect((await finish(launch(["unicode"])))!.stdout).toBe("ν•œκΈ€πŸ™‚η΅‚"); + const exit = (await finish(launch(["exit259"])))!; + expect(exit.terminal).toBe(true); expect(exit.exitCode).toBe(259); + expect(argv.observe()).toEqual({ terminal: true, exitCode: 0, unknown: false }); + expect((await argv.wait()).terminal).toBe(true); expect(argv.reapUnstarted()).toBe(false); + }); + + test("Unicode environment is explicit, ordinally sorted and rejects case aliases before CreateProcess", async () => { + const native = new WindowsChildNative(); + const child = startWindowsCliChildWithNative({ ...invocation(["env", "Z", "a", "ν•œκΈ€", "EMPTY", "MISSING"]), + env: { Z: "last", a: "first", "ν•œκΈ€": "κ°’πŸ™‚", EMPTY: "" } }, native); owners.push(child); + expect(JSON.parse((await finish(child))!.stdout)).toEqual(["last", "first", "κ°’πŸ™‚", "", null]); + const marker = path(), invalid = startWindowsCliChildWithNative({ ...invocation(["marker", marker]), + env: { Path: "first", PATH: "second" } }, new WindowsChildNative()); owners.push(invalid); + expect(invalid.setupFailure).toBe("windows_child_invalid_environment"); expect(invalid.process).toBe(0n); + await finish(invalid); expect(existsSync(marker)).toBe(false); + }); + + test("real named pipe has a protected single-principal DACL, rejects remote clients and uses overlapped mode", async () => { + const native = new WindowsChildNative(), pipe = new WindowsChildPipe(native); + const extra = dlopen("advapi32.dll", { + GetSecurityDescriptorControl: { args: ["ptr", "ptr", "ptr"], returns: "i32" }, + GetSecurityDescriptorDacl: { args: ["ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + }); + const create = native.k.CreateNamedPipeW!; + native.k.CreateNamedPipeW = (...args) => { + expect(args[1]).toBe(0x40080001); expect(args[2]).toBe(8); expect(args[3]).toBe(1); + const sa = toBuffer(args[7]!, 0, 24), descriptor = sa.readBigUInt64LE(8); + expect(sa.readUInt32LE()).toBe(24); expect(sa.readUInt32LE(16)).toBe(0); expect(descriptor).not.toBe(0n); + const control = Buffer.alloc(2), revision = Buffer.alloc(4), present = Buffer.alloc(4), acl = Buffer.alloc(8), defaulted = Buffer.alloc(4); + expect(extra.symbols.GetSecurityDescriptorControl!(descriptor, ptr(control), ptr(revision))).toBe(1); + expect(control.readUInt16LE() & 0x1000).toBe(0x1000); // SE_DACL_PROTECTED + expect(extra.symbols.GetSecurityDescriptorDacl!(descriptor, ptr(present), ptr(acl), ptr(defaulted))).toBe(1); + expect(present.readUInt32LE()).toBe(1); expect(defaulted.readUInt32LE()).toBe(0); + expect(read.u16(acl.readBigUInt64LE(), 4)).toBe(1); // One allow ACE, not a default ACL. + return create(...args); + }; + try { pipe.open(); } finally { await pipe.dispose(); extra.close(); } + expect(native.handles.size).toBe(0); expect(native.dispose()).toBe(true); + }); + + test("two overlapping owned setups whitelist their own stdio and exclude a foreign inheritable file by identity", async () => { + const first = new WindowsChildNative(), second = new WindowsChildNative(); + const extra = dlopen("kernel32.dll", { GetHandleInformation: { args: ["u64", "ptr"], returns: "i32" } }); + const inheritance = (handle: bigint) => { + const flags = Buffer.alloc(4); + expect(extra.symbols.GetHandleInformation!(handle, ptr(flags))).toBe(1); + return flags.readUInt32LE() & 1; + }; + const sentinelPath = path(); writeFileSync(sentinelPath, "foreign-sentinel"); + const attributes = securityAttributes(true); + const sentinel = first.keep(first.k.CreateFileW!(ptr(wide(sentinelPath)), 0x80000000, 3, ptr(attributes), 3, 0, 0n)); + const sentinelName = queryWindowsFixtureObjectName(sentinel)!; expect(sentinelName).toBeString(); + const firstAudit = path(), secondAudit = path(); + const lists: bigint[][] = [[], []]; + for (const [index, native] of [first, second].entries()) { + const original = native.k.UpdateProcThreadAttribute!; + native.k.UpdateProcThreadAttribute = (...args) => { + expect(args[2]).toBe(0x20002n); expect(args[4]).toBe(24n); + lists[index] = [0, 8, 16].map(offset => read.u64(args[3]!, offset)); + return original(...args); + }; + const resume = native.k.ResumeThread!; + native.k.ResumeThread = thread => { expect(inheritance(BigInt(thread))).toBe(0); return resume(thread); }; + } + const createFirst = first.k.CreateProcessW!, createSecond = second.k.CreateProcessW!; + let other: WindowsCliChild | undefined; + first.k.CreateProcessW = (...firstArgs) => { + let firstResult = 0; + second.k.CreateProcessW = (...secondArgs) => { + // Both complete stdio sets are still inheritable here, before either CreateProcess. + const objects = lists.map(list => list.map(handle => ({ handle: handle.toString(), name: queryWindowsFixtureObjectName(handle)! }))); + expect(objects.flat().every(item => typeof item.name === "string")).toBe(true); + for (const [index, native] of [first, second].entries()) { + for (const handle of native.handles) { + expect(inheritance(handle)).toBe(handle === sentinel || lists[index]!.includes(handle) ? 1 : 0); + } + } + // Each pipe name and the sentinel file are unique objects. NUL names are + // deliberately not identity evidence for another invocation's NUL handles. + writeFileSync(firstAudit, JSON.stringify({ own: objects[0], foreign: [objects[1]![1], { handle: sentinel.toString(), name: sentinelName }] })); + writeFileSync(secondAudit, JSON.stringify({ own: objects[1], foreign: [objects[0]![1], { handle: sentinel.toString(), name: sentinelName }] })); + for (const args of [firstArgs, secondArgs]) { expect(args[4]).toBe(1); expect(Number(args[5]) & 0x80004).toBe(0x80004); } + firstResult = createFirst(...firstArgs); + return createSecond(...secondArgs); + }; + other = launch(["audit", secondAudit], "pack", second); + return firstResult; + }; + const child = launch(["audit", firstAudit], "pack", first); + first.close(sentinel); // Fixture-owned foreign handle, never in either child's whitelist. + for (const item of [child, other!]) { + expect(item.setupFailure).toBeNull(); + for (const handle of item.native.handles) expect(inheritance(handle)).toBe(0); + const result = JSON.parse((await finish(item))!.stdout); + expect(result.own).toEqual([true, true, true]); expect(result.foreign).toEqual([false, false]); + expect(result.standards).toEqual([true, true, true]); + } + extra.close(); + // Forward isolation only; foreign unrestricted CreateProcess(TRUE) is not claimed safe. + }); + + test("primary exit is nonterminal while a normally created stdout-free descendant remains in the Job", async () => { + const gate = path(), child = launch(["descendant", gate]); + try { + await until(() => existsSync(`${gate}.ready`)); + await until(() => Number(child.native.k.WaitForSingleObject!(child.process, 0)) === 0); + expect(child.observe()).toEqual({ terminal: false, exitCode: null, unknown: false }); + expect(child.native.handles.size).toBeGreaterThan(0); + } finally { writeFileSync(gate, "release"); } + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(`${gate}.done`)).toBe(true); + }); + + test("real incompatible nested Job assignment refuses before fixture code", async () => { + const native = new WindowsChildNative(), marker = path(); + const gate = path(), other = launch(["gate", gate]); + const outer = native.keep(native.k.CreateJobObjectW!(null, null)); + try { + const assign = native.k.AssignProcessToJobObject!; + native.k.AssignProcessToJobObject = (job, process) => { + expect(assign(outer, process)).toBe(1); + // Establish a different nonempty hierarchy. A process already in outer + // cannot join this nonempty Job outside its current Job hierarchy. + expect(assign(job, other.process)).toBe(1); + return assign(job, process); + }; + const child = launch(["marker", marker], "pack", native); + expect(child.setupFailure).toBe("job_assignment"); + native.close(outer); + writeFileSync(gate, "release"); await finish(other); + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(marker)).toBe(false); + } finally { native.close(outer); writeFileSync(gate, "release"); } + }); + + test("assignment/resume failures kill only the still-suspended owned primary before user code", async () => { + for (const stage of ["AssignProcessToJobObject", "ResumeThread"] as const) { + const native = new WindowsChildNative(), marker = path(); + if (stage === "AssignProcessToJobObject") native.k.AssignProcessToJobObject = () => 0; + else native.k.ResumeThread = () => 0xffffffff; + const child = launch(["marker", marker], "installer", native); + expect(child.setupFailure).toBe(stage === "ResumeThread" ? "resume_thread" : "job_assignment"); + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(marker)).toBe(false); + } + }); + + test("failed pre-user-code termination stays owned and unknown until exact handle reap", async () => { + const native = new WindowsChildNative(), marker = path(), terminate = native.k.TerminateProcess!; + native.k.ResumeThread = () => 0xffffffff; native.k.TerminateProcess = () => 0; + const child = launch(["marker", marker], "installer", native); + expect((await child.wait()).terminal).toBe(false); expect(native.handles.size).toBeGreaterThan(0); + native.k.TerminateProcess = terminate; expect(child.reapUnstarted()).toBe(true); + expect((await finish(child))!.terminal).toBe(true); expect(existsSync(marker)).toBe(false); + }); + + test("pre-create setup failures release every handle exactly once and delete initialized attributes", async () => { + for (const stage of ["CreateNamedPipeW", "CreateEventW", "CreateFileW", "InitializeProcThreadAttributeList", "UpdateProcThreadAttribute", "CreateProcessW"] as const) { + const native = new WindowsChildNative(), deleted: number[] = [], closed = new Set(); + const keep = native.keep.bind(native); + native.keep = value => { const handle = keep(value); closed.delete(String(handle)); return handle; }; + const originalClose = native.k.CloseHandle!, originalDelete = native.k.DeleteProcThreadAttributeList!; + native.k.CloseHandle = handle => { expect(closed.has(String(handle))).toBe(false); closed.add(String(handle)); return originalClose(handle); }; + native.k.DeleteProcThreadAttributeList = pointer => { + expect(native.borrowedMemory.size).toBe(2); + deleted.push(1); return originalDelete(pointer); + }; + if (stage === "CreateNamedPipeW" || stage === "CreateEventW" || stage === "CreateFileW") native.k[stage] = (() => 0n) as never; + else if (stage === "InitializeProcThreadAttributeList") { + const initialize = native.k.InitializeProcThreadAttributeList!; + native.k.InitializeProcThreadAttributeList = (...args) => args[0] === null ? initialize(...args) : 0; + } else native.k[stage] = (() => 0) as never; + const child = launch(["marker", path()], "pack", native); + expect(child.process).toBe(0n); expect(child.setupFailure).toBeString(); + await finish(child); + expect(deleted.length).toBe(stage === "UpdateProcThreadAttribute" || stage === "CreateProcessW" ? 1 : 0); + } + }); + + test("pack byte cap drains/discards excess without termination; installer also has no kill policy", async () => { + for (const policy of ["pack", "installer"] as const) { + const native = new WindowsChildNative(); let kills = 0; + native.k.TerminateJobObject = () => { kills++; throw new Error("unexpected installer/pack termination"); }; + const result = (await finish(launch(["overflow", String(2 * 1024 * 1024)], policy, native)))!; + expect(result.terminal).toBe(true); expect(result.exitCode).toBe(0); expect(kills).toBe(0); + expect(result.stdout.length).toBe(policy === "pack" ? 1024 * 1024 : 0); + expect(result.overflow).toBe(policy === "pack"); + } + }); + + test("ordinary last Job handle close does not kill a running installer", async () => { + const gate = path(), child = launch(["gate", gate], "installer"); + await until(() => existsSync(`${gate}.ready`)); + child.native.close(child.job); // Deliberate own-fixture close, loses the helper's terminal proof. + expect(Number(child.native.k.WaitForSingleObject!(child.process, 0))).toBe(258); + expect(child.observe().unknown).toBe(true); + writeFileSync(gate, "release"); + await until(() => Number(child.native.k.WaitForSingleObject!(child.process, 0)) === 0); + expect(existsSync(`${gate}.done`)).toBe(true); + // The test intentionally destroyed the Job proof; exact own primary and I/O cleanup only. + await child.pipe.dispose(); child.native.close(child.process); expect(child.native.dispose()).toBe(true); + expect(child.native.handles.size).toBe(0); + }); + + test("WAIT_FAILED/query failure never counts as terminal; >32 active accounting is nonterminal", async () => { + for (const fault of ["wait", "query", "many"] as const) { + const native = new WindowsChildNative(), wait = native.k.WaitForSingleObject!, query = native.k.QueryInformationJobObject!; + const child = launch(["unicode"], "pack", native); + if (fault === "wait") native.k.WaitForSingleObject = () => 0xffffffff; + else if (fault === "query") native.k.QueryInformationJobObject = () => 0; + else native.k.QueryInformationJobObject = (job, kind, output, length, size) => { + const result = query(job, kind, output, length, size); + // FFI view is used only after a real native query succeeds. + if (result) toBuffer(output!, 0, 48).writeUInt32LE(4096, 40); + return result; + }; + expect(child.observe().terminal).toBe(false); + if (fault !== "many") expect((await child.wait()).terminal).toBe(false); + native.k.WaitForSingleObject = wait; native.k.QueryInformationJobObject = query; + expect((await finish(child))!.terminal).toBe(true); + } + }); + + test("read error is a failure, not lifetime proof, and overlapped cancellation settles before close", async () => { + const gate = path(), native = new WindowsChildNative(); + const extra = dlopen("kernel32.dll", { SetLastError: { args: ["u32"], returns: "void" } }); + const readFile = native.k.ReadFile!; + native.k.ReadFile = () => { extra.symbols.SetLastError!(31); return 0; }; + const child = launch(["gate", gate], "pack", native); + child.pipe.pump(() => {}); expect(child.pipe.error).toBe(true); expect(child.observe().terminal).toBe(false); + native.k.ReadFile = readFile; writeFileSync(gate, "release"); + expect((await finish(child))!.failed).toBe(true); extra.close(); + + const io = new WindowsChildNative(), pipe = new WindowsChildPipe(io); pipe.open(); + pipe.pump(() => {}); expect(pipe.pending).toBe(true); + const close = io.k.CloseHandle!, cancel = io.k.CancelIoEx!; let canceled = false; + io.k.CancelIoEx = (...args) => { canceled = true; return cancel(...args); }; + io.k.CloseHandle = handle => { + if (handle === pipe.handle || handle === pipe.event) expect(pipe.pending).toBe(false); + return close(handle); + }; + await pipe.dispose(); expect(canceled).toBe(true); expect(io.handles.size).toBe(0); expect(io.dispose()).toBe(true); + }); + + test("version overflow and failed termination remain unknown until actual Job drain", async () => { + const native = new WindowsChildNative(); let calls = 0; + native.k.TerminateJobObject = () => { calls++; return 0; }; + const child = launch(["overflow", "32768", "300"], "version", native); + const unknown = await child.wait(); + expect(unknown.terminal).toBe(false); expect(unknown.overflow).toBe(true); expect(calls).toBe(1); + expect(native.handles.size).toBeGreaterThan(0); + await until(() => child.observe().terminal); + const final = (await finish(child))!; expect(final.terminal).toBe(true); expect(final.failed).toBe(true); + }); + + test("version reap deadline is unknown even when termination was acknowledged", async () => { + const native = new WindowsChildNative(); let calls = 0; + // An acknowledged request is deliberately not applied by the native fault seam. + // The fixture itself exits, proving that acknowledgment is never terminal evidence. + native.k.TerminateJobObject = () => { calls++; return 1; }; + const child = launch(["overflow", "32768", "1500"], "version", native), started = Date.now(); + expect((await child.wait()).terminal).toBe(false); expect(calls).toBe(1); + expect(Date.now() - started).toBeGreaterThanOrEqual(1000); + expect(Number(native.k.WaitForSingleObject!(child.process, 0))).toBe(258); + await until(() => child.observe().terminal); + expect((await finish(child))!.terminal).toBe(true); + }); + + test("version 8 second deadline terminates only its owned Job and reaps within the separate policy", async () => { + const gate = path(), child = launch(["gate", gate], "version"), started = Date.now(); + const result = (await finish(child))!; + expect(result.terminal).toBe(true); expect(result.failed).toBe(true); expect(result.reason).toBe("deadline"); + expect(Date.now() - started).toBeGreaterThanOrEqual(7900); expect(Date.now() - started).toBeLessThan(10000); + expect(existsSync(`${gate}.done`)).toBe(false); + }, 12000); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e46a218206..07276bc003 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,7 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "service-proxy-env.test.ts": "service", "bridge-terminal-refusal.test.ts": "adapters", + "codex-cli-windows-child.test.ts": "codex-integration", "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", "codex-cli-update-worker.test.ts": "codex-integration", diff --git a/tests/fixtures/windows-cli-child-object-name.ts b/tests/fixtures/windows-cli-child-object-name.ts new file mode 100644 index 0000000000..8637a22ec5 --- /dev/null +++ b/tests/fixtures/windows-cli-child-object-name.ts @@ -0,0 +1,17 @@ +import { dlopen, ptr, read } from "bun:ffi"; + +/** Object identity, not merely a valid/reused numeric HANDLE in a different process. */ +export function queryWindowsFixtureObjectName(handle: bigint): string | null { + const nt = dlopen("ntdll.dll", { + NtQueryObject: { args: ["u64", "i32", "ptr", "u32", "ptr"], returns: "i32" }, + }); + try { + const bytes = Buffer.alloc(65536), size = Buffer.alloc(4); + if (Number(nt.symbols.NtQueryObject!(handle, 1, ptr(bytes), bytes.length, ptr(size))) !== 0) return null; + const length = bytes.readUInt16LE(), address = bytes.readBigUInt64LE(8); + if (!length || length > 32768 || !address) return null; + let result = ""; + for (let offset = 0; offset < length; offset += 2) result += String.fromCharCode(read.u16(address, offset)); + return result; + } finally { nt.close(); } +} diff --git a/tests/fixtures/windows-cli-child.ts b/tests/fixtures/windows-cli-child.ts new file mode 100644 index 0000000000..e748c67a9c --- /dev/null +++ b/tests/fixtures/windows-cli-child.ts @@ -0,0 +1,57 @@ +// Actual owned native fixture; never imports or starts the proxy or an installed Codex. +import { dlopen, ptr } from "bun:ffi"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { queryWindowsFixtureObjectName } from "./windows-cli-child-object-name"; + +const [mode, path] = process.argv.slice(2); +switch (mode) { + case "args": process.stdout.write(JSON.stringify(process.argv.slice(3))); break; + case "env": process.stdout.write(JSON.stringify(process.argv.slice(3).map(key => process.env[key] ?? null))); break; + case "unicode": { + const bytes = Buffer.from("ν•œκΈ€πŸ™‚η΅‚"); + for (const byte of bytes) { process.stdout.write(Buffer.from([byte])); await Bun.sleep(2); } + break; + } + case "overflow": { + const bytes = Buffer.alloc(Number(path), 120); + await Bun.write(Bun.stdout, bytes); + if (process.argv[4]) await Bun.sleep(Number(process.argv[4])); + break; + } + case "exit259": { + const { symbols } = dlopen("kernel32.dll", { ExitProcess: { args: ["u32"], returns: "void" } }); + symbols.ExitProcess!(259); break; + } + case "marker": writeFileSync(path!, "user-code-ran"); break; + case "gate": { + writeFileSync(`${path}.ready`, "ready"); + const deadline = Date.now() + 15000; + while (!existsSync(path!) && Date.now() < deadline) await Bun.sleep(5); + writeFileSync(`${path}.done`, "done"); break; + } + case "descendant": { + // Normal CreateProcess descendants inherit the helper's Job. No Bun runtime + // subprocess finalizer/Job can alter the lifetime being exercised here. + const k = dlopen("kernel32.dll", { + CreateProcessW: { args: ["ptr", "ptr", "ptr", "ptr", "i32", "u32", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + CloseHandle: { args: ["u64"], returns: "i32" }, ExitProcess: { args: ["u32"], returns: "void" }, + }).symbols; + const wide = (text: string) => Buffer.from(text + "\0", "utf16le"); + const quote = (arg: string) => '"' + arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"'; + const exe = wide(process.execPath), command = wide([process.execPath, import.meta.path, "gate", path!].map(quote).join(" ")); + const startup = Buffer.alloc(104), info = Buffer.alloc(24); startup.writeUInt32LE(104); + if (!k.CreateProcessW!(ptr(exe), ptr(command), null, null, 0, 0x08000000, null, null, ptr(startup), ptr(info))) throw new Error("fixture descendant spawn"); + writeFileSync(`${path}.primary`, JSON.stringify({ pid: info.readUInt32LE(16) })); + k.CloseHandle!(info.readBigUInt64LE()); k.CloseHandle!(info.readBigUInt64LE(8)); + k.ExitProcess!(0); break; + } + case "audit": { + const input = JSON.parse(readFileSync(path!, "utf8")) as { own: { handle: string; name: string }[]; foreign: { handle: string; name: string }[] }; + const own = input.own.map(item => queryWindowsFixtureObjectName(BigInt(item.handle)) === item.name); + const foreign = input.foreign.map(item => queryWindowsFixtureObjectName(BigInt(item.handle)) === item.name); + const k = dlopen("kernel32.dll", { GetStdHandle: { args: ["i32"], returns: "u64" } }); + const standards = input.own.map((item, index) => BigInt(k.symbols.GetStdHandle!(-10 - index)) === BigInt(item.handle)); + process.stdout.write(JSON.stringify({ own, foreign, standards })); k.close(); break; + } + default: throw new Error(`unexpected fixture mode ${mode}`); +}