From c05845c89a2cea6369c3769153e89b32d43e012e Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 17:11:26 +0900 Subject: [PATCH 01/31] feat(codex): add owned Windows x64 native child lifetime primitive Opt-in helper for the disabled Windows CLI apply path (#2811). Starts an explicit drive-absolute .exe via CreateProcessW(CREATE_SUSPENDED), assigns it to an unnamed non-inheritable Job, then resumes. STARTUPINFOEXW whitelists exactly the three owned inheritable stdio handles; output flows over a private single-principal overlapped named pipe. Terminal proof requires the original primary handle signaled plus a successful Job accounting query reporting zero active processes; WAIT_FAILED, query failure, PID absence, acknowledged termination and exit 259 are handled exactly. Installer and pack policies add no deadline, process cap, abort kill or kill-on-close; only the version probe keeps its existing 16 KiB / 8 s / 1 s reap policy. No consumer imports it yet; Windows apply remains refused and ARM64 is unsupported. --- .../test-layout/layout-codex-collision.json | 1 + src/codex/windows-cli-child-native.ts | 113 ++++++ src/codex/windows-cli-child-pipe.ts | 87 +++++ src/codex/windows-cli-child.ts | 228 ++++++++++++ structure/INDEX.md | 3 + structure/codex-cli-update.md | 38 ++ structure/manifest.json | 3 + .../codex-cli-windows-child.test.ts | 343 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../fixtures/windows-cli-child-object-name.ts | 17 + tests/fixtures/windows-cli-child.ts | 57 +++ 11 files changed, 891 insertions(+) create mode 100644 src/codex/windows-cli-child-native.ts create mode 100644 src/codex/windows-cli-child-pipe.ts create mode 100644 src/codex/windows-cli-child.ts create mode 100644 tests/codex-integration/codex-cli-windows-child.test.ts create mode 100644 tests/fixtures/windows-cli-child-object-name.ts create mode 100644 tests/fixtures/windows-cli-child.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 7565641f97..f2bab0a862 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 b1b5065bbb..4799bb9f8b 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -222,6 +222,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 122a743bdb..56a31e70e8 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -89,6 +89,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 77abe17230..0e065f4753 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 be96044d4a..0328d97f9c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,7 @@ { "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", "loop-terminal-refusal.test.ts": "images", 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}`); +} From 39119f43bb69ea656e0ff410ca0b551082d7c37b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 19:18:33 +0900 Subject: [PATCH 02/31] Bind separate-prefix Windows Node/npm for read-only plan under the launcher arch anchor --- docs-site/src/content/docs/reference/cli.md | 8 +- .../src/content/docs/reference/cli/agents.md | 10 +- src/codex/cli-windows-plan-provenance.ts | 19 +++- structure/codex-cli-update.md | 18 +-- .../codex-cli-windows-plan.test.ts | 105 ++++++++++++------ 5 files changed, 108 insertions(+), 52 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 8bc2ef7316..e2b65fc3fc 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -153,8 +153,10 @@ The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSour Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report `candidate_unavailable` on POSIX or `windows_inspection_deferred` on Windows. Windows x64 also supports -a proof-captured absolute standard npm `codex.cmd` with prefix-local Node/npm and a verified in-prefix -platform backing. It uses reparse-refusing held handles; other Windows layouts remain deferred or unmanaged. +a proof-captured absolute standard npm `codex.cmd` with a held-bound Node/npm toolchain — prefix-local +when present, otherwise the captured-PATH pair anchored to the launcher architecture — and a verified +in-prefix platform backing. It uses reparse-refusing held handles; other Windows layouts remain deferred +or unmanaged. The command does not install or repair software, execute Codex or npm, control a running process, or write configuration/cache state. @@ -164,7 +166,7 @@ exact version and SHA-512 integrity, and refuses active sessions or incomplete p Registry queries use isolated temporary npm configuration, cache and logs with best-effort cleanup. No software is installed and no process is signalled. A supported Windows installation can produce a deterministic read-only plan with `applicable: false` and `refusal: windows_apply_disabled`. -PATH-only selection, separate-prefix Node and OpenCodex autostart wrappers remain deferred. +PATH-only selection and OpenCodex autostart wrappers remain deferred. See [planning details](/reference/cli/agents/#read-only-codex-cli-update-plan). For a reviewed POSIX non-shim npm-global plan, `ocx system codex-cli-update apply --plan-id --yes` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 622c59d181..8af4f6a651 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1164,8 +1164,9 @@ can then establish `managed: true` when it proves ownership and, if a candidate and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report `candidate_unavailable` on POSIX. On Windows x64, a proof-captured absolute standard npm `codex.cmd` -can be managed when held local handles bind its package, prefix-local Node/npm and the first in-prefix -platform backing. Other Windows layouts remain deferred or unmanaged. Windows never consults persisted state, so a run +can be managed when held local handles bind its package, a held-bound Node/npm toolchain — prefix-local +when present, otherwise the captured-PATH pair anchored to the launcher architecture — and the first +in-prefix platform backing. Other Windows layouts remain deferred or unmanaged. Windows never consults persisted state, so a run with no captured environment candidate reports `windows_inspection_deferred` rather than `candidate_unavailable`: the command cannot observe whether a Codex CLI is installed, so it reports the deferral instead of asserting that no candidate exists. The command does not execute Codex or a package manager, repair a shim, @@ -1183,8 +1184,9 @@ ocx system codex-cli-update plan --channel latest --json Run through the published Node launcher with the intended `CODEX_CLI_PATH` in the shell environment. The plan requires an attested npm-global installation and a linked package manifest version. -Windows supports only an explicit absolute standard npm `codex.cmd` with prefix-local Node/npm; -PATH-only selection, separate-prefix Node, OpenCodex autostart wrappers and global/vendor fallback stay deferred. +Windows supports only an explicit absolute standard npm `codex.cmd` with a held-bound Node/npm toolchain — +prefix-local when present, otherwise the captured-PATH pair anchored to the launcher architecture; +PATH-only selection, OpenCodex autostart wrappers and global/vendor fallback stay deferred. It queries the official npm registry for an exact version, SHA-512 integrity and same-origin tarball, using the launcher's captured PATH and isolated temporary npm configuration, cache and logs. Proxy settings and `NODE_EXTRA_CA_CERTS` remain supported; cleanup is best-effort. No Codex command runs. diff --git a/src/codex/cli-windows-plan-provenance.ts b/src/codex/cli-windows-plan-provenance.ts index 0f7b5d5c05..9c0b0d9dd0 100644 --- a/src/codex/cli-windows-plan-provenance.ts +++ b/src/codex/cli-windows-plan-provenance.ts @@ -48,9 +48,14 @@ function peArch(bytes: Uint8Array): "x64" | "arm64" | null { /** * Read-only supported subset: a proof-captured absolute standard npm cmd-shim - * override, its global-prefix package, prefix-local Node/npm, and the prefix-local - * optional backing. PATH-only Node selection remains refused until the snapshot - * proves the target cmd-shim's cwd and search policy. + * override, its global-prefix package, a held-bound Node/npm toolchain, and the + * in-prefix optional backing. When the prefix has no node.exe, the toolchain is + * the npm pair the captured PATH resolves — the updater's own metadata vehicle, + * never a claim about which interpreter a later codex.cmd invocation resolves: + * the shim's bare `node` falls through to the ambient invocation working + * directory, which no launcher snapshot can prove. A separate toolchain + * therefore also requires the launcher arch anchor, keeping the backing lane + * tied to the selection instead of to an arbitrary PATH hit. */ export async function inspectWindowsCodexCliPlanInstall( deps: CodexCliInstallProvenanceDeps, fallback: CodexCliInstallReport, excluded: (path: string) => boolean, @@ -75,7 +80,11 @@ export async function inspectWindowsCodexCliPlanInstall( const npmCli = derivation.input.npmCli; const npmRoot = win32.dirname(win32.dirname(npmCli)); const pathNode = !sameWindowsPath(node, localNodePath); - if (pathNode) return fallback; + // A prefix-local node.exe is provably the shim's own interpreter; a PATH-resolved + // separate toolchain is bound as the updater's metadata vehicle only. Without the + // launcher arch the toolchain's arch cannot be tied to the selection at all, so a + // separate toolchain stays refused rather than claim an unanchored backing lane. + if (pathNode && !deps.arch) return fallback; const root = win32.join(prefix, "node_modules", "@openai", "codex"); const manifest = win32.join(root, "package.json"); const npmManifest = win32.join(npmRoot, "package.json"); @@ -137,7 +146,7 @@ export async function inspectWindowsCodexCliPlanInstall( const backing = json(files.get(backingManifest)!); if (!backing || "exports" in backing || ![pkg, "@openai/codex"].includes(String(backing.name)) || ![main.version, `${main.version}-win32-${arch}`].includes(String(backing.version))) return fallback; - const content = hash(JSON.stringify(["windows-npm-read-only-plan-v2", arch, prefix, candidate, node, npmCli, + const content = hash(JSON.stringify(["windows-npm-read-only-plan-v3", arch, prefix, candidate, node, npmCli, ...final.files.map(file => [file.path, file.identity, file.digest])])); const report: CodexCliInstallReport = Object.freeze({ ...fallback, managed: true, selectionAttested: true, provenance: "npm-global", reason: "managed_npm_global", packageVersion: main.version, diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 122a743bdb..843ecd6317 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -60,13 +60,15 @@ an existing Codex session. Windows enumeration/installation limits remain unchan `src/codex/cli-windows-plan-provenance.ts` admits only a proof-captured, drive-absolute `CODEX_CLI_PATH` naming the exact standard npm `codex.cmd` grammar. The global npm -package prefix comes from the shim directory and its `node_modules` tree. This plan -currently requires prefix-local `node.exe` and npm. Derivation may propose a PATH Node only -when the local Node probe proves `not-found`; a refused/reparse probe never counts as absent. -The production plan still refuses that external candidate: a standard npm cmd-shim runs bare -`node` when its local Node is absent, and CMD can prefer the invocation working directory. -The proof-bound launcher snapshot has PATH/PATHEXT but no target-shim invocation -cwd/search-result evidence, so a separate Node cannot be selected safely yet. The production runtime resolver confirms the explicit Codex override's +package prefix comes from the shim directory and its `node_modules` tree. A prefix-local +`node.exe` and npm bind as the shim's own interpreter toolchain. When the local probe +proves `not-found`, the captured PATH's Node/npm pair binds instead as the updater's +metadata toolchain; a refused or reparse probe never counts as absent, and a separate +toolchain without the launcher arch anchor stays refused because its arch could not be +tied to the selection at all. The bound Node is never claimed to be the interpreter a +later `codex.cmd` invocation resolves: the shim's bare `node` falls through to the +ambient invocation working directory — CMD prefers the current directory over PATH — +and no launcher snapshot can carry that per-invocation property. The production runtime resolver confirms the explicit Codex override's priority without saved-state reads or probes. A PE header binds Node's architecture; the package manifest/bin, npm manifest/bin, Node and the first in-prefix platform package plus native backing are observed together. Backing lookup follows the @@ -78,7 +80,7 @@ to 256 KiB and Node to 128 MiB. The native backing uses the same finite 1 GiB The native reader bounds aggregate requests to that backing plus 128 MiB Node and twelve 1 MiB text requests; hash-only bytes are never buffered whole. -PATH-only or separate-prefix Node selection, autostart wrappers, global/vendor fallback, +PATH-only candidate selection, autostart wrappers, global/vendor fallback, app/Store/MSIX bundles and version-manager ownership remain unverified and unmanaged. The explicit `attest` report stays observation-only. Only the production plan predicate creates private plan evidence; copying a public report cannot enable Windows planning. diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index 5f96068276..600b802ca2 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -155,29 +155,67 @@ native("a selected override changing during observation refuses the original pre }); native("npm toolchain drift after inspection refuses metadata without executing the replacement", async () => { - const f = fixture(); let queries = 0; - const plan = await createCodexCliUpdatePlan({ inspectionDeps: f.deps, inspect: async deps => { - const report = await inspectCodexCliInstall(deps); expect(report.managed).toBe(true); - writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), "throw new Error('replacement');"); - return report; - }, resolveTarget: () => { queries++; throw new Error("must not query changed toolchain"); } }); - expect(queries).toBe(0); expect(plan.refusal).toBe("target_unresolved"); + for (const f of [fixture(), fixture(true)]) { + let queries = 0; + const plan = await createCodexCliUpdatePlan({ inspectionDeps: f.deps, inspect: async deps => { + const report = await inspectCodexCliInstall(deps); expect(report.managed).toBe(true); + writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), "throw new Error('replacement');"); + return report; + }, resolveTarget: () => { queries++; throw new Error("must not query changed toolchain"); } }); + expect(queries).toBe(0); expect(plan.refusal).toBe("target_unresolved"); + } }); -native("a separate PATH Node is refused without proof of the target cmd-shim's search result", async () => { +native("a separate PATH Node/npm toolchain binds for read-only planning under the launcher arch anchor", async () => { const f = fixture(true); expect((await inspectCodexCliInstall({ ...f.deps, selectionAttested: false })).managed).toBe(false); const report = await inspectCodexCliInstall(f.deps); + expect(report).toMatchObject({ managed: true, selectionAttested: true, provenance: "npm-global", + reason: "managed_npm_global", packageVersion: "1.2.3" }); + expect(report.installContentDigest).toMatch(/^[a-f0-9]{64}$/); + expect((await inspectCodexCliInstall(f.deps)).installContentDigest).toBe(report.installContentDigest); + expect(JSON.stringify(report)).not.toContain(f.root); + + const deps = { inspectionDeps: f.deps, + resolveTarget: () => ({ kind: "resolved" as const, version: "1.2.4", integrity: "sha512-" + Buffer.alloc(64, 1).toString("base64") }), + scanProcesses: () => ({ kind: "observed" as const, processes: [] }) }; + const first = await createCodexCliUpdatePlan(deps); + expect(first).toMatchObject({ managed: true, applicable: false, refusal: "windows_apply_disabled", + installedVersion: "1.2.3", targetVersion: "1.2.4" }); + expect(first.planId).toMatch(/^[a-f0-9]{32}$/); + expect((await createCodexCliUpdatePlan(deps)).planId).toBe(first.planId); + expect(JSON.stringify(first)).not.toContain(f.root); +}); + +native("a separate PATH Node without the launcher arch anchor stays refused", async () => { + const f = fixture(true); + const report = await inspectCodexCliInstall({ ...f.deps, arch: undefined }); expect(report).toMatchObject({ managed: false, installContentDigest: null }); let targetQueries = 0; - const plan = await createCodexCliUpdatePlan({ inspectionDeps: f.deps, - resolveTarget: () => { targetQueries++; throw new Error("unproven Node must not query registry"); } }); + const plan = await createCodexCliUpdatePlan({ inspectionDeps: { ...f.deps, arch: undefined }, + resolveTarget: () => { targetQueries++; throw new Error("unanchored Node must not query registry"); } }); expect(targetQueries).toBe(0); expect(plan.managed).toBe(false); expect(plan.refusal).toBe("windows_inspection_deferred"); expect(JSON.stringify(plan)).not.toContain(f.root); }); +native("a separate PATH Node whose PE arch disagrees with the launcher anchor stays refused", async () => { + const f = fixture(true); + const report = await inspectCodexCliInstall({ ...f.deps, arch: "arm64" as const }); + expect(report).toMatchObject({ managed: false, installContentDigest: null }); +}); + +native("a separate PATH Node without its adjacent npm-cli.js stays deferred", async () => { + const f = fixture(true); + removeTreeWithRetry(f.npmRoot); + const report = await inspectCodexCliInstall(f.deps); + expect(report).toMatchObject({ managed: false, installContentDigest: null }); + const plan = await createCodexCliUpdatePlan({ inspectionDeps: f.deps }); + expect(plan.managed).toBe(false); + expect(plan.refusal).toBe("windows_inspection_deferred"); +}); + native("synthetic CMD lookup selects a current-directory node.cmd ahead of PATH", () => { const root = mkdtempSync(join(tmpdir(), "ocx-cmd-search-")); roots.push(root); const current = join(root, "current"); const pathDir = join(root, "path"); @@ -230,26 +268,29 @@ native("actual CLI handler reaches production inspector and plan from the proof- }); native("published Node CLI uses the production inspector and observed toolchain for read-only metadata", () => { - const f = fixture(); const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); - const node = join(f.toolchainPrefix, "node.exe"); copyFileSync(hostNode!, node); - const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); - const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, - dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; - const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, - tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; - // Controlled registry protocol fixture; no real npm installation or network request. - writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), - `const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); - const run = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { - cwd: repoRoot(), encoding: "utf8", windowsHide: true, timeout: 45_000, - env: { ...process.env, CODEX_CLI_PATH: f.deps.env.CODEX_CLI_PATH, OPENCODEX_HOME: f.deps.configDir, - OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, - }); - expect(run.error).toBeUndefined(); expect(run.status).toBe(0); - const plan = JSON.parse(run.stdout); - expect(plan.managed).toBe(true); expect(plan.targetVersion).toBe("1.2.4"); - expect(plan.targetIntegrity).toBe(integrity); - expect(plan.applicable).toBe(false); - expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); - expect(JSON.stringify(plan)).not.toContain(f.root); + const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); + for (const f of [fixture(), fixture(true)]) { + const node = join(f.toolchainPrefix, "node.exe"); copyFileSync(hostNode!, node); + const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); + const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; + const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, + tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; + // Controlled registry protocol fixture; no real npm installation or network request. + writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), + `const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); + const run = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { + cwd: repoRoot(), encoding: "utf8", windowsHide: true, timeout: 45_000, + env: { ...process.env, PATH: f.toolchainPrefix + ";" + process.env.PATH, + CODEX_CLI_PATH: f.deps.env.CODEX_CLI_PATH, OPENCODEX_HOME: f.deps.configDir, + OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, + }); + expect(run.error).toBeUndefined(); expect(run.status).toBe(0); + const plan = JSON.parse(run.stdout); + expect(plan.managed).toBe(true); expect(plan.targetVersion).toBe("1.2.4"); + expect(plan.targetIntegrity).toBe(integrity); + expect(plan.applicable).toBe(false); + expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); + expect(JSON.stringify(plan)).not.toContain(f.root); + } }, 60_000); From 88f8d21b0b7e99d82523f97da84183cd221c1bdd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 19:26:00 +0900 Subject: [PATCH 03/31] Document that managed Windows layouts bind a metadata toolchain, not the shim interpreter --- docs-site/src/content/docs/reference/cli.md | 4 +++- docs-site/src/content/docs/reference/cli/agents.md | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index e2b65fc3fc..9486f97540 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -156,7 +156,9 @@ a direct Bun/source launch has no such proof, ignores ambient and persisted cand a proof-captured absolute standard npm `codex.cmd` with a held-bound Node/npm toolchain — prefix-local when present, otherwise the captured-PATH pair anchored to the launcher architecture — and a verified in-prefix platform backing. It uses reparse-refusing held handles; other Windows layouts remain deferred -or unmanaged. +or unmanaged. A `managed` Windows result classifies the observed installation layout only: the bound Node/npm +is the updater's read-only metadata toolchain, never a claim about which interpreter a later `codex.cmd` +invocation resolves, and planning stays read-only with apply disabled. The command does not install or repair software, execute Codex or npm, control a running process, or write configuration/cache state. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 8af4f6a651..614a9ecf7f 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1166,7 +1166,10 @@ a direct Bun/source launch has no such proof, ignores ambient and persisted cand `candidate_unavailable` on POSIX. On Windows x64, a proof-captured absolute standard npm `codex.cmd` can be managed when held local handles bind its package, a held-bound Node/npm toolchain — prefix-local when present, otherwise the captured-PATH pair anchored to the launcher architecture — and the first -in-prefix platform backing. Other Windows layouts remain deferred or unmanaged. Windows never consults persisted state, so a run +in-prefix platform backing. Other Windows layouts remain deferred or unmanaged. A `managed` Windows result classifies +the observed installation layout only: the bound Node/npm is the updater's read-only metadata toolchain, never a claim +about which interpreter a later `codex.cmd` invocation resolves, and planning stays read-only with apply disabled. +Windows never consults persisted state, so a run with no captured environment candidate reports `windows_inspection_deferred` rather than `candidate_unavailable`: the command cannot observe whether a Codex CLI is installed, so it reports the deferral instead of asserting that no candidate exists. The command does not execute Codex or a package manager, repair a shim, From ec3cd769317dd6efe2f452b4e68aa057cb3ad764 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 19:27:37 +0900 Subject: [PATCH 04/31] feat(codex): verify independent Windows CLI artifact layout --- .../test-layout/layout-codex-collision.json | 1 + src/codex/cli-update-artifacts.ts | 20 ++- src/codex/cli-update-windows-layout.ts | 110 ++++++++++++ src/codex/cli-update-windows-paths.ts | 26 +++ structure/INDEX.md | 2 + structure/codex-cli-update.md | 22 +++ structure/manifest.json | 2 + .../codex-cli-update-windows-layout.test.ts | 165 ++++++++++++++++++ tests/fixtures/codex-windows-npm-shims.json | 9 + tests/fixtures/test-layout-expected.json | 1 + 10 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 src/codex/cli-update-windows-layout.ts create mode 100644 src/codex/cli-update-windows-paths.ts create mode 100644 tests/codex-integration/codex-cli-update-windows-layout.test.ts create mode 100644 tests/fixtures/codex-windows-npm-shims.json diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 7565641f97..b7ecde9fc0 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,5 @@ { + "codex-cli-update-windows-layout.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/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts index 8afe16b8c7..e3ce1a518b 100644 --- a/src/codex/cli-update-artifacts.ts +++ b/src/codex/cli-update-artifacts.ts @@ -5,6 +5,7 @@ import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { parseStrictSemver } from "../lib/strict-semver"; import { codexCliContentFileDigest, codexCliContentSetDigest, codexCliPlatformBacking } from "./cli-install-provenance"; +import { windowsArchivePathValidator, WINDOWS_CODEX_HEADER_BYTES, WINDOWS_CODEX_NATIVE_MEMBER } from "./cli-update-windows-paths"; export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org"; export interface CodexCliArtifact { @@ -67,14 +68,18 @@ export function codexCliArtifactSetDigest(set: CodexCliArtifactSet): string { export const MAX_CODEX_ARCHIVE_BYTES = 256 * 1024 * 1024; export const MAX_CODEX_UNPACKED_BYTES = 512 * 1024 * 1024; export const MAX_CODEX_MEMBER_BYTES = 384 * 1024 * 1024; -export interface CodexArchiveFile { readonly size: number; readonly mode: number; readonly digest: string; readonly bytes: Buffer | null } +export interface CodexArchiveFile { readonly size: number; readonly mode: number; readonly digest: string; readonly bytes: Buffer | null; + /** Optional bounded prefix from the same integrity-verified member stream, never a second path read. */ + readonly nativePrefix?: Buffer } export type CodexArchiveFiles = Map; /** Stream gzip and tar. Only package.json (256 KiB) and the JS entrypoint (1 MiB) * are retained; native binaries and other files contribute a streaming digest. + * Windows x64 opt-in additionally retains at most 4 KiB of its native member. * Official 0.163.0 linux-x64: 162,055,867 compressed, 429,938,138 unpacked, * largest member 296,865,480 bytes. Limits are independent, not a heap budget. */ -export async function verifyCodexCliArchive(source: Buffer | string, target: CodexCliArtifact): Promise { +export async function verifyCodexCliArchive(source: Buffer | string, target: CodexCliArtifact, + options: { windowsX64?: boolean } = {}): Promise { if (!isSha512Integrity(target.integrity)) throw new Error("artifact_integrity_mismatch"); let fd: number | undefined; let before: ReturnType | undefined; @@ -99,8 +104,9 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod const header = Buffer.alloc(512); const files: CodexArchiveFiles = new Map(); const names = new Set(); + const windowsName = options.windowsX64 ? windowsArchivePathValidator() : undefined; let current: { name: string; size: number; mode: number; remaining: number; keep: boolean; - chunks: Buffer[]; hash: ReturnType } | undefined; + chunks: Buffer[]; prefix?: Buffer; hash: ReturnType } | undefined; let tracker: Transform | undefined, gunzip: ReturnType | undefined, pumping: Promise | undefined; let verified = false; try { @@ -129,10 +135,13 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod const bytes = chunk.subarray(position, position + take); current.hash.update(bytes); if (current.keep) current.chunks.push(Buffer.from(bytes)); + const offset = current.size - current.remaining; + if (current.prefix && offset < current.prefix.length) bytes.copy(current.prefix, offset, 0, current.prefix.length - offset); current.remaining -= take; position += take; if (!current.remaining) { files.set(current.name, { size: current.size, mode: current.mode, digest: current.hash.digest("hex"), - bytes: current.keep ? Buffer.concat(current.chunks) : null }); + bytes: current.keep ? Buffer.concat(current.chunks) : null, + ...(current.prefix ? { nativePrefix: current.prefix } : {}) }); padding = (512 - current.size % 512) % 512; current = undefined; } continue; @@ -158,11 +167,14 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod || !["", "0", "5"].includes(type) || (type === "5" && size !== 0) || (type !== "5" && name.endsWith("/")) || size > MAX_CODEX_MEMBER_BYTES) throw new Error("artifact_tar_invalid"); names.add(name); + windowsName?.(name, type === "5"); if (type === "5") continue; const relative = name.slice(8); const keep = relative === "package.json" || relative === "bin/codex.js"; if (keep && size > (relative === "package.json" ? 256 * 1024 : 1024 * 1024)) throw new Error("artifact_metadata_limit"); current = { name: relative, size, mode, remaining: size, keep, chunks: [], + ...(options.windowsX64 && relative === WINDOWS_CODEX_NATIVE_MEMBER + ? { prefix: Buffer.alloc(Math.min(size, WINDOWS_CODEX_HEADER_BYTES)) } : {}), hash: createHash("sha256").update("codex-cli-observed-file-v1\0") }; if (!size) { files.set(relative, { size, mode, digest: current.hash.digest("hex"), bytes: keep ? Buffer.alloc(0) : null }); current = undefined; diff --git a/src/codex/cli-update-windows-layout.ts b/src/codex/cli-update-windows-layout.ts new file mode 100644 index 0000000000..9e2186d61a --- /dev/null +++ b/src/codex/cli-update-windows-layout.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { backingReference, codexCliArtifactSet, codexCliArtifactSetDigest, verifyCodexCliArchive, + type CodexArchiveFiles, type CodexCliArtifactSet } from "./cli-update-artifacts"; +import { WINDOWS_CODEX_HEADER_BYTES, WINDOWS_CODEX_NATIVE_MEMBER } from "./cli-update-windows-paths"; + +export interface WindowsCodexExpectedFile { readonly relativePath: string; readonly size: number; readonly digest: string } +export interface WindowsCodexArchiveLayout { + readonly schemaVersion: 1; + readonly applicable: false; + readonly mutationAuthority: false; + readonly platform: "win32"; + readonly arch: "x64"; + readonly nativeHeader: "PE32+-AMD64"; + readonly shimContract: "npm-cmd-shim-8-node"; + readonly mainRoot: "node_modules/@openai/codex"; + readonly backingRoot: "node_modules/@openai/codex-win32-x64"; + readonly nativePath: string; + readonly artifactsDigest: string; + readonly contentDigest: string; + readonly files: readonly WindowsCodexExpectedFile[]; +} +const MAIN = "node_modules/@openai/codex", BACKING = "node_modules/@openai/codex-win32-x64"; +const PACKAGE = "@openai/codex-win32-x64"; +// Exact cmd-shim@8.0.0 (ISC) output for the supported node shebang/global-prefix linkage. +// Independent captured outputs live in tests/fixtures/codex-windows-npm-shims.json. +// This is a readback contract, not a shim writer or a claim about every npm version. +const SHIMS: readonly WindowsCodexExpectedFile[] = [ + { relativePath: "codex", size: 421, digest: "e4cfdce20603991a0cc30e79b7080277f934100a317f9c8b2779c0a2f8213ab2" }, + { relativePath: "codex.cmd", size: 341, digest: "0ea5e45d176e31ed39d5b9aad97b58afd6b41f40023508ae5362e151335db803" }, + { relativePath: "codex.ps1", size: 869, digest: "1f82c9b7d42cb3293d459d7304e67f9da76297d1cc33230a3f786c297ee0cc6a" }, +]; +const object = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); +function manifest(files: CodexArchiveFiles): Record { + const bytes = files.get("package.json")?.bytes; + const value: unknown = bytes && JSON.parse(bytes.toString("utf8")); + if (!object(value)) throw new Error("artifact_manifest_unverified"); + return value; +} +function validateNative(files: CodexArchiveFiles): void { + const native = files.get(WINDOWS_CODEX_NATIVE_MEMBER), prefix = native?.nativePrefix; + if (!native || !prefix || prefix.length !== Math.min(native.size, WINDOWS_CODEX_HEADER_BYTES) + || prefix.length < 64 || prefix.toString("ascii", 0, 2) !== "MZ") throw new Error("artifact_windows_native_unverified"); + const pe = prefix.readUInt32LE(60); + if (pe < 64 || pe > prefix.length - 26 || prefix.toString("ascii", pe, pe + 4) !== "PE\0\0" + || prefix.readUInt16LE(pe + 4) !== 0x8664 || !prefix.readUInt16LE(pe + 6) + || prefix.readUInt16LE(pe + 20) < 2 || pe + 24 + prefix.readUInt16LE(pe + 20) > native.size + || !(prefix.readUInt16LE(pe + 22) & 2) || (prefix.readUInt16LE(pe + 22) & 0x2000) + || prefix.readUInt16LE(pe + 24) !== 0x20b) throw new Error("artifact_windows_native_unverified"); +} + +/** Read-only artifact verification. Relative paths never select a live prefix or grant write authority. + * Uses the existing Windows package/Node lookup contract's sibling optional-package lane only. + * PE header evidence is not a signature, complete image validation or execution proof. + */ +export async function verifyWindowsCodexCliLayout(set: CodexCliArtifactSet, mainSource: Buffer | string, + backingSource: Buffer | string): Promise { + if (set.platform !== "win32" || set.arch !== "x64" || set.main.name !== "@openai/codex" + || set.main.installName !== "@openai/codex" || set.backing.installName !== PACKAGE) throw new Error("artifact_platform_unverified"); + const main = await verifyCodexCliArchive(mainSource, set.main, { windowsX64: true }); + const backing = await verifyCodexCliArchive(backingSource, set.backing, { windowsX64: true }); + const pkg = manifest(main), platform = manifest(backing); + // Reuse metadata origin/version and exact direct/alias linkage checks, rather than trusting a structural DTO. + codexCliArtifactSet({ ...pkg, dist: { integrity: set.main.integrity, tarball: set.main.tarball } }, + () => ({ ...platform, dist: { integrity: set.backing.integrity, tarball: set.backing.tarball } }), "win32", "x64"); + for (const name of Object.keys(pkg.optionalDependencies ?? {})) { + if (!/^@openai\/codex-(linux|darwin|win32)-(x64|arm64)$/.test(name)) throw new Error("artifact_dependency_unverified"); + backingReference(pkg, name); + } + const bin = typeof pkg.bin === "string" ? pkg.bin + : object(pkg.bin) && Object.keys(pkg.bin).length === 1 ? pkg.bin.codex : null; + const entry = main.get("bin/codex.js")?.bytes; + if (typeof bin !== "string" || bin.replace(/^\.\//, "") !== "bin/codex.js" || !entry + || entry.toString("utf8").split(/\r?\n/, 1)[0] !== "#!/usr/bin/env node" + || platform.exports !== undefined || platform.bin !== undefined || Object.keys(platform.optionalDependencies ?? {}).length + || (platform.os !== undefined && (!Array.isArray(platform.os) || platform.os.length !== 1 || platform.os[0] !== "win32")) + || (platform.cpu !== undefined && (!Array.isArray(platform.cpu) || platform.cpu.length !== 1 || platform.cpu[0] !== "x64"))) { + throw new Error("artifact_backing_unverified"); + } + validateNative(backing); + const files: WindowsCodexExpectedFile[] = SHIMS.map(file => ({ ...file })); + for (const [root, archive] of [[MAIN, main], [BACKING, backing]] as const) { + for (const [relative, proof] of archive) { + // npm aliases are installed as explicit sibling packages. No embedded package may shadow that lookup. + if (relative.split("/").some(part => part.toLowerCase() === "node_modules")) throw new Error("artifact_backing_unverified"); + files.push({ relativePath: `${root}/${relative}`, size: proof.size, digest: proof.digest }); + } + } + files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0); + const artifactsDigest = codexCliArtifactSetDigest(set); + const contentDigest = createHash("sha256").update("codex-cli-windows-layout-v1\0") + .update(JSON.stringify([artifactsDigest, files])).digest("hex"); + return Object.freeze({ schemaVersion: 1, applicable: false, mutationAuthority: false, platform: "win32", arch: "x64", + nativeHeader: "PE32+-AMD64", shimContract: "npm-cmd-shim-8-node", mainRoot: MAIN, backingRoot: BACKING, + nativePath: `${BACKING}/${WINDOWS_CODEX_NATIVE_MEMBER}`, artifactsDigest, contentDigest, + files: Object.freeze(files.map(file => Object.freeze(file))) }); +} + +/** Compare the two package roots and three shim paths, not unrelated files in the global prefix. + * No filesystem reads, mode guesses or partial matches. + * The caller still owes held-file/linkage/selection proof; equal content is not mutation authority. + */ +export function matchesWindowsCodexCliLayout(expected: WindowsCodexArchiveLayout, + observed: readonly WindowsCodexExpectedFile[]): boolean { + if (expected.files.length !== observed.length) return false; + const actual = new Map(observed.map(file => [file.relativePath, file])); + return actual.size === observed.length && expected.files.every(file => { + const other = actual.get(file.relativePath); + return other?.size === file.size && other.digest === file.digest; + }); +} diff --git a/src/codex/cli-update-windows-paths.ts b/src/codex/cli-update-windows-paths.ts new file mode 100644 index 0000000000..51f3db5454 --- /dev/null +++ b/src/codex/cli-update-windows-paths.ts @@ -0,0 +1,26 @@ +/** Archive names only, never filesystem resolution or installation authority. */ +export const WINDOWS_CODEX_NATIVE_MEMBER = "vendor/x86_64-pc-windows-msvc/bin/codex.exe"; +export const WINDOWS_CODEX_HEADER_BYTES = 4096; + +/** ASCII is the supported archive namespace. Refuse unknown Windows case folding. */ +export function windowsArchivePathValidator(): (name: string, directory: boolean) => void { + const seen = new Map(); + return (name, directory) => { + const relative = directory && name.endsWith("/") ? name.slice(0, -1) : name; + const parts = relative.split("/"); + if (relative.length > 8192 || parts.length > 64 || parts.some(part => !part || part === "." || part === ".." + || /[^\x20-\x7e]|[<>:"\\|?*]/.test(part) || /[. ]$/.test(part) + || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)|^(?:conin\$|conout\$)$/i.test(part))) { + throw new Error("artifact_windows_path_unverified"); + } + for (let i = 0; i < parts.length; i++) { + const spelling = parts.slice(0, i + 1).join("/"), key = spelling.toLowerCase(); + const isDirectory = directory || i < parts.length - 1, explicit = i === parts.length - 1; + const prior = seen.get(key); + if (prior && (prior.spelling !== spelling || prior.directory !== isDirectory || prior.explicit && explicit)) { + throw new Error("artifact_windows_path_collision"); + } + seen.set(key, { spelling, directory: isDirectory, explicit: explicit || !!prior?.explicit }); + } + }; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index 71e40bb351..9a3b1631f2 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -222,6 +222,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/cli-update-job.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-lease.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-plan.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-windows-layout.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-windows-paths.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `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) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 122a743bdb..9b2767f469 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -89,6 +89,28 @@ 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 archive layout + +The independent `src/codex/cli-update-windows-layout.ts` verifies Windows x64 archives and +returns only prefix-relative expected files, sizes and content digests; `applicable` and +`mutationAuthority` remain false. No plan, selection, installer, lease or apply consumer uses it. +The main root is `node_modules/@openai/codex`; the explicit sibling backing is +`node_modules/@openai/codex-win32-x64`, with `vendor/x86_64-pc-windows-msvc/bin/codex.exe`. +Exact direct-version and npm-alias references retain the archive verifier's metadata/linkage rules. +An opt-in Windows archive mode uses `src/codex/cli-update-windows-paths.ts` to reject ASCII case +and file/directory collisions, ADS, device names, trailing dots/spaces and unknown non-ASCII paths, +including directory records. Embedded node_modules files cannot shadow the modeled backing. +The full native member still streams into its digest; at most 4 KiB from that same stream is retained +for the AMD64 PE32+ header check. This does not prove a complete valid image, signature or execution. +Native members remain unbuffered and Windows does not use POSIX executable-bit admission. +The exact codex/codex.cmd/codex.ps1 size/hash expectations model npm cmd-shim 8.0.0's node-shebang +output; independent captured output is in tests/fixtures/codex-windows-npm-shims.json. Other shim +grammars remain unsupported. All archive auxiliary files enter the expected set and digest, and +readback comparison requires the complete set. A future consumer must separately prove live held +file identities, package lookup, selected Node/shim and write authority; a matching DTO grants none. +Coverage: `tests/codex-integration/codex-cli-update-windows-layout.test.ts` uses only synthetic +archives and captured shim bytes, with no npm installation or native executable invocation. + ## 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 1a0722aa01..4b042bf999 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -182,6 +182,8 @@ "src/codex/cli-update-inspection-context.ts", "bin/ocx.mjs", "src/codex/cli-update-artifacts.ts", + "src/codex/cli-update-windows-layout.ts", + "src/codex/cli-update-windows-paths.ts", "src/codex/cli-update-files.ts", "src/codex/cli-update-lease.ts", "src/codex/app-server-processes.ts", diff --git a/tests/codex-integration/codex-cli-update-windows-layout.test.ts b/tests/codex-integration/codex-cli-update-windows-layout.test.ts new file mode 100644 index 0000000000..abbc6f9346 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-windows-layout.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { gunzipSync, gzipSync } from "node:zlib"; +import { fixturePath } from "../helpers/repo-root"; +import { fixtureArtifact, fixtureArtifactSet, fixtureTar } from "../helpers/codex-cli-apply-artifact"; +import { verifiedCodexCliContent, verifyCodexCliArchive, type CodexCliArtifactSet } from "../../src/codex/cli-update-artifacts"; +import { matchesWindowsCodexCliLayout, verifyWindowsCodexCliLayout } from "../../src/codex/cli-update-windows-layout"; + +const MAIN = "node_modules/@openai/codex", BACKING = "node_modules/@openai/codex-win32-x64"; +const NATIVE = "vendor/x86_64-pc-windows-msvc/bin/codex.exe", PACKAGE = "@openai/codex-win32-x64"; +const fileDigest = (bytes: string | Buffer) => createHash("sha256").update("codex-cli-observed-file-v1\0").update(bytes).digest("hex"); +function pe(machine = 0x8664, size = 65539): Buffer { + const bytes = Buffer.alloc(size, 0x61); + bytes.write("MZ"); bytes.writeUInt32LE(128, 60); bytes.write("PE\0\0", 128); + bytes.writeUInt16LE(machine, 132); bytes.writeUInt16LE(1, 134); + bytes.writeUInt16LE(240, 148); bytes.writeUInt16LE(2, 150); bytes.writeUInt16LE(0x20b, 152); + return bytes; +} +function fixture(alias = true) { + const version = "1.2.3", backingVersion = alias ? `${version}-win32-x64` : version; + const backingName = alias ? "@openai/codex" : PACKAGE; + const mainFiles: Record = { + "package.json": JSON.stringify({ name: "@openai/codex", version, bin: { codex: "bin/codex.js" }, + optionalDependencies: { [PACKAGE]: alias ? `npm:@openai/codex@${backingVersion}` : version } }), + "bin/codex.js": "#!/usr/bin/env node\n// Synthetic entrypoint; never executed.\n", + "README.md": "main auxiliary\n", + }; + const backingFiles: Record = { + "package.json": JSON.stringify({ name: backingName, version: backingVersion, os: ["win32"], cpu: ["x64"] }), + [NATIVE]: pe(), "vendor/auxiliary.txt": "backing auxiliary\n", + }; + const build = () => { + const main = fixtureTar(mainFiles), backing = fixtureTar(backingFiles); + const set: CodexCliArtifactSet = { platform: "win32", arch: "x64", main: fixtureArtifact(main, version), + backing: fixtureArtifact(backing, backingVersion, PACKAGE, backingName) }; + return { main, backing, set }; + }; + return { mainFiles, backingFiles, build }; +} +const verify = (f: ReturnType) => { + const input = f.build(); return verifyWindowsCodexCliLayout(input.set, input.main, input.backing); +}; +function editManifest(files: Record, change: (manifest: Record) => void) { + const value = JSON.parse(String(files["package.json"])); change(value); files["package.json"] = JSON.stringify(value); +} +function rewriteTar(bytes: Buffer, change: (header: Buffer, name: string) => void): Buffer { + const tar = gunzipSync(bytes); + for (let offset = 0; offset < tar.length && tar[offset];) { + const header = tar.subarray(offset, offset + 512); + const name = header.subarray(0, 100).toString().replace(/\0.*$/s, ""); + const size = parseInt(header.subarray(124, 136).toString(), 8); + change(header, name); + header.fill(32, 148, 156); + header.write(header.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(7, "0") + "\0", 148, 8); + offset += 512 + Math.ceil(size / 512) * 512; + } + return gzipSync(tar); +} + +describe("independent Windows x64 artifact layout", () => { + test.each([true, false])("verified %s alias backing yields only relative whole-file expectations", async alias => { + const f = fixture(alias), layout = await verify(f); + expect(layout).toMatchObject({ applicable: false, mutationAuthority: false, platform: "win32", arch: "x64", + mainRoot: MAIN, backingRoot: BACKING, nativePath: `${BACKING}/${NATIVE}`, nativeHeader: "PE32+-AMD64" }); + expect(layout.files).toHaveLength(9); + expect(layout.files.find(file => file.relativePath === `${BACKING}/${NATIVE}`)).toMatchObject({ + size: 65539, digest: fileDigest(f.backingFiles[NATIVE]!), + }); + expect(Object.isFrozen(layout)).toBe(true); expect(Object.isFrozen(layout.files)).toBe(true); + expect(layout.files.every(file => Object.isFrozen(file) && !file.relativePath.includes(":"))).toBe(true); + expect(matchesWindowsCodexCliLayout(layout, [...layout.files].reverse())).toBe(true); + }); + test("three exact npm shim expectations match independently captured cmd-shim 8 output", async () => { + const capture = JSON.parse(readFileSync(fixturePath("codex-windows-npm-shims.json"), "utf8")); + const layout = await verify(fixture()); + expect(capture.source).toContain("cmd-shim@8.0.0"); + for (const name of ["codex", "codex.cmd", "codex.ps1"]) { + const bytes = Buffer.from(capture.files[name]); + expect(layout.files.find(file => file.relativePath === name)).toEqual({ relativePath: name, size: bytes.length, digest: fileDigest(bytes) }); + const wrong = layout.files.map(file => file.relativePath === name ? { ...file, + size: bytes.length + 1, digest: fileDigest(Buffer.concat([bytes, Buffer.from("x")])) } : file); + expect(matchesWindowsCodexCliLayout(layout, wrong)).toBe(false); + } + }); + test("the full verified stream binds native hash and retains at most 4 KiB, with no second file read", async () => { + const f = fixture(), input = f.build(); + const files = await verifyCodexCliArchive(input.backing, input.set.backing, { windowsX64: true }); + expect(files.get(NATIVE)!.bytes).toBeNull(); + expect(files.get(NATIVE)!.nativePrefix).toEqual((f.backingFiles[NATIVE] as Buffer).subarray(0, 4096)); + expect(files.get(NATIVE)!.digest).toBe(fileDigest(f.backingFiles[NATIVE]!)); + expect([...files].filter(([, file]) => file.nativePrefix !== undefined).map(([name]) => name)).toEqual([NATIVE]); + const plain = await verifyCodexCliArchive(input.backing, input.set.backing); + expect(plain.get(NATIVE)!.nativePrefix).toBeUndefined(); + }); + test.each(["arm64", "x86"])("other requested architecture %s is not admitted", async arch => { + const f = fixture().build(); + await expect(verifyWindowsCodexCliLayout({ ...f.set, arch }, f.main, f.backing)).rejects.toThrow("artifact_platform_unverified"); + }); + test.each(["missing", "arm64", "x86", "not-pe", "header-outside-prefix", "dll", "pe32", "truncated"])("native %s refuses", async kind => { + const f = fixture(); + if (kind === "missing") { delete f.backingFiles[NATIVE]; f.backingFiles[NATIVE.replace(/\.exe$/, "")] = pe(); } + else { + const bytes = pe(kind === "arm64" ? 0xaa64 : kind === "x86" ? 0x14c : 0x8664); + if (kind === "not-pe") bytes.write("??"); + if (kind === "header-outside-prefix") bytes.writeUInt32LE(4096, 60); + if (kind === "dll") bytes.writeUInt16LE(0x2002, 150); + if (kind === "pe32") bytes.writeUInt16LE(0x10b, 152); + f.backingFiles[NATIVE] = kind === "truncated" ? bytes.subarray(0, 150) : bytes; + } + await expect(verify(f)).rejects.toThrow("artifact_windows_native_unverified"); + }); + test.each(["wrong-bin", "extra-bin", "missing-js", "wrong-shebang", "wrong-link", "nested-package", "cpu", "exports"])("linkage %s refuses", async kind => { + const f = fixture(); + if (kind === "wrong-bin") editManifest(f.mainFiles, pkg => pkg.bin.codex = "evil.js"); + if (kind === "extra-bin") editManifest(f.mainFiles, pkg => pkg.bin.extra = "evil.js"); + if (kind === "missing-js") delete f.mainFiles["bin/codex.js"]; + if (kind === "wrong-shebang") f.mainFiles["bin/codex.js"] = "#!/usr/bin/env node --eval evil\n"; + if (kind === "wrong-link") editManifest(f.mainFiles, pkg => pkg.optionalDependencies[PACKAGE] = "latest"); + if (kind === "nested-package") f.mainFiles[`bin/node_modules/${PACKAGE}/package.json`] = "{}"; + if (kind === "cpu") editManifest(f.backingFiles, pkg => pkg.cpu = ["arm64"]); + if (kind === "exports") editManifest(f.backingFiles, pkg => pkg.exports = {}); + await expect(verify(f)).rejects.toThrow(); + }); + test.each(["README.MD", "BIN/extra", "bin", "name:stream", "NUL.txt", "COM1.log", "AUX/file", "CONIN$", "dir./file", + "trailing /file", "name?", "non-ascii-한글", "../escape", "bad\\path"])("Windows ambiguous or unsafe member %s refuses", async name => { + const f = fixture(); f.mainFiles[name] = "x"; + await expect(verify(f)).rejects.toThrow(); + }); + test("empty directory records receive the same Windows path validation", async () => { + const f = fixture(); f.mainFiles["NUL"] = ""; + const input = f.build(), main = rewriteTar(input.main, (header, name) => { if (name === "package/NUL") header.write("5", 156); }); + await expect(verifyWindowsCodexCliLayout({ ...input.set, main: fixtureArtifact(main, "1.2.3") }, main, input.backing)) + .rejects.toThrow("artifact_windows_path_unverified"); + }); + test("Windows ignores POSIX executable bits without changing the POSIX content validator", async () => { + const f = fixture().build(), clearMode = (header: Buffer) => { header.write("0000644\0", 100, 8); }; + const main = rewriteTar(f.main, clearMode), backing = rewriteTar(f.backing, clearMode); + const set = { ...f.set, main: fixtureArtifact(main, "1.2.3"), backing: fixtureArtifact(backing, "1.2.3-win32-x64", PACKAGE) }; + expect((await verifyWindowsCodexCliLayout(set, main, backing)).nativeHeader).toBe("PE32+-AMD64"); + const posix = fixtureArtifactSet(); + const posixMain = await verifyCodexCliArchive(posix.main, posix.artifacts.main); + expect(verifiedCodexCliContent(posix.artifacts, posixMain, + await verifyCodexCliArchive(posix.backing, posix.artifacts.backing))).toMatch(/^[a-f0-9]{64}$/); + const notExecutable = rewriteTar(posix.backing, clearMode); + const files = await verifyCodexCliArchive(notExecutable, fixtureArtifact(notExecutable, posix.artifacts.backing.version, posix.artifacts.backing.installName)); + expect(() => verifiedCodexCliContent(posix.artifacts, posixMain, files)).toThrow(); + }); + test("auxiliary content, missing/extra files and duplicate readback paths cannot share a full match", async () => { + const f = fixture(), before = await verify(f); + f.backingFiles["vendor/auxiliary.txt"] = "changed auxiliary\n"; + const after = await verify(f); + expect(before.contentDigest).not.toBe(after.contentDigest); + expect(matchesWindowsCodexCliLayout(before, after.files)).toBe(false); + expect(matchesWindowsCodexCliLayout(before, before.files.slice(1))).toBe(false); + expect(matchesWindowsCodexCliLayout(before, [...before.files, before.files[0]!])).toBe(false); + expect(matchesWindowsCodexCliLayout(before, [before.files[0]!, ...before.files.slice(0, -1)])).toBe(false); + }); + test("archive corruption and non-registry metadata refuse before any layout is returned", async () => { + const input = fixture().build(), corrupted = Buffer.from(input.backing); corrupted[corrupted.length - 1] ^= 1; + await expect(verifyWindowsCodexCliLayout(input.set, input.main, corrupted)).rejects.toThrow(); + const foreign = { ...input.set, main: { ...input.set.main, tarball: "https://example.invalid/archive.tgz" } }; + await expect(verifyWindowsCodexCliLayout(foreign, input.main, input.backing)).rejects.toThrow("artifact_metadata_unverified"); + }); +}); diff --git a/tests/fixtures/codex-windows-npm-shims.json b/tests/fixtures/codex-windows-npm-shims.json new file mode 100644 index 0000000000..8736843a8d --- /dev/null +++ b/tests/fixtures/codex-windows-npm-shims.json @@ -0,0 +1,9 @@ +{ + "source": "npm/cmd-shim@8.0.0 (ISC); Windows global-prefix node shebang; captured with filesystem writes intercepted", + "sourceSha256": "4e5f3fcf05a00ece29888768a5f91b52d15ef736cfefab452ca77b66aa71b234", + "files": { + "codex.ps1": "#!/usr/bin/env pwsh\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\n$exe=\"\"\nif ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {\n # Fix case when both the Windows and Linux builds of Node\n # are installed in the same directory\n $exe=\".exe\"\n}\n$ret=0\nif (Test-Path \"$basedir/node$exe\") {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & \"$basedir/node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n } else {\n & \"$basedir/node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n }\n $ret=$LASTEXITCODE\n} else {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & \"node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n } else {\n & \"node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n }\n $ret=$LASTEXITCODE\n}\nexit $ret\n", + "codex.cmd": "@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n\r\nIF EXIST \"%dp0%\\node.exe\" (\r\n SET \"_prog=%dp0%\\node.exe\"\r\n) ELSE (\r\n SET \"_prog=node\"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & \"%_prog%\" \"%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js\" %*\r\n", + "codex": "#!/bin/sh\nbasedir=$(dirname \"$(echo \"$0\" | sed -e 's,\\\\,/,g')\")\n\ncase `uname` in\n *CYGWIN*|*MINGW*|*MSYS*)\n if command -v cygpath > /dev/null 2>&1; then\n basedir=`cygpath -w \"$basedir\"`\n fi\n ;;\nesac\n\nif [ -x \"$basedir/node\" ]; then\n exec \"$basedir/node\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" \"$@\"\nelse \n exec node \"$basedir/node_modules/@openai/codex/bin/codex.js\" \"$@\"\nfi\n" + } +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 332e41221a..1f7f4c88a2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "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", + "codex-cli-update-windows-layout.test.ts": "codex-integration", "bridge-terminal-refusal.test.ts": "adapters", "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", From 0b474d57b907e85c96d3f7a2d742c3af306b81ac Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 19:50:12 +0900 Subject: [PATCH 05/31] test(codex): bind shim fixture to verified npm artifact --- structure/codex-cli-update.md | 7 +++-- .../codex-cli-update-windows-layout.test.ts | 2 ++ tests/fixtures/codex-windows-npm-shims.json | 31 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 9b2767f469..c57a2fe72b 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -104,8 +104,11 @@ The full native member still streams into its digest; at most 4 KiB from that sa for the AMD64 PE32+ header check. This does not prove a complete valid image, signature or execution. Native members remain unbuffered and Windows does not use POSIX executable-bit admission. The exact codex/codex.cmd/codex.ps1 size/hash expectations model npm cmd-shim 8.0.0's node-shebang -output; independent captured output is in tests/fixtures/codex-windows-npm-shims.json. Other shim -grammars remain unsupported. All archive auxiliary files enter the expected set and digest, and +output; tests/fixtures/codex-windows-npm-shims.json records the official npm tarball URL/integrity, +raw archive source-file hashes and actual exported-function generation with intercepted filesystem +calls. All three generated strings matched the retained bytes. No npm installation or lifecycle +script ran; the offline fixture test checks byte consistency, not registry provenance afresh. +Other shim grammars remain unsupported. All archive auxiliary files enter the expected set and digest, and readback comparison requires the complete set. A future consumer must separately prove live held file identities, package lookup, selected Node/shim and write authority; a matching DTO grants none. Coverage: `tests/codex-integration/codex-cli-update-windows-layout.test.ts` uses only synthetic diff --git a/tests/codex-integration/codex-cli-update-windows-layout.test.ts b/tests/codex-integration/codex-cli-update-windows-layout.test.ts index abbc6f9346..a65062ebf3 100644 --- a/tests/codex-integration/codex-cli-update-windows-layout.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-layout.test.ts @@ -72,6 +72,8 @@ describe("independent Windows x64 artifact layout", () => { expect(matchesWindowsCodexCliLayout(layout, [...layout.files].reverse())).toBe(true); }); test("three exact npm shim expectations match independently captured cmd-shim 8 output", async () => { + // Offline byte-contract regression only. The fixture records the separately verified + // registry artifact, raw source hashes and actual exported-function generation receipt. const capture = JSON.parse(readFileSync(fixturePath("codex-windows-npm-shims.json"), "utf8")); const layout = await verify(fixture()); expect(capture.source).toContain("cmd-shim@8.0.0"); diff --git a/tests/fixtures/codex-windows-npm-shims.json b/tests/fixtures/codex-windows-npm-shims.json index 8736843a8d..47b335cd29 100644 --- a/tests/fixtures/codex-windows-npm-shims.json +++ b/tests/fixtures/codex-windows-npm-shims.json @@ -1,9 +1,36 @@ { - "source": "npm/cmd-shim@8.0.0 (ISC); Windows global-prefix node shebang; captured with filesystem writes intercepted", - "sourceSha256": "4e5f3fcf05a00ece29888768a5f91b52d15ef736cfefab452ca77b66aa71b234", + "source": "npm/cmd-shim@8.0.0 (ISC); generated by the verified official package's exported cmdShim function", + "sourceSha256": "ec89ec47d22e7012336aa41cea1216b497aecd36f7802b30eebf3b8e6a298b12", "files": { "codex.ps1": "#!/usr/bin/env pwsh\n$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\n$exe=\"\"\nif ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {\n # Fix case when both the Windows and Linux builds of Node\n # are installed in the same directory\n $exe=\".exe\"\n}\n$ret=0\nif (Test-Path \"$basedir/node$exe\") {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & \"$basedir/node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n } else {\n & \"$basedir/node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n }\n $ret=$LASTEXITCODE\n} else {\n # Support pipeline input\n if ($MyInvocation.ExpectingInput) {\n $input | & \"node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n } else {\n & \"node$exe\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" $args\n }\n $ret=$LASTEXITCODE\n}\nexit $ret\n", "codex.cmd": "@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n\r\nIF EXIST \"%dp0%\\node.exe\" (\r\n SET \"_prog=%dp0%\\node.exe\"\r\n) ELSE (\r\n SET \"_prog=node\"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & \"%_prog%\" \"%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js\" %*\r\n", "codex": "#!/bin/sh\nbasedir=$(dirname \"$(echo \"$0\" | sed -e 's,\\\\,/,g')\")\n\ncase `uname` in\n *CYGWIN*|*MINGW*|*MSYS*)\n if command -v cygpath > /dev/null 2>&1; then\n basedir=`cygpath -w \"$basedir\"`\n fi\n ;;\nesac\n\nif [ -x \"$basedir/node\" ]; then\n exec \"$basedir/node\" \"$basedir/node_modules/@openai/codex/bin/codex.js\" \"$@\"\nelse \n exec node \"$basedir/node_modules/@openai/codex/bin/codex.js\" \"$@\"\nfi\n" + }, + "provenance": { + "metadataUrl": "https://registry.npmjs.org/cmd-shim/8.0.0", + "tarballUrl": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "tarballIntegrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "tarballBytes": 4589, + "tarballSha256": "4ce9f5ab6f0b5d360af270e0483063ec92329a1bf9970efc45a2c02f49d16f70", + "registryGitHead": "ea0f7bd72dfb64d203032073cefdbb9324adc261", + "sourceSha256Target": "package/lib/index.js raw archive member bytes (7706 bytes), without newline normalization", + "helperSource": { + "path": "package/lib/to-batch-syntax.js", + "bytes": 1558, + "sha256": "e39a03dac6e5e31c6c4bb58fab2c23e8aeeaacd53e0b8c63e742fe7f4ef476ec" + }, + "generation": { + "executed": true, + "runtime": "Bun 1.4.3", + "pathImplementation": "node:path.win32", + "from": "C:\\fixture\\node_modules\\@openai\\codex\\bin\\codex.js", + "to": "C:\\fixture\\codex", + "entrypointBytes": "#!/usr/bin/env node\n", + "method": "Loaded the two integrity-verified CommonJS sources; called the exported cmdShim function. fs/promises calls were intercepted and writeFile strings captured in memory.", + "comparison": "All three generated byte strings exactly matched these retained files and the production size/domain-hash expectations.", + "installationPerformed": false, + "lifecycleScriptsRun": false + }, + "verificationBoundary": "The offline fixture test checks retained output bytes; it does not fetch the package or re-establish registry provenance on each run." } } From 75745a638d2f26281c57300c7cded822b75f688e Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 10:51:19 +0900 Subject: [PATCH 06/31] feat(codex): implement owned Windows npm apply transactions --- .../src/content/docs/ko/reference/cli.md | 2 +- docs-site/src/content/docs/reference/cli.md | 7 +- .../src/content/docs/reference/cli/agents.md | 21 +- .../layout-windows-cli-update.json | 1 + src/codex/cli-update-apply.ts | 11 +- src/codex/cli-update-job-execution.ts | 6 +- src/codex/cli-update-job-store-windows.ts | 87 ++++- src/codex/cli-update-job.ts | 2 +- src/codex/cli-update-plan.ts | 11 +- src/codex/cli-update-windows-apply.ts | 205 ++++++++++ src/codex/cli-update-windows-observation.ts | 89 +++++ src/codex/cli-windows-plan-provenance.ts | 51 ++- src/codex/windows-cli-child.ts | 5 +- src/codex/windows-installation-files.ts | 4 + structure/INDEX.md | 4 +- structure/codex-cli-update.md | 65 +++- structure/manifest.json | 4 +- .../codex-cli-update-artifacts.test.ts | 11 +- .../codex-cli-update-windows-apply.test.ts | 362 ++++++++++++++++++ .../codex-cli-windows-plan.test.ts | 7 +- tests/fixtures/test-layout-expected.json | 1 + 21 files changed, 904 insertions(+), 52 deletions(-) create mode 100644 src/codex/cli-update-windows-apply.ts create mode 100644 src/codex/cli-update-windows-observation.ts create mode 100644 tests/codex-integration/codex-cli-update-windows-apply.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 3abe8fbd11..ccbee9d462 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. -`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows x64는 증명에 바인딩된 npm 설치를 관측하며, 검증된 ACL 경로와 관찰 한도 안의 공유 prefix에서 명시적 apply를 허용합니다. 무관한 패키지·shim의 바이트와 신원 보존을 확인하며, prefix 관찰 한도는 4,096항목·총 512 MiB·파일당 384 MiB입니다. 지원하지 않는 경로와 ARM64는 거절합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참조하세요. 명시적 경로 없이 증명에 바인딩된 런처 스냅샷이 식별한 선택 후보를 관측하며, 업데이트 권한이나 런타임 선택을 증명하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 9486f97540..f756fdd7d0 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -166,12 +166,13 @@ Codex or npm, control a running process, or write configuration/cache state. npm-global installation. It resolves `latest` through the official npm registry, validates the exact version and SHA-512 integrity, and refuses active sessions or incomplete process observation. Registry queries use isolated temporary npm configuration, cache and logs with best-effort cleanup. -No software is installed and no process is signalled. A supported Windows installation can produce a -deterministic read-only plan with `applicable: false` and `refusal: windows_apply_disabled`. +No software is installed and no process is signalled. Windows x64 apply requires a bounded npm +prefix with verified private write authority and preserves unrelated packages and shims; unsupported observed layouts return +`applicable: false` and `refusal: windows_apply_disabled`. PATH-only selection and OpenCodex autostart wrappers remain deferred. See [planning details](/reference/cli/agents/#read-only-codex-cli-update-plan). -For a reviewed POSIX non-shim npm-global plan, `ocx system codex-cli-update apply --plan-id --yes` +For a reviewed applicable npm-global plan, `ocx system codex-cli-update apply --plan-id --yes` performs the exact update and returns a durable job. `ocx system codex-cli-update status --json` reads its result; use a stable `--request-id` for a lost-response retry. See [confirmation and job states](/reference/cli/agents/#confirmed-posix-codex-cli-apply-and-job-status). diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 614a9ecf7f..1bda3ab4bd 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1198,25 +1198,32 @@ An applicable plan reports `applicable: true` and a deterministic `planId` bindi installation-root/content digests, installed version, channel and exact main/platform artifacts. The plan is never persisted. It refuses downgrades, already-current versions, active Codex sessions and incomplete process observation. Supported Windows metadata queries use the observed Node/npm under matching -held handles. A completed Windows read-only plan reports a deterministic ID, `applicable: false` -and `refusal: windows_apply_disabled`; active/unknown sessions still refuse earlier. Unsupported +held handles. Windows x64 can be applicable only for a bounded npm prefix with verified +write authority; other observed prefixes retain `windows_apply_disabled`. Active/unknown sessions still refuse earlier. Unsupported Windows layouts return `windows_inspection_deferred` before registry/process queries. A refusal exits 0: inspect `applicable` and `refusal`. The printed install argv is indicative; this -command installs nothing, changes no application state and never signals a session. Windows apply -remains disabled, and a plan ID alone is not installation authorization: apply requires separate explicit confirmation. +command installs nothing, changes no application state and never signals a session. +A plan ID alone is not installation authorization: apply requires separate explicit confirmation. -#### Confirmed POSIX Codex CLI apply and job status +#### Confirmed Codex CLI apply and job status ```bash ocx system codex-cli-update apply --plan-id <32-hex> --yes --request-id --json ocx system codex-cli-update status --json ``` -Apply supports only the selected, managed, non-shim npm-global CLI on Linux/macOS. It revalidates the +Apply supports the selected, managed, non-shim npm-global CLI on Linux/macOS and the restricted +Windows x64 npm layout. It revalidates the approved plan under an installation lease, verifies the exact main/platform archives, installs them offline with lifecycle scripts disabled, and checks installed files and the selected CLI's version. Existing Codex sessions are never stopped; active or unknown process observations refuse the update. -Windows apply, shim repair, native apps and other installation managers remain unsupported. +Windows supports shared prefixes with verified ACLs and no reparse paths. Observation includes +unrelated packages and shims, capped at 4,096 entries, 512 MiB total and 384 MiB per file. Their +bytes and identities must be preserved. Larger or unsafe prefixes, ARM64, shim repair, native apps +and other installation managers remain unsupported. Unknown child lifetime or incomplete handle cleanup retains staging and +the installation lease; do not retry by deleting its lock. Readback includes auxiliary files, +new/absent backing roots and npm's hidden lockfile. `not_applied` means scoped installation state +was observed unchanged, not that npm performed no I/O anywhere. Apply exits 0 only for an `applied` job; `not_applied`, `refused`, `ambiguous` and pending duplicate jobs exit 1, including with `--json`. Status reads the diff --git a/scripts/test-layout/layout-windows-cli-update.json b/scripts/test-layout/layout-windows-cli-update.json index b30ad659ef..b083c2646b 100644 --- a/scripts/test-layout/layout-windows-cli-update.json +++ b/scripts/test-layout/layout-windows-cli-update.json @@ -1,3 +1,4 @@ { + "codex-cli-update-windows-apply.test.ts": "codex-integration", "windows-codex-cli-job-store.test.ts": "windows" } diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index 026f3da21a..2c34bc6f4a 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -25,7 +25,7 @@ export interface CodexCliApplyResult { readonly verificationPid: number | null; readonly cleanupErrors: readonly CodexCliApplyCleanupError[]; } -export type CodexCliApplyCleanupError = "lease_release_unverified" | "lease_release_exception" | "staging_cleanup_failed"; +export type CodexCliApplyCleanupError = "lease_release_unverified" | "lease_release_exception" | "staging_cleanup_failed" | "child_cleanup_failed"; /** Every cleanup phase is attempted even if another fails. Preserve the * computed readback outcome and expose path-free cleanup uncertainty separately. */ @@ -40,6 +40,8 @@ export function cleanupCodexCliApply(lease: { release(): boolean } | null, stagi } interface VersionResult { readonly terminal: boolean; readonly ok: boolean; readonly pid: number | null } export interface CodexCliApplyDeps extends CodexCliUpdatePlanDeps { + /** Trusted native fault-injection seam; never supplied by a request DTO. */ + readonly startWindowsChild?: typeof import("./windows-cli-child").startWindowsCliChild; readonly signal?: AbortSignal; readonly temporaryRoot?: string; readonly fetchArtifact?: (artifact: CodexCliArtifact, signal?: AbortSignal) => Promise; @@ -132,7 +134,7 @@ function freshInspection(deps: CodexCliApplyDeps): CodexCliInstallProvenanceDeps return { ...inspection, selectedCommand: observeCodexCliSelectedCommand(inspection) }; } -/** POSIX non-shim only. No restart, kill, automatic retry or rollback. */ +/** POSIX non-shim transaction; Windows dispatches to its separately owned native transaction. */ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDeps = {}): Promise { let installedVersion: string | null = null; let targetVersion: string | null = null; @@ -147,7 +149,10 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep output = { outcome, reason, planId, installedVersion, targetVersion, installerExitCode: exitCode, leaseRetained: keepLease, verificationPid, cleanupErrors: [] }; try { if (!/^[0-9a-f]{32}$/.test(planId)) return result("refused", "invalid_plan_id"); - if ((deps.platform ?? process.platform) === "win32") return result("refused", "windows_inspection_deferred"); + if ((deps.platform ?? process.platform) === "win32") { + const { applyWindowsCodexCliUpdate } = await import("./cli-update-windows-apply"); + return applyWindowsCodexCliUpdate(planId, deps); + } if (deps.signal?.aborted) return result("refused", "cancelled"); const inspect = deps.inspect ?? inspectCodexCliInstall; const first = await inspect(freshInspection(deps)); diff --git a/src/codex/cli-update-job-execution.ts b/src/codex/cli-update-job-execution.ts index 90dea74ccd..a7c4351ff8 100644 --- a/src/codex/cli-update-job-execution.ts +++ b/src/codex/cli-update-job-execution.ts @@ -1,14 +1,14 @@ import type { CodexCliApplyResult } from "./cli-update-apply"; import type { CodexCliUpdateJobDto } from "./cli-update-job"; -export const CODEX_CLI_JOB_REASONS: ReadonlySet = new Set(["invalid_plan_id", "windows_inspection_deferred", "cancelled", "mutation_evidence_unverified", +export const CODEX_CLI_JOB_REASONS: ReadonlySet = new Set(["invalid_plan_id", "windows_inspection_deferred", "windows_apply_disabled", "cancelled", "mutation_evidence_unverified", "installation_prefix_changed", "installation_prefix_unsupported", "installation_lease_unavailable", "installation_busy", "plan_refused", "plan_changed", "target_artifacts_unverified", "installation_changed", "npm_unavailable", "blocked_process_state_unknown", "blocked_active_session", - "installer_state_unknown", "version_process_state_unknown", "version_readback_unverified", "verified_target", + "installer_state_unknown", "version_process_state_unknown", "child_cleanup_unverified", "version_readback_unverified", "verified_target", "original_install_unchanged", "installation_readback_unverified", "installer_or_readback_error", "preparation_failed", "job_result_unknown", "not_managed", "installed_version_unverified", "install_content_unverified", "target_unresolved", "already_current", "target_not_newer"]); -export const CODEX_CLI_JOB_CLEANUP_ERRORS: ReadonlySet = new Set(["lease_release_unverified", "lease_release_exception", "staging_cleanup_failed"]); +export const CODEX_CLI_JOB_CLEANUP_ERRORS: ReadonlySet = new Set(["lease_release_unverified", "lease_release_exception", "staging_cleanup_failed", "child_cleanup_failed"]); export class CodexCliJobStoreBusyError extends Error { constructor() { super("job_store_busy"); } diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index f94a1eaf70..e8f9b65d70 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -55,7 +55,7 @@ * empirical cross-user proof. */ import { randomUUID } from "node:crypto"; -import { closeSync, fsyncSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import type { Library, Symbols } from "bun:ffi"; import { getConfigDir } from "../config"; @@ -542,7 +542,7 @@ async function acquireLock(ctx: Ctx, prepared: Prepared): Promise { if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new CodexCliJobStoreBusyError(); throw error; } - const lockDir = keep(ctx, relOpen(ctx, prepared.jobdir, CODEX_CLI_JOB_STORE_LOCK, true, + const lockDir = keep(ctx, relOpen(ctx, prepared.jobdir, prepared.lock.slice(prepared.dir.length + 1), true, ACCESS_DIR_WRITE, SHARE_DIR)); const lockIdentity = inspect(ctx, lockDir, true); const ownerPath = join(prepared.lock, "owner.json"); @@ -601,6 +601,89 @@ export interface WindowsJobStoreIo { writeStore(body: string): Promise; } +/** Separate installation authority. It does not use the job ledger or repair existing ACLs. + * Directory pins allow npm's writes but deny replacement of the prefix/ancestry. + * The serving account and kernel-equivalent principals remain inside the trust boundary. + */ +export async function openWindowsCliApplyAuthority(prefix: string, acquire: boolean) { + assertNotRealHomeUnderTest(prefix); + const { ffi, kernel, nt } = await loadNative(); + const ctx: Ctx = { ffi, kernel, nt, pins: new PinSet() }; + let held: HeldLock | undefined; + let transferred = false; + let closed = false; + try { + // The prefix is an ancestry pin, not a writable store directory. A write-access + // pin would prevent the ordinary deny-write installation snapshot from opening + // it, even though npm's child-file writes do not require that access on our pin. + const chain = pinChain(ctx, prefix, ACCESS_READ); + if (chain.length < 2) throw unsafe(); + const prefixDepth = chain.length, prefixPin = chain.at(-1)!; + let parent = prefixPin.handle, path = prefix; + for (const component of ["node_modules", "@openai"]) { + path = join(path, component); + const handle = relOpen(ctx, parent, component, true, ACCESS_READ, SHARE_DIR); + chain.push({ path, handle, inspection: inspect(ctx, handle, true) }); + parent = handle; + } + const entries: WindowsAclEntry[] = chain.map((entry, index) => ({ path: entry.path, directory: true, + policy: index >= prefixDepth - 2 ? "write-private" : "trusted-ancestry" })); + const verifyHeld = async () => { + for (const entry of chain) if (!sameIdentity(entry.inspection, inspect(ctx, entry.handle, true))) throw unsafe(); + await verifyAcl(entries); + if (held && (!sameIdentity(held.dirIdentity, inspect(ctx, held.dir, true)) + || !sameIdentity(held.ownerIdentity, inspect(ctx, held.owner, false)) + || !readAll(ctx, held.owner, Buffer.byteLength(held.record)).equals(Buffer.from(held.record)))) throw unsafe(); + }; + const verify = async () => { if (closed) throw unsafe(); await verifyHeld(); if (closed) throw unsafe(); }; + await verify(); + const lock = join(prefix, ".ocx-codex-cli-apply.lock"); + if (acquire) held = await acquireLock(ctx, { config: prefix, dir: prefix, file: "", lock, + jobdir: prefixPin.handle }); + let stage: { path: string; handle: bigint; identity: StoreInspection } | undefined; + transferred = true; + return { + lock, + verify, + async createStaging(): Promise { + if (closed || !held || stage) throw unsafe(); + await verify(); + const path = join(lock, "stage"); + mkdirSync(path); + const handle = relOpen(ctx, held.dir, "stage", true, ACCESS_READ, SHARE_DIR); + stage = { path, handle, identity: inspect(ctx, handle, true) }; + await hardenSecretDirAsync(path, { required: true }); + await verifyAcl([{ path, directory: true, policy: "private" }]); + if (closed) throw unsafe(); + return path; + }, + async removeStaging(): Promise { + if (closed || !stage) throw unsafe(); + await verify(); + if (!sameIdentity(stage.identity, inspect(ctx, stage.handle, true))) throw unsafe(); + await verifyAcl([{ path: stage.path, directory: true, policy: "private" }]); + if (closed) throw unsafe(); + ctx.pins.release(stage.handle, kernel); + // Ancestors remain pinned and all writers are within the verified trust boundary. + rmSync(stage.path, { recursive: true }); + stage = undefined; + }, + async release(): Promise { + if (closed) return false; + closed = true; + try { await verifyHeld(); if (held) releaseLock(ctx, held); } + finally { ctx.pins.closeAll(kernel); } + return !acquire || !existsSync(lock); + }, + }; + } finally { if (!transferred) ctx.pins.closeAll(kernel); } +} + +export async function windowsCliApplyPrefixSupported(prefix: string): Promise { + try { const guard = await openWindowsCliApplyAuthority(prefix, false); return await guard.release(); } + catch { return false; } +} + /** * Read-only store access outside the mutex, for status. Returns null when the * store tree or ledger file does not exist. diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts index 5856fc6452..6e811b917f 100644 --- a/src/codex/cli-update-job.ts +++ b/src/codex/cli-update-job.ts @@ -237,7 +237,7 @@ export function projectCodexCliUpdateJobLifecycle(job: LifecycleInput): Lifecycl error === "lease_release_unverified" || error === "lease_release_exception"); const retainedWithEvidence = job.leaseRetained && !leaseReleaseUnconfirmed && (job.reason === "installer_state_unknown" || job.reason === "version_process_state_unknown" - || job.reason === "installer_or_readback_error"); + || job.reason === "installer_or_readback_error" || job.reason === "child_cleanup_unverified"); const childUnknown = ownerUnknown || job.reason === "job_result_unknown" || job.reason === "installer_state_unknown" || job.reason === "version_process_state_unknown" || (job.reason === "installer_or_readback_error" && job.leaseRetained); diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index c30e50f2c3..d3f95a615f 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -10,6 +10,8 @@ import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch- import { CODEX_CLI_REGISTRY, isSha512Integrity, codexCliArtifactSet, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; export { CODEX_CLI_REGISTRY } from "./cli-update-artifacts"; import { windowsCodexCliPlanEvidence, withWindowsCodexCliPlanToolchain } from "./cli-windows-plan-provenance"; +import { windowsCliApplyPrefixSupported } from "./cli-update-job-store-windows"; +import { windowsInstallNamespaceSupported } from "./cli-update-windows-observation"; import { inspectCodexCliInstall, type CodexCliInstallKind, @@ -579,12 +581,17 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} return refusedPlan("blocked_active_session", report, channel, target, Object.freeze({ state: "active" as const, matches })); } + const applicable = platform !== "win32" || (!!windowsEvidence && windowsEvidence.arch === "x64" + && target.artifacts?.platform === "win32" && target.artifacts.arch === "x64" + && process.platform === "win32" && process.arch === "x64" + && windowsInstallNamespaceSupported(windowsEvidence.prefix) + && await windowsCliApplyPrefixSupported(windowsEvidence.prefix)); return Object.freeze({ schemaVersion: CODEX_CLI_UPDATE_SCHEMA_VERSION, package: CODEX_CLI_PACKAGE, channel, - applicable: platform !== "win32", - refusal: platform === "win32" ? "windows_apply_disabled" : null, + applicable, + refusal: applicable ? null : "windows_apply_disabled", planId: codexCliUpdatePlanId({ platform, provenance: report.provenance, diff --git a/src/codex/cli-update-windows-apply.ts b/src/codex/cli-update-windows-apply.ts new file mode 100644 index 0000000000..37905f81dc --- /dev/null +++ b/src/codex/cli-update-windows-apply.ts @@ -0,0 +1,205 @@ +import { writeFileSync } from "node:fs"; +import { win32 } from "node:path"; +import type { CodexCliApplyDeps, CodexCliApplyResult, CodexCliApplyCleanupError } from "./cli-update-apply"; +import { createCodexCliUpdatePlan, createNpmConfigIsolation } from "./cli-update-plan"; +import { inspectCodexCliInstall, observeCodexCliSelectedCommand } from "./cli-install-provenance"; +import { holdWindowsCodexCliPlanToolchain, windowsCodexCliPlanEvidence, windowsCodexCliSelectionMatches } from "./cli-windows-plan-provenance"; +import { openWindowsCliApplyAuthority } from "./cli-update-job-store-windows"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../lib/windows-secret-acl"; +import { verifyWindowsCodexCliLayout, matchesWindowsCodexCliLayout } from "./cli-update-windows-layout"; +import { observeWindowsInstall, sameWindowsInstall, sameWindowsPreservedEntries, windowsCodexOwnedPath, + type WindowsInstallSnapshot } from "./cli-update-windows-observation"; +import { scanCodexSessionProcesses } from "./app-server-processes"; +import { startWindowsCliChild, cleanupUnstartedWindowsChild, type WindowsCliChild, + type WindowsCliChildInvocation, type WindowsCliChildResult } from "./windows-cli-child"; + +type Authority = Awaited>; +type Toolchain = NonNullable>>; +interface Owner { authority: Authority; staging: string; toolchain: Toolchain | null; child?: WindowsCliChild } +// Strong owners survive an uncertain native wait and the HTTP/CLI caller returning. +// Never collect these as ordinary cleanup, infer terminal from a PID, or start a retry. +const retained = new Set(); + +function npmArgs(args: readonly string[], isolation: ReturnType) { + return [...args, "--registry=https://registry.npmjs.org", `--userconfig=${isolation.npmrc}`, + `--globalconfig=${isolation.globalNpmrc}`, `--cache=${isolation.cache}`]; +} +function expectedDirectories(files: readonly { relativePath: string }[], snapshot: WindowsInstallSnapshot): boolean { + const expected = new Set(); + for (const file of files) { + const parts = file.relativePath.split("/"); + for (let i = 3; i < parts.length; i++) expected.add(parts.slice(0, i).join("/")); + } + const directories = snapshot.directories.filter(dir => windowsCodexOwnedPath(dir.split(":")[0]!)); + return directories.length === expected.size && [...expected].every(path => + directories.some(directory => directory.startsWith(path + ":"))); +} + +/** Explicit apply only. Shared prefix entries are bounded preservation obligations; ARM64 refuses. + * Outcomes describe the scoped installation's observed state, never filesystem-wide no-I/O. + * The existing admission layer owns confirmed/planId and durable result persistence. + */ +export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliApplyDeps): Promise { + let owner: Owner | undefined, uncertain = false, cleanupPending = false, installerStarted = false; + let installedVersion: string | null = null, targetVersion: string | null = null, exitCode: number | null = null; + let verificationPid: number | null = null, output: CodexCliApplyResult | undefined; + const finish = (outcome: CodexCliApplyResult["outcome"], reason: string): CodexCliApplyResult => output = { + outcome, reason, planId, installedVersion, targetVersion, installerExitCode: exitCode, + leaseRetained: uncertain || cleanupPending, verificationPid, cleanupErrors: cleanupPending ? ["child_cleanup_failed"] : [], + }; + const inspection = () => { + const input = { ...deps.inspectionDeps, platform: "win32" as const, observeInstallContent: true }; + return { ...input, selectedCommand: observeCodexCliSelectedCommand(input) }; + }; + const inspect = () => (deps.inspect ?? inspectCodexCliInstall)(inspection()); + const plan = () => createCodexCliUpdatePlan({ ...deps, platform: "win32", inspectionDeps: inspection() }); + const run = async (invocation: WindowsCliChildInvocation, beforeStart?: () => void): Promise => { + if (!owner) throw new Error("ownership_required"); + if (owner.child) throw new Error("child_cleanup_pending"); + await owner.authority.verify(); + beforeStart?.(); + uncertain = true; // Adopt every resource before even a suspended child is created. + const child = owner.child = (deps.startWindowsChild ?? startWindowsCliChild)(invocation); + if (!child.process) { + const cleaned = await cleanupUnstartedWindowsChild(child); + uncertain = false; cleanupPending = !cleaned; + if (cleaned) owner.child = undefined; + return { terminal: true, cleanupComplete: cleaned, exitCode: null, stdout: "", failed: true, overflow: false, reason: child.setupFailure }; + } + const result = await child.wait(); + uncertain = !result.terminal; + cleanupPending = result.terminal && !result.cleanupComplete; + if (result.terminal && result.cleanupComplete) owner.child = undefined; + return result; + }; + try { + if (!/^[0-9a-f]{32}$/.test(planId)) return finish("refused", "invalid_plan_id"); + if (process.platform !== "win32" || process.arch !== "x64") return finish("refused", "windows_inspection_deferred"); + if (deps.signal?.aborted) return finish("refused", "cancelled"); + const first = await inspect(), evidence = windowsCodexCliPlanEvidence(first); + installedVersion = first.packageVersion; + if (!first.managed || !evidence || evidence.arch !== "x64") return finish("refused", "mutation_evidence_unverified"); + const initialPlan = await plan(); targetVersion = initialPlan.targetVersion; + if (!initialPlan.applicable) return finish("refused", initialPlan.refusal ?? "plan_refused"); + if (initialPlan.planId !== planId) return finish("refused", "plan_changed"); + const artifacts = initialPlan.targetArtifacts; + if (!artifacts || artifacts.platform !== "win32" || artifacts.arch !== "x64") return finish("refused", "target_artifacts_unverified"); + let authority: Authority; + try { authority = await openWindowsCliApplyAuthority(evidence.prefix, true); } + catch (error) { return finish("refused", error instanceof Error && error.message === "job_store_busy" + ? "installation_busy" : "installation_prefix_unsupported"); } + owner = { authority, staging: win32.join(authority.lock, "stage"), toolchain: null }; + retained.add(owner); + await authority.createStaging(); + owner.toolchain = await holdWindowsCodexCliPlanToolchain(first); + if (!owner.toolchain) return finish("refused", "npm_unavailable"); + const isolation = createNpmConfigIsolation(owner.staging, deps.inspectionDeps?.env); + // Include Windows temp/profile names: no npm or version cache may fall back to a live home. + const env = { ...isolation.env, HOME: owner.staging, USERPROFILE: owner.staging, TEMP: owner.staging, + TMP: owner.staging, TMPDIR: owner.staging, APPDATA: owner.staging, LOCALAPPDATA: owner.staging, CODEX_HOME: owner.staging }; + const invoke = (args: readonly string[], policy: WindowsCliChildInvocation["policy"], beforeStart?: () => void) => run({ + executable: evidence.node, args: [evidence.npmCli, ...npmArgs(args, isolation)], cwd: isolation.dir, env, policy, + }, beforeStart); + const archives: string[] = []; + for (const [index, artifact] of [artifacts.main, artifacts.backing].entries()) { + if (deps.signal?.aborted) return finish("refused", "cancelled"); + let path: string; + if (deps.fetchArtifact) { + path = win32.join(owner.staging, `${index}.tgz`); + writeFileSync(path, await deps.fetchArtifact(artifact, deps.signal), { flag: "wx" }); + } else { + const packed = await invoke(["pack", `${artifact.name}@${artifact.version}`, "--json", "--ignore-scripts", + "--pack-destination", owner.staging, "--fetch-retries=0", "--fetch-timeout=120000"], "pack"); + if (!packed.terminal) return finish("ambiguous", "installer_state_unknown"); + if (cleanupPending) return finish("ambiguous", "child_cleanup_unverified"); + if (packed.failed || packed.overflow || packed.exitCode !== 0) throw new Error("pack_failed"); + const names = JSON.parse(packed.stdout); + if (!Array.isArray(names) || names.length !== 1 || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(names[0]?.filename)) throw new Error("pack_unverified"); + path = win32.join(owner.staging, names[0].filename); + } + archives.push(path); + } + const layout = await verifyWindowsCodexCliLayout(artifacts, archives[0]!, archives[1]!); + const original = await observeWindowsInstall(evidence.prefix); + const fresh = await plan(); + if (!fresh.applicable) return finish("refused", fresh.refusal ?? "plan_refused"); + if (fresh.planId !== planId) return finish("refused", "plan_changed"); + const before = await inspect(), selected = windowsCodexCliPlanEvidence(before); + if (!selected || JSON.stringify(selected) !== JSON.stringify(evidence) + || before.installContentDigest !== initialPlan.installContentDigest + || !sameWindowsInstall(original, await observeWindowsInstall(evidence.prefix))) return finish("refused", "installation_changed"); + await owner.authority.verify(); + if (deps.signal?.aborted) return finish("refused", "cancelled"); + const beforeStart = () => { + if (deps.signal?.aborted) throw new Error("cancelled"); + if (!windowsCodexCliSelectionMatches(before, inspection())) throw new Error("installation_changed"); + const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); + if (scan.kind !== "observed") throw new Error("blocked_process_state_unknown"); + if (scan.processes.length) throw new Error("blocked_active_session"); + installerStarted = true; + }; + const args = ["install", "-g", "--prefix", evidence.prefix, "--offline", "--ignore-scripts", "--omit=optional", + "--no-audit", "--no-fund", "--package-lock=false", "--update-notifier=false", + `${artifacts.main.installName}@file:${archives[0]}`, `${artifacts.backing.installName}@file:${archives[1]}`]; + // The injectable installer seam is the existing trusted test dependency, never an HTTP input. + const installed = deps.install ? await (async () => { + await owner!.authority.verify(); beforeStart(); uncertain = true; + return deps.install!({ bin: evidence.node, args: [evidence.npmCli, ...npmArgs(args, isolation)], cwd: isolation.dir, env }); + })() : await invoke(args, "installer", beforeStart); + uncertain = !installed.terminal; exitCode = installed.exitCode; + if (uncertain) return finish("ambiguous", "installer_state_unknown"); + if (cleanupPending) return finish("ambiguous", "child_cleanup_unverified"); + await owner.authority.verify(); + const observed = await observeWindowsInstall(evidence.prefix); + const after = await inspect(), afterEvidence = windowsCodexCliPlanEvidence(after); + installedVersion = after.packageVersion; + const selectedAfter = after.managed && afterEvidence && afterEvidence.prefix === evidence.prefix + && windowsCodexCliSelectionMatches(first, inspection()) + && afterEvidence.launcher === evidence.launcher && afterEvidence.nodeEvidence === evidence.nodeEvidence + && afterEvidence.npmEvidence === evidence.npmEvidence; + if (!selectedAfter) return finish("ambiguous", "installation_readback_unverified"); + if (after.packageVersion === targetVersion && sameWindowsPreservedEntries(original, observed) + && expectedDirectories(layout.files, observed) + && afterEvidence.backingRoot === win32.join(evidence.prefix, layout.backingRoot) + && matchesWindowsCodexCliLayout(layout, observed.files.filter(file => windowsCodexOwnedPath(file.relativePath)))) { + const version = await run({ executable: evidence.node, args: [win32.join(evidence.prefix, layout.mainRoot, "bin/codex.js"), "--version"], + cwd: owner.staging, env, policy: "version" }); + verificationPid = version.terminal ? null : owner.child?.pid ?? null; + if (!version.terminal) return finish("ambiguous", "version_process_state_unknown"); + if (cleanupPending) return finish("ambiguous", "child_cleanup_unverified"); + if (version.failed || version.overflow || version.exitCode !== 0 || version.stdout.trim() !== `codex-cli ${targetVersion}`) { + return finish("ambiguous", "version_readback_unverified"); + } + const final = await inspect(); await owner.authority.verify(); + if (!windowsCodexCliSelectionMatches(after, inspection()) || final.installContentDigest !== after.installContentDigest + || !sameWindowsInstall(observed, await observeWindowsInstall(evidence.prefix))) { + return finish("ambiguous", "version_readback_unverified"); + } + return finish("applied", "verified_target"); + } + if (after.packageVersion === first.packageVersion && after.installContentDigest === first.installContentDigest + && sameWindowsInstall(original, observed)) return finish("not_applied", "original_install_unchanged"); + return finish("ambiguous", "installation_readback_unverified"); + } catch (error) { + if (!installerStarted && !uncertain && error instanceof Error + && ["cancelled", "installation_changed", "blocked_process_state_unknown", "blocked_active_session"].includes(error.message)) { + return finish("refused", error.message); + } + return finish(installerStarted || uncertain ? "ambiguous" : "refused", installerStarted || uncertain ? "installer_or_readback_error" : "preparation_failed"); + } finally { + if (owner && !uncertain && !cleanupPending) { + const errors: CodexCliApplyCleanupError[] = []; + try { + await flushWindowsSecretAclReapsBeforeRemoval(owner.staging); + await owner.authority.removeStaging(); + } catch { errors.push("staging_cleanup_failed"); } + try { if (owner.toolchain && !await owner.toolchain.release()) errors.push("lease_release_unverified"); } + catch { errors.push("lease_release_exception"); } + try { if (!await owner.authority.release()) errors.push("lease_release_unverified"); } + catch { errors.push("lease_release_exception"); } + if (!errors.length) retained.delete(owner); + if (output) Object.assign(output, { cleanupErrors: errors, leaseRetained: errors.length > 0 }); + } + if (output) { Object.freeze(output.cleanupErrors); Object.freeze(output); } + } +} diff --git a/src/codex/cli-update-windows-observation.ts b/src/codex/cli-update-windows-observation.ts new file mode 100644 index 0000000000..c984385044 --- /dev/null +++ b/src/codex/cli-update-windows-observation.ts @@ -0,0 +1,89 @@ +import { lstatSync, readdirSync } from "node:fs"; +import { win32 } from "node:path"; +import { inspectWindowsInstallationFiles } from "./windows-installation-files"; +import { verifyWindowsEntriesAclAsync, type WindowsAclEntry } from "../lib/windows-owner-acl"; +import { MAX_CODEX_MEMBER_BYTES, MAX_CODEX_UNPACKED_BYTES } from "./cli-update-artifacts"; +import { windowsArchivePathValidator } from "./cli-update-windows-paths"; +import type { WindowsCodexExpectedFile } from "./cli-update-windows-layout"; + +export interface WindowsInstallSnapshot { + readonly files: readonly (WindowsCodexExpectedFile & { readonly identity: string })[]; + readonly directories: readonly string[]; + readonly absent: readonly string[]; +} +const roots = ["codex", "codex.cmd", "codex.ps1", "node_modules/@openai/codex", + "node_modules/@openai/codex-win32-x64", "node_modules/.package-lock.json"]; +const key = (path: string) => path.replaceAll("\\", "/"); +export const windowsCodexOwnedPath = (relative: string): boolean => roots.slice(0, -1).some(root => + relative === root || relative.startsWith(root + "/")); + +/** Bound the existing shared prefix, never follow links outside it. Only our separately + * pinned mutex is excluded. Unrelated packages and shims are preservation obligations, + * not installer targets. Oversized or unsupported namespaces refuse before npm starts. + */ +function inventory(prefix: string) { + const paths: string[] = [], directories: string[] = [], acl: WindowsAclEntry[] = []; + const validate = windowsArchivePathValidator(); + let entries = 0, total = 0; + const walk = (relative: string) => { + if (++entries > 4096) throw new Error("installation_readback_limit"); + const path = win32.join(prefix, relative), st = lstatSync(path); + if (st.isSymbolicLink() || (!st.isDirectory() && !st.isFile()) || (!st.isDirectory() && st.nlink !== 1)) { + throw new Error("installation_path_unverified"); + } + validate(relative, st.isDirectory()); + acl.push({ path, directory: st.isDirectory(), policy: "write-private" }); + if (st.isDirectory()) { + directories.push(`${relative}:${st.dev}:${st.ino}`); + for (const name of readdirSync(path).sort()) walk(`${relative}/${name}`); + } else { + total += st.size; + if (st.size > MAX_CODEX_MEMBER_BYTES || total > MAX_CODEX_UNPACKED_BYTES) throw new Error("installation_readback_limit"); + paths.push(relative); + } + }; + for (const relative of readdirSync(prefix).sort()) if (relative !== ".ocx-codex-cli-apply.lock") walk(relative); + const absent = roots.filter(root => !paths.includes(root) && !directories.some(dir => dir.startsWith(root + ":"))); + return { paths, directories, acl, absent }; +} +export function windowsInstallNamespaceSupported(prefix: string): boolean { + try { inventory(prefix); return true; } catch { return false; } +} + +/** Full bounded observation of both old/new package lanes, three shims and npm hidden lock. + * Includes absence and empty directories. A positive match cannot be made by reading only + * expected filenames. No POSIX executable-bit guesses substitute for Windows identity/ACL. + */ +export async function observeWindowsInstall(prefix: string): Promise { + const { paths, directories, absent, acl } = inventory(prefix); + // Existing ancestors are guarded by the authority; each observed child must also + // refuse writes from principals outside that same trust boundary. + for (let i = 0; i < acl.length; i += 32) { + if (!await verifyWindowsEntriesAclAsync(acl.slice(i, i + 32), 5000)) throw new Error("installation_acl_unverified"); + } + const files: (WindowsCodexExpectedFile & { identity: string })[] = []; + for (let i = 0; i < paths.length; i += 12) { + const batch = paths.slice(i, i + 12); + const observed = await inspectWindowsInstallationFiles(batch.map(relative => ({ + path: win32.join(prefix, relative), maxBytes: lstatSync(win32.join(prefix, relative)).size, hashOnly: true, installationDigest: true, + }))); + if (observed.kind !== "observed") throw new Error("installation_file_unverified"); + for (const file of observed.files) files.push({ relativePath: key(win32.relative(prefix, file.path)), + size: file.identity.size, digest: file.digest, identity: JSON.stringify(file.identity) }); + } + // Detect namespace changes while the bounded observations yielded. + const again = inventory(prefix); + if (JSON.stringify([...again.directories, ...again.paths].sort()) !== JSON.stringify([...directories, ...paths].sort()) + || JSON.stringify(absent) !== JSON.stringify(again.absent)) throw new Error("installation_changed"); + return { files: files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)), directories: directories.sort(), absent: absent.sort() }; +} +export const sameWindowsInstall = (left: WindowsInstallSnapshot, right: WindowsInstallSnapshot): boolean => + JSON.stringify(left) === JSON.stringify(right); +/** npm metadata and every bounded unrelated entry must retain bytes and identity. */ +export function sameWindowsPreservedEntries(left: WindowsInstallSnapshot, right: WindowsInstallSnapshot): boolean { + const preserved = (snapshot: WindowsInstallSnapshot) => ({ + files: snapshot.files.filter(file => !windowsCodexOwnedPath(file.relativePath)), + directories: snapshot.directories.filter(dir => !windowsCodexOwnedPath(dir.split(":")[0]!)), + }); + return JSON.stringify(preserved(left)) === JSON.stringify(preserved(right)); +} diff --git a/src/codex/cli-windows-plan-provenance.ts b/src/codex/cli-windows-plan-provenance.ts index 9c0b0d9dd0..6636d8fe9a 100644 --- a/src/codex/cli-windows-plan-provenance.ts +++ b/src/codex/cli-windows-plan-provenance.ts @@ -10,6 +10,8 @@ import { inspectWindowsInstallationFiles, type WindowsInstallationFilesResult } /** Private plan evidence. Explicit `attest` observations never enter this map. */ interface WindowsPlanEvidence { readonly node: string; readonly npmCli: string; readonly arch: "x64" | "arm64"; + readonly prefix: string; readonly launcher: string; readonly backingRoot: string; + readonly envPath: string | undefined; readonly envPathExt: string | undefined; readonly nodeEvidence: string; readonly npmEvidence: string } const evidence = new WeakMap(); export const windowsCodexCliPlanEvidence = (report: CodexCliInstallReport): WindowsPlanEvidence | null => evidence.get(report) ?? null; @@ -17,20 +19,53 @@ const hash = (value: string): string => createHash("sha256").update(value).diges type File = Extract["files"][number]; const id = (file: File): string => JSON.stringify([file.path, file.identity, file.digest]); const sameWindowsPath = (left: string, right: string): boolean => win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase(); +function selectedWindowsOverride(candidate: string, env: NodeJS.ProcessEnv, configDir?: string): boolean { + return env.CODEX_CLI_PATH?.trim() === candidate && resolveCodexRuntime({ platform: "win32", env, configDir, + probeVersion: false, discoverAlternatives: false, existsSync: path => path === candidate, + readFileSync: () => { throw new Error("state not required for explicit override"); }, + readdirSync: () => [], statSync: () => { throw new Error("discovery disabled"); }, + }).runtime.command === candidate; +} +/** Synchronous selection fence after the last awaited authority check, before native start. */ +export function windowsCodexCliSelectionMatches(report: CodexCliInstallReport, deps: CodexCliInstallProvenanceDeps): boolean { + const captured = evidence.get(report), env = deps.env; + return !!captured && !!env && deps.selectionAttested === true && env.PATH === captured.envPath + && env.PATHEXT === captured.envPathExt && selectedWindowsOverride(captured.launcher, env, deps.configDir); +} /** Metadata queries execute only while the observed Node/npm and ancestors deny replacement. */ -export async function withWindowsCodexCliPlanToolchain(report: CodexCliInstallReport, run: () => T): Promise { +export async function withWindowsCodexCliPlanToolchain(report: CodexCliInstallReport, run: () => T | Promise): Promise { const toolchain = evidence.get(report); if (!toolchain) return null; let result: T | null = null; const observed = await inspectWindowsInstallationFiles([ { path: toolchain.node, maxBytes: 128 * 1024 * 1024, hashOnly: true }, { path: toolchain.npmCli, maxBytes: 256 * 1024 }, - ], files => { + ], async files => { if (id(files[0]!) !== toolchain.nodeEvidence || id(files[1]!) !== toolchain.npmEvidence) return false; - result = run(); return true; + result = await run(); return true; }); return observed.kind === "observed" ? result : null; } + +/** Transfer a held toolchain observation to a transaction, including unknown child lifetimes. */ +export async function holdWindowsCodexCliPlanToolchain(report: CodexCliInstallReport) { + const toolchain = evidence.get(report); + if (!toolchain) return null; + const admitted = Promise.withResolvers(); + const release = Promise.withResolvers(); + const observing = inspectWindowsInstallationFiles([ + { path: toolchain.node, maxBytes: 128 * 1024 * 1024, hashOnly: true }, + { path: toolchain.npmCli, maxBytes: 256 * 1024 }, + ], async files => { + if (id(files[0]!) !== toolchain.nodeEvidence || id(files[1]!) !== toolchain.npmEvidence) return false; + admitted.resolve(true); + await release.promise; + return true; + }); + void observing.then(() => admitted.resolve(false), () => admitted.resolve(false)); + if (!await admitted.promise) return null; + return { async release() { release.resolve(); return (await observing).kind === "observed"; } }; +} const json = (file: File): Record | null => { try { const item = JSON.parse(Buffer.from(file.bytes).toString("utf8")); return item && typeof item === "object" && !Array.isArray(item) ? item : null; @@ -93,11 +128,7 @@ export async function inspectWindowsCodexCliPlanInstall( if (prefix === win32.parse(prefix).root || paths.some(excluded)) return fallback; // Only already handle-observed files can pass the real resolver's existence // predicate. The explicit override wins before saved-state/shim/PATH candidates. - const selected = () => resolveCodexRuntime({ platform: "win32", env, configDir: deps.configDir, - probeVersion: false, discoverAlternatives: false, existsSync: path => path === candidate, - readFileSync: () => { throw new Error("state not required for explicit override"); }, - readdirSync: () => [], statSync: () => { throw new Error("discovery disabled"); }, - }).runtime.command === candidate && env.CODEX_CLI_PATH?.trim() === candidate; + const selected = () => selectedWindowsOverride(candidate, env, deps.configDir); try { const first = await inspectWindowsInstallationFiles([ { path: manifest, maxBytes: 256 * 1024 }, { path: npmManifest, maxBytes: 256 * 1024 }, @@ -154,7 +185,9 @@ export async function inspectWindowsCodexCliPlanInstall( installContentDigest: content, shim: Object.freeze({ status: "not-tracked", backingKind: null }), evidence: Object.freeze(["package_manifest", "package_manifest_digest", "install_content_digest", "global_npm_layout"] as const), }); - evidence.set(report, Object.freeze({ node, npmCli, arch, nodeEvidence: id(files.get(node)!), npmEvidence: id(files.get(npmCli)!) })); + evidence.set(report, Object.freeze({ node, npmCli, arch, prefix, launcher: candidate, + envPath: selectedPath, envPathExt: selectedPathExt, + backingRoot: win32.dirname(backingManifest), nodeEvidence: id(files.get(node)!), npmEvidence: id(files.get(npmCli)!) })); return report; } catch { return fallback; } } diff --git a/src/codex/windows-cli-child.ts b/src/codex/windows-cli-child.ts index d147b681be..9579d54f3e 100644 --- a/src/codex/windows-cli-child.ts +++ b/src/codex/windows-cli-child.ts @@ -20,6 +20,8 @@ export interface WindowsCliChildInvocation { } export interface WindowsCliChildResult { readonly terminal: boolean; + /** Terminal process/Job accounting does not establish successful handle disposal. */ + readonly cleanupComplete: boolean; readonly exitCode: number | null; readonly stdout: string; readonly overflow: boolean; @@ -100,7 +102,8 @@ export class WindowsCliChild { 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"), + return { terminal, cleanupComplete: terminal && this.native.handles.size === 0 && this.native.borrowedMemory.size === 0, + 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. */ diff --git a/src/codex/windows-installation-files.ts b/src/codex/windows-installation-files.ts index 4fb026f220..9ffba079e5 100644 --- a/src/codex/windows-installation-files.ts +++ b/src/codex/windows-installation-files.ts @@ -23,6 +23,8 @@ export interface WindowsInstallationFileRequest { readonly metadataOnly?: boolean; /** Read at most maxBytes from the start instead of refusing an oversized file. */ readonly prefixOnly?: boolean; + /** Same held stream, domain-separated archive/readback comparison. Default remains raw SHA-256. */ + readonly installationDigest?: boolean; } export interface WindowsInstallationFileIdentity { readonly volumeSerial: string; @@ -97,6 +99,7 @@ export async function inspectWindowsInstallationFiles( || (request.hashOnly !== undefined && typeof request.hashOnly !== "boolean") || (request.metadataOnly !== undefined && typeof request.metadataOnly !== "boolean") || (request.prefixOnly !== undefined && typeof request.prefixOnly !== "boolean") + || (request.installationDigest !== undefined && typeof request.installationDigest !== "boolean") || request.maxBytes > (request.hashOnly ? MAX_NATIVE_BACKING_BYTES : MIB)) return null; ceiling += request.maxBytes; return parsedPath; @@ -212,6 +215,7 @@ export async function inspectWindowsInstallationFiles( openedForTests?.(); const observed = files.map(({ request, handle, identity }) => { const hash = createHash("sha256"); + if (request.installationDigest) hash.update("codex-cli-observed-file-v1\0"); if (request.metadataOnly) return { path: request.path, identity, bytes: new Uint8Array(), digest: "" }; const truncated = Boolean(request.prefixOnly) && !request.hashOnly && identity.size > request.maxBytes; const readLimit = truncated ? request.maxBytes : identity.size; diff --git a/structure/INDEX.md b/structure/INDEX.md index c27e1a44a9..a25a92e188 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -28,7 +28,7 @@ Persisted config, the Codex home it writes into, and the model catalog it publis | [`codex-provider-routing.md`](codex-provider-routing.md) | Explicit provider-ID routing consent, URL-only publication and recovery. | | [`codex-home.md`](codex-home.md) | CODEX_HOME resolution, the files opencodex manages there, and Codex-home diagnostics. | | [`codex-prompts.md`](codex-prompts.md) | Prompt-layer repair, base import previews and bounded text-probe sources. | -| [`codex-cli-update.md`](codex-cli-update.md) | Content-bound planning and explicit POSIX apply with isolated npm, artifact validation, installation leases and readback. | +| [`codex-cli-update.md`](codex-cli-update.md) | Content-bound planning and explicit POSIX/Windows x64 apply with isolated npm, artifact validation, installation leases and readback. | | [`catalog.md`](catalog.md) | Shared Codex catalog assembly, account namespaces, pool rotation, and effort ladders. | | [`catalog-vendor-context.md`](catalog-vendor-context.md) | Bundled model-ID window fallback, ambiguity handling, and inferred-capacity provenance. | | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | Persisted effort-clamp observation identity and idempotent sync notices. | @@ -223,7 +223,9 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/cli-update-job.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-lease.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-plan.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-windows-apply.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-windows-layout.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-windows-observation.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-windows-paths.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `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) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index e329f8f783..01db70c274 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -87,16 +87,16 @@ creates private plan evidence; copying a public report cannot enable Windows pla This observes linkage and content, not publisher authenticity or a whole-tree guarantee. Metadata queries use the observed Node/npm paths under freshly matching held handles, the existing isolated npm configuration/cache, and the pinned registry. No installer runs. -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. +A completed Windows plan has a deterministic ID. Only the bounded x64 prefix predicate below +may set `applicable: true`; other observed layouts retain `windows_apply_disabled`. +Active/unknown sessions still refuse earlier. Apply never overrides a refused plan. ## 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. +`src/codex/windows-cli-child-pipe.ts`. Windows apply consumes it for pack, installer and version +children. 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, @@ -118,12 +118,14 @@ A termination acknowledgment, timeout, I/O error, failed wait/query or PID absen 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. +`cleanupComplete` separately establishes disposal: terminal plus EOF can still leave failed-close +handles owned. Such owners and lease/staging remain retained with `child_cleanup_failed`; the job +projects the child as terminal and the lease as retained, without claiming successful cleanup. 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 Windows transaction adopts lifetime and lease/staging retention before every child start, +including pack. Its strong owner registry keeps unknown children and their toolchain pins alive. 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, @@ -133,7 +135,8 @@ pre-user-code failures, byte caps, cancellation settlement, wait/query failures The independent `src/codex/cli-update-windows-layout.ts` verifies Windows x64 archives and returns only prefix-relative expected files, sizes and content digests; `applicable` and -`mutationAuthority` remain false. No plan, selection, installer, lease or apply consumer uses it. +`mutationAuthority` remain false. The Windows transaction consumes this content contract only +after obtaining separate selection and installation authority. The main root is `node_modules/@openai/codex`; the explicit sibling backing is `node_modules/@openai/codex-win32-x64`, with `vendor/x86_64-pc-windows-msvc/bin/codex.exe`. Exact direct-version and npm-alias references retain the archive verifier's metadata/linkage rules. @@ -149,11 +152,51 @@ raw archive source-file hashes and actual exported-function generation with inte calls. All three generated strings matched the retained bytes. No npm installation or lifecycle script ran; the offline fixture test checks byte consistency, not registry provenance afresh. Other shim grammars remain unsupported. All archive auxiliary files enter the expected set and digest, and -readback comparison requires the complete set. A future consumer must separately prove live held +readback comparison requires the complete set. The consumer separately proves live held file identities, package lookup, selected Node/shim and write authority; a matching DTO grants none. Coverage: `tests/codex-integration/codex-cli-update-windows-layout.test.ts` uses only synthetic archives and captured shim bytes, with no npm installation or native executable invocation. +## Explicit Windows x64 apply + +`src/codex/cli-update-windows-apply.ts` implements the Windows transaction behind the existing +confirmed CLI/HTTP admission and planId contract. `src/codex/cli-update-windows-observation.ts` +admits bounded shared global prefixes. The two Codex package trees and three shims are targets; +all other prefix entries, including `node_modules/.package-lock.json`, are preservation obligations. +Observation is capped at 4,096 entries, 512 MiB total and 384 MiB per file, including unrelated +packages. Oversized namespaces, unsupported ACL ancestry and ARM64 refuse without repair. +Actual ACL evidence decides profile-path support; an unsafe AppData ancestor on one host does +not establish that every profile is unsafe. No existing ancestor is rehardened. +The job-store native primitives provide a separate `.ocx-codex-cli-apply.lock` lease. Prefix, +parent and package-scope directory pins deny rename/delete; ACLs are verified before creation +and before/after execution. Only newly created lock/staging objects are hardened. The current +account and kernel-equivalent administrators remain inside the trust boundary; other-SID and +filtered-token behavior is not claimed as empirically tested. Reparse paths fail closed. + +The observed Node/npm pair stays pinned across awaited work. Private staging owns npm configs, +caches, Windows temp/profile variables and version state. Exact tarballs are verified before +the final fresh plan, selected command, preimage, authority and session checks. Pack and install +have no overall process deadline or cancellation kill. Unknown child state retains child handles, +toolchain pins, lease and staging; it is never converted to terminal by PID absence. + +Readback enumerates both package trees (including auxiliary files), three shims, empty directories, +the hidden lock, absence of the new backing, and unrelated prefix entries. Unrelated bytes, file +identities and directory identities must stay equal; added entries make the result ambiguous. +Only the separately held transaction lock/staging is excluded. It rejects unexpected target entries, +links, unsupported Windows names, unreadable/oversized observations and untrusted ACLs. The native +reader's optional installation-domain SHA-256 uses the same held stream, retaining raw SHA-256 as +the default. `not_applied` requires identical scoped preimage and selection, not merely an old +version; it is not a claim that npm performed no I/O. A complete target is classified by readback, +not npm's exit code. Version uses the pinned Node with the verified JS entrypoint in private cwd, +then repeats selection/content observation; it does not attest arbitrary future CMD lookup. +Cleanup errors remain separate from the result. The existing job execution layer retries result +persistence without repeating installation. No automatic rollback or stale-lock recovery exists. +`tests/codex-integration/codex-cli-update-windows-apply.test.ts` covers real offline npm in private +shared fixtures, unrelated package/shim preservation, terminal close failure, partial mutations, +absence, selection, unknown retention, async pins and persistence retry. One JS launcher executes +a compiled CLR x64 backing fixture that verifies Job membership and returns the version. This is +executable-fixture evidence, not a run of the vendor Codex product. + ## Explicit POSIX apply `src/cli/codex-cli-update.ts` accepts `apply --plan-id <32 lowercase hex> --yes` with optional JSON output. @@ -202,7 +245,7 @@ is `not_applied` even after exit zero. Partial, unreadable or uncertain results Cleanup failures are path-free `cleanupErrors` alongside the computed outcome. A failed or thrown lease release marks retention unverified without preventing staging cleanup; cleanup does not replace the readback outcome. Uncertain live installer/version children still retain both lease and staging. -Windows apply remains refused. Shims and non-npm managers remain unsupported. +The POSIX path refuses Windows; Windows uses the separate transaction above. Non-npm managers remain unsupported. `bin/ocx.mjs` includes the proof-bound Codex candidate snapshot for the published `start` command as well as the updater commands; this makes the production management plan work without accepting request-supplied candidate evidence. `src/codex/cli-update-inspection-context.ts` is the shared bridge from that Node snapshot to the installer inspector. Direct Bun/source launches have no trusted candidate and fail closed; neither path reconstructs write targets from display data. `src/codex/cli-update-job.ts` owns the bounded durable admission ledger shared by CLI and management. Request IDs are scoped to the protected config root and payload digest; exact retries return the same opaque job, conflicts refuse, and a full ledger refuses new admission. Every store operation requires a canonical same-owner config parent with safe write permissions and canonical ancestry to filesystem root; each ancestor must be owned by root or the current user, and shared-writable ancestors are refused unless root-owned and sticky. Only confirmed apply admission may create one absent config-root level, which is revalidated as owner-private. Plan and status reads never create it, and an existing root must pass the same canonical/no-link checks. This path-based ancestry check prevents untrusted other-user writable ancestors from redirecting store operations; it does not defend against concurrent tampering by the same user or root. Atomic updates use a private same-owner POSIX directory, no-follow reads and a short exclusive lock. Unresolved records are not expired. An in-process generation and revision own completion writes; after restart a queued/running record projects as `ambiguous` with `owner_state_unknown`, without claiming the old owner stopped. A `retained` lease state requires an execution reason that proves acquisition and intentional preservation; synthetic job errors, unverified release and release exceptions project as `unknown`. The path-free DTO separates owner state, installer child state, lease retention and overall lifetime uncertainty with fixed outcome/reason/cleanup codes. diff --git a/structure/manifest.json b/structure/manifest.json index 63989ba980..417919be63 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -173,13 +173,15 @@ "path": "codex-cli-update.md", "tier": 2, "title": "Codex CLI Update", - "scope": "Content-bound planning and explicit POSIX apply with isolated npm, artifact validation, installation leases and readback.", + "scope": "Content-bound planning and explicit POSIX/Windows x64 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-windows-apply.ts", + "src/codex/cli-update-windows-observation.ts", "src/codex/cli-update-job.ts", "src/codex/cli-update-job-execution.ts", "src/codex/cli-update-job-store-windows.ts", diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index 6e1e761b41..35bb8b2f17 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -300,9 +300,12 @@ describe("Codex CLI apply consumer", () => { removeTreeWithRetry(outer); } }); - test("Windows apply refuses before inspecting or installing", async () => { - let calls = 0; - const result = await applyCodexCliUpdate("a".repeat(32), { platform: "win32", inspect: async () => { calls++; throw new Error("not reached"); } }); - expect(result.reason).toBe("windows_inspection_deferred"); expect(calls).toBe(0); + test("Windows apply never installs when production inspection cannot establish evidence", async () => { + let calls = 0, installs = 0; + const result = await applyCodexCliUpdate("a".repeat(32), { platform: "win32", inspect: async () => { calls++; throw new Error("unverified"); }, + install: async () => { installs++; return { terminal: true, exitCode: 0 }; } }); + const supported = process.platform === "win32" && process.arch === "x64"; + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(result.reason).toBe(supported ? "preparation_failed" : "windows_inspection_deferred"); expect(calls).toBe(supported ? 1 : 0); }); }); diff --git a/tests/codex-integration/codex-cli-update-windows-apply.test.ts b/tests/codex-integration/codex-cli-update-windows-apply.test.ts new file mode 100644 index 0000000000..911ff09dc4 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-windows-apply.test.ts @@ -0,0 +1,362 @@ +import { afterEach, expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync, renameSync, symlinkSync, unlinkSync, linkSync } from "node:fs"; +import { join, dirname, parse } from "node:path"; +import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import { inspectCodexCliInstall } from "../../src/codex/cli-install-provenance"; +import { holdWindowsCodexCliPlanToolchain, withWindowsCodexCliPlanToolchain } from "../../src/codex/cli-windows-plan-provenance"; +import { openWindowsCliApplyAuthority } from "../../src/codex/cli-update-job-store-windows"; +import { hardenSecretDirAsync } from "../../src/lib/windows-secret-acl"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { fixtureArtifact, fixtureTar } from "../helpers/codex-cli-apply-artifact"; +import { fixturePath, repoPath, repoRoot } from "../helpers/repo-root"; +import { spawnTestChild } from "../helpers/spawn-test-child"; +import { runCodexCliUpdateJobWork } from "../../src/codex/cli-update-job-execution"; +import { startWindowsCliChild, type WindowsCliChild } from "../../src/codex/windows-cli-child"; +import { windowsInstallNamespaceSupported } from "../../src/codex/cli-update-windows-observation"; + +const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await removeTreeAfterBackgroundWork(root); }); +const mainRoot = "node_modules/@openai/codex", backingRoot = "node_modules/@openai/codex-win32-x64"; +const nativePath = "vendor/x86_64-pc-windows-msvc/bin/codex.exe"; +const write = (path: string, bytes: string | Buffer) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, bytes); }; +function artifacts(version: string, executable?: Buffer) { + const pe = Buffer.alloc(512); pe.write("MZ"); pe.writeUInt32LE(64, 60); pe.write("PE\0\0", 64); + pe.writeUInt16LE(0x8664, 68); pe.writeUInt16LE(1, 70); pe.writeUInt16LE(240, 84); pe.writeUInt16LE(2, 86); pe.writeUInt16LE(0x20b, 88); + // Synthetic PE only. The real Node verification child executes this fixture's JS; + // this is an npm/readback test, not a claim that a real Codex native binary ran. + const mainFiles = { "package.json": JSON.stringify({ name: "@openai/codex", version, + bin: { codex: "bin/codex.js" }, optionalDependencies: { "@openai/codex-win32-x64": version } }), + "bin/codex.js": executable ? '#!/usr/bin/env node\n' + + 'const cp=require("node:child_process"), path=require("node:path");\n' + + 'const exe=path.join(__dirname,"../../codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe");\n' + + 'const child=cp.spawn(exe,process.argv.slice(2),{stdio:"inherit",windowsHide:true});\n' + + 'child.on("error",()=>{process.exitCode=91});child.on("exit",code=>{process.exitCode=code??92});\n' + : `#!/usr/bin/env node\nconsole.log("codex-cli ${version}");\n`, "README.md": "auxiliary\n" }; + const backingFiles = { "package.json": JSON.stringify({ name: "@openai/codex-win32-x64", version, os: ["win32"], cpu: ["x64"] }), + [nativePath]: executable ?? pe, "vendor/auxiliary.txt": "auxiliary\n" }; + const main = fixtureTar(mainFiles), backing = fixtureTar(backingFiles); + return { mainFiles, backingFiles, main, backing, set: { platform: "win32" as const, arch: "x64", + main: fixtureArtifact(main, version), backing: fixtureArtifact(backing, version, "@openai/codex-win32-x64", "@openai/codex-win32-x64") } }; +} +async function fixture(executable?: Buffer) { + // This host's observed writable AppData ancestry refused; this is not a ban on + // every profile. Evaluate actual ancestry ACL/owner/reparse evidence instead. + // Create only this test-owned directory below the repository's volume anchor; + // never repair or suppress metadata on the host's existing ancestor directories. + const root = mkdtempSync(join(parse(process.cwd()).root, "ocx-win-apply-")); roots.push(root); + await hardenSecretDirAsync(root, { required: true }); + const prefix = join(root, "prefix"); mkdirSync(prefix); + await hardenSecretDirAsync(prefix, { required: true }); + const old = artifacts("1.0.0"), target = artifacts("1.1.0", executable); + for (const [relative, bytes] of Object.entries(old.mainFiles)) write(join(prefix, mainRoot, relative), bytes); + for (const [relative, bytes] of Object.entries(old.backingFiles)) write(join(prefix, backingRoot, relative), bytes); + const shims = JSON.parse(readFileSync(fixturePath("codex-windows-npm-shims.json"), "utf8")); + for (const [name, bytes] of Object.entries(shims.files)) write(join(prefix, name), String(bytes)); + const nodeDir = join(process.env.ProgramFiles ?? "C:/Program Files", "nodejs"); + const inspectionDeps = { env: { CODEX_CLI_PATH: join(prefix, "codex.cmd"), PATH: nodeDir, PATHEXT: ".EXE;.CMD" }, + configDir: join(root, "absent-home"), arch: "x64" as const, selectionAttested: true }; + const deps = { platform: "win32" as const, inspectionDeps, + resolveTarget: () => ({ kind: "resolved" as const, version: "1.1.0", integrity: target.set.main.integrity, artifacts: target.set }), + scanProcesses: () => ({ kind: "observed" as const, processes: [] }), + fetchArtifact: async (artifact: typeof target.set.main) => artifact.installName === "@openai/codex" ? target.main : target.backing }; + return { root, prefix, old, target, deps }; +} +async function approved(f: Awaited>) { + const plan = await createCodexCliUpdatePlan(f.deps); + expect(plan, JSON.stringify(plan)).toMatchObject({ applicable: true, refusal: null }); + expect(plan.planId).toMatch(/^[a-f0-9]{32}$/); return plan.planId!; +} + +native("Windows apply holds distinct prefix lease and private staging, then releases exact owners", async () => { + const f = await fixture(); + const guard = await openWindowsCliApplyAuthority(f.prefix, true); + try { + await guard.createStaging(); + expect(() => renameSync(f.prefix, f.prefix + "-moved")).toThrow(); + expect(() => renameSync(join(f.prefix, "node_modules", "@openai"), join(f.prefix, "node_modules", "moved"))).toThrow(); + await expect(openWindowsCliApplyAuthority(f.prefix, true)).rejects.toThrow(); + await guard.verify(); await guard.removeStaging(); + } finally { expect(await guard.release()).toBe(true); } + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + const replacement = await openWindowsCliApplyAuthority(f.prefix, true); + try { + await expect(guard.createStaging()).rejects.toThrow(); + await expect(guard.verify()).rejects.toThrow(); + expect(existsSync(join(replacement.lock, "stage"))).toBe(false); + await replacement.createStaging(); await replacement.removeStaging(); + } finally { expect(await replacement.release()).toBe(true); } +}, 60000); + +native("Windows apply runs real offline npm under owned Job, then verifies full target and version", async () => { + const f = await fixture(), id = await approved(f); + const result = await applyCodexCliUpdate(id, f.deps); + expect(result, JSON.stringify(result)).toMatchObject({ outcome: "applied", reason: "verified_target", + installerExitCode: 0, installedVersion: "1.1.0", leaseRetained: false, cleanupErrors: [] }); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); +}, 60000); + +native("Windows terminal no-op is not_applied even with nonzero installer exit", async () => { + const f = await fixture(), id = await approved(f); + const result = await applyCodexCliUpdate(id, { ...f.deps, install: async () => ({ terminal: true, exitCode: 17 }) }); + expect(result, JSON.stringify(result)).toMatchObject({ outcome: "not_applied", leaseRetained: false, cleanupErrors: [] }); +}, 60000); + +native("Windows pack children and installer descendant share transaction ownership", async () => { + const f = await fixture(), realNode = join(f.deps.inspectionDeps.env.PATH, "node.exe"); + const realNpm = join(f.deps.inspectionDeps.env.PATH, "node_modules/npm/bin/npm-cli.js"); + const tools = join(f.root, "toolchain"), npmCli = join(tools, "node_modules/npm/bin/npm-cli.js"), trace = join(f.root, "trace.jsonl"); + mkdirSync(dirname(npmCli), { recursive: true }); copyFileSync(realNode, join(tools, "node.exe")); + write(join(tools, "node_modules/npm/package.json"), JSON.stringify({ name: "npm", version: "11.16.0", bin: { npm: "bin/npm-cli.js" } })); + const sources = Object.fromEntries([f.target.set.main, f.target.set.backing].map((a, i) => { + const path = join(f.root, `${i}.tgz`); write(path, i ? f.target.backing : f.target.main); return [`${a.name}@${a.version}`, path]; + })); + write(npmCli, [ + 'const fs=require("node:fs"), path=require("node:path"), cp=require("node:child_process");', + 'const args=process.argv.slice(2);', + `fs.appendFileSync(${JSON.stringify(trace)},JSON.stringify(args[0])+"\\n");`, + `const sources=${JSON.stringify(sources)};`, + 'if(args[0]==="pack") { const source=sources[args[1]]; if(!source)process.exit(91);', + 'const name=path.basename(source); fs.copyFileSync(source,path.join(args[args.indexOf("--pack-destination")+1],name));', + 'console.log(JSON.stringify([{filename:name}])); } else if(args[0]==="install") {', + `const r=cp.spawnSync(${JSON.stringify(realNode)},[${JSON.stringify(realNpm)},...args],{env:process.env,stdio:"inherit"});process.exit(r.status??92);`, + '} else process.exit(93);', + ].join("\n")); + f.deps.inspectionDeps.env.PATH = tools; + const id = await approved(f); + const result = await applyCodexCliUpdate(id, { ...f.deps, fetchArtifact: undefined }); + expect(result, JSON.stringify(result)).toMatchObject({ outcome: "applied", leaseRetained: false, cleanupErrors: [] }); + expect(readFileSync(trace, "utf8").trim().split("\n").map(line => JSON.parse(line))).toEqual(["pack", "pack", "install"]); +}, 60000); + +native("Windows complete readback outranks a nonzero installer exit", async () => { + const f = await fixture(), id = await approved(f); + const result = await applyCodexCliUpdate(id, { ...f.deps, install: async () => { + for (const [path, bytes] of Object.entries(f.target.mainFiles)) write(join(f.prefix, mainRoot, path), bytes); + for (const [path, bytes] of Object.entries(f.target.backingFiles)) write(join(f.prefix, backingRoot, path), bytes); + return { terminal: true, exitCode: 17 }; + } }); + expect(result, JSON.stringify(result)).toMatchObject({ outcome: "applied", installerExitCode: 17, cleanupErrors: [] }); +}, 60000); + +native("Windows cancellation and unknown session observation cannot start an installer", async () => { + const f = await fixture(), id = await approved(f); let installs = 0; + const install = async () => { installs++; return { terminal: true, exitCode: 0 }; }; + const abort = new AbortController(); abort.abort(); + expect((await applyCodexCliUpdate(id, { ...f.deps, install, signal: abort.signal })).reason).toBe("cancelled"); + expect((await applyCodexCliUpdate(id, { ...f.deps, install, + scanProcesses: () => ({ kind: "unavailable" }) })).reason).toBe("blocked_process_state_unknown"); + let scans = 0; + expect((await applyCodexCliUpdate(id, { ...f.deps, install, + scanProcesses: () => ++scans < 3 ? { kind: "observed", processes: [] } : { kind: "unavailable" } })).reason) + .toBe("blocked_process_state_unknown"); + expect(scans).toBe(3); + expect(installs).toBe(0); +}, 60000); + +native.each(["auxiliary", "shim", "hidden-lock", "extra-file", "new-empty-backing", "selection"] as const)("Windows partial %s refuses not_applied", async kind => { + const f = await fixture(); + if (kind === "new-empty-backing") { + const nested = join(f.prefix, mainRoot, "bin", "node_modules", "@openai", "codex-win32-x64"); + mkdirSync(dirname(nested), { recursive: true }); renameSync(join(f.prefix, backingRoot), nested); + } + const id = await approved(f); + const result = await applyCodexCliUpdate(id, { ...f.deps, install: async () => { + if (kind === "auxiliary") write(join(f.prefix, mainRoot, "README.md"), "changed"); + if (kind === "shim") write(join(f.prefix, "codex.ps1"), "changed"); + if (kind === "hidden-lock") write(join(f.prefix, "node_modules/.package-lock.json"), "{}"); + if (kind === "extra-file") write(join(f.prefix, backingRoot, "unexpected.txt"), "extra"); + if (kind === "new-empty-backing") mkdirSync(join(f.prefix, backingRoot)); + if (kind === "selection") f.deps.inspectionDeps.env.CODEX_CLI_PATH = join(f.prefix, "other.cmd"); + return { terminal: true, exitCode: 1 }; + } }); + expect(result, JSON.stringify(result)).toMatchObject({ outcome: "ambiguous", leaseRetained: false }); +}, 60000); + +native("Windows uncertain installer retains lease and staging until owning process exits", async () => { + const f = await fixture(), id = await approved(f); + const entry = join(f.root, "uncertain.test.ts"), receipt = join(f.root, "result.json"); + const literals = JSON.stringify({ id, inspectionDeps: f.deps.inspectionDeps, target: f.deps.resolveTarget(), + main: f.target.main.toString("base64"), backing: f.target.backing.toString("base64"), receipt, prefix: f.prefix }); + writeFileSync(entry, [ + 'import { test, expect } from "bun:test";', + 'import { existsSync, writeFileSync } from "node:fs";', + `import { applyCodexCliUpdate } from ${JSON.stringify(repoPath("src/codex/cli-update-apply.ts"))};`, + `const f = ${literals};`, + 'test("retained transaction", async () => {', + 'const result = await applyCodexCliUpdate(f.id, { platform:"win32", inspectionDeps:f.inspectionDeps,', + 'resolveTarget:()=>f.target, scanProcesses:()=>({kind:"observed",processes:[]}),', + 'fetchArtifact:async a=>Buffer.from(a.installName==="@openai/codex"?f.main:f.backing,"base64"),', + 'install:async()=>({terminal:false,exitCode:null}) });', + 'const stage=existsSync(f.prefix+"/.ocx-codex-cli-apply.lock/stage");', + 'writeFileSync(f.receipt,JSON.stringify({result,stage}));', + 'expect(result.outcome).toBe("ambiguous"); expect(result.leaseRetained).toBe(true); expect(stage).toBe(true);', + '},60000);', + ].join("\n")); + const child = await spawnTestChild([process.execPath, "--no-env-file", "--no-install", "test", "--timeout", "60000", entry], + { cwd: repoRoot(), env: process.env, deadlineMs: 55000 }); + expect(child.reaped).toBe(true); expect(child.pipesClosed).toBe(true); expect(child.exitCode, child.stderr).toBe(0); + expect(JSON.parse(readFileSync(receipt, "utf8"))).toMatchObject({ stage: true, + result: { outcome: "ambiguous", reason: "installer_state_unknown", leaseRetained: true } }); + // The child has exited: only now can the fixture's retained kernel handles be gone. +}, 60000); + +native("Windows completion persistence retry does not repeat the installer", async () => { + const f = await fixture(), id = await approved(f); let installs = 0, finishes = 0; + await runCodexCliUpdateJobWork(() => applyCodexCliUpdate(id, { ...f.deps, + install: async () => { installs++; return { terminal: true, exitCode: 0 }; } }), { + begin: async () => 1, + finish: async (_revision, result) => { + expect(result.outcome).toBe("not_applied"); + if (++finishes < 3) throw Object.assign(new Error("fixture busy"), { code: "EBUSY" }); + return null; + }, read: async () => null, release: async () => {}, + }); + expect(installs).toBe(1); expect(finishes).toBe(3); +}, 60000); + +native("Windows toolchain pins survive an awaited callback; bounded shared prefix is eligible", async () => { + const f = await fixture(); + const tools = join(f.root, "toolchain"), npmCli = join(tools, "node_modules/npm/bin/npm-cli.js"); + write(join(tools, "node.exe"), f.old.backingFiles[nativePath]); + write(join(tools, "node_modules/npm/package.json"), JSON.stringify({ name: "npm", version: "11.0.0", bin: { npm: "bin/npm-cli.js" } })); + write(npmCli, "// never executed\n"); f.deps.inspectionDeps.env.PATH = tools; + const report = await inspectCodexCliInstall(f.deps.inspectionDeps); + expect(report.managed).toBe(true); + expect(await withWindowsCodexCliPlanToolchain(report, async () => { + await Bun.sleep(10); + // This write must still be denied after await; returning a promise while + // releasing native pins early would make this assertion fail. + expect(() => writeFileSync(npmCli, "replacement")).toThrow(); return 42; + })).toBe(42); + writeFileSync(npmCli, "// changed after release\n"); + const refreshed = await inspectCodexCliInstall(f.deps.inspectionDeps); + const pin = await holdWindowsCodexCliPlanToolchain(refreshed); expect(pin).not.toBeNull(); expect(await pin!.release()).toBe(true); + mkdirSync(join(f.prefix, "node_modules", "unrelated")); + expect((await createCodexCliUpdatePlan(f.deps)).applicable).toBe(true); +}, 60000); + +native("Windows shared prefix preserves unrelated package and shims while executing x64 backing", async () => { + // Use the existing Windows compiler, with no package install or downloaded binary. + // This is a CLR x64 executable fixture with actual Win32 Job observation, not vendor Codex. + const build = mkdtempSync(join(parse(process.cwd()).root, "ocx-win-apply-")); roots.push(build); + const source = join(build, "Backing.cs"), exe = join(build, "backing.exe"), trace = join(build, "native.json"); + write(source, [ + 'using System; using System.IO; using System.Runtime.InteropServices;', + 'class Backing {', + '[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();', + '[DllImport("kernel32.dll")] static extern bool IsProcessInJob(IntPtr p, IntPtr j, out bool inside);', + 'static int Main(string[] args) { bool inside;', + 'if(IntPtr.Size!=8 || args.Length!=1 || args[0]!="--version" || !IsProcessInJob(GetCurrentProcess(),IntPtr.Zero,out inside) || !inside)return 93;', + 'var self=System.Diagnostics.Process.GetCurrentProcess();', + `File.WriteAllText(@"${trace.replaceAll('"', '""')}", "{\\"inJob\\":true,\\"x64\\":true,\\"pid\\":"+self.Id+",\\"startUtc\\":\\""+self.StartTime.ToUniversalTime().ToString("o")+"\\"}");`, + 'Console.WriteLine("codex-cli 1.1.0"); return 0; } }', + ].join("\n")); + const compiler = join(process.env.SystemRoot ?? "C:/Windows", "Microsoft.NET/Framework64/v4.0.30319/csc.exe"); + expect(existsSync(compiler), "x64 fixture compiler must be available; do not substitute a synthetic PE").toBe(true); + const compiled = await spawnTestChild([compiler, "/nologo", "/platform:x64", "/target:exe", `/out:${exe}`, source], + { cwd: build, env: process.env, deadlineMs: 10000 }); + expect(compiled.reaped && compiled.pipesClosed).toBe(true); expect(compiled.exitCode, compiled.stdout + compiled.stderr).toBe(0); + const f = await fixture(readFileSync(exe)); + const unrelated = { "node_modules/unrelated/package.json": '{"name":"unrelated","version":"2.0.0","bin":"index.js"}', + "node_modules/unrelated/index.js": '#!/usr/bin/env node\nconsole.log("unrelated");\n', + "unrelated": "unrelated bare shim\n", "unrelated.cmd": "unrelated cmd shim\n", "unrelated.ps1": "unrelated powershell shim\n" }; + for (const [path, bytes] of Object.entries(unrelated)) write(join(f.prefix, path), bytes); + const children: WindowsCliChild[] = []; + const id = await approved(f), result = await applyCodexCliUpdate(id, { ...f.deps, startWindowsChild: invocation => { + const child = startWindowsCliChild(invocation); children.push(child); return child; + } }); + expect(result, JSON.stringify(result)).toMatchObject({ outcome: "applied", reason: "verified_target", installerExitCode: 0, + installedVersion: "1.1.0", leaseRetained: false, cleanupErrors: [], verificationPid: null }); + const backing = JSON.parse(readFileSync(trace, "utf8")); + expect(backing).toMatchObject({ inJob: true, x64: true }); expect(backing.pid).toBeGreaterThan(0); + expect(Number.isNaN(Date.parse(backing.startUtc))).toBe(false); + expect(children).toHaveLength(2); + for (const child of children) { + expect(child.observe()).toMatchObject({ terminal: true, unknown: false }); + expect((await child.wait()).cleanupComplete).toBe(true); expect(child.native.handles.size).toBe(0); + } + // Preserve actual descendant identity in the supervisor's bounded test log. + console.log("owned x64 fixture", JSON.stringify({ backing, jobs: children.map(c => ({ pid: c.pid, start: c.creationTime })) })); + for (const [path, bytes] of Object.entries(unrelated)) expect(readFileSync(join(f.prefix, path), "utf8")).toBe(bytes); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); +}, 60000); + +native.each(["package", "shim", "new-entry"] as const)("Windows shared prefix unexpected %s mutation is ambiguous", async kind => { + const f = await fixture(); + write(join(f.prefix, "node_modules/unrelated/package.json"), '{"name":"unrelated","version":"2.0.0"}'); + write(join(f.prefix, "unrelated.cmd"), "original"); + const id = await approved(f); + const result = await applyCodexCliUpdate(id, { ...f.deps, install: async () => { + for (const [path, bytes] of Object.entries(f.target.mainFiles)) write(join(f.prefix, mainRoot, path), bytes); + for (const [path, bytes] of Object.entries(f.target.backingFiles)) write(join(f.prefix, backingRoot, path), bytes); + write(join(f.prefix, kind === "package" ? "node_modules/unrelated/package.json" : kind === "shim" ? "unrelated.cmd" : "unexpected"), "changed"); + return { terminal: true, exitCode: 0 }; + } }); + expect(result).toMatchObject({ outcome: "ambiguous", leaseRetained: false }); +}, 60000); + +native("Windows shared namespace still refuses linked unrelated entries", async () => { + const f = await fixture(), outside = join(f.root, "outside"), alias = join(f.prefix, "node_modules/unrelated"); + mkdirSync(outside); write(join(outside, "value"), "must not be followed or changed"); + symlinkSync(outside, alias, "junction"); + expect(windowsInstallNamespaceSupported(f.prefix)).toBe(false); + expect((await createCodexCliUpdatePlan(f.deps)).refusal).toBe("windows_apply_disabled"); + unlinkSync(alias); + linkSync(join(outside, "value"), join(f.prefix, "unrelated.cmd")); + expect(windowsInstallNamespaceSupported(f.prefix)).toBe(false); + expect(readFileSync(join(outside, "value"), "utf8")).toBe("must not be followed or changed"); +}, 60000); + +native.each(["installer", "version"] as const)("Windows %s terminal EOF with native CloseHandle failure retains owner and reports cleanup", async policy => { + const f = await fixture(), id = await approved(f); + const entry = join(f.root, "cleanup.test.ts"), receipt = join(f.root, "cleanup.json"); + const literals = JSON.stringify({ id, inspectionDeps: f.deps.inspectionDeps, target: f.deps.resolveTarget(), + main: f.target.main.toString("base64"), backing: f.target.backing.toString("base64"), receipt, prefix: f.prefix, policy, + mainFiles: f.target.mainFiles, backingFiles: Object.fromEntries(Object.entries(f.target.backingFiles).map(([p, b]) => [p, Buffer.from(b).toString("base64")])) }); + write(entry, [ + 'import { test, expect } from "bun:test";', + 'import { existsSync, writeFileSync, readFileSync } from "node:fs";', + 'import { join } from "node:path";', + `import { applyCodexCliUpdate } from ${JSON.stringify(repoPath("src/codex/cli-update-apply.ts"))};`, + `import { WindowsChildNative } from ${JSON.stringify(repoPath("src/codex/windows-cli-child-native.ts"))};`, + `import { startWindowsCliChildWithNative } from ${JSON.stringify(repoPath("src/codex/windows-cli-child.ts"))};`, + `import { normalizeCodexCliJobResult } from ${JSON.stringify(repoPath("src/codex/cli-update-job-execution.ts"))};`, + `import { projectCodexCliUpdateJobLifecycle } from ${JSON.stringify(repoPath("src/codex/cli-update-job.ts"))};`, + `const f=${literals};`, + 'test("terminal cleanup ownership",async()=>{ let child, realClose; const native=new WindowsChildNative();', + 'try { const result=await applyCodexCliUpdate(f.id,{platform:"win32",inspectionDeps:f.inspectionDeps,', + 'resolveTarget:()=>f.target,scanProcesses:()=>({kind:"observed",processes:[]}),', + 'fetchArtifact:async a=>Buffer.from(a.installName==="@openai/codex"?f.main:f.backing,"base64"),', + 'install:f.policy==="version"?async()=>{ for(const [p,b] of Object.entries(f.mainFiles))writeFileSync(join(f.prefix,"node_modules/@openai/codex",p),b);', + 'for(const [p,b] of Object.entries(f.backingFiles))writeFileSync(join(f.prefix,"node_modules/@openai/codex-win32-x64",p),Buffer.from(b,"base64"));return {terminal:true,exitCode:0}; }:undefined,', + 'startWindowsChild: invocation=>{ child=startWindowsCliChildWithNative(invocation,native);realClose=native.k.CloseHandle;', + 'native.k.CloseHandle=h=>h===child.process?0:realClose(h);return child; }});', + 'expect(child.observe().terminal).toBe(true);expect(child.pipe.eof).toBe(true);', + 'expect((await child.wait()).cleanupComplete).toBe(false);expect(native.handles.has(child.process)).toBe(true);', + 'expect(result).toMatchObject({outcome:"ambiguous",reason:"child_cleanup_unverified",leaseRetained:true,cleanupErrors:["child_cleanup_failed"]});', + 'expect(JSON.parse(readFileSync(join(f.prefix,"node_modules/@openai/codex/package.json"),"utf8")).version).toBe("1.1.0");', + 'expect(normalizeCodexCliJobResult(result)).toEqual(result);', + 'expect(projectCodexCliUpdateJobLifecycle({...result,status:"finished",active:false})).toMatchObject({childState:"terminal",leaseState:"retained"});', + 'const stage=existsSync(join(f.prefix,".ocx-codex-cli-apply.lock/stage"));expect(stage).toBe(true);', + 'writeFileSync(f.receipt,JSON.stringify({result,stage,terminal:child.observe().terminal,handles:native.handles.size}));', + '} finally { if(realClose)native.k.CloseHandle=realClose; for(const h of [...native.handles])native.close(h);expect(native.dispose()).toBe(true); }', + '},60000);', + ].join("\n")); + const result = await spawnTestChild([process.execPath, "--no-env-file", "--no-install", "test", "--timeout", "60000", entry], + { cwd: repoRoot(), env: process.env, deadlineMs: 55000 }); + expect(result.reaped && result.pipesClosed).toBe(true); expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(receipt, "utf8"))).toMatchObject({ terminal: true, handles: 1, stage: true }); + // Retained authority is intentionally not released by production. This isolated + // owner has exited, so only now may afterEach remove its private fixture prefix. +}, 60000); + +native("Windows shared prefix terminal no-op preserves full observed original", async () => { + const f = await fixture(); + write(join(f.prefix, "node_modules/unrelated/package.json"), '{"name":"unrelated","version":"2.0.0"}'); + write(join(f.prefix, "unrelated.cmd"), "original"); + const id = await approved(f); + const result = await applyCodexCliUpdate(id, { ...f.deps, install: async () => ({ terminal: true, exitCode: 0 }) }); + expect(result).toMatchObject({ outcome: "not_applied", reason: "original_install_unchanged", leaseRetained: false, cleanupErrors: [] }); +}, 60000); diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index 600b802ca2..124cdb0682 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -76,7 +76,7 @@ native("a production plan is deterministic while Windows apply stays explicitly expect((await createCodexCliUpdatePlan(deps)).planId).not.toBe(first.planId); }); -native("a Windows plan id with applicable=false cannot enter a management apply job", async () => { +native("an unsafe Windows ledger refuses admission and a disabled plan never installs", async () => { const f = fixture(); const deps = { inspectionDeps: f.deps, resolveTarget: () => ({ kind: "resolved" as const, version: "1.2.4", integrity: "sha512-" + Buffer.alloc(64, 1).toString("base64") }), @@ -98,13 +98,14 @@ native("a Windows plan id with applicable=false cannot enter a management apply syncClaudeAgentDefsBestEffort: async () => {} }; const response = await handleCodexCliUpdateRoutes(ctx); expect(response?.status).toBe(503); - expect(await response?.json()).toEqual({ error: "unsupported_store" }); + expect(await response?.json()).toEqual({ error: "unsafe_store" }); let leaseAttempts = 0, installAttempts = 0; const apply = await applyCodexCliUpdate(plan.planId!, { platform: "win32", inspectionDeps: f.deps, acquireLease: () => { leaseAttempts += 1; return null; }, + resolveTarget: deps.resolveTarget, scanProcesses: deps.scanProcesses, install: async () => { installAttempts += 1; return { terminal: true, exitCode: 0 }; } }); - expect(apply).toMatchObject({ outcome: "refused", reason: "windows_inspection_deferred" }); + expect(apply).toMatchObject({ outcome: "refused", reason: "windows_apply_disabled" }); expect(leaseAttempts).toBe(0); expect(installAttempts).toBe(0); } finally { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 78746fe65b..df58c53bbb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "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", + "codex-cli-update-windows-apply.test.ts": "codex-integration", "codex-cli-update-windows-layout.test.ts": "codex-integration", "bridge-terminal-refusal.test.ts": "adapters", "codex-cli-windows-child.test.ts": "codex-integration", From b7c357add1a09117f16e51d4ebd35ca645b4b5e5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:16:23 +0900 Subject: [PATCH 07/31] test(codex): bind Windows plan expectations to admission evidence --- .../codex-cli-windows-plan.test.ts | 70 +++++++++++++++---- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index 124cdb0682..264b28a0a4 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -1,25 +1,27 @@ import { afterEach, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, parse } from "node:path"; import { inspectCodexCliInstall, MAX_NATIVE_BACKING_BYTES } from "../../src/codex/cli-install-provenance"; import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; import type { ManagementContext } from "../../src/server/management/context"; import { setWindowsInstallationFilesOpenedForTests } from "../../src/codex/windows-installation-files"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { removeTreeAfterBackgroundWork, removeTreeWithRetry } from "../helpers/remove-tree"; +import { hardenSecretDirAsync } from "../../src/lib/windows-secret-acl"; +import { spawnTestChild } from "../helpers/spawn-test-child"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; const roots: string[] = []; -afterEach(() => { +afterEach(async () => { initializeNodeLauncherContext(["bun", "cli"], {}); setWindowsInstallationFilesOpenedForTests(); - for (const root of roots.splice(0)) removeTreeWithRetry(root); + for (const root of roots.splice(0)) await removeTreeAfterBackgroundWork(root); }); // npm's independent cmd-shim fixture; no candidate is ever executed. const cmd = ['@ECHO off', 'GOTO start', ':find_dp0', 'SET dp0=%~dp0', 'EXIT /b', ':start', 'SETLOCAL', @@ -27,8 +29,8 @@ const cmd = ['@ECHO off', 'GOTO start', ':find_dp0', 'SET dp0=%~dp0', 'EXIT /b', ' SET "_prog=node"', ' SET PATHEXT=%PATHEXT:;.JS;=;%', ')', '', 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js" %*', ].join('\r\n'); -function fixture(separateNodeToolchain = false) { - const root = mkdtempSync(join(tmpdir(), "ocx-windows-plan-")); roots.push(root); +function fixture(separateNodeToolchain = false, parent = tmpdir()) { + const root = mkdtempSync(join(parent, "ocx-windows-plan-")); roots.push(root); const prefix = join(root, "npm"); const toolchainPrefix = separateNodeToolchain ? join(root, "nodejs") : prefix; const npmRoot = join(toolchainPrefix, "node_modules", "npm"); @@ -78,6 +80,10 @@ native("a production plan is deterministic while Windows apply stays explicitly native("an unsafe Windows ledger refuses admission and a disabled plan never installs", async () => { const f = fixture(); + // A TEMP ancestry can be safe on hosted Windows. Create an actual unsupported + // config-root reparse point instead of inferring unsafe ACLs from the host path. + const ledgerTarget = join(f.root, "ledger-target"); mkdirSync(ledgerTarget); + symlinkSync(ledgerTarget, f.deps.configDir, "junction"); const deps = { inspectionDeps: f.deps, resolveTarget: () => ({ kind: "resolved" as const, version: "1.2.4", integrity: "sha512-" + Buffer.alloc(64, 1).toString("base64") }), scanProcesses: () => ({ kind: "observed" as const, processes: [] }) }; @@ -99,6 +105,7 @@ native("an unsafe Windows ledger refuses admission and a disabled plan never ins const response = await handleCodexCliUpdateRoutes(ctx); expect(response?.status).toBe(503); expect(await response?.json()).toEqual({ error: "unsafe_store" }); + expect(existsSync(join(ledgerTarget, "codex-cli-update-jobs"))).toBe(false); let leaseAttempts = 0, installAttempts = 0; const apply = await applyCodexCliUpdate(plan.planId!, { platform: "win32", inspectionDeps: f.deps, @@ -268,30 +275,63 @@ native("actual CLI handler reaches production inspector and plan from the proof- expect(reached).toBe(true); }); -native("published Node CLI uses the production inspector and observed toolchain for read-only metadata", () => { +native("published Node CLI uses the production inspector and observed toolchain for read-only metadata", async () => { const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); - for (const f of [fixture(), fixture(true)]) { + for (const separate of [false, true]) { + // Keep a real supported positive path even when this host's TEMP ancestry is + // writable by other users. Only newly owned fixture directories are hardened. + const f = fixture(separate, parse(process.cwd()).root); + await hardenSecretDirAsync(f.root, { required: true }); + await hardenSecretDirAsync(f.prefix, { required: true }); const node = join(f.toolchainPrefix, "node.exe"); copyFileSync(hostNode!, node); const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; + const calls = join(f.root, "npm-calls.jsonl"), forbidden = join(f.root, "forbidden-execution"); + writeFileSync(join(f.packageRoot, "bin/codex.js"), + `require('node:fs').writeFileSync(${JSON.stringify(forbidden)},'candidate');process.exit(97);`); // Controlled registry protocol fixture; no real npm installation or network request. writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), - `const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); - const run = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { - cwd: repoRoot(), encoding: "utf8", windowsHide: true, timeout: 45_000, + `const fs=require('node:fs'), args=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(calls)},JSON.stringify(args)+'\\n');` + + `if(args[0]!=='view'){fs.writeFileSync(${JSON.stringify(forbidden)},'npm mutation');process.exit(98);}` + + `const spec=args[1];console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); + const controlled = await createCodexCliUpdatePlan({ inspectionDeps: f.deps, + scanProcesses: () => ({ kind: "observed", processes: [] }) }); + expect(controlled).toMatchObject({ applicable: true, refusal: null, session: { state: "none", matches: 0 } }); + const before = [...f.files.keys()].map(path => readFileSync(path)); + writeFileSync(calls, ""); + const run = await spawnTestChild([node, repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { + cwd: repoRoot(), deadlineMs: 45_000, env: { ...process.env, PATH: f.toolchainPrefix + ";" + process.env.PATH, CODEX_CLI_PATH: f.deps.env.CODEX_CLI_PATH, OPENCODEX_HOME: f.deps.configDir, OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, }); - expect(run.error).toBeUndefined(); expect(run.status).toBe(0); + expect(run.reaped && run.pipesClosed).toBe(true); expect(run.timedOut).toBe(false); expect(run.exitCode, run.stderr).toBe(0); const plan = JSON.parse(run.stdout); expect(plan.managed).toBe(true); expect(plan.targetVersion).toBe("1.2.4"); expect(plan.targetIntegrity).toBe(integrity); - expect(plan.applicable).toBe(false); - expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); + // The public CLI scans real sessions. Assert the exact evidence-dependent + // contract, never a permissive list of unrelated refusal reasons. + if (plan.session.state === "active") { + expect(plan.session.matches).toBeGreaterThan(0); + expect(plan.applicable).toBe(false); expect(plan.refusal).toBe("blocked_active_session"); + } else if (plan.session.state === "unknown") { + expect(plan.session.matches).toBeNull(); + expect(plan.applicable).toBe(false); expect(plan.refusal).toBe("blocked_process_state_unknown"); + } else { + expect(plan.session).toEqual({ state: "none", matches: 0 }); + expect(plan.applicable).toBe(true); expect(plan.refusal).toBeNull(); + expect(plan.planId).toBe(controlled.planId); + } + const queries = readFileSync(calls, "utf8").trim().split("\n").map(line => JSON.parse(line)); + expect(queries.map(args => args.slice(0, 2))).toEqual([ + ["view", "@openai/codex@latest"], ["view", "@openai/codex@1.2.4"], ["view", "@openai/codex-win32-x64@1.2.4"], + ]); + expect(existsSync(forbidden)).toBe(false); + expect([...f.files.keys()].map(path => readFileSync(path))).toEqual(before); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); expect(JSON.stringify(plan)).not.toContain(f.root); } }, 60_000); From 4b87e15aa05e0cb191098699cba450f21d7992ba Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:27:12 +0900 Subject: [PATCH 08/31] test(codex): harden owned Windows plan authority directories --- tests/codex-integration/codex-cli-windows-plan.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index 264b28a0a4..549a26bac0 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -281,8 +281,13 @@ native("published Node CLI uses the production inspector and observed toolchain // Keep a real supported positive path even when this host's TEMP ancestry is // writable by other users. Only newly owned fixture directories are hardened. const f = fixture(separate, parse(process.cwd()).root); - await hardenSecretDirAsync(f.root, { required: true }); - await hardenSecretDirAsync(f.prefix, { required: true }); + // The helper hardens one entry, not every already-created descendant's DACL. + // Bind the fixture's full package authority chain explicitly, without /T or + // changing the volume root or any pre-existing host directory. + for (const directory of [f.root, f.prefix, join(f.prefix, "node_modules"), + join(f.prefix, "node_modules", "@openai"), f.packageRoot]) { + await hardenSecretDirAsync(directory, { required: true }); + } const node = join(f.toolchainPrefix, "node.exe"); copyFileSync(hostNode!, node); const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, From fe46d32401e7a99be0c6c8461c2a469458d0f8be Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 15:27:10 +0900 Subject: [PATCH 09/31] docs(cli): align localized Windows codex-cli-update toolchain contract Align the seven locale summaries (fr, ja, ko, ru, tr, zh-cn, zh-tw) in cli.md and cli/agents.md with the English contract: the held-bound Node/npm toolchain is prefix-local when present, otherwise the captured-PATH pair anchored to the launcher architecture; a managed Windows result classifies the observed installation layout only and does not guarantee which interpreter a later codex.cmd resolves; PATH-only selection, OpenCodex autostart wrappers, and global/vendor fallback stay deferred with apply disabled. --- docs-site/src/content/docs/fr/reference/cli.md | 2 +- docs-site/src/content/docs/fr/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ja/reference/cli.md | 2 +- docs-site/src/content/docs/ja/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ko/reference/cli.md | 2 +- docs-site/src/content/docs/ko/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ru/reference/cli.md | 2 +- docs-site/src/content/docs/ru/reference/cli/agents.md | 2 +- docs-site/src/content/docs/tr/reference/cli.md | 2 +- docs-site/src/content/docs/tr/reference/cli/agents.md | 2 +- docs-site/src/content/docs/zh-cn/reference/cli.md | 2 +- docs-site/src/content/docs/zh-cn/reference/cli/agents.md | 2 +- docs-site/src/content/docs/zh-tw/reference/cli.md | 2 +- docs-site/src/content/docs/zh-tw/reference/cli/agents.md | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 015a3e3aaf..b2b8edace1 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -17,7 +17,7 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. -`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows x64 prend aussi en charge un `codex.cmd` npm standard absolu lié à la preuve, avec Node/npm locaux au préfixe et une plateforme de support vérifiée dans le préfixe, en utilisant des handles maintenus qui refusent la réanalyse. Les autres dispositions Windows restent différées ou non gérées. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Une installation Windows prise en charge peut produire un plan en lecture seule déterministe avec `applicable: false` et `refusal: windows_apply_disabled` ; la sélection par PATH seul, un Node de préfixe distinct et les wrappers de démarrage automatique OpenCodex restent différés. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows x64 prend aussi en charge un `codex.cmd` npm standard absolu lié à la preuve, avec une chaîne d’outils Node/npm liée par les handles — locale au préfixe quand elle est présente, sinon la paire PATH capturée ancrée à l’architecture du lanceur — et une plateforme de support vérifiée dans le préfixe, en utilisant des handles maintenus qui refusent la réanalyse. Les autres dispositions Windows restent différées ou non gérées. Un résultat `managed` Windows ne décrit que la disposition d’installation observée : le Node/npm lié est la chaîne d’outils de métadonnées en lecture seule de l’outil de mise à jour, et non une garantie sur l’interpréteur qu’une invocation ultérieure de `codex.cmd` résoudra. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Une installation Windows prise en charge peut produire un plan en lecture seule déterministe avec `applicable: false` et `refusal: windows_apply_disabled` ; la sélection par PATH seul, les wrappers de démarrage automatique OpenCodex et les replis global/vendor restent différés. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Pour observer une installation Windows x64, consultez [`attest`](/fr/reference/cli/agents/). Sans chemins explicites, la commande observe le candidat sélectionné identifié par l’instantané du lanceur lié à la preuve ; aucune autorité de mise à jour ni sélection du runtime n’est attestée. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index e788fa47bb..05c0fba25d 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -287,7 +287,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows x64 prend aussi en charge un `codex.cmd` npm standard absolu lié à la preuve, avec Node/npm locaux au préfixe et une plateforme de support vérifiée dans le préfixe, en utilisant des handles maintenus qui refusent la réanalyse. Les autres dispositions Windows restent différées ou non gérées. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Une installation Windows prise en charge peut produire un plan en lecture seule déterministe avec `applicable: false` et `refusal: windows_apply_disabled` ; la sélection par PATH seul, un Node de préfixe distinct et les wrappers de démarrage automatique OpenCodex restent différés. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows x64 prend aussi en charge un `codex.cmd` npm standard absolu lié à la preuve, avec une chaîne d’outils Node/npm liée par les handles — locale au préfixe quand elle est présente, sinon la paire PATH capturée ancrée à l’architecture du lanceur — et une plateforme de support vérifiée dans le préfixe, en utilisant des handles maintenus qui refusent la réanalyse. Les autres dispositions Windows restent différées ou non gérées. Un résultat `managed` Windows ne décrit que la disposition d’installation observée : le Node/npm lié est la chaîne d’outils de métadonnées en lecture seule de l’outil de mise à jour, et non une garantie sur l’interpréteur qu’une invocation ultérieure de `codex.cmd` résoudra. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Une installation Windows prise en charge peut produire un plan en lecture seule déterministe avec `applicable: false` et `refusal: windows_apply_disabled` ; la sélection par PATH seul, les wrappers de démarrage automatique OpenCodex et les replis global/vendor restent différés. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Sous Windows, une commande simple capturée comme `CODEX_CLI_PATH=codex`, un chemin distant ou un chemin de périphérique produit plutôt `candidate_path_unavailable`. Le candidat a été capturé, mais son chemin ne convient pas à cette inspection. diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 4762c9a1fa..2be6fe58d1 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -20,7 +20,7 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 -`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows x64 は、証明にバインドされた絶対パスの標準 npm `codex.cmd` とプレフィックスローカルの Node/npm、検証済みのインプレフィックスプラットフォームバッキングもサポートし、リパースを拒否する保持ハンドルを使用します。他の Windows レイアウトは引き続き延期または非管理です。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。サポートされている Windows インストールは `applicable: false` と `refusal: windows_apply_disabled` を持つ決定論的な読み取り専用 plan を生成でき、PATH のみの選択・別プレフィックスの Node・OpenCodex 自動起動ラッパーは引き続き延期されます。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows x64 は、証明にバインドされた絶対パスの標準 npm `codex.cmd` と保持ハンドルにバインドされた Node/npm ツールチェーン(存在する場合はプレフィックスローカル、存在しないことが確認された場合はランチャーアーキテクチャにアンカーされたキャプチャ済み PATH ペア)、検証済みのインプレフィックスプラットフォームバッキングもサポートし、リパースを拒否する保持ハンドルを使用します。他の Windows レイアウトは引き続き延期または非管理です。Windows の `managed` 結果は観測されたインストールレイアウトのみを分類し、バインドされた Node/npm はアップデーターの読み取り専用メタデータツールチェーンであって、後の `codex.cmd` 呼び出しがどのインタープリターを解決するかを保証するものではありません。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。サポートされている Windows インストールは `applicable: false` と `refusal: windows_apply_disabled` を持つ決定論的な読み取り専用 plan を生成でき、PATH のみの選択・OpenCodex 自動起動ラッパー・global/vendor フォールバックは引き続き延期されます。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows x64 インストールの観測は [`attest` コマンド](/ja/reference/cli/agents/)を参照してください。明示パスを省略すると、証明にバインドされたランチャースナップショットが特定した選択候補を観測します。更新権限やランタイム選択は証明しません。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index fa91fed772..9a23067b60 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -218,7 +218,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows x64 は、証明にバインドされた絶対パスの標準 npm `codex.cmd` とプレフィックスローカルの Node/npm、検証済みのインプレフィックスプラットフォームバッキングもサポートし、リパースを拒否する保持ハンドルを使用します。他の Windows レイアウトは引き続き延期または非管理です。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。サポートされている Windows インストールは `applicable: false` と `refusal: windows_apply_disabled` を持つ決定論的な読み取り専用 plan を生成でき、PATH のみの選択・別プレフィックスの Node・OpenCodex 自動起動ラッパーは引き続き延期されます。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows x64 は、証明にバインドされた絶対パスの標準 npm `codex.cmd` と保持ハンドルにバインドされた Node/npm ツールチェーン(存在する場合はプレフィックスローカル、存在しないことが確認された場合はランチャーアーキテクチャにアンカーされたキャプチャ済み PATH ペア)、検証済みのインプレフィックスプラットフォームバッキングもサポートし、リパースを拒否する保持ハンドルを使用します。他の Windows レイアウトは引き続き延期または非管理です。Windows の `managed` 結果は観測されたインストールレイアウトのみを分類し、バインドされた Node/npm はアップデーターの読み取り専用メタデータツールチェーンであって、後の `codex.cmd` 呼び出しがどのインタープリターを解決するかを保証するものではありません。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。サポートされている Windows インストールは `applicable: false` と `refusal: windows_apply_disabled` を持つ決定論的な読み取り専用 plan を生成でき、PATH のみの選択・OpenCodex 自動起動ラッパー・global/vendor フォールバックは引き続き延期されます。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows で `CODEX_CLI_PATH=codex` のような単純なコマンド名、リモートパス、デバイスパスが候補としてキャプチャされた場合は、`candidate_path_unavailable` を報告します。候補は取得されていますが、そのパスはこの検査の対象になりません。 diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 9b02fb11fc..124b2050da 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. -`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows x64는 증명에 바인딩된 절대 표준 npm `codex.cmd`와 프리픽스 로컬 Node/npm, 검증된 인프리픽스 플랫폼 백킹도 지원하며 reparse를 거부하는 보유 핸들을 사용합니다. 다른 Windows 레이아웃은 계속 연기되거나 관리되지 않습니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. 지원되는 Windows 설치는 `applicable: false`와 `refusal: windows_apply_disabled`를 가진 결정적 읽기 전용 plan을 생성할 수 있으며 PATH 전용 선택·분리된 프리픽스 Node·OpenCodex 자동 시작 래퍼는 계속 연기됩니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows x64는 증명에 바인딩된 절대 표준 npm `codex.cmd`와 보유 핸들로 바인딩된 Node/npm 도구체인(있으면 프리픽스 로컬, 없다고 확인되면 런처 아키텍처에 고정된 캡처된 PATH 쌍), 검증된 인프리픽스 플랫폼 백킹도 지원하며 reparse를 거부하는 보유 핸들을 사용합니다. 다른 Windows 레이아웃은 계속 연기되거나 관리되지 않습니다. Windows의 `managed` 결과는 관측된 설치 레이아웃만 분류하며, 바인딩된 Node/npm은 업데이터의 읽기 전용 메타데이터 도구체인일 뿐 이후 `codex.cmd` 호출이 어떤 인터프리터를 해석할지를 보증하지 않습니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. 지원되는 Windows 설치는 `applicable: false`와 `refusal: windows_apply_disabled`를 가진 결정적 읽기 전용 plan을 생성할 수 있으며 PATH 전용 선택·OpenCodex 자동 시작 래퍼·global/vendor 폴백은 계속 연기됩니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참조하세요. 명시적 경로 없이 증명에 바인딩된 런처 스냅샷이 식별한 선택 후보를 관측하며, 업데이트 권한이나 런타임 선택을 증명하지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 8fc893a989..5f672ae539 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -256,7 +256,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows x64는 증명에 바인딩된 절대 표준 npm `codex.cmd`와 프리픽스 로컬 Node/npm, 검증된 인프리픽스 플랫폼 백킹도 지원하며 reparse를 거부하는 보유 핸들을 사용합니다. 다른 Windows 레이아웃은 계속 연기되거나 관리되지 않습니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. 지원되는 Windows 설치는 `applicable: false`와 `refusal: windows_apply_disabled`를 가진 결정적 읽기 전용 plan을 생성할 수 있으며 PATH 전용 선택·분리된 프리픽스 Node·OpenCodex 자동 시작 래퍼는 계속 연기됩니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows x64는 증명에 바인딩된 절대 표준 npm `codex.cmd`와 보유 핸들로 바인딩된 Node/npm 도구체인(있으면 프리픽스 로컬, 없다고 확인되면 런처 아키텍처에 고정된 캡처된 PATH 쌍), 검증된 인프리픽스 플랫폼 백킹도 지원하며 reparse를 거부하는 보유 핸들을 사용합니다. 다른 Windows 레이아웃은 계속 연기되거나 관리되지 않습니다. Windows의 `managed` 결과는 관측된 설치 레이아웃만 분류하며, 바인딩된 Node/npm은 업데이터의 읽기 전용 메타데이터 도구체인일 뿐 이후 `codex.cmd` 호출이 어떤 인터프리터를 해석할지를 보증하지 않습니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. 지원되는 Windows 설치는 `applicable: false`와 `refusal: windows_apply_disabled`를 가진 결정적 읽기 전용 plan을 생성할 수 있으며 PATH 전용 선택·OpenCodex 자동 시작 래퍼·global/vendor 폴백은 계속 연기됩니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows에서 `CODEX_CLI_PATH=codex` 같은 단순 명령 이름이나 원격 경로·장치 경로가 후보로 캡처되면 `candidate_path_unavailable`을 보고합니다. 후보는 캡처됐지만 해당 경로가 이 검사 대상에 적합하지 않은 경우입니다. diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index aca2b049ee..37272642e1 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -31,7 +31,7 @@ runtime port и проверку identity, а не поддерживая вто явно документированные как offline-операции с конфигурацией, вместо этого могут валидировать и редактировать файл конфигурации без живого прокси. -`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`, если он доказывает владение и, когда версия кандидата сообщается, она совпадает с версией манифеста; сохранённые кандидаты остаются неаттестованными. Windows x64 также поддерживает привязанный к доказательству абсолютный стандартный npm `codex.cmd` с локальными для префикса Node/npm и проверенной внутрипрефиксной платформенной подложкой, используя удерживаемые дескрипторы, отклоняющие reparse. Остальные раскладки Windows остаются отложенными или неуправляемыми. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. Поддерживаемая установка Windows может создать детерминированный план только для чтения с `applicable: false` и `refusal: windows_apply_disabled`; выбор только по PATH, Node из отдельного префикса и обёртки автозапуска OpenCodex остаются отложенными. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`, если он доказывает владение и, когда версия кандидата сообщается, она совпадает с версией манифеста; сохранённые кандидаты остаются неаттестованными. Windows x64 также поддерживает привязанный к доказательству абсолютный стандартный npm `codex.cmd` с цепочкой инструментов Node/npm — локальной для префикса при её наличии, иначе парой из захваченного PATH, привязанной к архитектуре средства запуска, — и проверенной внутрипрефиксной платформенной подложкой, используя удерживаемые дескрипторы, отклоняющие reparse. Остальные раскладки Windows остаются отложенными или неуправляемыми. Результат `managed` в Windows классифицирует только наблюдаемую раскладку установки: привязанные Node/npm — это цепочка инструментов метаданных средства обновления, доступная только для чтения, а не гарантия того, какой интерпретатор разрешит последующий вызов `codex.cmd`. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. Поддерживаемая установка Windows может создать детерминированный план только для чтения с `applicable: false` и `refusal: windows_apply_disabled`; выбор только по PATH, обёртки автозапуска OpenCodex и откат к global/vendor остаются отложенными. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). Для наблюдения установки Windows x64 см. [`attest`](/ru/reference/cli/agents/). Без явных путей команда наблюдает выбранного кандидата, определённого привязанным к доказательству снимком средства запуска; полномочия на обновление и выбор runtime не подтверждаются. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 1656aca847..8d4a389d3b 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -270,7 +270,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`, если он доказывает владение и, когда версия кандидата сообщается, она совпадает с версией манифеста; сохранённые кандидаты остаются неаттестованными. Windows x64 также поддерживает привязанный к доказательству абсолютный стандартный npm `codex.cmd` с локальными для префикса Node/npm и проверенной внутрипрефиксной платформенной подложкой, используя удерживаемые дескрипторы, отклоняющие reparse. Остальные раскладки Windows остаются отложенными или неуправляемыми. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. Поддерживаемая установка Windows может создать детерминированный план только для чтения с `applicable: false` и `refusal: windows_apply_disabled`; выбор только по PATH, Node из отдельного префикса и обёртки автозапуска OpenCodex остаются отложенными. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`, если он доказывает владение и, когда версия кандидата сообщается, она совпадает с версией манифеста; сохранённые кандидаты остаются неаттестованными. Windows x64 также поддерживает привязанный к доказательству абсолютный стандартный npm `codex.cmd` с цепочкой инструментов Node/npm — локальной для префикса при её наличии, иначе парой из захваченного PATH, привязанной к архитектуре средства запуска, — и проверенной внутрипрефиксной платформенной подложкой, используя удерживаемые дескрипторы, отклоняющие reparse. Остальные раскладки Windows остаются отложенными или неуправляемыми. Результат `managed` в Windows классифицирует только наблюдаемую раскладку установки: привязанные Node/npm — это цепочка инструментов метаданных средства обновления, доступная только для чтения, а не гарантия того, какой интерпретатор разрешит последующий вызов `codex.cmd`. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. Поддерживаемая установка Windows может создать детерминированный план только для чтения с `applicable: false` и `refusal: windows_apply_disabled`; выбор только по PATH, обёртки автозапуска OpenCodex и откат к global/vendor остаются отложенными. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). В Windows захваченная команда без полного пути, например `CODEX_CLI_PATH=codex`, удалённый путь или путь устройства возвращает `candidate_path_unavailable`. Кандидат захвачен, но его путь не подходит для этой проверки. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index 9422b3821d..f9f7e7cbe8 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -36,7 +36,7 @@ yönetim API'sine gidiş-dönüş yapar. Durdurulmuş veya erişilemeyen bir pro yapılandırma işlemleri olarak açıkça belgelenen komutlar, bunun yerine canlı bir proxy olmadan yapılandırma dosyasını doğrulayabilir ve düzenleyebilir. -`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa ve aday sürüm bildirildiyse bu sürüm bildirimdeki sürümle eşleşirse `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows x64 ayrıca kanıta bağlı mutlak standart npm `codex.cmd` ile önek-yerel Node/npm ve önek içinde doğrulanmış platform desteğini destekler; yeniden ayrıştırmayı reddeden tutulan tanıtıcılar kullanır. Diğer Windows düzenleri ertelenmiş veya yönetimsiz kalır. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. Desteklenen bir Windows kurulumu `applicable: false` ve `refusal: windows_apply_disabled` ile belirleyici bir salt okunur plan üretebilir; yalnızca PATH seçimi, ayrı önekli Node ve OpenCodex otomatik başlatma sarmalayıcıları ertelenmiş kalır. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. +`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa ve aday sürüm bildirildiyse bu sürüm bildirimdeki sürümle eşleşirse `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows x64 ayrıca kanıta bağlı mutlak standart npm `codex.cmd` ile tutulan tanıtıcılara bağlı bir Node/npm araç zincirini (varsa önek-yerel, yokluğu doğrulanırsa başlatıcı mimarisine bağlanmış yakalanan PATH çifti) ve önek içinde doğrulanmış platform desteğini destekler; yeniden ayrıştırmayı reddeden tutulan tanıtıcılar kullanır. Diğer Windows düzenleri ertelenmiş veya yönetimsiz kalır. Windows'taki bir `managed` sonucu yalnızca gözlemlenen kurulum düzenini sınıflandırır: bağlı Node/npm, güncelleyicinin salt okunur meta veri araç zinciridir ve sonraki bir `codex.cmd` çağrısının hangi yorumlayıcıyı çözeceğine dair bir güvence değildir. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. Desteklenen bir Windows kurulumu `applicable: false` ve `refusal: windows_apply_disabled` ile belirleyici bir salt okunur plan üretebilir; yalnızca PATH seçimi, OpenCodex otomatik başlatma sarmalayıcıları ve global/vendor geri dönüşü ertelenmiş kalır. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. Windows x64 kurulum gözlemi için [`attest`](/tr/reference/cli/agents/) komutuna bakın. Açık yollar verilmezse komut, kanıta bağlı başlatıcı anlık görüntüsünün belirlediği seçili adayı gözlemler; güncelleme yetkisi veya çalışma zamanı seçimi doğrulanmaz. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 6a52461ec5..b27dbabfd2 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -322,7 +322,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa ve aday sürüm bildirildiyse bu sürüm bildirimdeki sürümle eşleşirse `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows x64 ayrıca kanıta bağlı mutlak standart npm `codex.cmd` ile önek-yerel Node/npm ve önek içinde doğrulanmış platform desteğini destekler; yeniden ayrıştırmayı reddeden tutulan tanıtıcılar kullanır. Diğer Windows düzenleri ertelenmiş veya yönetimsiz kalır. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. Desteklenen bir Windows kurulumu `applicable: false` ve `refusal: windows_apply_disabled` ile belirleyici bir salt okunur plan üretebilir; yalnızca PATH seçimi, ayrı önekli Node ve OpenCodex otomatik başlatma sarmalayıcıları ertelenmiş kalır. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. +`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa ve aday sürüm bildirildiyse bu sürüm bildirimdeki sürümle eşleşirse `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows x64 ayrıca kanıta bağlı mutlak standart npm `codex.cmd` ile tutulan tanıtıcılara bağlı bir Node/npm araç zincirini (varsa önek-yerel, yokluğu doğrulanırsa başlatıcı mimarisine bağlanmış yakalanan PATH çifti) ve önek içinde doğrulanmış platform desteğini destekler; yeniden ayrıştırmayı reddeden tutulan tanıtıcılar kullanır. Diğer Windows düzenleri ertelenmiş veya yönetimsiz kalır. Windows'taki bir `managed` sonucu yalnızca gözlemlenen kurulum düzenini sınıflandırır: bağlı Node/npm, güncelleyicinin salt okunur meta veri araç zinciridir ve sonraki bir `codex.cmd` çağrısının hangi yorumlayıcıyı çözeceğine dair bir güvence değildir. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. Desteklenen bir Windows kurulumu `applicable: false` ve `refusal: windows_apply_disabled` ile belirleyici bir salt okunur plan üretebilir; yalnızca PATH seçimi, OpenCodex otomatik başlatma sarmalayıcıları ve global/vendor geri dönüşü ertelenmiş kalır. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. Windows üzerinde `CODEX_CLI_PATH=codex` gibi yalın bir komut, uzak yol veya aygıt yolu aday olarak yakalanırsa `candidate_path_unavailable` bildirilir. Aday yakalanmıştır; ancak yolu bu inceleme için uygun değildir. diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 80e9204f39..e5e615b1fb 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -17,7 +17,7 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 管理命令会通过实时代理的管理 API 往返调用,使用记录下来的运行时端口和身份检查,而不是维护第二条配置路径。已停止或不可达的代理会被表示为 HTTP 503,并导致 CLI 以非零状态退出。明确标注为离线配置操作的命令,则可以在没有实时代理的情况下验证并编辑配置文件。 -`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及前缀本地 Node/npm 和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。受支持的 Windows 安装可以生成 `applicable: false` 且 `refusal: windows_apply_disabled` 的确定性只读 plan;仅 PATH 选择、独立前缀的 Node 和 OpenCodex 自启动包装器仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及由持有句柄绑定的 Node/npm 工具链(存在时为前缀本地,确认不存在时为锚定到启动器架构的已捕获 PATH 对)和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。Windows 的 `managed` 结果仅对观察到的安装布局进行分类:绑定的 Node/npm 是更新器的只读元数据工具链,并不保证后续 `codex.cmd` 调用将解析到哪个解释器。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。受支持的 Windows 安装可以生成 `applicable: false` 且 `refusal: windows_apply_disabled` 的确定性只读 plan;仅 PATH 选择、OpenCodex 自启动包装器和 global/vendor 回退仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 有关 Windows x64 安装观察,请参阅 [`attest` 命令](/zh-cn/reference/cli/agents/)。未提供显式路径时,该命令观察由受证明约束的启动器快照识别出的已选候选项;它不授予更新权限,也不证明运行时选择。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 868a1553d6..20116370b8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -222,7 +222,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及前缀本地 Node/npm 和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。受支持的 Windows 安装可以生成 `applicable: false` 且 `refusal: windows_apply_disabled` 的确定性只读 plan;仅 PATH 选择、独立前缀的 Node 和 OpenCodex 自启动包装器仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及由持有句柄绑定的 Node/npm 工具链(存在时为前缀本地,确认不存在时为锚定到启动器架构的已捕获 PATH 对)和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。Windows 的 `managed` 结果仅对观察到的安装布局进行分类:绑定的 Node/npm 是更新器的只读元数据工具链,并不保证后续 `codex.cmd` 调用将解析到哪个解释器。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。受支持的 Windows 安装可以生成 `applicable: false` 且 `refusal: windows_apply_disabled` 的确定性只读 plan;仅 PATH 选择、OpenCodex 自启动包装器和 global/vendor 回退仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 在 Windows 上,如果捕获到 `CODEX_CLI_PATH=codex` 这样的裸命令、远程路径或设备路径,则报告 `candidate_path_unavailable`。这些情况下候选项已被捕获,但其路径不适用于此检查。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index ad1e14b503..94db6894e2 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -28,7 +28,7 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 設定路徑。停止或無法連線的代理以 HTTP 503 呈現,並產生非零的 CLI 離開碼。明確記載為 離線設定操作的命令,可以在沒有執行中代理的情況下驗證與編輯設定檔。 -`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及前綴本機 Node/npm 和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。受支援的 Windows 安裝可產生 `applicable: false` 且 `refusal: windows_apply_disabled` 的確定性唯讀 plan;僅 PATH 選取、獨立前綴的 Node 和 OpenCodex 自動啟動包裝器仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及由持有控制代碼繫結的 Node/npm 工具鏈(存在時為前綴本機,確認不存在時為錨定到啟動器架構的已擷取 PATH 配對)和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。Windows 的 `managed` 結果僅對觀測到的安裝配置進行分類:繫結的 Node/npm 是更新器的唯讀中繼資料工具鏈,並不保證後續 `codex.cmd` 呼叫將解析到哪個直譯器。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。受支援的 Windows 安裝可產生 `applicable: false` 且 `refusal: windows_apply_disabled` 的確定性唯讀 plan;僅 PATH 選取、OpenCodex 自動啟動包裝器和 global/vendor 後援仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 觀測 Windows x64 安裝的方式請參閱 [`attest` 命令](/zh-tw/reference/cli/agents/)。未提供明確路徑時,該命令會觀測由受證明約束的啟動器快照識別出的已選候選項;它不授予更新權限,也不證明執行階段選擇。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 5ab8a27cde..5b97e68f4b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -223,7 +223,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及前綴本機 Node/npm 和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。受支援的 Windows 安裝可產生 `applicable: false` 且 `refusal: windows_apply_disabled` 的確定性唯讀 plan;僅 PATH 選取、獨立前綴的 Node 和 OpenCodex 自動啟動包裝器仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及由持有控制代碼繫結的 Node/npm 工具鏈(存在時為前綴本機,確認不存在時為錨定到啟動器架構的已擷取 PATH 配對)和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。Windows 的 `managed` 結果僅對觀測到的安裝配置進行分類:繫結的 Node/npm 是更新器的唯讀中繼資料工具鏈,並不保證後續 `codex.cmd` 呼叫將解析到哪個直譯器。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。受支援的 Windows 安裝可產生 `applicable: false` 且 `refusal: windows_apply_disabled` 的確定性唯讀 plan;僅 PATH 選取、OpenCodex 自動啟動包裝器和 global/vendor 後援仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 在 Windows 上,如果擷取到 `CODEX_CLI_PATH=codex` 這類單純命令名稱、遠端路徑或裝置路徑,則回報 `candidate_path_unavailable`。這些情況已有擷取的候選項,但其路徑不適用於此檢查。 From 282e081d918cc7ab38e326bc1087f754d692af75 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 16:00:52 +0900 Subject: [PATCH 10/31] docs(cli): fix Windows apply canonical anchors and read-only scope wording --- docs-site/src/content/docs/reference/cli.md | 4 ++-- docs-site/src/content/docs/reference/cli/agents.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index f756fdd7d0..b5f33165c9 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -158,7 +158,7 @@ when present, otherwise the captured-PATH pair anchored to the launcher architec in-prefix platform backing. It uses reparse-refusing held handles; other Windows layouts remain deferred or unmanaged. A `managed` Windows result classifies the observed installation layout only: the bound Node/npm is the updater's read-only metadata toolchain, never a claim about which interpreter a later `codex.cmd` -invocation resolves, and planning stays read-only with apply disabled. +invocation resolves, while inspection and planning stay read-only; applicability and confirmed apply are decided separately. The command does not install or repair software, execute Codex or npm, control a running process, or write configuration/cache state. @@ -175,7 +175,7 @@ See [planning details](/reference/cli/agents/#read-only-codex-cli-update-plan). For a reviewed applicable npm-global plan, `ocx system codex-cli-update apply --plan-id --yes` performs the exact update and returns a durable job. `ocx system codex-cli-update status --json` reads its result; use a stable `--request-id` for a lost-response retry. See -[confirmation and job states](/reference/cli/agents/#confirmed-posix-codex-cli-apply-and-job-status). +[confirmation and job states](/reference/cli/agents/#confirmed-codex-cli-apply-and-job-status). For Windows x64 installation observation, see [the `attest` command](/reference/cli/agents/#explicit-installation-observation-on-windows-x64). Without explicit paths it observes the selected candidate identified from the proof-bound launcher snapshot; it does not grant update authority or attest runtime selection. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 1bda3ab4bd..0e0682b5b9 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1168,7 +1168,7 @@ can be managed when held local handles bind its package, a held-bound Node/npm t when present, otherwise the captured-PATH pair anchored to the launcher architecture — and the first in-prefix platform backing. Other Windows layouts remain deferred or unmanaged. A `managed` Windows result classifies the observed installation layout only: the bound Node/npm is the updater's read-only metadata toolchain, never a claim -about which interpreter a later `codex.cmd` invocation resolves, and planning stays read-only with apply disabled. +about which interpreter a later `codex.cmd` invocation resolves, while inspection and planning stay read-only; applicability and confirmed apply are decided separately. Windows never consults persisted state, so a run with no captured environment candidate reports `windows_inspection_deferred` rather than `candidate_unavailable`: the command cannot observe whether a Codex CLI is installed, so it reports the deferral instead of asserting From 72991b7ae58589111079a8bb3558a73581072bf4 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 16:09:03 +0900 Subject: [PATCH 11/31] test(codex): observe install content before Windows toolchain pin assertions --- .../codex-integration/codex-cli-update-windows-apply.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-windows-apply.test.ts b/tests/codex-integration/codex-cli-update-windows-apply.test.ts index 911ff09dc4..187e150909 100644 --- a/tests/codex-integration/codex-cli-update-windows-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-apply.test.ts @@ -222,7 +222,7 @@ native("Windows toolchain pins survive an awaited callback; bounded shared prefi write(join(tools, "node.exe"), f.old.backingFiles[nativePath]); write(join(tools, "node_modules/npm/package.json"), JSON.stringify({ name: "npm", version: "11.0.0", bin: { npm: "bin/npm-cli.js" } })); write(npmCli, "// never executed\n"); f.deps.inspectionDeps.env.PATH = tools; - const report = await inspectCodexCliInstall(f.deps.inspectionDeps); + const report = await inspectCodexCliInstall({ ...f.deps.inspectionDeps, observeInstallContent: true }); expect(report.managed).toBe(true); expect(await withWindowsCodexCliPlanToolchain(report, async () => { await Bun.sleep(10); @@ -231,7 +231,7 @@ native("Windows toolchain pins survive an awaited callback; bounded shared prefi expect(() => writeFileSync(npmCli, "replacement")).toThrow(); return 42; })).toBe(42); writeFileSync(npmCli, "// changed after release\n"); - const refreshed = await inspectCodexCliInstall(f.deps.inspectionDeps); + const refreshed = await inspectCodexCliInstall({ ...f.deps.inspectionDeps, observeInstallContent: true }); const pin = await holdWindowsCodexCliPlanToolchain(refreshed); expect(pin).not.toBeNull(); expect(await pin!.release()).toBe(true); mkdirSync(join(f.prefix, "node_modules", "unrelated")); expect((await createCodexCliUpdatePlan(f.deps)).applicable).toBe(true); From 695ec0c86b9904e6823805e624c7b620c6c74be1 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:09:38 +0900 Subject: [PATCH 12/31] test: cover separate Windows toolchain planning in workers --- .../codex-cli-windows-plan.test.ts | 58 ++++++++++++++++++- .../helpers/cli-windows-plan-drift-worker.ts | 34 +++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/helpers/cli-windows-plan-drift-worker.ts diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index 1666400b24..1e86c90925 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { inspectCodexCliInstall, MAX_NATIVE_BACKING_BYTES } from "../../src/codex/cli-install-provenance"; @@ -379,6 +379,62 @@ native("a fresh production Worker rebuilds private Windows plan evidence before } }, 60_000); +function separateWorkerFixture() { + const f = fixture(true), hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); + copyFileSync(hostNode!, join(f.toolchainPrefix, "node.exe")); + expect(existsSync(join(f.prefix, "node.exe"))).toBe(false); + const marker = join(f.root, "metadata-ran"), integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); + const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; + const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, + tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; + writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), + `require('node:fs').appendFileSync(${JSON.stringify(marker)}, 'metadata\\n'); const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); + return { ...f, marker, integrity }; +} + +async function separateWorkerPlan(f: ReturnType, arch: "x64" | "arm64" | null, drift = false) { + let starts = 0, closed = false; + const plan = await runCodexCliUpdatePlanInWorker({ + snapshot: { codexCliPath: f.deps.env.CODEX_CLI_PATH, path: f.toolchainPrefix, pathExt: ".EXE;.CMD", + home: f.root, nodePath: null, managerRoots: {}, transportEnv: {}, configDir: f.deps.configDir, arch }, + spawnWorkerFn: (url, key) => { + expect(key).toBe("cli-update-worker"); starts++; + // Drift needs a test-only injection seam; the positive and arch refusals use the production entry. + const worker = new Worker(drift ? repoPath("tests", "helpers", "cli-windows-plan-drift-worker.ts") : url, { type: "module" }); + liveWorkerRoots.add(f.root); + worker.addEventListener("close", () => { closed = true; liveWorkerRoots.delete(f.root); }, { once: true }); + return worker; + }, + }); + // afterEach retains the root if dispatch/test timeout occurs without real close. + expect(starts).toBe(1); expect(closed).toBe(true); + return plan; +} + +native("a fresh production Worker binds a separate Node/npm metadata vehicle with the launcher arch", async () => { + const f = separateWorkerFixture(), plan = await separateWorkerPlan(f, "x64"); + expect(plan).toMatchObject({ managed: true, installedVersion: "1.2.3", targetVersion: "1.2.4", + targetIntegrity: f.integrity, applicable: false }); + expect(plan.installContentDigest).toMatch(/^[a-f0-9]{64}$/); + expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); + expect(readFileSync(f.marker, "utf8")).toContain("metadata"); + expect(JSON.stringify(plan)).not.toContain(f.root); +}, 60_000); + +native("fresh Worker realms refuse missing or mismatched arch and reject post-inspection npm drift before metadata", async () => { + for (const mode of ["missing", "mismatch", "drift"] as const) { + const f = separateWorkerFixture(); + const plan = await separateWorkerPlan(f, mode === "missing" ? null : mode === "mismatch" ? "arm64" : "x64", mode === "drift"); + expect(plan).toMatchObject({ managed: mode === "drift", applicable: false, targetVersion: null, + refusal: mode === "drift" ? "target_unresolved" : "windows_inspection_deferred" }); + if (mode === "drift") expect(plan.installContentDigest).toMatch(/^[a-f0-9]{64}$/); + else expect(plan.installContentDigest).toBeNull(); + expect(existsSync(f.marker)).toBe(false); + expect(JSON.stringify(plan)).not.toContain(f.root); + } +}, 60_000); + native("published Node CLI uses the production inspector and observed toolchain for read-only metadata", () => { const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); for (const f of [fixture(), fixture(true)]) { diff --git a/tests/helpers/cli-windows-plan-drift-worker.ts b/tests/helpers/cli-windows-plan-drift-worker.ts new file mode 100644 index 0000000000..2ec60a37bb --- /dev/null +++ b/tests/helpers/cli-windows-plan-drift-worker.ts @@ -0,0 +1,34 @@ +/** Test-only post-inspection drift seam; this is NOT the production Worker entry. */ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { inspectCodexCliInstall } from "../../src/codex/cli-install-provenance"; +import { codexCliUpdateInspectionDepsFromSnapshot } from "../../src/codex/cli-update-inspection-context"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import type { CodexCliUpdateWorkerRunMessage } from "../../src/codex/cli-update-worker"; + +declare const self: Worker; +if (typeof Bun !== "undefined" && Bun.isMainThread === false) { + let ran = false; + self.onmessage = async ({ data }: MessageEvent) => { + if (ran || data.type !== "run" || data.operation !== "plan") return; + ran = true; + try { + const snapshot = data.snapshot; + if (!snapshot?.path || !snapshot.home) throw new Error("fixture_snapshot_missing"); + const plan = await createCodexCliUpdatePlan({ + inspectionDeps: codexCliUpdateInspectionDepsFromSnapshot(snapshot), + inspect: async deps => { + const report = await inspectCodexCliInstall(deps); + if (!report.managed || !report.installContentDigest) throw new Error("fixture_inspection_missing"); + // Same realm/report/private evidence; only the previously observed npm bytes change. + writeFileSync(join(snapshot.path!, "node_modules", "npm", "bin", "npm-cli.js"), + `require('node:fs').writeFileSync(${JSON.stringify(join(snapshot.home!, "metadata-ran"))}, 'unexpected'); process.exit(97);`); + return report; + }, + }); + self.postMessage({ type: "done", requestId: data.requestId, jobId: data.jobId, operation: "plan", plan }); + } catch { + self.postMessage({ type: "error", requestId: data.requestId, jobId: data.jobId, message: "fixture_worker_failed" }); + } finally { self.onmessage = null; } + }; +} From ba22f168585ef282dd7734e8eb1b480e34035ac6 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:43:21 +0900 Subject: [PATCH 13/31] test: avoid executable copies for pre-metadata arch refusals --- .../codex-integration/codex-cli-windows-plan.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index 1e86c90925..d61472af54 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -379,9 +379,12 @@ native("a fresh production Worker rebuilds private Windows plan evidence before } }, 60_000); -function separateWorkerFixture() { - const f = fixture(true), hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); - copyFileSync(hostNode!, join(f.toolchainPrefix, "node.exe")); +function separateWorkerFixture(copyExecutable = true) { + const f = fixture(true); + if (copyExecutable) { + const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); + copyFileSync(hostNode!, join(f.toolchainPrefix, "node.exe")); + } expect(existsSync(join(f.prefix, "node.exe"))).toBe(false); const marker = join(f.root, "metadata-ran"), integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, @@ -424,7 +427,7 @@ native("a fresh production Worker binds a separate Node/npm metadata vehicle wit native("fresh Worker realms refuse missing or mismatched arch and reject post-inspection npm drift before metadata", async () => { for (const mode of ["missing", "mismatch", "drift"] as const) { - const f = separateWorkerFixture(); + const f = separateWorkerFixture(mode === "drift"); const plan = await separateWorkerPlan(f, mode === "missing" ? null : mode === "mismatch" ? "arm64" : "x64", mode === "drift"); expect(plan).toMatchObject({ managed: mode === "drift", applicable: false, targetVersion: null, refusal: mode === "drift" ? "target_unresolved" : "windows_inspection_deferred" }); From 9027a5149e6d6725aafeed386ec973d012c3a582 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:47:09 +0900 Subject: [PATCH 14/31] Integrate Windows native apply commit fencing and Worker lifetime retention --- src/codex/cli-update-apply.ts | 2 + src/codex/cli-update-job-store-windows.ts | 12 +- src/codex/cli-update-selection-gate.ts | 8 +- src/codex/cli-update-windows-apply.ts | 115 ++++++++++++------ src/codex/cli-update-worker.ts | 26 +++- src/codex/shim.ts | 24 +++- structure/INDEX.md | 1 + structure/codex-cli-update.md | 28 +++-- structure/manifest.json | 1 + .../codex-cli-update-windows-apply.test.ts | 93 +++++++++++++- .../codex-cli-update-worker.test.ts | 15 +++ .../codex-cli-windows-plan.test.ts | 5 +- .../codex-shim-windows-revision.test.ts | 18 ++- tests/helpers/windows-apply-worker.ts | 48 ++++++++ .../windows-codex-cli-job-store.test.ts | 4 +- 15 files changed, 335 insertions(+), 65 deletions(-) create mode 100644 tests/helpers/windows-apply-worker.ts diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index c9f569c8d9..1ba8e87782 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -41,6 +41,8 @@ export function cleanupCodexCliApply(lease: { release(): boolean } | null, stagi } interface VersionResult { readonly terminal: boolean; readonly ok: boolean; readonly pid: number | null } export interface CodexCliApplyDeps extends CodexCliUpdatePlanDeps { + /** Internal Worker lifetime root, never accepted from a request DTO. */ + readonly retainExecution?: (owner: object) => { release(): void }; /** Trusted native fault-injection seam; never supplied by a request DTO. */ readonly startWindowsChild?: typeof import("./windows-cli-child").startWindowsCliChild; readonly signal?: AbortSignal; diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index e8f9b65d70..3e82ee7238 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -628,23 +628,29 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool } const entries: WindowsAclEntry[] = chain.map((entry, index) => ({ path: entry.path, directory: true, policy: index >= prefixDepth - 2 ? "write-private" : "trusted-ancestry" })); - const verifyHeld = async () => { + let stage: { path: string; handle: bigint; identity: StoreInspection } | undefined; + const verifyIdentitySync = () => { for (const entry of chain) if (!sameIdentity(entry.inspection, inspect(ctx, entry.handle, true))) throw unsafe(); - await verifyAcl(entries); if (held && (!sameIdentity(held.dirIdentity, inspect(ctx, held.dir, true)) || !sameIdentity(held.ownerIdentity, inspect(ctx, held.owner, false)) || !readAll(ctx, held.owner, Buffer.byteLength(held.record)).equals(Buffer.from(held.record)))) throw unsafe(); + if (stage && !sameIdentity(stage.identity, inspect(ctx, stage.handle, true))) throw unsafe(); + }; + const verifyHeld = async () => { + verifyIdentitySync(); + await verifyAcl(entries); + verifyIdentitySync(); }; const verify = async () => { if (closed) throw unsafe(); await verifyHeld(); if (closed) throw unsafe(); }; await verify(); const lock = join(prefix, ".ocx-codex-cli-apply.lock"); if (acquire) held = await acquireLock(ctx, { config: prefix, dir: prefix, file: "", lock, jobdir: prefixPin.handle }); - let stage: { path: string; handle: bigint; identity: StoreInspection } | undefined; transferred = true; return { lock, verify, + verifyIdentitySync() { if (closed) throw unsafe(); verifyIdentitySync(); }, async createStaging(): Promise { if (closed || !held || stage) throw unsafe(); await verify(); diff --git a/src/codex/cli-update-selection-gate.ts b/src/codex/cli-update-selection-gate.ts index 5b0b0eeda1..0efc30df02 100644 --- a/src/codex/cli-update-selection-gate.ts +++ b/src/codex/cli-update-selection-gate.ts @@ -11,15 +11,15 @@ * PREPARING -> CANCELLED, and the worker's only commit word is * PREPARING -> COMMITTED. Exactly one compareExchange wins. A cancel that * lands first is observed by the worker's isCancelled checkpoints and by - * the commit CAS losing; a commit that lands first means the npm spawn - * already happened and a late cancel is deferred to readback. + * the commit CAS losing; a commit is the irrevocable start decision, not + * evidence of successful spawn. Later cancellation is deferred to readback. * * - GATE (one process-wide cell): FREE -> PARENT_WRITE is taken by the five * parent selection writers (runtime pin persist/clear, shim install/ * auto-restore/uninstall) and the two parent session-spawn paths (remote * workspace child, desktop relaunch). FREE -> WORKER_COMMIT is taken by the * update worker for only its short final window. Writers report busy rather - * than wait: the window is seconds at most and a synchronous parent writer + * than wait: Windows final ACL/content observations may be slow; a parent writer * must never park the event loop behind it. The gate is intentionally one * cell for the whole process rather than per-configDir: alias/junction/case * spellings of the same target must not split the exclusion, so a writer on @@ -113,7 +113,7 @@ export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrit /** * The worker's short final window. tryEnter covers the uncontended same-turn * case; waitEnter is the bounded retry for a held parent write. commit() is - * the fence CAS issued in the same event-loop turn as the installer spawn; + * the fence CAS issued in the same event-loop turn as the installer start call; * exit() releases the gate once the spawn call has returned. */ export interface CodexCliUpdateCommitWindow { diff --git a/src/codex/cli-update-windows-apply.ts b/src/codex/cli-update-windows-apply.ts index 37905f81dc..9d8dff66b0 100644 --- a/src/codex/cli-update-windows-apply.ts +++ b/src/codex/cli-update-windows-apply.ts @@ -15,7 +15,7 @@ import { startWindowsCliChild, cleanupUnstartedWindowsChild, type WindowsCliChil type Authority = Awaited>; type Toolchain = NonNullable>>; -interface Owner { authority: Authority; staging: string; toolchain: Toolchain | null; child?: WindowsCliChild } +interface Owner { retention?: { release(): void }; authority: Authority; staging: string; toolchain: Toolchain | null; child?: WindowsCliChild } // Strong owners survive an uncertain native wait and the HTTP/CLI caller returning. // Never collect these as ordinary cleanup, infer terminal from a PID, or start a retry. const retained = new Set(); @@ -40,12 +40,14 @@ function expectedDirectories(files: readonly { relativePath: string }[], snapsho * The existing admission layer owns confirmed/planId and durable result persistence. */ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliApplyDeps): Promise { + const cancelled = () => deps.signal?.aborted === true || deps.isCancelled?.() === true; let owner: Owner | undefined, uncertain = false, cleanupPending = false, installerStarted = false; + let ownerRegistrationPending = false; let installedVersion: string | null = null, targetVersion: string | null = null, exitCode: number | null = null; let verificationPid: number | null = null, output: CodexCliApplyResult | undefined; const finish = (outcome: CodexCliApplyResult["outcome"], reason: string): CodexCliApplyResult => output = { outcome, reason, planId, installedVersion, targetVersion, installerExitCode: exitCode, - leaseRetained: uncertain || cleanupPending, verificationPid, cleanupErrors: cleanupPending ? ["child_cleanup_failed"] : [], + leaseRetained: uncertain || cleanupPending || ownerRegistrationPending, verificationPid, cleanupErrors: cleanupPending ? ["child_cleanup_failed"] : [], }; const inspection = () => { const input = { ...deps.inspectionDeps, platform: "win32" as const, observeInstallContent: true }; @@ -53,13 +55,9 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA }; const inspect = () => (deps.inspect ?? inspectCodexCliInstall)(inspection()); const plan = () => createCodexCliUpdatePlan({ ...deps, platform: "win32", inspectionDeps: inspection() }); - const run = async (invocation: WindowsCliChildInvocation, beforeStart?: () => void): Promise => { + const settle = async (child: WindowsCliChild): Promise => { if (!owner) throw new Error("ownership_required"); - if (owner.child) throw new Error("child_cleanup_pending"); - await owner.authority.verify(); - beforeStart?.(); - uncertain = true; // Adopt every resource before even a suspended child is created. - const child = owner.child = (deps.startWindowsChild ?? startWindowsCliChild)(invocation); + if (!child.process) { const cleaned = await cleanupUnstartedWindowsChild(child); uncertain = false; cleanupPending = !cleaned; @@ -72,37 +70,58 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA if (result.terminal && result.cleanupComplete) owner.child = undefined; return result; }; + const startOwned = (invocation: WindowsCliChildInvocation): WindowsCliChild => { + if (!owner || owner.child) throw new Error("child_cleanup_pending"); + uncertain = true; // A throwing start may already have created native resources. + return owner.child = (deps.startWindowsChild ?? startWindowsCliChild)(invocation); + }; + const run = async (invocation: WindowsCliChildInvocation): Promise => { + if (!owner) throw new Error("ownership_required"); + await owner.authority.verify(); + if (!installerStarted && cancelled()) throw new Error("cancelled"); + return settle(startOwned(invocation)); + }; try { if (!/^[0-9a-f]{32}$/.test(planId)) return finish("refused", "invalid_plan_id"); if (process.platform !== "win32" || process.arch !== "x64") return finish("refused", "windows_inspection_deferred"); - if (deps.signal?.aborted) return finish("refused", "cancelled"); + if (cancelled()) return finish("refused", "cancelled"); const first = await inspect(), evidence = windowsCodexCliPlanEvidence(first); installedVersion = first.packageVersion; if (!first.managed || !evidence || evidence.arch !== "x64") return finish("refused", "mutation_evidence_unverified"); + if (cancelled()) return finish("refused", "cancelled"); const initialPlan = await plan(); targetVersion = initialPlan.targetVersion; + if (cancelled()) return finish("refused", "cancelled"); if (!initialPlan.applicable) return finish("refused", initialPlan.refusal ?? "plan_refused"); if (initialPlan.planId !== planId) return finish("refused", "plan_changed"); const artifacts = initialPlan.targetArtifacts; if (!artifacts || artifacts.platform !== "win32" || artifacts.arch !== "x64") return finish("refused", "target_artifacts_unverified"); + // Root the acquisition anchor before await/Owner registration. If registration + // throws, the Worker token still owns any acquired authority through this anchor. + const anchor: { authority?: Authority; owner?: Owner } = {}; + const retention = deps.retainExecution?.(anchor); let authority: Authority; - try { authority = await openWindowsCliApplyAuthority(evidence.prefix, true); } - catch (error) { return finish("refused", error instanceof Error && error.message === "job_store_busy" + try { authority = anchor.authority = await openWindowsCliApplyAuthority(evidence.prefix, true); } + catch (error) { retention?.release(); return finish("refused", error instanceof Error && error.message === "job_store_busy" ? "installation_busy" : "installation_prefix_unsupported"); } - owner = { authority, staging: win32.join(authority.lock, "stage"), toolchain: null }; + ownerRegistrationPending = true; + owner = anchor.owner = { retention, authority, staging: win32.join(authority.lock, "stage"), toolchain: null }; retained.add(owner); + ownerRegistrationPending = false; await authority.createStaging(); + if (cancelled()) return finish("refused", "cancelled"); owner.toolchain = await holdWindowsCodexCliPlanToolchain(first); + if (cancelled()) return finish("refused", "cancelled"); if (!owner.toolchain) return finish("refused", "npm_unavailable"); const isolation = createNpmConfigIsolation(owner.staging, deps.inspectionDeps?.env); // Include Windows temp/profile names: no npm or version cache may fall back to a live home. const env = { ...isolation.env, HOME: owner.staging, USERPROFILE: owner.staging, TEMP: owner.staging, TMP: owner.staging, TMPDIR: owner.staging, APPDATA: owner.staging, LOCALAPPDATA: owner.staging, CODEX_HOME: owner.staging }; - const invoke = (args: readonly string[], policy: WindowsCliChildInvocation["policy"], beforeStart?: () => void) => run({ + const invoke = (args: readonly string[], policy: WindowsCliChildInvocation["policy"]) => run({ executable: evidence.node, args: [evidence.npmCli, ...npmArgs(args, isolation)], cwd: isolation.dir, env, policy, - }, beforeStart); + }); const archives: string[] = []; for (const [index, artifact] of [artifacts.main, artifacts.backing].entries()) { - if (deps.signal?.aborted) return finish("refused", "cancelled"); + if (cancelled()) return finish("refused", "cancelled"); let path: string; if (deps.fetchArtifact) { path = win32.join(owner.staging, `${index}.tgz`); @@ -117,35 +136,55 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA if (!Array.isArray(names) || names.length !== 1 || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(names[0]?.filename)) throw new Error("pack_unverified"); path = win32.join(owner.staging, names[0].filename); } + if (cancelled()) return finish("refused", "cancelled"); archives.push(path); } const layout = await verifyWindowsCodexCliLayout(artifacts, archives[0]!, archives[1]!); + if (cancelled()) return finish("refused", "cancelled"); const original = await observeWindowsInstall(evidence.prefix); const fresh = await plan(); if (!fresh.applicable) return finish("refused", fresh.refusal ?? "plan_refused"); if (fresh.planId !== planId) return finish("refused", "plan_changed"); - const before = await inspect(), selected = windowsCodexCliPlanEvidence(before); - if (!selected || JSON.stringify(selected) !== JSON.stringify(evidence) - || before.installContentDigest !== initialPlan.installContentDigest - || !sameWindowsInstall(original, await observeWindowsInstall(evidence.prefix))) return finish("refused", "installation_changed"); - await owner.authority.verify(); - if (deps.signal?.aborted) return finish("refused", "cancelled"); - const beforeStart = () => { - if (deps.signal?.aborted) throw new Error("cancelled"); - if (!windowsCodexCliSelectionMatches(before, inspection())) throw new Error("installation_changed"); - const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); - if (scan.kind !== "observed") throw new Error("blocked_process_state_unknown"); - if (scan.processes.length) throw new Error("blocked_active_session"); - installerStarted = true; - }; + if (cancelled()) return finish("refused", "cancelled"); const args = ["install", "-g", "--prefix", evidence.prefix, "--offline", "--ignore-scripts", "--omit=optional", "--no-audit", "--no-fund", "--package-lock=false", "--update-notifier=false", `${artifacts.main.installName}@file:${archives[0]}`, `${artifacts.backing.installName}@file:${archives[1]}`]; - // The injectable installer seam is the existing trusted test dependency, never an HTTP input. - const installed = deps.install ? await (async () => { - await owner!.authority.verify(); beforeStart(); uncertain = true; - return deps.install!({ bin: evidence.node, args: [evidence.npmCli, ...npmArgs(args, isolation)], cwd: isolation.dir, env }); - })() : await invoke(args, "installer", beforeStart); + const invocation: WindowsCliChildInvocation = { executable: evidence.node, + args: [evidence.npmCli, ...npmArgs(args, isolation)], cwd: isolation.dir, env, policy: "installer" }; + const window = deps.commitWindow; + let held = false; + let nativeChild: WindowsCliChild | undefined; + let injectedInstall: Promise<{ terminal: boolean; exitCode: number | null }> | undefined; + try { + if (window) { + held = window.tryEnter() || await window.waitEnter(); + if (!held) return finish("refused", cancelled() ? "cancelled" : "installation_busy"); + } + // Renew all observations after gate acquisition, including asynchronous ACLs. + // Acquisition is bounded; the Windows observation window is not short by guarantee. + if (cancelled()) return finish("refused", "cancelled"); + await owner.authority.verify(); + const before = await inspect(), selected = windowsCodexCliPlanEvidence(before); + if (!selected || JSON.stringify(selected) !== JSON.stringify(evidence) + || before.installContentDigest !== initialPlan.installContentDigest + || !sameWindowsInstall(original, await observeWindowsInstall(evidence.prefix))) return finish("refused", "installation_changed"); + const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); + if (scan.kind !== "observed") return finish("refused", "blocked_process_state_unknown"); + if (scan.processes.length) return finish("refused", "blocked_active_session"); + // No await from these held-identity/selection checks through CAS and native start. + owner.authority.verifyIdentitySync(); + if (!windowsCodexCliSelectionMatches(before, inspection())) return finish("refused", "installation_changed"); + if (cancelled() || (window && !window.commit())) return finish("refused", "cancelled"); + installerStarted = true; // CAS is the irrevocable start decision, not spawn success. + uncertain = true; + // Trusted injection must begin its start synchronously, like native Create/Assign/Resume. + if (deps.install) injectedInstall = deps.install({ bin: invocation.executable, + args: invocation.args, cwd: invocation.cwd, env: invocation.env }); + else nativeChild = startOwned(invocation); + } finally { + if (held) window!.exit(); // start returned/threw; never hold across wait or cleanup. + } + const installed = nativeChild ? await settle(nativeChild) : await injectedInstall!; uncertain = !installed.terminal; exitCode = installed.exitCode; if (uncertain) return finish("ambiguous", "installer_state_unknown"); if (cleanupPending) return finish("ambiguous", "child_cleanup_unverified"); @@ -181,11 +220,13 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA && sameWindowsInstall(original, observed)) return finish("not_applied", "original_install_unchanged"); return finish("ambiguous", "installation_readback_unverified"); } catch (error) { - if (!installerStarted && !uncertain && error instanceof Error + if (!installerStarted && !uncertain && !ownerRegistrationPending && cancelled()) return finish("refused", "cancelled"); + if (!installerStarted && !uncertain && !ownerRegistrationPending && error instanceof Error && ["cancelled", "installation_changed", "blocked_process_state_unknown", "blocked_active_session"].includes(error.message)) { return finish("refused", error.message); } - return finish(installerStarted || uncertain ? "ambiguous" : "refused", installerStarted || uncertain ? "installer_or_readback_error" : "preparation_failed"); + return finish(installerStarted || uncertain || ownerRegistrationPending ? "ambiguous" : "refused", + installerStarted || uncertain || ownerRegistrationPending ? "installer_or_readback_error" : "preparation_failed"); } finally { if (owner && !uncertain && !cleanupPending) { const errors: CodexCliApplyCleanupError[] = []; @@ -197,7 +238,7 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA catch { errors.push("lease_release_exception"); } try { if (!await owner.authority.release()) errors.push("lease_release_unverified"); } catch { errors.push("lease_release_exception"); } - if (!errors.length) retained.delete(owner); + if (!errors.length) { retained.delete(owner); ownerRegistrationPending = false; owner.retention?.release(); } if (output) Object.assign(output, { cleanupErrors: errors, leaseRetained: errors.length > 0 }); } if (output) { Object.freeze(output.cleanupErrors); Object.freeze(output); } diff --git a/src/codex/cli-update-worker.ts b/src/codex/cli-update-worker.ts index aba494fa3c..90748784f8 100644 --- a/src/codex/cli-update-worker.ts +++ b/src/codex/cli-update-worker.ts @@ -86,6 +86,25 @@ declare const self: Worker; // apply at its first checkpoint via signal.aborted. const applyAbort = new AbortController(); let ran = false; +// A raw Win32 handle is not a Bun event-loop reference. Keep the run-once +// handler AND exact resource roots until their terminal cleanup releases them. +export function createCodexCliWorkerLifetime(detach: () => void) { + let executionFinished = false; + let detached = false; + const retainedExecutions = new Set(); + const maybeDetach = () => { + if (executionFinished && !detached && retainedExecutions.size === 0) { detached = true; detach(); } + }; + return { + retain(owner: object): { release(): void } { + const token = { owner }; + retainedExecutions.add(token); + return { release() { if (retainedExecutions.delete(token)) maybeDetach(); } }; + }, + finish() { executionFinished = true; maybeDetach(); }, + }; +} +const lifetime = createCodexCliWorkerLifetime(() => { self.onmessage = null; }); // Guarded so the module can be imported directly by tests without a Worker host. if (typeof Bun !== "undefined" && Bun.isMainThread === false @@ -122,6 +141,7 @@ if (typeof Bun !== "undefined" && Bun.isMainThread === false signal: applyAbort.signal, isCancelled: codexCliUpdateIsCancelled(message.fence), commitWindow: createWorkerCommitWindow({ gate: message.gate, fence: message.fence }), + retainExecution: owner => lifetime.retain(owner), }); self.postMessage({ type: "done", requestId: message.requestId, jobId: message.jobId, @@ -134,10 +154,10 @@ if (typeof Bun !== "undefined" && Bun.isMainThread === false message: error instanceof Error ? error.message : "cli_update_worker_failed", } satisfies CodexCliUpdateWorkerReply); } finally { - // Bun 1.4.4 has no self.close(). Remove this realm's message keepalive; - // pending referenced child/I/O work must drain before natural termination. + // Bun 1.4.4 has no self.close(). Drop the handler only when native owners + // have released every token; referenced child/I/O work must also drain. // The parent's close event is not proof that every OS descendant exited. - self.onmessage = null; + lifetime.finish(); } }; } diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 60c9d672f1..bf4531bf87 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; import { chmodSync, existsSync, @@ -1096,10 +1097,21 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i export function installCodexShim(deps: RuntimePreflightDeps = {}): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } { if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath, lastCodexDiscoveryError); - return installCodexShimInternal({ ...deps, allowFreshInstall: true }); + const gate = beginCodexCliSelectionParentWrite(); + if (!gate.entered) return { installed: false, refused: true, message: "codex_cli_selection_busy" }; + try { return installCodexShimInternal({ ...deps, allowFreshInstall: true }); } + finally { gate.exit(); } } -export function autoRestoreCodexShim(options: RuntimePreflightDeps & { +export function autoRestoreCodexShim(options: Parameters[0]): CodexShimAutoRestoreResult { + if (process.platform !== "win32") return autoRestoreUnixOverlay(options); + const gate = beginCodexCliSelectionParentWrite(); + if (!gate.entered) return { status: "deferred", message: "codex_cli_selection_busy" }; + try { return autoRestoreWindowsCodexShim(options); } + finally { gate.exit(); } +} + +function autoRestoreWindowsCodexShim(options: RuntimePreflightDeps & { enabled: () => boolean; stabilitySleep?: (ms: number) => void; /** Narrow deterministic seam used to hold the interprocess lock in tests. */ @@ -1109,7 +1121,6 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { /** Narrow deterministic race seam for the guarded transaction tests. */ beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; }): CodexShimAutoRestoreResult { - if (process.platform !== "win32") return autoRestoreUnixOverlay(options); const stateRead = readStateResult(); const state = stateRead.state; if (!state) { @@ -1223,6 +1234,13 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { export function uninstallCodexShim(): { removed: boolean; message: string } { if (process.platform !== "win32") return uninstallUnixCodexShim(); + const gate = beginCodexCliSelectionParentWrite(); + if (!gate.entered) return { removed: false, message: "codex_cli_selection_busy" }; + try { return uninstallWindowsCodexShim(); } + finally { gate.exit(); } +} + +function uninstallWindowsCodexShim(): { removed: boolean; message: string } { const state = readState(); if (!state) return { removed: false, message: "Codex autostart shim is not installed." }; const files = stateFiles(state); diff --git a/structure/INDEX.md b/structure/INDEX.md index 8922bf05c9..1300d3869e 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -232,6 +232,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/cli-update-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `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/shim.ts` | [`codex-cli-update.md`](codex-cli-update.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) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index eb9f72b432..6a652fae85 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -281,7 +281,7 @@ the worker derives its own fresh selected-command evidence; the installer inspec WeakMap is per-realm, which is exactly why the whole job — not just its hashing — must run there. A job is one fresh Worker, one unique requestId and the parent ledger generation; no wire generation -exists. The worker runs one message, replies and closes itself; a second message is ignored, so a stale +exists. The worker runs one message, replies and drains; a second run is ignored, so a stale or duplicate delivery cannot attach new work to a dying realm. Settlement requires BOTH a valid reply and the worker's own close — a done payload alone is never terminal evidence because a legitimate result may still report installer_state_unknown or version_process_state_unknown with a retained lease @@ -294,18 +294,26 @@ code provably ran. Ledger completion writes keep their existing bounded retry. Entry installation is limited to `Bun.isMainThread === false`; importing either entry or its test fixture in the main realm must not replace that realm's message handler. Bun 1.4.4 does not -provide `self.close()`: completion removes the worker's message handler and lets referenced work +provide `self.close()`: terminal completion removes the worker's message handler and lets referenced work drain naturally, without `process.exit()` or parent termination. A real Bun-subprocess regression keeps the worker alive after handler removal until that exact child exits. This establishes the referenced Bun-child path only, not arbitrary native handles or all OS descendants. Bun's close event marks worker termination and can precede full thread exit; it is not independent proof that every installer descendant or retained native owner has finished. +Windows native ownership acquires an internal execution-retention token before authority acquisition. +The token roots the acquisition anchor, then the registered Owner, across registration exceptions. +Raw FFI handles alone are not considered Worker keepalive: unresolved process/Job/pipe or cleanup +keeps the run-once handler installed. Only successful terminal cleanup removes the Owner and releases +its token. No polling, automatic retry/reap or termination follows from retention; a retained realm +can remain resident until separately authorized recovery. A crash still loses in-memory ownership +and leaves durable state unknown. The pure lifetime test is not native disposal evidence. + Cancellation crosses realms through shared memory, not a message: the parent CASes the job fence PREPARING-to-CANCELLED, the worker's `isCancelled` checkpoints read that word during its synchronous sections, and the worker's own PREPARING-to-COMMITTED CAS immediately before the installer spawn is the -commit decision. COMMITTED is deliberately not "cancelled" — once it lands the installer is a live -child whose close must be observed. Because a plain AbortSignal message could not interrupt a +irrevocable start decision, not proof of successful CreateProcess/ResumeThread. COMMITTED is not +"cancelled" — later cancellation defers to actual child/readback evidence. A plain signal cannot interrupt a synchronous worker section, signal-based cancellation alone is not equivalent and is not used as the worker path's only channel. @@ -313,7 +321,7 @@ The worker's final window is also serialized against the parent's own selection process-wide gate cell. The gate covers exactly seven parent operations: `persistCodexRuntime` and `clearPersistedCodexRuntime` in `src/codex/runtime.ts` (busy refusal — the `codex_cli_selection_busy` error — before mkdir, cache invalidation or the swallowing unlink catch), -the three shim overlay boundaries in `src/codex/shim-overlay.ts` (install, auto-restore, uninstall — +the three public shim boundaries (`src/codex/shim-overlay.ts` on POSIX, `src/codex/shim.ts` on Windows; install, auto-restore, uninstall — busy in their existing failure/deferred/removed:false shapes before namespace creation, the restore lock and journal mutation), the remote-workspace app-server `Bun.spawn` in `src/remote-control/workspace-codex-runtime.ts` (busy refuses after running the same bridge/isolation @@ -321,7 +329,13 @@ cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation remo `src/codex/desktop-app-restart.ts` (busy returns the new `selection_mutation_busy` skipped reason before the restart lock, package discovery, any app quit and the handoff, so a queued relaunch — and its own lock-acquisition failure — cannot slip inside the window). Writers never wait: the commit -window spans only the worker's final process scan through its installer-spawn call, and a synchronous +POSIX window spans the final process scan through its installer-spawn call. The Windows window also +renews full authority ACL/identity, inspector and full scoped preimage after acquisition, then performs +the final process scan, synchronous held-identity/selection/cancellation checks, CAS and synchronous +native Create/Assign/Resume with no intervening await. It releases immediately after start returns or +throws, before any native wait or setup cleanup. Windows ACL/content observations can take substantial +time; neither a short held window nor a hard I/O deadline is claimed. Acquisition's 30s deadline does +not bound time held. A synchronous parent writer must not park the event loop behind it, so a held gate is an immediate busy. The gate is one cell for the whole process rather than per-configDir because alias, junction and case spellings of the same target must not split the exclusion; a writer on a different configDir can see a brief busy. @@ -344,7 +358,7 @@ The async Windows ledger stays in the parent realm: CLI/HTTP await admission and and one generation-fenced callback dispatches the Worker. The store/Worker regressions in `tests/windows/windows-codex-cli-job-store.test.ts` cover stale completion refusal and an unknown still-live fixture Worker whose persisted child/lease state remains unknown. The production-entry -case retains Windows apply-disabled behavior; neither case claims a successful Windows installation. +case refuses the null proof-bound snapshot in the enabled Windows lane; neither store case claims a successful Windows installation. The ledger retains at most 64 jobs, including terminal jobs, and never prunes request IDs automatically. At capacity it refuses new request IDs with `store_full`; existing matching retries still return the same job. diff --git a/structure/manifest.json b/structure/manifest.json index 6a6ac78db2..7f7f547b9d 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -187,6 +187,7 @@ "src/codex/cli-update-job-store-windows.ts", "src/codex/cli-update-job-worker.ts", "src/codex/cli-update-selection-gate.ts", + "src/codex/shim.ts", "src/codex/cli-update-worker.ts", "src/server/management/codex-cli-update-routes.ts", "src/codex/cli-update-inspection-context.ts", diff --git a/tests/codex-integration/codex-cli-update-windows-apply.test.ts b/tests/codex-integration/codex-cli-update-windows-apply.test.ts index 187e150909..5f508d5360 100644 --- a/tests/codex-integration/codex-cli-update-windows-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-apply.test.ts @@ -14,10 +14,18 @@ import { spawnTestChild } from "../helpers/spawn-test-child"; import { runCodexCliUpdateJobWork } from "../../src/codex/cli-update-job-execution"; import { startWindowsCliChild, type WindowsCliChild } from "../../src/codex/windows-cli-child"; import { windowsInstallNamespaceSupported } from "../../src/codex/cli-update-windows-observation"; +import { createCodexCliUpdateFence, cancelCodexCliUpdateFence, createWorkerCommitWindow, + codexCliUpdateIsCancelled } from "../../src/codex/cli-update-selection-gate"; const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; const roots: string[] = []; -afterEach(async () => { for (const root of roots.splice(0)) await removeTreeAfterBackgroundWork(root); }); +const liveFixtureRoots = new Set(); +afterEach(async () => { + for (const root of roots.splice(0)) { + if (liveFixtureRoots.has(root)) { roots.push(root); continue; } + await removeTreeAfterBackgroundWork(root); + } +}); const mainRoot = "node_modules/@openai/codex", backingRoot = "node_modules/@openai/codex-win32-x64"; const nativePath = "vendor/x86_64-pc-windows-msvc/bin/codex.exe"; const write = (path: string, bytes: string | Buffer) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, bytes); }; @@ -40,12 +48,13 @@ function artifacts(version: string, executable?: Buffer) { return { mainFiles, backingFiles, main, backing, set: { platform: "win32" as const, arch: "x64", main: fixtureArtifact(main, version), backing: fixtureArtifact(backing, version, "@openai/codex-win32-x64", "@openai/codex-win32-x64") } }; } -async function fixture(executable?: Buffer) { +async function fixture(executable?: Buffer, onCreated?: (root: string) => void) { // This host's observed writable AppData ancestry refused; this is not a ban on // every profile. Evaluate actual ancestry ACL/owner/reparse evidence instead. // Create only this test-owned directory below the repository's volume anchor; // never repair or suppress metadata on the host's existing ancestor directories. - const root = mkdtempSync(join(parse(process.cwd()).root, "ocx-win-apply-")); roots.push(root); + const root = mkdtempSync(join(parse(process.cwd()).root, "ocx-win-apply-")); + onCreated?.(root); roots.push(root); await hardenSecretDirAsync(root, { required: true }); const prefix = join(root, "prefix"); mkdirSync(prefix); await hardenSecretDirAsync(prefix, { required: true }); @@ -74,6 +83,7 @@ native("Windows apply holds distinct prefix lease and private staging, then rele const guard = await openWindowsCliApplyAuthority(f.prefix, true); try { await guard.createStaging(); + expect(() => guard.verifyIdentitySync()).not.toThrow(); expect(() => renameSync(f.prefix, f.prefix + "-moved")).toThrow(); expect(() => renameSync(join(f.prefix, "node_modules", "@openai"), join(f.prefix, "node_modules", "moved"))).toThrow(); await expect(openWindowsCliApplyAuthority(f.prefix, true)).rejects.toThrow(); @@ -84,11 +94,88 @@ native("Windows apply holds distinct prefix lease and private staging, then rele try { await expect(guard.createStaging()).rejects.toThrow(); await expect(guard.verify()).rejects.toThrow(); + expect(() => guard.verifyIdentitySync()).toThrow(); expect(existsSync(join(replacement.lock, "stage"))).toBe(false); await replacement.createStaging(); await replacement.removeStaging(); } finally { expect(await replacement.release()).toBe(true); } }, 60000); +native("Windows native installer starts under commit CAS and frees gate before native wait", async () => { + const f = await fixture(undefined, root => liveFixtureRoots.add(root)), id = await approved(f); + const fence = createCodexCliUpdateFence(), gate = new SharedArrayBuffer(4); + let starts = 0, retained = 0; + const result = await applyCodexCliUpdate(id, { ...f.deps, + commitWindow: createWorkerCommitWindow({ gate, fence }), + retainExecution: () => { retained++; return { release() { retained--; } }; }, + startWindowsChild: invocation => { + if (invocation.policy === "installer") { + starts++; + expect(Atomics.load(new Int32Array(gate), 0)).toBe(2); + expect(Atomics.load(new Int32Array(fence), 0)).toBe(2); + } + const child = startWindowsCliChild(invocation); + const wait = child.wait.bind(child); + child.wait = () => { + expect(Atomics.load(new Int32Array(gate), 0)).toBe(0); + return wait(); + }; + return child; + }, + }); + if (!result.leaseRetained) liveFixtureRoots.delete(f.root); + expect(result).toMatchObject({ outcome: "applied", reason: "verified_target", leaseRetained: false }); + expect(starts).toBe(1); expect(retained).toBe(0); +}, 60_000); + +native("a fresh Worker runs real offline Windows FFI install and closes only after native settlement", async () => { + let root: string | undefined, closed = false, worker: Worker | undefined; + try { + const f = await fixture(undefined, created => { root = created; liveFixtureRoots.add(created); }); + const id = await approved(f); + worker = new Worker(new URL("../helpers/windows-apply-worker.ts", import.meta.url), { type: "module" }); + const messages: { type: string; result?: import("../../src/codex/cli-update-apply").CodexCliApplyResult }[] = []; + const joined = new Promise((resolve, reject) => { + worker!.addEventListener("message", event => { messages.push(event.data); }); + worker!.addEventListener("error", () => reject(new Error("offline Worker failed")), { once: true }); + worker!.addEventListener("close", () => { closed = true; liveFixtureRoots.delete(f.root); resolve(); }, { once: true }); + }); + const gate = new SharedArrayBuffer(4), fence = createCodexCliUpdateFence(); + worker.postMessage({ planId: id, inspectionDeps: f.deps.inspectionDeps, target: f.target, gate, fence }); + await joined; + expect(messages.filter(m => m.type === "native-start")).toHaveLength(1); + expect(messages.filter(m => m.type === "fixture-error")).toHaveLength(0); + const results = messages.filter(m => m.type === "result"); + expect(results).toHaveLength(1); + expect(results[0]!.result).toMatchObject({ outcome: "applied", reason: "verified_target", leaseRetained: false, cleanupErrors: [] }); + expect(Atomics.load(new Int32Array(gate), 0)).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + } finally { + // A still-live native Worker keeps its exact fixture; never terminate to fake settlement. + if (root && (!worker || closed)) liveFixtureRoots.delete(root); + } +}, 60_000); + +native("Windows cancellation won inside final synchronous scan cannot create an installer", async () => { + const f = await fixture(undefined, root => liveFixtureRoots.add(root)), id = await approved(f); + const fence = createCodexCliUpdateFence(), gate = new SharedArrayBuffer(4); + let scans = 0, starts = 0; + const result = await applyCodexCliUpdate(id, { ...f.deps, + isCancelled: codexCliUpdateIsCancelled(fence), + commitWindow: createWorkerCommitWindow({ gate, fence }), + scanProcesses: () => { + scans++; + if (Atomics.load(new Int32Array(gate), 0) === 2) expect(cancelCodexCliUpdateFence(fence)).toBe(true); + return { kind: "observed", processes: [] }; + }, + startWindowsChild: invocation => { starts++; return startWindowsCliChild(invocation); }, + }); + if (!result.leaseRetained) liveFixtureRoots.delete(f.root); + expect(scans).toBeGreaterThan(0); + expect(result).toMatchObject({ outcome: "refused", reason: "cancelled", leaseRetained: false }); + expect(starts).toBe(0); + expect(Atomics.load(new Int32Array(gate), 0)).toBe(0); +}, 60_000); + native("Windows apply runs real offline npm under owned Job, then verifies full target and version", async () => { const f = await fixture(), id = await approved(f); const result = await applyCodexCliUpdate(id, f.deps); diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts index ba4b970e1b..71dd5b50d7 100644 --- a/tests/codex-integration/codex-cli-update-worker.test.ts +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -26,6 +26,7 @@ import { codexCliUpdateInspectionDepsFromSnapshot } from "../../src/codex/cli-up import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; import { spawnTestChild } from "../helpers/spawn-test-child"; import { repoRoot } from "../helpers/repo-root"; +import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; import { FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, FIXTURE_CONTROL_WORDS, type FixtureConfiguration, @@ -170,6 +171,20 @@ afterEach(async () => { if (firstError !== undefined) throw firstError; }); describe("process-wide selection commit gate and cancellation fence", () => { + test("execution retention roots survive completion until every exact owner releases", () => { + let detached = 0; + const lifetime = createCodexCliWorkerLifetime(() => { detached++; }); + const first = lifetime.retain({ native: "authority-acquisition-anchor" }); + const second = lifetime.retain({ native: "unresolved-overlapped-owner" }); + first.release(); + expect(detached).toBe(0); + lifetime.finish(); + expect(detached).toBe(0); + first.release(); + expect(detached).toBe(0); + second.release(); lifetime.finish(); second.release(); + expect(detached).toBe(1); + }); test("parent write is nonblocking busy; release restores admission", async () => { const first = beginCodexCliSelectionParentWrite(); expect(first.entered).toBe(true); diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index ef4a6cf604..8cef698381 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -374,11 +374,12 @@ native("a fresh production Worker rebuilds private Windows plan evidence before expect(starts).toBe(1); expect(closed).toBe(true); expect(plan).toMatchObject({ managed: true, installedVersion: "1.2.3", targetVersion: "1.2.4", - targetIntegrity: integrity, applicable: false }); + targetIntegrity: integrity }); expect(plan.installContentDigest).toMatch(/^[a-f0-9]{64}$/); // Real process observation may find active/unreadable sessions. Either refusal // is after private-evidence admission and successful fixture metadata resolution. - expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); + if (plan.applicable) expect(plan.refusal).toBeNull(); + else expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); expect(JSON.stringify(plan)).not.toContain(f.root); } finally { // afterEach retains this fixture even if the test times out before this diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index 1143f7a44e..2d6e08aff0 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { autoRestoreCodexShim, installCodexShim } from "../../src/codex/shim"; +import { autoRestoreCodexShim, installCodexShim, uninstallCodexShim } from "../../src/codex/shim"; +import { createWorkerCommitWindow, createCodexCliUpdateFence, codexCliUpdateSelectionGate } from "../../src/codex/cli-update-selection-gate"; import { stableShimPathProbe } from "../../src/codex/shim-fingerprint"; import { refreshObsoleteWindowsShim } from "../../src/codex/shim-windows-refresh"; import { buildWindowsCodexShim, buildWindowsPowerShellCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; @@ -18,6 +19,21 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; isolateCodexShimEnvironment(); +test("all Windows public shim writers refuse a held Worker commit gate before preflight", () => { + const original = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence: createCodexCliUpdateFence() }); + let enabledReads = 0; + try { + expect(window.tryEnter()).toBe(true); + expect(installCodexShim()).toMatchObject({ installed: false, refused: true, message: "codex_cli_selection_busy" }); + expect(autoRestoreCodexShim({ enabled: () => { enabledReads++; return true; } })) + .toMatchObject({ status: "deferred", message: "codex_cli_selection_busy" }); + expect(uninstallCodexShim()).toEqual({ removed: false, message: "codex_cli_selection_busy" }); + expect(enabledReads).toBe(0); + } finally { window.exit(); Object.defineProperty(process, "platform", original); } +}); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; const previousHome = process.env.OPENCODEX_HOME; let dir = ""; diff --git a/tests/helpers/windows-apply-worker.ts b/tests/helpers/windows-apply-worker.ts new file mode 100644 index 0000000000..1ff7188f5e --- /dev/null +++ b/tests/helpers/windows-apply-worker.ts @@ -0,0 +1,48 @@ +/** Owned offline fixture driver, never a production dispatch or installer input. */ +import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; +import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; +import { createWorkerCommitWindow, codexCliUpdateIsCancelled } from "../../src/codex/cli-update-selection-gate"; +import { startWindowsCliChild } from "../../src/codex/windows-cli-child"; + +declare const self: Worker; +if (Bun.isMainThread === false) { + let ran = false; + const lifetime = createCodexCliWorkerLifetime(() => { self.onmessage = null; }); + self.onmessage = async event => { + if (ran) return; + ran = true; + const { planId, inspectionDeps, target, gate, fence } = event.data; + const abort = new AbortController(); + const view = new Int32Array(gate), word = new Int32Array(fence); + try { + const result = await applyCodexCliUpdate(planId, { + platform: "win32", inspectionDeps, signal: abort.signal, + isCancelled: codexCliUpdateIsCancelled(fence), + commitWindow: createWorkerCommitWindow({ gate, fence }), + retainExecution: owner => lifetime.retain(owner), + resolveTarget: () => ({ kind: "resolved", version: "1.1.0", + integrity: target.set.main.integrity, artifacts: target.set }), + // The installer and metadata are offline owned fixtures; no host session controls. + scanProcesses: () => ({ kind: "observed", processes: [] }), + fetchArtifact: async (artifact, signal) => { + if (!(signal instanceof AbortSignal)) throw new Error("not a real AbortSignal"); + return Buffer.from(artifact.installName === "@openai/codex" ? target.main : target.backing); + }, + startWindowsChild: invocation => { + const installing = invocation.policy === "installer"; + if (installing && (Atomics.load(view, 0) !== 2 || Atomics.load(word, 0) !== 2)) throw new Error("native start outside commit"); + const child = startWindowsCliChild(invocation), wait = child.wait.bind(child); + child.wait = () => { + if (Atomics.load(view, 0) !== 0) throw new Error("gate held during native wait"); + return wait(); + }; + if (installing) self.postMessage({ type: "native-start", committed: true }); + return child; + }, + }); + self.postMessage({ type: "result", result }); + } catch { + self.postMessage({ type: "fixture-error" }); + } finally { lifetime.finish(); } + }; +} diff --git a/tests/windows/windows-codex-cli-job-store.test.ts b/tests/windows/windows-codex-cli-job-store.test.ts index 2b4bbba789..446e55a519 100644 --- a/tests/windows/windows-codex-cli-job-store.test.ts +++ b/tests/windows/windows-codex-cli-job-store.test.ts @@ -228,8 +228,8 @@ describe("Windows CLI update job store backend", () => { expect(JSON.parse(lines[0]!)).toMatchObject({ jobId: accepted.job.jobId, status: "running" }); } finally { log.mockRestore(); } } finally { release(); await pending.catch(() => {}); } - expect(await pending).toMatchObject({ status: "refused", outcome: "refused", reason: "windows_apply_disabled", ownerState: "finished" }); - // The real production entry refuses before inspection/lease/installer on Windows. + expect(await pending).toMatchObject({ status: "refused", outcome: "refused", reason: "mutation_evidence_unverified", ownerState: "finished" }); + // A null proof-bound snapshot cannot authorize installation in this enabled Windows lane. expect(engineCalls).toBe(1); expect(workerStarts).toBe(1); expect(await readCodexCliUpdateJob(accepted.job.jobId, configDir)).toMatchObject({ status: "refused", ownerState: "finished" }); const ledger = JSON.parse(readFileSync(join(configDir, "codex-cli-update-jobs", "jobs.json"), "utf8")) as { jobs: { revision: number }[] }; From 22cdbaac425425e69e9266666b7521d431c5af4d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:52:16 +0900 Subject: [PATCH 15/31] test: give each Windows CLI layout an independent case --- .../codex-cli-windows-plan.test.ts | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index d61472af54..e0dc738e72 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -438,30 +438,43 @@ native("fresh Worker realms refuse missing or mismatched arch and reject post-in } }, 60_000); -native("published Node CLI uses the production inspector and observed toolchain for read-only metadata", () => { +function checkPublishedNodeCliPlan(separateNodeToolchain: boolean) { + const started = performance.now(); const hostNode = Bun.which("node"); expect(hostNode).not.toBeNull(); - for (const f of [fixture(), fixture(true)]) { - const node = join(f.toolchainPrefix, "node.exe"); copyFileSync(hostNode!, node); - const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); - const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, - dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; - const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, - tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; - // Controlled registry protocol fixture; no real npm installation or network request. - writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), - `const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); - const run = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { - cwd: repoRoot(), encoding: "utf8", windowsHide: true, timeout: 45_000, - env: { ...process.env, PATH: f.toolchainPrefix + ";" + process.env.PATH, - CODEX_CLI_PATH: f.deps.env.CODEX_CLI_PATH, OPENCODEX_HOME: f.deps.configDir, - OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, - }); - expect(run.error).toBeUndefined(); expect(run.status).toBe(0); - const plan = JSON.parse(run.stdout); - expect(plan.managed).toBe(true); expect(plan.targetVersion).toBe("1.2.4"); - expect(plan.targetIntegrity).toBe(integrity); - expect(plan.applicable).toBe(false); - expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); - expect(JSON.stringify(plan)).not.toContain(f.root); + const f = fixture(separateNodeToolchain); + const node = join(f.toolchainPrefix, "node.exe"); copyFileSync(hostNode!, node); + const integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); + const main = { name: "@openai/codex", version: "1.2.4", optionalDependencies: { "@openai/codex-win32-x64": "1.2.4" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.2.4.tgz" } }; + const backing = { name: "@openai/codex-win32-x64", version: "1.2.4", dist: { integrity, + tarball: "https://registry.npmjs.org/@openai/codex-win32-x64/-/codex-win32-x64-1.2.4.tgz" } }; + // Controlled registry protocol fixture; no real npm installation or network request. + writeFileSync(join(f.npmRoot, "bin", "npm-cli.js"), + `const spec = process.argv[3]; console.log(JSON.stringify(spec.startsWith('@openai/codex-win32-x64@') ? ${JSON.stringify(backing)} : ${JSON.stringify(main)}));`); + const run = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "plan", "--json"], { + cwd: repoRoot(), encoding: "utf8", windowsHide: true, timeout: 45_000, + env: { ...process.env, PATH: f.toolchainPrefix + ";" + process.env.PATH, + CODEX_CLI_PATH: f.deps.env.CODEX_CLI_PATH, OPENCODEX_HOME: f.deps.configDir, + OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, + }); + if (run.error || run.status !== 0) { + console.error(JSON.stringify({ layout: separateNodeToolchain ? "separate-PATH" : "prefix-local", + elapsedMs: Math.round(performance.now() - started), status: run.status, signal: run.signal, + errorCode: (run.error as NodeJS.ErrnoException | undefined)?.code ?? null })); } + expect(run.error).toBeUndefined(); expect(run.status).toBe(0); + const plan = JSON.parse(run.stdout); + expect(plan.managed).toBe(true); expect(plan.targetVersion).toBe("1.2.4"); + expect(plan.targetIntegrity).toBe(integrity); + expect(plan.applicable).toBe(false); + expect(["blocked_active_session", "blocked_process_state_unknown", "windows_apply_disabled"]).toContain(plan.refusal); + expect(JSON.stringify(plan)).not.toContain(f.root); +} + +native("published Node CLI plans with the production inspector and prefix-local toolchain", () => { + checkPublishedNodeCliPlan(false); +}, 60_000); + +native("published Node CLI plans with the production inspector and separate PATH toolchain", () => { + checkPublishedNodeCliPlan(true); }, 60_000); From 94482b52be22e8919c500600802c60a6ad6751c9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:53:23 +0900 Subject: [PATCH 16/31] Preserve failed native pin ownership and require correlated fixture cleanup proof --- src/codex/cli-update-job-store-windows.ts | 27 +++++++++++---- structure/codex-cli-update.md | 5 +++ .../codex-cli-update-windows-apply.test.ts | 34 +++++++++++++++---- .../codex-cli-update-worker.test.ts | 23 +++++++++++++ tests/helpers/windows-apply-worker.ts | 16 +++++---- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index 3e82ee7238..f64fad73b2 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -140,18 +140,31 @@ function loadNative(): Promise { const unsafe = (): Error => new Error("unsafe_store"); const enoent = (): NodeJS.ErrnoException => Object.assign(new Error("ENOENT"), { code: "ENOENT" }); -class PinSet { +export class WindowsJobStorePinSet { private readonly handles: bigint[] = []; + private readonly failedCloses = new Set(); + get remaining(): number { return this.handles.length; } keep(handle: bigint): bigint { this.handles.push(handle); return handle; } - release(handle: bigint, kernel: Library): void { + release(handle: bigint, kernel: Library): boolean { const index = this.handles.lastIndexOf(handle); - if (index >= 0) this.handles.splice(index, 1); - kernel.symbols.CloseHandle(handle); + if (index < 0) return true; // Already released; never close a reused native handle. + try { + if (!kernel.symbols.CloseHandle(handle)) { this.failedCloses.add(handle); return false; } + } catch { this.failedCloses.add(handle); return false; } + this.handles.splice(index, 1); + this.failedCloses.delete(handle); + return true; } closeAll(kernel: Library): void { - for (const handle of this.handles.splice(0).reverse()) kernel.symbols.CloseHandle(handle); + // One attempt per disposal. A failed explicit release must not be retried + // invisibly by a later finally/closeAll in the same ownership lifecycle. + for (const handle of [...this.handles].reverse()) { + if (!this.failedCloses.has(handle)) this.release(handle, kernel); + } } } +const PinSet = WindowsJobStorePinSet; +type PinSet = WindowsJobStorePinSet; interface Prepared { readonly config: string; @@ -669,7 +682,7 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool if (!sameIdentity(stage.identity, inspect(ctx, stage.handle, true))) throw unsafe(); await verifyAcl([{ path: stage.path, directory: true, policy: "private" }]); if (closed) throw unsafe(); - ctx.pins.release(stage.handle, kernel); + if (!ctx.pins.release(stage.handle, kernel)) throw unsafe(); // Ancestors remain pinned and all writers are within the verified trust boundary. rmSync(stage.path, { recursive: true }); stage = undefined; @@ -679,7 +692,7 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool closed = true; try { await verifyHeld(); if (held) releaseLock(ctx, held); } finally { ctx.pins.closeAll(kernel); } - return !acquire || !existsSync(lock); + return ctx.pins.remaining === 0 && (!acquire || !existsSync(lock)); }, }; } finally { if (!transferred) ctx.pins.closeAll(kernel); } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 6a652fae85..3df62d4c4e 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -308,6 +308,11 @@ keeps the run-once handler installed. Only successful terminal cleanup removes t its token. No polling, automatic retry/reap or termination follows from retention; a retained realm can remain resident until separately authorized recovery. A crash still loses in-memory ownership and leaves durable state unknown. The pure lifetime test is not native disposal evidence. +Authority release succeeds only when lock disposal is verified and its pin set is empty. +A failed CloseHandle stays in that set; automatic closeAll skips the failed close instead of retrying +or forgetting it, so apply retains its Owner/token with `lease_release_unverified`. +Fixture teardown needs both a correlated non-retained, cleanup-confirmed result and Worker join; +Worker close alone, an error, missing reply or retained owner never permits root removal. Cancellation crosses realms through shared memory, not a message: the parent CASes the job fence PREPARING-to-CANCELLED, the worker's `isCancelled` checkpoints read that word during its synchronous diff --git a/tests/codex-integration/codex-cli-update-windows-apply.test.ts b/tests/codex-integration/codex-cli-update-windows-apply.test.ts index 5f508d5360..ef0ce0a96c 100644 --- a/tests/codex-integration/codex-cli-update-windows-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-apply.test.ts @@ -20,6 +20,27 @@ import { createCodexCliUpdateFence, cancelCodexCliUpdateFence, createWorkerCommi const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; const roots: string[] = []; const liveFixtureRoots = new Set(); +type WorkerFixtureReply = { type: string; requestId?: string; cleanupConfirmed?: boolean; result?: import("../../src/codex/cli-update-apply").CodexCliApplyResult }; +function fixtureCleanupProven(messages: readonly WorkerFixtureReply[], requestId: string, closed: boolean, errored: boolean): boolean { + if (!closed || errored) return false; + const replies = messages.filter(message => message.requestId === requestId); + const results = replies.filter(message => message.type === "result"); + return !replies.some(message => message.type === "fixture-error") && results.length === 1 + && results[0]!.cleanupConfirmed === true && results[0]!.result?.leaseRetained === false + && Array.isArray(results[0]!.result?.cleanupErrors) && results[0]!.result!.cleanupErrors.length === 0; +} +test("Worker close alone never authorizes removing a native fixture", () => { + const result = { leaseRetained: false, cleanupErrors: [] } as unknown as import("../../src/codex/cli-update-apply").CodexCliApplyResult; + const safe: WorkerFixtureReply = { type: "result", requestId: "owned", result, cleanupConfirmed: true }; + expect(fixtureCleanupProven([], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([safe], "different", true, false)).toBe(false); + expect(fixtureCleanupProven([safe], "owned", true, true)).toBe(false); + expect(fixtureCleanupProven([{ ...safe, result: { ...result, leaseRetained: true } }], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([{ ...safe, cleanupConfirmed: false }], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([safe, safe], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([safe], "owned", false, false)).toBe(false); + expect(fixtureCleanupProven([safe], "owned", true, false)).toBe(true); +}); afterEach(async () => { for (const root of roots.splice(0)) { if (liveFixtureRoots.has(root)) { roots.push(root); continue; } @@ -128,30 +149,31 @@ native("Windows native installer starts under commit CAS and frees gate before n }, 60_000); native("a fresh Worker runs real offline Windows FFI install and closes only after native settlement", async () => { - let root: string | undefined, closed = false, worker: Worker | undefined; + let root: string | undefined, closed = false, errored = false, worker: Worker | undefined; + const requestId = crypto.randomUUID(), messages: WorkerFixtureReply[] = []; try { const f = await fixture(undefined, created => { root = created; liveFixtureRoots.add(created); }); const id = await approved(f); worker = new Worker(new URL("../helpers/windows-apply-worker.ts", import.meta.url), { type: "module" }); - const messages: { type: string; result?: import("../../src/codex/cli-update-apply").CodexCliApplyResult }[] = []; const joined = new Promise((resolve, reject) => { worker!.addEventListener("message", event => { messages.push(event.data); }); - worker!.addEventListener("error", () => reject(new Error("offline Worker failed")), { once: true }); - worker!.addEventListener("close", () => { closed = true; liveFixtureRoots.delete(f.root); resolve(); }, { once: true }); + worker!.addEventListener("error", () => { errored = true; reject(new Error("offline Worker failed")); }, { once: true }); + worker!.addEventListener("close", () => { closed = true; resolve(); }, { once: true }); }); const gate = new SharedArrayBuffer(4), fence = createCodexCliUpdateFence(); - worker.postMessage({ planId: id, inspectionDeps: f.deps.inspectionDeps, target: f.target, gate, fence }); + worker.postMessage({ requestId, planId: id, inspectionDeps: f.deps.inspectionDeps, target: f.target, gate, fence }); await joined; expect(messages.filter(m => m.type === "native-start")).toHaveLength(1); expect(messages.filter(m => m.type === "fixture-error")).toHaveLength(0); const results = messages.filter(m => m.type === "result"); expect(results).toHaveLength(1); expect(results[0]!.result).toMatchObject({ outcome: "applied", reason: "verified_target", leaseRetained: false, cleanupErrors: [] }); + expect(fixtureCleanupProven(messages, requestId, closed, errored)).toBe(true); expect(Atomics.load(new Int32Array(gate), 0)).toBe(0); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); } finally { // A still-live native Worker keeps its exact fixture; never terminate to fake settlement. - if (root && (!worker || closed)) liveFixtureRoots.delete(root); + if (root && fixtureCleanupProven(messages, requestId, closed, errored)) liveFixtureRoots.delete(root); } }, 60_000); diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts index 71dd5b50d7..3a468cda9b 100644 --- a/tests/codex-integration/codex-cli-update-worker.test.ts +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -27,6 +27,7 @@ import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; import { spawnTestChild } from "../helpers/spawn-test-child"; import { repoRoot } from "../helpers/repo-root"; import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; +import { WindowsJobStorePinSet } from "../../src/codex/cli-update-job-store-windows"; import { FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, FIXTURE_CONTROL_WORDS, type FixtureConfiguration, @@ -171,6 +172,28 @@ afterEach(async () => { if (firstError !== undefined) throw firstError; }); describe("process-wide selection commit gate and cancellation fence", () => { + test("a failed pin close keeps its lifetime token without an automatic close retry", () => { + const pins = new WindowsJobStorePinSet(), handle = pins.keep(123n); + let attempts = 0, fault = true, detached = 0; + const kernel = { symbols: { CloseHandle: () => { attempts++; return fault ? 0 : 1; } } } + as unknown as Parameters[1]; + const lifetime = createCodexCliWorkerLifetime(() => { detached++; }); + const token = lifetime.retain(pins); + expect(pins.release(handle, kernel)).toBe(false); + pins.closeAll(kernel); + expect(attempts).toBe(1); + expect(pins.remaining).toBe(1); + lifetime.finish(); + expect(detached).toBe(0); + // Explicit fixture-only recovery of the same recorded handle, not production retry. + fault = false; + expect(pins.release(handle, kernel)).toBe(true); + expect(pins.remaining).toBe(0); + token.release(); + expect(detached).toBe(1); + expect(pins.release(handle, kernel)).toBe(true); + expect(attempts).toBe(2); // Already released never closes a potentially reused handle. + }); test("execution retention roots survive completion until every exact owner releases", () => { let detached = 0; const lifetime = createCodexCliWorkerLifetime(() => { detached++; }); diff --git a/tests/helpers/windows-apply-worker.ts b/tests/helpers/windows-apply-worker.ts index 1ff7188f5e..1a3833004b 100644 --- a/tests/helpers/windows-apply-worker.ts +++ b/tests/helpers/windows-apply-worker.ts @@ -11,7 +11,8 @@ if (Bun.isMainThread === false) { self.onmessage = async event => { if (ran) return; ran = true; - const { planId, inspectionDeps, target, gate, fence } = event.data; + const { requestId, planId, inspectionDeps, target, gate, fence } = event.data; + const nativeSettlements: { terminal: boolean; cleanupComplete: boolean }[] = []; const abort = new AbortController(); const view = new Int32Array(gate), word = new Int32Array(fence); try { @@ -32,17 +33,20 @@ if (Bun.isMainThread === false) { const installing = invocation.policy === "installer"; if (installing && (Atomics.load(view, 0) !== 2 || Atomics.load(word, 0) !== 2)) throw new Error("native start outside commit"); const child = startWindowsCliChild(invocation), wait = child.wait.bind(child); - child.wait = () => { + child.wait = async () => { if (Atomics.load(view, 0) !== 0) throw new Error("gate held during native wait"); - return wait(); + const result = await wait(); + nativeSettlements.push({ terminal: result.terminal, cleanupComplete: result.cleanupComplete }); + return result; }; - if (installing) self.postMessage({ type: "native-start", committed: true }); + if (installing) self.postMessage({ type: "native-start", requestId, committed: true }); return child; }, }); - self.postMessage({ type: "result", result }); + self.postMessage({ type: "result", requestId, result, cleanupConfirmed: nativeSettlements.length > 0 + && nativeSettlements.every(item => item.terminal && item.cleanupComplete) }); } catch { - self.postMessage({ type: "fixture-error" }); + self.postMessage({ type: "fixture-error", requestId }); } finally { lifetime.finish(); } }; } From b9fe203709a2ed4321e5b54064f60619b3bdcb86 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:55:09 +0900 Subject: [PATCH 17/31] test: retain fixtures for unsettled Windows CLI children --- tests/codex-integration/codex-cli-windows-plan.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index e0dc738e72..9aa558ee9a 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -24,8 +24,8 @@ afterEach(() => { setWindowsInstallationFilesOpenedForTests(); setWindowsInstallationFilesReadForTests(); for (const root of roots.splice(0)) { - // A test timeout does not cancel its async body. Keep even that body's - // fixture owned until a real Worker close, never remove beneath native I/O. + // A test timeout does not cancel its async body. Retain fixtures until a real + // Worker close, or external reconciliation of an unsettled CLI process tree. if (liveWorkerRoots.has(root)) { roots.push(root); continue; } removeTreeWithRetry(root); } @@ -457,7 +457,9 @@ function checkPublishedNodeCliPlan(separateNodeToolchain: boolean) { CODEX_CLI_PATH: f.deps.env.CODEX_CLI_PATH, OPENCODEX_HOME: f.deps.configDir, OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, }); - if (run.error || run.status !== 0) { + if (run.error || run.status !== 0 || run.signal) { + // A failed synchronous result does not prove Node/Bun descendants have exited. + liveWorkerRoots.add(f.root); console.error(JSON.stringify({ layout: separateNodeToolchain ? "separate-PATH" : "prefix-local", elapsedMs: Math.round(performance.now() - started), status: run.status, signal: run.signal, errorCode: (run.error as NodeJS.ErrnoException | undefined)?.code ?? null })); From dabee59c43e4940da0241c04e31d5ed835e0f228 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:58:13 +0900 Subject: [PATCH 18/31] Scope strict pin disposal to apply and reject mixed fixture replies --- src/codex/cli-update-job-store-windows.ts | 38 +++++++++++++------ structure/codex-cli-update.md | 9 +++-- .../codex-cli-update-windows-apply.test.ts | 35 +++++++++++++++-- .../codex-cli-update-worker.test.ts | 6 +-- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index f64fad73b2..d9b1bacd2d 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -140,31 +140,44 @@ function loadNative(): Promise { const unsafe = (): Error => new Error("unsafe_store"); const enoent = (): NodeJS.ErrnoException => Object.assign(new Error("ENOENT"), { code: "ENOENT" }); -export class WindowsJobStorePinSet { +// General ledger callers retain their existing best-effort close contract. +class PinSet { private readonly handles: bigint[] = []; - private readonly failedCloses = new Set(); - get remaining(): number { return this.handles.length; } keep(handle: bigint): bigint { this.handles.push(handle); return handle; } - release(handle: bigint, kernel: Library): boolean { + release(handle: bigint, kernel: Library): void { const index = this.handles.lastIndexOf(handle); + if (index >= 0) this.handles.splice(index, 1); + kernel.symbols.CloseHandle(handle); + } + closeAll(kernel: Library): void { + for (const handle of this.handles.splice(0).reverse()) kernel.symbols.CloseHandle(handle); + } +} + +/** Strict disposal is scoped to apply authority, not ordinary ledger/status I/O. */ +export class WindowsApplyAuthorityPins extends PinSet { + private readonly authorityHandles: bigint[] = []; + private readonly failedCloses = new Set(); + get remaining(): number { return this.authorityHandles.length; } + override keep(handle: bigint): bigint { this.authorityHandles.push(handle); return handle; } + override release(handle: bigint, kernel: Library): boolean { + const index = this.authorityHandles.lastIndexOf(handle); if (index < 0) return true; // Already released; never close a reused native handle. try { if (!kernel.symbols.CloseHandle(handle)) { this.failedCloses.add(handle); return false; } } catch { this.failedCloses.add(handle); return false; } - this.handles.splice(index, 1); + this.authorityHandles.splice(index, 1); this.failedCloses.delete(handle); return true; } - closeAll(kernel: Library): void { + override closeAll(kernel: Library): void { // One attempt per disposal. A failed explicit release must not be retried // invisibly by a later finally/closeAll in the same ownership lifecycle. - for (const handle of [...this.handles].reverse()) { + for (const handle of [...this.authorityHandles].reverse()) { if (!this.failedCloses.has(handle)) this.release(handle, kernel); } } } -const PinSet = WindowsJobStorePinSet; -type PinSet = WindowsJobStorePinSet; interface Prepared { readonly config: string; @@ -621,7 +634,8 @@ export interface WindowsJobStoreIo { export async function openWindowsCliApplyAuthority(prefix: string, acquire: boolean) { assertNotRealHomeUnderTest(prefix); const { ffi, kernel, nt } = await loadNative(); - const ctx: Ctx = { ffi, kernel, nt, pins: new PinSet() }; + const pins = new WindowsApplyAuthorityPins(); + const ctx: Ctx = { ffi, kernel, nt, pins }; let held: HeldLock | undefined; let transferred = false; let closed = false; @@ -682,7 +696,7 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool if (!sameIdentity(stage.identity, inspect(ctx, stage.handle, true))) throw unsafe(); await verifyAcl([{ path: stage.path, directory: true, policy: "private" }]); if (closed) throw unsafe(); - if (!ctx.pins.release(stage.handle, kernel)) throw unsafe(); + if (!pins.release(stage.handle, kernel)) throw unsafe(); // Ancestors remain pinned and all writers are within the verified trust boundary. rmSync(stage.path, { recursive: true }); stage = undefined; @@ -692,7 +706,7 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool closed = true; try { await verifyHeld(); if (held) releaseLock(ctx, held); } finally { ctx.pins.closeAll(kernel); } - return ctx.pins.remaining === 0 && (!acquire || !existsSync(lock)); + return pins.remaining === 0 && (!acquire || !existsSync(lock)); }, }; } finally { if (!transferred) ctx.pins.closeAll(kernel); } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 3df62d4c4e..da15fa2b1b 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -309,10 +309,13 @@ its token. No polling, automatic retry/reap or termination follows from retentio can remain resident until separately authorized recovery. A crash still loses in-memory ownership and leaves durable state unknown. The pure lifetime test is not native disposal evidence. Authority release succeeds only when lock disposal is verified and its pin set is empty. -A failed CloseHandle stays in that set; automatic closeAll skips the failed close instead of retrying -or forgetting it, so apply retains its Owner/token with `lease_release_unverified`. +A failed CloseHandle stays in the apply-only `WindowsApplyAuthorityPins` set; its closeAll skips +failed closes instead of retrying or forgetting them, preserving Owner/token with `lease_release_unverified`. +The ordinary ledger/status PinSet keeps its pre-existing best-effort disposal contract; this change +does not claim to repair or prove all native cleanup in those shared job-store callers. Fixture teardown needs both a correlated non-retained, cleanup-confirmed result and Worker join; -Worker close alone, an error, missing reply or retained owner never permits root removal. +the entire message stream must be well-formed and correlated with exactly one result. Worker close +alone, any foreign/error/malformed traffic, missing reply or retained owner never permits root removal. Cancellation crosses realms through shared memory, not a message: the parent CASes the job fence PREPARING-to-CANCELLED, the worker's `isCancelled` checkpoints read that word during its synchronous diff --git a/tests/codex-integration/codex-cli-update-windows-apply.test.ts b/tests/codex-integration/codex-cli-update-windows-apply.test.ts index ef0ce0a96c..9c61e36d9d 100644 --- a/tests/codex-integration/codex-cli-update-windows-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-apply.test.ts @@ -21,16 +21,39 @@ const native = process.platform === "win32" && process.arch === "x64" ? test : t const roots: string[] = []; const liveFixtureRoots = new Set(); type WorkerFixtureReply = { type: string; requestId?: string; cleanupConfirmed?: boolean; result?: import("../../src/codex/cli-update-apply").CodexCliApplyResult }; -function fixtureCleanupProven(messages: readonly WorkerFixtureReply[], requestId: string, closed: boolean, errored: boolean): boolean { +function fixtureCleanupProven(messages: readonly unknown[], requestId: string, closed: boolean, errored: boolean): boolean { if (!closed || errored) return false; - const replies = messages.filter(message => message.requestId === requestId); + const replies: WorkerFixtureReply[] = []; + for (const value of messages) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const message = value as WorkerFixtureReply; + // Assertions can fail independently: teardown authority rejects ALL foreign, + // malformed or error traffic, including traffic mixed with a valid result. + if (message.requestId !== requestId || !["native-start", "result"].includes(message.type)) return false; + if (message.type === "native-start" && (value as { committed?: unknown }).committed !== true) return false; + if (message.type === "result" && (!message.result || typeof message.result !== "object" + || Array.isArray(message.result) || message.cleanupConfirmed !== true)) return false; + if (message.type === "result") { + const result = message.result!; + const version = (value: unknown) => value === null || typeof value === "string"; + if (!["applied", "not_applied", "refused", "ambiguous"].includes(result.outcome) + || typeof result.reason !== "string" || !/^[a-f0-9]{32}$/.test(result.planId) + || !version(result.installedVersion) || !version(result.targetVersion) + || !(result.installerExitCode === null || Number.isInteger(result.installerExitCode)) + || !(result.verificationPid === null || (Number.isInteger(result.verificationPid) && result.verificationPid > 0))) return false; + } + replies.push(message); + } const results = replies.filter(message => message.type === "result"); - return !replies.some(message => message.type === "fixture-error") && results.length === 1 + return results.length === 1 && results[0]!.cleanupConfirmed === true && results[0]!.result?.leaseRetained === false && Array.isArray(results[0]!.result?.cleanupErrors) && results[0]!.result!.cleanupErrors.length === 0; } test("Worker close alone never authorizes removing a native fixture", () => { - const result = { leaseRetained: false, cleanupErrors: [] } as unknown as import("../../src/codex/cli-update-apply").CodexCliApplyResult; + const result: import("../../src/codex/cli-update-apply").CodexCliApplyResult = { + outcome: "applied", reason: "verified_target", planId: "a".repeat(32), installedVersion: "1.0.0", targetVersion: "1.1.0", + installerExitCode: 0, verificationPid: null, leaseRetained: false, cleanupErrors: [], + }; const safe: WorkerFixtureReply = { type: "result", requestId: "owned", result, cleanupConfirmed: true }; expect(fixtureCleanupProven([], "owned", true, false)).toBe(false); expect(fixtureCleanupProven([safe], "different", true, false)).toBe(false); @@ -38,6 +61,10 @@ test("Worker close alone never authorizes removing a native fixture", () => { expect(fixtureCleanupProven([{ ...safe, result: { ...result, leaseRetained: true } }], "owned", true, false)).toBe(false); expect(fixtureCleanupProven([{ ...safe, cleanupConfirmed: false }], "owned", true, false)).toBe(false); expect(fixtureCleanupProven([safe, safe], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([safe, { ...safe, requestId: "foreign" }], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([safe, { type: "fixture-error", requestId: "foreign" }], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([safe, null], "owned", true, false)).toBe(false); + expect(fixtureCleanupProven([safe, { type: "unknown", requestId: "owned" }], "owned", true, false)).toBe(false); expect(fixtureCleanupProven([safe], "owned", false, false)).toBe(false); expect(fixtureCleanupProven([safe], "owned", true, false)).toBe(true); }); diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts index 3a468cda9b..50798e8d3b 100644 --- a/tests/codex-integration/codex-cli-update-worker.test.ts +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -27,7 +27,7 @@ import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; import { spawnTestChild } from "../helpers/spawn-test-child"; import { repoRoot } from "../helpers/repo-root"; import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; -import { WindowsJobStorePinSet } from "../../src/codex/cli-update-job-store-windows"; +import { WindowsApplyAuthorityPins } from "../../src/codex/cli-update-job-store-windows"; import { FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, FIXTURE_CONTROL_WORDS, type FixtureConfiguration, @@ -173,10 +173,10 @@ afterEach(async () => { }); describe("process-wide selection commit gate and cancellation fence", () => { test("a failed pin close keeps its lifetime token without an automatic close retry", () => { - const pins = new WindowsJobStorePinSet(), handle = pins.keep(123n); + const pins = new WindowsApplyAuthorityPins(), handle = pins.keep(123n); let attempts = 0, fault = true, detached = 0; const kernel = { symbols: { CloseHandle: () => { attempts++; return fault ? 0 : 1; } } } - as unknown as Parameters[1]; + as unknown as Parameters[1]; const lifetime = createCodexCliWorkerLifetime(() => { detached++; }); const token = lifetime.retain(pins); expect(pins.release(handle, kernel)).toBe(false); From 9004861a3b61644b6f1bace8296f4db224ba3276 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 08:59:50 +0900 Subject: [PATCH 19/31] test: track unsettled CLI and plan-worker fixture producers --- .../codex-cli-windows-plan.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-cli-windows-plan.test.ts b/tests/codex-integration/codex-cli-windows-plan.test.ts index 9aa558ee9a..129c9b521f 100644 --- a/tests/codex-integration/codex-cli-windows-plan.test.ts +++ b/tests/codex-integration/codex-cli-windows-plan.test.ts @@ -15,6 +15,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; +import { trackBackgroundWork } from "../../src/lib/background-work"; const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; const roots: string[] = []; @@ -359,7 +360,11 @@ native("a fresh production Worker rebuilds private Windows plan evidence before // Pin this integration to current source, not a leftover generated bundle. worker = new Worker(url, { type: "module" }); liveWorkerRoots.add(f.root); - worker.addEventListener("close", () => { closed = true; liveWorkerRoots.delete(f.root); }, { once: true }); + const actualClose = Promise.withResolvers(); + void trackBackgroundWork("windows-plan-fixture-worker-close", actualClose.promise); + worker.addEventListener("close", () => { + closed = true; liveWorkerRoots.delete(f.root); actualClose.resolve(); + }, { once: true }); return worker; }, }); @@ -406,7 +411,11 @@ async function separateWorkerPlan(f: ReturnType, a // Drift needs a test-only injection seam; the positive and arch refusals use the production entry. const worker = new Worker(drift ? repoPath("tests", "helpers", "cli-windows-plan-drift-worker.ts") : url, { type: "module" }); liveWorkerRoots.add(f.root); - worker.addEventListener("close", () => { closed = true; liveWorkerRoots.delete(f.root); }, { once: true }); + const actualClose = Promise.withResolvers(); + void trackBackgroundWork("windows-plan-fixture-worker-close", actualClose.promise); + worker.addEventListener("close", () => { + closed = true; liveWorkerRoots.delete(f.root); actualClose.resolve(); + }, { once: true }); return worker; }, }); @@ -460,6 +469,8 @@ function checkPublishedNodeCliPlan(separateNodeToolchain: boolean) { if (run.error || run.status !== 0 || run.signal) { // A failed synchronous result does not prove Node/Bun descendants have exited. liveWorkerRoots.add(f.root); + // Keep the preload's ancestor sandbox too; only external ownership checks can release it. + void trackBackgroundWork("windows-plan-fixture-cli-unsettled", new Promise(() => {})); console.error(JSON.stringify({ layout: separateNodeToolchain ? "separate-PATH" : "prefix-local", elapsedMs: Math.round(performance.now() - started), status: run.status, signal: run.signal, errorCode: (run.error as NodeJS.ErrnoException | undefined)?.code ?? null })); From b40d1f0fa977f77cf493f9fd0a069202fe9540f2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:02:12 +0900 Subject: [PATCH 20/31] Retain rejected Windows authority acquisition contexts with failed pins --- src/codex/cli-update-job-store-windows.ts | 16 +++++++++- src/codex/cli-update-windows-apply.ts | 21 +++++++++---- structure/codex-cli-update.md | 5 ++++ .../codex-cli-update-worker.test.ts | 30 ++++++++++++++++++- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index d9b1bacd2d..96fe35bc59 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -179,6 +179,16 @@ export class WindowsApplyAuthorityPins extends PinSet { } } +/** Internal acquisition root; never serialized into job/HTTP DTOs. */ +export class WindowsApplyAcquisition { + constructor(readonly resources: object, private readonly pins: WindowsApplyAuthorityPins) {} + releaseIfClean(token?: { release(): void }): boolean { + if (this.pins.remaining !== 0) return false; + token?.release(); + return true; + } +} + interface Prepared { readonly config: string; readonly dir: string; @@ -631,7 +641,8 @@ export interface WindowsJobStoreIo { * Directory pins allow npm's writes but deny replacement of the prefix/ancestry. * The serving account and kernel-equivalent principals remain inside the trust boundary. */ -export async function openWindowsCliApplyAuthority(prefix: string, acquire: boolean) { +export async function openWindowsCliApplyAuthority(prefix: string, acquire: boolean, + onAcquiring?: (owner: WindowsApplyAcquisition) => void) { assertNotRealHomeUnderTest(prefix); const { ffi, kernel, nt } = await loadNative(); const pins = new WindowsApplyAuthorityPins(); @@ -640,6 +651,9 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool let transferred = false; let closed = false; try { + // Publish the context before the first pin/open or async verification can fail. + // The caller already holds its Worker token; failed cleanup remains rooted there. + onAcquiring?.(new WindowsApplyAcquisition(ctx, pins)); // The prefix is an ancestry pin, not a writable store directory. A write-access // pin would prevent the ordinary deny-write installation snapshot from opening // it, even though npm's child-file writes do not require that access on our pin. diff --git a/src/codex/cli-update-windows-apply.ts b/src/codex/cli-update-windows-apply.ts index 9d8dff66b0..560dd1b87c 100644 --- a/src/codex/cli-update-windows-apply.ts +++ b/src/codex/cli-update-windows-apply.ts @@ -4,7 +4,7 @@ import type { CodexCliApplyDeps, CodexCliApplyResult, CodexCliApplyCleanupError import { createCodexCliUpdatePlan, createNpmConfigIsolation } from "./cli-update-plan"; import { inspectCodexCliInstall, observeCodexCliSelectedCommand } from "./cli-install-provenance"; import { holdWindowsCodexCliPlanToolchain, windowsCodexCliPlanEvidence, windowsCodexCliSelectionMatches } from "./cli-windows-plan-provenance"; -import { openWindowsCliApplyAuthority } from "./cli-update-job-store-windows"; +import { openWindowsCliApplyAuthority, type WindowsApplyAcquisition } from "./cli-update-job-store-windows"; import { flushWindowsSecretAclReapsBeforeRemoval } from "../lib/windows-secret-acl"; import { verifyWindowsCodexCliLayout, matchesWindowsCodexCliLayout } from "./cli-update-windows-layout"; import { observeWindowsInstall, sameWindowsInstall, sameWindowsPreservedEntries, windowsCodexOwnedPath, @@ -97,12 +97,23 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA if (!artifacts || artifacts.platform !== "win32" || artifacts.arch !== "x64") return finish("refused", "target_artifacts_unverified"); // Root the acquisition anchor before await/Owner registration. If registration // throws, the Worker token still owns any acquired authority through this anchor. - const anchor: { authority?: Authority; owner?: Owner } = {}; + const anchor: { acquisition?: WindowsApplyAcquisition; authority?: Authority; owner?: Owner } = {}; const retention = deps.retainExecution?.(anchor); let authority: Authority; - try { authority = anchor.authority = await openWindowsCliApplyAuthority(evidence.prefix, true); } - catch (error) { retention?.release(); return finish("refused", error instanceof Error && error.message === "job_store_busy" - ? "installation_busy" : "installation_prefix_unsupported"); } + try { + authority = anchor.authority = await openWindowsCliApplyAuthority(evidence.prefix, true, + acquisition => { anchor.acquisition = acquisition; }); + } catch (error) { + // Authority's finally has already attempted cleanup, without retrying failed pins. + // A rejected acquisition can still own handles even though no Authority was returned. + if (anchor.acquisition && !anchor.acquisition.releaseIfClean(retention)) { + ownerRegistrationPending = true; + return finish("ambiguous", "installer_or_readback_error"); + } + if (!anchor.acquisition) retention?.release(); // Native load/validation failed before any ctx existed. + return finish("refused", error instanceof Error && error.message === "job_store_busy" + ? "installation_busy" : "installation_prefix_unsupported"); + } ownerRegistrationPending = true; owner = anchor.owner = { retention, authority, staging: win32.join(authority.lock, "stage"), toolchain: null }; retained.add(owner); diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index da15fa2b1b..fcd445f270 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -303,6 +303,11 @@ every installer descendant or retained native owner has finished. Windows native ownership acquires an internal execution-retention token before authority acquisition. The token roots the acquisition anchor, then the registered Owner, across registration exceptions. +The authority publishes its context to that anchor before opening pins or awaiting ACL checks. +If acquisition rejects, its finally attempts cleanup before the caller checks the published pin set; +only an empty set releases the token. Failed pins keep the context/realm rooted and report ambiguous +ownership even though no successful Authority object was returned. Native-load failure before a +context exists holds no such pins. This internal callback never crosses a job/HTTP DTO boundary. Raw FFI handles alone are not considered Worker keepalive: unresolved process/Job/pipe or cleanup keeps the run-once handler installed. Only successful terminal cleanup removes the Owner and releases its token. No polling, automatic retry/reap or termination follows from retention; a retained realm diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts index 50798e8d3b..a3d8087ca9 100644 --- a/tests/codex-integration/codex-cli-update-worker.test.ts +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -27,7 +27,7 @@ import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; import { spawnTestChild } from "../helpers/spawn-test-child"; import { repoRoot } from "../helpers/repo-root"; import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; -import { WindowsApplyAuthorityPins } from "../../src/codex/cli-update-job-store-windows"; +import { WindowsApplyAcquisition, WindowsApplyAuthorityPins } from "../../src/codex/cli-update-job-store-windows"; import { FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, FIXTURE_CONTROL_WORDS, type FixtureConfiguration, @@ -172,6 +172,34 @@ afterEach(async () => { if (firstError !== undefined) throw firstError; }); describe("process-wide selection commit gate and cancellation fence", () => { + test("a rejected authority acquisition roots failed pins before any returned authority exists", () => { + const pins = new WindowsApplyAuthorityPins(); + let fault = true, closes = 0, detached = 0; + const kernel = { symbols: { CloseHandle: () => { closes++; return fault ? 0 : 1; } } } + as unknown as Parameters[1]; + const lifetime = createCodexCliWorkerLifetime(() => { detached++; }); + const anchor: { acquisition?: WindowsApplyAcquisition } = {}; + const token = lifetime.retain(anchor); + // Model the actual callback order: ctx publication -> pin creation -> failure -> finally. + anchor.acquisition = new WindowsApplyAcquisition({ fixtureContext: true }, pins); + const exactPin = pins.keep(456n); + try { throw new Error("fixture acquisition rejected"); } + catch { pins.closeAll(kernel); } + expect(anchor.acquisition.releaseIfClean(token)).toBe(false); + lifetime.finish(); + expect(detached).toBe(0); + expect(pins.remaining).toBe(1); expect(closes).toBe(1); + pins.closeAll(kernel); expect(closes).toBe(1); + // Only explicit fixture recovery retries the exact fake handle; not an automatic policy. + fault = false; + expect(pins.release(exactPin, kernel)).toBe(true); + expect(anchor.acquisition.releaseIfClean(token)).toBe(true); + expect(detached).toBe(1); + const cleanAnchor = new WindowsApplyAcquisition({}, new WindowsApplyAuthorityPins()); + let released = 0; + expect(cleanAnchor.releaseIfClean({ release() { released++; } })).toBe(true); + expect(released).toBe(1); + }); test("a failed pin close keeps its lifetime token without an automatic close retry", () => { const pins = new WindowsApplyAuthorityPins(), handle = pins.keep(123n); let attempts = 0, fault = true, detached = 0; From 4dcf1281b16b03655f22f178cb8ccc16b5260480 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:22:13 +0900 Subject: [PATCH 21/31] Prepare real protected-pin acquisition Worker fixture arms A1 and A2 --- .../windows-cli-apply-native-validation.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/helpers/windows-cli-apply-native-validation.ts diff --git a/tests/helpers/windows-cli-apply-native-validation.ts b/tests/helpers/windows-cli-apply-native-validation.ts new file mode 100644 index 0000000000..ed01bab06a --- /dev/null +++ b/tests/helpers/windows-cli-apply-native-validation.ts @@ -0,0 +1,200 @@ +/** Private A1/A2 driver. No installer, provider or production request DTO seam. */ +import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join, parse } from "node:path"; +import { dlopen, ptr } from "bun:ffi"; +import type { Library } from "bun:ffi"; +import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; +import type { WindowsApplyAcquisition, WindowsApplyAuthorityPins } from "../../src/codex/cli-update-job-store-windows"; +import { hardenSecretDirAsync } from "../../src/lib/windows-secret-acl"; +import { fixtureArtifact, fixtureTar } from "./codex-cli-apply-artifact"; +import { fixturePath } from "./repo-root"; +import { readFileSync } from "node:fs"; + +export const NATIVE_ACQUISITION_CASES = { + A1: "authority acquisition rejection retains a real protected pin and Worker until exact recovery", + A2: "authority acquisition rejection with real successful pin disposal releases token and joins Worker", +} as const; +type Ids = { runNonce: string; caseId: "A1" | "A2"; requestId: string; jobId: string }; +const FLAGS = { + SetHandleInformation: { args: ["u64", "u32", "u32"], returns: "i32" }, + GetHandleInformation: { args: ["u64", "ptr"], returns: "i32" }, + GetLastError: { args: [], returns: "u32" }, +} as const; +type Pins = WindowsApplyAuthorityPins; +type Kernel = Parameters[1]; +type Context = { pins: Pins; kernel: Kernel }; +declare const self: Worker; + +// Every resource is rooted here before checking or reporting any observation. +const owned: { + ids?: Ids; root?: string; prefix?: string; handle?: bigint; acquisition?: WindowsApplyAcquisition; + context?: Context; flags?: Library; keep?: Pins["keep"]; release?: Pins["release"]; + token?: { release(): void }; tokenCount: number; releaseCount: number; + closeAttempts: number; closeResult?: boolean; closeError?: number; protected?: boolean; + protectionVerified?: boolean; + applyFinished: boolean; recoveryStarted: boolean; sequence: number; +} = { tokenCount: 0, releaseCount: 0, closeAttempts: 0, applyFinished: false, recoveryStarted: false, sequence: 0 }; +function emit(type: string, fields: Record = {}) { + self.postMessage({ ...owned.ids, sequence: ++owned.sequence, type, ...fields }); +} +class FixtureCheckError extends Error {} +function need(value: unknown, phase: string): asserts value { + if (!value) throw new FixtureCheckError(phase); +} +function handleFlags(): number { + const value = Buffer.alloc(4); + need(owned.handle !== undefined && owned.flags, "missing_owned_handle"); + need(owned.flags.symbols.GetHandleInformation(owned.handle, ptr(value)), "GetHandleInformation_failed"); + return value.readUInt32LE(0); +} +const lifetime = createCodexCliWorkerLifetime(() => { self.onmessage = null; }); +function cleanupReceipt() { + need(owned.context && owned.prefix, "missing_context"); + return { + childCount: 0, lockCreated: false, stagingCreated: false, + allOriginalProcessesSignaled: "NOT_CREATED", allOwnedJobsEmpty: "NOT_CREATED", + allPipesEOFOrSettledCancellation: "NOT_CREATED", allBorrowedBuffersReleased: "NOT_CREATED", + nativeHandleCount: owned.context.pins.remaining, authorityPinCount: owned.context.pins.remaining, + retentionTokenCount: owned.tokenCount, tokenReleaseCount: owned.releaseCount, + toolchainReleased: "NOT_ACQUIRED", stagingDisposition: "NOT_CREATED", + leaseDisposition: existsSync(join(owned.prefix, ".ocx-codex-cli-apply.lock")) ? "UNEXPECTED_PRESENT" : "NOT_CREATED", + closeAttempts: owned.closeAttempts, protectionCleared: owned.protected !== true, + }; +} +function restoreInstrumentation() { + need(owned.context && owned.keep && owned.release, "missing_instance_instrumentation"); + owned.context.pins.keep = owned.keep; + owned.context.pins.release = owned.release; +} +async function prepareFixture() { + owned.root = mkdtempSync(join(parse(process.cwd()).root, "ocx-native-acquire-")); + emit("started", { fixtureRoot: owned.root, name: NATIVE_ACQUISITION_CASES[owned.ids!.caseId] }); + await hardenSecretDirAsync(owned.root, { required: true }); + owned.prefix = join(owned.root, "prefix"); + const mainRoot = join(owned.prefix, "node_modules/@openai/codex"); + const backingRoot = join(owned.prefix, "node_modules/@openai/codex-win32-x64"); + const npmRoot = join(owned.prefix, "node_modules/npm"); + const write = (path: string, value: string | Buffer) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, value); }; + write(join(mainRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "1.0.0", bin: { codex: "bin/codex.js" } })); + write(join(mainRoot, "bin/codex.js"), 'throw new Error("fixture candidate must not execute");'); + write(join(backingRoot, "package.json"), JSON.stringify({ name: "@openai/codex-win32-x64", version: "1.0.0" })); + write(join(backingRoot, "vendor/x86_64-pc-windows-msvc/bin/codex.exe"), "owned backing observation bytes"); + write(join(npmRoot, "package.json"), JSON.stringify({ name: "npm", version: "11.0.0", bin: { npm: "bin/npm-cli.js" } })); + write(join(npmRoot, "bin/npm-cli.js"), 'throw new Error("fixture npm must not execute");'); + const pe = Buffer.alloc(128); pe.write("MZ"); pe.writeUInt32LE(64, 60); pe.write("PE\0\0", 64); pe.writeUInt16LE(0x8664, 68); + write(join(owned.prefix, "node.exe"), pe); // Header observation only; never executed in acquisition cases. + const shims = JSON.parse(readFileSync(fixturePath("codex-windows-npm-shims.json"), "utf8")); + for (const [name, bytes] of Object.entries(shims.files)) write(join(owned.prefix, name), String(bytes)); + for (const path of [owned.prefix, join(owned.prefix, "node_modules"), join(owned.prefix, "node_modules/@openai"), mainRoot]) { + await hardenSecretDirAsync(path, { required: true }); + } + const tar = fixtureTar({ "package.json": JSON.stringify({ name: "@openai/codex", version: "1.1.0" }) }); + const main = fixtureArtifact(tar, "1.1.0"); + const backing = fixtureArtifact(tar, "1.1.0", "@openai/codex-win32-x64", "@openai/codex-win32-x64"); + return { + platform: "win32" as const, + inspectionDeps: { env: { CODEX_CLI_PATH: join(owned.prefix, "codex.cmd"), PATH: owned.prefix, PATHEXT: ".EXE;.CMD" }, + configDir: join(owned.root, "absent-home"), selectionAttested: true, arch: "x64", observeInstallContent: true }, + resolveTarget: () => ({ kind: "resolved" as const, version: "1.1.0", integrity: main.integrity, + artifacts: { platform: "win32" as const, arch: "x64", main, backing } }), + scanProcesses: () => ({ kind: "observed" as const, processes: [] }), + }; +} +function observeAcquisition(value: WindowsApplyAcquisition) { + owned.acquisition = value; + const context = owned.context = value.resources as Context; + owned.keep = context.pins.keep; owned.release = context.pins.release; + context.pins.release = function(handle, kernel) { + const result = Reflect.apply(owned.release!, this, [handle, kernel]); + if (handle === owned.handle) { + owned.closeResult = result; owned.closeAttempts++; + owned.closeError = Number(owned.flags!.symbols.GetLastError()); + } + return result; + }; + context.pins.keep = function(handle) { + const result = Reflect.apply(owned.keep!, this, [handle]); + owned.handle = handle; // Exact real registered handle is rooted before any flag assertion. + if (owned.ids!.caseId === "A1") { + need(owned.flags!.symbols.SetHandleInformation(handle, 2, 2), "SetHandleInformation_failed"); + owned.protected = true; + need((handleFlags() & 2) === 2, "protection_readback_failed"); + } else need((handleFlags() & 2) === 0, "counterarm_unexpected_protection"); + owned.protectionVerified = true; + throw new Error("fixture-induced-acquisition-rejection"); + }; +} +async function run(ids: Ids) { + owned.ids = ids; + need(process.platform === "win32" && process.arch === "x64", "native_platform_unavailable"); + const deps = await prepareFixture(); + const plan = await createCodexCliUpdatePlan(deps); + need(plan.applicable && plan.planId, "fixture_plan_not_applicable"); + owned.flags = dlopen("kernel32.dll", FLAGS); + const result = await applyCodexCliUpdate(plan.planId, { ...deps, + retainExecution: anchor => { + const token = lifetime.retain(anchor); owned.tokenCount++; + let released = false; + owned.token = { release() { if (released) return; released = true; owned.tokenCount--; owned.releaseCount++; token.release(); } }; + Object.defineProperty(anchor, "acquisition", { configurable: true, set: observeAcquisition, get: () => owned.acquisition }); + return owned.token; + }, + startWindowsChild: () => { emit("native-start"); throw new Error("unexpected_child_start"); }, + }); + owned.applyFinished = true; + need(owned.context && owned.handle !== undefined && owned.acquisition, "acquisition_seam_not_reached"); + restoreInstrumentation(); + emit("native-observation", { closeAttempts: owned.closeAttempts, closeReturned: owned.closeResult, + closeLastError: owned.closeError, protectedFlag: owned.protected === true, + protectionVerified: owned.protectionVerified === true, + remainingPins: owned.context.pins.remaining, tokenCount: owned.tokenCount, tokenReleaseCount: owned.releaseCount, + fault: ids.caseId === "A1" ? "REAL_OS_PROTECT_FROM_CLOSE_WITH_SYNTHETIC_ACQUISITION_THROW" : "SYNTHETIC_ACQUISITION_THROW_REAL_OS_CLOSE" }); + emit("apply-result", { result }); // Immutable first outcome; recovery never republishes it. + if (ids.caseId === "A2") { + need(owned.context.pins.remaining === 0 && owned.tokenCount === 0 && owned.releaseCount === 1, "counterarm_cleanup_unverified"); + owned.flags.close(); owned.flags = undefined; + emit("cleanup-receipt", cleanupReceipt()); + } + lifetime.finish(); +} +function recover() { + need(owned.ids?.caseId === "A1" && owned.applyFinished && !owned.recoveryStarted, "recovery_not_admitted"); + owned.recoveryStarted = true; + need(owned.context && owned.flags && owned.handle !== undefined && owned.acquisition && owned.token, "recovery_owner_missing"); + restoreInstrumentation(); + need(owned.context.pins.remaining === 1 && (handleFlags() & 2) === 2, "recovery_identity_state_mismatch"); + need(owned.flags.symbols.SetHandleInformation(owned.handle, 2, 0), "clear_protect_failed"); + need((handleFlags() & 2) === 0, "clear_protect_readback_failed"); + owned.protected = false; + need(owned.context.pins.release(owned.handle, owned.context.kernel), "explicit_exact_pin_release_failed"); + need(owned.context.pins.remaining === 0, "pins_not_empty_after_recovery"); + owned.flags.close(); owned.flags = undefined; + need(owned.acquisition.releaseIfClean(owned.token), "token_release_not_clean"); + need(owned.tokenCount === 0 && owned.releaseCount === 1, "token_release_count_mismatch"); + emit("cleanup-receipt", cleanupReceipt()); +} +if (Bun.isMainThread === false) { + self.onmessage = async event => { + const input = event.data; + try { + if (!owned.ids) { + need(input?.type === "run" && ["A1", "A2"].includes(input.caseId) + && [input.runNonce, input.requestId, input.jobId].every(value => typeof value === "string" && value.length >= 16), "bad_first_envelope"); + await run(input); + } else { + need(input?.runNonce === owned.ids.runNonce && input.caseId === owned.ids.caseId + && input.requestId === owned.ids.requestId && input.jobId === owned.ids.jobId, "uncorrelated_command"); + if (input.type === "probe") { + need(owned.applyFinished && owned.tokenCount === 1, "probe_not_retained"); + emit("lifetime-probe-ack", { probeNonce: input.probeNonce, remainingPins: owned.context!.pins.remaining, tokenCount: owned.tokenCount }); + } else if (input.type === "recover") recover(); + else throw new Error("unknown_command"); + } + } catch (error) { + // No finally-disposal here: an unexplained error keeps every recorded root/handle. + emit("fixture-error", { phase: error instanceof FixtureCheckError ? error.message : "unexpected_fixture_error" }); + } + }; +} From bf77092f4c602798398288e1afded530e25a8029 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:34:38 +0900 Subject: [PATCH 22/31] Preserve A1 diagnostic recovery after failed native observations --- .../windows-cli-apply-native-validation.ts | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/tests/helpers/windows-cli-apply-native-validation.ts b/tests/helpers/windows-cli-apply-native-validation.ts index ed01bab06a..850199cfbd 100644 --- a/tests/helpers/windows-cli-apply-native-validation.ts +++ b/tests/helpers/windows-cli-apply-native-validation.ts @@ -4,6 +4,7 @@ import { dirname, join, parse } from "node:path"; import { dlopen, ptr } from "bun:ffi"; import type { Library } from "bun:ffi"; import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; +import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; import type { WindowsApplyAcquisition, WindowsApplyAuthorityPins } from "../../src/codex/cli-update-job-store-windows"; @@ -34,8 +35,12 @@ const owned: { token?: { release(): void }; tokenCount: number; releaseCount: number; closeAttempts: number; closeResult?: boolean; closeError?: number; protected?: boolean; protectionVerified?: boolean; + stage: string; runSettled: boolean; runInFlight: boolean; caseFailed: boolean; stateNonce?: string; + originalResult?: CodexCliApplyResult; faultError?: string; applyFinished: boolean; recoveryStarted: boolean; sequence: number; -} = { tokenCount: 0, releaseCount: 0, closeAttempts: 0, applyFinished: false, recoveryStarted: false, sequence: 0 }; +} = { tokenCount: 0, releaseCount: 0, closeAttempts: 0, applyFinished: false, recoveryStarted: false, sequence: 0, + stage: "BEFORE_RUN", runSettled: false, runInFlight: false, caseFailed: false }; +const commandIds = new Set(); function emit(type: string, fields: Record = {}) { self.postMessage({ ...owned.ids, sequence: ++owned.sequence, type, ...fields }); } @@ -96,9 +101,9 @@ async function prepareFixture() { return { platform: "win32" as const, inspectionDeps: { env: { CODEX_CLI_PATH: join(owned.prefix, "codex.cmd"), PATH: owned.prefix, PATHEXT: ".EXE;.CMD" }, - configDir: join(owned.root, "absent-home"), selectionAttested: true, arch: "x64", observeInstallContent: true }, + configDir: join(owned.root, "absent-home"), selectionAttested: true, arch: "x64" as const, observeInstallContent: true }, resolveTarget: () => ({ kind: "resolved" as const, version: "1.1.0", integrity: main.integrity, - artifacts: { platform: "win32" as const, arch: "x64", main, backing } }), + artifacts: { platform: "win32" as const, arch: "x64" as const, main, backing } }), scanProcesses: () => ({ kind: "observed" as const, processes: [] }), }; } @@ -117,22 +122,30 @@ function observeAcquisition(value: WindowsApplyAcquisition) { context.pins.keep = function(handle) { const result = Reflect.apply(owned.keep!, this, [handle]); owned.handle = handle; // Exact real registered handle is rooted before any flag assertion. - if (owned.ids!.caseId === "A1") { - need(owned.flags!.symbols.SetHandleInformation(handle, 2, 2), "SetHandleInformation_failed"); - owned.protected = true; - need((handleFlags() & 2) === 2, "protection_readback_failed"); - } else need((handleFlags() & 2) === 0, "counterarm_unexpected_protection"); - owned.protectionVerified = true; + owned.stage = "REAL_PIN_REGISTERED"; + try { + if (owned.ids!.caseId === "A1") { + need(owned.flags!.symbols.SetHandleInformation(handle, 2, 2), "SetHandleInformation_failed"); + owned.protected = true; owned.stage = "PROTECT_SET_READBACK_PENDING"; + need((handleFlags() & 2) === 2, "protection_readback_failed"); + } else need((handleFlags() & 2) === 0, "counterarm_unexpected_protection"); + owned.protectionVerified = true; owned.stage = "FLAG_OBSERVED"; + } catch (error) { + owned.faultError = error instanceof FixtureCheckError ? error.message : "flag_observation_error"; + throw error; // Production catch/finally still attempts the real close. + } throw new Error("fixture-induced-acquisition-rejection"); }; } async function run(ids: Ids) { owned.ids = ids; + owned.stage = "PREPARING_FIXTURE"; need(process.platform === "win32" && process.arch === "x64", "native_platform_unavailable"); const deps = await prepareFixture(); const plan = await createCodexCliUpdatePlan(deps); need(plan.applicable && plan.planId, "fixture_plan_not_applicable"); owned.flags = dlopen("kernel32.dll", FLAGS); + owned.stage = "APPLY_ACQUISITION"; const result = await applyCodexCliUpdate(plan.planId, { ...deps, retainExecution: anchor => { const token = lifetime.retain(anchor); owned.tokenCount++; @@ -143,9 +156,11 @@ async function run(ids: Ids) { }, startWindowsChild: () => { emit("native-start"); throw new Error("unexpected_child_start"); }, }); + owned.originalResult = result; owned.applyFinished = true; need(owned.context && owned.handle !== undefined && owned.acquisition, "acquisition_seam_not_reached"); restoreInstrumentation(); + if (owned.faultError) throw new FixtureCheckError(owned.faultError); emit("native-observation", { closeAttempts: owned.closeAttempts, closeReturned: owned.closeResult, closeLastError: owned.closeError, protectedFlag: owned.protected === true, protectionVerified: owned.protectionVerified === true, @@ -159,21 +174,41 @@ async function run(ids: Ids) { } lifetime.finish(); } -function recover() { - need(owned.ids?.caseId === "A1" && owned.applyFinished && !owned.recoveryStarted, "recovery_not_admitted"); - owned.recoveryStarted = true; - need(owned.context && owned.flags && owned.handle !== undefined && owned.acquisition && owned.token, "recovery_owner_missing"); +function inspectOwnedState() { + owned.stateNonce = crypto.randomUUID(); + let protection: number | "UNOBSERVED" | "UNAVAILABLE" = "UNOBSERVED"; + if (owned.context?.pins.remaining && owned.handle !== undefined && owned.flags) { + try { protection = handleFlags() & 2; } catch { protection = "UNAVAILABLE"; } + } + return { stage: owned.stage, runSettled: owned.runSettled, caseFailed: owned.caseFailed, + runInFlight: owned.runInFlight, + stateNonce: owned.stateNonce, knownOwnedPin: !!owned.context && owned.handle !== undefined, + remainingPins: owned.context?.pins.remaining ?? 0, protection, tokenCount: owned.tokenCount, + hasAcquisition: !!owned.acquisition, flagsLibraryOpen: !!owned.flags }; +} +function recover(stateNonce: unknown) { + need(owned.runSettled && !owned.runInFlight && typeof stateNonce === "string" && stateNonce === owned.stateNonce, "fresh_inspect_required"); + owned.stateNonce = undefined; // Consumed before action; uncertain actions are never auto-replayed. + need(owned.context && owned.acquisition && owned.token, "recovery_owner_missing"); restoreInstrumentation(); - need(owned.context.pins.remaining === 1 && (handleFlags() & 2) === 2, "recovery_identity_state_mismatch"); - need(owned.flags.symbols.SetHandleInformation(owned.handle, 2, 0), "clear_protect_failed"); - need((handleFlags() & 2) === 0, "clear_protect_readback_failed"); - owned.protected = false; - need(owned.context.pins.release(owned.handle, owned.context.kernel), "explicit_exact_pin_release_failed"); + owned.stage = "EXACT_RECOVERY"; + if (owned.context.pins.remaining !== 0) { + need(owned.context.pins.remaining === 1 && owned.handle !== undefined && owned.flags, "recovery_identity_state_mismatch"); + const flags = handleFlags(); // Fresh query of the exact still-owned pin, not cached success. + if ((flags & 2) !== 0) { + need(owned.flags.symbols.SetHandleInformation(owned.handle, 2, 0), "clear_protect_failed"); + need((handleFlags() & 2) === 0, "clear_protect_readback_failed"); + } + owned.protected = false; + need(owned.context.pins.release(owned.handle, owned.context.kernel), "explicit_exact_pin_release_failed"); + } need(owned.context.pins.remaining === 0, "pins_not_empty_after_recovery"); - owned.flags.close(); owned.flags = undefined; + if (owned.flags) { owned.flags.close(); owned.flags = undefined; } + owned.protected = false; need(owned.acquisition.releaseIfClean(owned.token), "token_release_not_clean"); need(owned.tokenCount === 0 && owned.releaseCount === 1, "token_release_count_mismatch"); emit("cleanup-receipt", cleanupReceipt()); + lifetime.finish(); } if (Bun.isMainThread === false) { self.onmessage = async event => { @@ -182,19 +217,28 @@ if (Bun.isMainThread === false) { if (!owned.ids) { need(input?.type === "run" && ["A1", "A2"].includes(input.caseId) && [input.runNonce, input.requestId, input.jobId].every(value => typeof value === "string" && value.length >= 16), "bad_first_envelope"); - await run(input); + owned.runInFlight = true; + try { await run(input); } + finally { owned.runInFlight = false; owned.runSettled = true; } } else { need(input?.runNonce === owned.ids.runNonce && input.caseId === owned.ids.caseId && input.requestId === owned.ids.requestId && input.jobId === owned.ids.jobId, "uncorrelated_command"); - if (input.type === "probe") { + need(typeof input.commandId === "string" && !commandIds.has(input.commandId), "duplicate_or_missing_command"); + commandIds.add(input.commandId); + emit("command-ack", { commandId: input.commandId }); + if (input.type === "inspect") { + emit("owned-state", inspectOwnedState()); + } else if (input.type === "probe") { need(owned.applyFinished && owned.tokenCount === 1, "probe_not_retained"); emit("lifetime-probe-ack", { probeNonce: input.probeNonce, remainingPins: owned.context!.pins.remaining, tokenCount: owned.tokenCount }); - } else if (input.type === "recover") recover(); + } else if (input.type === "recover") recover(input.stateNonce); else throw new Error("unknown_command"); } } catch (error) { // No finally-disposal here: an unexplained error keeps every recorded root/handle. - emit("fixture-error", { phase: error instanceof FixtureCheckError ? error.message : "unexpected_fixture_error" }); + owned.caseFailed = true; + emit("fixture-error", { phase: error instanceof FixtureCheckError ? error.message : "unexpected_fixture_error", + knownOwnership: inspectOwnedState(), originalApplyResult: owned.originalResult }); } }; } From a2b663e07bb3843dcd7e7d45de09f2989a64b665 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:43:02 +0900 Subject: [PATCH 23/31] Separate no-acquisition cleanup evidence from native pin recovery --- .../windows-cli-apply-native-validation.ts | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/tests/helpers/windows-cli-apply-native-validation.ts b/tests/helpers/windows-cli-apply-native-validation.ts index 850199cfbd..5993978c92 100644 --- a/tests/helpers/windows-cli-apply-native-validation.ts +++ b/tests/helpers/windows-cli-apply-native-validation.ts @@ -37,9 +37,11 @@ const owned: { protectionVerified?: boolean; stage: string; runSettled: boolean; runInFlight: boolean; caseFailed: boolean; stateNonce?: string; originalResult?: CodexCliApplyResult; faultError?: string; + preparation: string; planning: string; flagsLifecycle: string; applyInvoked: boolean; retentionAdopted: boolean; applyFinished: boolean; recoveryStarted: boolean; sequence: number; } = { tokenCount: 0, releaseCount: 0, closeAttempts: 0, applyFinished: false, recoveryStarted: false, sequence: 0, - stage: "BEFORE_RUN", runSettled: false, runInFlight: false, caseFailed: false }; + stage: "BEFORE_RUN", runSettled: false, runInFlight: false, caseFailed: false, + preparation: "NOT_STARTED", planning: "NOT_STARTED", flagsLifecycle: "NOT_LOADED", applyInvoked: false, retentionAdopted: false }; const commandIds = new Set(); function emit(type: string, fields: Record = {}) { self.postMessage({ ...owned.ids, sequence: ++owned.sequence, type, ...fields }); @@ -55,6 +57,13 @@ function handleFlags(): number { return value.readUInt32LE(0); } const lifetime = createCodexCliWorkerLifetime(() => { self.onmessage = null; }); +function closeFlagsLibrary() { + need(owned.flags && owned.flagsLifecycle === "OPEN", "auxiliary_library_not_proven_open"); + owned.flagsLifecycle = "CLOSING"; + try { owned.flags.close(); } + catch (error) { owned.flagsLifecycle = "UNKNOWN_CLOSE_FAILURE"; throw error; } + owned.flags = undefined; owned.flagsLifecycle = "CLOSED"; +} function cleanupReceipt() { need(owned.context && owned.prefix, "missing_context"); return { @@ -141,13 +150,25 @@ async function run(ids: Ids) { owned.ids = ids; owned.stage = "PREPARING_FIXTURE"; need(process.platform === "win32" && process.arch === "x64", "native_platform_unavailable"); - const deps = await prepareFixture(); - const plan = await createCodexCliUpdatePlan(deps); + owned.preparation = "IN_FLIGHT"; + let deps: Awaited>; + try { deps = await prepareFixture(); owned.preparation = "COMPLETED"; } + catch (error) { owned.preparation = "FAILED"; throw error; } + owned.planning = "IN_FLIGHT"; + owned.stage = "PLANNING"; + let plan: Awaited>; + try { plan = await createCodexCliUpdatePlan(deps); owned.planning = "RETURNED"; } + catch (error) { owned.planning = "THREW"; throw error; } need(plan.applicable && plan.planId, "fixture_plan_not_applicable"); - owned.flags = dlopen("kernel32.dll", FLAGS); + owned.flagsLifecycle = "LOADING"; + owned.stage = "AUXILIARY_NATIVE_LOAD"; + try { owned.flags = dlopen("kernel32.dll", FLAGS); owned.flagsLifecycle = "OPEN"; } + catch (error) { owned.flagsLifecycle = "UNKNOWN_LOAD_FAILURE"; throw error; } owned.stage = "APPLY_ACQUISITION"; + owned.applyInvoked = true; const result = await applyCodexCliUpdate(plan.planId, { ...deps, retainExecution: anchor => { + owned.retentionAdopted = true; const token = lifetime.retain(anchor); owned.tokenCount++; let released = false; owned.token = { release() { if (released) return; released = true; owned.tokenCount--; owned.releaseCount++; token.release(); } }; @@ -169,7 +190,7 @@ async function run(ids: Ids) { emit("apply-result", { result }); // Immutable first outcome; recovery never republishes it. if (ids.caseId === "A2") { need(owned.context.pins.remaining === 0 && owned.tokenCount === 0 && owned.releaseCount === 1, "counterarm_cleanup_unverified"); - owned.flags.close(); owned.flags = undefined; + closeFlagsLibrary(); emit("cleanup-receipt", cleanupReceipt()); } lifetime.finish(); @@ -184,11 +205,34 @@ function inspectOwnedState() { runInFlight: owned.runInFlight, stateNonce: owned.stateNonce, knownOwnedPin: !!owned.context && owned.handle !== undefined, remainingPins: owned.context?.pins.remaining ?? 0, protection, tokenCount: owned.tokenCount, - hasAcquisition: !!owned.acquisition, flagsLibraryOpen: !!owned.flags }; + hasAcquisition: !!owned.acquisition, flagsLibraryOpen: !!owned.flags, + preparation: owned.preparation, planning: owned.planning, flagsLifecycle: owned.flagsLifecycle, + applyInvoked: owned.applyInvoked, retentionAdopted: owned.retentionAdopted }; +} +function recoverNoAcquisition() { + // This is evidence of fixture/apply acquisition scope only, not an audit of + // every internal native handle used by the already-returned production planner. + need(owned.preparation === "COMPLETED" && owned.planning === "RETURNED" + && !owned.applyInvoked && !owned.retentionAdopted && !owned.acquisition && !owned.context + && !owned.token && owned.handle === undefined && owned.tokenCount === 0 + && owned.releaseCount === 0 && owned.closeAttempts === 0 && owned.prefix, "no_acquisition_not_proven"); + need((owned.flagsLifecycle === "NOT_LOADED" && !owned.flags) || (owned.flagsLifecycle === "OPEN" && owned.flags), "auxiliary_load_state_unknown"); + if (owned.flags) closeFlagsLibrary(); + need(!existsSync(join(owned.prefix, ".ocx-codex-cli-apply.lock")), "unexpected_pre_acquisition_lock"); + emit("cleanup-receipt", { + cleanupBranch: "NO_ACQUISITION_PREPARATION_SETTLED", preparation: owned.preparation, planning: owned.planning, + flagsLifecycle: owned.flagsLifecycle, applyInvoked: false, retentionAdopted: false, + childCount: 0, lockCreated: false, stagingCreated: false, nativeHandleCount: 0, + authorityPinCount: 0, retentionTokenCount: 0, tokenReleaseCount: 0, protectionCleared: true, + leaseDisposition: "NOT_CREATED", stagingDisposition: "NOT_CREATED", toolchainReleased: "NOT_ACQUIRED", + nativeHandleScope: "FIXTURE_APPLY_ACQUISITION_ONLY", planningNativeInternals: "RETURNED_NOT_EXHAUSTIVELY_AUDITED", + }); + lifetime.finish(); } function recover(stateNonce: unknown) { need(owned.runSettled && !owned.runInFlight && typeof stateNonce === "string" && stateNonce === owned.stateNonce, "fresh_inspect_required"); owned.stateNonce = undefined; // Consumed before action; uncertain actions are never auto-replayed. + if (!owned.acquisition && !owned.context && !owned.token) { recoverNoAcquisition(); return; } need(owned.context && owned.acquisition && owned.token, "recovery_owner_missing"); restoreInstrumentation(); owned.stage = "EXACT_RECOVERY"; @@ -203,7 +247,7 @@ function recover(stateNonce: unknown) { need(owned.context.pins.release(owned.handle, owned.context.kernel), "explicit_exact_pin_release_failed"); } need(owned.context.pins.remaining === 0, "pins_not_empty_after_recovery"); - if (owned.flags) { owned.flags.close(); owned.flags = undefined; } + if (owned.flags) closeFlagsLibrary(); owned.protected = false; need(owned.acquisition.releaseIfClean(owned.token), "token_release_not_clean"); need(owned.tokenCount === 0 && owned.releaseCount === 1, "token_release_count_mismatch"); From c24b4a68eccfd9aac27b8fcf5a5ab2da9a6c1e71 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 10:30:39 +0900 Subject: [PATCH 24/31] Add owned native Job and overlapped pipe Worker fixtures --- .../windows-cli-child-worker-validation.ts | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 tests/helpers/windows-cli-child-worker-validation.ts diff --git a/tests/helpers/windows-cli-child-worker-validation.ts b/tests/helpers/windows-cli-child-worker-validation.ts new file mode 100644 index 0000000000..a9e266ccfa --- /dev/null +++ b/tests/helpers/windows-cli-child-worker-validation.ts @@ -0,0 +1,275 @@ +/** A3/A4 owned native fixtures. No application, npm installer, or production recovery API. */ +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join, parse } from "node:path"; +import { ptr } from "bun:ffi"; +import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; +import { WindowsChildNative, WIN64_CHILD_ABI } from "../../src/codex/windows-cli-child-native"; +import { WindowsChildPipe } from "../../src/codex/windows-cli-child-pipe"; +import { cleanupUnstartedWindowsChild, startWindowsCliChildWithNative, + type WindowsCliChild, type WindowsCliChildResult } from "../../src/codex/windows-cli-child"; +import { fixturePath } from "./repo-root"; + +type CaseId = "A3" | "A4"; +type Ids = { runNonce: string; caseId: CaseId; requestId: string; jobId: string }; +type Kernel = WindowsChildNative["k"]; +declare const self: Worker; +const own: { + ids?: Ids; root?: string; gate?: string; native?: WindowsChildNative; child?: WindowsCliChild; pipe?: WindowsChildPipe; + realQuery?: Kernel["QueryInformationJobObject"]; realOverlapped?: Kernel["GetOverlappedResult"]; realClose?: Kernel["CloseHandle"]; + token?: { release(): void }; tokenCount: number; tokenReleases: number; + sequence: number; stage: string; runSettled: boolean; runInFlight: boolean; recovering: boolean; + failed: boolean; stateNonce?: string; nativeConstruction: string; nativeDisposed: boolean; + queryFaultInstalled: boolean; syntheticQueryCalls: number; unknownResult?: Readonly; + originalResult?: Record; primaryAndDescendantObserved: boolean; + gateWriteAttempted: boolean; gateReleased: boolean; cooperativeReleaseObserved: boolean; fixtureStartedAt?: number; + cancellationAttempted: boolean; cancellationReturned?: boolean; cancellationError?: number; + overlappedCompletion?: { ok: boolean; error: number; internalPending: boolean }; + pendingReadObserved: boolean; closeWhilePending: boolean; settledPipeCloses: number; buffer?: Buffer; overlapped?: Buffer; +} = { tokenCount: 0, tokenReleases: 0, sequence: 0, stage: "BEFORE_RUN", runSettled: false, + runInFlight: false, recovering: false, failed: false, nativeConstruction: "NOT_STARTED", nativeDisposed: false, + queryFaultInstalled: false, syntheticQueryCalls: 0, primaryAndDescendantObserved: false, + gateWriteAttempted: false, gateReleased: false, cooperativeReleaseObserved: false, + cancellationAttempted: false, pendingReadObserved: false, closeWhilePending: false, settledPipeCloses: 0 }; +const commands = new Set(); +const lifetime = createCodexCliWorkerLifetime(() => { self.onmessage = null; }); +class FixtureError extends Error {} +function need(value: unknown, phase: string): asserts value { if (!value) throw new FixtureError(phase); } +function emit(type: string, fields: Record = {}) { + self.postMessage({ ...own.ids, sequence: ++own.sequence, type, ...fields }); +} +async function until(check: () => boolean, phase: string, budgetMs = 5000) { + const deadline = performance.now() + budgetMs; + while (!check()) { if (performance.now() >= deadline) throw new FixtureError(phase); await Bun.sleep(5); } +} +/** Always queries the original handles using the saved real API, never child.observe's cache. */ +function realJobObservation() { + const n = own.native, child = own.child, query = own.realQuery; + if (!n || !child?.process || !child.job || !query || own.nativeDisposed + || !n.handles.has(child.process) || !n.handles.has(child.job)) return null; + const accounting = Buffer.alloc(WIN64_CHILD_ABI.jobAccounting); + const wait = Number(n.k.WaitForSingleObject(child.process, 0)); + const queried = Boolean(query(child.job, 1, ptr(accounting), accounting.length, null)); + if (![0, 258].includes(wait) || !queried) return null; + return { primarySignaled: wait === 0, activeProcesses: accounting.readUInt32LE(WIN64_CHILD_ABI.activeProcesses) }; +} +function state() { + own.stateNonce = crypto.randomUUID(); + return { stateNonce: own.stateNonce, stage: own.stage, runSettled: own.runSettled, runInFlight: own.runInFlight, + recovering: own.recovering, caseFailed: own.failed, nativeConstruction: own.nativeConstruction, + nativeDisposed: own.nativeDisposed, knownNativeOwner: !!own.native, + handles: own.native?.handles.size ?? null, borrowedBuffers: own.native?.borrowedMemory.size ?? null, + tokenCount: own.tokenCount, tokenReleases: own.tokenReleases, job: realJobObservation(), + queryFaultInstalled: own.queryFaultInstalled, gateWriteAttempted: own.gateWriteAttempted, gateReleased: own.gateReleased, + pipePending: own.pipe?.pending ?? null, pipeEOF: own.pipe?.eof ?? null, + buffersStillRooted: !!own.pipe && own.buffer === own.pipe.buffer && own.overlapped === own.pipe.overlapped, + cancellationAttempted: own.cancellationAttempted, cancellationReturned: own.cancellationReturned ?? null, + overlappedCompletion: own.overlappedCompletion ?? null }; +} +function fail(phase: string) { + own.failed = true; + emit("fixture-error", { phase, knownOwnership: state(), originalResult: own.originalResult }); +} +function adoptPipe(pipe: WindowsChildPipe) { + own.pipe = pipe; own.buffer = pipe.buffer; own.overlapped = pipe.overlapped; +} +async function run(ids: Ids) { + own.ids = ids; + need(process.platform === "win32" && process.arch === "x64", "WINDOWS_X64_REQUIRED"); + own.root = mkdtempSync(join(parse(process.cwd()).root, "ocx-native-worker-")); + own.gate = join(own.root, "descendant-release"); + emit("started", { fixtureRoot: own.root, caseName: ids.caseId === "A3" ? "native Job unknown observation" : "overlapped cancellation settlement" }); + const token = lifetime.retain(own); own.tokenCount++; + let released = false; + own.token = { release() { if (released) return; released = true; own.tokenCount--; own.tokenReleases++; token.release(); } }; + own.nativeConstruction = "IN_FLIGHT"; + try { own.native = new WindowsChildNative(); own.nativeConstruction = "CREATED"; } + catch (error) { own.nativeConstruction = "UNKNOWN_FAILURE"; throw error; } + const n = own.native; + if (ids.caseId === "A3") { + own.stage = "STARTING_OWNED_FIXTURE"; own.realQuery = n.k.QueryInformationJobObject; + own.fixtureStartedAt = performance.now(); + own.child = startWindowsCliChildWithNative({ executable: process.execPath, + args: [fixturePath("windows-cli-child.ts"), "descendant", own.gate], cwd: own.root, + env: process.env, policy: "installer" }, n); + adoptPipe(own.child.pipe); + need(!own.child.setupFailure && own.child.process && own.child.job, "NATIVE_FIXTURE_SETUP_FAILED"); + await until(() => { + const observed = realJobObservation(); + return !!observed?.primarySignaled && observed.activeProcesses > 0 && existsSync(own.gate! + ".ready"); + }, "PRIMARY_DESCENDANT_CHECKPOINT_TIMEOUT"); + own.primaryAndDescendantObserved = true; + const beforeFault = realJobObservation(); + need(beforeFault?.primarySignaled && beforeFault.activeProcesses > 0, "DESCENDANT_ALREADY_LEFT"); + n.k.QueryInformationJobObject = (job, kind, output, length, returned) => { + if (job === own.child!.job) { own.syntheticQueryCalls++; return 0; } + return own.realQuery!(job, kind, output, length, returned); + }; + own.queryFaultInstalled = true; + const unknown = await own.child.wait(); + own.unknownResult = Object.freeze(unknown); + need(!unknown.terminal && !unknown.cleanupComplete && own.syntheticQueryCalls > 0 && n.handles.size > 0, + "SYNTHETIC_QUERY_DID_NOT_RETAIN_NATIVE_OWNER"); + own.stage = "UNKNOWN_JOB_RETAINED"; + own.originalResult = Object.freeze({ outcome: "EXPECTED_UNKNOWN", terminal: unknown.terminal, + cleanupComplete: unknown.cleanupComplete, reason: unknown.reason }); + emit("native-observation", { faultKind: "SYNTHETIC_QUERY_RETURN_ON_REAL_JOB", primarySignaled: beforeFault.primarySignaled, + activeProcesses: beforeFault.activeProcesses, primaryPid: own.child.pid, primaryCreationTime: own.child.creationTime, + syntheticQueryCalls: own.syntheticQueryCalls, handles: n.handles.size, tokenCount: own.tokenCount }); + } else { + own.stage = "OPENING_OWNED_PIPE"; + const pipe = new WindowsChildPipe(n); adoptPipe(pipe); pipe.open(); + own.realOverlapped = n.k.GetOverlappedResult; own.realClose = n.k.CloseHandle; + // Observation only: every API result is the real native result for this exact pipe. + n.k.GetOverlappedResult = (handle, overlapped, count, wait) => { + const result = own.realOverlapped!(handle, overlapped, count, wait); + if (handle === pipe.handle) { + const error = result ? 0 : Number(n.k.GetLastError()); + own.overlappedCompletion = { ok: Boolean(result), error, internalPending: pipe.overlapped.readBigUInt64LE(0) === 0x103n }; + } + return result; + }; + n.k.CloseHandle = handle => { + if ((handle === pipe.handle || handle === pipe.event) && pipe.pending) { + own.closeWhilePending = true; throw new FixtureError("PIPE_CLOSE_BEFORE_IO_SETTLEMENT"); + } + const result = own.realClose!(handle); + if (result && (handle === pipe.handle || handle === pipe.event)) own.settledPipeCloses++; + return result; + }; + pipe.pump(() => { throw new FixtureError("UNEXPECTED_PIPE_DATA"); }); + need(pipe.pending && !pipe.eof && n.handles.has(pipe.handle) && n.handles.has(pipe.event) + && n.handles.has(pipe.child) && pipe.overlapped.readBigUInt64LE(0) === 0x103n, "REAL_PENDING_READ_NOT_OBSERVED"); + own.pendingReadObserved = true; own.stage = "PENDING_IO_RETAINED"; + own.originalResult = Object.freeze({ outcome: "PENDING_IO", pending: true, cleanupComplete: false }); + emit("native-observation", { faultKind: "NONE_REAL_PENDING_OVERLAPPED_READ", pending: true, + internalPending: true, bufferBytes: pipe.buffer.length, handles: n.handles.size, tokenCount: own.tokenCount }); + } + emit("case-result", { result: own.originalResult }); + lifetime.finish(); // Retained exact owner is the only reason this run-once Worker stays available. +} +function restoreQuery() { + if (own.native && own.realQuery) own.native.k.QueryInformationJobObject = own.realQuery; + own.queryFaultInstalled = false; +} +async function recoverA3() { + const n = own.native, child = own.child; + need(n && child && own.gate, "JOB_OWNER_NOT_AVAILABLE"); restoreQuery(); + if (!child.process) { + need(await cleanupUnstartedWindowsChild(child), "UNSTARTED_CLEANUP_UNVERIFIED"); + own.nativeDisposed = n.dispose(); + return { primarySignaled: false, jobEmpty: false, freshWait: false, pipeEOF: child.pipe.eof, noProcessCreated: true }; + } + const before = realJobObservation(); need(before, "REAL_JOB_QUERY_UNAVAILABLE"); + if (!own.gateWriteAttempted) { + if (before.activeProcesses === 0) fail("DESCENDANT_EXITED_BEFORE_COOPERATIVE_RELEASE"); + else { + own.gateWriteAttempted = true; + writeFileSync(own.gate, "release", { flag: "wx" }); + own.gateReleased = readFileSync(own.gate, "utf8") === "release"; + // Existing descendant fixture has a 15s self-exit bound. Require margin + // from primary creation, which precedes descendant startup, for PASS. + own.cooperativeReleaseObserved = own.gateReleased && own.fixtureStartedAt !== undefined + && performance.now() - own.fixtureStartedAt < 10_000; + if (!own.cooperativeReleaseObserved) fail("COOPERATIVE_RELEASE_OUTSIDE_PROVEN_TIME_WINDOW"); + } + } else if (!own.gateReleased && existsSync(own.gate)) { + // Read back a possibly applied exact write; never repeat the write on an uncertain outcome. + own.gateReleased = readFileSync(own.gate, "utf8") === "release"; + } + await until(() => { const now = realJobObservation(); return !!now?.primarySignaled && now.activeProcesses === 0; }, "JOB_NOT_DRAINED"); + const finalNative = realJobObservation(); + need(finalNative?.primarySignaled && finalNative.activeProcesses === 0, "FRESH_TERMINAL_QUERY_MISSING"); + let realRequeries = 0; + n.k.QueryInformationJobObject = (job, kind, output, length, returned) => { + const result = own.realQuery!(job, kind, output, length, returned); + if (job === child.job && result) realRequeries++; + return result; + }; + let settled: WindowsCliChildResult; + try { settled = await child.wait(); } finally { restoreQuery(); } + // Require actual successful native queries from THIS wait, not just inequality + // with an earlier result or child.observe's cached terminal observation. + const freshWait = settled !== own.unknownResult && settled.terminal && settled.cleanupComplete && realRequeries > 0; + need(freshWait && child.pipe.eof && !child.pipe.pending && n.handles.size === 0 && n.borrowedMemory.size === 0, + "JOB_PIPE_CLEANUP_UNVERIFIED"); + own.nativeDisposed = n.dispose(); + return { primarySignaled: true, jobEmpty: true, freshWait, realRequeries, pipeEOF: true, noProcessCreated: false, + cooperativeReleaseObserved: own.cooperativeReleaseObserved, originalUnknownPreserved: own.unknownResult?.terminal === false }; +} +async function recoverA4() { + const n = own.native, pipe = own.pipe; + need(n && pipe, "PIPE_OWNER_NOT_AVAILABLE"); + need(own.buffer === pipe.buffer && own.overlapped === pipe.overlapped, "IO_BUFFER_OWNER_CHANGED"); + if (pipe.pending && !own.cancellationAttempted) { + own.cancellationAttempted = true; // Exact pending operation; never automatically issue another request. + own.cancellationReturned = Boolean(n.k.CancelIoEx(pipe.handle, ptr(pipe.overlapped))); + own.cancellationError = own.cancellationReturned ? 0 : Number(n.k.GetLastError()); + emit("cancellation-request", { returned: own.cancellationReturned, error: own.cancellationError, + pendingAfterRequest: pipe.pending, buffersStillRooted: own.buffer === pipe.buffer && own.overlapped === pipe.overlapped }); + if (!own.cancellationReturned) { fail("CANCEL_REQUEST_NOT_ACCEPTED"); pipe.closeChild(); } + } + await until(() => { pipe.pump(() => fail("UNEXPECTED_PIPE_DATA")); return !pipe.pending; }, "IO_COMPLETION_NOT_OBSERVED"); + const completion = own.overlappedCompletion; + const cancelled = own.cancellationReturned === true && completion?.ok === false + && completion.error === 995 && !completion.internalPending; + if (!cancelled) fail("EXPECTED_ABORTED_IO_COMPLETION_NOT_PROVEN"); + need(!pipe.pending && pipe.eof && own.buffer === pipe.buffer && own.overlapped === pipe.overlapped, "PIPE_NOT_SETTLED"); + // dispose sees no pending operation, so it cannot issue a second CancelIoEx request. + await pipe.dispose(); + need(n.handles.size === 0 && n.borrowedMemory.size === 0 && own.settledPipeCloses === 2, "PIPE_DISPOSAL_UNVERIFIED"); + if (own.closeWhilePending) fail("PREMATURE_PIPE_CLOSE_WAS_BLOCKED"); + if (own.realOverlapped) n.k.GetOverlappedResult = own.realOverlapped; + if (own.realClose) n.k.CloseHandle = own.realClose; + own.nativeDisposed = n.dispose(); + return { childCount: 0, cancellationAccepted: own.cancellationReturned === true, abortedCompletionObserved: cancelled, + pending: pipe.pending, pipeEOF: pipe.eof, closeWhilePending: own.closeWhilePending, + nativePipeCloseAfterSettlement: true, buffersUnborrowedAfterSettlement: true, bufferRootsHeldThroughCompletion: true }; +} +async function recover(nonce: unknown) { + need(own.runSettled && !own.runInFlight && !own.recovering && typeof nonce === "string" && nonce === own.stateNonce, "FRESH_STATE_REQUIRED"); + own.stateNonce = undefined; own.recovering = true; + try { + own.stage = "EXACT_RECOVERY"; + if (own.nativeConstruction === "CREATED" && own.native && !own.child && !own.pipe + && own.native.handles.size === 0 && own.native.borrowedMemory.size === 0) { + own.nativeDisposed = own.native.dispose(); + need(own.nativeDisposed && own.token, "EMPTY_NATIVE_OWNER_NOT_DISPOSED"); + own.token.release(); + emit("cleanup-receipt", { noFixtureCreated: true, handles: 0, borrowedBuffers: 0, + tokenCount: own.tokenCount, tokenReleases: own.tokenReleases, nativeDisposed: true, + caseFailed: true, fixtureRootRetained: true }); + lifetime.finish(); return; + } + const proof = own.ids!.caseId === "A3" ? await recoverA3() : await recoverA4(); + need(own.nativeDisposed && own.native?.handles.size === 0 && own.native.borrowedMemory.size === 0 && own.token, "OWNED_RESOURCES_NOT_DISPOSED"); + own.token.release(); need(own.tokenCount === 0 && own.tokenReleases === 1, "TOKEN_RELEASE_UNVERIFIED"); + own.stage = "CLEANUP_PROVEN"; + emit("cleanup-receipt", { ...proof, handles: 0, borrowedBuffers: 0, tokenCount: 0, tokenReleases: 1, + nativeDisposed: true, caseFailed: own.failed, originalResult: own.originalResult, fixtureRootRetained: true }); + lifetime.finish(); + } finally { own.recovering = false; } +} +if (Bun.isMainThread === false) { + self.onmessage = async event => { + const input = event.data; + try { + if (!own.ids) { + need(input?.type === "run" && ["A3", "A4"].includes(input.caseId) + && [input.runNonce, input.requestId, input.jobId].every(value => typeof value === "string" && value.length >= 16), "BAD_FIRST_ENVELOPE"); + own.runInFlight = true; + try { await run(input); } finally { own.runInFlight = false; own.runSettled = true; } + } else { + need(input?.runNonce === own.ids.runNonce && input.caseId === own.ids.caseId + && input.requestId === own.ids.requestId && input.jobId === own.ids.jobId, "UNCORRELATED_COMMAND"); + need(typeof input.commandId === "string" && input.commandId.length >= 16 && !commands.has(input.commandId), "DUPLICATE_OR_MISSING_COMMAND"); + commands.add(input.commandId); emit("command-ack", { commandId: input.commandId }); + if (input.type === "inspect") emit("owned-state", state()); + else if (input.type === "probe") { + need(own.runSettled && own.tokenCount === 1, "PROBE_NOT_RETAINED"); + emit("lifetime-probe-ack", { probeNonce: input.probeNonce, tokenCount: own.tokenCount, runSettled: true }); + } else if (input.type === "recover") await recover(input.stateNonce); + else throw new FixtureError("UNKNOWN_COMMAND"); + } + } catch (error) { fail(error instanceof FixtureError ? error.message : "UNEXPECTED_FIXTURE_ERROR"); } + }; +} From 8d479ccc2e36a9a9c8ea51ed7f8be4f2e6fe5710 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 10:58:55 +0900 Subject: [PATCH 25/31] Fence expected native fixture recovery with fresh owner observations --- .../windows-cli-child-worker-validation.ts | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/tests/helpers/windows-cli-child-worker-validation.ts b/tests/helpers/windows-cli-child-worker-validation.ts index a9e266ccfa..2d08512bb9 100644 --- a/tests/helpers/windows-cli-child-worker-validation.ts +++ b/tests/helpers/windows-cli-child-worker-validation.ts @@ -15,6 +15,7 @@ type Kernel = WindowsChildNative["k"]; declare const self: Worker; const own: { ids?: Ids; root?: string; gate?: string; native?: WindowsChildNative; child?: WindowsCliChild; pipe?: WindowsChildPipe; + nativeOwnerId?: string; realQuery?: Kernel["QueryInformationJobObject"]; realOverlapped?: Kernel["GetOverlappedResult"]; realClose?: Kernel["CloseHandle"]; token?: { release(): void }; tokenCount: number; tokenReleases: number; sequence: number; stage: string; runSettled: boolean; runInFlight: boolean; recovering: boolean; @@ -52,15 +53,29 @@ function realJobObservation() { if (![0, 258].includes(wait) || !queried) return null; return { primarySignaled: wait === 0, activeProcesses: accounting.readUInt32LE(WIN64_CHILD_ABI.activeProcesses) }; } +function realPendingRead(): boolean | null { + if (own.ids?.caseId === "A4" && own.native && own.pipe && own.realOverlapped && !own.nativeDisposed + && own.native.handles.has(own.pipe.handle) && own.native.handles.has(own.pipe.event)) { + const ok = own.realOverlapped(own.pipe.handle, ptr(own.pipe.overlapped), ptr(own.pipe.count), 0); + const error = ok ? 0 : Number(own.native.k.GetLastError()); + return !ok && error === 996 && own.pipe.overlapped.readBigUInt64LE(0) === 0x103n; + } + return null; +} function state() { own.stateNonce = crypto.randomUUID(); return { stateNonce: own.stateNonce, stage: own.stage, runSettled: own.runSettled, runInFlight: own.runInFlight, + nativeOwnerId: own.nativeOwnerId, settledUnknown: own.unknownResult?.terminal === false && own.unknownResult.cleanupComplete === false, + fixtureElapsedMs: own.fixtureStartedAt === undefined ? null : performance.now() - own.fixtureStartedAt, recovering: own.recovering, caseFailed: own.failed, nativeConstruction: own.nativeConstruction, nativeDisposed: own.nativeDisposed, knownNativeOwner: !!own.native, handles: own.native?.handles.size ?? null, borrowedBuffers: own.native?.borrowedMemory.size ?? null, tokenCount: own.tokenCount, tokenReleases: own.tokenReleases, job: realJobObservation(), queryFaultInstalled: own.queryFaultInstalled, gateWriteAttempted: own.gateWriteAttempted, gateReleased: own.gateReleased, pipePending: own.pipe?.pending ?? null, pipeEOF: own.pipe?.eof ?? null, + ioNativePending: realPendingRead(), pendingReadObserved: own.pendingReadObserved, + pipeEndsOwned: !!own.native && !!own.pipe && own.native.handles.has(own.pipe.handle) + && own.native.handles.has(own.pipe.event) && own.native.handles.has(own.pipe.child), buffersStillRooted: !!own.pipe && own.buffer === own.pipe.buffer && own.overlapped === own.pipe.overlapped, cancellationAttempted: own.cancellationAttempted, cancellationReturned: own.cancellationReturned ?? null, overlappedCompletion: own.overlappedCompletion ?? null }; @@ -79,6 +94,7 @@ async function run(ids: Ids) { own.gate = join(own.root, "descendant-release"); emit("started", { fixtureRoot: own.root, caseName: ids.caseId === "A3" ? "native Job unknown observation" : "overlapped cancellation settlement" }); const token = lifetime.retain(own); own.tokenCount++; + own.nativeOwnerId = crypto.randomUUID(); let released = false; own.token = { release() { if (released) return; released = true; own.tokenCount--; own.tokenReleases++; token.release(); } }; own.nativeConstruction = "IN_FLIGHT"; @@ -112,7 +128,7 @@ async function run(ids: Ids) { own.stage = "UNKNOWN_JOB_RETAINED"; own.originalResult = Object.freeze({ outcome: "EXPECTED_UNKNOWN", terminal: unknown.terminal, cleanupComplete: unknown.cleanupComplete, reason: unknown.reason }); - emit("native-observation", { faultKind: "SYNTHETIC_QUERY_RETURN_ON_REAL_JOB", primarySignaled: beforeFault.primarySignaled, + emit("native-observation", { nativeOwnerId: own.nativeOwnerId, faultKind: "SYNTHETIC_QUERY_RETURN_ON_REAL_JOB", primarySignaled: beforeFault.primarySignaled, activeProcesses: beforeFault.activeProcesses, primaryPid: own.child.pid, primaryCreationTime: own.child.creationTime, syntheticQueryCalls: own.syntheticQueryCalls, handles: n.handles.size, tokenCount: own.tokenCount }); } else { @@ -141,7 +157,7 @@ async function run(ids: Ids) { && n.handles.has(pipe.child) && pipe.overlapped.readBigUInt64LE(0) === 0x103n, "REAL_PENDING_READ_NOT_OBSERVED"); own.pendingReadObserved = true; own.stage = "PENDING_IO_RETAINED"; own.originalResult = Object.freeze({ outcome: "PENDING_IO", pending: true, cleanupComplete: false }); - emit("native-observation", { faultKind: "NONE_REAL_PENDING_OVERLAPPED_READ", pending: true, + emit("native-observation", { nativeOwnerId: own.nativeOwnerId, faultKind: "NONE_REAL_PENDING_OVERLAPPED_READ", pending: true, internalPending: true, bufferBytes: pipe.buffer.length, handles: n.handles.size, tokenCount: own.tokenCount }); } emit("case-result", { result: own.originalResult }); @@ -151,15 +167,19 @@ function restoreQuery() { if (own.native && own.realQuery) own.native.k.QueryInformationJobObject = own.realQuery; own.queryFaultInstalled = false; } -async function recoverA3() { +async function recoverA3(expectedOnly: boolean) { const n = own.native, child = own.child; need(n && child && own.gate, "JOB_OWNER_NOT_AVAILABLE"); restoreQuery(); if (!child.process) { + need(!expectedOnly, "AUTOMATIC_UNSTARTED_CHILD_HOLD"); need(await cleanupUnstartedWindowsChild(child), "UNSTARTED_CLEANUP_UNVERIFIED"); own.nativeDisposed = n.dispose(); return { primarySignaled: false, jobEmpty: false, freshWait: false, pipeEOF: child.pipe.eof, noProcessCreated: true }; } const before = realJobObservation(); need(before, "REAL_JOB_QUERY_UNAVAILABLE"); + if (expectedOnly) need(!own.failed && own.unknownResult?.terminal === false && own.unknownResult.cleanupComplete === false + && before.primarySignaled && before.activeProcesses > 0 && !own.gateWriteAttempted && own.fixtureStartedAt !== undefined + && performance.now() - own.fixtureStartedAt < 10_000, "AUTOMATIC_JOB_STATE_CHANGED_HOLD"); if (!own.gateWriteAttempted) { if (before.activeProcesses === 0) fail("DESCENDANT_EXITED_BEFORE_COOPERATIVE_RELEASE"); else { @@ -196,23 +216,32 @@ async function recoverA3() { return { primarySignaled: true, jobEmpty: true, freshWait, realRequeries, pipeEOF: true, noProcessCreated: false, cooperativeReleaseObserved: own.cooperativeReleaseObserved, originalUnknownPreserved: own.unknownResult?.terminal === false }; } -async function recoverA4() { +async function recoverA4(expectedOnly: boolean) { const n = own.native, pipe = own.pipe; need(n && pipe, "PIPE_OWNER_NOT_AVAILABLE"); need(own.buffer === pipe.buffer && own.overlapped === pipe.overlapped, "IO_BUFFER_OWNER_CHANGED"); + if (expectedOnly) need(!own.failed && own.pendingReadObserved && pipe.pending && !own.cancellationAttempted + && n.handles.has(pipe.child) && realPendingRead() === true, "AUTOMATIC_PIPE_STATE_CHANGED_HOLD"); if (pipe.pending && !own.cancellationAttempted) { own.cancellationAttempted = true; // Exact pending operation; never automatically issue another request. own.cancellationReturned = Boolean(n.k.CancelIoEx(pipe.handle, ptr(pipe.overlapped))); own.cancellationError = own.cancellationReturned ? 0 : Number(n.k.GetLastError()); emit("cancellation-request", { returned: own.cancellationReturned, error: own.cancellationError, pendingAfterRequest: pipe.pending, buffersStillRooted: own.buffer === pipe.buffer && own.overlapped === pipe.overlapped }); - if (!own.cancellationReturned) { fail("CANCEL_REQUEST_NOT_ACCEPTED"); pipe.closeChild(); } + if (!own.cancellationReturned) { + fail("CANCEL_REQUEST_NOT_ACCEPTED"); + if (expectedOnly) throw new FixtureError("AUTOMATIC_CANCEL_FAILURE_HOLD"); + } } + if (!expectedOnly && pipe.pending && own.cancellationAttempted && own.cancellationReturned === false) pipe.closeChild(); await until(() => { pipe.pump(() => fail("UNEXPECTED_PIPE_DATA")); return !pipe.pending; }, "IO_COMPLETION_NOT_OBSERVED"); const completion = own.overlappedCompletion; const cancelled = own.cancellationReturned === true && completion?.ok === false && completion.error === 995 && !completion.internalPending; - if (!cancelled) fail("EXPECTED_ABORTED_IO_COMPLETION_NOT_PROVEN"); + if (!cancelled) { + fail("EXPECTED_ABORTED_IO_COMPLETION_NOT_PROVEN"); + if (expectedOnly) throw new FixtureError("AUTOMATIC_IO_COMPLETION_CHANGED_HOLD"); + } need(!pipe.pending && pipe.eof && own.buffer === pipe.buffer && own.overlapped === pipe.overlapped, "PIPE_NOT_SETTLED"); // dispose sees no pending operation, so it cannot issue a second CancelIoEx request. await pipe.dispose(); @@ -225,12 +254,12 @@ async function recoverA4() { pending: pipe.pending, pipeEOF: pipe.eof, closeWhilePending: own.closeWhilePending, nativePipeCloseAfterSettlement: true, buffersUnborrowedAfterSettlement: true, bufferRootsHeldThroughCompletion: true }; } -async function recover(nonce: unknown) { +async function recover(nonce: unknown, expectedOnly = false) { need(own.runSettled && !own.runInFlight && !own.recovering && typeof nonce === "string" && nonce === own.stateNonce, "FRESH_STATE_REQUIRED"); own.stateNonce = undefined; own.recovering = true; try { own.stage = "EXACT_RECOVERY"; - if (own.nativeConstruction === "CREATED" && own.native && !own.child && !own.pipe + if (!expectedOnly && own.nativeConstruction === "CREATED" && own.native && !own.child && !own.pipe && own.native.handles.size === 0 && own.native.borrowedMemory.size === 0) { own.nativeDisposed = own.native.dispose(); need(own.nativeDisposed && own.token, "EMPTY_NATIVE_OWNER_NOT_DISPOSED"); @@ -240,7 +269,7 @@ async function recover(nonce: unknown) { caseFailed: true, fixtureRootRetained: true }); lifetime.finish(); return; } - const proof = own.ids!.caseId === "A3" ? await recoverA3() : await recoverA4(); + const proof = own.ids!.caseId === "A3" ? await recoverA3(expectedOnly) : await recoverA4(expectedOnly); need(own.nativeDisposed && own.native?.handles.size === 0 && own.native.borrowedMemory.size === 0 && own.token, "OWNED_RESOURCES_NOT_DISPOSED"); own.token.release(); need(own.tokenCount === 0 && own.tokenReleases === 1, "TOKEN_RELEASE_UNVERIFIED"); own.stage = "CLEANUP_PROVEN"; @@ -267,7 +296,7 @@ if (Bun.isMainThread === false) { else if (input.type === "probe") { need(own.runSettled && own.tokenCount === 1, "PROBE_NOT_RETAINED"); emit("lifetime-probe-ack", { probeNonce: input.probeNonce, tokenCount: own.tokenCount, runSettled: true }); - } else if (input.type === "recover") await recover(input.stateNonce); + } else if (input.type === "recover") await recover(input.stateNonce, input.expectedOnly === true); else throw new FixtureError("UNKNOWN_COMMAND"); } } catch (error) { fail(error instanceof FixtureError ? error.message : "UNEXPECTED_FIXTURE_ERROR"); } From c01767c4faff28a6bf7e6aaf0755f56648a5bebd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 11:36:54 +0900 Subject: [PATCH 26/31] Type owned native dispatch copies and verify fixture mutation boundary --- src/codex/windows-cli-child-native.ts | 6 ++- .../windows-cli-child-worker-validation.ts | 51 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/codex/windows-cli-child-native.ts b/src/codex/windows-cli-child-native.ts index 1e53b77a0f..3ba79ecd36 100644 --- a/src/codex/windows-cli-child-native.ts +++ b/src/codex/windows-cli-child-native.ts @@ -47,10 +47,12 @@ export function securityAttributes(inherit: boolean, descriptor = 0n): Buffer { return bytes; } +type OwnedDispatch = { -readonly [Key in keyof SymbolsTable]: SymbolsTable[Key] }; + /** Owns only handles returned by this invocation, never PID-reopened handles. */ export class WindowsChildNative { - readonly k: Library["symbols"]; - readonly a: Library["symbols"]; + readonly k: OwnedDispatch["symbols"]>; + readonly a: OwnedDispatch["symbols"]>; readonly handles = new Set(); /** Explicit GC roots for pointers stored inside native attribute lists. */ readonly borrowedMemory = new Set(); diff --git a/tests/helpers/windows-cli-child-worker-validation.ts b/tests/helpers/windows-cli-child-worker-validation.ts index 2d08512bb9..67f162f923 100644 --- a/tests/helpers/windows-cli-child-worker-validation.ts +++ b/tests/helpers/windows-cli-child-worker-validation.ts @@ -16,6 +16,7 @@ declare const self: Worker; const own: { ids?: Ids; root?: string; gate?: string; native?: WindowsChildNative; child?: WindowsCliChild; pipe?: WindowsChildPipe; nativeOwnerId?: string; + dispatchBoundaryVerified: boolean; realQuery?: Kernel["QueryInformationJobObject"]; realOverlapped?: Kernel["GetOverlappedResult"]; realClose?: Kernel["CloseHandle"]; token?: { release(): void }; tokenCount: number; tokenReleases: number; sequence: number; stage: string; runSettled: boolean; runInFlight: boolean; recovering: boolean; @@ -30,11 +31,49 @@ const own: { runInFlight: false, recovering: false, failed: false, nativeConstruction: "NOT_STARTED", nativeDisposed: false, queryFaultInstalled: false, syntheticQueryCalls: 0, primaryAndDescendantObserved: false, gateWriteAttempted: false, gateReleased: false, cooperativeReleaseObserved: false, - cancellationAttempted: false, pendingReadObserved: false, closeWhilePending: false, settledPipeCloses: 0 }; + cancellationAttempted: false, pendingReadObserved: false, closeWhilePending: false, settledPipeCloses: 0, + dispatchBoundaryVerified: false }; const commands = new Set(); const lifetime = createCodexCliWorkerLifetime(() => { self.onmessage = null; }); class FixtureError extends Error {} function need(value: unknown, phase: string): asserts value { if (!value) throw new FixtureError(phase); } +/** Fixture-only runtime check of the repository-owned spread copies; never writes DLL symbols. */ +function verifyOwnedDispatch(n: WindowsChildNative) { + const librarySymbols = (name: "kernel" | "advapi"): object => { + const library: unknown = Object.getOwnPropertyDescriptor(n, name)?.value; + need(library !== null && typeof library === "object", "LIBRARY_BOUNDARY_UNAVAILABLE"); + const symbols: unknown = Reflect.get(library, "symbols"); + need(symbols !== null && typeof symbols === "object" && !Array.isArray(symbols), "SYMBOL_BOUNDARY_UNAVAILABLE"); + return symbols; + }; + const kernelSymbols = librarySymbols("kernel"), advapiSymbols = librarySymbols("advapi"); + need(n.k !== kernelSymbols && n.a !== advapiSymbols && !Object.is(n.k, n.a) + && Object.getOwnPropertyDescriptor(n, "k")?.value === n.k + && Object.getOwnPropertyDescriptor(n, "a")?.value === n.a + && Object.getPrototypeOf(n.k) === Object.prototype && Object.getPrototypeOf(n.a) === Object.prototype, + "DISPATCH_IS_NOT_AN_OWNED_COPY"); + for (const name of ["QueryInformationJobObject", "GetOverlappedResult", "CloseHandle"] as const) { + const descriptor = Object.getOwnPropertyDescriptor(n.k, name); + need(descriptor && "value" in descriptor && descriptor.writable === true + && typeof descriptor.value === "function" && descriptor.value === Reflect.get(kernelSymbols, name), "DISPATCH_SLOT_NOT_WRITABLE_COPY"); + } + const tokenDescriptor = Object.getOwnPropertyDescriptor(n.a, "OpenProcessToken"); + need(tokenDescriptor && "value" in tokenDescriptor && tokenDescriptor.writable === true + && tokenDescriptor.value === Reflect.get(advapiSymbols, "OpenProcessToken"), "ADVAPI_SLOT_NOT_OWNED_COPY"); + const original = n.k.QueryInformationJobObject; + const probe: Kernel["QueryInformationJobObject"] = (...args: Parameters) => original(...args); + let installed = false; + try { + n.k.QueryInformationJobObject = probe; installed = true; // No call: verify only this copy's write boundary. + need(n.k.QueryInformationJobObject === probe && Reflect.get(kernelSymbols, "QueryInformationJobObject") === original, + "DISPATCH_PROBE_CHANGED_LIBRARY_SYMBOLS"); + } finally { + if (installed) n.k.QueryInformationJobObject = original; + } + need(n.k.QueryInformationJobObject === original && Reflect.get(kernelSymbols, "QueryInformationJobObject") === original, + "DISPATCH_PROBE_RESTORE_UNVERIFIED"); + own.dispatchBoundaryVerified = true; +} function emit(type: string, fields: Record = {}) { self.postMessage({ ...own.ids, sequence: ++own.sequence, type, ...fields }); } @@ -65,7 +104,8 @@ function realPendingRead(): boolean | null { function state() { own.stateNonce = crypto.randomUUID(); return { stateNonce: own.stateNonce, stage: own.stage, runSettled: own.runSettled, runInFlight: own.runInFlight, - nativeOwnerId: own.nativeOwnerId, settledUnknown: own.unknownResult?.terminal === false && own.unknownResult.cleanupComplete === false, + nativeOwnerId: own.nativeOwnerId, dispatchBoundaryVerified: own.dispatchBoundaryVerified, + settledUnknown: own.unknownResult?.terminal === false && own.unknownResult.cleanupComplete === false, fixtureElapsedMs: own.fixtureStartedAt === undefined ? null : performance.now() - own.fixtureStartedAt, recovering: own.recovering, caseFailed: own.failed, nativeConstruction: own.nativeConstruction, nativeDisposed: own.nativeDisposed, knownNativeOwner: !!own.native, @@ -101,6 +141,7 @@ async function run(ids: Ids) { try { own.native = new WindowsChildNative(); own.nativeConstruction = "CREATED"; } catch (error) { own.nativeConstruction = "UNKNOWN_FAILURE"; throw error; } const n = own.native; + verifyOwnedDispatch(n); // A failed descriptor/copy contract stops before any fixture process or pipe opens. if (ids.caseId === "A3") { own.stage = "STARTING_OWNED_FIXTURE"; own.realQuery = n.k.QueryInformationJobObject; own.fixtureStartedAt = performance.now(); @@ -128,7 +169,8 @@ async function run(ids: Ids) { own.stage = "UNKNOWN_JOB_RETAINED"; own.originalResult = Object.freeze({ outcome: "EXPECTED_UNKNOWN", terminal: unknown.terminal, cleanupComplete: unknown.cleanupComplete, reason: unknown.reason }); - emit("native-observation", { nativeOwnerId: own.nativeOwnerId, faultKind: "SYNTHETIC_QUERY_RETURN_ON_REAL_JOB", primarySignaled: beforeFault.primarySignaled, + emit("native-observation", { nativeOwnerId: own.nativeOwnerId, dispatchBoundaryVerified: own.dispatchBoundaryVerified, + faultKind: "SYNTHETIC_QUERY_RETURN_ON_REAL_JOB", primarySignaled: beforeFault.primarySignaled, activeProcesses: beforeFault.activeProcesses, primaryPid: own.child.pid, primaryCreationTime: own.child.creationTime, syntheticQueryCalls: own.syntheticQueryCalls, handles: n.handles.size, tokenCount: own.tokenCount }); } else { @@ -157,7 +199,8 @@ async function run(ids: Ids) { && n.handles.has(pipe.child) && pipe.overlapped.readBigUInt64LE(0) === 0x103n, "REAL_PENDING_READ_NOT_OBSERVED"); own.pendingReadObserved = true; own.stage = "PENDING_IO_RETAINED"; own.originalResult = Object.freeze({ outcome: "PENDING_IO", pending: true, cleanupComplete: false }); - emit("native-observation", { nativeOwnerId: own.nativeOwnerId, faultKind: "NONE_REAL_PENDING_OVERLAPPED_READ", pending: true, + emit("native-observation", { nativeOwnerId: own.nativeOwnerId, dispatchBoundaryVerified: own.dispatchBoundaryVerified, + faultKind: "NONE_REAL_PENDING_OVERLAPPED_READ", pending: true, internalPending: true, bufferBytes: pipe.buffer.length, handles: n.handles.size, tokenCount: own.tokenCount }); } emit("case-result", { result: own.originalResult }); From ea4a38ec513cc18179989833fe8290a6d1ba737d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 11:40:37 +0900 Subject: [PATCH 27/31] Preserve callable signatures without raw FFI branding on owned dispatch --- src/codex/windows-cli-child-native.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/codex/windows-cli-child-native.ts b/src/codex/windows-cli-child-native.ts index 3ba79ecd36..07c16b5c6b 100644 --- a/src/codex/windows-cli-child-native.ts +++ b/src/codex/windows-cli-child-native.ts @@ -47,7 +47,8 @@ export function securityAttributes(inherit: boolean, descriptor = 0n): Buffer { return bytes; } -type OwnedDispatch = { -readonly [Key in keyof SymbolsTable]: SymbolsTable[Key] }; +type OwnedDispatch = { -readonly [Key in keyof SymbolsTable]: + SymbolsTable[Key] extends (...args: infer Args) => infer Result ? (...args: Args) => Result : SymbolsTable[Key] }; /** Owns only handles returned by this invocation, never PID-reopened handles. */ export class WindowsChildNative { From 715bf40657bfb15ec75608a5b6dc21fdd10d6fe4 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 12:19:14 +0900 Subject: [PATCH 28/31] test(codex): prepare private Windows apply retention integration --- .../windows-cli-apply-retention-fixture.ts | 59 ++++ .../windows-cli-apply-retention-session.ts | 280 +++++++++++++++++ .../windows-cli-apply-retention-worker.ts | 296 ++++++++++++++++++ 3 files changed, 635 insertions(+) create mode 100644 tests/helpers/windows-cli-apply-retention-fixture.ts create mode 100644 tests/helpers/windows-cli-apply-retention-session.ts create mode 100644 tests/helpers/windows-cli-apply-retention-worker.ts diff --git a/tests/helpers/windows-cli-apply-retention-fixture.ts b/tests/helpers/windows-cli-apply-retention-fixture.ts new file mode 100644 index 0000000000..522e74d0b3 --- /dev/null +++ b/tests/helpers/windows-cli-apply-retention-fixture.ts @@ -0,0 +1,59 @@ +/** Private candidate; controlled artifacts only. Never executes fixture Node/npm/vendor bytes. */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { hardenSecretDirAsync } from "../../src/lib/windows-secret-acl"; +import type { CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import { fixtureArtifact, fixtureTar } from "./codex-cli-apply-artifact"; +import { fixturePath } from "./repo-root"; + +const mainRoot = "node_modules/@openai/codex"; +const backingRoot = "node_modules/@openai/codex-win32-x64"; +const nativePath = "vendor/x86_64-pc-windows-msvc/bin/codex.exe"; +function write(path: string, bytes: string | Buffer) { + mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, bytes, { flag: "wx" }); +} +function artifacts(version: string) { + // Same valid synthetic PE/layout pattern as windows-apply.test.ts, not a runnable Node. + const pe = Buffer.alloc(512); pe.write("MZ"); pe.writeUInt32LE(64, 60); pe.write("PE\0\0", 64); + pe.writeUInt16LE(0x8664, 68); pe.writeUInt16LE(1, 70); pe.writeUInt16LE(240, 84); + pe.writeUInt16LE(2, 86); pe.writeUInt16LE(0x20b, 88); + const mainFiles = { "package.json": JSON.stringify({ name: "@openai/codex", version, + bin: { codex: "bin/codex.js" }, optionalDependencies: { "@openai/codex-win32-x64": version } }), + "bin/codex.js": `#!/usr/bin/env node\nconsole.log("codex-cli ${version}");\n`, "README.md": "fixture\n" }; + const backingFiles = { "package.json": JSON.stringify({ name: "@openai/codex-win32-x64", version, + os: ["win32"], cpu: ["x64"] }), [nativePath]: pe, "vendor/auxiliary.txt": "fixture\n" }; + const main = fixtureTar(mainFiles), backing = fixtureTar(backingFiles); + return { pe, mainFiles, backingFiles, main, backing, set: { platform: "win32" as const, arch: "x64", + main: fixtureArtifact(main, version), + backing: fixtureArtifact(backing, version, "@openai/codex-win32-x64", "@openai/codex-win32-x64") } }; +} + +export async function prepareRetentionFixture(root: string) { + const prefix = join(root, "prefix"), tools = join(root, "tools"), configDir = join(root, "config"); + mkdirSync(prefix); mkdirSync(tools); + await hardenSecretDirAsync(prefix, { required: true }); + await hardenSecretDirAsync(tools, { required: true }); + const old = artifacts("1.0.0"), target = artifacts("1.1.0"); + for (const [path, bytes] of Object.entries(old.mainFiles)) write(join(prefix, mainRoot, path), bytes); + for (const [path, bytes] of Object.entries(old.backingFiles)) write(join(prefix, backingRoot, path), bytes); + const shims = JSON.parse(readFileSync(fixturePath("codex-windows-npm-shims.json"), "utf8")); + for (const [name, bytes] of Object.entries(shims.files)) write(join(prefix, name), String(bytes)); + write(join(tools, "node.exe"), old.pe); + write(join(tools, "node_modules/npm/package.json"), JSON.stringify({ name: "npm", version: "11.0.0", + bin: { npm: "bin/npm-cli.js" } })); + const npmCli = join(tools, "node_modules/npm/bin/npm-cli.js"); + write(npmCli, "throw new Error('FIXTURE_NPM_MUST_NEVER_EXECUTE');\n"); + const deps: CodexCliApplyDeps = { + platform: "win32", inspectionDeps: { env: { CODEX_CLI_PATH: join(prefix, "codex.cmd"), + PATH: tools, PATHEXT: ".EXE;.CMD" }, configDir, arch: "x64", selectionAttested: true }, + resolveTarget: () => ({ kind: "resolved", version: "1.1.0", integrity: target.set.main.integrity, artifacts: target.set }), + // Controlled empty process observation, not a claim to have scanned live applications. + scanProcesses: () => ({ kind: "observed", processes: [] }), + fetchArtifact: async artifact => { + if (artifact.integrity === target.set.main.integrity && artifact.installName === "@openai/codex") return target.main; + if (artifact.integrity === target.set.backing.integrity && artifact.installName === "@openai/codex-win32-x64") return target.backing; + throw new Error("UNEXPECTED_ARTIFACT_NO_NETWORK"); + }, + }; + return { root, prefix, configDir, npmCli, deps }; +} diff --git a/tests/helpers/windows-cli-apply-retention-session.ts b/tests/helpers/windows-cli-apply-retention-session.ts new file mode 100644 index 0000000000..4628383a45 --- /dev/null +++ b/tests/helpers/windows-cli-apply-retention-session.ts @@ -0,0 +1,280 @@ +/** Explicitly invoked private harness. No top-level execution or bun:test teardown. */ +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { hardenSecretDirAsync } from "../../src/lib/windows-secret-acl"; +import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../src/codex/cli-update-job"; +import { runCodexCliUpdateApplyInWorker, codexCliUpdateUnjoinedWorkerCount, + codexCliUpdateUnjoinedWorkerClosedTotal } from "../../src/codex/cli-update-job-worker"; +import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; + +export const ACTUAL_APPLY_SOURCE = "ea4a38ec513cc18179989833fe8290a6d1ba737d"; +const OFFICIAL_BUN = "CB1D8E6564E655E49D30CEAD359750A1CBBD12F42651CAA4B62244D7BD93C244"; +type Case = "close-within-grace" | "close-after-ledger-fallback"; +type Frame = Record & { phase: string }; +type Operation = { id: number; name: string; promise: Promise; status: "pending" | "fulfilled" | "rejected"; value?: unknown; error?: unknown }; +type RecoveryStep = "native" | "acl" | "staging" | "toolchain" | "authority" | "token"; +const need: (value: unknown, code: string) => asserts value = (value, code) => { if (!value) throw new Error(code); }; + +/** Caller owns recorder, exact process/controller, and root cleanup after exit/EOF proof. + * Do NOT attach this to an unconditional test afterEach. On uncertainty the session stays live. + */ +export function createActualApplyRetentionSession(options: { + executionAuthorized: true; sourceVerified: typeof ACTUAL_APPLY_SOURCE; fixtureVolumeRoot: string; + caseId: Case; record: (event: Record) => void; +}) { + need(options.executionAuthorized === true && options.sourceVerified === ACTUAL_APPLY_SOURCE, "SOURCE_AND_EXECUTION_ADMISSION_REQUIRED"); + need(process.platform === "win32" && process.arch === "x64" && Bun.version === "1.4.4" + && createHash("sha256").update(readFileSync(process.execPath)).digest("hex").toUpperCase() === OFFICIAL_BUN, "OFFICIAL_RUNTIME_REQUIRED"); + need(/^[A-Z]:[\\/]$/i.test(options.fixtureVolumeRoot), "EXPLICIT_VOLUME_ANCHOR_REQUIRED"); + need(["close-within-grace", "close-after-ledger-fallback"].includes(options.caseId), "EXACT_CASE_REQUIRED"); + const root = mkdtempSync(join(options.fixtureVolumeRoot, "ocx-actual-apply-")); + const nonce = randomUUID(), configDir = join(root, "config"), began = performance.now(); + const state = { root, configDir, caseId: options.caseId, source: ACTUAL_APPLY_SOURCE, + status: "PREPARED", failure: null as string | null, workerClosed: false, cleanupProven: false, + commandAttempts: [] as { id: string; action: string; acknowledged: boolean }[], + canonicalReplies: 0, starts: 0, runCalls: 0, nativeIdentity: null as Record | null }; + let worker: Worker | undefined, sequence = 0, ownerNonce: string | undefined; + let hostRequestId: string | undefined, hostJobId: string | undefined; + let canonical: CodexCliApplyResult | undefined, hostResult: CodexCliApplyResult | undefined; + let hostSettled = false, doneAt: number | undefined; + const stopFlag = new SharedArrayBuffer(4), operations: Operation[] = []; + let wakeFailure!: () => void, failureSerial = 0, manualBusy = false; + let latestInspection: Frame | undefined; + const failureSignal = new Promise(resolve => { wakeFailure = resolve; }); + const frames: Frame[] = []; + const keepalive = setInterval(() => {}, 1000); // Observer remains owned on uncertainty, never a kill timer. + const record = (phase: string, data: Record = {}) => options.record({ phase, + caseId: options.caseId, elapsedMs: Math.round(performance.now() - began), ...data }); + function failure(code: string) { + state.failure ??= code; failureSerial++; Atomics.store(new Int32Array(stopFlag), 0, 1); + wakeFailure(); state.status = "UNRESOLVED"; record("hold", { code }); maybeFailClean(); + } + function maybeFailClean() { + if (state.failure && state.cleanupProven && state.workerClosed && operations.every(op => op.status !== "pending")) { + state.status = "FAIL_CLEAN"; clearTimeout(budget); clearInterval(keepalive); + } + } + const budget = setTimeout(() => failure("OBSERVATION_120S_OWNER_RETAINED"), 120000); + function healthy() { + // Check the monotonic deadline even if a delayed timer has not run yet. + if (performance.now() - began >= 120000 && !state.failure) failure("OBSERVATION_120S_OWNER_RETAINED"); + need(!state.failure, state.failure ?? "UNKNOWN"); + } + function startOperation(name: string, work: () => Promise): { entry: Operation; promise: Promise } { + healthy(); + let promise: Promise; + try { promise = work(); } catch (error) { promise = Promise.reject(error); } + const entry: Operation = { id: operations.length + 1, name, promise, status: "pending" }; operations.push(entry); + void promise.then(value => { entry.status = "fulfilled"; entry.value = value; record("operation-terminal", { id: entry.id, name, status: entry.status }); maybeFailClean(); }, + error => { entry.status = "rejected"; entry.error = error; failure(`OPERATION_REJECTED:${name}`); }); + return { entry, promise }; // Original Promise and owner survive the observation race. + } + async function observe(op: { entry: Operation; promise: Promise }): Promise { + healthy(); + const value = await Promise.race([op.promise, failureSignal.then(() => { throw new Error(state.failure ?? "OBSERVATION_HELD"); })]); + healthy(); return value; // A late fulfilled Promise cannot restore PASS. + } + const perform = (name: string, work: () => Promise) => observe(startOperation(name, work)); + async function until(check: () => boolean) { + while (!check()) { healthy(); await Bun.sleep(5); } + healthy(); + } + async function next(phase: string) { + await until(() => frames.some(frame => frame.phase === phase)); + return frames.splice(frames.findIndex(frame => frame.phase === phase), 1)[0]!; + } + async function control(action: string, fields: Record = {}) { + healthy(); + need(worker && ownerNonce && hostRequestId && hostJobId && !state.failure, "CONTROL_OWNER_REQUIRED"); + const id = randomUUID(), attempt = { id, action, acknowledged: false }; + state.commandAttempts.push(attempt); record("control-attempt", { id, action }); + try { worker.postMessage({ type: "fixture-control", commandId: id, action, nonce, ownerNonce, + hostRequestId, hostJobId, ...fields }); } catch { failure("DELIVERY_UNKNOWN_NO_RETRY"); } + await until(() => attempt.acknowledged); + } + async function probe() { + const probeNonce = randomUUID(); await control("probe", { probeNonce }); const observed = await next("probe"); + need(observed.probeNonce === probeNonce && observed.tokens === 1 && observed.releases === 0 + && observed.authorityVerified === true && observed.toolchainHeld === true && observed.stagingHeld === true + && typeof observed.stateNonce === "string", "LIVE_OWNER_PROBE_REQUIRED"); + return observed.stateNonce; + } + async function manualRequest(action: "inspect" | "recover-step", fields: Record = {}) { + need(!manualBusy && worker && !state.workerClosed && ownerNonce, "MANUAL_OWNER_UNAVAILABLE"); + if (!state.failure) failure("MANUAL_INTERVENTION_NOT_A_PASS"); + manualBusy = true; + const serial = failureSerial, deadline = performance.now() + 5000; + const id = randomUUID(), attempt = { id, action, acknowledged: false }; + state.commandAttempts.push(attempt); // Never reuse this ID or replay an uncertain send. + const wait = async (check: () => boolean) => { + while (!check()) { + need(failureSerial === serial, "NEW_FAILURE_DURING_MANUAL_OBSERVATION"); + need(performance.now() < deadline, "MANUAL_OBSERVATION_5S_OWNER_RETAINED"); await Bun.sleep(5); + } + need(failureSerial === serial && performance.now() < deadline, "MANUAL_OBSERVATION_HELD"); + }; + try { + record("manual-control-attempt", { id, action, ...fields }); + try { worker.postMessage({ type: "fixture-control", manual: true, commandId: id, action, + nonce, ownerNonce, hostRequestId, hostJobId, ...fields }); } + catch { failure("MANUAL_DELIVERY_UNKNOWN_NO_REPLAY"); throw new Error("MANUAL_DELIVERY_UNKNOWN_NO_REPLAY"); } + await wait(() => attempt.acknowledged); + const phase = action === "inspect" ? "inspection" : "recovery-step"; + await wait(() => frames.some(frame => frame.phase === phase && frame.commandId === id)); + const frame = frames.splice(frames.findIndex(frame => frame.phase === phase && frame.commandId === id), 1)[0]!; + maybeFailClean(); return frame; + } catch (error) { + failure(error instanceof Error ? error.message : "MANUAL_OBSERVATION_UNKNOWN"); throw error; + } finally { manualBusy = false; } + } + async function manualInspect() { + latestInspection = undefined; + const frame = await manualRequest("inspect"); latestInspection = frame; return frame; + } + async function manualRecoverStep(step: RecoveryStep, inspectedNonce: string) { + const order: RecoveryStep[] = ["native", "acl", "staging", "toolchain", "authority", "token"]; + const s = latestInspection, phases = s?.phases as Record | undefined; + need(order.includes(step) && s && s.stateNonce === inspectedNonce && typeof inspectedNonce === "string" + && s.ownerNonce === ownerNonce && s.busy === false && s.applySettled === true && s.sameOwner === true + && s.observationError === null && phases?.[step] === "pending" + && order.slice(0, order.indexOf(step)).every(prior => phases[prior] === "done"), "FRESH_REMAINING_STEP_ONLY"); + // An unacknowledged mutation might still have been delivered. Inspection is + // allowed, but never turn that uncertainty into another mutation attempt. + need(state.commandAttempts.filter(a => !["inspect", "probe"].includes(a.action)).every(a => a.acknowledged), + "UNACKNOWLEDGED_MUTATION_NO_REPLAY"); + latestInspection = undefined; + return manualRequest("recover-step", { step, stateNonce: inspectedNonce }); + } + async function runCase() { + healthy(); need(state.runCalls++ === 0, "NO_SESSION_REPLAY"); state.status = "RUNNING"; + record("start", { root, configDir, processPid: process.pid, parentPid: process.ppid }); + const unjoinedBefore = codexCliUpdateUnjoinedWorkerCount(), closedBefore = codexCliUpdateUnjoinedWorkerClosedTotal(); + try { + await perform("harden-root", () => hardenSecretDirAsync(root, { required: true })); healthy(); mkdirSync(configDir); + await perform("harden-config", () => hardenSecretDirAsync(configDir, { required: true })); healthy(); + worker = new Worker(new URL("./windows-cli-apply-retention-worker.ts", import.meta.url).href, { type: "module" }); + worker.addEventListener("error", () => failure("WORKER_ERROR_PRESERVE_OWNER")); + worker.addEventListener("messageerror", () => failure("WORKER_MESSAGE_ERROR_PRESERVE_OWNER")); + worker.addEventListener("close", () => { + state.workerClosed = true; record("worker-close", { cleanupProven: state.cleanupProven }); + if (!state.cleanupProven) failure("CLOSE_WITHOUT_RESOURCE_PROOF"); + maybeFailClean(); + }, { once: true }); + worker.addEventListener("message", event => { + try { + const m = event.data; + need(m && typeof m === "object" && !Array.isArray(m), "MALFORMED_FRAME"); + if (m.type === "fixture-telemetry") { + need(m.nonce === nonce && m.requestId === `telemetry:${nonce}` && m.jobId === `telemetry:${nonce}` + && m.sequence === ++sequence && typeof m.ownerNonce === "string", "TELEMETRY_CORRELATION"); + ownerNonce ??= m.ownerNonce; need(ownerNonce === m.ownerNonce, "OWNER_CHANGED"); + if (m.hostRequestId !== undefined) { + hostRequestId ??= m.hostRequestId; hostJobId ??= m.hostJobId; + need(hostRequestId === m.hostRequestId && hostJobId === m.hostJobId, "HOST_CORRELATION_CHANGED"); + } + if (m.phase === "failure") failure(String(m.code)); + if (m.phase === "native-start") { + need(state.nativeIdentity === null, "ONE_NATIVE_IDENTITY_ONLY"); + state.nativeIdentity = { primaryPid: m.primaryPid, primaryCreationTime: m.primaryCreationTime, + setupFailure: m.setupFailure, starts: m.starts }; + } + if (m.phase === "native-checkpoint") { + need(state.nativeIdentity && state.nativeIdentity.primaryPid === m.primaryPid + && state.nativeIdentity.primaryCreationTime === m.primaryCreationTime, "NATIVE_CHECKPOINT_IDENTITY"); + state.nativeIdentity.descendantPid = m.descendantPid; + } + if (m.phase === "ack") { + const attempt = state.commandAttempts.find(item => item.id === m.commandId); + need(attempt && !attempt.acknowledged, "UNKNOWN_OR_DUPLICATE_ACK"); attempt.acknowledged = true; + } else frames.push(m); + if (m.phase === "retained") { + need(m.starts === 1 && m.ownerAdopted === true && m.primarySignaled === true && m.active > 0 + && m.fault === "SYNTHETIC_QUERY_RETURN_ON_REAL_JOB", "NATIVE_APPLY_EVIDENCE_REQUIRED"); state.starts = m.starts; + } + if (m.phase === "clean") { + need(JSON.stringify(m.cleanupOrder) === JSON.stringify(["nativeEOFHandles0", "ACLreapflush", "staging", "toolchain", "authority", "token"]) + && m.tokens === 0 && m.releases === 1 && m.handles === 0 && m.borrowedBuffers === 0 + && m.pipeEOF === true && m.nativeSettled === true && m.freshQueries > 0 && m.starts === 1 + && (canonical ? JSON.stringify(m.result) === JSON.stringify(canonical) : !!state.failure) + && m.phases && Object.values(m.phases).every(value => value === "done"), "EXACT_CLEANUP_AND_ORIGINAL_RESULT_REQUIRED"); + state.cleanupProven = true; + if (m.failed === true && !state.failure) failure("WORKER_FAILURE_CLEANED_NOT_PASS"); + maybeFailClean(); + } + // Controlled fixed-schema fixture frames only: no body, argv or environment. + record("telemetry", { event: m }); + } else { + need(m.type === "done" && m.operation === "apply" && m.requestId === hostRequestId && m.jobId === hostJobId + && ++state.canonicalReplies === 1, "ONE_CANONICAL_DONE_ONLY"); + canonical = m.result; doneAt = performance.now(); + need(canonical?.outcome === "ambiguous" && canonical.reason === "installer_state_unknown" + && canonical.leaseRetained === true && canonical.cleanupErrors.length === 0, "CANONICAL_APPLY_RESULT"); + } + } catch (error) { failure(error instanceof Error ? error.message : "FRAME_ERROR"); } + }); + healthy(); worker.postMessage({ type: "fixture-init", executionAuthorized: true, root, nonce, stopFlag }); + const ready = await next("ready"); need(typeof ready.planId === "string", "WORKER_REAL_PLAN_REQUIRED"); + const requestId = randomUUID(); + const admittedPlan = ready.planId; + const admission = await perform("admit", () => admitCodexCliUpdateJob({ requestId, planId: admittedPlan, confirmed: true, configDir })); + need(admission.kind === "accepted", "FRESH_ADMISSION_REQUIRED"); + let spawned = false; + const ledgerOperation = startOperation("run-ledger", () => runCodexCliUpdateJob(admission.job.jobId, admission.generation, async () => { + healthy(); // A late begin/admission never starts a Worker after the failure fence. + const hostOperation = startOperation("host-dispatch", () => runCodexCliUpdateApplyInWorker(ready.planId as string, + { jobId: admission.job.jobId, snapshot: null, + spawnWorkerFn: () => { healthy(); need(!spawned && worker, "ONE_WORKER_DISPATCH"); spawned = true; return worker; } })); + // The observer can time out; the ORIGINAL persistence producer must still + // receive the actual eventual result, not a manufactured timeout outcome. + hostResult = await hostOperation.promise; + hostSettled = true; return hostResult; + }, configDir)); + await next("retained"); await until(() => canonical !== undefined); + need(!hostSettled && !state.workerClosed, "DONE_IS_NOT_TERMINAL"); + const running = await perform("read-running", () => readCodexCliUpdateJob(admission.job.jobId, configDir)); + need(running?.status === "running" && running.childState === "active_or_not_observed" + && running.leaseState === "unknown", "REAL_RUNNING_LEDGER_REQUIRED"); + let fresh = await probe(); + if (options.caseId === "close-within-grace") { + await control("all", { stateNonce: fresh }); + } else { + // Keep the 15s descendant unchanged. Reap it within 10s, then retain real + // authority/staging/toolchain/token past the DEFAULT 10s host close grace. + await control("native-only", { stateNonce: fresh }); await next("native-clean"); + await until(() => hostSettled); + need(!state.workerClosed && doneAt !== undefined && performance.now() - doneAt >= 10000 + && hostResult?.reason === "job_result_unknown" && hostResult.leaseRetained === true, + "DEFAULT_GRACE_FALLBACK_REQUIRED"); + const stored = await observe(ledgerOperation); + need(stored?.status === "ambiguous" && stored.reason === "job_result_unknown" + && stored.childState === "unknown" && stored.leaseState === "unknown", "FALLBACK_LEDGER_REQUIRED"); + need(codexCliUpdateUnjoinedWorkerCount() === unjoinedBefore + 1, "UNJOINED_TRACKING_REQUIRED"); + const before = JSON.stringify(stored); + fresh = await probe(); await control("release", { stateNonce: fresh }); + await next("clean"); await until(() => state.workerClosed); + need(JSON.stringify(await perform("read-after-cleanup", () => readCodexCliUpdateJob(admission.job.jobId, configDir))) === before, "LATE_CLEANUP_MUST_NOT_REWRITE_LEDGER"); + need(codexCliUpdateUnjoinedWorkerCount() === unjoinedBefore + && codexCliUpdateUnjoinedWorkerClosedTotal() === closedBefore + 1, "LATE_CLOSE_ACCOUNTING_REQUIRED"); + } + if (options.caseId === "close-within-grace") { await next("clean"); await until(() => state.workerClosed && hostSettled); } + const final = await observe(ledgerOperation); + need(final?.outcome === "ambiguous" && final.childState === "unknown", "FINAL_AMBIGUITY_PRESERVED"); + if (options.caseId === "close-within-grace") need(hostResult && JSON.stringify(hostResult) === JSON.stringify(canonical) + && final.reason === "installer_state_unknown" && final.leaseState === "retained", "ORIGINAL_WITHIN_GRACE_RESULT_REQUIRED"); + const duplicate = await perform("duplicate-admission", () => admitCodexCliUpdateJob({ requestId, planId: admittedPlan, confirmed: true, configDir })); + need(duplicate.kind === "duplicate" && duplicate.job.jobId === admission.job.jobId && state.starts === 1 + && state.canonicalReplies === 1 && state.cleanupProven && state.workerClosed, "NO_REPEAT_INSTALLER"); + healthy(); need(operations.every(op => op.status !== "pending"), "OPERATIONS_STILL_OWNED"); + state.status = "PASS_SOURCE_UNIT"; clearTimeout(budget); clearInterval(keepalive); + record("complete", { hostResult, final, cleanupProven: true, workerClosed: true, fixtureRootRetained: true }); + healthy(); + return { ...state, hostResult, final, fixtureRootRetained: true }; + } catch (error) { + failure(error instanceof Error ? error.message : "UNKNOWN"); + maybeFailClean(); + throw error; // Unknown owner: keep observer/Worker/root. No automatic retry or terminate. + } + } + return { state, run: runCase, manualInspect, manualRecoverStep, get worker() { return worker; }, operations }; +} diff --git a/tests/helpers/windows-cli-apply-retention-worker.ts b/tests/helpers/windows-cli-apply-retention-worker.ts new file mode 100644 index 0000000000..0585c9d504 --- /dev/null +++ b/tests/helpers/windows-cli-apply-retention-worker.ts @@ -0,0 +1,296 @@ +/** Private source candidate. Controlled installer + synthetic QueryInformationJobObject fault. */ +import { existsSync, readFileSync, writeFileSync, openSync, closeSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { ptr } from "bun:ffi"; +import { applyCodexCliUpdate, type CodexCliApplyResult } from "../../src/codex/cli-update-apply"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import { createCodexCliWorkerLifetime, type CodexCliUpdateWorkerRunMessage } from "../../src/codex/cli-update-worker"; +import { createWorkerCommitWindow, codexCliUpdateIsCancelled } from "../../src/codex/cli-update-selection-gate"; +import { openWindowsCliApplyAuthority } from "../../src/codex/cli-update-job-store-windows"; +import { holdWindowsCodexCliPlanToolchain } from "../../src/codex/cli-windows-plan-provenance"; +import { WindowsChildNative, WIN64_CHILD_ABI } from "../../src/codex/windows-cli-child-native"; +import { startWindowsCliChildWithNative, type WindowsCliChild, type WindowsCliChildResult } from "../../src/codex/windows-cli-child"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; +import { fixturePath } from "./repo-root"; +import { prepareRetentionFixture } from "./windows-cli-apply-retention-fixture"; + +type Authority = Awaited>; +type Toolchain = NonNullable>>; +type Owner = { authority: Authority; staging: string; toolchain: Toolchain | null; child?: WindowsCliChild }; +type Anchor = { acquisition?: object; authority?: Authority; owner?: Owner }; +declare const self: Worker; +const need: (value: unknown, code: string) => asserts value = (value, code) => { if (!value) throw new Error(code); }; +const lifetime = createCodexCliWorkerLifetime(() => { self.onmessage = null; }); +const abort = new AbortController(); +let telemetryNonce = "", ownerNonce = randomUUID(), seq = 0, stateNonce: string | undefined; +let fixture: Awaited> | undefined; +let anchor: Anchor | undefined, adopted: Owner | undefined, toolchain: Toolchain | undefined; +let native: WindowsChildNative | undefined, child: WindowsCliChild | undefined; +let realQuery: WindowsChildNative["k"]["QueryInformationJobObject"] | undefined; +let unknownResult: WindowsCliChildResult | undefined, result: CodexCliApplyResult | undefined; +let run: CodexCliUpdateWorkerRunMessage | undefined, planId: string | undefined; +let failed = false, busy = false, ran = false, nativeSettled = false, gateAttempted = false; +let childStarted = 0, starts = 0, tokens = 0, releases = 0, queryFaults = 0, freshQueries = 0; +let token: { release(): void } | undefined; +let stopFlag: Int32Array | undefined, applySettled = false; +let originalChildWait: (() => Promise) | undefined; +type Step = "native" | "acl" | "staging" | "toolchain" | "authority" | "token"; +type Phase = "pending" | "in_flight" | "done" | "unknown"; +const phases: Record = { native: "pending", acl: "pending", staging: "pending", toolchain: "pending", authority: "pending", token: "pending" }; +const operations: { step: Step; promise: Promise }[] = []; // Never replace an earlier operation's evidence. +let gateWriteState: Phase = "pending", gateReleased = false; +let nativeWaitAttempted = false; +function automaticAllowed() { + if (stopFlag && Atomics.load(stopFlag, 0) !== 0) failed = true; + need(!failed, "AUTOMATIC_PROGRESSION_HELD"); +} +const cleanupOrder: string[] = [], cleanupCalls = { staging: 0, toolchain: 0, authority: 0 }; +const commandIds = new Set(); +function emit(phase: string, data: Record = {}) { + self.postMessage({ type: "fixture-telemetry", requestId: `telemetry:${telemetryNonce}`, jobId: `telemetry:${telemetryNonce}`, + nonce: telemetryNonce, ownerNonce, sequence: ++seq, phase, hostRequestId: run?.requestId, hostJobId: run?.jobId, ...data }); +} +async function until(check: () => boolean, code: string, ms = 5000) { + const end = performance.now() + ms; + while (!check()) { need(performance.now() < end, code); await Bun.sleep(5); } +} +function realJob() { + need(native && child && realQuery && native.handles.has(child.process) && native.handles.has(child.job), "ORIGINAL_HANDLES_REQUIRED"); + const accounting = Buffer.alloc(WIN64_CHILD_ABI.jobAccounting); + const wait = Number(native.k.WaitForSingleObject(child.process, 0)); + need([0, 258].includes(wait) && realQuery(child.job, 1, ptr(accounting), accounting.length, null), "NATIVE_QUERY_UNKNOWN"); + return { primarySignaled: wait === 0, active: accounting.readUInt32LE(WIN64_CHILD_ABI.activeProcesses) }; +} +function owner() { + need(adopted && anchor?.owner === adopted && anchor.authority === adopted.authority + && anchor.acquisition && adopted.child === child && adopted.toolchain === toolchain, "APPLY_OWNER_IDENTITY_CHANGED"); + return adopted; +} +async function heldEvidence() { + const o = owner(); + need(tokens === 1 && releases === 0 && cleanupCalls.staging + cleanupCalls.toolchain + cleanupCalls.authority === 0, + "OWNER_ALREADY_RELEASED"); + o.authority.verifyIdentitySync(); await o.authority.verify(); + need(existsSync(o.staging) && existsSync(o.authority.lock) && toolchain, "HELD_PATHS_MISSING"); + // Owned npm fixture only: attempt open-for-write without writing any byte. + let writable: number | undefined; + try { writable = openSync(fixture!.npmCli, "r+"); } catch { /* exact pin must deny write access */ } + if (writable !== undefined) { closeSync(writable); throw new Error("TOOLCHAIN_PIN_NOT_HELD"); } + return { authorityVerified: true, stagingHeld: true, toolchainHeld: true, ownerAdopted: true, + tokens, releases, nativeSettled, starts, cleanupCalls: { ...cleanupCalls } }; +} +function instrumentOwner() { + const value = anchor?.owner; + need(value?.toolchain, "REAL_APPLY_OWNER_REQUIRED"); adopted = value; toolchain = value.toolchain; + const a = adopted.authority, remove = a.removeStaging.bind(a), release = a.release.bind(a), unpin = toolchain.release.bind(toolchain); + a.removeStaging = async () => { cleanupCalls.staging++; return remove(); }; + a.release = async () => { cleanupCalls.authority++; return release(); }; + toolchain.release = async () => { cleanupCalls.toolchain++; return unpin(); }; +} +async function apply(message: CodexCliUpdateWorkerRunMessage) { + need(fixture && planId && message.operation === "apply" && message.planId === planId && !ran, "ONE_MATCHING_APPLY_ONLY"); + ran = true; run = message; + const gate = new Int32Array(message.gate), fence = new Int32Array(message.fence); + try { result = await applyCodexCliUpdate(planId, { ...fixture.deps, signal: abort.signal, + isCancelled: codexCliUpdateIsCancelled(message.fence), + commitWindow: createWorkerCommitWindow({ gate: message.gate, fence: message.fence }), + retainExecution(value) { + need(!anchor, "ONE_ACQUISITION_ONLY"); anchor = value as Anchor; + const held = lifetime.retain(value); tokens++; + token = { release() { need(tokens === 1 && releases === 0, "TOKEN_RELEASE_ONCE"); tokens--; releases++; held.release(); } }; + return token; + }, + startWindowsChild(invocation) { + automaticAllowed(); + need(invocation.policy === "installer" && ++starts === 1, "NO_PACK_VERSION_OR_SECOND_CHILD"); + need(Atomics.load(gate, 0) === 2 && Atomics.load(fence, 0) === 2, "REAL_COMMIT_CAS_REQUIRED"); + instrumentOwner(); native = new WindowsChildNative(); realQuery = native.k.QueryInformationJobObject; + childStarted = performance.now(); + // Trusted seam substitutes ONLY the owned Bun descendant fixture for npm. + // Allowlisted fixture env; no credential or entire process.env propagation. + const env = { SystemRoot: process.env.SystemRoot, PATH: dirname(process.execPath), + HOME: fixture!.root, USERPROFILE: fixture!.root, TEMP: fixture!.root, TMP: fixture!.root, + CODEX_HOME: fixture!.configDir, OPENCODEX_HOME: fixture!.configDir }; + child = startWindowsCliChildWithNative({ executable: process.execPath, + args: [fixturePath("windows-cli-child.ts"), "descendant", join(fixture!.root, "release")], + cwd: fixture!.root, env, policy: "installer" }, native); + emit("native-start", { primaryPid: child.pid, primaryCreationTime: child.creationTime, + setupFailure: child.setupFailure, handles: native.handles.size, starts }); + const originalWait = child.wait.bind(child); + originalChildWait = originalWait; + child.wait = async () => { + need(Atomics.load(gate, 0) === 0 && anchor?.owner?.child === child, "GATE_FREE_AND_OWNER_ADOPTED_BEFORE_WAIT"); + if (!unknownResult) { + await until(() => { const j = realJob(); return j.primarySignaled && j.active > 0 + && existsSync(join(fixture!.root, "release.ready")); }, "DESCENDANT_CHECKPOINT"); + const marker = readFileSync(join(fixture!.root, "release.primary"), "utf8"); + need(marker.length < 128, "DESCENDANT_MARKER_BOUNDS"); + const descendantPid: unknown = JSON.parse(marker).pid; + need(Number.isSafeInteger(descendantPid) && Number(descendantPid) > 0, "DESCENDANT_ID_REQUIRED"); + emit("native-checkpoint", { primaryPid: child!.pid, primaryCreationTime: child!.creationTime, + descendantPid, ...realJob() }); // PID is a locator; original Job remains cleanup authority. + native!.k.QueryInformationJobObject = (job, ...args) => { + if (job === child!.job) { queryFaults++; return 0; } return realQuery!(job, ...args); + }; + unknownResult = Object.freeze(await originalWait()); + need(!unknownResult.terminal && !unknownResult.cleanupComplete && unknownResult.reason === "job_query_failed" + && queryFaults > 0, "EXPECTED_NATIVE_RETURN_FAILURE_REQUIRED"); + return unknownResult; + } + return originalWait(); + }; + return child; + }, + }); } finally { applySettled = true; } + need(result.outcome === "ambiguous" && result.reason === "installer_state_unknown" && result.leaseRetained + && result.cleanupErrors.length === 0 && Object.isFrozen(result) && Object.isFrozen(result.cleanupErrors), "REAL_APPLY_RESULT_REQUIRED"); + const j = realJob(); need(j.primarySignaled && j.active > 0, "DESCENDANT_MUST_STILL_BE_ACTIVE"); + emit("retained", { ...await heldEvidence(), ...j, fault: "SYNTHETIC_QUERY_RETURN_ON_REAL_JOB", result }); + self.postMessage({ type: "done", requestId: message.requestId, jobId: message.jobId, operation: "apply", result }); + lifetime.finish(); // Retained actual apply token must keep the handler/realm available. +} +async function settleNative(manual = false) { + need(applySettled && child && native && realQuery && originalChildWait && !nativeSettled, "NATIVE_RECOVERY_NOT_ADMITTED"); + owner(); const before = realJob(); + need(before.primarySignaled, "PRIMARY_NOT_TERMINAL"); + if (!manual) need(before.active > 0 && performance.now() - childStarted < 10000 && !gateAttempted, "COOPERATIVE_WINDOW_REQUIRED"); + native.k.QueryInformationJobObject = realQuery; + if (before.active > 0) { + // No replay of a successful OR uncertain write. Manual inspection may only read it back. + if (gateAttempted) need(gateReleased, "GATE_DELIVERY_UNKNOWN_NO_RETRY"); + else { + if (!manual) automaticAllowed(); gateAttempted = true; gateWriteState = "in_flight"; + try { writeFileSync(join(fixture!.root, "release"), "release", { flag: "wx" }); + gateReleased = readFileSync(join(fixture!.root, "release"), "utf8") === "release"; + need(gateReleased, "GATE_READBACK_UNKNOWN"); gateWriteState = "done"; + } catch (error) { gateWriteState = "unknown"; throw error; } + } + } + if (!manual) need(performance.now() - childStarted < 10000, "GATE_WRITE_TOO_LATE"); + await until(() => { const j = realJob(); return j.primarySignaled && j.active === 0; }, "JOB_DRAIN_UNKNOWN"); + if (!manual) automaticAllowed(); + native.k.QueryInformationJobObject = (job, ...args) => { + const ok = realQuery!(job, ...args); if (job === child!.job && ok) freshQueries++; return ok; + }; + let terminal: WindowsCliChildResult; + nativeWaitAttempted = true; + try { terminal = await originalChildWait(); } finally { native.k.QueryInformationJobObject = realQuery; } + need(terminal !== unknownResult && terminal.terminal && terminal.cleanupComplete && freshQueries > 0 + && child.pipe.eof && !child.pipe.pending && native.handles.size === 0 && native.borrowedMemory.size === 0 + && native.dispose(), "NATIVE_TERMINAL_CLEANUP_NOT_PROVEN"); + nativeSettled = true; cleanupOrder.push("nativeEOFHandles0"); + emit("native-terminal", { primaryPid: child.pid, primaryCreationTime: child.creationTime, + freshQueries, handles: native.handles.size, borrowedBuffers: native.borrowedMemory.size, + pipeEOF: child.pipe.eof, pendingIO: child.pipe.pending }); +} +const order: Step[] = ["native", "acl", "staging", "toolchain", "authority", "token"]; +async function cleanupStep(step: Step, manual = false) { + if (!manual) automaticAllowed(); + need(applySettled && phases[step] === "pending" && order.slice(0, order.indexOf(step)).every(s => phases[s] === "done"), "STEP_NOT_PENDING_OR_PREDECESSOR_UNKNOWN"); + owner(); phases[step] = "in_flight"; // Consume before any side effect; never retry an unknown phase. + const operation = (async () => { + try { + const o = owner(); + if (step === "native") await settleNative(manual); + else { + need(nativeSettled && native?.handles.size === 0 && native.borrowedMemory.size === 0 && child?.pipe.eof && !child.pipe.pending, "NATIVE_CLEANUP_FIRST"); + if (step === "acl") { await flushWindowsSecretAclReapsBeforeRemoval(o.staging); cleanupOrder.push("ACLreapflush"); } + if (step === "staging") { await o.authority.removeStaging(); need(!existsSync(o.staging), "STAGING_REMAINS"); cleanupOrder.push("staging"); } + if (step === "toolchain") { need(await toolchain!.release(), "TOOLCHAIN_RELEASE_UNKNOWN"); cleanupOrder.push("toolchain"); } + if (step === "authority") { need(await o.authority.release(), "AUTHORITY_RELEASE_UNKNOWN"); cleanupOrder.push("authority"); } + if (step === "token") { + need(cleanupCalls.staging === 1 && cleanupCalls.toolchain === 1 && cleanupCalls.authority === 1, "CLEANUP_ONCE"); + token!.release(); cleanupOrder.push("token"); lifetime.finish(); + } + } + phases[step] = "done"; + } catch (error) { + // The observations/gate can fail before the cleanup wait was ever invoked. + // That leaves a remaining native obligation, not permission to repeat a wait. + phases[step] = step === "native" && nativeSettled ? "done" + : step === "native" && !nativeWaitAttempted ? "pending" : "unknown"; + throw error; + } + })(); operations.push({ step, promise: operation }); + await operation; + if (step === "token") emit("clean", { cleanupOrder, tokens, releases, starts, freshQueries, handles: native!.handles.size, + borrowedBuffers: native!.borrowedMemory.size, pipeEOF: child!.pipe.eof, nativeSettled, result, + failed: failed || !!(stopFlag && Atomics.load(stopFlag, 0)), phases: { ...phases } }); + if (!manual) automaticAllowed(); // A late successful phase is evidence, never permission to continue after failure. +} +async function releaseResources() { + automaticAllowed(); await heldEvidence(); automaticAllowed(); + for (const step of order.slice(1)) await cleanupStep(step); +} +function inspectState() { + if (stopFlag && Atomics.load(stopFlag, 0)) failed = true; + stateNonce = undefined; + let sameOwner = false, job: ReturnType | null = null, observationError: string | null = null; + try { + owner(); sameOwner = true; + if (!nativeSettled) job = realJob(); + else need(native?.handles.size === 0 && native.borrowedMemory.size === 0 && child?.pipe.eof && !child.pipe.pending, "NATIVE_PROOF_CHANGED"); + if (gateAttempted && !gateReleased && existsSync(join(fixture!.root, "release"))) { + gateReleased = readFileSync(join(fixture!.root, "release"), "utf8") === "release"; + if (gateReleased) gateWriteState = "done"; // Readback only, not another write or native-wait retry. + } + if (phases.authority === "pending") adopted!.authority.verifyIdentitySync(); + } catch (error) { observationError = error instanceof Error ? error.message : "UNKNOWN"; } + if (!busy && applySettled && sameOwner && observationError === null && !Object.values(phases).includes("in_flight")) stateNonce = randomUUID(); + return { stateNonce: stateNonce ?? null, busy, applySettled, sameOwner, observationError, job, failed, + phases: { ...phases }, gateWriteState, gateAttempted, gateReleased, nativeSettled, tokens, releases, + nativeWaitAttempted, seenCommands: [...commandIds], + handles: native?.handles.size ?? null, borrowedBuffers: native?.borrowedMemory.size ?? null, + pipeEOF: child?.pipe.eof ?? null, pendingIO: child?.pipe.pending ?? null, cleanupOrder: [...cleanupOrder] }; +} +if (!Bun.isMainThread) self.onmessage = async event => { + const m = event.data; + let acquired = false; + try { + if (m?.type === "cancel" && run && m.requestId === run.requestId && m.jobId === run.jobId) { abort.abort(); return; } + const manual = m?.type === "fixture-control" && m.manual === true; + // Manual inspection is read-only even while an earlier operation is still pending. + const inspecting = manual && m.action === "inspect"; + if (!inspecting) { need(!busy, "BUSY_OWNER_HOLD"); if (!manual) automaticAllowed(); busy = true; acquired = true; } + if (m?.type === "fixture-init") { + need(!fixture && !telemetryNonce && m.executionAuthorized === true && process.platform === "win32" && process.arch === "x64" + && typeof m.nonce === "string" && m.nonce.length === 36 && /^[A-Z]:[\\/]ocx-actual-apply-[^\\/]+$/i.test(m.root), "EXPLICIT_FIXTURE_INIT_REQUIRED"); + need(m.stopFlag instanceof SharedArrayBuffer && m.stopFlag.byteLength === 4, "PRIVATE_FAILURE_FENCE_REQUIRED"); + telemetryNonce = m.nonce; stopFlag = new Int32Array(m.stopFlag); + fixture = await prepareRetentionFixture(m.root); + automaticAllowed(); + const plan = await createCodexCliUpdatePlan(fixture.deps); + automaticAllowed(); + need(plan.applicable && plan.planId && /^[a-f0-9]{32}$/.test(plan.planId), "REAL_PLAN_NOT_APPLICABLE"); + planId = plan.planId; emit("ready", { planId }); + } else if (m?.type === "run") await apply(m); + else { + need(m?.type === "fixture-control" && m.nonce === telemetryNonce && m.ownerNonce === ownerNonce + && m.hostRequestId === run?.requestId && m.hostJobId === run?.jobId && typeof m.commandId === "string" + && !commandIds.has(m.commandId), "CONTROL_CORRELATION_REQUIRED"); + commandIds.add(m.commandId); // Delivery/operation uncertainty never permits replay. + emit("ack", { commandId: m.commandId }); + if (inspecting) emit("inspection", { ...inspectState(), commandId: m.commandId }); + else if (manual) { + need(m.action === "recover-step" && order.includes(m.step) && typeof m.stateNonce === "string" + && m.stateNonce === stateNonce, "MANUAL_FRESH_STEP_REQUIRED"); stateNonce = undefined; + await cleanupStep(m.step, true); + emit("recovery-step", { commandId: m.commandId, step: m.step, phases: { ...phases }, failed }); + } else if (m.action === "probe") { + need(result && tokens === 1, "RETAINED_PROBE_REQUIRED"); stateNonce = randomUUID(); + emit("probe", { ...await heldEvidence(), stateNonce, probeNonce: m.probeNonce }); + } else { + need(m.stateNonce === stateNonce && typeof stateNonce === "string", "FRESH_STATE_REQUIRED"); stateNonce = undefined; + if (m.action === "native-only") { await cleanupStep("native"); emit("native-clean", await heldEvidence()); } + else if (m.action === "all") { await cleanupStep("native"); await releaseResources(); } + else if (m.action === "release") await releaseResources(); + else throw new Error("UNKNOWN_CONTROL"); + } + } + } catch (error) { + failed = true; emit("failure", { code: error instanceof Error ? error.message : "UNKNOWN", starts, tokens, releases, + nativeSettled, gateAttempted, handles: native?.handles.size ?? null, borrowedBuffers: native?.borrowedMemory.size ?? null }); + // No automatic cleanup, token release, retry, terminate or root deletion on failure. + } finally { if (acquired) busy = false; } +}; From dff6b62e0c30b41cfac77c13d6d46e9cd0eb24da Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 16:02:52 +0900 Subject: [PATCH 29/31] refactor(codex): extract shim wrapper write cluster into shim-wrapper-write Move cliEntry, windowsShimContent, writeShim, ownedWrapperFingerprint, and wrapperInodeIsOurs verbatim into src/codex/shim-wrapper-write.ts so shim.ts returns under the file-size ratchet cap after the selection-gate wrappers grew it past 1246. One-directional import edge shim.ts -> shim-wrapper-write.ts; no behavior change, no new public surface. --- src/codex/shim-wrapper-write.ts | 136 ++++++++++++++++++++++++++++++++ src/codex/shim.ts | 125 +---------------------------- 2 files changed, 139 insertions(+), 122 deletions(-) create mode 100644 src/codex/shim-wrapper-write.ts diff --git a/src/codex/shim-wrapper-write.ts b/src/codex/shim-wrapper-write.ts new file mode 100644 index 0000000000..8d3f39a9e5 --- /dev/null +++ b/src/codex/shim-wrapper-write.ts @@ -0,0 +1,136 @@ +/** + * Wrapper write and ownership fingerprint for the Codex autostart shim. + * + * Extracted from shim.ts so the parent module stays inside the file-size + * ratchet. This cluster is a closed dependency edge: everything here flows + * from node:fs/node:path, the shim template builders, and the fingerprint + * probes — nothing imports back into shim.ts. + */ +import { randomUUID } from "node:crypto"; +import { chmodSync, lstatSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; +import { serviceApiTokenFilePath } from "../lib/service-secrets"; +import { + buildUnixCodexShim, + buildWindowsCodexShim, + buildWindowsPowerShellCodexShim, + gitBashPath, + SHIM_MARKER, +} from "./shim-templates"; +import { + sameFingerprint, + stableShimPathProbe, + type ShimPathFingerprint, + type StableShimPathProbe, +} from "./shim-fingerprint"; + +function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { + // Bundled Bun path (survives `ocx update`); all three shim builders + // (Unix / Windows cmd / Windows PowerShell) receive it via this entry. + // This module lives in src/codex/, the CLI entry in src/cli/index.ts. + // Path and provenance resolve together so the marker always describes this binary. + return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts") }; +} + +/** + * Write the wrapper and return the identity of the inode this call created, or + * `undefined` where the platform still writes the destination in place. + * + * Callers must derive ownership from the returned identity rather than from a + * later `stat` of `wrapperPath`: the shim markers are public, so a concurrent + * updater's wrapper can carry them, and a replacement landing between the write + * and the observation is otherwise indistinguishable from our own file. + */ +/** The Windows wrapper for `wrapperPath`'s flavor (.ps1, .cmd/.bat, or extensionless Git-Bash sh). */ +export function windowsShimContent(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): string { + const { bun, bunRuntimeSource, cli } = cliEntry(runtime); + const lower = wrapperPath.toLowerCase(); + // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI + // codepage, which mangles non-ASCII paths embedded in the shim. + if (lower.endsWith(".ps1")) return `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`; + if (lower.endsWith(".cmd") || lower.endsWith(".bat")) return buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource); + // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths. + return buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())); +} + +export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined { + const { bun, bunRuntimeSource, cli } = cliEntry(runtime); + if (process.platform === "win32") { + writeFileSync(wrapperPath, windowsShimContent(wrapperPath, realCodexPath, runtime), "utf8"); + return undefined; + } else { + // Stage the wrapper as its own inode and rename it into place, so ownership + // comes from the write itself rather than from observing the path afterwards. + // Writing the destination directly leaves a window in which a concurrent + // updater can replace the file between our write and our fingerprint; we would + // then adopt that replacement as ours and unlink it during rollback, deleting + // an executable we never wrote. + // Hidden and non-executable while staged, so a crash between the write and the + // rename cannot leave an executable `codex*` artifact that a glob or a shell + // completion would surface. + const staged = join(dirname(wrapperPath), `.${basename(wrapperPath)}.opencodex-staging.${process.pid}.${randomUUID()}`); + let renamed = false; + try { + // "wx" fails if the staging path somehow exists, so we never inherit a file. + writeFileSync(staged, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), { encoding: "utf8", flag: "wx", mode: 0o600 }); + const stagedStat = lstatSync(staged); + chmodSync(staged, 0o755); + renameSync(staged, wrapperPath); + renamed = true; + // rename() preserves dev/ino and updates ctime, so identity is the inode + // pair, captured from the file we created rather than from the destination. + return { dev: stagedStat.dev, ino: stagedStat.ino }; + } finally { + if (!renamed) { + try { unlinkSync(staged); } catch { /* best-effort: nothing to clean up */ } + } + } + } +} + +/** + * The fingerprint to record as "we wrote this", or `undefined` when the file at + * `wrapperPath` is not the inode `writeShim()` created. Returning `undefined` + * makes every rollback path treat the file as someone else's and leave it alone. + */ +export function ownedWrapperFingerprint( + wrapperPath: string, + written: { dev: number; ino: number } | undefined, +): ShimPathFingerprint | undefined { + const probe = stableShimPathProbe(wrapperPath); + if (!probe) return undefined; + // Platforms that still write in place (Windows) have no staged identity; fall + // back to the marker check they have always used. + if (!written) return probe.prefix.includes(SHIM_MARKER) ? probe.fingerprint : undefined; + if (probe.fingerprint.dev !== written.dev || probe.fingerprint.ino !== written.ino) return undefined; + return probe.fingerprint; +} + +/** + * Whether the file now at the wrapper path is one this transaction may unlink. + * + * The inode our write created is the authority. It stays ours through an in-place + * truncation — a partial write we must clean up — and stops being ours the moment + * someone renames a different file over the path, which is exactly the concurrent + * updater we must not delete. Where no inode was recorded (Windows writes the + * destination directly), fall back to the exact fingerprint recorded at the time. + */ +export function wrapperInodeIsOurs( + wrapper: StableShimPathProbe, + written: { dev: number; ino: number } | undefined, + recorded: ShimPathFingerprint | undefined, +): boolean { + // A different inode is conclusive: someone renamed their own file over ours. + if (written && (wrapper.fingerprint.dev !== written.dev || wrapper.fingerprint.ino !== written.ino)) { + return false; + } + // Same inode is not sufficient on its own — an in-place truncation (shell `>`, + // writeFileSync) keeps it while replacing the contents. When we recorded a full + // fingerprint, require it to still match; that covers our own partial write, + // whose fingerprint we record before anything can fail. + if (recorded !== undefined) return sameFingerprint(wrapper.fingerprint, recorded); + // No recorded fingerprint: only the inode identity we captured at write time can + // speak for us, and there is nothing else to distinguish this file. + return written !== undefined; +} diff --git a/src/codex/shim.ts b/src/codex/shim.ts index bf4531bf87..4bac2ed2ca 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; import { - chmodSync, existsSync, lstatSync, readFileSync, @@ -9,22 +8,13 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path"; +import { delimiter, extname, join, posix, win32 } from "node:path"; import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; import { assertSelectedRuntimeWritable, RuntimePreflightError, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; import { getConfigDir } from "../config"; -import type { BunRuntimeSource } from "../lib/bun-runtime"; -import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { isWslRuntime, wslAutomountRoot } from "./home"; import { truncateRetainedUtf8 } from "../lib/admission"; -import { - buildUnixCodexShim, - buildWindowsCodexShim, - buildWindowsPowerShellCodexShim, - gitBashPath, - SHIM_MARKER, - UNIX_SHIM_REVISION_MARKER, -} from "./shim-templates"; +import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates"; import { hasUsableBackingPath, isCurrentShimProbe, @@ -66,6 +56,7 @@ import { resolveStableFnmCodexPath, type CodexPathScanDeps, } from "./shim-path-resolution"; +import { ownedWrapperFingerprint, windowsShimContent, wrapperInodeIsOurs, writeShim } from "./shim-wrapper-write"; export { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "./shim-templates"; export { isVersionManagerOwnedCodexPath } from "./shim-fingerprint"; @@ -112,14 +103,6 @@ export type CodexShimAutoRestoreResult = | { status: "ineligible" | "deferred"; message?: string } | { status: "restored"; message: string }; -function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { - // Bundled Bun path (survives `ocx update`); all three shim builders - // (Unix / Windows cmd / Windows PowerShell) receive it via this entry. - // This module lives in src/codex/, the CLI entry in src/cli/index.ts. - // Path and provenance resolve together so the marker always describes this binary. - return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts") }; -} - function commandNames(name: string): string[] { if (process.platform !== "win32") return [name]; const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean); @@ -342,108 +325,6 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry if (errors.length > 0) throw new AggregateError(errors, "Codex shim install validation rollback failed"); } -/** - * Write the wrapper and return the identity of the inode this call created, or - * `undefined` where the platform still writes the destination in place. - * - * Callers must derive ownership from the returned identity rather than from a - * later `stat` of `wrapperPath`: the shim markers are public, so a concurrent - * updater's wrapper can carry them, and a replacement landing between the write - * and the observation is otherwise indistinguishable from our own file. - */ -/** The Windows wrapper for `wrapperPath`'s flavor (.ps1, .cmd/.bat, or extensionless Git-Bash sh). */ -function windowsShimContent(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): string { - const { bun, bunRuntimeSource, cli } = cliEntry(runtime); - const lower = wrapperPath.toLowerCase(); - // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI - // codepage, which mangles non-ASCII paths embedded in the shim. - if (lower.endsWith(".ps1")) return `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`; - if (lower.endsWith(".cmd") || lower.endsWith(".bat")) return buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource); - // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths. - return buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())); -} - -function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined { - const { bun, bunRuntimeSource, cli } = cliEntry(runtime); - if (process.platform === "win32") { - writeFileSync(wrapperPath, windowsShimContent(wrapperPath, realCodexPath, runtime), "utf8"); - return undefined; - } else { - // Stage the wrapper as its own inode and rename it into place, so ownership - // comes from the write itself rather than from observing the path afterwards. - // Writing the destination directly leaves a window in which a concurrent - // updater can replace the file between our write and our fingerprint; we would - // then adopt that replacement as ours and unlink it during rollback, deleting - // an executable we never wrote. - // Hidden and non-executable while staged, so a crash between the write and the - // rename cannot leave an executable `codex*` artifact that a glob or a shell - // completion would surface. - const staged = join(dirname(wrapperPath), `.${basename(wrapperPath)}.opencodex-staging.${process.pid}.${randomUUID()}`); - let renamed = false; - try { - // "wx" fails if the staging path somehow exists, so we never inherit a file. - writeFileSync(staged, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), { encoding: "utf8", flag: "wx", mode: 0o600 }); - const stagedStat = lstatSync(staged); - chmodSync(staged, 0o755); - renameSync(staged, wrapperPath); - renamed = true; - // rename() preserves dev/ino and updates ctime, so identity is the inode - // pair, captured from the file we created rather than from the destination. - return { dev: stagedStat.dev, ino: stagedStat.ino }; - } finally { - if (!renamed) { - try { unlinkSync(staged); } catch { /* best-effort: nothing to clean up */ } - } - } - } -} - -/** - * The fingerprint to record as "we wrote this", or `undefined` when the file at - * `wrapperPath` is not the inode `writeShim()` created. Returning `undefined` - * makes every rollback path treat the file as someone else's and leave it alone. - */ -function ownedWrapperFingerprint( - wrapperPath: string, - written: { dev: number; ino: number } | undefined, -): ShimPathFingerprint | undefined { - const probe = stableShimPathProbe(wrapperPath); - if (!probe) return undefined; - // Platforms that still write in place (Windows) have no staged identity; fall - // back to the marker check they have always used. - if (!written) return probe.prefix.includes(SHIM_MARKER) ? probe.fingerprint : undefined; - if (probe.fingerprint.dev !== written.dev || probe.fingerprint.ino !== written.ino) return undefined; - return probe.fingerprint; -} - -/** - * Whether the file now at the wrapper path is one this transaction may unlink. - * - * The inode our write created is the authority. It stays ours through an in-place - * truncation — a partial write we must clean up — and stops being ours the moment - * someone renames a different file over the path, which is exactly the concurrent - * updater we must not delete. Where no inode was recorded (Windows writes the - * destination directly), fall back to the exact fingerprint recorded at the time. - */ -function wrapperInodeIsOurs( - wrapper: StableShimPathProbe, - written: { dev: number; ino: number } | undefined, - recorded: ShimPathFingerprint | undefined, -): boolean { - // A different inode is conclusive: someone renamed their own file over ours. - if (written && (wrapper.fingerprint.dev !== written.dev || wrapper.fingerprint.ino !== written.ino)) { - return false; - } - // Same inode is not sufficient on its own — an in-place truncation (shell `>`, - // writeFileSync) keeps it while replacing the contents. When we recorded a full - // fingerprint, require it to still match; that covers our own partial write, - // whose fingerprint we record before anything can fail. - if (recorded !== undefined) return sameFingerprint(wrapper.fingerprint, recorded); - // No recorded fingerprint: only the inode identity we captured at write time can - // speak for us, and there is nothing else to distinguish this file. - return written !== undefined; -} - function primaryState(files: ShimFileState[]): ShimState { const first = files[0]!; return { platform: process.platform, ...first, wrappers: files }; From 2eb0139ae281ffb857c1389deff3f4800162eb29 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 16:12:32 +0900 Subject: [PATCH 30/31] test(codex-shim): retarget source oracles to shim-wrapper-write module The verbatim extraction moved windowsShimContent/writeShim out of src/codex/shim.ts into src/codex/shim-wrapper-write.ts. Update the two source-oracle assertions accordingly: the BOM assert and the Git-Bash render assert now read the new module; the Windows target-discovery asserts keep reading shim.ts since discovery stayed there. No assertion softened or removed. --- tests/codex-integration/codex-shim.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index 7bfb275abf..0512257d9b 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -200,17 +200,18 @@ describe("Codex autostart shim", () => { }); test("PowerShell shim is written with a UTF-8 BOM (Windows PowerShell 5.1 decodes BOM-less ps1 as ANSI)", async () => { - const source = readFileSync(repoPath("src", "codex", "shim.ts"), "utf8"); + const source = readFileSync(repoPath("src", "codex", "shim-wrapper-write.ts"), "utf8"); expect(source).toContain("`\\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`"); }); test("Windows target discovery includes the extensionless Git-Bash launcher and writeShim emits a forward-slash sh shim for it", () => { const source = readFileSync(repoPath("src", "codex", "shim.ts"), "utf8"); + const writeSource = readFileSync(repoPath("src", "codex", "shim-wrapper-write.ts"), "utf8"); expect(source).toContain('const gitBashLauncher = join(dir, "codex");'); expect(source).toContain("for (const path of [cmd, ps1, gitBashLauncher])"); - expect(source).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath()))"); + expect(writeSource).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath()))"); }); test("Unix shim accepts an injected token-file path (Git-Bash shims need forward slashes everywhere)", () => { From ecd1e887f4b3b1d120f76216145ecce2af0f82f2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 17:11:30 +0900 Subject: [PATCH 31/31] Keep worker pin model assertions in a single TypeScript expression --- tests/codex-integration/codex-cli-update-worker.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts index a3d8087ca9..db9e5c1311 100644 --- a/tests/codex-integration/codex-cli-update-worker.test.ts +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -175,8 +175,7 @@ describe("process-wide selection commit gate and cancellation fence", () => { test("a rejected authority acquisition roots failed pins before any returned authority exists", () => { const pins = new WindowsApplyAuthorityPins(); let fault = true, closes = 0, detached = 0; - const kernel = { symbols: { CloseHandle: () => { closes++; return fault ? 0 : 1; } } } - as unknown as Parameters[1]; + const kernel = { symbols: { CloseHandle: () => { closes++; return fault ? 0 : 1; } } } as unknown as Parameters[1]; const lifetime = createCodexCliWorkerLifetime(() => { detached++; }); const anchor: { acquisition?: WindowsApplyAcquisition } = {}; const token = lifetime.retain(anchor); @@ -203,8 +202,7 @@ describe("process-wide selection commit gate and cancellation fence", () => { test("a failed pin close keeps its lifetime token without an automatic close retry", () => { const pins = new WindowsApplyAuthorityPins(), handle = pins.keep(123n); let attempts = 0, fault = true, detached = 0; - const kernel = { symbols: { CloseHandle: () => { attempts++; return fault ? 0 : 1; } } } - as unknown as Parameters[1]; + const kernel = { symbols: { CloseHandle: () => { attempts++; return fault ? 0 : 1; } } } as unknown as Parameters[1]; const lifetime = createCodexCliWorkerLifetime(() => { detached++; }); const token = lifetime.retain(pins); expect(pins.release(handle, kernel)).toBe(false);