From d5e5b4158a713c4bf805f50826b24cf2698a942b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 07:20:49 +0900 Subject: [PATCH 01/33] fix(codex): keep npm Node cache within inspection lifetime --- src/codex/cli-update-plan.ts | 8 +++- structure/codex-cli-update.md | 4 +- .../codex-cli-update-plan.test.ts | 48 ++++++++++++++++++- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index f5a6d5d770..d3c4835b74 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -2,7 +2,7 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import { npmInvocation } from "../update/npm-invocation.mjs"; @@ -327,7 +327,11 @@ function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): N const globalNpmrc = join(root, "ocx-update-global.npmrc"); writeFileSync(globalNpmrc, ""); const cache = join(root, "npm-cache"); - return { dir: root, npmrc, globalNpmrc, cache, env: codexCliUpdateBoundEnv(boundEnv) }; + const env = codexCliUpdateBoundEnv(boundEnv); + // npm enables Node's compile cache itself. Dropping the ambient output path + // alone recreates /node-compile-cache outside this owned lifetime. + env.NODE_COMPILE_CACHE = resolve(root, "node-compile-cache"); + return { dir: root, npmrc, globalNpmrc, cache, env }; } catch (error) { // A mid-setup failure must not leak a directory this call created; a // caller-supplied directory stays the caller's responsibility. diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 5fa77a9be6..310445ddb3 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -46,7 +46,9 @@ Selection observation uses the runtime resolver with provenance's bounded no-fol Its child environment drops npm configuration overrides, named Node startup/output controls, the `LD_*`/`DYLD_*` loader families and the OpenSSL startup configuration and provider/engine search variables; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. -Distinct npm config files, cache and logs stay under the owned temporary root with best-effort +Ambient NODE_COMPILE_CACHE is replaced with an owned path inside the isolation directory because +npm's own enableCompileCache() otherwise recreates a default cache outside that directory. +Distinct npm config files, npm/Node caches and logs stay under the owned temporary root with best-effort cleanup; a cleanup error does not replace the computed plan/refusal. The POSIX update scan includes all readable users by default, retaining an explicitly injected UID scope. Linux per-process `status`/`cmdline` read failures invalidate a complete observation except for `ENOENT` diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index e30fe6865d..b77188877e 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -781,7 +781,7 @@ describe("registry configuration isolation", () => { expect(refused.kind).toBe("unresolved"); }); - test("npm cache and log state stay inside the owned temporary root", () => { + test("npm and Node caches and logs stay inside the owned temporary root", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const target = resolveCodexCliUpdateTarget("latest", spawn); expect(target.kind).toBe("resolved"); @@ -791,6 +791,50 @@ describe("registry configuration isolation", () => { // The cache root (and therefore npm's _logs directory beneath it) must live // inside the owned isolation dir, not under an ambient ~/.npm. expect(cachePath(call)).toBe(join(cwd, "npm-cache")); + expect((call.options.env as NodeJS.ProcessEnv).NODE_COMPILE_CACHE).toBe(join(cwd, "node-compile-cache")); + } + }); + + test("real npm startup through the resolver seam cannot recreate an ambient Node compile cache", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-update-node-cache-")); + const home = join(root, "home"), temporaryRoot = join(root, "temp"), ambientCache = join(root, "ambient-cache"); + const previous = process.env.NODE_COMPILE_CACHE; + let isolatedDir = "", observedCache: string | undefined, calls = 0; + try { + fs.mkdirSync(home); fs.mkdirSync(temporaryRoot); + process.env.NODE_COMPILE_CACHE = ambientCache; + const spawn = ((bin: string, args: string[], options: Record) => { + const field = queriedField({ args } as CapturedCall); + if (calls++ === 0) { + isolatedDir = options.cwd as string; + const env = { ...(options.env as NodeJS.ProcessEnv), HOME: home, USERPROFILE: home, + TMPDIR: temporaryRoot, TMP: temporaryRoot, TEMP: temporaryRoot }; + // Keep production argv/env/cwd; --version exercises npm startup without a registry call. + // cmd's last argument is one outer-quoted command, so append inside that quote. + const probeArgs = options.windowsVerbatimArguments + ? [...args.slice(0, 3), args[3]!.slice(0, -1) + ' --version --offline"'] + : [...args, "--version", "--offline"]; + for (const key of Object.keys(env)) if (key.toLowerCase() === "node_disable_compile_cache") delete env[key]; + const result = spawnSync(bin, probeArgs, { ...options, env, + timeout: 15_000, windowsHide: true, encoding: "utf8" }); + expect(result.error).toBeUndefined(); expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + observedCache = env.NODE_COMPILE_CACHE; + } + // Only registry evidence is synthetic; npm startup above is real. + return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + }) as never; + expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(calls).toBe(3); + expect(existsSync(isolatedDir)).toBe(false); + expect(readdirSync(temporaryRoot)).toEqual([]); + expect(existsSync(ambientCache)).toBe(false); + expect(readdirSync(home)).toEqual([]); + expect(observedCache).toBe(join(isolatedDir, "node-compile-cache")); + } finally { + if (previous === undefined) delete process.env.NODE_COMPILE_CACHE; + else process.env.NODE_COMPILE_CACHE = previous; + rmSync(root, { recursive: true }); } }); @@ -870,7 +914,7 @@ describe("registry configuration isolation", () => { // Anything npm persisted was inside the owned root and is removed with it; // the disposable HOME stays completely empty (no ~/.npm, no ~/.npmrc, no _logs). for (const name of ownedDuringRun) { - expect(["package.json", "ocx-update.npmrc", "ocx-update-global.npmrc", "npm-cache"].includes(name)).toBe(true); + expect(["package.json", "ocx-update.npmrc", "ocx-update-global.npmrc", "npm-cache", "node-compile-cache"].includes(name)).toBe(true); } expect(existsSync(cwd)).toBe(false); expect(readdirSync(fakeHome)).toEqual([]); From 840ca43e2f90c9ef41cb10c4505132325b389fc5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 05:31:45 +0900 Subject: [PATCH 02/33] feat(codex): add confirmed CLI update apply candidate (cherry picked from commit ad1bb338ef4b1fcdf55009862ef6e479ce11e3dd) --- .../test-layout/layout-codex-collision.json | 2 + src/cli/codex-cli-update.ts | 41 ++- src/codex/cli-install-provenance.ts | 54 +++- src/codex/cli-update-apply.ts | 243 ++++++++++++++++++ src/codex/cli-update-artifacts.ts | 208 +++++++++++++++ src/codex/cli-update-files.ts | 65 +++++ src/codex/cli-update-lease.ts | 54 ++++ src/codex/cli-update-plan.ts | 44 +++- structure/INDEX.md | 6 +- structure/codex-cli-update.md | 41 ++- structure/manifest.json | 8 +- structure/runtime.md | 2 +- .../cli/cli-codex-cli-update-boundary.test.ts | 10 + .../codex-cli-update-apply.test.ts | 227 ++++++++++++++++ .../codex-cli-update-artifacts.test.ts | 143 +++++++++++ .../codex-cli-update-plan.test.ts | 7 + tests/fixtures/test-layout-expected.json | 2 + tests/helpers/codex-cli-apply-artifact.ts | 45 ++++ tests/helpers/codex-cli-apply-fixture.ts | 86 +++++++ 19 files changed, 1263 insertions(+), 25 deletions(-) create mode 100644 src/codex/cli-update-apply.ts create mode 100644 src/codex/cli-update-artifacts.ts create mode 100644 src/codex/cli-update-files.ts create mode 100644 src/codex/cli-update-lease.ts create mode 100644 tests/codex-integration/codex-cli-update-apply.test.ts create mode 100644 tests/codex-integration/codex-cli-update-artifacts.test.ts create mode 100644 tests/helpers/codex-cli-apply-artifact.ts create mode 100644 tests/helpers/codex-cli-apply-fixture.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index c4709d021d..bc5862df3f 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,6 @@ { + "codex-cli-update-artifacts.test.ts": "codex-integration", + "codex-cli-update-apply.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", "codex-pool-caller-fallback-policy.test.ts": "codex-integration", "codex-windows-current-sid.test.ts": "codex-integration" diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 11bb7b95f4..7a46db8c41 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -24,16 +24,19 @@ import { } from "../codex/runtime"; import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api"; import { trustedNodeLauncherContext } from "./launcher-context"; +import type { CodexCliApplyDeps, CodexCliApplyResult } from "../codex/cli-update-apply"; export const CODEX_CLI_UPDATE_USAGE = `Usage: ocx system codex-cli-update check [--json] ocx system codex-cli-update attest [--json] ocx system codex-cli-update attest --candidate --npm-prefix --npm-cli --node [--json] - ocx system codex-cli-update plan [--channel latest] [--json]`; + ocx system codex-cli-update plan [--channel latest] [--json] + ocx system codex-cli-update apply --plan-id <32-hex> --yes [--json]`; export type ParsedCodexCliUpdateArgs = | Readonly<{ action: "check"; json: boolean }> | Readonly<{ action: "plan"; json: boolean; channel: CodexCliUpdateChannel }> + | Readonly<{ action: "apply"; json: boolean; planId: string }> | Readonly<{ json: boolean; attest: CodexCliInstallationIdentityInput | "selected" }>; export interface CodexCliUpdateCommandDeps { @@ -44,6 +47,7 @@ export interface CodexCliUpdateCommandDeps { ) => Promise; readonly createPlan?: (deps: CodexCliUpdatePlanDeps) => Promise; readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; + readonly applyUpdate?: (planId: string, deps: CodexCliApplyDeps) => Promise; } function identitySummary(report: CodexCliInstallationIdentityReport): string[] { @@ -93,7 +97,7 @@ function planSummary(plan: CodexCliUpdatePlan): string[] { `session: ${plan.session.state}${plan.session.matches === null ? "" : ` (${plan.session.matches})`}`, ]; if (plan.planId) lines.push(`plan: ${plan.planId}`); - if (plan.command) lines.push(`command: ${plan.command.join(" ")} (indicative — a future apply phase verifies the packed tarball and installs the verified file)`); + if (plan.command) lines.push(`command: ${plan.command.join(" ")} (indicative — apply verifies both packed archives and installs their local files)`); return lines; } @@ -161,8 +165,28 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli }) }); } const action = rest[0]; - if (action !== "check" && action !== "plan") { - throw new CliUsageError("codex-cli-update action must be check, attest or plan", CODEX_CLI_UPDATE_USAGE); + if (action !== "check" && action !== "plan" && action !== "apply") { + throw new CliUsageError("codex-cli-update action must be check, attest, plan or apply", CODEX_CLI_UPDATE_USAGE); + } + + if (action === "apply") { + let planId: string | undefined; + let yes = false; + for (let index = 1; index < rest.length;) { + const token = rest[index]!; + if (token === "--yes" && !yes) { yes = true; index++; continue; } + if (isOption(token, "plan-id") && planId === undefined) { + const value = optionValue(rest, index, "plan-id"); + planId = value.value; + index = value.next; + continue; + } + throw new CliUsageError("unsupported or duplicate apply option", CODEX_CLI_UPDATE_USAGE); + } + if (!yes || !planId || !/^[0-9a-f]{32}$/.test(planId)) { + throw new CliUsageError("apply requires --plan-id <32 lowercase hex> and --yes", CODEX_CLI_UPDATE_USAGE); + } + return Object.freeze({ action, json, planId }); } if (action === "check") { @@ -304,6 +328,15 @@ export async function handleCodexCliUpdateCommand( printData(plan, parsed.json, planSummary(plan)); return; } + if (parsed.action === "apply") { + const apply = deps.applyUpdate ?? (await import("../codex/cli-update-apply")).applyCodexCliUpdate; + const report = await apply(parsed.planId, { inspectionDeps: inspectionDeps(deps) }); + printData(report, parsed.json, [`outcome: ${report.outcome}`, `reason: ${report.reason}`, + `installed-version: ${report.installedVersion ?? "unavailable"}`, + `target-version: ${report.targetVersion ?? "unavailable"}`, + `lease-retained: ${report.leaseRetained ? "yes" : "no"}`, + `verification-pid: ${report.verificationPid ?? "none"}`]); + } }); return code; } diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index 81822401ec..5ca1ba3388 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -128,6 +128,26 @@ export interface CodexCliInstallReport { readonly evidence: readonly CodexCliInstallEvidence[]; } +/** Process-local evidence; deliberately absent from the public/redacted report. */ +export interface CodexCliMutationEvidence { + readonly prefix: string; + readonly launcher: string; + readonly canonicalLauncher: string; + readonly packageRoot: string; + readonly backingRoot: string | null; +} +const mutationEvidence = new WeakMap(); +export function codexCliMutationEvidence(report: CodexCliInstallReport): CodexCliMutationEvidence | null { + return mutationEvidence.get(report) ?? null; +} +/** Final synchronous selector/link observation after a potentially long full-file readback. */ +export function codexCliMutationSelectionMatches(evidence: CodexCliMutationEvidence, deps: CodexCliInstallProvenanceDeps): boolean { + const selected = observeCodexCliSelectedCommand(deps); + const path = selected ? resolveCandidateCommandPath(selected, deps) : null; + return !!path && canonicalize(path, deps) === evidence.canonicalLauncher + && canonicalize(evidence.launcher, deps) === evidence.canonicalLauncher; +} + export interface CodexCliInstallProvenanceDeps { readonly env?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; @@ -700,6 +720,9 @@ const CODEX_PLATFORM_BACKING: Readonly void, ): string | null { const arch = deps.arch ?? process.arch; const backing = CODEX_PLATFORM_BACKING[platform + ":" + arch]; @@ -1022,15 +1053,10 @@ function observeInstallContent( return null; } } - const record = { - layout: "npm-global" as const, - platform, - arch, - triple: backing.triple, - backingKind: backingManifestPath ? "optional-package" as const : "package-vendor" as const, - files: requests.map(req => ({ role: req.role, digest: observed.get(req.role)?.digest })), - }; - return sha256("codex-cli-install-content-v1", JSON.stringify(record)); + observedBacking?.(backingPackageDir); + return codexCliContentSetDigest(platform, arch, backing.triple, + backingManifestPath ? "optional-package" : "package-vendor", + requests.map(req => ({ role: req.role, digest: observed.get(req.role)?.digest }))); } /** Inspect ownership of one configured Codex CLI candidate, without mutation. */ @@ -1227,6 +1253,7 @@ export async function inspectCodexCliInstall( const npmPrefix = manifestOwned ? manifest.root.slice(0, manifest.root.length - "/lib/node_modules/@openai/codex".length) : ""; + let backingRoot: string | null = null; const installContentDigest = manifestOwned && deps.observeInstallContent === true // The selection's own lexical->canonical link is part of the claim: // verified again at the end of the observation so a retargeted launcher @@ -1234,14 +1261,14 @@ export async function inspectCodexCliInstall( ? observeInstallContent(manifest, npmPrefix, platform, env, deps, { lexical: ownershipPath, canonical: canonicalOwnershipPath, - }) + }, root => { backingRoot = root; }) : null; const versionMatches = candidate.version === null || candidate.version === manifest.version; const reason: CodexCliInstallReason = !global || !linked ? "npm_global_unverified" : !versionMatches ? "version_mismatch" : selectionAttested ? "managed_npm_global" : "selection_unattested"; - return freezeReport({ + const report = freezeReport({ schemaVersion: 1, candidateAvailable: true, candidateVersion: candidate.version, @@ -1272,6 +1299,11 @@ export async function inspectCodexCliInstall( ...(installContentDigest ? ["install_content_digest" as const] : []), ]), }); + if (report.managed && report.installContentDigest && shim.status === "not-tracked") { + mutationEvidence.set(report, Object.freeze({ prefix: npmPrefix, launcher: ownershipPath, + canonicalLauncher: canonicalOwnershipPath, packageRoot: manifest.root, backingRoot })); + } + return report; } const stat = deps.stat ?? statSync; diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts new file mode 100644 index 0000000000..c7f483ba58 --- /dev/null +++ b/src/codex/cli-update-apply.ts @@ -0,0 +1,243 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, + realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectCodexCliInstall, observeCodexCliSelectedCommand, codexCliMutationEvidence, codexCliMutationSelectionMatches, + type CodexCliInstallProvenanceDeps, type CodexCliInstallReport } from "./cli-install-provenance"; +import { createCodexCliUpdatePlan, createNpmConfigIsolation, isolatedNpmTarget, + type CodexCliUpdatePlanDeps } from "./cli-update-plan"; +import { codexCliPlatformBacking } from "./cli-install-provenance"; +import { verifyCodexCliArchive, + verifiedCodexCliContent, type CodexCliArtifact } from "./cli-update-artifacts"; +import { acquireCodexCliApplyLease } from "./cli-update-lease"; +import { scanCodexSessionProcesses } from "./app-server-processes"; +import { observeCodexCliFileSet, codexCliExpectedFiles, sameCodexCliFiles } from "./cli-update-files"; + +export interface CodexCliApplyResult { + readonly outcome: "refused" | "applied" | "not_applied" | "ambiguous"; + readonly reason: string; + readonly planId: string; + readonly installedVersion: string | null; + readonly targetVersion: string | null; + readonly installerExitCode: number | null; + readonly leaseRetained: boolean; + readonly verificationPid: number | null; +} +interface VersionResult { readonly terminal: boolean; readonly ok: boolean; readonly pid: number | null } +export interface CodexCliApplyDeps extends CodexCliUpdatePlanDeps { + readonly signal?: AbortSignal; + readonly temporaryRoot?: string; + readonly fetchArtifact?: (artifact: CodexCliArtifact, signal?: AbortSignal) => Promise; + readonly install?: (invocation: { bin: string; args: readonly string[]; cwd: string; env: NodeJS.ProcessEnv }) => Promise<{ terminal: boolean; exitCode: number | null }>; + readonly acquireLease?: typeof acquireCodexCliApplyLease; + readonly verifyVersion?: (command: string, version: string, cwd: string, env: NodeJS.ProcessEnv) => Promise; +} +/** Only this owned verification child may be stopped. A deadline/kill request + * is not terminal evidence: without close the installation lease stays held. + * A new POSIX process group contains the launcher and its native child. + * CODEX_HOME/HOME/TMPDIR isolate even --version's state writes in staging. + */ +export function verifyInstalledCodexVersion(command: string, version: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + const home = join(cwd, "version-home"); + mkdirSync(home, { mode: 0o700 }); + return new Promise(resolve => { + let terminal = false, failed = false, stopping = false, output = ""; + let deadline: ReturnType | undefined, reap: ReturnType | undefined; + const child = spawn(command, ["--version"], { cwd, env: { ...env, CODEX_HOME: home, HOME: home, TMPDIR: home, + XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_DATA_HOME: home }, + stdio: ["ignore", "pipe", "ignore"], windowsHide: true, detached: process.platform !== "win32" }); + const groupGone = () => { + if (process.platform === "win32" || !child.pid) return true; + try { process.kill(-child.pid, 0); return false; } + catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH"; } + }; + const finish = (closed: boolean, code: number | null) => { + if (terminal) return; + terminal = true; clearTimeout(deadline); clearTimeout(reap); + const gone = closed && groupGone(); + if (!gone) child.unref(); // bounded caller; retained lease/staging and PID fence the unresolved child + resolve({ terminal: gone, ok: gone && !failed && code === 0 && output.trim() === `codex-cli ${version}`, + pid: gone ? null : child.pid ?? null }); + }; + const stop = () => { + if (stopping) return; + stopping = true; failed = true; + // Kill only this invocation's new group, never an existing Codex session. + try { if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL"); else child.kill("SIGKILL"); } + catch { /* close/group observation remains the exit proof */ } + child.stdout?.destroy(); + reap = setTimeout(() => finish(false, null), 1_000); + }; + child.stdout?.on("data", bytes => { + if (Buffer.byteLength(output) + bytes.length > 16 * 1024) stop(); + else if (!failed) output += bytes.toString(); + }); + child.once("error", () => { failed = true; }); + child.once("close", code => finish(true, code)); + deadline = setTimeout(stop, 8_000); + }); +} +/** A close event, not a timeout/error alone, settles a spawned npm child's + * lifetime. Abort during a running child is intentionally deferred: do not + * kill it or release its lease/staging before terminal readback. + */ +function runNpm(invocation: { bin: string; args: readonly string[]; cwd: string; env: NodeJS.ProcessEnv }, capture = false) { + return new Promise<{ terminal: boolean; exitCode: number | null; stdout: string }>(resolve => { + try { + const child = spawn(invocation.bin, [...invocation.args], { cwd: invocation.cwd, env: invocation.env, + stdio: ["ignore", capture ? "pipe" : "ignore", "ignore"], windowsHide: true }); + let stdout = ""; + let overflow = false; + child.stdout?.on("data", chunk => { + if (stdout.length + chunk.length > 1024 * 1024) overflow = true; + else if (!overflow) stdout += chunk.toString(); + }); + child.on("error", () => { /* close follows even when spawning failed */ }); + child.once("close", code => resolve({ terminal: true, exitCode: overflow ? null : code, stdout })); + } catch { resolve({ terminal: true, exitCode: null, stdout: "" }); } + }); +} +async function packArtifact(artifact: CodexCliArtifact, dir: string, env?: NodeJS.ProcessEnv): Promise { + const isolation = createNpmConfigIsolation(dir, env); + const invocation = isolatedNpmTarget(["pack", `${artifact.name}@${artifact.version}`, "--json", "--ignore-scripts", + "--pack-destination", dir, "--fetch-retries=0", "--fetch-timeout=120000"], isolation); + if (!invocation) throw new Error("npm_unavailable"); + const packed = await runNpm({ bin: invocation.bin, args: invocation.args, cwd: isolation.dir, env: isolation.env }, true); + if (packed.exitCode !== 0) throw new Error("artifact_pack_failed"); + const files = JSON.parse(packed.stdout); + if (!Array.isArray(files) || files.length !== 1 || typeof files[0]?.filename !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(files[0].filename)) throw new Error("artifact_pack_unverified"); + const path = join(dir, files[0].filename); + if (realpathSync.native(path) !== path) throw new Error("artifact_pack_unverified"); + return path; +} +function freshInspection(deps: CodexCliApplyDeps): CodexCliInstallProvenanceDeps { + const inspection = { ...(deps.inspectionDeps ?? {}), platform: deps.platform ?? deps.inspectionDeps?.platform, + observeInstallContent: true }; + return { ...inspection, selectedCommand: observeCodexCliSelectedCommand(inspection) }; +} + +/** POSIX non-shim only. No restart, kill, automatic retry or rollback. */ +export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDeps = {}): Promise { + let installedVersion: string | null = null; + let targetVersion: string | null = null; + let exitCode: number | null = null; + let lease: ReturnType = null; + let keepLease = false; + let staging: string | undefined; + let started = false; + let verificationPid: number | null = null; + let output: CodexCliApplyResult | undefined; + const result = (outcome: CodexCliApplyResult["outcome"], reason: string): CodexCliApplyResult => + output = { outcome, reason, planId, installedVersion, targetVersion, installerExitCode: exitCode, leaseRetained: keepLease, verificationPid }; + 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.signal?.aborted) return result("refused", "cancelled"); + const inspect = deps.inspect ?? inspectCodexCliInstall; + const first = await inspect(freshInspection(deps)); + installedVersion = first.packageVersion; + const evidence = codexCliMutationEvidence(first); + if (!first.managed || !evidence || first.shim.status !== "not-tracked") return result("refused", "mutation_evidence_unverified"); + const prefix = realpathSync.native(evidence.prefix); + if (prefix !== evidence.prefix) return result("refused", "installation_prefix_changed"); + const prefixIdentity = lstatSync(prefix); + lease = (deps.acquireLease ?? acquireCodexCliApplyLease)(prefix); + if (!lease) return result("refused", "installation_busy"); + const freshPlan = () => createCodexCliUpdatePlan({ ...deps, inspectionDeps: freshInspection(deps) }); + const plan = await freshPlan(); + targetVersion = plan.targetVersion; + if (!plan.applicable) return result("refused", plan.refusal ?? "plan_refused"); + if (plan.planId !== planId) return result("refused", "plan_changed"); + const artifacts = plan.targetArtifacts; + if (!artifacts || artifacts.platform !== (deps.platform ?? process.platform) + || artifacts.arch !== (deps.inspectionDeps?.arch ?? process.arch)) return result("refused", "target_artifacts_unverified"); + staging = mkdtempSync(join(deps.temporaryRoot ?? tmpdir(), "ocx-codex-cli-apply-")); + const paths: string[] = []; + for (const [index, artifact] of [artifacts.main, artifacts.backing].entries()) { + let path: string; + if (deps.fetchArtifact) { + const bytes = await deps.fetchArtifact(artifact, deps.signal); + path = join(staging, `${index}.tgz`); + writeFileSync(path, bytes, { flag: "wx", mode: 0o400 }); + } else path = await packArtifact(artifact, staging, deps.inspectionDeps?.env); + await verifyCodexCliArchive(path, artifact); + paths.push(path); + } + if (deps.signal?.aborted) return result("refused", "cancelled"); + const main = await verifyCodexCliArchive(paths[0]!, artifacts.main); + const backing = await verifyCodexCliArchive(paths[1]!, artifacts.backing); + const expectedContent = verifiedCodexCliContent(artifacts, main, backing); + const originalRoots = [evidence.packageRoot, ...(evidence.backingRoot ? [evidence.backingRoot] : [])]; + const originalFiles = observeCodexCliFileSet(originalRoots); + // Metadata, content, selection and process observation are renewed after + // potentially slow downloads; a final synchronous process check precedes spawn. + const finalPlan = await freshPlan(); + if (!finalPlan.applicable) return result("refused", finalPlan.refusal ?? "plan_refused"); + if (finalPlan.planId !== planId) return result("refused", "plan_changed"); + const beforeInstall = await inspect(freshInspection(deps)); + const selected = codexCliMutationEvidence(beforeInstall); + const currentPrefix = lstatSync(prefix); + if (!selected || selected.prefix !== prefix || selected.launcher !== evidence.launcher + || selected.canonicalLauncher !== evidence.canonicalLauncher || selected.packageRoot !== evidence.packageRoot + || beforeInstall.installContentDigest !== plan.installContentDigest || !beforeInstall.managed + || realpathSync.native(prefix) !== prefix || currentPrefix.dev !== prefixIdentity.dev + || currentPrefix.ino !== prefixIdentity.ino) return result("refused", "installation_changed"); + if (deps.signal?.aborted) return result("refused", "cancelled"); + const isolation = createNpmConfigIsolation(staging, deps.inspectionDeps?.env); + const args = ["install", "-g", "--prefix", prefix, "--offline", "--ignore-scripts", "--omit=optional", + "--no-audit", "--no-fund", "--package-lock=false", "--update-notifier=false", + `${artifacts.main.installName}@file:${paths[0]}`, `${artifacts.backing.installName}@file:${paths[1]}`]; + const invocation = isolatedNpmTarget(args, isolation); + if (!invocation) return result("refused", "npm_unavailable"); + const lastScan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); + if (lastScan.kind !== "observed") return result("refused", "blocked_process_state_unknown"); + if (lastScan.processes.length) return result("refused", "blocked_active_session"); + started = true; + keepLease = true; + const installed = await (deps.install ?? runNpm)({ bin: invocation.bin, args: invocation.args, + cwd: isolation.dir, env: isolation.env }); + exitCode = installed.exitCode; + if (!installed.terminal) return result("ambiguous", "installer_state_unknown"); + keepLease = false; + // Readback outranks exit code. Partial or unreadable installs are never retried. + const after: CodexCliInstallReport = await inspect(freshInspection(deps)); + const afterEvidence = codexCliMutationEvidence(after); + installedVersion = after.packageVersion; + const prefixAfter = lstatSync(prefix); + if (realpathSync.native(prefix) !== prefix || prefixAfter.dev !== prefixIdentity.dev || prefixAfter.ino !== prefixIdentity.ino) { + return result("ambiguous", "installation_prefix_changed"); + } + const backingRoot = join(prefix, "lib", "node_modules", ...artifacts.backing.installName.split("/")); + if (after.managed && afterEvidence?.prefix === prefix && afterEvidence.launcher === evidence.launcher + && after.packageVersion === targetVersion && after.installContentDigest === expectedContent + && sameCodexCliFiles(codexCliExpectedFiles([[afterEvidence.packageRoot, main], [backingRoot, backing]]), + observeCodexCliFileSet([afterEvidence.packageRoot, backingRoot])) + && codexCliMutationSelectionMatches(afterEvidence, freshInspection(deps))) { + const layout = codexCliPlatformBacking(artifacts.platform, artifacts.arch)!; + accessSync(afterEvidence.launcher, constants.X_OK); + accessSync(join(backingRoot, "vendor", layout.triple, "bin", "codex"), constants.X_OK); + keepLease = true; + const version = await (deps.verifyVersion ?? verifyInstalledCodexVersion)(afterEvidence.launcher, targetVersion!, staging, isolation.env); + verificationPid = version.pid; + if (!version.terminal) return result("ambiguous", "version_process_state_unknown"); + keepLease = false; + if (!version.ok || !codexCliMutationSelectionMatches(afterEvidence, freshInspection(deps))) return result("ambiguous", "version_readback_unverified"); + return result("applied", "verified_target"); + } + if (afterEvidence?.prefix === prefix && after.managed && after.packageVersion === first.packageVersion + && after.installContentDigest === plan.installContentDigest + && sameCodexCliFiles(originalFiles, observeCodexCliFileSet(originalRoots)) + && codexCliMutationSelectionMatches(afterEvidence, freshInspection(deps))) return result("not_applied", "original_install_unchanged"); + return result("ambiguous", "installation_readback_unverified"); + } catch { + return result(started ? "ambiguous" : "refused", started ? "installer_or_readback_error" : "preparation_failed"); + } finally { + if (!keepLease) { + if (lease && !lease.release() && output) (output as { leaseRetained: boolean }).leaseRetained = true; + if (staging) { try { rmSync(staging, { recursive: true }); } catch { /* owned residue; never retry install */ } } + } + if (output) Object.freeze(output); + } +} diff --git a/src/codex/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts new file mode 100644 index 0000000000..700bfacec5 --- /dev/null +++ b/src/codex/cli-update-artifacts.ts @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; +import { createGunzip } from "node:zlib"; +import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync } from "node:fs"; +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"; + +export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org"; +export interface CodexCliArtifact { + readonly name: string; + readonly installName: string; + readonly version: string; + readonly integrity: string; + readonly tarball: string; +} +export interface CodexCliArtifactSet { + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly main: CodexCliArtifact; + readonly backing: CodexCliArtifact; +} +export function isSha512Integrity(token: string): boolean { + if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(token)) return false; + const digest = Buffer.from(token.slice(7), "base64"); + return digest.length === 64 && "sha512-" + digest.toString("base64") === token; +} +export function officialCodexTarball(value: string): boolean { + try { + const url = new URL(value); + return url.origin === CODEX_CLI_REGISTRY && !url.username && !url.password && !url.search && !url.hash; + } catch { return false; } +} +type Metadata = Record; +function artifact(meta: Metadata, installName: string): CodexCliArtifact { + if (typeof meta.name !== "string" || typeof meta.version !== "string" || !parseStrictSemver(meta.version) + || typeof meta.dist?.integrity !== "string" || !isSha512Integrity(meta.dist.integrity) + || typeof meta.dist?.tarball !== "string" || !officialCodexTarball(meta.dist.tarball)) throw new Error("artifact_metadata_unverified"); + return Object.freeze({ name: meta.name, installName, version: meta.version, + integrity: meta.dist.integrity, tarball: meta.dist.tarball }); +} +/** Only exact optional-package versions or the publisher's exact npm alias are modeled. */ +export function backingReference(main: Metadata, installName: string): { name: string; version: string; spec: string } { + const dependency = main.optionalDependencies?.[installName]; + const direct = dependency === main.version; + const alias = `npm:@openai/codex@${main.version}-${installName.slice("@openai/codex-".length)}`; + if (!direct && dependency !== alias) throw new Error("backing_reference_unverified"); + const name = direct ? installName : "@openai/codex"; + const version = direct ? main.version : alias.slice("npm:@openai/codex@".length); + return { name, version, spec: `${name}@${version}` }; +} +export function codexCliArtifactSet(main: Metadata, query: (spec: string) => Metadata, + platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { + const layout = codexCliPlatformBacking(platform, arch); + if (!layout || platform === "win32" || main.name !== "@openai/codex") throw new Error("artifact_platform_unverified"); + const mainArtifact = artifact(main, "@openai/codex"); + const reference = backingReference(main, layout.package); + const backing = artifact(query(reference.spec), layout.package); + if (backing.name !== reference.name || backing.version !== reference.version) throw new Error("backing_metadata_unverified"); + return Object.freeze({ platform, arch, main: mainArtifact, backing }); +} +export function codexCliArtifactSetDigest(set: CodexCliArtifactSet): string { + return createHash("sha256").update(JSON.stringify([set.platform, set.arch, + ...[set.main, set.backing].map(file => [file.name, file.installName, file.version, file.integrity, file.tarball])])).digest("hex"); +} + +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 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. + * 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 { + if (!isSha512Integrity(target.integrity)) throw new Error("artifact_integrity_mismatch"); + let fd: number | undefined; + let before: ReturnType | undefined; + if (typeof source === "string") { + fd = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { before = fstatSync(fd); } catch (error) { closeSync(fd); throw error; } + if (!before.isFile() || before.size > MAX_CODEX_ARCHIVE_BYTES) { closeSync(fd); throw new Error("artifact_file_unverified"); } + } + const input = typeof source === "string" ? createReadStream(source, { fd, autoClose: false, highWaterMark: 64 * 1024 }) : Readable.from([source]); + const archiveHash = createHash("sha512"); + let compressed = 0, unpacked = 0, count = 0, headerSize = 0, padding = 0, trailer = 0; + let ended = false; + const header = Buffer.alloc(512); + const files: CodexArchiveFiles = new Map(); + const names = new Set(); + let current: { name: string; size: number; mode: number; remaining: number; keep: boolean; + chunks: Buffer[]; hash: ReturnType } | undefined; + const tracker = new Transform({ transform(chunk: Buffer, _encoding, callback) { + compressed += chunk.length; + if (compressed > MAX_CODEX_ARCHIVE_BYTES) return callback(new Error("artifact_compressed_limit")); + archiveHash.update(chunk); callback(null, chunk); + } }); + const gunzip = createGunzip({ chunkSize: 64 * 1024 }); + const pumping = pipeline(input, tracker, gunzip); + void pumping.catch(() => {}); + try { + for await (const data of gunzip) { + const chunk = Buffer.from(data); + unpacked += chunk.length; + if (unpacked > MAX_CODEX_UNPACKED_BYTES) throw new Error("artifact_unpacked_limit"); + let position = 0; + while (position < chunk.length) { + if (ended) { + const tail = chunk.subarray(position); + if (!tail.every(byte => byte === 0)) throw new Error("artifact_tar_trailing_data"); + trailer += tail.length; break; + } + if (padding) { const take = Math.min(padding, chunk.length - position); padding -= take; position += take; continue; } + if (current) { + const take = Math.min(current.remaining, chunk.length - position); + const bytes = chunk.subarray(position, position + take); + current.hash.update(bytes); + if (current.keep) current.chunks.push(Buffer.from(bytes)); + 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 }); + padding = (512 - current.size % 512) % 512; current = undefined; + } + continue; + } + const take = Math.min(512 - headerSize, chunk.length - position); + chunk.copy(header, headerSize, position, position + take); position += take; headerSize += take; + if (headerSize < 512) continue; + headerSize = 0; + if (header.every(byte => byte === 0)) { ended = true; continue; } + const field = (start: number, length: number) => header.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, ""); + const octal = (start: number, length: number) => { + const value = field(start, length).trim(); + if (!/^[0-7]+$/.test(value)) throw new Error("artifact_tar_invalid"); + return parseInt(value, 8); + }; + const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0); + if (sum !== octal(148, 8)) throw new Error("artifact_tar_invalid"); + const prefix = field(345, 155); + const name = (prefix ? prefix + "/" : "") + field(0, 100); + const type = field(156, 1), size = octal(124, 12), mode = octal(100, 8); + if (++count > 4096 || !name.startsWith("package/") || /[\\\x00-\x1f]/.test(name) + || name.replace(/\/$/, "").split("/").some(part => !part || part === ".." || part === ".") || names.has(name) + || !["", "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); + 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: [], + 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; + } + } + } + await pumping; + if (!ended || trailer < 512 || current || padding || headerSize) throw new Error("artifact_tar_truncated"); + if ("sha512-" + archiveHash.digest("base64") !== target.integrity) throw new Error("artifact_integrity_mismatch"); + if (fd !== undefined && before && typeof source === "string") { + const after = fstatSync(fd), lexical = lstatSync(source); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || lexical.isSymbolicLink() + || lexical.dev !== before.dev || lexical.ino !== before.ino) throw new Error("artifact_file_changed"); + } + } finally { + input.destroy(); tracker.destroy(); gunzip.destroy(); + await pumping.catch(() => {}); + if (fd !== undefined) closeSync(fd); + } + const manifestBytes = files.get("package.json")?.bytes; + if (!manifestBytes || manifestBytes.length > 256 * 1024 || files.has("npm-shrinkwrap.json") || files.has("package-lock.json")) throw new Error("artifact_manifest_unverified"); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + if (manifest.name !== target.name || manifest.version !== target.version) throw new Error("artifact_manifest_unverified"); + for (const key of ["dependencies", "peerDependencies", "bundledDependencies", "bundleDependencies"]) { + if (manifest[key] && Object.keys(manifest[key]).length) throw new Error("artifact_dependency_unverified"); + } + return files; +} +export function verifiedCodexCliContent(set: CodexCliArtifactSet, main: CodexArchiveFiles, backing: CodexArchiveFiles): string { + const layout = codexCliPlatformBacking(set.platform, set.arch); + if (!layout) throw new Error("artifact_platform_unverified"); + const manifest = JSON.parse(main.get("package.json")!.bytes!.toString("utf8")); + const ref = backingReference(manifest, layout.package); + for (const name of Object.keys(manifest.optionalDependencies ?? {})) { + if (!/^@openai\/codex-(linux|darwin|win32)-(x64|arm64)$/.test(name)) throw new Error("artifact_dependency_unverified"); + backingReference(manifest, name); + } + if (ref.name !== set.backing.name || ref.version !== set.backing.version + || (typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex)?.replace(/^\.\//, "") !== "bin/codex.js") throw new Error("artifact_backing_unverified"); + const files = [ + { role: "manifest" as const, file: main.get("package.json") }, + { role: "entrypoint" as const, file: main.get("bin/codex.js") }, + { role: "backing-manifest" as const, file: backing.get("package.json") }, + { role: "backing-binary" as const, file: backing.get(`vendor/${layout.triple}/bin/codex`) }, + ]; + const backingManifest = JSON.parse(backing.get("package.json")!.bytes!.toString("utf8")); + if (backingManifest.exports || backingManifest.bin || Object.keys(backingManifest.optionalDependencies ?? {}).length + || files.some(file => !file.file) || !(main.get("bin/codex.js")!.mode & 0o111) + || !(backing.get(`vendor/${layout.triple}/bin/codex`)!.mode & 0o111)) throw new Error("artifact_backing_unverified"); + return codexCliContentSetDigest(set.platform, set.arch, layout.triple, "optional-package", + files.map(file => ({ role: file.role, digest: file.role === "manifest" || file.role === "backing-manifest" + ? codexCliContentFileDigest(file.role, file.file!.bytes!) : file.file!.digest }))); +} diff --git a/src/codex/cli-update-files.ts b/src/codex/cli-update-files.ts new file mode 100644 index 0000000000..033f5c41aa --- /dev/null +++ b/src/codex/cli-update-files.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { MAX_CODEX_MEMBER_BYTES, MAX_CODEX_UNPACKED_BYTES, type CodexArchiveFiles } from "./cli-update-artifacts"; + +interface FileProof { readonly size: number; readonly digest: string; readonly executable: boolean; readonly mode?: number } +export type CodexCliFileSet = Map; +/** Full bounded regular-file observation of the selected package roots only. + * Ancestor/path links, nonregular files and changed identities refuse it. + * Big members are hashed in 64 KiB chunks, never retained in memory. + */ +export function observeCodexCliFileSet(roots: readonly string[]): CodexCliFileSet { + const files: CodexCliFileSet = new Map(); + let total = 0, entries = 0; + const walk = (path: string) => { + if (++entries > 8192 || realpathSync.native(path) !== path) throw new Error("install_tree_unverified"); + const lexical = lstatSync(path); + if (lexical.isSymbolicLink()) throw new Error("install_tree_unverified"); + if (lexical.isDirectory()) { + for (const name of readdirSync(path)) walk(join(path, name)); + const after = lstatSync(path); + if (lexical.dev !== after.dev || lexical.ino !== after.ino || lexical.mtimeMs !== after.mtimeMs) throw new Error("install_tree_changed"); + return; + } + if (!lexical.isFile() || lexical.size > MAX_CODEX_MEMBER_BYTES) throw new Error("install_tree_unverified"); + total += lexical.size; + if (total > MAX_CODEX_UNPACKED_BYTES || files.size >= 4096) throw new Error("install_tree_limit"); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = fstatSync(fd); + if (!before.isFile() || before.dev !== lexical.dev || before.ino !== lexical.ino || before.size !== lexical.size) throw new Error("install_tree_changed"); + const hash = createHash("sha256").update("codex-cli-observed-file-v1\0"); + const chunk = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + for (;;) { + const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - offset + 1), offset); + if (!count) break; + offset += count; + if (offset > before.size) throw new Error("install_tree_changed"); + hash.update(chunk.subarray(0, count)); + } + const after = fstatSync(fd), final = lstatSync(path); + if (offset !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs + || before.ctimeMs !== after.ctimeMs || final.isSymbolicLink() || final.dev !== before.dev || final.ino !== before.ino + || final.mode !== before.mode) throw new Error("install_tree_changed"); + let executable = false; + if (before.mode & 0o111) { try { accessSync(path, constants.X_OK); executable = true; } catch { /* current user cannot execute it */ } } + files.set(path, { size: before.size, digest: hash.digest("hex"), executable, mode: before.mode & 0o7777 }); + } finally { closeSync(fd); } + }; + const unique = [...new Set(roots)].filter(root => !roots.some(other => other !== root && root.startsWith(other + "/"))); + for (const root of unique) walk(root); + return files; +} +export function codexCliExpectedFiles(roots: readonly [string, CodexArchiveFiles][]): CodexCliFileSet { + return new Map(roots.flatMap(([root, files]) => [...files].map(([path, proof]) => [join(root, path), + { size: proof.size, digest: proof.digest, executable: !!(proof.mode & 0o111) }] as const))); +} +export function sameCodexCliFiles(left: CodexCliFileSet, right: CodexCliFileSet): boolean { + return left.size === right.size && [...left].every(([path, proof]) => { + const other = right.get(path); + return other?.size === proof.size && other.digest === proof.digest && other.executable === proof.executable + && (proof.mode === undefined || proof.mode === other.mode); + }); +} diff --git a/src/codex/cli-update-lease.ts b/src/codex/cli-update-lease.ts new file mode 100644 index 0000000000..6f4558067f --- /dev/null +++ b/src/codex/cli-update-lease.ts @@ -0,0 +1,54 @@ +import { randomUUID } from "node:crypto"; +import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, + realpathSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Installation-unit mutex. No stale/empty-owner recovery and no token delegation. */ +export function acquireCodexCliApplyLease(prefix: string): { release(): boolean } | null { + let fd: number | undefined; + let lockIdentity: { dev: number; ino: number } | undefined; + let ownerIdentity: { dev: number; ino: number } | undefined; + const path = join(prefix, ".ocx-codex-cli-apply.lock"); + const owner = join(path, "owner.json"); + const record = JSON.stringify({ version: 1, pid: process.pid, token: randomUUID() }); + const release = () => { + let current: number | undefined; + try { + if (!lockIdentity || !ownerIdentity) return false; + const directory = lstatSync(path); + if (!directory.isDirectory() || directory.isSymbolicLink() + || directory.dev !== lockIdentity.dev || directory.ino !== lockIdentity.ino) return false; + current = openSync(owner, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const identity = fstatSync(current); + if (!identity.isFile() || identity.size !== Buffer.byteLength(record) + || identity.dev !== ownerIdentity.dev || identity.ino !== ownerIdentity.ino + || readFileSync(current, "utf8") !== record) return false; + const lexical = lstatSync(owner); + if (lexical.isSymbolicLink() || lexical.dev !== identity.dev || lexical.ino !== identity.ino) return false; + unlinkSync(owner); + rmdirSync(path); + lockIdentity = undefined; + return true; + } catch { return false; } + finally { if (current !== undefined) closeSync(current); } + }; + try { + const root = lstatSync(prefix); + if (!root.isDirectory() || root.isSymbolicLink() || realpathSync.native(prefix) !== prefix + || (root.mode & 0o022) !== 0 || root.uid !== process.geteuid?.()) return null; + mkdirSync(path, { mode: 0o700 }); + lockIdentity = lstatSync(path); + fd = openSync(owner, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + ownerIdentity = fstatSync(fd); + writeFileSync(fd, record); + closeSync(fd); + fd = undefined; + return { release }; + } catch { + if (fd !== undefined) closeSync(fd); + // Only a positively identified owner is eligible for cleanup. Empty or + // suspicious directories intentionally remain busy for operator inspection. + if (ownerIdentity) release(); + return null; + } +} diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index d3c4835b74..93dd6caa4e 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -7,6 +7,7 @@ import { join, resolve } from "node:path"; import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import { npmInvocation } from "../update/npm-invocation.mjs"; import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch-policy.mjs"; +import { codexCliArtifactSet, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; import { inspectCodexCliInstall, type CodexCliInstallKind, @@ -83,7 +84,7 @@ export interface CodexCliUpdateSession { } export type CodexCliUpdateTarget = - | Readonly<{ kind: "resolved"; version: string; integrity: string }> + | Readonly<{ kind: "resolved"; version: string; integrity: string; artifacts?: CodexCliArtifactSet }> | Readonly<{ kind: "unresolved"; reason: string }>; export interface CodexCliUpdatePlan { @@ -101,6 +102,7 @@ export interface CodexCliUpdatePlan { readonly location: string | null; readonly targetVersion: string | null; readonly targetIntegrity: string | null; + readonly targetArtifacts?: CodexCliArtifactSet; readonly session: CodexCliUpdateSession; /** * The bounded content observation the installation report produced: digest of @@ -170,6 +172,7 @@ export function codexCliUpdatePlanId(bound: { readonly channel: CodexCliUpdateChannel; readonly targetVersion: string; readonly targetIntegrity: string; + readonly targetArtifactsDigest?: string; }): string { // Ordered pairs, not object key order: the digest must not depend on how a caller // happened to build the record. @@ -187,6 +190,7 @@ export function codexCliUpdatePlanId(bound: { ]; const hash = createHash("sha256"); for (const [key, value] of fields) hash.update(`${key}=${value}\n`); + if (bound.targetArtifactsDigest) hash.update(`targetArtifactsDigest=${bound.targetArtifactsDigest}\n`); return hash.digest("hex").slice(0, PLAN_ID_LENGTH); } @@ -317,7 +321,7 @@ interface NpmConfigIsolation { * and the env filter removes the env-var channel. What remains is exactly the pinned * --registry flag plus proxy/CA values captured before project dotenv loading. */ -function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation { +export function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation { const ownsRoot = dir === undefined; const root = dir ?? mkdtempSync(join(tmpdir(), "ocx-codex-cli-meta-")); try { @@ -351,7 +355,7 @@ function isolatedNpmArgs(args: readonly string[], isolation: NpmConfigIsolation) ]; } -function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolation): NpmTarget | null { +export function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolation): NpmTarget | null { const target = npmTarget(isolatedNpmArgs(args, isolation), isolation.env); if (!target) return null; return { @@ -447,6 +451,35 @@ export function resolveCodexCliUpdateTarget( } } +/** Resolve both exact archives, including the publisher's platform-package alias. */ +export function resolveCodexCliUpdateArtifactTarget(channel: CodexCliUpdateChannel, + spawn: typeof spawnSync = spawnSync, boundEnv?: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, arch: string = process.arch): CodexCliUpdateTarget { + let isolation: NpmConfigIsolation | undefined; + try { + isolation = createNpmConfigIsolation(undefined, boundEnv); + const query = (spec: string) => { + const invocation = isolatedNpmTarget(["view", spec, "--json"], isolation!); + if (!invocation) throw new Error("npm unavailable"); + const result = spawn(invocation.bin, invocation.args, { encoding: "utf8", timeout: REGISTRY_TIMEOUT_MS, + maxBuffer: 1024 * 1024, windowsHide: true, ...invocation.options }); + if (result.status !== 0) throw new Error("registry query failed"); + return JSON.parse(result.stdout); + }; + const latest = query(`${CODEX_CLI_PACKAGE}@${channel}`); + if (!parseStrictSemver(latest.version)) throw new Error("target version unavailable"); + const pinned = query(`${CODEX_CLI_PACKAGE}@${latest.version}`); + if (pinned.version !== latest.version) throw new Error("target version drift"); + const artifacts = codexCliArtifactSet(pinned, query, platform, arch); + return Object.freeze({ kind: "resolved", version: artifacts.main.version, + integrity: artifacts.main.integrity, artifacts }); + } catch { + return Object.freeze({ kind: "unresolved", reason: "main/platform artifact metadata unverified" }); + } finally { + if (isolation) { try { rmSync(isolation.dir, { recursive: true }); } catch { /* owned metadata residue */ } } + } +} + function refusedPlan( refusal: CodexCliUpdateRefusal, report: CodexCliInstallReport, @@ -525,7 +558,8 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} // When the caller had no trusted snapshot, inspectionDeps.env is the fail-closed // { PATH: "" } form, so this resolution cannot silently fall back to ambient. const resolveTarget = deps.resolveTarget - ?? (ch => resolveCodexCliUpdateTarget(ch, deps.spawnProcess ?? spawnSync, deps.inspectionDeps?.env)); + ?? (ch => resolveCodexCliUpdateArtifactTarget(ch, deps.spawnProcess ?? spawnSync, deps.inspectionDeps?.env, + platform, deps.inspectionDeps?.arch ?? process.arch)); const target = resolveTarget(channel); if (target.kind !== "resolved") { return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED); @@ -572,6 +606,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} channel, targetVersion: target.version, targetIntegrity: target.integrity, + targetArtifactsDigest: target.artifacts ? codexCliArtifactSetDigest(target.artifacts) : undefined, }), provenance: report.provenance, managed: report.managed, @@ -580,6 +615,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} location: report.location, targetVersion: target.version, targetIntegrity: target.integrity, + ...(target.artifacts ? { targetArtifacts: target.artifacts } : {}), session: Object.freeze({ state: "none" as const, matches: 0 }), installContentDigest: report.installContentDigest, command: codexCliUpdateCommand(target.version), diff --git a/structure/INDEX.md b/structure/INDEX.md index f17d17381f..be0566d051 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) | Read-only update planning, isolated npm environment, and complete process observation. | +| [`codex-cli-update.md`](codex-cli-update.md) | Content-bound planning and explicit POSIX 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. | @@ -209,6 +209,10 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/catalog/parsing.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/routed-gather.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)
[`providers/opengateway.md`](providers/opengateway.md) | | `src/codex/catalog/vendor-context.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | +| `src/codex/cli-update-apply.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-artifacts.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-files.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/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 310445ddb3..84299b88be 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -1,4 +1,4 @@ -# Codex CLI Update Plan +# Codex CLI Update `src/codex/cli-update-plan.ts` constructs the read-only update plan; `src/codex/app-server-processes.ts` supplies its process observation. @@ -39,7 +39,7 @@ retargeted selection link, or an unmodelable global lookup all fail closed. The links are checked after the final file-identity pass while native descriptors remain open; this observes linkage at that boundary and does not prevent later symlink mutation. A managed install with no bound content is refused install_content_unverified before any registry query. The digest is -observation only — it carries no execution guarantee — so a future apply phase must re-observe +observation only — it carries no execution guarantee — so the apply phase re-observes rather than rely on it. The observation is opt-in: the plan path requests it because the planId binds it, while a bare check leaves the digest null rather than paying the bounded read. Selection observation uses the runtime resolver with provenance's bounded no-follow, nonblocking state reader and local executable-path policy. Windows returns before invoking the resolver; its deferred inspection performs no candidate or configuration I/O. @@ -55,3 +55,40 @@ Linux per-process `status`/`cmdline` read failures invalidate a complete observa (a process that disappeared); incomplete observation refuses with `blocked_process_state_unknown`; the restart/kill path skips unreadable entries while retaining independently verified same-user app-server targets. This plan neither installs a runtime nor signals an existing Codex session. Windows enumeration/installation limits remain unchanged. + +## Explicit POSIX apply + +`src/cli/codex-cli-update.ts` accepts `apply --plan-id <32 lowercase hex> --yes` with optional JSON output. +`src/codex/cli-update-apply.ts` applies only a selected, managed, non-shim POSIX npm-global installation. +The inspector provides process-local canonical prefix/launcher/package evidence through an internal WeakMap; +public report locations are display-only and are never reconstructed into write targets. +`src/codex/cli-update-lease.ts` acquires one installation-prefix mutex without reentrancy, delegation, +waiting, or automatic stale/empty/suspicious-owner recovery. Only a matching owned directory and owner +file can be released. This apply lease is separate from service ownership leases. + +Under that lease, a fresh applicable plan must match the approved ID. Main and platform metadata include +exact versions, canonical SHA-512 integrity and official registry tarball URLs; all artifact fields, +platform and inspection architecture contribute to plan identity. Platform aliases whose published +manifest name is also `@openai/codex` are modeled explicitly. The existing isolated npm environment, +cwd, cache and distinct npmrc boundary is reused for metadata, exact-version pack and install calls. +`src/codex/cli-update-artifacts.ts` streams compressed integrity and tar validation with independent +256 MiB compressed, 512 MiB total unpacked, 384 MiB member and 4,096 member bounds. Only small +manifest/entrypoint bytes are retained; native and auxiliary members retain size/mode/hash metadata. +Links, traversal, unsupported tar records, dependency-bearing manifests and missing backing files refuse. + +After artifact preparation, selection/content, the approved metadata and active/unknown process blockers +are re-observed. npm receives only two verified local tarball specs and an explicit canonical prefix, +with offline mode, optional dependencies omitted, lifecycle scripts disabled and no audit/fund/lockfile +side effects. Main manifests are not rewritten to make installation succeed. Cancelled preparation +refuses; cancellation during an admitted npm child waits for its close rather than killing it. The lease +and staging remain held while the installer is live or uncertain. There is no automatic retry or rollback. +`src/codex/cli-update-files.ts` compares the complete bounded regular-file sets after installation, +including auxiliary members and executable status; original-state readback also compares original modes. +Required launcher/native paths must also pass the current user's X_OK check; another user's executable bit alone is insufficient. +After file/selection readback, the selected launcher runs with `--version` in an owned home/temp sandbox, +with 16 KiB output and an eight-second deadline. Normal exit and the exact target version are required. +Only this verification's new POSIX process group may be terminated on a deadline; an unconfirmed close/group +exit retains lease/staging and reports its PID with an ambiguous result. Existing sessions are never signalled. +Verified target readback and execution are `applied` even after nonzero installer exit; unchanged full original evidence +is `not_applied` even after exit zero. Partial, unreadable or uncertain results are `ambiguous`. +Windows apply remains refused. Eligible shim recovery, management/status and dashboard apply are separate work. diff --git a/structure/manifest.json b/structure/manifest.json index 447b7f3312..6b28557d22 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -172,10 +172,14 @@ { "path": "codex-cli-update.md", "tier": 2, - "title": "Codex CLI Update Plan", - "scope": "Read-only update planning, isolated npm environment, and complete process observation.", + "title": "Codex CLI Update", + "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/cli-update-apply.ts", + "src/codex/cli-update-artifacts.ts", + "src/codex/cli-update-files.ts", + "src/codex/cli-update-lease.ts", "src/codex/app-server-processes.ts" ] }, diff --git a/structure/runtime.md b/structure/runtime.md index 5255939fa2..3224ed2d5d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -308,7 +308,7 @@ The probe uses a child-specific PATH and startup bypass; read-only config diagno Overlay diagnosis keeps `runnable` (owned executable wrapper/current binding and usable native launcher) separate from `active` (the selected PATH command is this wrapper); `healthy` requires both. Failed PATH inspection is unverified, not active. Explicit `codex-shim install` (`src/cli/dispatch.ts`) refuses with nonzero exit; a runnable overlay succeeds even while inactive, prints activation guidance, and receives no green readiness claim until active and free of routing/proxy warnings. Healthy existing Windows shims still succeed. -Codex CLI update inspection is split from mutation. `system codex-cli-update check` makes no +Codex CLI update inspection is split from the [explicit POSIX apply contract](codex-cli-update.md#explicit-posix-apply). `system codex-cli-update check` makes no package-registry request and reads bounded provenance evidence for the configured launcher candidate, npm ownership layout, package metadata, and shim binding. The proof-bound launcher snapshot does not attest successful Codex execution; POSIX selection is attested when the runtime resolver selects the same canonical environment candidate without executing it. A linked npm-global manifest can then establish managed ownership; persisted candidates stay unattested. diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts index c0c82b7bb5..9c9967a728 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -247,9 +247,19 @@ describe("Codex CLI selection inspection boundary", () => { // and node directories. Everything the spawned npm view needs is answered // by the script; nothing else on the PATH can shadow it. const integrity = "sha512-" + Buffer.alloc(64, 7).toString("base64"); + const backingVersion = "9.9.9-" + selected.pkg.slice("@openai/codex-".length); + const mainMetadata = JSON.stringify({ name: "@openai/codex", version: "9.9.9", + optionalDependencies: { [selected.pkg]: "npm:@openai/codex@" + backingVersion }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-9.9.9.tgz" } }); + const backingMetadata = JSON.stringify({ name: "@openai/codex", version: backingVersion, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-" + backingVersion + ".tgz" } }); writeFileSync(join(fakeBin, "npm"), [ "#!/bin/sh", 'case "$*" in', + ` *codex@${backingVersion}*--json*) printf '%s\\n' '${backingMetadata}'; exit 0 ;;`, + ` *--json*) printf '%s\\n' '${mainMetadata}'; exit 0 ;;`, + "esac", + 'case "$*" in', ' *dist.integrity*) printf "%s\n" ' + JSON.stringify(integrity) + " ;;", ' *dist.tarball*) printf "%s\n" "https://registry.npmjs.org/@openai/codex/-/codex-9.9.9.tgz" ;;', ' *) printf "%s\n" "9.9.9" ;;', diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts new file mode 100644 index 0000000000..2fae255c3b --- /dev/null +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyCodexCliUpdate, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; +import { createCodexCliApplyFixture } from "../helpers/codex-cli-apply-fixture"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); +const posix = process.platform !== "win32"; +function fixture(targetNativeSource?: string) { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-private-")); roots.push(root); + return createCodexCliApplyFixture(root, process.platform, process.arch, targetNativeSource); +} +function depsFor(f: ReturnType, overrides: Partial = {}): CodexCliApplyDeps { + return { platform: process.platform, temporaryRoot: f.temporaryRoot, + inspectionDeps: { env: f.env, configDir: join(f.home, "ocx"), selectionAttested: true, selectedCommand: f.launcher }, + scanProcesses: () => ({ kind: "observed", processes: [] }), + resolveTarget: () => ({ kind: "resolved", version: "1.1.0", integrity: f.target.artifacts.main.integrity, artifacts: f.target.artifacts }), + fetchArtifact: async artifact => artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes, + ...overrides }; +} +async function approved(f: ReturnType, deps = depsFor(f)) { + const plan = await createCodexCliUpdatePlan(deps); + expect(plan.applicable).toBe(true); expect(plan.planId).toMatch(/^[0-9a-f]{32}$/); + return plan.planId!; +} +function cli(f: ReturnType, args: string[]) { + const result = spawnSync(Bun.which("node")!, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", ...args, "--json"], { + cwd: repoRoot(), encoding: "utf8", env: f.env, timeout: 60_000, + }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(0); + return JSON.parse(result.stdout); +} +describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { + test("published Node launcher -> real resolver/provenance -> plan/apply -> verified new files", () => { + const f = fixture(); + const plan = cli(f, ["plan"]); + expect(plan.applicable).toBe(true); expect(plan.managed).toBe(true); + expect(plan.targetArtifacts.backing.name).toBe("@openai/codex"); + const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); + expect(result.outcome).toBe("applied"); expect(result.installedVersion).toBe("1.1.0"); + expect(result.leaseRetained).toBe(false); + const after = cli(f, ["check"]); expect(after.managed).toBe(true); expect(after.packageVersion).toBe("1.1.0"); + const calls = readFileSync(f.trace, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("--offline"); expect(calls[0]).toContain("--ignore-scripts"); + expect(calls[0]![calls[0]!.indexOf("--prefix") + 1]).toBe(f.prefix); + expect(calls[0]!.filter(arg => arg.includes("@file:"))).toHaveLength(2); + expect(calls[0]!.join(" ")).not.toContain("@latest"); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + }, 120_000); + + test.each(["stale-content", "same-version-integrity", "active", "unknown", "archive-tamper", "missing-backing"] as const)( + "%s refuses before installer admission", async scenario => { + const f = fixture(); let installs = 0; + const deps = depsFor(f, { install: async () => { installs++; return { terminal: true, exitCode: 0 }; } }); + const id = await approved(f, deps); + if (scenario === "stale-content") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), "#!/usr/bin/env node\n// changed bytes\n"); + const altered = { ...deps } as { -readonly [K in keyof CodexCliApplyDeps]: CodexCliApplyDeps[K] }; + if (scenario === "same-version-integrity") altered.resolveTarget = () => ({ kind: "resolved", version: "1.1.0", integrity: f.target.artifacts.main.integrity, + artifacts: { ...f.target.artifacts, backing: { ...f.target.artifacts.backing, integrity: "sha512-" + Buffer.alloc(64, 8).toString("base64") } } }); + if (scenario === "active") altered.scanProcesses = () => ({ kind: "observed", processes: [{ pid: 1, command: "codex" }] } as never); + if (scenario === "unknown") altered.scanProcesses = () => ({ kind: "unavailable" } as never); + if (scenario === "archive-tamper") altered.fetchArtifact = async () => Buffer.from("invalid archive"); + if (scenario === "missing-backing") altered.fetchArtifact = async artifact => artifact.version === "1.1.0" ? f.target.main.bytes : f.target.main.bytes; + const result = await applyCodexCliUpdate(id, altered); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + }, 120_000, + ); + test("a plan from a different prefix refuses without installing", async () => { + const first = fixture(), second = fixture(); const id = await approved(first); + let installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(second, { install: async () => { installs++; return { terminal: true, exitCode: 0 }; } })); + expect(result.reason).toBe("plan_changed"); expect(installs).toBe(0); + }, 120_000); + test("content and selection are renewed after slow artifact preparation", async () => { + const f = fixture(); let installs = 0; + const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { + fetchArtifact: async artifact => { + if (artifact.version !== "1.1.0") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), "#!/usr/bin/env node\n// changed during prepare\n"); + return artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes; + }, install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + })); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + }, 120_000); + test.each(["selection", "active", "unknown"] as const)("late %s change refuses after artifact preparation", async kind => { + const f = fixture(); const id = await approved(f); let prepared = false, installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(f, { + fetchArtifact: async artifact => { + if (artifact.version !== "1.1.0") { + prepared = true; + if (kind === "selection") { unlinkSync(f.launcher); symlinkSync(f.target.main.path, f.launcher); } + } + return artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes; + }, + scanProcesses: () => prepared && kind !== "selection" + ? kind === "active" ? { kind: "observed", processes: [{ pid: 1, command: "codex" }] } as never : { kind: "unavailable" } as never + : { kind: "observed", processes: [] }, + install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + })); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + }, 120_000); + test("exit zero with original full files is not_applied", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => ({ terminal: true, exitCode: 0 }) })); + expect(result.outcome).toBe("not_applied"); expect(result.installerExitCode).toBe(0); + }, 120_000); + test.each(["complete-nonzero", "missing-auxiliary", "changed-auxiliary", "main-only", "native-nonexecutable"] as const)( + "%s is classified by complete readback, not installer exit", async scenario => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + const args = scenario === "main-only" ? invocation.args.filter(arg => !arg.startsWith(f.target.artifacts.backing.installName + "@file:")) : [...invocation.args]; + f.privateNpm([...args]); + const auxiliary = join(f.prefix, "lib", "node_modules", ...f.target.artifacts.backing.installName.split("/"), "vendor/auxiliary.txt"); + if (scenario === "missing-auxiliary") rmSync(auxiliary); + if (scenario === "changed-auxiliary") writeFileSync(auxiliary, "synthetic auxiliary CHANGED\n"); + if (scenario === "native-nonexecutable") chmodSync(join(f.prefix, "lib", "node_modules", ...f.target.artifacts.backing.installName.split("/"), + "vendor", codexCliPlatformBacking(process.platform, process.arch)!.triple, "bin/codex"), 0o644); + return { terminal: true, exitCode: scenario === "complete-nonzero" ? 17 : 0 }; + } })); + expect(result.outcome).toBe(scenario === "complete-nonzero" ? "applied" : "ambiguous"); + expect(result.leaseRetained).toBe(false); + }, 120_000, + ); + test.skipIf(process.geteuid?.() === 0)("mode 0641 is not executable by the nonroot file owner", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); + chmodSync(join(f.prefix, "lib", "node_modules", ...f.target.artifacts.backing.installName.split("/"), + "vendor", codexCliPlatformBacking(process.platform, process.arch)!.triple, "bin/codex"), 0o641); + return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome).toBe("ambiguous"); + }, 120_000); + test.each(["#!/bin/sh\nexit 7\n", "#!/bin/sh\nprintf 'codex-cli 9.9.9\\n'\n"])("verified bytes with unsuccessful/wrong version execution are ambiguous", async source => { + const f = fixture(source); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome).toBe("ambiguous"); expect(result.reason).toBe("version_readback_unverified"); + expect(result.leaseRetained).toBe(false); expect(result.verificationPid).toBeNull(); + }, 120_000); + test("uncertain owned version child retains lease/staging and exposes its PID", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); return { terminal: true, exitCode: 0 }; + }, verifyVersion: async () => ({ terminal: false, ok: false, pid: 12345 }) })); + expect(result.outcome).toBe("ambiguous"); expect(result.reason).toBe("version_process_state_unknown"); + expect(result.leaseRetained).toBe(true); expect(result.verificationPid).toBe(12345); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + // Only an injected lifetime observation: there is no actual PID 12345 owned here. + }, 120_000); + test("empty and damaged apply locks remain busy without reclaim", async () => { + const f = fixture(); const id = await approved(f); let installs = 0; + const path = join(f.prefix, ".ocx-codex-cli-apply.lock"); mkdirSync(path, { mode: 0o700 }); + for (const owner of [null, "", "{broken"]) { + if (owner !== null) writeFileSync(join(path, "owner.json"), owner); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => { installs++; return { terminal: true, exitCode: 0 }; } })); + expect(result.reason).toBe("installation_busy"); expect(existsSync(path)).toBe(true); + } + expect(installs).toBe(0); + }, 120_000); + test("one prefix lease excludes a concurrent apply and remains held for an uncertain installer", async () => { + const f = fixture(); const id = await approved(f); + let resume!: () => void; let entered!: () => void; + const pending = new Promise(resolve => { resume = resolve; }); + const admitted = new Promise(resolve => { entered = resolve; }); + const first = applyCodexCliUpdate(id, depsFor(f, { fetchArtifact: async artifact => { + entered(); await pending; return artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes; + }, install: async () => ({ terminal: false, exitCode: null }) })); + await admitted; + const other = await applyCodexCliUpdate(id, depsFor(f)); + expect(other.reason).toBe("installation_busy"); + resume(); const result = await first; + expect(result.outcome).toBe("ambiguous"); expect(result.leaseRetained).toBe(true); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(acquireCodexCliApplyLease(f.prefix)).toBeNull(); + // The uncertain child is a test double; no real installer survives teardown. + }, 120_000); + test("original file mode damage is ambiguous, not not_applied", async () => { + const f = fixture(); const id = await approved(f); + const native = join(f.prefix, "lib", "node_modules", ...f.old.artifacts.backing.installName.split("/"), + "vendor", codexCliPlatformBacking(process.platform, process.arch)!.triple, "bin/codex"); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => { + chmodSync(native, 0o644); return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome).toBe("ambiguous"); + }, 120_000); + test("cancellation during npm waits for terminal state, keeping lease and staging until readback", async () => { + const f = fixture(); const id = await approved(f); const controller = new AbortController(); + let close!: () => void, admitted!: () => void; + const started = new Promise(resolve => { admitted = resolve; }); + const terminal = new Promise(resolve => { close = resolve; }); + let done = false; + const running = applyCodexCliUpdate(id, depsFor(f, { signal: controller.signal, install: async () => { + admitted(); await terminal; return { terminal: true, exitCode: 0 }; + } })).then(result => { done = true; return result; }); + await started; controller.abort(); await Promise.resolve(); + expect(done).toBe(false); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + close(); const result = await running; + expect(result.outcome).toBe("not_applied"); expect(result.leaseRetained).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); + test.each(["cancel", "error"] as const)("%s during preparation releases only its own lease", async kind => { + const f = fixture(); const id = await approved(f); const controller = new AbortController(); + let installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(f, { signal: controller.signal, + fetchArtifact: async () => { if (kind === "error") throw new Error("synthetic failure"); controller.abort(); return f.target.main.bytes; }, + install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + })); + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); +}); diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts new file mode 100644 index 0000000000..fd046a2d2b --- /dev/null +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { codexCliArtifactSet, codexCliArtifactSetDigest, MAX_CODEX_ARCHIVE_BYTES, MAX_CODEX_MEMBER_BYTES, verifyCodexCliArchive, verifiedCodexCliContent } from "../../src/codex/cli-update-artifacts"; +import { codexCliUpdatePlanId } from "../../src/codex/cli-update-plan"; +import { fixtureArtifact, fixtureArtifactSet, fixtureTar } from "../helpers/codex-cli-apply-artifact"; +import { applyCodexCliUpdate, verifyInstalledCodexVersion } from "../../src/codex/cli-update-apply"; +import { parseCodexCliUpdateArgs, handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; +import { closeSync, ftruncateSync, mkdtempSync, openSync, readFileSync } from "node:fs"; +import { gzipSync, gunzipSync } from "node:zlib"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createCodexCliApplyFixture } from "../helpers/codex-cli-apply-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { sameCodexCliFiles, type CodexCliFileSet } from "../../src/codex/cli-update-files"; + +describe("Codex CLI apply artifacts", () => { + test("real npm installs main and platform aliases from local tarballs into a private prefix offline", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-alias-")); + try { + const f = createCodexCliApplyFixture(root, "linux", "x64"); + const modules = join(f.prefix, ...(process.platform === "win32" ? [] : ["lib"]), "node_modules"); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex", "package.json"), "utf8")).version).toBe("1.0.0"); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex-linux-x64", "package.json"), "utf8")).version).toBe("1.0.0-linux-x64"); + expect(Object.keys(f.target.mainManifest.optionalDependencies)).toHaveLength(6); + f.privateNpm(["install", "-g", "--prefix", f.prefix, "--package-lock=false", + `${f.target.artifacts.main.installName}@file:${f.target.main.path}`, + `${f.target.artifacts.backing.installName}@file:${f.target.backing.path}`]); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex", "package.json"), "utf8")).version).toBe("1.1.0"); + expect(JSON.parse(readFileSync(join(modules, "@openai", "codex-linux-x64", "package.json"), "utf8")).version).toBe("1.1.0-linux-x64"); + expect(readFileSync(join(modules, "@openai", "codex", "package.json"), "utf8")).toBe(JSON.stringify(f.target.mainManifest) + "\n"); + } finally { removeTreeWithRetry(root); } + }, 120_000); + test("verifies the exact main and aliased platform manifest/entrypoint/native bytes", async () => { + const f = fixtureArtifactSet(); + expect(verifiedCodexCliContent(f.artifacts, await verifyCodexCliArchive(f.main, f.artifacts.main), + await verifyCodexCliArchive(f.backing, f.artifacts.backing))).toMatch(/^[0-9a-f]{64}$/); + const metadata = (artifact: typeof f.artifacts.main) => ({ name: artifact.name, version: artifact.version, + dist: { integrity: artifact.integrity, tarball: artifact.tarball } }); + const set = codexCliArtifactSet({ ...f.mainManifest, ...metadata(f.artifacts.main) }, + spec => { expect(spec).toBe("@openai/codex@1.1.0-linux-x64"); return metadata(f.artifacts.backing); }, "linux", "x64"); + expect(set).toEqual(f.artifacts); + const observed = await verifyCodexCliArchive(f.backing, f.artifacts.backing); + expect([...observed].find(([name]) => name.endsWith("/bin/codex"))![1].bytes).toBeNull(); + }); + test("explicit inspection architecture selects the backing independently of the Bun host architecture", () => { + const f = fixtureArtifactSet("linux", "arm64"); + const set = codexCliArtifactSet({ ...f.mainManifest, dist: { integrity: f.artifacts.main.integrity, tarball: f.artifacts.main.tarball } }, + spec => { expect(spec).toBe("@openai/codex@1.1.0-linux-arm64"); return { name: "@openai/codex", version: f.artifacts.backing.version, + dist: { integrity: f.artifacts.backing.integrity, tarball: f.artifacts.backing.tarball } }; }, "linux", "arm64"); + expect(set.backing.installName).toBe("@openai/codex-linux-arm64"); + }); + test("readback catches executable-bit loss and preserves exact original modes", () => { + const target: CodexCliFileSet = new Map([["native", { size: 1, digest: "same", executable: true }]]); + const observed: CodexCliFileSet = new Map([["native", { size: 1, digest: "same", executable: false, mode: 0o644 }]]); + expect(sameCodexCliFiles(target, observed)).toBe(false); + const original: CodexCliFileSet = new Map([["data", { size: 1, digest: "same", executable: false, mode: 0o644 }]]); + expect(sameCodexCliFiles(original, new Map([["data", { size: 1, digest: "same", executable: false, mode: 0o600 }]]))).toBe(false); + }); + test("same-version backing integrity drift changes the approved plan identity", () => { + const f = fixtureArtifactSet(); + const bound = { platform: "linux" as const, provenance: "npm-global" as const, installedVersion: "1.0.0", + installDigest: "a", installContentDigest: "b", channel: "latest" as const, targetVersion: "1.1.0", targetIntegrity: f.artifacts.main.integrity }; + const digest = (set: typeof f.artifacts) => codexCliUpdatePlanId({ ...bound, targetArtifactsDigest: codexCliArtifactSetDigest(set) }); + expect(digest(f.artifacts)).not.toBe(digest({ ...f.artifacts, backing: { ...f.artifacts.backing, integrity: "changed" } })); + expect(codexCliArtifactSetDigest(f.artifacts)).toBe(codexCliArtifactSetDigest({ arch: f.artifacts.arch, platform: f.artifacts.platform, + backing: { ...f.artifacts.backing }, main: { tarball: f.artifacts.main.tarball, integrity: f.artifacts.main.integrity, + name: f.artifacts.main.name, installName: f.artifacts.main.installName, version: f.artifacts.main.version } })); + }); + test("archive byte tampering is refused", async () => { + const f = fixtureArtifactSet(); const changed = Buffer.from(f.main); changed[changed.length - 1] ^= 1; + await expect(verifyCodexCliArchive(changed, f.artifacts.main)).rejects.toThrow(); + }); + test("an oversized tar member refuses from its header before reading or allocating its body", async () => { + const tar = gunzipSync(fixtureTar({ "package.json": "{}" })); + tar.write((MAX_CODEX_MEMBER_BYTES + 1).toString(8).padStart(11, "0") + "\0", 124, 12); + tar.fill(32, 148, 156); + tar.write(tar.subarray(0, 512).reduce((sum, byte) => sum + byte, 0).toString(8).padStart(7, "0") + "\0", 148, 8); + const bytes = gzipSync(tar); + await expect(verifyCodexCliArchive(bytes, fixtureArtifact(bytes))).rejects.toThrow("artifact_tar_invalid"); + }); + test("an oversized compressed file refuses from fstat without reading its sparse body", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-archive-bound-")); + try { + const path = join(root, "oversize.tgz"), fd = openSync(path, "wx"); + try { ftruncateSync(fd, MAX_CODEX_ARCHIVE_BYTES + 1); } finally { closeSync(fd); } + await expect(verifyCodexCliArchive(path, fixtureArtifact(Buffer.alloc(0)))).rejects.toThrow("artifact_file_unverified"); + } finally { removeTreeWithRetry(root); } + }); + test.each(["../escape", "/absolute", "dir/../escape", "bad\\path"])("unsafe archive entry %s is refused", async name => { + const bytes = fixtureTar({ "package.json": JSON.stringify({ name: "@openai/codex", version: "1.1.0" }), [name]: "x" }); + await expect(verifyCodexCliArchive(bytes, fixtureArtifact(bytes))).rejects.toThrow(); + }); + test("a missing backing binary refuses a verified manifest-only archive", async () => { + const f = fixtureArtifactSet(); + const bytes = fixtureTar({ "package.json": f.backingFiles["package.json"] }); + const main = await verifyCodexCliArchive(f.main, f.artifacts.main); + const backing = await verifyCodexCliArchive(bytes, fixtureArtifact(bytes, f.artifacts.backing.version, f.artifacts.backing.installName)); + expect(() => verifiedCodexCliContent(f.artifacts, main, backing)).toThrow(); + }); + test("dependency-bearing archives cannot trigger an installer resolution", async () => { + const bytes = fixtureTar({ "package.json": JSON.stringify({ name: "@openai/codex", version: "1.1.0", dependencies: { unexpected: "latest" } }) }); + await expect(verifyCodexCliArchive(bytes, fixtureArtifact(bytes))).rejects.toThrow("artifact_dependency_unverified"); + }); +}); +describe("Codex CLI apply consumer", () => { + test("the owned version probe waits for terminal exit and rejects unrelated version output", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-apply-version-")); + try { + const result = await verifyInstalledCodexVersion(process.execPath, "9.9.9", root, process.env); + expect(result).toEqual({ terminal: true, ok: false, pid: null }); + } finally { removeTreeWithRetry(root); } + }); + test("requires explicit exact plan and confirmation, and accepts shared JSON positions", () => { + const planId = "a".repeat(32); + expect(parseCodexCliUpdateArgs(["--json", "apply", `--plan-id=${planId}`, "--yes"])) + .toEqual({ action: "apply", planId, json: true }); + for (const args of [["apply"], ["apply", "--yes"], ["apply", "--plan-id", planId], + ["apply", "--yes", "--yes", "--plan-id", planId], ["apply", "--yes", "--plan-id", "latest"]]) { + expect(() => parseCodexCliUpdateArgs(args)).toThrow(); + } + }); + test("malformed apply cannot reach its handler", async () => { + let calls = 0; + expect(await handleCodexCliUpdateCommand(["apply", "--yes"], { applyUpdate: async () => { calls++; throw new Error("not reached"); } })).toBe(2); + expect(calls).toBe(0); + }); + test("confirmed apply reaches its consumer with the exact plan ID and prints its public result", async () => { + const planId = "a".repeat(32); let calls = 0; const logs: string[] = [], log = console.log; + const report = { outcome: "not_applied" as const, reason: "original_install_unchanged", planId, + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null }; + try { + console.log = value => { logs.push(String(value)); }; + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", planId, "--yes", "--json"], { + applyUpdate: async (id, deps) => { calls++; expect(id).toBe(planId); expect(deps.inspectionDeps).toBeDefined(); return report; }, + })).toBe(0); + expect(calls).toBe(1); expect(logs).toHaveLength(1); expect(JSON.parse(logs[0]!)).toEqual(report); + } finally { console.log = log; } + }); + 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); + }); +}); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index b77188877e..ee54184784 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -446,6 +446,13 @@ describe("registry configuration isolation", () => { }; calls.push(call); const field = queriedField(call); + if (argvLine(call).includes("--json")) { + const version = (outputs.version ?? "").trim(); + const backing = argvLine(call).includes("@openai/codex-linux-x64@"); + return { status: 0, stdout: JSON.stringify({ name: backing ? "@openai/codex-linux-x64" : "@openai/codex", version, + optionalDependencies: backing ? undefined : { "@openai/codex-linux-x64": version }, + dist: { integrity: (outputs["dist.integrity"] ?? "").trim(), tarball: (outputs["dist.tarball"] ?? "").trim() } }), stderr: "" }; + } return { status: 0, stdout: outputs[field] ?? "", stderr: "" }; }) as never; return { calls, spawn }; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d6637f3362..2e44270c87 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,8 @@ { "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-update-artifacts.test.ts": "codex-integration", + "codex-cli-update-apply.test.ts": "codex-integration", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", diff --git a/tests/helpers/codex-cli-apply-artifact.ts b/tests/helpers/codex-cli-apply-artifact.ts new file mode 100644 index 0000000000..ac6fde0e86 --- /dev/null +++ b/tests/helpers/codex-cli-apply-artifact.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import type { CodexCliArtifact, CodexCliArtifactSet } from "../../src/codex/cli-update-artifacts"; +import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; + +export function fixtureTar(files: Record): Buffer { + const parts: Buffer[] = []; + for (const [name, value] of Object.entries(files)) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value); + const header = Buffer.alloc(512); + header.write("package/" + name); + const octal = (offset: number, length: number, number: number) => header.write(number.toString(8).padStart(length - 1, "0") + "\0", offset, length); + octal(100, 8, 0o755); octal(108, 8, 0); octal(116, 8, 0); + octal(124, 12, bytes.length); octal(136, 12, 0); + header.fill(32, 148, 156); header.write("0", 156); header.write("ustar\0", 257); + octal(148, 8, header.reduce((sum, byte) => sum + byte, 0)); + parts.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + } + parts.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(parts)); +} +export function fixtureArtifact(bytes: Buffer, version = "1.1.0", installName = "@openai/codex", name = "@openai/codex"): CodexCliArtifact { + return { name, installName, version, integrity: "sha512-" + createHash("sha512").update(bytes).digest("base64"), + tarball: `https://registry.npmjs.org/@openai/codex/-/codex-${version}.tgz` }; +} +export function fixtureArtifactSet(platform: NodeJS.Platform = "linux", arch = "x64", version = "1.1.0") { + const layout = codexCliPlatformBacking(platform, arch)!; + const variant = `${version}-${layout.package.slice("@openai/codex-".length)}`; + const mainManifest = { name: "@openai/codex", version, bin: { codex: "bin/codex.js" }, + optionalDependencies: Object.fromEntries(["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"] + .map(suffix => [`@openai/codex-${suffix}`, `npm:@openai/codex@${version}-${suffix}`])) }; + const mainFiles = { "package.json": JSON.stringify(mainManifest) + "\n", "bin/codex.js": [ + "#!/usr/bin/env node", "// Synthetic fixture: resolve the installed platform alias like the published launcher.", + `const root = require('node:path').dirname(require.resolve('${layout.package}/package.json'));`, + `const native = require('node:path').join(root, 'vendor/${layout.triple}/bin/codex');`, + "const child = require('node:child_process').spawnSync(native, process.argv.slice(2), {stdio:'inherit'});", + "process.exit(child.status ?? 1);", "", + ].join("\n") }; + const backingFiles = { "package.json": JSON.stringify({ name: "@openai/codex", version: variant }) + "\n", + [`vendor/${layout.triple}/bin/codex`]: `#!/bin/sh\n# synthetic backing\n[ "$1" = "--version" ] || exit 2\nprintf 'codex-cli ${version}\\n'\n` }; + const main = fixtureTar(mainFiles), backing = fixtureTar(backingFiles); + const artifacts: CodexCliArtifactSet = { platform, arch, main: fixtureArtifact(main, version), + backing: fixtureArtifact(backing, variant, layout.package) }; + return { artifacts, main, backing, mainManifest, mainFiles, backingFiles }; +} diff --git a/tests/helpers/codex-cli-apply-fixture.ts b/tests/helpers/codex-cli-apply-fixture.ts new file mode 100644 index 0000000000..205ef7c691 --- /dev/null +++ b/tests/helpers/codex-cli-apply-fixture.ts @@ -0,0 +1,86 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createNpmConfigIsolation, isolatedNpmTarget } from "../../src/codex/cli-update-plan"; +import { fixtureArtifact, fixtureArtifactSet } from "./codex-cli-apply-artifact"; + +/** All npm config/cache/prefix/temp effects belong to root; no network or scripts. */ +export function privateNpm(root: string, args: string[]) { + // Each command starts with a new empty cache, not the previous pack/install's. + const work = mkdtempSync(join(root, "npm-work-")); + const isolation = createNpmConfigIsolation(work); + const target = isolatedNpmTarget([...args, "--ignore-scripts", "--no-audit", "--no-fund", "--offline", "--omit=optional"], isolation); + if (!target) throw new Error("real npm unavailable"); + const result = spawnSync(target.bin, target.args, { ...target.options, encoding: "utf8", timeout: 60_000, + env: { ...isolation.env, HOME: root, TMPDIR: root } }); + if (result.error || result.status !== 0) throw new Error(`private npm failed: ${result.status} ${result.error?.message ?? ""}\n${result.stderr}`); + return result; +} +/** Tiny synthetic packages are packed by real npm; metadata and pack bytes are + * substituted by an owned npm wrapper, while install delegates to real npm. + * This exercises published Node -> Bun CLI -> resolver/provenance -> plan/apply. + */ +export function createCodexCliApplyFixture(root: string, platform: NodeJS.Platform = process.platform, arch = process.arch, targetNativeSource?: string) { + const prefix = join(root, "prefix"), home = join(root, "home"), temporaryRoot = join(root, "tmp"), fakeBin = join(root, "bin"); + for (const dir of [prefix, home, temporaryRoot, fakeBin]) mkdirSync(dir, { recursive: true }); + const versions = ["1.0.0", "1.1.0"].map(version => { + const fixture = fixtureArtifactSet(platform, arch, version); + if (version === "1.1.0" && targetNativeSource !== undefined) { + fixture.backingFiles[Object.keys(fixture.backingFiles).find(path => path.endsWith("/bin/codex"))!] = targetNativeSource; + } + // An auxiliary native member makes partial readback independently observable. + fixture.backingFiles["vendor/auxiliary.txt"] = `synthetic auxiliary ${version}\n`; + const mainSource = join(root, `main-${version}`), backingSource = join(root, `backing-${version}`); + for (const [source, files] of [[mainSource, fixture.mainFiles], [backingSource, fixture.backingFiles]] as const) { + for (const [name, bytes] of Object.entries(files)) { + mkdirSync(dirname(join(source, name)), { recursive: true }); + writeFileSync(join(source, name), bytes); + if (name !== "package.json") chmodSync(join(source, name), 0o755); + } + } + const pack = (source: string) => { + const info = JSON.parse(privateNpm(root, ["pack", source, "--json", "--pack-destination", source]).stdout); + const path = join(source, info[0].filename); + return { path, bytes: readFileSync(path) }; + }; + const main = pack(mainSource), backing = pack(backingSource); + const artifacts = { ...fixture.artifacts, main: fixtureArtifact(main.bytes, version), + backing: fixtureArtifact(backing.bytes, fixture.artifacts.backing.version, fixture.artifacts.backing.installName) }; + return { artifacts, main, backing, mainManifest: fixture.mainManifest }; + }); + const old = versions[0]!, target = versions[1]!; + const installArgs = (v: typeof old) => ["install", "-g", "--prefix", prefix, "--package-lock=false", + `${v.artifacts.main.installName}@file:${v.main.path}`, `${v.artifacts.backing.installName}@file:${v.backing.path}`]; + privateNpm(root, installArgs(old)); + const trace = join(root, "install-trace.jsonl"); + writeFileSync(trace, ""); + const node = Bun.which("node"), npm = Bun.which("npm"); + if (!node || !npm) throw new Error("fixture toolchain unavailable"); + const realNpm = realpathSync.native(npm); + const metadata = Object.fromEntries([old, target].flatMap(v => [ + ["@openai/codex@" + v.artifacts.main.version, { ...v.mainManifest, dist: { integrity: v.artifacts.main.integrity, tarball: v.artifacts.main.tarball } }], + ["@openai/codex@" + v.artifacts.backing.version, { name: v.artifacts.backing.name, version: v.artifacts.backing.version, + dist: { integrity: v.artifacts.backing.integrity, tarball: v.artifacts.backing.tarball } }], + ])); + metadata["@openai/codex@latest"] = metadata["@openai/codex@1.1.0"]; + const packs = Object.fromEntries([old, target].flatMap(v => [ + ["@openai/codex@" + v.artifacts.main.version, v.main.path], ["@openai/codex@" + v.artifacts.backing.version, v.backing.path], + ])); + if (process.platform !== "win32") { + const wrapper = [ + `#!${node}`, 'const fs = require("node:fs"), path = require("node:path"), cp = require("node:child_process");', + `const metadata = ${JSON.stringify(metadata)}, packs = ${JSON.stringify(packs)};`, + 'const args = process.argv.slice(2);', + 'if (args[0] === "view") { if (!metadata[args[1]]) process.exit(81); console.log(JSON.stringify(metadata[args[1]])); process.exit(0); }', + 'if (args[0] === "pack") { const source = packs[args[1]]; if (!source) process.exit(82); const dir = args[args.indexOf("--pack-destination") + 1]; const name = path.basename(source); fs.copyFileSync(source, path.join(dir, name)); console.log(JSON.stringify([{filename:name}])); process.exit(0); }', + `if (args[0] !== "install") process.exit(83); fs.appendFileSync(${JSON.stringify(trace)}, JSON.stringify(args) + "\\n");`, + `const installed = cp.spawnSync(${JSON.stringify(node)}, [${JSON.stringify(realNpm)}, ...args], {env:process.env, stdio:"inherit"}); process.exit(installed.status ?? 84);`, + ].join("\n") + "\n"; + writeFileSync(join(fakeBin, "npm"), wrapper); chmodSync(join(fakeBin, "npm"), 0o755); + } + const launcher = process.platform === "win32" ? join(prefix, "codex.cmd") : join(prefix, "bin", "codex"); + const env = { ...process.env, HOME: home, TMPDIR: temporaryRoot, OPENCODEX_HOME: join(home, "ocx"), + PATH: [fakeBin, join(prefix, "bin"), dirname(node), process.env.PATH ?? ""].join(process.platform === "win32" ? ";" : ":"), + CODEX_CLI_PATH: launcher, OCX_NO_DESKTOP_HANDOFF: "1", OPENCODEX_BUN_PATH: process.execPath }; + return { root, prefix, home, temporaryRoot, launcher, env, trace, old, target, privateNpm: (args: string[]) => privateNpm(root, args) }; +} From e9fdc4b753762f3a61ab680ec8a297625330eb6c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 05:49:02 +0900 Subject: [PATCH 03/33] fix(codex): preserve canonical staging and complete install preimages (cherry picked from commit a38c1a097815affe33e47c9bf060b5c0aeda7345) --- src/codex/cli-update-apply.ts | 12 +++--- src/codex/cli-update-files.ts | 37 +++++++++++++++++- structure/codex-cli-update.md | 5 +++ .../cli/cli-codex-cli-update-boundary.test.ts | 12 ++++-- .../codex-cli-update-apply.test.ts | 39 ++++++++++++++++++- .../codex-cli-update-artifacts.test.ts | 20 +++++++++- tests/helpers/codex-cli-apply-fixture.ts | 1 + 7 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index c7f483ba58..c2609e996e 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -12,7 +12,7 @@ import { verifyCodexCliArchive, verifiedCodexCliContent, type CodexCliArtifact } from "./cli-update-artifacts"; import { acquireCodexCliApplyLease } from "./cli-update-lease"; import { scanCodexSessionProcesses } from "./app-server-processes"; -import { observeCodexCliFileSet, codexCliExpectedFiles, sameCodexCliFiles } from "./cli-update-files"; +import { observeCodexCliFileSet, observeCodexCliPreimage, sameCodexCliPreimage, codexCliExpectedFiles, sameCodexCliFiles } from "./cli-update-files"; export interface CodexCliApplyResult { readonly outcome: "refused" | "applied" | "not_applied" | "ambiguous"; @@ -154,6 +154,7 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep if (!artifacts || artifacts.platform !== (deps.platform ?? process.platform) || artifacts.arch !== (deps.inspectionDeps?.arch ?? process.arch)) return result("refused", "target_artifacts_unverified"); staging = mkdtempSync(join(deps.temporaryRoot ?? tmpdir(), "ocx-codex-cli-apply-")); + staging = realpathSync.native(staging); const paths: string[] = []; for (const [index, artifact] of [artifacts.main, artifacts.backing].entries()) { let path: string; @@ -169,8 +170,10 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep const main = await verifyCodexCliArchive(paths[0]!, artifacts.main); const backing = await verifyCodexCliArchive(paths[1]!, artifacts.backing); const expectedContent = verifiedCodexCliContent(artifacts, main, backing); - const originalRoots = [evidence.packageRoot, ...(evidence.backingRoot ? [evidence.backingRoot] : [])]; - const originalFiles = observeCodexCliFileSet(originalRoots); + const targetMainRoot = join(prefix, "lib", "node_modules", ...artifacts.main.installName.split("/")); + const backingRoot = join(prefix, "lib", "node_modules", ...artifacts.backing.installName.split("/")); + const originalRoots = [evidence.packageRoot, ...(evidence.backingRoot ? [evidence.backingRoot] : []), targetMainRoot, backingRoot]; + const original = observeCodexCliPreimage(originalRoots); // Metadata, content, selection and process observation are renewed after // potentially slow downloads; a final synchronous process check precedes spawn. const finalPlan = await freshPlan(); @@ -209,7 +212,6 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep if (realpathSync.native(prefix) !== prefix || prefixAfter.dev !== prefixIdentity.dev || prefixAfter.ino !== prefixIdentity.ino) { return result("ambiguous", "installation_prefix_changed"); } - const backingRoot = join(prefix, "lib", "node_modules", ...artifacts.backing.installName.split("/")); if (after.managed && afterEvidence?.prefix === prefix && afterEvidence.launcher === evidence.launcher && after.packageVersion === targetVersion && after.installContentDigest === expectedContent && sameCodexCliFiles(codexCliExpectedFiles([[afterEvidence.packageRoot, main], [backingRoot, backing]]), @@ -228,7 +230,7 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep } if (afterEvidence?.prefix === prefix && after.managed && after.packageVersion === first.packageVersion && after.installContentDigest === plan.installContentDigest - && sameCodexCliFiles(originalFiles, observeCodexCliFileSet(originalRoots)) + && sameCodexCliPreimage(original, observeCodexCliPreimage(originalRoots)) && codexCliMutationSelectionMatches(afterEvidence, freshInspection(deps))) return result("not_applied", "original_install_unchanged"); return result("ambiguous", "installation_readback_unverified"); } catch { diff --git a/src/codex/cli-update-files.ts b/src/codex/cli-update-files.ts index 033f5c41aa..de7bf446b0 100644 --- a/src/codex/cli-update-files.ts +++ b/src/codex/cli-update-files.ts @@ -1,10 +1,45 @@ import { createHash } from "node:crypto"; import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { MAX_CODEX_MEMBER_BYTES, MAX_CODEX_UNPACKED_BYTES, type CodexArchiveFiles } from "./cli-update-artifacts"; interface FileProof { readonly size: number; readonly digest: string; readonly executable: boolean; readonly mode?: number } export type CodexCliFileSet = Map; +export interface CodexCliPreimage { + readonly presentRoots: readonly string[]; + readonly absentRoots: readonly string[]; + readonly files: CodexCliFileSet; +} +/** An absent planned root is evidence too. Reject link/invalid ancestor lanes + * instead of treating an inaccessible or redirected tree as simply absent. + */ +function rootAbsent(path: string): boolean { + try { lstatSync(path); return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + let ancestor = dirname(path); + for (;;) { + try { + const stat = lstatSync(ancestor); + if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync.native(ancestor) !== ancestor) throw new Error("install_tree_unverified"); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(ancestor) === ancestor) throw error; + ancestor = dirname(ancestor); + } + } +} +export function observeCodexCliPreimage(roots: readonly string[]): CodexCliPreimage { + const presentRoots: string[] = [], absentRoots: string[] = []; + for (const root of new Set(roots)) (rootAbsent(root) ? absentRoots : presentRoots).push(root); + const files = observeCodexCliFileSet(presentRoots); + if (absentRoots.some(root => !rootAbsent(root))) throw new Error("install_tree_changed"); + return { presentRoots, absentRoots, files }; +} +export function sameCodexCliPreimage(left: CodexCliPreimage, right: CodexCliPreimage): boolean { + const sameRoots = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every(root => b.includes(root)); + return sameRoots(left.presentRoots, right.presentRoots) && sameRoots(left.absentRoots, right.absentRoots) + && sameCodexCliFiles(left.files, right.files); +} /** Full bounded regular-file observation of the selected package roots only. * Ancestor/path links, nonregular files and changed identities refuse it. * Big members are hashed in 64 KiB chunks, never retained in memory. diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 84299b88be..03337f34ad 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -71,6 +71,8 @@ exact versions, canonical SHA-512 integrity and official registry tarball URLs; platform and inspection architecture contribute to plan identity. Platform aliases whose published manifest name is also `@openai/codex` are modeled explicitly. The existing isolated npm environment, cwd, cache and distinct npmrc boundary is reused for metadata, exact-version pack and install calls. +The owned staging directory is canonicalized immediately after creation, so symlinked temporary roots +and macOS's /var alias are supported without accepting a linked archive file. `src/codex/cli-update-artifacts.ts` streams compressed integrity and tar validation with independent 256 MiB compressed, 512 MiB total unpacked, 384 MiB member and 4,096 member bounds. Only small manifest/entrypoint bytes are retained; native and auxiliary members retain size/mode/hash metadata. @@ -84,6 +86,9 @@ refuses; cancellation during an admitted npm child waits for its close rather th and staging remain held while the installer is live or uncertain. There is no automatic retry or rollback. `src/codex/cli-update-files.ts` compares the complete bounded regular-file sets after installation, including auxiliary members and executable status; original-state readback also compares original modes. +The preimage covers selected roots and every planned main/backing mutation root, recording both full +existing file sets and absent roots. A newly created sibling backing cannot be called unchanged merely +because the previously selected nested backing and main package still match. Required launcher/native paths must also pass the current user's X_OK check; another user's executable bit alone is insufficient. After file/selection readback, the selected launcher runs with `--version` in an owned home/temp sandbox, with 16 KiB output and an eight-second deadline. Normal exit and the exact target version are required. diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts index 9c9967a728..c8e0742e32 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -479,9 +479,15 @@ const result = await handleCodexCliUpdateCommand(process.argv.slice(4), { requireEvidence(equal(captureCodexCliTransportEnv(options.env)), "npm transport mismatch"); requireEvidence(options.env.PATH === ${JSON.stringify(tools)}, "npm PATH mismatch"); queries++; - return { status: 0, stdout: args.includes("version") ? "1.1.0" - : args.includes("dist.integrity") ? "sha512-" + Buffer.alloc(64, 7).toString("base64") - : "https://registry.npmjs.org/@openai/codex/-/codex-1.1.0.tgz", stderr: "" }; + requireEvidence(args[0] === "view" && args.includes("--json"), "exact artifact metadata query missing"); + const requested = args[1].slice(args[1].lastIndexOf("@") + 1); + const version = requested === "latest" ? "1.1.0" : requested; + return { status: 0, stdout: JSON.stringify({ name: "@openai/codex", version, + optionalDependencies: version === "1.1.0" ? Object.fromEntries( + ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"] + .map(suffix => ["@openai/codex-" + suffix, "npm:@openai/codex@1.1.0-" + suffix])) : undefined, + dist: { integrity: "sha512-" + Buffer.alloc(64, 7).toString("base64"), + tarball: "https://registry.npmjs.org/@openai/codex/-/codex-" + version + ".tgz" } }), stderr: "" }; }, }), }); diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index 2fae255c3b..e5ce182b1c 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyCodexCliUpdate, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; @@ -10,6 +10,7 @@ import { createCodexCliApplyFixture } from "../helpers/codex-cli-apply-fixture"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; +import { observeCodexCliFileSet, sameCodexCliFiles } from "../../src/codex/cli-update-files"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); @@ -56,6 +57,20 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { expect(calls[0]!.join(" ")).not.toContain("@latest"); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); }, 120_000); + test("published CLI packs and applies through a symlinked TMPDIR using canonical staging", () => { + const f = fixture(); const alias = join(f.root, "tmp-alias"); + symlinkSync(f.temporaryRoot, alias, "dir"); f.env.TMPDIR = alias; + const plan = cli(f, ["plan"]); expect(plan.applicable).toBe(true); + const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); + expect(result.outcome).toBe("applied"); expect(result.installedVersion).toBe("1.1.0"); + expect(result.leaseRetained).toBe(false); + const calls = readFileSync(f.trace, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); + expect(calls).toHaveLength(1); + const archives = calls[0]!.filter(arg => arg.includes("@file:")).map(arg => arg.split("@file:")[1]!); + expect(archives).toHaveLength(2); + for (const archive of archives) { expect(archive.startsWith(f.temporaryRoot + "/")).toBe(true); expect(archive.startsWith(alias + "/")).toBe(false); } + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); test.each(["stale-content", "same-version-integrity", "active", "unknown", "archive-tamper", "missing-backing"] as const)( "%s refuses before installer admission", async scenario => { @@ -115,6 +130,28 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => ({ terminal: true, exitCode: 0 }) })); expect(result.outcome).toBe("not_applied"); expect(result.installerExitCode).toBe(0); }, 120_000); + test("a newly installed sibling backing with unchanged nested-old main is ambiguous", async () => { + const f = fixture(); + const mainRoot = join(f.prefix, "lib", "node_modules", "@openai", "codex"); + const sibling = join(f.prefix, "lib", "node_modules", ...f.old.artifacts.backing.installName.split("/")); + const nestedScope = join(mainRoot, "node_modules", "@openai"); mkdirSync(nestedScope, { recursive: true }); + renameSync(sibling, join(nestedScope, f.old.artifacts.backing.installName.split("/")[1]!)); + const before = readFileSync(join(mainRoot, "package.json"), "utf8"); + const originalFiles = observeCodexCliFileSet([mainRoot]); + expect(existsSync(sibling)).toBe(false); + const id = await approved(f); let installs = 0; + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + installs++; + f.privateNpm(invocation.args.filter(arg => !arg.startsWith(f.target.artifacts.main.installName + "@file:"))); + return { terminal: true, exitCode: 17 }; + } })); + expect(installs).toBe(1); expect(result.outcome).toBe("ambiguous"); + expect(result.reason).toBe("installation_readback_unverified"); expect(result.leaseRetained).toBe(false); + expect(result.installedVersion).toBe("1.0.0"); + expect(sameCodexCliFiles(originalFiles, observeCodexCliFileSet([mainRoot]))).toBe(true); + expect(readFileSync(join(mainRoot, "package.json"), "utf8")).toBe(before); + expect(JSON.parse(readFileSync(join(sibling, "package.json"), "utf8")).version).toBe(f.target.artifacts.backing.version); + }, 120_000); test.each(["complete-nonzero", "missing-auxiliary", "changed-auxiliary", "main-only", "native-nonexecutable"] as const)( "%s is classified by complete readback, not installer exit", async scenario => { const f = fixture(); const id = await approved(f); diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index fd046a2d2b..6dc5972f5a 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -4,13 +4,13 @@ import { codexCliUpdatePlanId } from "../../src/codex/cli-update-plan"; import { fixtureArtifact, fixtureArtifactSet, fixtureTar } from "../helpers/codex-cli-apply-artifact"; import { applyCodexCliUpdate, verifyInstalledCodexVersion } from "../../src/codex/cli-update-apply"; import { parseCodexCliUpdateArgs, handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; -import { closeSync, ftruncateSync, mkdtempSync, openSync, readFileSync } from "node:fs"; +import { closeSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { gzipSync, gunzipSync } from "node:zlib"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createCodexCliApplyFixture } from "../helpers/codex-cli-apply-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { sameCodexCliFiles, type CodexCliFileSet } from "../../src/codex/cli-update-files"; +import { observeCodexCliPreimage, sameCodexCliPreimage, sameCodexCliFiles, type CodexCliFileSet } from "../../src/codex/cli-update-files"; describe("Codex CLI apply artifacts", () => { test("real npm installs main and platform aliases from local tarballs into a private prefix offline", () => { @@ -55,6 +55,22 @@ describe("Codex CLI apply artifacts", () => { const original: CodexCliFileSet = new Map([["data", { size: 1, digest: "same", executable: false, mode: 0o644 }]]); expect(sameCodexCliFiles(original, new Map([["data", { size: 1, digest: "same", executable: false, mode: 0o600 }]]))).toBe(false); }); + test("preimage tracks absent target roots in addition to the unchanged selected main/nested backing", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-preimage-"))); + try { + const main = join(root, "main"), nested = join(main, "node_modules", "backing"), target = join(root, "new-backing"); + mkdirSync(nested, { recursive: true }); writeFileSync(join(main, "package.json"), "old main\n"); + writeFileSync(join(nested, "native"), "old native\n"); + const roots = [main, nested, main, target]; + const before = observeCodexCliPreimage(roots); + expect(before.absentRoots).toEqual([target]); expect(before.presentRoots).toEqual([main, nested]); + expect(sameCodexCliPreimage(before, observeCodexCliPreimage(roots))).toBe(true); + mkdirSync(target); // Even an empty newly created root is a partial mutation. + expect(sameCodexCliPreimage(before, observeCodexCliPreimage(roots))).toBe(false); + writeFileSync(join(target, "native"), "new native\n"); + expect(sameCodexCliPreimage(before, observeCodexCliPreimage(roots))).toBe(false); + } finally { removeTreeWithRetry(root); } + }); test("same-version backing integrity drift changes the approved plan identity", () => { const f = fixtureArtifactSet(); const bound = { platform: "linux" as const, provenance: "npm-global" as const, installedVersion: "1.0.0", diff --git a/tests/helpers/codex-cli-apply-fixture.ts b/tests/helpers/codex-cli-apply-fixture.ts index 205ef7c691..7f839e59ab 100644 --- a/tests/helpers/codex-cli-apply-fixture.ts +++ b/tests/helpers/codex-cli-apply-fixture.ts @@ -21,6 +21,7 @@ export function privateNpm(root: string, args: string[]) { * This exercises published Node -> Bun CLI -> resolver/provenance -> plan/apply. */ export function createCodexCliApplyFixture(root: string, platform: NodeJS.Platform = process.platform, arch = process.arch, targetNativeSource?: string) { + root = realpathSync.native(root); const prefix = join(root, "prefix"), home = join(root, "home"), temporaryRoot = join(root, "tmp"), fakeBin = join(root, "bin"); for (const dir of [prefix, home, temporaryRoot, fakeBin]) mkdirSync(dir, { recursive: true }); const versions = ["1.0.0", "1.1.0"].map(version => { From 12b7c5966a672b3feb810e0462200ee295fe86c3 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 06:32:08 +0900 Subject: [PATCH 04/33] fix(codex): await archive descriptor release before readback (cherry picked from commit 25b698e87f4e406e7d92c69f9b124c7c5cb8ccf5) --- src/cli/codex-cli-update.ts | 3 +- src/codex/cli-update-apply.ts | 22 +++- src/codex/cli-update-artifacts.ts | 38 ++++-- src/codex/cli-update-lease.ts | 16 ++- structure/codex-cli-update.md | 6 + .../codex-cli-update-apply.test.ts | 48 +++++--- .../codex-cli-update-artifacts.test.ts | 116 +++++++++++++++++- .../codex-cli-update-plan.test.ts | 11 +- .../codex-cli-update-zero-effect.test.ts | 2 +- tests/helpers/codex-cli-apply-fixture.ts | 13 ++ 10 files changed, 229 insertions(+), 46 deletions(-) diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 7a46db8c41..e5a7395d0c 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -335,7 +335,8 @@ export async function handleCodexCliUpdateCommand( `installed-version: ${report.installedVersion ?? "unavailable"}`, `target-version: ${report.targetVersion ?? "unavailable"}`, `lease-retained: ${report.leaseRetained ? "yes" : "no"}`, - `verification-pid: ${report.verificationPid ?? "none"}`]); + `verification-pid: ${report.verificationPid ?? "none"}`, + `cleanup-errors: ${report.cleanupErrors.join(",") || "none"}`]); } }); return code; diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index c2609e996e..803358dee5 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -23,6 +23,20 @@ export interface CodexCliApplyResult { readonly installerExitCode: number | null; readonly leaseRetained: boolean; readonly verificationPid: number | null; + readonly cleanupErrors: readonly CodexCliApplyCleanupError[]; +} +export type CodexCliApplyCleanupError = "lease_release_unverified" | "lease_release_exception" | "staging_cleanup_failed"; +/** Every cleanup phase is attempted even if another fails. Preserve the + * computed readback outcome and expose path-free cleanup uncertainty separately. + */ +export function cleanupCodexCliApply(lease: ReturnType, staging?: string) { + const cleanupErrors: CodexCliApplyCleanupError[] = []; + let leaseRetained = false; + try { if (lease && !lease.release()) { leaseRetained = true; cleanupErrors.push("lease_release_unverified"); } } + catch { leaseRetained = true; cleanupErrors.push("lease_release_exception"); } + try { if (staging) rmSync(staging, { recursive: true }); } + catch { cleanupErrors.push("staging_cleanup_failed"); } + return Object.freeze({ leaseRetained, cleanupErrors: Object.freeze(cleanupErrors) }); } interface VersionResult { readonly terminal: boolean; readonly ok: boolean; readonly pid: number | null } export interface CodexCliApplyDeps extends CodexCliUpdatePlanDeps { @@ -130,7 +144,7 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep let verificationPid: number | null = null; let output: CodexCliApplyResult | undefined; const result = (outcome: CodexCliApplyResult["outcome"], reason: string): CodexCliApplyResult => - output = { outcome, reason, planId, installedVersion, targetVersion, installerExitCode: exitCode, leaseRetained: keepLease, verificationPid }; + 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"); @@ -237,9 +251,9 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep return result(started ? "ambiguous" : "refused", started ? "installer_or_readback_error" : "preparation_failed"); } finally { if (!keepLease) { - if (lease && !lease.release() && output) (output as { leaseRetained: boolean }).leaseRetained = true; - if (staging) { try { rmSync(staging, { recursive: true }); } catch { /* owned residue; never retry install */ } } + const cleanup = cleanupCodexCliApply(lease, staging); + if (output) Object.assign(output, cleanup); } - if (output) Object.freeze(output); + if (output) { Object.freeze(output.cleanupErrors); Object.freeze(output); } } } diff --git a/src/codex/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts index 700bfacec5..7c660610a3 100644 --- a/src/codex/cli-update-artifacts.ts +++ b/src/codex/cli-update-artifacts.ts @@ -83,7 +83,16 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod try { before = fstatSync(fd); } catch (error) { closeSync(fd); throw error; } if (!before.isFile() || before.size > MAX_CODEX_ARCHIVE_BYTES) { closeSync(fd); throw new Error("artifact_file_unverified"); } } - const input = typeof source === "string" ? createReadStream(source, { fd, autoClose: false, highWaterMark: 64 * 1024 }) : Readable.from([source]); + let input: Readable; + try { + input = typeof source === "string" ? createReadStream(source, { fd, autoClose: false, emitClose: true, highWaterMark: 64 * 1024 }) : Readable.from([source]); + } catch (error) { if (fd !== undefined) closeSync(fd); throw error; } + // The stream exclusively owns fd after construction. In Bun destroy closes + // even with autoClose:false; that option keeps the fd held through our final + // identity check, not through an explicit destroy. Never close the raw fd too. + let inputError: Error | undefined; + input.on("error", error => { inputError = error; }); + const inputClosed = new Promise(resolve => input.once("close", resolve)); const archiveHash = createHash("sha512"); let compressed = 0, unpacked = 0, count = 0, headerSize = 0, padding = 0, trailer = 0; let ended = false; @@ -92,15 +101,17 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod const names = new Set(); let current: { name: string; size: number; mode: number; remaining: number; keep: boolean; chunks: Buffer[]; hash: ReturnType } | undefined; - const tracker = new Transform({ transform(chunk: Buffer, _encoding, callback) { - compressed += chunk.length; - if (compressed > MAX_CODEX_ARCHIVE_BYTES) return callback(new Error("artifact_compressed_limit")); - archiveHash.update(chunk); callback(null, chunk); - } }); - const gunzip = createGunzip({ chunkSize: 64 * 1024 }); - const pumping = pipeline(input, tracker, gunzip); - void pumping.catch(() => {}); + let tracker: Transform | undefined, gunzip: ReturnType | undefined, pumping: Promise | undefined; + let verified = false; try { + tracker = new Transform({ transform(chunk: Buffer, _encoding, callback) { + compressed += chunk.length; + if (compressed > MAX_CODEX_ARCHIVE_BYTES) return callback(new Error("artifact_compressed_limit")); + archiveHash.update(chunk); callback(null, chunk); + } }); + gunzip = createGunzip({ chunkSize: 64 * 1024 }); + pumping = pipeline(input, tracker, gunzip); + void pumping.catch(() => {}); for await (const data of gunzip) { const chunk = Buffer.from(data); unpacked += chunk.length; @@ -167,10 +178,13 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || lexical.isSymbolicLink() || lexical.dev !== before.dev || lexical.ino !== before.ino) throw new Error("artifact_file_changed"); } + verified = true; } finally { - input.destroy(); tracker.destroy(); gunzip.destroy(); - await pumping.catch(() => {}); - if (fd !== undefined) closeSync(fd); + input.destroy(); tracker?.destroy(); gunzip?.destroy(); + await pumping?.catch(() => {}); + // Reawaiting the pipeline alone does not wait for ReadStream's async close. + await inputClosed; + if (verified && inputError) throw new Error("artifact_close_unverified"); } const manifestBytes = files.get("package.json")?.bytes; if (!manifestBytes || manifestBytes.length > 256 * 1024 || files.has("npm-shrinkwrap.json") || files.has("package-lock.json")) throw new Error("artifact_manifest_unverified"); diff --git a/src/codex/cli-update-lease.ts b/src/codex/cli-update-lease.ts index 6f4558067f..b88e06ef63 100644 --- a/src/codex/cli-update-lease.ts +++ b/src/codex/cli-update-lease.ts @@ -13,6 +13,7 @@ export function acquireCodexCliApplyLease(prefix: string): { release(): boolean const record = JSON.stringify({ version: 1, pid: process.pid, token: randomUUID() }); const release = () => { let current: number | undefined; + let released = false; try { if (!lockIdentity || !ownerIdentity) return false; const directory = lstatSync(path); @@ -28,9 +29,12 @@ export function acquireCodexCliApplyLease(prefix: string): { release(): boolean unlinkSync(owner); rmdirSync(path); lockIdentity = undefined; - return true; + released = true; } catch { return false; } - finally { if (current !== undefined) closeSync(current); } + finally { + if (current !== undefined) { try { closeSync(current); } catch { released = false; } } + } + return released; }; try { const root = lstatSync(prefix); @@ -41,11 +45,15 @@ export function acquireCodexCliApplyLease(prefix: string): { release(): boolean fd = openSync(owner, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); ownerIdentity = fstatSync(fd); writeFileSync(fd, record); - closeSync(fd); + const completed = fd; fd = undefined; + closeSync(completed); return { release }; } catch { - if (fd !== undefined) closeSync(fd); + if (fd !== undefined) { + const owned = fd; fd = undefined; + try { closeSync(owned); } catch { return null; } + } // Only a positively identified owner is eligible for cleanup. Empty or // suspicious directories intentionally remain busy for operator inspection. if (ownerIdentity) release(); diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 03337f34ad..c2f868d7fa 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -77,6 +77,9 @@ and macOS's /var alias are supported without accepting a linked archive file. 256 MiB compressed, 512 MiB total unpacked, 384 MiB member and 4,096 member bounds. Only small manifest/entrypoint bytes are retained; native and auxiliary members retain size/mode/hash metadata. Links, traversal, unsupported tar records, dependency-bearing manifests and missing backing files refuse. +After ReadStream construction it exclusively owns the archive descriptor. The held-fd identity check +precedes explicit stream destruction, and verification waits for the close event on success and failure; +pipeline settlement alone is not close completion and the caller never closes that raw fd again. After artifact preparation, selection/content, the approved metadata and active/unknown process blockers are re-observed. npm receives only two verified local tarball specs and an explicit canonical prefix, @@ -96,4 +99,7 @@ Only this verification's new POSIX process group may be terminated on a deadline exit retains lease/staging and reports its PID with an ambiguous result. Existing sessions are never signalled. Verified target readback and execution are `applied` even after nonzero installer exit; unchanged full original evidence is `not_applied` even after exit zero. Partial, unreadable or uncertain results are `ambiguous`. +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. Eligible shim recovery, management/status and dashboard apply are separate work. diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index e5ce182b1c..c1492063c0 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { applyCodexCliUpdate, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; -import { createCodexCliApplyFixture } from "../helpers/codex-cli-apply-fixture"; +import { createCodexCliApplyFixture, waitForApplyAdmission } from "../helpers/codex-cli-apply-fixture"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; @@ -29,7 +29,7 @@ function depsFor(f: ReturnType, overrides: Partial, deps = depsFor(f)) { const plan = await createCodexCliUpdatePlan(deps); - expect(plan.applicable).toBe(true); expect(plan.planId).toMatch(/^[0-9a-f]{32}$/); + expect(plan.applicable, JSON.stringify(plan)).toBe(true); expect(plan.planId).toMatch(/^[0-9a-f]{32}$/); return plan.planId!; } function cli(f: ReturnType, args: string[]) { @@ -59,7 +59,8 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { }, 120_000); test("published CLI packs and applies through a symlinked TMPDIR using canonical staging", () => { const f = fixture(); const alias = join(f.root, "tmp-alias"); - symlinkSync(f.temporaryRoot, alias, "dir"); f.env.TMPDIR = alias; + symlinkSync(f.temporaryRoot, alias, "dir"); + Object.assign(f.env, { TMPDIR: alias, NODE_COMPILE_CACHE: join(f.root, "node-compile-cache") }); const plan = cli(f, ["plan"]); expect(plan.applicable).toBe(true); const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); expect(result.outcome).toBe("applied"); expect(result.installedVersion).toBe("1.1.0"); @@ -128,7 +129,7 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { test("exit zero with original full files is not_applied", async () => { const f = fixture(); const id = await approved(f); const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => ({ terminal: true, exitCode: 0 }) })); - expect(result.outcome).toBe("not_applied"); expect(result.installerExitCode).toBe(0); + expect(result.outcome, JSON.stringify(result)).toBe("not_applied"); expect(result.installerExitCode).toBe(0); }, 120_000); test("a newly installed sibling backing with unchanged nested-old main is ambiguous", async () => { const f = fixture(); @@ -165,7 +166,7 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { "vendor", codexCliPlatformBacking(process.platform, process.arch)!.triple, "bin/codex"), 0o644); return { terminal: true, exitCode: scenario === "complete-nonzero" ? 17 : 0 }; } })); - expect(result.outcome).toBe(scenario === "complete-nonzero" ? "applied" : "ambiguous"); + expect(result.outcome, JSON.stringify(result)).toBe(scenario === "complete-nonzero" ? "applied" : "ambiguous"); expect(result.leaseRetained).toBe(false); }, 120_000, ); @@ -184,7 +185,7 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { f.privateNpm([...invocation.args]); return { terminal: true, exitCode: 0 }; } })); - expect(result.outcome).toBe("ambiguous"); expect(result.reason).toBe("version_readback_unverified"); + expect(result.outcome, JSON.stringify(result)).toBe("ambiguous"); expect(result.reason, JSON.stringify(result)).toBe("version_readback_unverified"); expect(result.leaseRetained).toBe(false); expect(result.verificationPid).toBeNull(); }, 120_000); test("uncertain owned version child retains lease/staging and exposes its PID", async () => { @@ -216,10 +217,12 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { const first = applyCodexCliUpdate(id, depsFor(f, { fetchArtifact: async artifact => { entered(); await pending; return artifact.version === "1.1.0" ? f.target.main.bytes : f.target.backing.bytes; }, install: async () => ({ terminal: false, exitCode: null }) })); - await admitted; - const other = await applyCodexCliUpdate(id, depsFor(f)); - expect(other.reason).toBe("installation_busy"); - resume(); const result = await first; + try { + await waitForApplyAdmission(admitted, first); + const other = await applyCodexCliUpdate(id, depsFor(f)); + expect(other.reason, JSON.stringify(other)).toBe("installation_busy"); + } finally { resume(); await first.catch(() => {}); } + const result = await first; expect(result.outcome).toBe("ambiguous"); expect(result.leaseRetained).toBe(true); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); expect(acquireCodexCliApplyLease(f.prefix)).toBeNull(); @@ -243,11 +246,13 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { const running = applyCodexCliUpdate(id, depsFor(f, { signal: controller.signal, install: async () => { admitted(); await terminal; return { terminal: true, exitCode: 0 }; } })).then(result => { done = true; return result; }); - await started; controller.abort(); await Promise.resolve(); - expect(done).toBe(false); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); - expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); - close(); const result = await running; - expect(result.outcome).toBe("not_applied"); expect(result.leaseRetained).toBe(false); + try { + await waitForApplyAdmission(started, running); controller.abort(); await Promise.resolve(); + expect(done).toBe(false); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + } finally { close(); await running.catch(() => {}); } + const result = await running; + expect(result.outcome, JSON.stringify(result)).toBe("not_applied"); expect(result.leaseRetained).toBe(false); expect(readdirSync(f.temporaryRoot)).toEqual([]); }, 120_000); test.each(["cancel", "error"] as const)("%s during preparation releases only its own lease", async kind => { @@ -261,4 +266,17 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); expect(readdirSync(f.temporaryRoot)).toEqual([]); }, 120_000); + test("lease cleanup failure preserves the original outcome and still removes staging", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { + acquireLease: prefix => { + const owned = acquireCodexCliApplyLease(prefix); if (!owned) return null; + return { release: () => { owned.release(); throw new Error("injected cleanup failure"); } }; + }, + install: async () => ({ terminal: true, exitCode: 0 }), + })); + expect(result.outcome, JSON.stringify(result)).toBe("not_applied"); + expect(result.cleanupErrors).toEqual(["lease_release_exception"]); + expect(result.leaseRetained).toBe(true); expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); }); diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index 6dc5972f5a..0864eb78c4 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -1,14 +1,16 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { codexCliArtifactSet, codexCliArtifactSetDigest, MAX_CODEX_ARCHIVE_BYTES, MAX_CODEX_MEMBER_BYTES, verifyCodexCliArchive, verifiedCodexCliContent } from "../../src/codex/cli-update-artifacts"; import { codexCliUpdatePlanId } from "../../src/codex/cli-update-plan"; import { fixtureArtifact, fixtureArtifactSet, fixtureTar } from "../helpers/codex-cli-apply-artifact"; -import { applyCodexCliUpdate, verifyInstalledCodexVersion } from "../../src/codex/cli-update-apply"; +import { applyCodexCliUpdate, cleanupCodexCliApply, verifyInstalledCodexVersion } from "../../src/codex/cli-update-apply"; +import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; import { parseCodexCliUpdateArgs, handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; import { closeSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { gzipSync, gunzipSync } from "node:zlib"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createCodexCliApplyFixture } from "../helpers/codex-cli-apply-fixture"; +import { createCodexCliApplyFixture, waitForApplyAdmission } from "../helpers/codex-cli-apply-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { observeCodexCliPreimage, sameCodexCliPreimage, sameCodexCliFiles, type CodexCliFileSet } from "../../src/codex/cli-update-files"; @@ -85,6 +87,81 @@ describe("Codex CLI apply artifacts", () => { const f = fixtureArtifactSet(); const changed = Buffer.from(f.main); changed[changed.length - 1] ^= 1; await expect(verifyCodexCliArchive(changed, f.artifacts.main)).rejects.toThrow(); }); + test.each(["valid", "corrupt"] as const)("real-file archive %s closes its stream before return and preserves a reused sentinel fd", async kind => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-archive-lifetime-"))); + const f = fixtureArtifactSet(), path = join(root, "artifact.tgz"), sentinelPath = join(root, "sentinel"); + writeFileSync(path, kind === "valid" ? f.main : Buffer.from("not gzip")); writeFileSync(sentinelPath, "sentinel"); + const create = fs.createReadStream; + let captured: { fd: number; closed: boolean; heldAtEnd: boolean; close: Promise } | undefined; + const spy = spyOn(fs, "createReadStream").mockImplementation((...args) => { + const stream = create(...args); + const observed = { fd: Number((args[1] as { fd: number }).fd), closed: false, heldAtEnd: false, close: Promise.resolve() }; + observed.close = new Promise(resolve => stream.once("close", () => { observed.closed = true; resolve(); })); + stream.once("end", () => { try { observed.heldAtEnd = fs.fstatSync(observed.fd).isFile(); } catch { /* assertion below */ } }); + captured = observed; + return stream; + }); + let sentinel: number | undefined; + let sentinelIdentity: { dev: number; ino: number } | undefined; + try { + const verified = await verifyCodexCliArchive(path, f.artifacts.main).then(() => true, () => false); + const observed = captured!; + expect(observed).toBeDefined(); + const closedAtReturn = observed.closed; + // A stale asynchronous archive close used to target this immediately reused fd. + sentinel = openSync(sentinelPath, "r"); + sentinelIdentity = fs.fstatSync(sentinel); + await observed.close; + await new Promise(resolve => setImmediate(resolve)); + let sentinelAlive = false; + try { + const after = fs.fstatSync(sentinel), bytes = Buffer.alloc(8); + sentinelAlive = after.dev === sentinelIdentity.dev && after.ino === sentinelIdentity.ino + && fs.readSync(sentinel, bytes, 0, 8, 0) === 8 && bytes.toString() === "sentinel"; + } catch { /* assertion below */ } + expect({ kind, verified, closedAtReturn, sentinelAlive, sentinelReused: sentinel === observed.fd }) + .toEqual({ kind, verified: kind === "valid", closedAtReturn: true, sentinelAlive: true, sentinelReused: true }); + if (kind === "valid") expect(observed.heldAtEnd).toBe(true); + } finally { + spy.mockRestore(); + if (captured) await captured.close; + if (sentinel !== undefined) { + try { + const owned = fs.fstatSync(sentinel); + if (owned.dev === sentinelIdentity?.dev && owned.ino === sentinelIdentity?.ino) closeSync(sentinel); + } catch { /* old implementation may have closed the sentinel; no unknown fd is closed */ } + } + removeTreeWithRetry(root); + } + }); + test.skipIf(process.platform === "win32")("real-file archive closure precedes repeated lease acquire/release without touching a sentinel fd", async () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-archive-lease-"))); + const f = fixtureArtifactSet(), path = join(root, "artifact.tgz"); writeFileSync(path, f.main); + const sentinel = openSync(join(root, "sentinel"), "wx+"); + try { + fs.writeSync(sentinel, "sentinel"); + for (let index = 0; index < 12; index++) { + await verifyCodexCliArchive(path, f.artifacts.main); + const lease = acquireCodexCliApplyLease(root); expect(lease).not.toBeNull(); + expect(lease!.release()).toBe(true); + await new Promise(resolve => setImmediate(resolve)); + expect(fs.fstatSync(sentinel).size).toBe(8); + } + } finally { closeSync(sentinel); removeTreeWithRetry(root); } + }); + test.skipIf(process.platform === "win32")("lease descriptor-close failure is structured and still permits staging cleanup", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-lease-close-failure-"))); + const stage = join(root, "stage"); mkdirSync(stage); + const lease = acquireCodexCliApplyLease(root); expect(lease).not.toBeNull(); + const close = fs.closeSync; + const spy = spyOn(fs, "closeSync").mockImplementation(fd => { + close(fd); throw Object.assign(new Error("injected post-close failure"), { code: "EBADF" }); + }); + try { + expect(cleanupCodexCliApply(lease, stage)).toEqual({ leaseRetained: true, cleanupErrors: ["lease_release_unverified"] }); + expect(fs.existsSync(stage)).toBe(false); + } finally { spy.mockRestore(); removeTreeWithRetry(root); } + }); test("an oversized tar member refuses from its header before reading or allocating its body", async () => { const tar = gunzipSync(fixtureTar({ "package.json": "{}" })); tar.write((MAX_CODEX_MEMBER_BYTES + 1).toString(8).padStart(11, "0") + "\0", 124, 12); @@ -118,6 +195,37 @@ describe("Codex CLI apply artifacts", () => { }); }); describe("Codex CLI apply consumer", () => { + test("admission observes early outcomes and rejections without waiting for an unentered installer", async () => { + const never = new Promise(() => {}); + const result = { outcome: "refused" as const, reason: "installation_busy", planId: "a".repeat(32), installedVersion: null, + targetVersion: null, installerExitCode: 17, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; + await expect(waitForApplyAdmission(never, Promise.resolve(result))).rejects.toThrow("outcome=refused reason=installation_busy installerExitCode=17"); + await expect(waitForApplyAdmission(never, Promise.reject(new TypeError("fixture rejection")))).rejects.toThrow("apply rejected before admission: TypeError"); + }); + test("a lease cleanup exception is structured and cannot prevent staging cleanup", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-cleanup-"))); + const stage = join(root, "stage"); mkdirSync(stage); writeFileSync(join(stage, "artifact"), "owned fixture"); + try { + const cleanup = cleanupCodexCliApply({ release: () => { throw new Error("private native error"); } }, stage); + expect(cleanup).toEqual({ leaseRetained: true, cleanupErrors: ["lease_release_exception"] }); + expect(fs.existsSync(stage)).toBe(false); + expect(Object.isFrozen(cleanup.cleanupErrors)).toBe(true); + } finally { removeTreeWithRetry(root); } + }); + test("lease and staging cleanup failures are both retained without native error text", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-cleanup-"))); + const stage = join(root, "stage"); mkdirSync(stage); + const remove = fs.rmSync; + const spy = spyOn(fs, "rmSync").mockImplementation((path, options) => { + if (path === stage) throw new Error("private stage path"); return remove(path, options); + }); + try { + expect(cleanupCodexCliApply({ release: () => false }, stage)).toEqual({ + leaseRetained: true, cleanupErrors: ["lease_release_unverified", "staging_cleanup_failed"], + }); + expect(fs.existsSync(stage)).toBe(true); + } finally { spy.mockRestore(); removeTreeWithRetry(root); } + }); test("the owned version probe waits for terminal exit and rejects unrelated version output", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-apply-version-")); try { @@ -142,7 +250,7 @@ describe("Codex CLI apply consumer", () => { test("confirmed apply reaches its consumer with the exact plan ID and prints its public result", async () => { const planId = "a".repeat(32); let calls = 0; const logs: string[] = [], log = console.log; const report = { outcome: "not_applied" as const, reason: "original_install_unchanged", planId, - installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null }; + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; try { console.log = value => { logs.push(String(value)); }; expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", planId, "--yes", "--json"], { diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index ee54184784..d477d0ef7f 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -448,9 +448,10 @@ describe("registry configuration isolation", () => { const field = queriedField(call); if (argvLine(call).includes("--json")) { const version = (outputs.version ?? "").trim(); - const backing = argvLine(call).includes("@openai/codex-linux-x64@"); - return { status: 0, stdout: JSON.stringify({ name: backing ? "@openai/codex-linux-x64" : "@openai/codex", version, - optionalDependencies: backing ? undefined : { "@openai/codex-linux-x64": version }, + const backing = argvLine(call).match(/@openai\/codex-(?:linux|darwin)-(?:x64|arm64)(?=@)/)?.[0]; + return { status: 0, stdout: JSON.stringify({ name: backing ?? "@openai/codex", version, + optionalDependencies: backing ? undefined : Object.fromEntries(["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"] + .map(suffix => [`@openai/codex-${suffix}`, version])), dist: { integrity: (outputs["dist.integrity"] ?? "").trim(), tarball: (outputs["dist.tarball"] ?? "").trim() } }), stderr: "" }; } return { status: 0, stdout: outputs[field] ?? "", stderr: "" }; @@ -581,14 +582,14 @@ describe("registry configuration isolation", () => { } }); - test("the plan resolves registry evidence through the attested launcher environment", async () => { + test.each(["x64", "arm64"] as const)("the plan resolves registry evidence through the attested %s launcher environment", async arch => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const boundEnv = markerBoundEnv(); const plan = await applicablePlan({ // Dropping the resolveTarget stub exercises the real resolver wiring: the // provenance snapshot env must reach npm's spawn options. resolveTarget: undefined, - inspectionDeps: { env: boundEnv }, + inspectionDeps: { env: boundEnv, arch }, spawnProcess: spawn, }); expect(plan.targetVersion).toBe("1.4.2"); diff --git a/tests/codex-integration/codex-cli-update-zero-effect.test.ts b/tests/codex-integration/codex-cli-update-zero-effect.test.ts index d985ea3144..3b339f7311 100644 --- a/tests/codex-integration/codex-cli-update-zero-effect.test.ts +++ b/tests/codex-integration/codex-cli-update-zero-effect.test.ts @@ -203,7 +203,7 @@ describe("Codex CLI updater zero-effect boundary", () => { }); expect(result.error).toBeUndefined(); expect(result.status).toBe(2); - expect(result.stderr).toContain("codex-cli-update action must be check, attest or plan"); + expect(result.stderr).toContain("codex-cli-update action must be check, attest, plan or apply"); expect(readFileSync(statePath)).toEqual(before); expect(existsSync(marker)).toBe(false); }); diff --git a/tests/helpers/codex-cli-apply-fixture.ts b/tests/helpers/codex-cli-apply-fixture.ts index 7f839e59ab..b54f75a503 100644 --- a/tests/helpers/codex-cli-apply-fixture.ts +++ b/tests/helpers/codex-cli-apply-fixture.ts @@ -3,6 +3,19 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFil import { dirname, join } from "node:path"; import { createNpmConfigIsolation, isolatedNpmTarget } from "../../src/codex/cli-update-plan"; import { fixtureArtifact, fixtureArtifactSet } from "./codex-cli-apply-artifact"; +import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; + +/** Admission waits must also observe an early refusal/rejection. Otherwise a + * diagnostic failure before install/fetch entry becomes a misleading timeout. + */ +export async function waitForApplyAdmission(admitted: Promise, running: Promise): Promise { + const first = await Promise.race([ + admitted.then(() => ({ kind: "admitted" as const })), + running.then(result => ({ kind: "early" as const, result }), error => ({ kind: "rejected" as const, code: error?.code ?? error?.name ?? "unknown" })), + ]); + if (first.kind === "early") throw new Error(`apply ended before admission: outcome=${first.result.outcome} reason=${first.result.reason} installerExitCode=${first.result.installerExitCode}`); + if (first.kind === "rejected") throw new Error(`apply rejected before admission: ${first.code}`); +} /** All npm config/cache/prefix/temp effects belong to root; no network or scripts. */ export function privateNpm(root: string, args: string[]) { From 09bf422132d09fa54ed9fe29249412b871a49265 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 07:12:35 +0900 Subject: [PATCH 05/33] feat(codex): stage durable CLI update job management (cherry picked from commit be9df8a233c4ba7994ba98ec5a954940e93b7a58) --- .../ocx/references/01_management_surface.md | 14 +- .../references/01_surface_observe-system.md | 41 ++- src/cli/capabilities-base.ts | 18 +- src/cli/codex-cli-update.ts | 96 ++++--- src/cli/registry.ts | 4 +- src/cli/system-command.ts | 2 + src/codex/cli-update-inspection-context.ts | 23 ++ src/codex/cli-update-job.ts | 234 ++++++++++++++++++ src/server/management-api.ts | 2 + .../management/codex-cli-update-routes.ts | 54 ++++ src/server/management/route-registry.ts | 4 + structure/INDEX.md | 3 + structure/cli-management.md | 2 + structure/codex-cli-update.md | 6 +- structure/gui-and-management-api.md | 2 +- structure/manifest.json | 5 +- tests/cli/cli-codex-cli-update.test.ts | 77 ++++++ .../server/management-route-registry.test.ts | 94 ++++++- 18 files changed, 617 insertions(+), 64 deletions(-) create mode 100644 src/codex/cli-update-inspection-context.ts create mode 100644 src/codex/cli-update-job.ts create mode 100644 src/server/management/codex-cli-update-routes.ts diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 29b7b2b747..cada79b9a6 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -30,7 +30,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [accounts](01_surface_accounts.md) | 42 | | [agents-routing](01_surface_agents-routing.md) | 67 | | [integrations](01_surface_integrations.md) | 41 | -| [observe-system](01_surface_observe-system.md) | 96 | +| [observe-system](01_surface_observe-system.md) | 98 | | [access-remote](01_surface_access-remote.md) | 33 | | [lab](01_surface_lab.md) | 21 | @@ -279,6 +279,14 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_observe-system.md#ocx-system-codex-cli-update-plan) +### `ocx system codex-cli-update apply` + +[State-changing task](01_surface_observe-system.md#ocx-system-codex-cli-update-apply) + +### `ocx system codex-cli-update status` + +[Read-oriented task](01_surface_observe-system.md#ocx-system-codex-cli-update-status) + ### `ocx system codex-restart` [State-changing task](01_surface_observe-system.md#ocx-system-codex-restart) @@ -1497,6 +1505,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 362 -- of those, state-changing: 221 +- declared capabilities: 364 +- of those, state-changing: 222 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index 7407f5bb74..c47a8d4f32 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 96. +Declared capabilities: 98. ### `ocx companion` @@ -395,9 +395,46 @@ Drives no management route. - Adds the three inputs check leaves out: an exact registry version with its sha512 integrity, a fail-closed process-table read, and a decision. - The registry evidence is pinned to the official npm registry with project/user npm configuration isolated, so a redirected .npmrc cannot supply the answer. - Installs nothing and mutates no application state; registry evidence is gathered under an isolated temporary npm root (npmrc, cwd, cache and logs) removed best-effort afterwards. A refusal is a normal dry-run answer and still exits 0. -- The plan id is a digest of the evidence the decision rests on — ownership, the installed version, a digest of the canonical install root, the resolved target — not a stored job. There is no plan state on disk to expire, collide or clean up. +- The plan id is a digest of the evidence; apply re-observes it before any mutation. +- Apply durably admits a bounded, same-user job before running; request IDs deduplicate retries and a conflicting payload is rejected. Unresolved records are retained rather than expired. - An unreadable process table refuses rather than reading as no live session. +### `ocx system codex-cli-update apply` + +Usage: `ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]` + +Apply a confirmed Codex CLI update as a durable job. + +State-changing: yes. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--plan-id` | string | Required 32-hex id from the current plan. | +| `--yes` | boolean | Required confirmation. | +| `--request-id` | string | Optional stable retry key. | +| `--json` | boolean | Emit the public job status. | + +- Admission is stored under the protected current-user config root before the installer can run. +- Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused. + +### `ocx system codex-cli-update status` + +Usage: `ocx system codex-cli-update status [--json]` + +Read a Codex CLI update job by opaque id. + +State-changing: no. JSON mode: `envelope`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the path-free job status. | + +- Reads the protected job record directly; the job id is not an authorization credential. + ### `ocx system codex-restart` Usage: `ocx system codex-restart --yes [--json]` diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 3bda7c1e29..1d0cb1df09 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -605,11 +605,27 @@ export const CAPABILITIES: readonly Capability[] = [ "Adds the three inputs check leaves out: an exact registry version with its sha512 integrity, a fail-closed process-table read, and a decision.", "The registry evidence is pinned to the official npm registry with project/user npm configuration isolated, so a redirected .npmrc cannot supply the answer.", "Installs nothing and mutates no application state; registry evidence is gathered under an isolated temporary npm root (npmrc, cwd, cache and logs) removed best-effort afterwards. A refusal is a normal dry-run answer and still exits 0.", - "The plan id is a digest of the evidence the decision rests on — ownership, the installed version, a digest of the canonical install root, the resolved target — not a stored job. There is no plan state on disk to expire, collide or clean up.", + "The plan id is a digest of the evidence; apply re-observes it before any mutation.", + "Apply durably admits a bounded, same-user job before running; request IDs deduplicate retries and a conflicting payload is rejected. Unresolved records are retained rather than expired.", "An unreadable process table refuses rather than reading as no live session.", ], usage: "ocx system codex-cli-update plan [--channel latest] [--json]", }, + { + command: ["system", "codex-cli-update", "apply"], + summary: "Apply a confirmed Codex CLI update as a durable job.", + routes: [], + flags: [{ name: "--plan-id", value: "string", summary: "Required 32-hex id from the current plan." }, { name: "--yes", value: "boolean", summary: "Required confirmation." }, { name: "--request-id", value: "string", summary: "Optional stable retry key." }, { name: "--json", value: "boolean", summary: "Emit the public job status." }], + mutates: true, json: "envelope", + details: ["Admission is stored under the protected current-user config root before the installer can run.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], + usage: "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", + }, + { + command: ["system", "codex-cli-update", "status"], + summary: "Read a Codex CLI update job by opaque id.", routes: [], flags: [{ name: "--json", value: "boolean", summary: "Emit the path-free job status." }], + mutates: false, json: "envelope", details: ["Reads the protected job record directly; the job id is not an authorization credential."], + usage: "ocx system codex-cli-update status [--json]", + }, { command: ["system","codex-restart"], summary: "Restart the Codex app-servers, and the desktop app when its model picker predates the catalog.", diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index e5a7395d0c..d1b55d63dc 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -1,6 +1,5 @@ import { inspectCodexCliInstall, - observeCodexCliSelectedCommand, type CodexCliInstallProvenanceDeps, type CodexCliInstallReport, } from "../codex/cli-install-provenance"; @@ -23,20 +22,26 @@ import { type ResolveCodexRuntimeResult, } from "../codex/runtime"; import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api"; +import { codexCliUpdateInspectionDeps } from "../codex/cli-update-inspection-context"; +export { codexCliUpdateInspectionDeps } from "../codex/cli-update-inspection-context"; import { trustedNodeLauncherContext } from "./launcher-context"; import type { CodexCliApplyDeps, CodexCliApplyResult } from "../codex/cli-update-apply"; +import { randomUUID } from "node:crypto"; +import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../codex/cli-update-job"; export const CODEX_CLI_UPDATE_USAGE = `Usage: ocx system codex-cli-update check [--json] ocx system codex-cli-update attest [--json] ocx system codex-cli-update attest --candidate --npm-prefix --npm-cli --node [--json] ocx system codex-cli-update plan [--channel latest] [--json] - ocx system codex-cli-update apply --plan-id <32-hex> --yes [--json]`; + ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json] + ocx system codex-cli-update status [--json]`; export type ParsedCodexCliUpdateArgs = | Readonly<{ action: "check"; json: boolean }> | Readonly<{ action: "plan"; json: boolean; channel: CodexCliUpdateChannel }> - | Readonly<{ action: "apply"; json: boolean; planId: string }> + | Readonly<{ action: "apply"; json: boolean; planId: string; requestId: string }> + | Readonly<{ action: "status"; json: boolean; jobId: string }> | Readonly<{ json: boolean; attest: CodexCliInstallationIdentityInput | "selected" }>; export interface CodexCliUpdateCommandDeps { @@ -165,12 +170,14 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli }) }); } const action = rest[0]; - if (action !== "check" && action !== "plan" && action !== "apply") { - throw new CliUsageError("codex-cli-update action must be check, attest, plan or apply", CODEX_CLI_UPDATE_USAGE); + if (action !== "check" && action !== "plan" && action !== "apply" && action !== "status") { + throw new CliUsageError("codex-cli-update action must be check, attest, plan, apply or status", CODEX_CLI_UPDATE_USAGE); } if (action === "apply") { let planId: string | undefined; + let requestId: string = randomUUID(); + let seenRequestId = false; let yes = false; for (let index = 1; index < rest.length;) { const token = rest[index]!; @@ -181,12 +188,26 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli index = value.next; continue; } + if (isOption(token, "request-id") && !seenRequestId) { + const value = optionValue(rest, index, "request-id"); + requestId = value.value; seenRequestId = true; index = value.next; continue; + } throw new CliUsageError("unsupported or duplicate apply option", CODEX_CLI_UPDATE_USAGE); } if (!yes || !planId || !/^[0-9a-f]{32}$/.test(planId)) { throw new CliUsageError("apply requires --plan-id <32 lowercase hex> and --yes", CODEX_CLI_UPDATE_USAGE); } - return Object.freeze({ action, json, planId }); + if (!/^[A-Za-z0-9._:-]{8,128}$/.test(requestId)) { + throw new CliUsageError("--request-id must be 8-128 safe characters", CODEX_CLI_UPDATE_USAGE); + } + return Object.freeze({ action, json, planId, requestId }); + } + + if (action === "status") { + if (rest.length !== 2 || !/^[0-9a-f-]{36}$/.test(rest[1]!)) { + throw new CliUsageError("status requires one job id", CODEX_CLI_UPDATE_USAGE); + } + return Object.freeze({ action, json, jobId: rest[1]! }); } if (action === "check") { @@ -227,44 +248,6 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli * evidence, not selected-runtime admission. A direct Bun or source launch has no such * proof, so nothing ambient or persisted is inspected at all. */ -function inspectionDeps(commandDeps: CodexCliUpdateCommandDeps): CodexCliInstallProvenanceDeps { - const trusted = trustedNodeLauncherContext()?.codexCliInspectionEnv; - if (!trusted || trusted.managerRoots === null) return { env: { PATH: "" }, configDir: "." }; - // Attestation also requires the runtime resolver to pick the snapshot's - // candidate. probeVersion: false keeps this read-only — no codex --version - // executes on a check/plan path — while still refusing attestation when the - // resolver would settle on a different executable. - const selectedCommand = observeCodexCliSelectedCommand({ - env: { - CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, - PATH: trusted.path ?? undefined, - PATHEXT: trusted.pathExt ?? undefined, - }, - configDir: trusted.configDir, - }, commandDeps.resolveSelectedRuntime); - return { - env: { - ...trusted.managerRoots, - ...trusted.transportEnv, - CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, - PATH: trusted.path ?? undefined, - PATHEXT: trusted.pathExt ?? undefined, - // The lookup seeds the inspection models against the launch-time - // capture; ambient values are never consulted. - HOME: trusted.home ?? undefined, - NODE_PATH: trusted.nodePath ?? undefined, - }, - configDir: trusted.configDir, - // Content observation picks the platform backing by the Node launcher's - // arch, which can differ from this Bun process's under Rosetta. - arch: trusted.arch ?? undefined, - // The snapshot is proof-bound to the launcher, but the report may only attest - // the environment candidate when the resolver picks the same canonical path — - // managed requires both. - selectionAttested: true, - selectedCommand, - }; -} export async function handleCodexCliUpdateCommand( argv: readonly string[], @@ -315,14 +298,21 @@ export async function handleCodexCliUpdateCommand( return; } if (parsed.action === "check") { - const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps(deps)); + const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(codexCliUpdateInspectionDeps(deps)); printData(report, parsed.json, installSummary(report)); return; } + if (parsed.action === "status") { + const job = readCodexCliUpdateJob(parsed.jobId); + if (!job) { console.error("Codex CLI update job was not found or is unavailable."); return; } + printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, + `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); + return; + } if (parsed.action === "plan") { const plan = await (deps.createPlan ?? createCodexCliUpdatePlan)({ channel: parsed.channel, - inspectionDeps: inspectionDeps(deps), + inspectionDeps: codexCliUpdateInspectionDeps(deps), inspect: deps.inspectInstall, }); printData(plan, parsed.json, planSummary(plan)); @@ -330,13 +320,13 @@ export async function handleCodexCliUpdateCommand( } if (parsed.action === "apply") { const apply = deps.applyUpdate ?? (await import("../codex/cli-update-apply")).applyCodexCliUpdate; - const report = await apply(parsed.planId, { inspectionDeps: inspectionDeps(deps) }); - printData(report, parsed.json, [`outcome: ${report.outcome}`, `reason: ${report.reason}`, - `installed-version: ${report.installedVersion ?? "unavailable"}`, - `target-version: ${report.targetVersion ?? "unavailable"}`, - `lease-retained: ${report.leaseRetained ? "yes" : "no"}`, - `verification-pid: ${report.verificationPid ?? "none"}`, - `cleanup-errors: ${report.cleanupErrors.join(",") || "none"}`]); + const admitted = admitCodexCliUpdateJob({ requestId: parsed.requestId, planId: parsed.planId }); + const job = admitted.kind === "accepted" + ? await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => apply(parsed.planId, { inspectionDeps: codexCliUpdateInspectionDeps(deps) })) + : admitted.job; + printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, + `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); } }); return code; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 70290fdbe5..1f936908d1 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -547,7 +547,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "system", usage: "ocx system ...", - summary: "Manage headless runtime settings, health, startup, sync, diagnostics, OpenCodex updates, and read-only Codex CLI inspection and planning.", + summary: "Manage headless runtime settings, health, startup, sync, diagnostics, OpenCodex updates, and Codex CLI update plans and jobs.", details: [ "system update manages OpenCodex itself.", "system health reads authenticated service and spend-ledger diagnostics; root health remains liveness.", @@ -555,6 +555,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "ocx system codex-cli-update attest [--json]", "ocx system codex-cli-update attest --candidate --npm-prefix --npm-cli --node [--json]", "ocx system codex-cli-update plan [--channel latest] [--json]", + "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", + "ocx system codex-cli-update status [--json]", "check and attest make no package-registry request, do not execute Codex or npm, and write nothing. plan adds a registry query and a process-table read and mutates no application state (temporary isolated npm files only, removed best-effort).", ], }, diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index c109e93d8b..def126a39a 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -29,6 +29,8 @@ const USAGE = `Usage: ocx system codex-cli-update attest [--json] ocx system codex-cli-update attest --candidate --npm-prefix --npm-cli --node [--json] ocx system codex-cli-update plan [--channel latest] [--json] + ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json] + ocx system codex-cli-update status [--json] ocx system update check [--channel ] [--json] ocx system update run [--channel ] [--restart ] --yes [--json] ocx system update status [--json] diff --git a/src/codex/cli-update-inspection-context.ts b/src/codex/cli-update-inspection-context.ts new file mode 100644 index 0000000000..1f960142b6 --- /dev/null +++ b/src/codex/cli-update-inspection-context.ts @@ -0,0 +1,23 @@ +import type { CodexCliInstallProvenanceDeps } from "./cli-install-provenance"; +import { observeCodexCliSelectedCommand } from "./cli-install-provenance"; +import type { ResolveCodexRuntimeDeps, ResolveCodexRuntimeResult } from "./runtime"; +import { trustedNodeLauncherContext } from "../cli/launcher-context"; + +/** Build the updater's only production inspection context from the Node launcher's proof-bound snapshot. */ +export function codexCliUpdateInspectionDeps(deps: { + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; +} = {}): CodexCliInstallProvenanceDeps { + const trusted = trustedNodeLauncherContext()?.codexCliInspectionEnv; + if (!trusted || trusted.managerRoots === null) return { env: { PATH: "" }, configDir: "." }; + const selectedCommand = observeCodexCliSelectedCommand({ + env: { CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, PATH: trusted.path ?? undefined, PATHEXT: trusted.pathExt ?? undefined }, + configDir: trusted.configDir, + }, deps.resolveSelectedRuntime); + return { + env: { ...trusted.managerRoots, ...trusted.transportEnv, CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, + PATH: trusted.path ?? undefined, PATHEXT: trusted.pathExt ?? undefined, + HOME: trusted.home ?? undefined, NODE_PATH: trusted.nodePath ?? undefined }, + configDir: trusted.configDir, arch: trusted.arch ?? undefined, + selectionAttested: true, selectedCommand, + }; +} diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts new file mode 100644 index 0000000000..26eeedbbea --- /dev/null +++ b/src/codex/cli-update-job.ts @@ -0,0 +1,234 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync, closeSync, constants, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, + readFileSync, readSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { getConfigDir } from "../config"; +import type { CodexCliApplyResult } from "./cli-update-apply"; + +const MAX_BYTES = 256 * 1024; +const MAX_JOBS = 64; +const JOB_DIR = "codex-cli-update-jobs"; +const FILE = "jobs.json"; +const LOCK = "admission.lock"; +const SAFE_REASONS = new Set(["invalid_plan_id", "windows_inspection_deferred", "cancelled", "mutation_evidence_unverified", + "installation_prefix_changed", "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", + "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"]); +type JobStatus = "queued" | "running" | "applied" | "not_applied" | "refused" | "ambiguous"; + +interface StoredJob { + id: string; requestKey: string; payloadHash: string; generation: string; revision: number; + status: JobStatus; createdAt: string; updatedAt: string; planId: string; + outcome: CodexCliApplyResult["outcome"] | null; reason: string | null; + cleanupErrors: readonly string[]; leaseRetained: boolean; +} +interface Store { version: 1; jobs: StoredJob[] } +export interface CodexCliUpdateJobDto { + readonly jobId: string; readonly status: "queued" | "running" | "applied" | "not_applied" | "refused" | "ambiguous"; + readonly ownerState: "active" | "unknown" | "finished"; readonly lifetimeUncertain: boolean; + readonly childState: "not_started" | "active_or_not_observed" | "terminal" | "unknown"; readonly leaseRetained: boolean; + readonly createdAt: string; readonly updatedAt: string; readonly planId: string; + readonly outcome: CodexCliApplyResult["outcome"] | null; readonly reason: string | null; + readonly cleanupErrors: readonly string[]; +} + +const live = new Map(); +const started = new Set(); +const digest = (text: string) => createHash("sha256").update(text).digest("hex"); +const now = () => new Date().toISOString(); + +function paths(configDir = getConfigDir()) { + const config = resolve(configDir); + const dir = join(config, JOB_DIR); + return { config, dir, file: join(dir, FILE), lock: join(dir, LOCK) }; +} +function prepare(configDir?: string, create = false) { + const p = paths(configDir); + if (process.platform === "win32" || process.geteuid?.() === undefined) throw new Error("unsupported_store"); + const configStat = lstatSync(p.config); + if (!configStat.isDirectory() || configStat.isSymbolicLink() || realpathSync.native(p.config) !== p.config + || (configStat.mode & 0o022) !== 0) throw new Error("unsafe_store"); + if (create) { + if (configStat.uid !== process.geteuid()) throw new Error("unsupported_store"); + try { mkdirSync(p.dir, { mode: 0o700 }); chmodSync(p.dir, 0o700); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + } + const stat = lstatSync(p.dir); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== configStat.uid || (stat.mode & 0o077) !== 0 + || realpathSync.native(p.dir) !== p.dir) throw new Error("unsafe_store"); + return p; +} +function readStore(configDir?: string): Store { + let p: ReturnType; + try { p = prepare(configDir); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, jobs: [] }; throw error; } + let fd: number | undefined; + try { + fd = openSync(p.file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_BYTES || (stat.mode & 0o077) !== 0 + || stat.uid !== process.geteuid?.() || stat.nlink !== 1) throw new Error("unsafe_store"); + const lexical = lstatSync(p.file); + if (lexical.isSymbolicLink() || lexical.dev !== stat.dev || lexical.ino !== stat.ino) throw new Error("unsafe_store"); + const bytes = Buffer.alloc(MAX_BYTES + 1); + const length = readSync(fd, bytes, 0, bytes.length, 0); + if (length > MAX_BYTES) throw new Error("unsafe_store"); + const value = JSON.parse(bytes.subarray(0, length).toString("utf8")) as Store; + if (value.version !== 1 || !Array.isArray(value.jobs) || value.jobs.length > MAX_JOBS + || value.jobs.some(job => !job || typeof job !== "object" || !/^[0-9a-f-]{36}$/.test(job.id) + || !/^[0-9a-f]{64}$/.test(job.requestKey) || !/^[0-9a-f]{64}$/.test(job.payloadHash) + || !/^[0-9a-f-]{36}$/.test(job.generation) || !Number.isSafeInteger(job.revision) || job.revision < 0 + || !["queued", "running", "applied", "not_applied", "refused", "ambiguous"].includes(job.status) + || typeof job.planId !== "string" || !/^[0-9a-f]{32}$/.test(job.planId) + || (job.reason !== null && (typeof job.reason !== "string" || !SAFE_REASONS.has(job.reason))) + || !Array.isArray(job.cleanupErrors) || job.cleanupErrors.length > 8 + || job.cleanupErrors.some((item: unknown) => !["lease_release_unverified", "lease_release_exception", "staging_cleanup_failed"].includes(String(item))) + || typeof job.leaseRetained !== "boolean" + || typeof job.createdAt !== "string" || typeof job.updatedAt !== "string")) throw new Error("invalid_store"); + return value; + } finally { if (fd !== undefined) closeSync(fd); } +} +function withLock(configDir: string | undefined, fn: (p: ReturnType) => T): T { + const p = prepare(configDir, true); + let lockIdentity: { dev: number; ino: number } | undefined; + let ownerIdentity: { dev: number; ino: number } | undefined; + const owner = join(p.lock, "owner.json"); + const record = JSON.stringify({ version: 1, token: randomUUID() }); + try { + mkdirSync(p.lock, { mode: 0o700 }); + const lockStat = lstatSync(p.lock); + if (!lockStat.isDirectory() || lockStat.isSymbolicLink() || lockStat.uid !== process.geteuid?.() + || (lockStat.mode & 0o077) !== 0 || realpathSync.native(p.lock) !== p.lock) throw new Error("unsafe_store"); + lockIdentity = { dev: lockStat.dev, ino: lockStat.ino }; + const fd = openSync(owner, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { ownerIdentity = fstatSync(fd); writeFileSync(fd, record); fsyncSync(fd); } finally { closeSync(fd); } + return fn(p); + } finally { + if (lockIdentity && ownerIdentity) { + try { + const dir = lstatSync(p.lock); + const fd = openSync(owner, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd); + if (dir.dev === lockIdentity.dev && dir.ino === lockIdentity.ino && stat.isFile() + && stat.size <= 1024 && stat.uid === process.geteuid?.() && stat.nlink === 1 + && stat.dev === ownerIdentity.dev && stat.ino === ownerIdentity.ino + && readFileSync(fd, "utf8") === record) { + unlinkSync(owner); rmdirSync(p.lock); + } + } finally { closeSync(fd); } + } catch { /* Fail closed; a leftover lock blocks future admission. */ } + } + } +} +function writeStore(p: ReturnType, store: Store) { + const body = JSON.stringify(store); + if (Buffer.byteLength(body) > MAX_BYTES) throw new Error("store_full"); + const temp = join(p.dir, `.jobs-${randomUUID()}.tmp`); + let fd: number | undefined; + try { + fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + writeFileSync(fd, body); fsyncSync(fd); closeSync(fd); fd = undefined; + renameSync(temp, p.file); + const dirFd = openSync(p.dir, constants.O_RDONLY); + try { fsyncSync(dirFd); } finally { closeSync(dirFd); } + } finally { + if (fd !== undefined) closeSync(fd); + try { unlinkSync(temp); } catch { /* already renamed */ } + } +} +function validStore(configDir?: string): Store { + try { return readStore(configDir); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, jobs: [] }; + throw error; + } +} +function dto(job: StoredJob): CodexCliUpdateJobDto { + const active = live.get(job.id) === job.generation; + const unknown = job.status === "queued" || job.status === "running" ? !active : false; + const uncertain = unknown || job.outcome === "ambiguous"; + const childState = unknown || job.reason === "installer_state_unknown" || job.reason === "version_process_state_unknown" + ? "unknown" : job.status === "queued" || job.outcome === "refused" ? "not_started" + : job.status === "running" ? "active_or_not_observed" : "terminal"; + return Object.freeze({ jobId: job.id, status: uncertain ? "ambiguous" : job.status, + ownerState: active ? "active" : unknown ? "unknown" : "finished", lifetimeUncertain: uncertain, + childState, leaseRetained: job.leaseRetained, + createdAt: job.createdAt, updatedAt: job.updatedAt, planId: job.planId, + outcome: unknown ? "ambiguous" : job.outcome, reason: unknown ? "owner_state_unknown" : job.reason, + cleanupErrors: Object.freeze([...job.cleanupErrors]) }); +} + +export function readCodexCliUpdateJob(jobId: string, configDir?: string): CodexCliUpdateJobDto | null { + if (!/^[0-9a-f-]{36}$/.test(jobId)) return null; + const job = readStore(configDir).jobs.find(item => item.id === jobId); + return job ? dto(job) : null; +} + +/** Persist dedupe admission before returning; a duplicate key never schedules a second installer. */ +export function admitCodexCliUpdateJob(input: { requestId: string; planId: string; configDir?: string }): + { kind: "accepted" | "duplicate"; job: CodexCliUpdateJobDto; generation: string } { + if (!/^[A-Za-z0-9._:-]{8,128}$/.test(input.requestId) || !/^[0-9a-f]{32}$/.test(input.planId)) throw new Error("invalid_request"); + const requestKey = digest(input.requestId); + const payloadHash = digest(JSON.stringify({ planId: input.planId })); + return withLock(input.configDir, p => { + const store = validStore(input.configDir); + const existing = store.jobs.find(job => job.requestKey === requestKey); + if (existing) { + if (existing.payloadHash !== payloadHash) throw new Error("idempotency_conflict"); + return { kind: "duplicate", job: dto(existing), generation: existing.generation }; + } + if (store.jobs.length >= MAX_JOBS) throw new Error("store_full"); + const stamp = now(); + const record: StoredJob = { id: randomUUID(), requestKey, payloadHash, generation: randomUUID(), revision: 0, + status: "queued", createdAt: stamp, updatedAt: stamp, planId: input.planId, outcome: null, + reason: null, cleanupErrors: [], leaseRetained: false }; + store.jobs.push(record); writeStore(p, store); + live.set(record.id, record.generation); + return { kind: "accepted", job: dto(record), generation: record.generation }; + }); +} + +/** Updates are generation/revision fenced so an old worker cannot replace a newer state. */ +export function finishCodexCliUpdateJob(jobId: string, generation: string, expectedRevision: number, + result: CodexCliApplyResult, configDir?: string): CodexCliUpdateJobDto | null { + return withLock(configDir, p => { + const store = validStore(configDir); + const index = store.jobs.findIndex(job => job.id === jobId); + if (index < 0) return null; + const current = store.jobs[index]!; + if (current.generation !== generation || current.revision !== expectedRevision + || !live.has(jobId) || !started.has(jobId)) return dto(current); + const next: StoredJob = { ...current, revision: current.revision + 1, status: result.outcome, + outcome: result.outcome, reason: result.reason, cleanupErrors: result.cleanupErrors, leaseRetained: result.leaseRetained, updatedAt: now() }; + store.jobs[index] = next; writeStore(p, store); live.delete(jobId); started.delete(jobId); + return dto(next); + }); +} + +export async function runCodexCliUpdateJob(jobId: string, generation: string, run: () => Promise, configDir?: string): Promise { + let expectedRevision: number | undefined; + try { + expectedRevision = withLock(configDir, p => { + const store = validStore(configDir); const index = store.jobs.findIndex(job => job.id === jobId); + if (index < 0 || store.jobs[index]!.generation !== generation) throw new Error("job_generation_changed"); + if (!live.has(jobId) || started.has(jobId)) throw new Error("job_not_owned"); + const current = store.jobs[index]!; const next = { ...current, revision: current.revision + 1, status: "running" as const, updatedAt: now() }; + store.jobs[index] = next; writeStore(p, store); started.add(jobId); return next.revision; + }); + const result = await run(); + return finishCodexCliUpdateJob(jobId, generation, expectedRevision, result, configDir) ?? readCodexCliUpdateJob(jobId, configDir)!; + } catch { + if (expectedRevision === undefined) return readCodexCliUpdateJob(jobId, configDir)!; + const result: CodexCliApplyResult = { outcome: "ambiguous", reason: "job_result_unknown", planId: "", + installedVersion: null, targetVersion: null, installerExitCode: null, leaseRetained: true, + verificationPid: null, cleanupErrors: [] }; + return finishCodexCliUpdateJob(jobId, generation, expectedRevision, result, configDir) ?? readCodexCliUpdateJob(jobId, configDir)!; + } finally { + live.delete(jobId); + started.delete(jobId); + } +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index e4baeac4d7..b0878d5d6c 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -83,6 +83,7 @@ import { handleClaudeDesktopPickerRoutes } from "./management/claude-desktop-pic import { handleDesktopCompatibilityRoutes } from "./management/desktop-compatibility-routes"; import { handleDesktopCompatibilityRuntimeRoutes } from "./management/desktop-compatibility-runtime-routes"; import { handleDesktopCompatibilitySettingsRoutes } from "./management/desktop-compatibility-settings-routes"; +import { handleCodexCliUpdateRoutes } from "./management/codex-cli-update-routes"; import { handleCursorIntegrationRoutes } from "./management/cursor-integration-routes"; import type { ManagementContext } from "./management/context"; import type { ManagementPrincipal, ManagementSessionControl } from "./management-auth"; @@ -384,6 +385,7 @@ export async function handleManagementAPI( ?? (await handleDesktopCompatibilityRoutes(ctx)) ?? (await handleDesktopCompatibilityRuntimeRoutes(ctx)) ?? (await handleDesktopCompatibilitySettingsRoutes(ctx)) + ?? (await handleCodexCliUpdateRoutes(ctx)) ?? (await handleAgentSettingsRoutes(ctx)) ?? (await handleCodexPromptRoutes(ctx)) ?? (await handleOauthAccountRoutes(ctx)) diff --git a/src/server/management/codex-cli-update-routes.ts b/src/server/management/codex-cli-update-routes.ts new file mode 100644 index 0000000000..b20320af36 --- /dev/null +++ b/src/server/management/codex-cli-update-routes.ts @@ -0,0 +1,54 @@ +import { createCodexCliUpdatePlan } from "../../codex/cli-update-plan"; +import { applyCodexCliUpdate } from "../../codex/cli-update-apply"; +import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../codex/cli-update-job"; +import { codexCliUpdateInspectionDeps } from "../../codex/cli-update-inspection-context"; +import { jsonResponse } from "../auth-cors"; +import { trackBackgroundWork } from "../../lib/background-work"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +const STATUS = /^\/api\/codex\/cli-update\/jobs\/([0-9a-f-]{36})$/; + +export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promise { + if (ctx.url.pathname === "/api/codex/cli-update/plan") { + if (ctx.req.method !== "GET") return jsonResponse({ error: "method_not_allowed" }, 405, ctx.req, ctx.config); + try { + const plan = await createCodexCliUpdatePlan({ inspectionDeps: codexCliUpdateInspectionDeps() }); + return jsonResponse(plan, 200, ctx.req, ctx.config); + } catch { return jsonResponse({ error: "plan_unavailable" }, 503, ctx.req, ctx.config); } + } + const match = STATUS.exec(ctx.url.pathname); + if (match && ctx.req.method === "GET") { + try { + const job = readCodexCliUpdateJob(match[1]!); + return job ? jsonResponse(job, 200, ctx.req, ctx.config) : jsonResponse({ error: "job_not_found" }, 404, ctx.req, ctx.config); + } catch { return jsonResponse({ error: "job_state_unavailable" }, 503, ctx.req, ctx.config); } + } + if (ctx.url.pathname !== "/api/codex/cli-update/apply") return null; + if (ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405, ctx.req, ctx.config); + if (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress) { + return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403, ctx.req, ctx.config); + } + let body: unknown; + try { body = await readManagementJsonBody(ctx.req); } + catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400, ctx.req, ctx.config); } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config); + const value = body as Record; + if (Object.keys(value).some(key => !["requestId", "planId", "confirmed"].includes(key)) + || value.confirmed !== true || typeof value.requestId !== "string" || typeof value.planId !== "string") { + return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config); + } + try { + const admitted = admitCodexCliUpdateJob({ requestId: value.requestId, planId: value.planId }); + if (admitted.kind === "accepted") { + const work = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => applyCodexCliUpdate(value.planId as string, { inspectionDeps: codexCliUpdateInspectionDeps() })); + trackBackgroundWork(`codex-cli-update:${admitted.job.jobId}`, work).catch(() => undefined); + } + return jsonResponse({ job: admitted.job }, 202, ctx.req, ctx.config); + } catch (error) { + const code = error instanceof Error ? error.message : "admission_failed"; + const status = code === "idempotency_conflict" ? 409 : code === "invalid_request" ? 400 : 503; + return jsonResponse({ error: ["invalid_request", "idempotency_conflict", "store_full", "unsupported_store", "unsafe_store"].includes(code) ? code : "admission_failed" }, status, ctx.req, ctx.config); + } +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 5e11e8f6e3..235cd3479b 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -175,6 +175,10 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/codex/desktop-compatibility/runtime", module: "server/management/desktop-compatibility-runtime-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Native app launch and account-wide UI trial require explicit local dashboard confirmation." } }, { method: "GET", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "deferred-verb", owner: "codex-desktop-compatibility", ownerDoc: "structure/clients/codex-desktop.md", why: "The persisted startup preference is currently owned by the local desktop compatibility panel." } }, { method: "POST", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Saved native observation intent requires an explicit local GUI action and fresh field revision." } }, + // server/management/codex-cli-update-routes + { method: "GET", path: "/api/codex/cli-update/plan", module: "server/management/codex-cli-update-routes", mutates: false, exempt: { reason: "local-transport", why: "The CLI runs the same proof-bound plan builder locally; the authenticated route also supplies the dashboard plan." } }, + { method: "POST", path: "/api/codex/cli-update/apply", module: "server/management/codex-cli-update-routes", mutates: true, exempt: { reason: "session-only", why: "HTTP apply requires a confirmed trusted loopback GUI session; the CLI invokes the same durable job owner locally." } }, + { method: "GET", path: "/api/codex/cli-update/jobs/{jobId}", module: "server/management/codex-cli-update-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "The CLI status command reads the same protected job record directly and does not use job IDs as authorization." } }, { method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/effort-caps", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true }, diff --git a/structure/INDEX.md b/structure/INDEX.md index be0566d051..72b8e13545 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -188,6 +188,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | +| `src/cli/codex-cli-update.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/cli/codex-provider.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/cli/link-apply-restart.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | @@ -212,6 +213,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/cli-update-apply.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-artifacts.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-files.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-inspection-context.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `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/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | diff --git a/structure/cli-management.md b/structure/cli-management.md index dd5114f152..fc8dc48705 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -153,3 +153,5 @@ rechecks it under the config lock before saving. Noninteractive allow requires ` no prompt is issued during background start/sync. The [configuration contract](codex-provider-routing.md) owns scope, eligibility, drift refusal and recovery. No endpoint or credential is printed by this command. + +`ocx system codex-cli-update apply` durably admits and records the apply result under the protected config root; `--request-id` provides a retry key, and reusing it with a different plan conflicts. `ocx system codex-cli-update status ` reads the same path-free terminal DTO without a GUI token. A record left queued/running after owner restart is reported ambiguous with unknown owner state. Apply remains limited to the supported POSIX npm-global installation. diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index c2f868d7fa..84854bf7a5 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -102,4 +102,8 @@ 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. Eligible shim recovery, management/status and dashboard apply are separate work. +Windows apply remains refused. Shims and non-npm managers remain unsupported. + +`src/codex/cli-update-inspection-context.ts` is the shared production bridge from the published Node launcher's proof-bound inspection snapshot to the installer inspector. Direct Bun/source launches have no trusted candidate and fail closed; the management plan uses this same bridge and never reconstructs paths 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. 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. The path-free DTO separates owner state, installer child state, lease retention and overall lifetime uncertainty with fixed outcome/reason/cleanup codes. + +`src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed `gui-session` plus trusted-loopback apply admission, and authenticated status. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 1ca7608963..2367514210 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -597,4 +597,4 @@ keep their existing no-catalog-refresh behavior. The regression suite is Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. The PINNED badge confirms before sending a null selection, which clears the pin and returns routing to automatic while bound threads keep their captured account. The dashboard also sends `expectedPinnedAccountId`; the persisted mutation rejects a changed pin with `account_pin_changed` before writing. Refused or uncertain releases reload `/active` and close the old confirmation without claiming that the prior selection survived. -`src/server/management/subagent-model-routes.ts` owns roster/picker and optional Claude force updates. The route registry declares its GET/PUT ownership; `agent-settings-routes.ts` only lazy-loads and delegates the matching path. GET includes `force`, exposed-only `forceAvailable`, and bounded `forceStatus`; PUT accepts force alone, null clears, and omission preserves it. Force-only updates do not regenerate the roster or converge Codex catalogs. The Subagents page keeps save failures visible and disables overlapping force writes. +`src/server/management/subagent-model-routes.ts` owns roster/picker and optional Claude force updates. The route registry declares its GET/PUT ownership; `agent-settings-routes.ts` only lazy-loads and delegates the matching path. GET includes `force`, exposed-only `forceAvailable`, and bounded `forceStatus`; PUT accepts force alone, null clears, and omission preserves it. Force-only updates do not regenerate the roster or converge Codex catalogs. The Subagents page keeps save failures visible and disables overlapping force writes. `src/server/management/codex-cli-update-routes.ts` serves the same proof-bound Codex CLI plan to the dashboard, admits apply only from a confirmed trusted loopback GUI session, and exposes authenticated job status. The job ID is a lookup key, never authorization; CLI status reads the protected common job record directly. The route registry declares the three paths and the CLI/local-transport parity. diff --git a/structure/manifest.json b/structure/manifest.json index 6b28557d22..608e28ffbb 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -177,10 +177,13 @@ "documents": [ "src/codex/cli-update-plan.ts", "src/codex/cli-update-apply.ts", + "src/codex/cli-update-job.ts", + "src/codex/cli-update-inspection-context.ts", "src/codex/cli-update-artifacts.ts", "src/codex/cli-update-files.ts", "src/codex/cli-update-lease.ts", - "src/codex/app-server-processes.ts" + "src/codex/app-server-processes.ts", + "src/cli/codex-cli-update.ts" ] }, { diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index df948dd06c..3941a2b64a 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; import { handleCodexCliUpdateCommand, parseCodexCliUpdateArgs } from "../../src/cli/codex-cli-update"; +import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../src/codex/cli-update-job"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, @@ -40,6 +45,78 @@ const identityReport: CodexCliInstallationIdentityReport = { }; describe("Codex CLI update CLI", () => { + test("apply retry keys deduplicate durable jobs and status exposes only the shared public DTO", async () => { + if (process.platform === "win32" || process.geteuid?.() === undefined) return; + const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-job-test-")); + chmodSync(configDir, 0o700); + try { + const requestId = "retry-key-0000001"; + const planId = "a".repeat(32); + const accepted = admitCodexCliUpdateJob({ requestId, planId, configDir }); + const replay = admitCodexCliUpdateJob({ requestId, planId, configDir }); + expect(accepted.kind).toBe("accepted"); + expect(replay.kind).toBe("duplicate"); + expect(replay.job.jobId).toBe(accepted.job.jobId); + expect(() => admitCodexCliUpdateJob({ requestId, planId: "b".repeat(32), configDir })).toThrow("idempotency_conflict"); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configDir; + const statusLogs: string[] = []; + const oldLog = console.log; + try { + console.log = (...values: unknown[]) => statusLogs.push(values.map(String).join(" ")); + expect(await handleCodexCliUpdateCommand(["status", accepted.job.jobId, "--json"])).toBe(0); + expect(JSON.parse(statusLogs[0]!)).toMatchObject({ jobId: accepted.job.jobId, ownerState: "active" }); + } finally { + console.log = oldLog; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + } + + let installerRuns = 0; + const terminal = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, async () => { + expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)).toMatchObject({ status: "running", ownerState: "active" }); + installerRuns += 1; + return { outcome: "applied", reason: "verified_target", planId, installedVersion: "1.0.0", targetVersion: "1.1.0", + installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; + }, configDir); + expect(installerRuns).toBe(1); + expect(terminal).toMatchObject({ status: "applied", outcome: "applied", ownerState: "finished" }); + expect(JSON.stringify(terminal)).not.toMatch(/ownerPid|generation|payloadHash|requestKey|token|path/i); + const stale = finishCodexCliUpdateJob(accepted.job.jobId, accepted.generation, 1, + { outcome: "refused", reason: "late_result", planId, installedVersion: null, targetVersion: null, + installerExitCode: null, leaseRetained: false, verificationPid: null, cleanupErrors: [] }, configDir); + expect(stale?.status).toBe("applied"); + expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)?.status).toBe("applied"); + } finally { rmSync(configDir, { recursive: true }); } + }); + + test("a persisted running job from a restarted owner is ambiguous, not assumed stopped", () => { + if (process.platform === "win32" || process.geteuid?.() === undefined) return; + const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-restart-test-")); + chmodSync(configDir, 0o700); + try { + const accepted = admitCodexCliUpdateJob({ requestId: "restart-key-00001", planId: "c".repeat(32), configDir }); + const modulePath = new URL("../../src/codex/cli-update-job.ts", import.meta.url).href; + const script = `import { readCodexCliUpdateJob } from ${JSON.stringify(modulePath)}; console.log(JSON.stringify(readCodexCliUpdateJob(${JSON.stringify(accepted.job.jobId)})));`; + const child = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 5_000, + env: { ...process.env, OPENCODEX_HOME: configDir } }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + const dto = JSON.parse(child.stdout.trim()); + expect(dto).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "unknown", lifetimeUncertain: true, + reason: "owner_state_unknown" }); + } finally { rmSync(configDir, { recursive: true }); } + }); + + test("parses job status and stable apply request IDs", () => { + const planId = "d".repeat(32); + const jobId = "12345678-1234-1234-1234-123456789012"; + expect(parseCodexCliUpdateArgs(["apply", "--plan-id", planId, "--yes", "--request-id", "retry-key-123456"])).toMatchObject({ + action: "apply", planId, requestId: "retry-key-123456", + }); + expect(parseCodexCliUpdateArgs(["status", jobId])).toEqual({ action: "status", json: false, jobId }); + expect(() => parseCodexCliUpdateArgs(["apply", "--plan-id", planId, "--yes", "--request-id", "short"])).toThrow(); + }); + test("bare attest selects the proof-bound candidate and forwards the derived input", async () => { expect(parseCodexCliUpdateArgs(["attest"])).toEqual({ json: false, attest: "selected" }); expect(parseCodexCliUpdateArgs(["attest", "--json"])).toEqual({ json: true, attest: "selected" }); diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index ff38029550..6dd4d1783c 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; +import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; import { SIBLING_REFUSED_MANAGEMENT_PATHS, siblingRefusesManagementRequest } from "../../src/server/management/sibling-guard"; import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; import { scanRoutes, distinctRoutes } from "../helpers/management-route-scan"; @@ -59,6 +62,95 @@ const moduleOf = (file: string): string => file.replace(/^src\//, "").replace(/\ const key = (method: string, path: string): string => `${method} ${path}`; describe("management route registry reconciliation", () => { + test("dashboard plan uses the shared proof-bound production inspection path", async () => { + const proof = "P".repeat(43); + const root = process.platform === "win32" ? "C:\\fixture-only" : "/tmp/fixture-only"; + const candidate = process.platform === "win32" ? "C:\\fixture-only\\codex.cmd" : "/tmp/fixture-only/codex"; + const env: NodeJS.ProcessEnv = { [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ version: 1, proof, anthropicEnvSlots: [], + codexCliInspectionEnv: { codexCliPath: candidate, path: root, pathExt: null, managerRoots: {}, transportEnv: {}, + arch: process.arch, configDir: root } }) }; + initializeNodeLauncherContext(["bun", "test", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], env); + try { + const request = new Request("http://localhost/api/codex/cli-update/plan"); + const ctx: ManagementContext = { req: request, url: new URL(request.url), config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ applicable: false, schemaVersion: 1 }); + } finally { initializeNodeLauncherContext(["bun", "test"], {}); } + }); + + test("Codex CLI HTTP apply requires a GUI session and trusted loopback before admission", async () => { + const config = { port: 10100, providers: {} } as import("../../src/types").OcxConfig; + const request = new Request("http://localhost/api/codex/cli-update/apply", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "retry-key-00001", planId: "a".repeat(32), confirmed: true }), + }); + const ctx: ManagementContext = { req: request, url: new URL(request.url), config, deps: {}, version: "test", + principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(403); + expect(await response?.json()).toEqual({ error: "local_dashboard_confirmation_required" }); + ctx.principal = "gui-session"; + ctx.trustedLoopbackIngress = false; + const untrustedIngress = await handleCodexCliUpdateRoutes(ctx); + expect(untrustedIngress?.status).toBe(403); + expect(await untrustedIngress?.json()).toEqual({ error: "local_dashboard_confirmation_required" }); + ctx.trustedLoopbackIngress = true; + ctx.req = new Request(request.url, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "retry-key-00001", planId: "a".repeat(32), confirmed: false }) }); + const unconfirmed = await handleCodexCliUpdateRoutes(ctx); + expect(unconfirmed?.status).toBe(400); + ctx.req = new Request(request.url, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "retry-key-00001", planId: "a".repeat(32), confirmed: true, + prefix: "/tmp/ignored", executable: "/tmp/ignored", managed: true, selectionAttested: true }) }); + const clientEvidence = await handleCodexCliUpdateRoutes(ctx); + expect(clientEvidence?.status).toBe(400); + }); + + test("HTTP apply retry returns the durable job without scheduling a second engine run", async () => { + if (process.platform === "win32" || process.geteuid?.() === undefined) return; + const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-http-test-")); + chmodSync(configDir, 0o700); + const oldHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configDir; + try { + const makeRequest = () => new Request("http://localhost/api/codex/cli-update/apply", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "lost-response-retry-01", planId: "e".repeat(32), confirmed: true }) }); + const ctx: ManagementContext = { req: makeRequest(), url: new URL("http://localhost/api/codex/cli-update/apply"), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, deps: {}, version: "test", + principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const first = await handleCodexCliUpdateRoutes(ctx); + const firstBody = await first!.json(); + ctx.req = makeRequest(); + const retry = await handleCodexCliUpdateRoutes(ctx); + const retryBody = await retry!.json(); + expect(first?.status).toBe(202); + expect(retry?.status).toBe(202); + expect(retryBody.job.jobId).toBe(firstBody.job.jobId); + let status: { jobId?: string; ownerState?: string; outcome?: string; reason?: string } = {}; + for (let i = 0; i < 100; i += 1) { + const statusRequest = new Request(`http://localhost/api/codex/cli-update/jobs/${firstBody.job.jobId}`); + ctx.req = statusRequest; ctx.url = new URL(statusRequest.url); + status = await (await handleCodexCliUpdateRoutes(ctx))!.json() as typeof status; + if (status.ownerState === "finished") break; + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(status).toMatchObject({ jobId: firstBody.job.jobId, ownerState: "finished", outcome: "refused" }); + expect(["not_managed", "mutation_evidence_unverified"].includes(status.reason ?? "")).toBe(true); + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + rmSync(configDir, { recursive: true }); + } + }); + test("data-key reveal is declared as a dashboard-session-only read", () => { expect(MANAGEMENT_ROUTES.find(route => route.method === "POST" && route.path === "/api/keys/reveal")) .toMatchObject({ module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "session-only" } }); From 26a070ee113f60020cd20efd6a50af5d03bbdad7 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 07:52:19 +0900 Subject: [PATCH 06/33] fix(codex): bind management launches and preserve uncertain job lifetimes (cherry picked from commit f33757ab6b5df3f3ee66f92598df197c368ca13d) --- bin/ocx.mjs | 7 +- src/codex/cli-update-job.ts | 41 ++- src/update/codex-cli-update-launch-policy.mjs | 9 + structure/INDEX.md | 1 + structure/codex-cli-update.md | 2 +- structure/manifest.json | 1 + tests/cli/cli-codex-cli-update.test.ts | 100 +++++- .../codex-cli-update-apply.test.ts | 338 +++++++++++++++++- .../codex-cli-update-launcher-policy.test.ts | 10 + .../server/management-route-registry.test.ts | 3 +- 10 files changed, 488 insertions(+), 24 deletions(-) diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6f3d29a138..689ade8bdc 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -63,6 +63,7 @@ import { CODEX_CLI_TRANSPORT_ENV_KEYS, CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS, codexCliInspectionLookupSeeds, + carriesCodexCliUpdateInspectionContext, isCodexCliUpdateInspectionArgv, } from "../src/update/codex-cli-update-launch-policy.mjs"; import { @@ -1086,6 +1087,10 @@ if (updateHelpRequested) { } const codexCliUpdateInspection = isCodexCliUpdateInspectionArgv(process.argv); +// Windows inspection currently refuses before candidate I/O, so a long-lived +// Windows server does not duplicate PATH into its inherited environment block. +const codexCliUpdateInspectionContext = codexCliUpdateInspection + || (process.platform !== "win32" && carriesCodexCliUpdateInspectionContext(process.argv)); if (codexCliUpdateInspection && typeof process.versions.bun === "string") { console.error("opencodex: codex-cli-update inspection must use the published Node launcher."); process.exit(1); @@ -1217,7 +1222,7 @@ function createNodeLaunchContext() { version: 1, proof: launchProof, anthropicEnvSlots: preBunAnthropicSlots, - codexCliInspectionEnv: codexCliUpdateInspection ? { + codexCliInspectionEnv: codexCliUpdateInspectionContext ? { codexCliPath: preBunCodexCliPath, path: preBunPath, pathExt: preBunPathExt, diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts index 26eeedbbea..45a89810ff 100644 --- a/src/codex/cli-update-job.ts +++ b/src/codex/cli-update-job.ts @@ -29,7 +29,8 @@ interface Store { version: 1; jobs: StoredJob[] } export interface CodexCliUpdateJobDto { readonly jobId: string; readonly status: "queued" | "running" | "applied" | "not_applied" | "refused" | "ambiguous"; readonly ownerState: "active" | "unknown" | "finished"; readonly lifetimeUncertain: boolean; - readonly childState: "not_started" | "active_or_not_observed" | "terminal" | "unknown"; readonly leaseRetained: boolean; + readonly childState: "not_started" | "active_or_not_observed" | "terminal" | "unknown"; + readonly leaseState: "not_acquired_or_released" | "retained" | "unknown"; readonly createdAt: string; readonly updatedAt: string; readonly planId: string; readonly outcome: CodexCliApplyResult["outcome"] | null; readonly reason: string | null; readonly cleanupErrors: readonly string[]; @@ -147,18 +148,36 @@ function validStore(configDir?: string): Store { throw error; } } +type LifecycleInput = Pick & { active: boolean }; +type LifecycleProjection = Pick; + +/** Project only states supported by persisted evidence; retention booleans alone are ambiguous. */ +export function projectCodexCliUpdateJobLifecycle(job: LifecycleInput): LifecycleProjection { + const ownerUnknown = job.status === "queued" || job.status === "running" ? !job.active : false; + const uncertain = ownerUnknown || job.outcome === "ambiguous"; + const leaseReleaseUnconfirmed = job.cleanupErrors.some(error => + 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"); + 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); + const childState = childUnknown ? "unknown" : job.status === "queued" || job.outcome === "refused" ? "not_started" + : job.status === "running" ? "active_or_not_observed" : "terminal"; + const leaseState = ownerUnknown || job.status === "queued" || job.status === "running" + || job.reason === "job_result_unknown" || leaseReleaseUnconfirmed ? "unknown" + : retainedWithEvidence ? "retained" : job.leaseRetained ? "unknown" : "not_acquired_or_released"; + return Object.freeze({ status: uncertain ? "ambiguous" : job.status, + ownerState: job.active ? "active" : ownerUnknown ? "unknown" : "finished", lifetimeUncertain: uncertain, + childState, leaseState, outcome: ownerUnknown ? "ambiguous" : job.outcome, + reason: ownerUnknown ? "owner_state_unknown" : job.reason }); +} + function dto(job: StoredJob): CodexCliUpdateJobDto { - const active = live.get(job.id) === job.generation; - const unknown = job.status === "queued" || job.status === "running" ? !active : false; - const uncertain = unknown || job.outcome === "ambiguous"; - const childState = unknown || job.reason === "installer_state_unknown" || job.reason === "version_process_state_unknown" - ? "unknown" : job.status === "queued" || job.outcome === "refused" ? "not_started" - : job.status === "running" ? "active_or_not_observed" : "terminal"; - return Object.freeze({ jobId: job.id, status: uncertain ? "ambiguous" : job.status, - ownerState: active ? "active" : unknown ? "unknown" : "finished", lifetimeUncertain: uncertain, - childState, leaseRetained: job.leaseRetained, + const lifecycle = projectCodexCliUpdateJobLifecycle({ ...job, active: live.get(job.id) === job.generation }); + return Object.freeze({ jobId: job.id, ...lifecycle, createdAt: job.createdAt, updatedAt: job.updatedAt, planId: job.planId, - outcome: unknown ? "ambiguous" : job.outcome, reason: unknown ? "owner_state_unknown" : job.reason, cleanupErrors: Object.freeze([...job.cleanupErrors]) }); } diff --git a/src/update/codex-cli-update-launch-policy.mjs b/src/update/codex-cli-update-launch-policy.mjs index 8a1759d66d..bef64366e2 100644 --- a/src/update/codex-cli-update-launch-policy.mjs +++ b/src/update/codex-cli-update-launch-policy.mjs @@ -39,6 +39,15 @@ export function isCodexCliUpdateInspectionArgv(argv) { return args[0] === "system" && args[1] === "codex-cli-update"; } +/** Long-lived published proxy servers need the same proof-bound snapshot so management routes + * can inspect the Node-selected Codex CLI without accepting a client path or ambient dotenv value. + * This only controls snapshot inclusion; it does not extend the one-shot inspection restrictions. + */ +export function carriesCodexCliUpdateInspectionContext(argv) { + const args = argv.slice(2).filter(value => !value.startsWith("--ocx-internal-launch-proof=")); + return isCodexCliUpdateInspectionArgv(argv) || args[0] === "start"; +} + /** Serialized OCX_NODE_LAUNCH_CONTEXT ceiling; src/cli/launcher-context.ts refuses above it. */ export const NODE_LAUNCH_CONTEXT_MAX_CHARS = 64 * 1024; diff --git a/structure/INDEX.md b/structure/INDEX.md index 72b8e13545..a1168c5a53 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -151,6 +151,7 @@ A source area can be described by more than one doc, because these docs are orga | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `app/` | [`overview.md`](overview.md)
[`companion.md`](companion.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `bin/ocx.mjs` | [`codex-cli-update.md`](codex-cli-update.md) | | `desktop/` | [`desktop-shell.md`](desktop-shell.md)
[`companion.md`](companion.md) | | `desktop/src-tauri/src/cli_command.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_posix.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 84854bf7a5..351568499a 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -104,6 +104,6 @@ lease release marks retention unverified without preventing staging cleanup; cle 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. -`src/codex/cli-update-inspection-context.ts` is the shared production bridge from the published Node launcher's proof-bound inspection snapshot to the installer inspector. Direct Bun/source launches have no trusted candidate and fail closed; the management plan uses this same bridge and never reconstructs paths 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. 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. The path-free DTO separates owner state, installer child state, lease retention and overall lifetime uncertainty with fixed outcome/reason/cleanup codes. +`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. 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. `src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed `gui-session` plus trusted-loopback apply admission, and authenticated status. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. diff --git a/structure/manifest.json b/structure/manifest.json index 608e28ffbb..b9c69d17e1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -179,6 +179,7 @@ "src/codex/cli-update-apply.ts", "src/codex/cli-update-job.ts", "src/codex/cli-update-inspection-context.ts", + "bin/ocx.mjs", "src/codex/cli-update-artifacts.ts", "src/codex/cli-update-files.ts", "src/codex/cli-update-lease.ts", diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 3941a2b64a..2dad1936b2 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from "bun:test"; import { chmodSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { handleCodexCliUpdateCommand, parseCodexCliUpdateArgs } from "../../src/cli/codex-cli-update"; -import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../src/codex/cli-update-job"; +import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, projectCodexCliUpdateJobLifecycle, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../src/codex/cli-update-job"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, @@ -45,8 +45,29 @@ const identityReport: CodexCliInstallationIdentityReport = { }; describe("Codex CLI update CLI", () => { - test("apply retry keys deduplicate durable jobs and status exposes only the shared public DTO", async () => { - if (process.platform === "win32" || process.geteuid?.() === undefined) return; + test("job lifecycle projection keeps lease and child uncertainty distinct from the outcome", () => { + const project = (input: Parameters[0]) => projectCodexCliUpdateJobLifecycle(input); + const base = { active: false, status: "ambiguous" as const, outcome: "ambiguous" as const, + reason: "job_result_unknown", leaseRetained: true, cleanupErrors: [] as readonly string[] }; + expect(project(base)).toMatchObject({ childState: "unknown", leaseState: "unknown", outcome: "ambiguous" }); + expect(project({ ...base, reason: "installer_or_readback_error" })).toMatchObject({ + childState: "unknown", leaseState: "retained", outcome: "ambiguous", + }); + expect(project({ ...base, status: "applied", outcome: "applied", reason: "verified_target", + cleanupErrors: ["lease_release_exception"] })).toMatchObject({ + childState: "terminal", leaseState: "unknown", status: "applied", outcome: "applied", + }); + expect(project({ ...base, status: "applied", outcome: "applied", reason: "verified_target", + cleanupErrors: ["lease_release_unverified"] })).toMatchObject({ leaseState: "unknown", outcome: "applied" }); + expect(project({ ...base, reason: "installation_readback_unverified" })).toMatchObject({ + leaseState: "unknown", + }); + expect(project({ ...base, reason: "version_process_state_unknown" })).toMatchObject({ + childState: "unknown", leaseState: "retained", + }); + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("apply retry keys deduplicate durable jobs and status exposes only the shared public DTO", async () => { const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-job-test-")); chmodSync(configDir, 0o700); try { @@ -89,12 +110,19 @@ describe("Codex CLI update CLI", () => { } finally { rmSync(configDir, { recursive: true }); } }); - test("a persisted running job from a restarted owner is ambiguous, not assumed stopped", () => { - if (process.platform === "win32" || process.geteuid?.() === undefined) return; + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a new owner reports a live owner's job unknown; the original generation can finish without stale overwrite", async () => { const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-restart-test-")); chmodSync(configDir, 0o700); + let release: () => void = () => {}; + let pending: Promise | undefined; try { const accepted = admitCodexCliUpdateJob({ requestId: "restart-key-00001", planId: "c".repeat(32), configDir }); + pending = runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, () => new Promise(resolve => { + release = () => resolve({ outcome: "applied", reason: "verified_target", planId: "c".repeat(32), + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }); + }), configDir); + expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)).toMatchObject({ status: "running", ownerState: "active" }); const modulePath = new URL("../../src/codex/cli-update-job.ts", import.meta.url).href; const script = `import { readCodexCliUpdateJob } from ${JSON.stringify(modulePath)}; console.log(JSON.stringify(readCodexCliUpdateJob(${JSON.stringify(accepted.job.jobId)})));`; const child = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 5_000, @@ -104,9 +132,69 @@ describe("Codex CLI update CLI", () => { const dto = JSON.parse(child.stdout.trim()); expect(dto).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "unknown", lifetimeUncertain: true, reason: "owner_state_unknown" }); + release(); + const completed = await pending; + expect(completed).toMatchObject({ status: "applied", ownerState: "finished" }); + expect(finishCodexCliUpdateJob(accepted.job.jobId, accepted.generation, 1, + { outcome: "refused", reason: "late_result", planId: "c".repeat(32), installedVersion: null, + targetVersion: null, installerExitCode: null, leaseRetained: false, verificationPid: null, + cleanupErrors: [] }, configDir)?.status).toBe("applied"); + } finally { + release(); + if (pending) await pending.catch(() => undefined); + rmSync(configDir, { recursive: true }); + } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an exception without close evidence preserves child and lease uncertainty", async () => { + const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-unknown-child-test-")); + chmodSync(configDir, 0o700); + try { + const accepted = admitCodexCliUpdateJob({ requestId: "unknown-child-0001", planId: "f".repeat(32), configDir }); + const result = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, + async () => { throw new Error("unobservable child exception"); }, configDir); + expect(result).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "finished", + childState: "unknown", leaseState: "unknown", lifetimeUncertain: true, reason: "job_result_unknown" }); } finally { rmSync(configDir, { recursive: true }); } }); + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an engine exception while its fixture child is live reports unknown child state", async () => { + const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-live-child-test-")); + chmodSync(configDir, 0o700); + let child: ChildProcess | undefined; + try { + const accepted = admitCodexCliUpdateJob({ requestId: "live-child-exception-001", planId: "9".repeat(32), configDir }); + const result = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, async () => { + child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", windowsHide: true }); + await new Promise((resolve, reject) => { + child!.once("spawn", resolve); + child!.once("error", reject); + child!.once("exit", () => reject(new Error("fixture child exited before the engine exception"))); + }); + if (!child?.pid) throw new Error("fixture child did not expose its process id"); + process.kill(child.pid, 0); + throw new Error("simulated engine error after fixture child started"); + }, configDir); + if (!child?.pid) throw new Error("fixture child process identity was lost"); + process.kill(child.pid, 0); + expect(result).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "finished", + childState: "unknown", leaseState: "unknown", lifetimeUncertain: true, reason: "job_result_unknown" }); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = new Promise(resolve => child!.once("exit", () => resolve())); + child.kill("SIGTERM"); + let timer: ReturnType | undefined; + await Promise.race([closed, new Promise(resolve => { timer = setTimeout(resolve, 2_000); })]); + if (timer) clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await closed; + } + } + rmSync(configDir, { recursive: true }); + } + }); + test("parses job status and stable apply request IDs", () => { const planId = "d".repeat(32); const jobId = "12345678-1234-1234-1234-123456789012"; diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index c1492063c0..ce1f639600 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { createServer, connect } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { applyCodexCliUpdate, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, runCodexCliUpdateJob, type CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; import { createCodexCliApplyFixture, waitForApplyAdmission } from "../helpers/codex-cli-apply-fixture"; @@ -39,6 +42,192 @@ function cli(f: ReturnType, args: string[]) { expect(result.error).toBeUndefined(); expect(result.status).toBe(0); return JSON.parse(result.stdout); } +function fixtureInWorkspaceScratch() { + const scratch = join(repoRoot(), ".tmp"); + mkdirSync(scratch, { recursive: true }); + const stat = lstatSync(scratch); + if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync.native(scratch) !== scratch) { + throw new Error("workspace .tmp is not a private local directory"); + } + const root = mkdtempSync(join(scratch, "codex-cli-management-e2e-")); roots.push(root); + return createCodexCliApplyFixture(root, process.platform, process.arch); +} + +function managementTestEnv(f: ReturnType) { + const node = Bun.which("node"); + if (!node) throw new Error("fixture node executable unavailable"); + const home = f.home; + const configDir = join(home, "ocx"); + const env: NodeJS.ProcessEnv = { + PATH: [join(f.root, "bin"), join(f.prefix, "bin"), dirname(node), "/usr/bin", "/bin"].join(":"), + HOME: home, CODEX_HOME: join(home, ".codex"), TMPDIR: f.temporaryRoot, TEMP: f.temporaryRoot, TMP: f.temporaryRoot, + OPENCODEX_HOME: configDir, CODEX_CLI_PATH: f.launcher, OPENCODEX_BUN_PATH: process.execPath, + OCX_NO_DESKTOP_HANDOFF: "1", NODE_COMPILE_CACHE: join(f.root, "node-compile-cache"), + XDG_CONFIG_HOME: join(home, "xdg-config"), XDG_CACHE_HOME: join(home, "xdg-cache"), XDG_DATA_HOME: join(home, "xdg-data"), + }; + return { node, configDir, env }; +} + +async function reserveLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("private listener port unavailable"); + const port = address.port; + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + return port; +} + +function startFixtureManagementServer(f: ReturnType, port: number, gate: string) { + const { node, env } = managementTestEnv(f); + env.OCX_FIXTURE_NPM_GATE = gate; + const child = spawn(node, [repoPath("bin", "ocx.mjs"), "start", "--port", String(port)], { + cwd: f.root, env, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + const append = (chunk: Buffer) => { output = (output + chunk.toString("utf8")).slice(-64 * 1024); }; + child.stdout?.on("data", append); + child.stderr?.on("data", append); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once("close", (code, signal) => resolve({ code, signal })); + }); + return { child, closed, output: () => output, env }; +} + +async function waitForManagementServer(base: string, child: ChildProcess, closed: Promise, output: () => string): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`fixture server exited early (${child.exitCode}); outputBytes=${Buffer.byteLength(output())}`); + try { + const response = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(1000) }); + if (response.ok) return; + } catch { /* the isolated listener is still starting */ } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(`fixture server readiness timed out; outputBytes=${Buffer.byteLength(output())}`); +} + +function closeOrTimeout(closed: Promise, timeoutMs: number): Promise { + return new Promise(resolve => { + const timer = setTimeout(() => resolve(false), timeoutMs); + closed.then(() => { clearTimeout(timer); resolve(true); }); + }); +} + +async function stopFixtureManagementServer(server: ReturnType): Promise { + const pid = server.child.pid; + if (!pid) throw new Error("fixture server pid unavailable during owned cleanup"); + const groupAlive = () => { + try { process.kill(-pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } + }; + if (server.child.exitCode === null && server.child.signalCode === null) { + server.child.kill("SIGINT"); + await closeOrTimeout(server.closed, 10_000); + } + if (groupAlive()) { + try { process.kill(-pid, "SIGTERM"); } catch { /* owned process group already exited */ } + await closeOrTimeout(server.closed, 5_000); + } + if (groupAlive()) { + try { process.kill(-pid, "SIGKILL"); } catch { /* owned process group already exited */ } + await closeOrTimeout(server.closed, 2_000); + } +} + +async function issueFixtureGuiSession(base: string) { + const origin = new URL(base).origin; + const response = await fetch(`${base}/opencodex-session`, { headers: { origin } }); + if (!response.ok) throw new Error(`fixture GUI session refused (${response.status})`); + const html = await response.text(); + const read = (name: string) => new RegExp(`>((resolve, reject) => { + const socket = connect(Number(url.port), url.hostname); + let connected = false; + socket.once("error", error => { if (!connected) reject(error); }); + socket.once("connect", () => socket.write(Buffer.concat([Buffer.from(headers), payload]), error => { + if (error) { socket.destroy(); reject(error); return; } + connected = true; + socket.pause(); + resolve(socket); + })); + }); +} + +async function waitForJob(base: string, session: { token: string; csrf: string; origin: string }, jobId: string, + predicate: (value: Record) => boolean, timeoutMs = 30_000): Promise> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const response = await fetch(`${base}/api/codex/cli-update/jobs/${jobId}`, { headers: fixtureSessionHeaders(session) }); + if (response.ok) { + const value = await response.json() as Record; + if (predicate(value)) return value; + } + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error("fixture management job state timed out"); +} + +async function waitForDurableAdmission(configDir: string, requestId: string, timeoutMs = 10_000): Promise { + const file = join(configDir, "codex-cli-update-jobs", "jobs.json"); + const requestKey = createHash("sha256").update(requestId).digest("hex"); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(file)) { + const value = JSON.parse(readFileSync(file, "utf8")) as { jobs?: Array<{ id?: string; requestKey?: string }> }; + const job = value.jobs?.find(entry => entry.requestKey === requestKey); + if (job?.id) return job.id; + } + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error("fixture durable job admission was not observed"); +} + +function gateFixtureNpmInstall(f: ReturnType) { + const path = join(f.root, "bin", "npm"); + const source = readFileSync(path, "utf8"); + const marker = "const installed = cp.spawnSync("; + if (source.split(marker).length !== 2) throw new Error("fixture npm wrapper shape changed"); + const wait = [ + 'const fixtureGate = process.env.OCX_FIXTURE_NPM_GATE;', + 'if (fixtureGate) {', + ' const cell = new Int32Array(new SharedArrayBuffer(4));', + ' const deadline = Date.now() + 45000;', + ' while (!fs.existsSync(fixtureGate) && Date.now() < deadline) Atomics.wait(cell, 0, 0, 20);', + ' if (!fs.existsSync(fixtureGate)) process.exit(85);', + '}', + ].join("\n") + "\n"; + writeFileSync(path, source.replace(marker, wait + marker), { encoding: "utf8", mode: 0o755 }); + chmodSync(path, 0o755); +} + +async function postFixtureApply(base: string, session: { token: string; csrf: string; origin: string }, body: unknown) { + return fetch(`${base}/api/codex/cli-update/apply`, { method: "POST", headers: fixtureSessionHeaders(session, true), body: JSON.stringify(body) }); +} describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { test("published Node launcher -> real resolver/provenance -> plan/apply -> verified new files", () => { const f = fixture(); @@ -57,6 +246,149 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { expect(calls[0]!.join(" ")).not.toContain("@latest"); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); }, 120_000); + + test("published Node launcher -> isolated management plan, lost-response apply retry, and CLI status", async () => { + const f = fixtureInWorkspaceScratch(); + const gate = join(f.root, "release-npm-install"); + gateFixtureNpmInstall(f); + const port = await reserveLoopbackPort(); + const base = `http://127.0.0.1:${port}`; + let server = startFixtureManagementServer(f, port, gate); + let releaseGate = false; + let session: Awaited> | undefined; + let jobId: string | undefined; + let lostSocket: ReturnType | undefined; + let finishOldOwner: (() => void) | undefined; + let oldOwnerWork: Promise | undefined; + try { + await waitForManagementServer(base, server.child, server.closed, server.output); + session = await issueFixtureGuiSession(base); + const planResponse = await fetch(`${base}/api/codex/cli-update/plan`, { headers: fixtureSessionHeaders(session) }); + if (!planResponse.ok) throw new Error(`isolated production plan refused (${planResponse.status})`); + const plan = await planResponse.json() as { applicable: boolean; planId: string | null; managed: boolean; + installedVersion: string | null; targetVersion: string | null; installContentDigest: string | null }; + if (!plan.applicable || !plan.planId || !plan.managed || plan.installedVersion !== "1.0.0" + || plan.targetVersion !== "1.1.0" || !plan.installContentDigest) { + throw new Error("published launcher management route did not produce the fixture-owned managed plan"); + } + + const requestId = "lost-response-management-e2e-01"; + const applyBody = { requestId, planId: plan.planId, confirmed: true }; + lostSocket = await sendLostApplyResponse(base, session, applyBody); + jobId = await waitForDurableAdmission(join(f.home, "ocx"), requestId); + lostSocket.destroy(); lostSocket = undefined; + const installTrace = join(f.root, "install-trace.jsonl"); + const installDeadline = Date.now() + 30_000; + while (Date.now() < installDeadline) { + const lines = existsSync(installTrace) ? readFileSync(installTrace, "utf8").split("\n").filter(Boolean) : []; + if (lines.length > 0) break; + await new Promise(resolve => setTimeout(resolve, 20)); + } + const beforeRetry = existsSync(installTrace) ? readFileSync(installTrace, "utf8").split("\n").filter(Boolean) : []; + if (beforeRetry.length !== 1) throw new Error("lost HTTP response did not reach one fixture installer admission"); + + const retry = await postFixtureApply(base, session, applyBody); + if (retry.status !== 202) throw new Error(`same-key management retry returned ${retry.status}`); + const retryBody = await retry.json() as { job?: { jobId?: string } }; + if (retryBody.job?.jobId !== jobId) throw new Error("same-key retry did not return the admitted job id"); + const conflict = await postFixtureApply(base, session, { ...applyBody, planId: "f".repeat(32) }); + if (conflict.status !== 409) throw new Error(`changed-payload key reuse returned ${conflict.status}`); + const running = await fetch(`${base}/api/codex/cli-update/jobs/${jobId}`, { headers: fixtureSessionHeaders(session) }); + const runningDto = await running.json() as Record; + if (!running.ok || runningDto.status !== "running" || runningDto.ownerState !== "active" + || runningDto.childState !== "active_or_not_observed" || runningDto.leaseState !== "unknown") { + throw new Error("active fixture job status lost its execution/lease uncertainty projection"); + } + const stillOneInstall = readFileSync(installTrace, "utf8").split("\n").filter(Boolean); + if (stillOneInstall.length !== 1) throw new Error("idempotent response replay scheduled another installer"); + + writeFileSync(gate, "release\n", { flag: "wx", mode: 0o600 }); + releaseGate = true; + const terminal = await waitForJob(base, session, jobId, + value => value.ownerState === "finished", 45_000); + if (terminal.outcome !== "applied" || terminal.status !== "applied" || terminal.leaseState !== "not_acquired_or_released") { + throw new Error("private fixture apply did not finish with verified installation and lease cleanup"); + } + const args = JSON.parse(readFileSync(installTrace, "utf8").trim()) as string[]; + if (args.length === 0 || !args.includes("--offline") || !args.includes("--ignore-scripts") + || args[args.indexOf("--prefix") + 1] !== f.prefix || args.filter(arg => arg.includes("@file:")).length !== 2) { + throw new Error("private npm invocation escaped the fixture prefix or install restrictions"); + } + + const { node, env } = managementTestEnv(f); + const cli = spawnSync(node, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "status", jobId, "--json"], { + cwd: f.root, encoding: "utf8", env, timeout: 30_000, + }); + if (cli.error || cli.status !== 0) throw new Error("published CLI status consumer failed"); + const cliDto = JSON.parse(cli.stdout.trim()) as Record; + if (JSON.stringify(cliDto) !== JSON.stringify(terminal)) throw new Error("CLI and management job DTOs diverged"); + const publicDto = JSON.stringify(terminal); + for (const privateValue of [f.root, f.home, f.prefix, session.token, session.csrf, gate]) { + if (publicDto.includes(privateValue)) throw new Error("job DTO exposed fixture-private state"); + } + for (const field of ["requestKey", "payloadHash", "generation", "ownerPid", "verificationPid", "environment", "token"]) { + if (Object.hasOwn(terminal, field)) throw new Error("job DTO included a private schema field"); + } + jobId = undefined; + + // Keep a separate old owner alive across the management server restart. The restarted + // server must project its persisted running record as unknown, then accept only that + // original generation's late completion. + const configDir = join(f.home, "ocx"); + const restartPlanId = "b".repeat(32); + const oldOwner = admitCodexCliUpdateJob({ requestId: "server-restart-owner-unknown-01", planId: restartPlanId, configDir }); + oldOwnerWork = runCodexCliUpdateJob(oldOwner.job.jobId, oldOwner.generation, () => new Promise(resolve => { + finishOldOwner = () => resolve({ outcome: "applied", reason: "verified_target", planId: restartPlanId, + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }); + }), configDir); + const oldOwnerStatus = await fetch(`${base}/api/codex/cli-update/jobs/${oldOwner.job.jobId}`, { headers: fixtureSessionHeaders(session) }); + const oldOwnerDto = await oldOwnerStatus.json() as Record; + if (!oldOwnerStatus.ok || oldOwnerDto.ownerState !== "unknown" || oldOwnerDto.status !== "ambiguous" + || oldOwnerDto.reason !== "owner_state_unknown") throw new Error("management API treated an unowned running record as stopped"); + + await stopFixtureManagementServer(server); + const restartedPort = await reserveLoopbackPort(); + const restartedBase = `http://127.0.0.1:${restartedPort}`; + server = startFixtureManagementServer(f, restartedPort, gate); + await waitForManagementServer(restartedBase, server.child, server.closed, server.output); + const restartedSession = await issueFixtureGuiSession(restartedBase); + const afterRestart = await fetch(`${restartedBase}/api/codex/cli-update/jobs/${oldOwner.job.jobId}`, + { headers: fixtureSessionHeaders(restartedSession) }); + const restartedDto = await afterRestart.json() as Record; + if (!afterRestart.ok || restartedDto.ownerState !== "unknown" || restartedDto.reason !== "owner_state_unknown") { + throw new Error("restarted management process inferred an old owner's termination"); + } + finishOldOwner?.(); + const lateResult = await oldOwnerWork; + const staleWrite = finishCodexCliUpdateJob(oldOwner.job.jobId, oldOwner.generation, 1, + { outcome: "refused", reason: "late_result", planId: restartPlanId, installedVersion: null, + targetVersion: null, installerExitCode: null, leaseRetained: false, verificationPid: null, + cleanupErrors: [] }, configDir); + if (lateResult.outcome !== "applied" || staleWrite?.outcome !== "applied") { + throw new Error("late old-generation state overwrote the owner's completed result"); + } + const afterLate = await fetch(`${restartedBase}/api/codex/cli-update/jobs/${oldOwner.job.jobId}`, + { headers: fixtureSessionHeaders(restartedSession) }); + const finalDto = await afterLate.json() as Record; + if (!afterLate.ok || finalDto.status !== "applied" || finalDto.ownerState !== "finished") { + throw new Error("restarted management process did not observe the fenced owner completion"); + } + } finally { + finishOldOwner?.(); + if (oldOwnerWork) await oldOwnerWork.catch(() => undefined); + lostSocket?.destroy(); + if (!releaseGate) { + try { writeFileSync(gate, "release\n", { flag: "wx", mode: 0o600 }); } catch { /* gate was not installed */ } + } + if (session && jobId) { + try { await waitForJob(base, session, jobId, value => value.ownerState === "finished", 45_000); } + catch { /* preserve the primary assertion; the owned server is still drained below */ } + } + await stopFixtureManagementServer(server); + } + }, 120_000); + test("published CLI packs and applies through a symlinked TMPDIR using canonical staging", () => { const f = fixture(); const alias = join(f.root, "tmp-alias"); symlinkSync(f.temporaryRoot, alias, "dir"); diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index aa833c5562..4cc117a15b 100644 --- a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts +++ b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { + carriesCodexCliUpdateInspectionContext, codexCliInspectionLookupSeeds, isCodexCliUpdateInspectionArgv, NODE_LAUNCH_CONTEXT_MAX_CHARS, @@ -21,6 +22,13 @@ describe("Codex CLI updater launcher policy", () => { expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "system", "update"])).toBe(false); }); + test("published start carries only the proof-bound updater snapshot, without becoming a one-shot inspection", () => { + expect(carriesCodexCliUpdateInspectionContext(["node", "ocx", "start", "--port", "12345"])).toBe(true); + expect(carriesCodexCliUpdateInspectionContext(["node", "ocx", "--ocx-internal-launch-proof=bad", "start"])).toBe(true); + expect(carriesCodexCliUpdateInspectionContext(["node", "ocx", "status"])).toBe(false); + expect(isCodexCliUpdateInspectionArgv(["node", "ocx", "start", "--port", "12345"])).toBe(false); + }); + test("launcher skips boot repair and lazy Bun installation for this namespace", () => { const source = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); // Read the guard that actually wraps the probe rather than a fixed pair of adjacent @@ -71,5 +79,7 @@ describe("Codex CLI updater launcher policy", () => { expect(capture).toContain('["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,'); expect(capture).toContain("...CODEX_CLI_TRANSPORT_ENV_KEYS].map(name => name.toLowerCase())"); expect(capture).toContain("if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];"); + expect(source).toContain("|| (process.platform !== \"win32\" && carriesCodexCliUpdateInspectionContext(process.argv))"); + expect(capture).toContain("codexCliInspectionEnv: codexCliUpdateInspectionContext ? {"); }); }); diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index 6dd4d1783c..bae7d15797 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -112,8 +112,7 @@ describe("management route registry reconciliation", () => { expect(clientEvidence?.status).toBe(400); }); - test("HTTP apply retry returns the durable job without scheduling a second engine run", async () => { - if (process.platform === "win32" || process.geteuid?.() === undefined) return; + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("HTTP apply retry returns the durable job without scheduling a second engine run", async () => { const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-http-test-")); chmodSync(configDir, 0o700); const oldHome = process.env.OPENCODEX_HOME; From b06e809fb3f6c5fce85b724e1667bdf706938e0b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:32:35 +0900 Subject: [PATCH 07/33] test(codex): align POSIX CLI job consumer contracts Carry only the request-id, POSIX job-store and status usage hunks from 86ad5a00d3aa533a5c517edf2c3adbaf33786a09; Windows read-only plan tests remain separate. --- tests/codex-integration/codex-cli-update-artifacts.test.ts | 7 ++++--- .../codex-integration/codex-cli-update-zero-effect.test.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index 0864eb78c4..30a1aa6bcd 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -235,8 +235,9 @@ describe("Codex CLI apply consumer", () => { }); test("requires explicit exact plan and confirmation, and accepts shared JSON positions", () => { const planId = "a".repeat(32); - expect(parseCodexCliUpdateArgs(["--json", "apply", `--plan-id=${planId}`, "--yes"])) - .toEqual({ action: "apply", planId, json: true }); + const requestId = "apply-parser-test-0001"; + expect(parseCodexCliUpdateArgs(["--json", "apply", `--plan-id=${planId}`, "--yes", "--request-id", requestId])) + .toEqual({ action: "apply", planId, requestId, json: true }); for (const args of [["apply"], ["apply", "--yes"], ["apply", "--plan-id", planId], ["apply", "--yes", "--yes", "--plan-id", planId], ["apply", "--yes", "--plan-id", "latest"]]) { expect(() => parseCodexCliUpdateArgs(args)).toThrow(); @@ -247,7 +248,7 @@ describe("Codex CLI apply consumer", () => { expect(await handleCodexCliUpdateCommand(["apply", "--yes"], { applyUpdate: async () => { calls++; throw new Error("not reached"); } })).toBe(2); expect(calls).toBe(0); }); - test("confirmed apply reaches its consumer with the exact plan ID and prints its public result", async () => { + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed apply reaches its consumer with the exact plan ID and prints its public result", async () => { const planId = "a".repeat(32); let calls = 0; const logs: string[] = [], log = console.log; const report = { outcome: "not_applied" as const, reason: "original_install_unchanged", planId, installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; diff --git a/tests/codex-integration/codex-cli-update-zero-effect.test.ts b/tests/codex-integration/codex-cli-update-zero-effect.test.ts index 3b339f7311..5b14b96ea2 100644 --- a/tests/codex-integration/codex-cli-update-zero-effect.test.ts +++ b/tests/codex-integration/codex-cli-update-zero-effect.test.ts @@ -203,7 +203,7 @@ describe("Codex CLI updater zero-effect boundary", () => { }); expect(result.error).toBeUndefined(); expect(result.status).toBe(2); - expect(result.stderr).toContain("codex-cli-update action must be check, attest, plan or apply"); + expect(result.stderr).toContain("codex-cli-update action must be check, attest, plan, apply or status"); expect(readFileSync(statePath)).toEqual(before); expect(existsSync(marker)).toBe(false); }); From 3df99b8fafe3ed30dee603c5201976213138566a Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 08:52:27 +0900 Subject: [PATCH 08/33] fix(codex): initialize confirmed CLI update jobs safely (cherry picked from commit 7db5a1e127def624dd6436265103dafd48ab9a2d) --- src/cli/codex-cli-update.ts | 2 +- src/codex/cli-update-job.ts | 61 +++++- .../management/codex-cli-update-routes.ts | 2 +- structure/codex-cli-update.md | 2 +- tests/cli/cli-codex-cli-update.test.ts | 201 ++++++++++++++++-- .../codex-cli-update-apply.test.ts | 27 ++- .../codex-cli-update-artifacts.test.ts | 38 +++- 7 files changed, 296 insertions(+), 37 deletions(-) diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index d1b55d63dc..b4520feeb8 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -320,7 +320,7 @@ export async function handleCodexCliUpdateCommand( } if (parsed.action === "apply") { const apply = deps.applyUpdate ?? (await import("../codex/cli-update-apply")).applyCodexCliUpdate; - const admitted = admitCodexCliUpdateJob({ requestId: parsed.requestId, planId: parsed.planId }); + const admitted = admitCodexCliUpdateJob({ requestId: parsed.requestId, planId: parsed.planId, confirmed: true }); const job = admitted.kind === "accepted" ? await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, () => apply(parsed.planId, { inspectionDeps: codexCliUpdateInspectionDeps(deps) })) diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts index 45a89810ff..519086077c 100644 --- a/src/codex/cli-update-job.ts +++ b/src/codex/cli-update-job.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { chmodSync, closeSync, constants, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { getConfigDir } from "../config"; import type { CodexCliApplyResult } from "./cli-update-apply"; @@ -46,10 +46,54 @@ function paths(configDir = getConfigDir()) { const dir = join(config, JOB_DIR); return { config, dir, file: join(dir, FILE), lock: join(dir, LOCK) }; } -function prepare(configDir?: string, create = false) { +function assertTrustedConfigAncestry(parent: string) { + const euid = process.geteuid?.(); + if (euid === undefined) throw new Error("unsupported_store"); + let current = resolve(parent); + for (;;) { + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.uid !== euid && stat.uid !== 0) + || realpathSync.native(current) !== current) throw new Error("unsafe_store"); + const sharedWritable = (stat.mode & 0o022) !== 0; + const stickyRootDirectory = (stat.mode & 0o1000) !== 0 && stat.uid === 0; + if (sharedWritable && !stickyRootDirectory) throw new Error("unsafe_store"); + const ancestor = dirname(current); + if (ancestor === current) return; + current = ancestor; + } +} +function prepare(configDir?: string, create = false, createConfigRoot = false) { const p = paths(configDir); if (process.platform === "win32" || process.geteuid?.() === undefined) throw new Error("unsupported_store"); - const configStat = lstatSync(p.config); + const parent = dirname(p.config); + const parentStat = lstatSync(parent); + if (!parentStat.isDirectory() || parentStat.isSymbolicLink() || parentStat.uid !== process.geteuid() + || (parentStat.mode & 0o022) !== 0 || (parentStat.mode & 0o300) !== 0o300 + || realpathSync.native(parent) !== parent) throw new Error("unsafe_store"); + assertTrustedConfigAncestry(parent); + let configStat: ReturnType; + try { configStat = lstatSync(p.config); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !createConfigRoot) throw error; + const parentIdentity = { dev: parentStat.dev, ino: parentStat.ino }; + let created = false; + try { mkdirSync(p.config, { mode: 0o700 }); created = true; } + catch (mkdirError) { if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; } + const parentAfter = lstatSync(parent); + if (parentAfter.dev !== parentIdentity.dev || parentAfter.ino !== parentIdentity.ino + || !parentAfter.isDirectory() || parentAfter.isSymbolicLink() || parentAfter.uid !== process.geteuid() + || (parentAfter.mode & 0o022) !== 0 || (parentAfter.mode & 0o300) !== 0o300 + || realpathSync.native(parent) !== parent) { + throw new Error("unsafe_store"); + } + configStat = lstatSync(p.config); + if (!configStat.isDirectory() || configStat.isSymbolicLink() || configStat.uid !== process.geteuid() + || realpathSync.native(p.config) !== p.config) throw new Error("unsafe_store"); + if (created && ((configStat.mode & 0o077) !== 0 || (configStat.mode & 0o700) !== 0o700)) { + throw new Error("unsafe_store"); + } + configStat = lstatSync(p.config); + } if (!configStat.isDirectory() || configStat.isSymbolicLink() || realpathSync.native(p.config) !== p.config || (configStat.mode & 0o022) !== 0) throw new Error("unsafe_store"); if (create) { @@ -92,8 +136,8 @@ function readStore(configDir?: string): Store { return value; } finally { if (fd !== undefined) closeSync(fd); } } -function withLock(configDir: string | undefined, fn: (p: ReturnType) => T): T { - const p = prepare(configDir, true); +function withLock(configDir: string | undefined, fn: (p: ReturnType) => T, createConfigRoot = false): T { + const p = prepare(configDir, true, createConfigRoot); let lockIdentity: { dev: number; ino: number } | undefined; let ownerIdentity: { dev: number; ino: number } | undefined; const owner = join(p.lock, "owner.json"); @@ -188,9 +232,10 @@ export function readCodexCliUpdateJob(jobId: string, configDir?: string): CodexC } /** Persist dedupe admission before returning; a duplicate key never schedules a second installer. */ -export function admitCodexCliUpdateJob(input: { requestId: string; planId: string; configDir?: string }): +export function admitCodexCliUpdateJob(input: { requestId: string; planId: string; confirmed: true; configDir?: string }): { kind: "accepted" | "duplicate"; job: CodexCliUpdateJobDto; generation: string } { - if (!/^[A-Za-z0-9._:-]{8,128}$/.test(input.requestId) || !/^[0-9a-f]{32}$/.test(input.planId)) throw new Error("invalid_request"); + if (input.confirmed !== true || !/^[A-Za-z0-9._:-]{8,128}$/.test(input.requestId) + || !/^[0-9a-f]{32}$/.test(input.planId)) throw new Error("invalid_request"); const requestKey = digest(input.requestId); const payloadHash = digest(JSON.stringify({ planId: input.planId })); return withLock(input.configDir, p => { @@ -208,7 +253,7 @@ export function admitCodexCliUpdateJob(input: { requestId: string; planId: strin store.jobs.push(record); writeStore(p, store); live.set(record.id, record.generation); return { kind: "accepted", job: dto(record), generation: record.generation }; - }); + }, true); } /** Updates are generation/revision fenced so an old worker cannot replace a newer state. */ diff --git a/src/server/management/codex-cli-update-routes.ts b/src/server/management/codex-cli-update-routes.ts index b20320af36..4ece7ed2ad 100644 --- a/src/server/management/codex-cli-update-routes.ts +++ b/src/server/management/codex-cli-update-routes.ts @@ -39,7 +39,7 @@ export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promis return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config); } try { - const admitted = admitCodexCliUpdateJob({ requestId: value.requestId, planId: value.planId }); + const admitted = admitCodexCliUpdateJob({ requestId: value.requestId, planId: value.planId, confirmed: true }); if (admitted.kind === "accepted") { const work = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, () => applyCodexCliUpdate(value.planId as string, { inspectionDeps: codexCliUpdateInspectionDeps() })); diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 351568499a..8d8fd50e27 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -104,6 +104,6 @@ lease release marks retention unverified without preventing staging cleanup; cle 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. -`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. 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. +`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. `src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed `gui-session` plus trusted-loopback apply admission, and authenticated status. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 2dad1936b2..b04611792d 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; @@ -44,7 +45,173 @@ const identityReport: CodexCliInstallationIdentityReport = { proof: "windows-handle-bound", toolchain: "observed-only", }; +function privateStoreParent(prefix: string): { outer: string; parent: string } { + const outer = realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); + try { + chmodSync(outer, 0o700); + const parentPath = join(outer, "private-parent"); + mkdirSync(parentPath, { mode: 0o700 }); + chmodSync(parentPath, 0o700); + const parent = realpathSync.native(parentPath); + const stat = lstatSync(parent); + const diagnostic = { + canonicalEqual: realpathSync.native(parent) === parent, + modeSafe: (stat.mode & 0o022) === 0, + isDirectory: stat.isDirectory(), + notSymlink: !stat.isSymbolicLink(), + ownerMatches: process.geteuid?.() !== undefined && stat.uid === process.geteuid(), + }; + if (!Object.values(diagnostic).every(Boolean)) { + throw new Error(`private job-store fixture parent failed safe checks: ${JSON.stringify(diagnostic)}`); + } + return { outer, parent }; + } catch (error) { + rmSync(outer, { recursive: true }); + throw error; + } +} + +function privateStoreRoot(prefix: string): { outer: string; parent: string; configDir: string } { + const fixture = privateStoreParent(prefix); + try { + const configPath = join(fixture.parent, "ocx"); + mkdirSync(configPath, { mode: 0o700 }); + chmodSync(configPath, 0o700); + const configDir = realpathSync.native(configPath); + const stat = lstatSync(configDir); + const diagnostic = { + canonicalEqual: realpathSync.native(configDir) === configDir, + modePrivate: (stat.mode & 0o077) === 0, + isDirectory: stat.isDirectory(), + notSymlink: !stat.isSymbolicLink(), + ownerMatches: process.geteuid?.() !== undefined && stat.uid === process.geteuid(), + }; + if (!Object.values(diagnostic).every(Boolean)) { + throw new Error(`private job-store config root failed safe checks: ${JSON.stringify(diagnostic)}`); + } + return { ...fixture, configDir }; + } catch (error) { + rmSync(fixture.outer, { recursive: true }); + throw error; + } +} + +function hasTrustedStickyTmp(): boolean { + if (process.platform === "win32" || process.geteuid?.() === undefined) return false; + try { + const canonical = realpathSync.native("/tmp"); + const stat = lstatSync(canonical); + return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === 0 && (stat.mode & 0o1000) !== 0; + } catch { return false; } +} + describe("Codex CLI update CLI", () => { + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed admission creates one missing config root without making reads write", () => { + const { outer, parent } = privateStoreParent("ocx-cli-update-missing-parent-"); + const configDir = join(parent, "ocx"); + try { + expect(readCodexCliUpdateJob("12345678-1234-1234-1234-123456789012", configDir)).toBeNull(); + expect(existsSync(configDir)).toBe(false); + const missingParent = join(parent, "not-created"); + expect(() => admitCodexCliUpdateJob({ requestId: "no-recursive-root-001", planId: "c".repeat(32), confirmed: true, + configDir: join(missingParent, "ocx") })).toThrow(); + expect(existsSync(missingParent)).toBe(false); + const accepted = admitCodexCliUpdateJob({ requestId: "missing-root-admission-01", planId: "a".repeat(32), confirmed: true, configDir }); + const stat = lstatSync(configDir); + expect({ + canonicalEqual: realpathSync.native(configDir) === configDir, + modePrivate: (stat.mode & 0o077) === 0, + isDirectory: stat.isDirectory(), + isSymlink: stat.isSymbolicLink(), + ownerMatches: process.geteuid?.() !== undefined && stat.uid === process.geteuid?.(), + accepted: accepted.kind === "accepted", + }).toEqual({ canonicalEqual: true, modePrivate: true, isDirectory: true, isSymlink: false, ownerMatches: true, accepted: true }); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed admission reuses an existing canonical config root", () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-existing-root-"); + try { + const before = lstatSync(configDir); + const accepted = admitCodexCliUpdateJob({ requestId: "existing-root-admission-01", planId: "b".repeat(32), confirmed: true, configDir }); + const after = lstatSync(configDir); + expect(accepted.kind).toBe("accepted"); + expect(after.dev).toBe(before.dev); + expect(after.ino).toBe(before.ino); + expect(realpathSync.native(configDir)).toBe(configDir); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(!hasTrustedStickyTmp())("missing config root admission allows a root-owned sticky temporary ancestor", () => { + const stickyTmp = realpathSync.native("/tmp"); + const directConfig = join(stickyTmp, `ocx-cli-update-direct-sticky-${randomUUID()}`); + expect(() => admitCodexCliUpdateJob({ requestId: "direct-sticky-parent-001", planId: "d".repeat(32), + confirmed: true, configDir: directConfig })).toThrow("unsafe_store"); + expect(existsSync(directConfig)).toBe(false); + const outer = realpathSync.native(mkdtempSync(join(stickyTmp, "ocx-cli-update-sticky-parent-"))); + try { + chmodSync(outer, 0o700); + const parent = join(outer, "private-parent"); + mkdirSync(parent, { mode: 0o700 }); + chmodSync(parent, 0o700); + const configDir = join(parent, "ocx"); + const accepted = admitCodexCliUpdateJob({ requestId: "sticky-parent-admission-001", planId: "d".repeat(32), confirmed: true, configDir }); + const stat = lstatSync(configDir); + expect(accepted.kind).toBe("accepted"); + expect((stat.mode & 0o077) === 0 && stat.isDirectory() && !stat.isSymbolicLink() + && process.geteuid?.() !== undefined && stat.uid === process.geteuid?.() + && realpathSync.native(configDir) === configDir).toBe(true); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("missing config root admission refuses a non-sticky shared ancestor before mkdir", () => { + const { outer, parent } = privateStoreParent("ocx-cli-update-shared-ancestor-"); + const configDir = join(parent, "ocx"); + try { + chmodSync(outer, 0o777); + expect(() => admitCodexCliUpdateJob({ requestId: "shared-ancestor-admission-01", planId: "e".repeat(32), + confirmed: true, configDir })).toThrow("unsafe_store"); + expect(existsSync(configDir)).toBe(false); + } finally { + chmodSync(outer, 0o700); + rmSync(outer, { recursive: true }); + } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("existing config root refuses unsafe ancestry before admission or completion writes", async () => { + const { outer, parent, configDir } = privateStoreRoot("ocx-cli-update-existing-shared-ancestor-"); + let release: (() => void) | undefined; + let pending: Promise | undefined; + try { + const accepted = admitCodexCliUpdateJob({ requestId: "existing-shared-admission-001", planId: "f".repeat(32), + confirmed: true, configDir }); + pending = runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, () => new Promise(resolve => { + release = () => resolve({ outcome: "applied", reason: "verified_target", planId: "f".repeat(32), + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }); + }), configDir); + if (!release) throw new Error("fixture job owner did not enter its awaited operation"); + const ledger = join(configDir, "codex-cli-update-jobs", "jobs.json"); + const before = readFileSync(ledger); + chmodSync(outer, 0o777); + expect(() => admitCodexCliUpdateJob({ requestId: "blocked-existing-admission-001", planId: "1".repeat(32), + confirmed: true, configDir })).toThrow("unsafe_store"); + expect(() => readCodexCliUpdateJob(accepted.job.jobId, configDir)).toThrow("unsafe_store"); + expect(() => finishCodexCliUpdateJob(accepted.job.jobId, accepted.generation, 1, { + outcome: "applied", reason: "verified_target", planId: "f".repeat(32), installedVersion: "1.0.0", + targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [], + }, configDir)).toThrow("unsafe_store"); + await expect(runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, + async () => { throw new Error("unsafe ancestry must reject before running"); }, configDir)).rejects.toThrow("unsafe_store"); + expect(readFileSync(ledger)).toEqual(before); + } finally { + chmodSync(outer, 0o700); + release?.(); + if (pending) await pending.catch(() => undefined); + rmSync(outer, { recursive: true }); + } + }); + test("job lifecycle projection keeps lease and child uncertainty distinct from the outcome", () => { const project = (input: Parameters[0]) => projectCodexCliUpdateJobLifecycle(input); const base = { active: false, status: "ambiguous" as const, outcome: "ambiguous" as const, @@ -68,17 +235,16 @@ describe("Codex CLI update CLI", () => { }); test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("apply retry keys deduplicate durable jobs and status exposes only the shared public DTO", async () => { - const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-job-test-")); - chmodSync(configDir, 0o700); + const { outer, configDir } = privateStoreRoot("ocx-cli-update-job-test-"); try { const requestId = "retry-key-0000001"; const planId = "a".repeat(32); - const accepted = admitCodexCliUpdateJob({ requestId, planId, configDir }); - const replay = admitCodexCliUpdateJob({ requestId, planId, configDir }); + const accepted = admitCodexCliUpdateJob({ requestId, planId, confirmed: true, configDir }); + const replay = admitCodexCliUpdateJob({ requestId, planId, confirmed: true, configDir }); expect(accepted.kind).toBe("accepted"); expect(replay.kind).toBe("duplicate"); expect(replay.job.jobId).toBe(accepted.job.jobId); - expect(() => admitCodexCliUpdateJob({ requestId, planId: "b".repeat(32), configDir })).toThrow("idempotency_conflict"); + expect(() => admitCodexCliUpdateJob({ requestId, planId: "b".repeat(32), confirmed: true, configDir })).toThrow("idempotency_conflict"); const previousHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = configDir; const statusLogs: string[] = []; @@ -107,16 +273,15 @@ describe("Codex CLI update CLI", () => { installerExitCode: null, leaseRetained: false, verificationPid: null, cleanupErrors: [] }, configDir); expect(stale?.status).toBe("applied"); expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)?.status).toBe("applied"); - } finally { rmSync(configDir, { recursive: true }); } + } finally { rmSync(outer, { recursive: true }); } }); test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a new owner reports a live owner's job unknown; the original generation can finish without stale overwrite", async () => { - const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-restart-test-")); - chmodSync(configDir, 0o700); + const { outer, configDir } = privateStoreRoot("ocx-cli-update-restart-test-"); let release: () => void = () => {}; let pending: Promise | undefined; try { - const accepted = admitCodexCliUpdateJob({ requestId: "restart-key-00001", planId: "c".repeat(32), configDir }); + const accepted = admitCodexCliUpdateJob({ requestId: "restart-key-00001", planId: "c".repeat(32), confirmed: true, configDir }); pending = runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, () => new Promise(resolve => { release = () => resolve({ outcome: "applied", reason: "verified_target", planId: "c".repeat(32), installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, @@ -142,28 +307,26 @@ describe("Codex CLI update CLI", () => { } finally { release(); if (pending) await pending.catch(() => undefined); - rmSync(configDir, { recursive: true }); + rmSync(outer, { recursive: true }); } }); test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an exception without close evidence preserves child and lease uncertainty", async () => { - const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-unknown-child-test-")); - chmodSync(configDir, 0o700); + const { outer, configDir } = privateStoreRoot("ocx-cli-update-unknown-child-test-"); try { - const accepted = admitCodexCliUpdateJob({ requestId: "unknown-child-0001", planId: "f".repeat(32), configDir }); + const accepted = admitCodexCliUpdateJob({ requestId: "unknown-child-0001", planId: "f".repeat(32), confirmed: true, configDir }); const result = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, async () => { throw new Error("unobservable child exception"); }, configDir); expect(result).toMatchObject({ status: "ambiguous", outcome: "ambiguous", ownerState: "finished", childState: "unknown", leaseState: "unknown", lifetimeUncertain: true, reason: "job_result_unknown" }); - } finally { rmSync(configDir, { recursive: true }); } + } finally { rmSync(outer, { recursive: true }); } }); test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an engine exception while its fixture child is live reports unknown child state", async () => { - const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-live-child-test-")); - chmodSync(configDir, 0o700); + const { outer, configDir } = privateStoreRoot("ocx-cli-update-live-child-test-"); let child: ChildProcess | undefined; try { - const accepted = admitCodexCliUpdateJob({ requestId: "live-child-exception-001", planId: "9".repeat(32), configDir }); + const accepted = admitCodexCliUpdateJob({ requestId: "live-child-exception-001", planId: "9".repeat(32), confirmed: true, configDir }); const result = await runCodexCliUpdateJob(accepted.job.jobId, accepted.generation, async () => { child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", windowsHide: true }); await new Promise((resolve, reject) => { @@ -191,7 +354,7 @@ describe("Codex CLI update CLI", () => { await closed; } } - rmSync(configDir, { recursive: true }); + rmSync(outer, { recursive: true }); } }); diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index ce1f639600..a5d2c4c9a2 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -265,11 +265,30 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { session = await issueFixtureGuiSession(base); const planResponse = await fetch(`${base}/api/codex/cli-update/plan`, { headers: fixtureSessionHeaders(session) }); if (!planResponse.ok) throw new Error(`isolated production plan refused (${planResponse.status})`); - const plan = await planResponse.json() as { applicable: boolean; planId: string | null; managed: boolean; - installedVersion: string | null; targetVersion: string | null; installContentDigest: string | null }; + const plan = await planResponse.json() as { schemaVersion?: number; applicable: boolean; refusal?: string | null; + planId: string | null; provenance?: string; managed: boolean; installedVersion: string | null; + targetVersion: string | null; installContentDigest: string | null; + session?: { state?: string; matches?: number | null } }; if (!plan.applicable || !plan.planId || !plan.managed || plan.installedVersion !== "1.0.0" || plan.targetVersion !== "1.1.0" || !plan.installContentDigest) { - throw new Error("published launcher management route did not produce the fixture-owned managed plan"); + // Keep the diagnostic useful for separating missing launcher provenance from a + // fixture/target refusal, without printing paths, commands, environment or IDs. + const refusalCodes = ["windows_inspection_deferred", "windows_apply_disabled", "not_managed", + "installed_version_unverified", "install_content_unverified", "target_unresolved", "already_current", + "target_not_newer", "blocked_active_session", "blocked_process_state_unknown"]; + const provenanceKinds = ["npm-global", "app-bundle", "version-manager", "standalone-unverified", "unknown"]; + const sessionStates = ["none", "active", "unknown", "not-evaluated"]; + const diagnostic = { + schemaVersion: plan.schemaVersion === 1 ? 1 : null, applicable: plan.applicable === true, + refusal: typeof plan.refusal === "string" && refusalCodes.includes(plan.refusal) ? plan.refusal : null, + provenance: typeof plan.provenance === "string" && provenanceKinds.includes(plan.provenance) ? plan.provenance : null, + managed: plan.managed === true, planIdPresent: typeof plan.planId === "string", + installedVersionMatches: plan.installedVersion === "1.0.0", targetVersionMatches: plan.targetVersion === "1.1.0", + installContentDigestPresent: typeof plan.installContentDigest === "string", + sessionState: typeof plan.session?.state === "string" && sessionStates.includes(plan.session.state) ? plan.session.state : null, + sessionMatches: Number.isSafeInteger(plan.session?.matches) && (plan.session?.matches ?? -1) >= 0 ? plan.session!.matches : null, + }; + throw new Error(`published launcher management plan was not applicable: ${JSON.stringify(diagnostic)}`); } const requestId = "lost-response-management-e2e-01"; @@ -336,7 +355,7 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { // original generation's late completion. const configDir = join(f.home, "ocx"); const restartPlanId = "b".repeat(32); - const oldOwner = admitCodexCliUpdateJob({ requestId: "server-restart-owner-unknown-01", planId: restartPlanId, configDir }); + const oldOwner = admitCodexCliUpdateJob({ requestId: "server-restart-owner-unknown-01", planId: restartPlanId, confirmed: true, configDir }); oldOwnerWork = runCodexCliUpdateJob(oldOwner.job.jobId, oldOwner.generation, () => new Promise(resolve => { finishOldOwner = () => resolve({ outcome: "applied", reason: "verified_target", planId: restartPlanId, installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index 30a1aa6bcd..f3d2f1384f 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -6,7 +6,7 @@ import { fixtureArtifact, fixtureArtifactSet, fixtureTar } from "../helpers/code import { applyCodexCliUpdate, cleanupCodexCliApply, verifyInstalledCodexVersion } from "../../src/codex/cli-update-apply"; import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; import { parseCodexCliUpdateArgs, handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; -import { closeSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { gzipSync, gunzipSync } from "node:zlib"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -250,15 +250,47 @@ describe("Codex CLI apply consumer", () => { }); test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed apply reaches its consumer with the exact plan ID and prints its public result", async () => { const planId = "a".repeat(32); let calls = 0; const logs: string[] = [], log = console.log; + const outer = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-apply-cli-job-"))); + const previousHome = process.env.OPENCODEX_HOME; const report = { outcome: "not_applied" as const, reason: "original_install_unchanged", planId, installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }; try { + chmodSync(outer, 0o700); + const parent = join(outer, "private-parent"); + mkdirSync(parent, { mode: 0o700 }); + chmodSync(parent, 0o700); + const configPath = join(parent, "ocx"); + mkdirSync(configPath, { mode: 0o700 }); + chmodSync(configPath, 0o700); + const configDir = realpathSync.native(configPath); + chmodSync(configDir, 0o700); + process.env.OPENCODEX_HOME = configDir; console.log = value => { logs.push(String(value)); }; expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", planId, "--yes", "--json"], { applyUpdate: async (id, deps) => { calls++; expect(id).toBe(planId); expect(deps.inspectionDeps).toBeDefined(); return report; }, })).toBe(0); - expect(calls).toBe(1); expect(logs).toHaveLength(1); expect(JSON.parse(logs[0]!)).toEqual(report); - } finally { console.log = log; } + expect(calls).toBe(1); expect(logs).toHaveLength(1); + expect(fs.existsSync(join(configDir, "codex-cli-update-jobs", "jobs.json"))).toBe(true); + const dto = JSON.parse(logs[0]!) as Record; + expect(dto).toMatchObject({ jobId: expect.stringMatching(/^[0-9a-f-]{36}$/), status: "not_applied", + ownerState: "finished", lifetimeUncertain: false, childState: "terminal", + leaseState: "not_acquired_or_released", planId, outcome: "not_applied", + reason: "original_install_unchanged", cleanupErrors: [], + createdAt: expect.any(String), updatedAt: expect.any(String) }); + expect(Number.isFinite(Date.parse(dto.createdAt as string))).toBe(true); + expect(Number.isFinite(Date.parse(dto.updatedAt as string))).toBe(true); + expect(Object.keys(dto).sort()).toEqual([ + "childState", "cleanupErrors", "createdAt", "jobId", "leaseState", "lifetimeUncertain", + "outcome", "ownerState", "planId", "reason", "status", "updatedAt", + ]); + for (const field of ["requestKey", "payloadHash", "generation", "ownerPid", "verificationPid", "environment", "token", "leaseRetained"]) { + expect(Object.hasOwn(dto, field)).toBe(false); + } + } finally { + console.log = log; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(outer); + } }); test("Windows apply refuses before inspecting or installing", async () => { let calls = 0; From 8e0eeaaccd2f4942433c76896453dac41a977a93 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:11:00 +0900 Subject: [PATCH 09/33] test(codex): preserve job DTO evidence during matching (cherry picked from commit 1c61f31178ad02ae2990b855069d50807aadf9ff) --- .../codex-cli-update-apply.test.ts | 31 ++++++++++++++++--- .../codex-cli-update-artifacts.test.ts | 12 +++++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index a5d2c4c9a2..b62c2dcbcf 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -212,6 +212,18 @@ function gateFixtureNpmInstall(f: ReturnType) { const source = readFileSync(path, "utf8"); const marker = "const installed = cp.spawnSync("; if (source.split(marker).length !== 2) throw new Error("fixture npm wrapper shape changed"); + const viewMarker = 'if (args[0] === "view") { if (!metadata[args[1]]) process.exit(81); console.log(JSON.stringify(metadata[args[1]])); process.exit(0); }'; + if (source.split(viewMarker).length !== 2) throw new Error("fixture npm metadata wrapper shape changed"); + const metadataTrace = join(f.root, "metadata-view-trace.jsonl"); + const tracedView = [ + 'if (args[0] === "view") {', + ' const spec = args[1], value = metadata[spec];', + ' const stage = spec === "@openai/codex@latest" ? "latest" : value?.name === "@openai/codex" && value?.version?.includes("-") ? "backing" : "pinned";', + ` fs.appendFileSync(${JSON.stringify(metadataTrace)}, JSON.stringify({stage,spec,exitCode:value?0:81,signal:null,spawnError:null,stdoutJsonValid:!!value,reason:value?"metadata_returned":"fixture_metadata_missing",name:value?.name??null,version:value?.version??null,optionalDependencyKeys:value?.optionalDependencies?Object.keys(value.optionalDependencies):[],hasIntegrity:typeof value?.dist?.integrity==="string",hasTarball:typeof value?.dist?.tarball==="string"})+"\\n");`, + ' if (!value) process.exit(81); console.log(JSON.stringify(value)); process.exit(0);', + '}', + ].join("\n"); + const tracedSource = source.replace(viewMarker, tracedView); const wait = [ 'const fixtureGate = process.env.OCX_FIXTURE_NPM_GATE;', 'if (fixtureGate) {', @@ -221,7 +233,7 @@ function gateFixtureNpmInstall(f: ReturnType) { ' if (!fs.existsSync(fixtureGate)) process.exit(85);', '}', ].join("\n") + "\n"; - writeFileSync(path, source.replace(marker, wait + marker), { encoding: "utf8", mode: 0o755 }); + writeFileSync(path, tracedSource.replace(marker, wait + marker), { encoding: "utf8", mode: 0o755 }); chmodSync(path, 0o755); } @@ -235,8 +247,8 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { expect(plan.applicable).toBe(true); expect(plan.managed).toBe(true); expect(plan.targetArtifacts.backing.name).toBe("@openai/codex"); const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); - expect(result.outcome).toBe("applied"); expect(result.installedVersion).toBe("1.1.0"); - expect(result.leaseRetained).toBe(false); + expect(result).toMatchObject({ status: "applied", outcome: "applied", childState: "terminal", + leaseState: "not_acquired_or_released", lifetimeUncertain: false }); const after = cli(f, ["check"]); expect(after.managed).toBe(true); expect(after.packageVersion).toBe("1.1.0"); const calls = readFileSync(f.trace, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); expect(calls).toHaveLength(1); @@ -287,6 +299,12 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { installContentDigestPresent: typeof plan.installContentDigest === "string", sessionState: typeof plan.session?.state === "string" && sessionStates.includes(plan.session.state) ? plan.session.state : null, sessionMatches: Number.isSafeInteger(plan.session?.matches) && (plan.session?.matches ?? -1) >= 0 ? plan.session!.matches : null, + metadataQueries: existsSync(join(f.root, "metadata-view-trace.jsonl")) + ? readFileSync(join(f.root, "metadata-view-trace.jsonl"), "utf8").split("\n").filter(Boolean) + .map(line => JSON.parse(line) as { stage: string; spec: string; exitCode: number; signal: null; + spawnError: null; stdoutJsonValid: boolean; reason: string; name: string | null; version: string | null; + optionalDependencyKeys: string[]; hasIntegrity: boolean; hasTarball: boolean }) + : [], }; throw new Error(`published launcher management plan was not applicable: ${JSON.stringify(diagnostic)}`); } @@ -414,13 +432,16 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { Object.assign(f.env, { TMPDIR: alias, NODE_COMPILE_CACHE: join(f.root, "node-compile-cache") }); const plan = cli(f, ["plan"]); expect(plan.applicable).toBe(true); const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); - expect(result.outcome).toBe("applied"); expect(result.installedVersion).toBe("1.1.0"); - expect(result.leaseRetained).toBe(false); + expect(result).toMatchObject({ status: "applied", outcome: "applied", childState: "terminal", + leaseState: "not_acquired_or_released", lifetimeUncertain: false }); + const after = cli(f, ["check"]); + expect(after.managed).toBe(true); expect(after.packageVersion).toBe("1.1.0"); const calls = readFileSync(f.trace, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); expect(calls).toHaveLength(1); const archives = calls[0]!.filter(arg => arg.includes("@file:")).map(arg => arg.split("@file:")[1]!); expect(archives).toHaveLength(2); for (const archive of archives) { expect(archive.startsWith(f.temporaryRoot + "/")).toBe(true); expect(archive.startsWith(alias + "/")).toBe(false); } + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); expect(readdirSync(f.temporaryRoot)).toEqual([]); }, 120_000); diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index f3d2f1384f..fe749b3f46 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -272,13 +272,19 @@ describe("Codex CLI apply consumer", () => { expect(calls).toBe(1); expect(logs).toHaveLength(1); expect(fs.existsSync(join(configDir, "codex-cli-update-jobs", "jobs.json"))).toBe(true); const dto = JSON.parse(logs[0]!) as Record; - expect(dto).toMatchObject({ jobId: expect.stringMatching(/^[0-9a-f-]{36}$/), status: "not_applied", + const matcherDto = structuredClone(dto); + expect(typeof dto.jobId).toBe("string"); + expect(dto.jobId).toMatch(/^[0-9a-f-]{36}$/); + expect(matcherDto).toMatchObject({ jobId: expect.stringMatching(/^[0-9a-f-]{36}$/), status: "not_applied", ownerState: "finished", lifetimeUncertain: false, childState: "terminal", leaseState: "not_acquired_or_released", planId, outcome: "not_applied", reason: "original_install_unchanged", cleanupErrors: [], createdAt: expect.any(String), updatedAt: expect.any(String) }); - expect(Number.isFinite(Date.parse(dto.createdAt as string))).toBe(true); - expect(Number.isFinite(Date.parse(dto.updatedAt as string))).toBe(true); + const isIsoUtcTimestamp = (value: unknown) => typeof value === "string" + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) + && Number.isFinite(Date.parse(value)); + expect(isIsoUtcTimestamp(dto.createdAt)).toBe(true); + expect(isIsoUtcTimestamp(dto.updatedAt)).toBe(true); expect(Object.keys(dto).sort()).toEqual([ "childState", "cleanupErrors", "createdAt", "jobId", "leaseState", "lifetimeUncertain", "outcome", "ownerState", "planId", "reason", "status", "updatedAt", From 224e797883651c115915a2e612c7b5986fa0cb3f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:29:18 +0900 Subject: [PATCH 10/33] test(codex): isolate npm wrapper module scope (cherry picked from commit 951e594bf444590c91ee2e6c87a4084af64feb80) --- .../codex-cli-update-apply.test.ts | 34 +++++++++++++++++-- tests/helpers/codex-cli-apply-fixture.ts | 29 ++++++++++------ .../server/management-route-registry.test.ts | 20 +++++++---- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index b62c2dcbcf..c64e35c25d 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -9,7 +9,8 @@ import { applyCodexCliUpdate, type CodexCliApplyDeps } from "../../src/codex/cli import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, runCodexCliUpdateJob, type CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; -import { createCodexCliApplyFixture, waitForApplyAdmission } from "../helpers/codex-cli-apply-fixture"; +import { createCodexCliApplyFixture, waitForApplyAdmission, writeFixtureNpmWrapper } from "../helpers/codex-cli-apply-fixture"; +import { fixtureArtifactSet } from "../helpers/codex-cli-apply-artifact"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; @@ -207,7 +208,7 @@ async function waitForDurableAdmission(configDir: string, requestId: string, tim throw new Error("fixture durable job admission was not observed"); } -function gateFixtureNpmInstall(f: ReturnType) { +function gateFixtureNpmInstall(f: Pick, "root">) { const path = join(f.root, "bin", "npm"); const source = readFileSync(path, "utf8"); const marker = "const installed = cp.spawnSync("; @@ -240,6 +241,35 @@ function gateFixtureNpmInstall(f: ReturnType) { async function postFixtureApply(base: string, session: { token: string; csrf: string; origin: string }, body: unknown) { return fetch(`${base}/api/codex/cli-update/apply`, { method: "POST", headers: fixtureSessionHeaders(session, true), body: JSON.stringify(body) }); } +test("HTTP fixture npm wrapper returns all metadata under an ESM ancestor", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-http-npm-module-"))); roots.push(root); + writeFileSync(join(root, "package.json"), JSON.stringify({ private: true, type: "module" })); + const fakeBin = join(root, "bin"); mkdirSync(fakeBin); + const node = Bun.which("node"); if (!node) throw new Error("fixture node unavailable"); + const f = fixtureArtifactSet("linux", "x64"); + const main = { ...f.mainManifest, dist: { integrity: f.artifacts.main.integrity, tarball: f.artifacts.main.tarball } }; + const backing = { name: f.artifacts.backing.name, version: f.artifacts.backing.version, + dist: { integrity: f.artifacts.backing.integrity, tarball: f.artifacts.backing.tarball } }; + const metadata = { "@openai/codex@latest": main, "@openai/codex@1.1.0": main, + ["@openai/codex@" + backing.version]: backing }; + const installTrace = join(root, "install-trace.jsonl"); + writeFixtureNpmWrapper(fakeBin, node, join(root, "unused-npm.cjs"), metadata, {}, installTrace); + gateFixtureNpmInstall({ root }); + const env: NodeJS.ProcessEnv = { ...process.env, NODE_COMPILE_CACHE: join(root, "node-cache") }; + for (const key of Object.keys(env)) if (["node_options", "node_path", "node_v8_coverage", "node_redirect_warnings"].includes(key.toLowerCase())) delete env[key]; + for (const [spec, expected] of Object.entries(metadata)) { + const result = spawnSync(node, [join(fakeBin, "npm"), "view", spec, "--json"], { + cwd: root, env, encoding: "utf8", timeout: 10_000, windowsHide: true, + }); + expect({ exitCode: result.status, spawnFailed: !!result.error, + requireUndefined: result.stderr?.includes("require is not defined") }).toEqual({ exitCode: 0, spawnFailed: false, requireUndefined: false }); + expect(JSON.parse(result.stdout)).toEqual(expected); + } + const views = readFileSync(join(root, "metadata-view-trace.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + expect(views.map(value => value.stage)).toEqual(["latest", "pinned", "backing"]); + expect(views.every(value => value.reason === "metadata_returned")).toBe(true); + expect(existsSync(installTrace)).toBe(false); +}); describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { test("published Node launcher -> real resolver/provenance -> plan/apply -> verified new files", () => { const f = fixture(); diff --git a/tests/helpers/codex-cli-apply-fixture.ts b/tests/helpers/codex-cli-apply-fixture.ts index b54f75a503..73371d5d17 100644 --- a/tests/helpers/codex-cli-apply-fixture.ts +++ b/tests/helpers/codex-cli-apply-fixture.ts @@ -29,6 +29,24 @@ export function privateNpm(root: string, args: string[]) { if (result.error || result.status !== 0) throw new Error(`private npm failed: ${result.status} ${result.error?.message ?? ""}\n${result.stderr}`); return result; } +/** The same synthetic npm entrypoint used by the published-launcher fixture. */ +export function writeFixtureNpmWrapper(fakeBin: string, node: string, realNpm: string, + metadata: Record, packs: Record, trace: string): void { + // An extensionless Node entrypoint inherits its nearest package's module type. + // HTTP fixtures live under this ESM repository; keep only this synthetic bin + // CommonJS without changing either installed package's original manifest. + writeFileSync(join(fakeBin, "package.json"), JSON.stringify({ private: true, type: "commonjs" }) + "\n"); + const wrapper = [ + `#!${node}`, 'const fs = require("node:fs"), path = require("node:path"), cp = require("node:child_process");', + `const metadata = ${JSON.stringify(metadata)}, packs = ${JSON.stringify(packs)};`, + 'const args = process.argv.slice(2);', + 'if (args[0] === "view") { if (!metadata[args[1]]) process.exit(81); console.log(JSON.stringify(metadata[args[1]])); process.exit(0); }', + 'if (args[0] === "pack") { const source = packs[args[1]]; if (!source) process.exit(82); const dir = args[args.indexOf("--pack-destination") + 1]; const name = path.basename(source); fs.copyFileSync(source, path.join(dir, name)); console.log(JSON.stringify([{filename:name}])); process.exit(0); }', + `if (args[0] !== "install") process.exit(83); fs.appendFileSync(${JSON.stringify(trace)}, JSON.stringify(args) + "\\n");`, + `const installed = cp.spawnSync(${JSON.stringify(node)}, [${JSON.stringify(realNpm)}, ...args], {env:process.env, stdio:"inherit"}); process.exit(installed.status ?? 84);`, + ].join("\n") + "\n"; + writeFileSync(join(fakeBin, "npm"), wrapper); chmodSync(join(fakeBin, "npm"), 0o755); +} /** Tiny synthetic packages are packed by real npm; metadata and pack bytes are * substituted by an owned npm wrapper, while install delegates to real npm. * This exercises published Node -> Bun CLI -> resolver/provenance -> plan/apply. @@ -81,16 +99,7 @@ export function createCodexCliApplyFixture(root: string, platform: NodeJS.Platfo ["@openai/codex@" + v.artifacts.main.version, v.main.path], ["@openai/codex@" + v.artifacts.backing.version, v.backing.path], ])); if (process.platform !== "win32") { - const wrapper = [ - `#!${node}`, 'const fs = require("node:fs"), path = require("node:path"), cp = require("node:child_process");', - `const metadata = ${JSON.stringify(metadata)}, packs = ${JSON.stringify(packs)};`, - 'const args = process.argv.slice(2);', - 'if (args[0] === "view") { if (!metadata[args[1]]) process.exit(81); console.log(JSON.stringify(metadata[args[1]])); process.exit(0); }', - 'if (args[0] === "pack") { const source = packs[args[1]]; if (!source) process.exit(82); const dir = args[args.indexOf("--pack-destination") + 1]; const name = path.basename(source); fs.copyFileSync(source, path.join(dir, name)); console.log(JSON.stringify([{filename:name}])); process.exit(0); }', - `if (args[0] !== "install") process.exit(83); fs.appendFileSync(${JSON.stringify(trace)}, JSON.stringify(args) + "\\n");`, - `const installed = cp.spawnSync(${JSON.stringify(node)}, [${JSON.stringify(realNpm)}, ...args], {env:process.env, stdio:"inherit"}); process.exit(installed.status ?? 84);`, - ].join("\n") + "\n"; - writeFileSync(join(fakeBin, "npm"), wrapper); chmodSync(join(fakeBin, "npm"), 0o755); + writeFixtureNpmWrapper(fakeBin, node, realNpm, metadata, packs, trace); } const launcher = process.platform === "win32" ? join(prefix, "codex.cmd") : join(prefix, "bin", "codex"); const env = { ...process.env, HOME: home, TMPDIR: temporaryRoot, OPENCODEX_HOME: join(home, "ocx"), diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index bae7d15797..db3962dbcc 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -113,11 +113,14 @@ describe("management route registry reconciliation", () => { }); test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("HTTP apply retry returns the durable job without scheduling a second engine run", async () => { - const configDir = mkdtempSync(join(tmpdir(), "ocx-cli-update-http-test-")); - chmodSync(configDir, 0o700); + const outer = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-update-http-test-"))); + const parent = join(outer, "private-parent"), configDir = join(parent, "ocx"); const oldHome = process.env.OPENCODEX_HOME; - process.env.OPENCODEX_HOME = configDir; try { + chmodSync(outer, 0o700); + mkdirSync(parent, { mode: 0o700 }); chmodSync(parent, 0o700); + mkdirSync(configDir, { mode: 0o700 }); chmodSync(configDir, 0o700); + process.env.OPENCODEX_HOME = realpathSync.native(configDir); const makeRequest = () => new Request("http://localhost/api/codex/cli-update/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId: "lost-response-retry-01", planId: "e".repeat(32), confirmed: true }) }); @@ -131,8 +134,11 @@ describe("management route registry reconciliation", () => { ctx.req = makeRequest(); const retry = await handleCodexCliUpdateRoutes(ctx); const retryBody = await retry!.json(); - expect(first?.status).toBe(202); - expect(retry?.status).toBe(202); + const diagnostic = (body: { error?: unknown }) => JSON.stringify({ error: + typeof body.error === "string" && ["invalid_request", "idempotency_conflict", "store_full", + "unsupported_store", "unsafe_store", "admission_failed"].includes(body.error) ? body.error : null }); + expect(first?.status, diagnostic(firstBody)).toBe(202); + expect(retry?.status, diagnostic(retryBody)).toBe(202); expect(retryBody.job.jobId).toBe(firstBody.job.jobId); let status: { jobId?: string; ownerState?: string; outcome?: string; reason?: string } = {}; for (let i = 0; i < 100; i += 1) { @@ -146,7 +152,7 @@ describe("management route registry reconciliation", () => { expect(["not_managed", "mutation_evidence_unverified"].includes(status.reason ?? "")).toBe(true); } finally { if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; - rmSync(configDir, { recursive: true }); + rmSync(outer, { recursive: true }); } }); From 7299e4b311073a454a5748178077c8edc82b983c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:39:44 +0900 Subject: [PATCH 11/33] docs(codex): describe POSIX update apply and job consumers --- docs-site/src/content/docs/reference/cli.md | 5 ++++ .../src/content/docs/reference/cli/agents.md | 30 +++++++++++++++++-- src/codex/cli-update-plan.ts | 1 + structure/codex-cli-update.md | 2 ++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index d6ba1d76c3..77086aa31b 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -164,6 +164,11 @@ Registry queries use isolated temporary npm configuration, cache and logs with b No software is installed and no process is signalled. Windows returns `windows_inspection_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` +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). + 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. List or status is the default where unambiguous. Use `--json` only on leaves that support it, and diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 583a68fbad..491d4a2fdc 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1184,12 +1184,36 @@ using the launcher's captured PATH and isolated temporary npm configuration, cac settings and `NODE_EXTRA_CA_CERTS` remain supported; cleanup is best-effort. No Codex command runs. An applicable plan reports `applicable: true` and a deterministic `planId` binding platform, -installation-root digest, installed version, channel and target version/integrity. The plan is never +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. Windows returns `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. No apply command -is exposed, and a plan ID is not installation authorization. +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 + +```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 +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. + +Apply returns a job with `applied`, `not_applied`, `refused` or `ambiguous` outcome. Status reads the +same private job record without a running proxy or GUI token. Reuse the same optional request ID after +a lost response: the same key and plan return the original job; a changed plan with that key refuses. +An ambiguous result is never automatically retried or rolled back. After an owner restart, an unfinished +job reports unknown ownership instead of claiming the old process stopped. Inspect the job's outcome, +child/lease state and cleanup errors; command exit status alone does not establish an installation result. + +The management API provides authenticated plan/status reads and confirmed trusted-loopback GUI-session +apply admission. A published Node `ocx start` supplies the required inspection snapshot; direct Bun or +compiled sidecar startup does not provide that proof. The Dashboard update panel is a separate follow-up. #### Explicit installation observation on Windows x64 diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 93dd6caa4e..5b96bd15d3 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -334,6 +334,7 @@ export function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.Process const env = codexCliUpdateBoundEnv(boundEnv); // npm enables Node's compile cache itself. Dropping the ambient output path // alone recreates /node-compile-cache outside this owned lifetime. + // The version verification child inherits this same controlled path. env.NODE_COMPILE_CACHE = resolve(root, "node-compile-cache"); return { dir: root, npmrc, globalNpmrc, cache, env }; } catch (error) { diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 8d8fd50e27..9493ed85d7 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -71,6 +71,8 @@ exact versions, canonical SHA-512 integrity and official registry tarball URLs; platform and inspection architecture contribute to plan identity. Platform aliases whose published manifest name is also `@openai/codex` are modeled explicitly. The existing isolated npm environment, cwd, cache and distinct npmrc boundary is reused for metadata, exact-version pack and install calls. +The version verification child inherits the owned Node compile-cache path, which is removed with +terminal apply staging; an uncertain live child retains that staging and its lease. The owned staging directory is canonicalized immediately after creation, so symlinked temporary roots and macOS's /var alias are supported without accepting a linked archive file. `src/codex/cli-update-artifacts.ts` streams compressed integrity and tar validation with independent From 40a1075c977da79c1fbb20bb1993335178044cb3 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:42:07 +0900 Subject: [PATCH 12/33] fix(cli): document Codex updater resource parity (cherry picked from commit b1dc856e8712d5adf2602e3f6ce92e60e8ebb01b) --- .../ocx/references/01_surface_observe-system.md | 3 +++ src/cli/capabilities-base.ts | 5 +++-- structure/cli-management.md | 2 ++ tests/cli/cli-headless-parity.test.ts | 15 +++++++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index c47a8d4f32..dd367fc923 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -392,6 +392,7 @@ Drives no management route. | `--channel` | string | Registry channel to resolve. Only the stable latest channel is offered. | | `--json` | boolean | Emit the plan as JSON. | +- Local counterpart of GET /api/codex/cli-update/plan: runs the same proof-bound plan builder without HTTP transport. - Adds the three inputs check leaves out: an exact registry version with its sha512 integrity, a fail-closed process-table read, and a decision. - The registry evidence is pinned to the official npm registry with project/user npm configuration isolated, so a redirected .npmrc cannot supply the answer. - Installs nothing and mutates no application state; registry evidence is gathered under an isolated temporary npm root (npmrc, cwd, cache and logs) removed best-effort afterwards. A refusal is a normal dry-run answer and still exits 0. @@ -416,6 +417,7 @@ Drives no management route. | `--request-id` | string | Optional stable retry key. | | `--json` | boolean | Emit the public job status. | +- Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session. - Admission is stored under the protected current-user config root before the installer can run. - Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused. @@ -433,6 +435,7 @@ Drives no management route. |---|---|---| | `--json` | boolean | Emit the path-free job status. | +- Local counterpart of GET /api/codex/cli-update/jobs/{jobId}: reads the same path-free job status without HTTP transport. - Reads the protected job record directly; the job id is not an authorization credential. ### `ocx system codex-restart` diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 1d0cb1df09..c44ce4c34e 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -602,6 +602,7 @@ export const CAPABILITIES: readonly Capability[] = [ mutates: false, json: "envelope", details: [ + "Local counterpart of GET /api/codex/cli-update/plan: runs the same proof-bound plan builder without HTTP transport.", "Adds the three inputs check leaves out: an exact registry version with its sha512 integrity, a fail-closed process-table read, and a decision.", "The registry evidence is pinned to the official npm registry with project/user npm configuration isolated, so a redirected .npmrc cannot supply the answer.", "Installs nothing and mutates no application state; registry evidence is gathered under an isolated temporary npm root (npmrc, cwd, cache and logs) removed best-effort afterwards. A refusal is a normal dry-run answer and still exits 0.", @@ -617,13 +618,13 @@ export const CAPABILITIES: readonly Capability[] = [ routes: [], flags: [{ name: "--plan-id", value: "string", summary: "Required 32-hex id from the current plan." }, { name: "--yes", value: "boolean", summary: "Required confirmation." }, { name: "--request-id", value: "string", summary: "Optional stable retry key." }, { name: "--json", value: "boolean", summary: "Emit the public job status." }], mutates: true, json: "envelope", - details: ["Admission is stored under the protected current-user config root before the installer can run.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], + details: ["Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session.", "Admission is stored under the protected current-user config root before the installer can run.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], usage: "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", }, { command: ["system", "codex-cli-update", "status"], summary: "Read a Codex CLI update job by opaque id.", routes: [], flags: [{ name: "--json", value: "boolean", summary: "Emit the path-free job status." }], - mutates: false, json: "envelope", details: ["Reads the protected job record directly; the job id is not an authorization credential."], + mutates: false, json: "envelope", details: ["Local counterpart of GET /api/codex/cli-update/jobs/{jobId}: reads the same path-free job status without HTTP transport.", "Reads the protected job record directly; the job id is not an authorization credential."], usage: "ocx system codex-cli-update status [--json]", }, { diff --git a/structure/cli-management.md b/structure/cli-management.md index fc8dc48705..b594dd7345 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -155,3 +155,5 @@ no prompt is issued during background start/sync. The eligibility, drift refusal and recovery. No endpoint or credential is printed by this command. `ocx system codex-cli-update apply` durably admits and records the apply result under the protected config root; `--request-id` provides a retry key, and reusing it with a different plan conflicts. `ocx system codex-cli-update status ` reads the same path-free terminal DTO without a GUI token. A record left queued/running after owner restart is reported ambiguous with unknown owner state. Apply remains limited to the supported POSIX npm-global installation. + +The capability declarations document `ocx system codex-cli-update plan`, `apply` and `status` as the local counterparts of `GET /api/codex/cli-update/plan`, `POST /api/codex/cli-update/apply` and `GET /api/codex/cli-update/jobs/{jobId}`. They share the plan builder and durable job owner with the dashboard rather than sending HTTP management requests, so their driven-route lists remain empty. CLI apply requires `--yes`; HTTP apply separately requires explicit confirmation from a trusted loopback GUI session. The GUI parity test resolves these registered capabilities, checks their mutation classifications and documented resource counterparts, and includes all three resources in endpoint coverage. diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index f256ef9497..936b10343d 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -13,6 +13,7 @@ import { handleProviderRuntimeCommand } from "../../src/cli/provider-runtime"; import { providerQuotaLine } from "../../src/cli/account-extended"; import { formatAccountTable } from "../../src/cli/account"; import { handleConnectCommand } from "../../src/cli/connect"; +import { CAPABILITIES, capabilityInvocation } from "../../src/cli/capabilities"; import { codexRestartLines, handleSystemCommand } from "../../src/cli/system-command"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -638,6 +639,20 @@ describe("headless GUI parity CLI", () => { ["/api/v2", "ocx v2/agent"], ["/api/windows-tray", "ocx tray"], ]; + // These commands share local owners with the GUI routes; declaring HTTP routes + // on the capabilities would falsely claim the CLI sends management requests. + // Resolve real capability declarations and require their documented counterpart. + for (const [resource, verb, mutates] of [ + ["/api/codex/cli-update/plan", "plan", false], + ["/api/codex/cli-update/apply", "apply", true], + ["/api/codex/cli-update/jobs", "status", false], + ] as const) { + const capability = CAPABILITIES.find(cap => cap.command.join(" ") === `system codex-cli-update ${verb}`); + expect(capability, resource).toBeDefined(); + expect(capability!.mutates).toBe(mutates); + expect(capability!.details?.some(detail => detail.includes(resource)), resource).toBe(true); + coverage.push([resource, capabilityInvocation(capability!)]); + } const uncovered = [...endpoints].filter(endpoint => !coverage.some(([prefix]) => endpoint === prefix || endpoint.startsWith(prefix))); expect(uncovered).toEqual([]); }); From 13cf987b3c06e850b00d33751b8ec5519ee27eb3 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 11:59:00 +0900 Subject: [PATCH 13/33] fix(codex): preserve update job results across store failures --- .../src/content/docs/reference/cli/agents.md | 6 + src/cli/codex-cli-update.ts | 15 +- src/codex/cli-update-apply.ts | 12 +- src/codex/cli-update-job-execution.ts | 72 +++++++++ src/codex/cli-update-job.ts | 67 ++++---- src/codex/cli-update-lease.ts | 18 ++- src/codex/cli-update-plan.ts | 25 +-- .../management/codex-cli-update-routes.ts | 2 +- structure/codex-cli-update.md | 12 ++ tests/cli/cli-codex-cli-update.test.ts | 153 +++++++++++++++++- .../codex-cli-update-apply.test.ts | 21 ++- .../codex-cli-update-artifacts.test.ts | 8 +- .../codex-cli-update-integrity.test.ts | 24 ++- .../codex-cli-update-plan.test.ts | 81 ++++++---- 14 files changed, 396 insertions(+), 120 deletions(-) create mode 100644 src/codex/cli-update-job-execution.ts diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 491d4a2fdc..b1a525417d 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1207,6 +1207,12 @@ Windows apply, shim repair, native apps and other installation managers remain u Apply returns a job with `applied`, `not_applied`, `refused` or `ambiguous` outcome. Status reads the same private job record without a running proxy or GUI token. Reuse the same optional request ID after a lost response: the same key and plan return the original job; a changed plan with that key refuses. +The private ledger retains up to 64 jobs, including finished jobs, without automatic pruning. At that +limit, new request IDs are refused with `store_full`; existing matching retries still return their +original job. Capacity and operator recovery remain a separate follow-up that must preserve this +deduplication contract. A busy admission ledger reports `job_store_busy`; missing or unreadable CLI +status returns `job_unavailable` and a nonzero exit. Completion persistence failures do not rerun the +installer or turn an uncertain job into a confirmed success. An ambiguous result is never automatically retried or rolled back. After an owner restart, an unfinished job reports unknown ownership instead of claiming the old process stopped. Inspect the job's outcome, child/lease state and cleanup errors; command exit status alone does not establish an installation result. diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index b4520feeb8..65182a908c 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -45,6 +45,7 @@ export type ParsedCodexCliUpdateArgs = | Readonly<{ json: boolean; attest: CodexCliInstallationIdentityInput | "selected" }>; export interface CodexCliUpdateCommandDeps { + readonly readJob?: typeof readCodexCliUpdateJob; readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise; readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise; readonly deriveInstallationInput?: ( @@ -266,6 +267,11 @@ export async function handleCodexCliUpdateCommand( } // A refusal or an unapplied update is a legitimate answer rather than a crash, so the // outcome exit code is decided here and only a thrown error is left to runCliAction. + let resultCode = 0; + const unavailableJob = () => { + printData({ error: "job_unavailable" }, parsed.json, ["Codex CLI update job was not found or is unavailable."]); + resultCode = 1; + }; const code = await runCliAction(async () => { if ("attest" in parsed) { let report: CodexCliInstallationIdentityReport; @@ -303,8 +309,10 @@ export async function handleCodexCliUpdateCommand( return; } if (parsed.action === "status") { - const job = readCodexCliUpdateJob(parsed.jobId); - if (!job) { console.error("Codex CLI update job was not found or is unavailable."); return; } + let job: ReturnType; + try { job = (deps.readJob ?? readCodexCliUpdateJob)(parsed.jobId); } + catch { unavailableJob(); return; } + if (!job) { unavailableJob(); return; } printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); return; @@ -325,9 +333,10 @@ export async function handleCodexCliUpdateCommand( ? await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, () => apply(parsed.planId, { inspectionDeps: codexCliUpdateInspectionDeps(deps) })) : admitted.job; + if (!job) { unavailableJob(); return; } printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); } }); - return code; + return code || resultCode; } diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index 803358dee5..026f3da21a 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -29,7 +29,7 @@ export type CodexCliApplyCleanupError = "lease_release_unverified" | "lease_rele /** Every cleanup phase is attempted even if another fails. Preserve the * computed readback outcome and expose path-free cleanup uncertainty separately. */ -export function cleanupCodexCliApply(lease: ReturnType, staging?: string) { +export function cleanupCodexCliApply(lease: { release(): boolean } | null, staging?: string) { const cleanupErrors: CodexCliApplyCleanupError[] = []; let leaseRetained = false; try { if (lease && !lease.release()) { leaseRetained = true; cleanupErrors.push("lease_release_unverified"); } } @@ -137,7 +137,7 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep let installedVersion: string | null = null; let targetVersion: string | null = null; let exitCode: number | null = null; - let lease: ReturnType = null; + let lease: { release(): boolean } | null = null; let keepLease = false; let staging: string | undefined; let started = false; @@ -157,8 +157,11 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep const prefix = realpathSync.native(evidence.prefix); if (prefix !== evidence.prefix) return result("refused", "installation_prefix_changed"); const prefixIdentity = lstatSync(prefix); - lease = (deps.acquireLease ?? acquireCodexCliApplyLease)(prefix); - if (!lease) return result("refused", "installation_busy"); + const acquired = (deps.acquireLease ?? acquireCodexCliApplyLease)(prefix); + if (acquired.kind === "prefix_unsupported") return result("refused", "installation_prefix_unsupported"); + if (acquired.kind === "busy") return result("refused", "installation_busy"); + if (acquired.kind !== "acquired") return result("refused", "installation_lease_unavailable"); + lease = acquired; const freshPlan = () => createCodexCliUpdatePlan({ ...deps, inspectionDeps: freshInspection(deps) }); const plan = await freshPlan(); targetVersion = plan.targetVersion; @@ -177,7 +180,6 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep path = join(staging, `${index}.tgz`); writeFileSync(path, bytes, { flag: "wx", mode: 0o400 }); } else path = await packArtifact(artifact, staging, deps.inspectionDeps?.env); - await verifyCodexCliArchive(path, artifact); paths.push(path); } if (deps.signal?.aborted) return result("refused", "cancelled"); diff --git a/src/codex/cli-update-job-execution.ts b/src/codex/cli-update-job-execution.ts new file mode 100644 index 0000000000..e3ae63ed76 --- /dev/null +++ b/src/codex/cli-update-job-execution.ts @@ -0,0 +1,72 @@ +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", + "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", + "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 class CodexCliJobStoreBusyError extends Error { + constructor() { super("job_store_busy"); } +} +export class CodexCliJobResultPersistenceError extends Error { + constructor(readonly result: CodexCliApplyResult) { super("job_result_persistence_failed"); } +} +export function normalizeCodexCliJobResult(result: CodexCliApplyResult): CodexCliApplyResult { + const cleanup = Array.isArray(result.cleanupErrors) ? result.cleanupErrors : []; + const safe = CODEX_CLI_JOB_REASONS.has(result.reason) + && ["applied", "not_applied", "refused", "ambiguous"].includes(result.outcome) + && typeof result.leaseRetained === "boolean" && Array.isArray(result.cleanupErrors) + && cleanup.length <= 8 && cleanup.every(error => CODEX_CLI_JOB_CLEANUP_ERRORS.has(error)); + return safe ? result : { ...result, outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + cleanupErrors: cleanup.filter(error => CODEX_CLI_JOB_CLEANUP_ERRORS.has(error)).slice(0, 8) }; +} +export async function retryCodexCliJobStoreOperation(operation: () => T, + delay: (milliseconds: number) => Promise = ms => new Promise(resolve => setTimeout(resolve, ms)), + persistence = false): Promise { + for (let attempt = 0; ; attempt++) { + try { return operation(); } + catch (error) { + const transientIo = persistence && ["EIO", "ENOSPC", "EBUSY", "EAGAIN", "EINTR", "EMFILE", "ENFILE"] + .includes((error as NodeJS.ErrnoException)?.code ?? ""); + if (attempt === 7 || (!(error instanceof CodexCliJobStoreBusyError) && !transientIo)) throw error; + await delay(25); + } + } +} +interface JobWork { + begin(): number; + finish(revision: number, result: CodexCliApplyResult): CodexCliUpdateJobDto | null; + read(): CodexCliUpdateJobDto | null; + release(): void; +} +/** Execution and persistence are separate ownership phases. */ +export async function runCodexCliUpdateJobWork(run: () => Promise, work: JobWork): Promise { + let released = false; + try { + let revision: number; + try { revision = await retryCodexCliJobStoreOperation(() => work.begin()); } + catch { + work.release(); released = true; + try { return work.read(); } + catch (error) { + const code = error instanceof Error ? error.message : ""; + throw new Error(["unsafe_store", "invalid_store", "unsupported_store"].includes(code) ? code : "job_state_unavailable"); + } + } + let result: CodexCliApplyResult; + try { result = await run(); } + catch { + result = { outcome: "ambiguous", reason: "job_result_unknown", planId: "", + installedVersion: null, targetVersion: null, installerExitCode: null, leaseRetained: true, + verificationPid: null, cleanupErrors: [] }; + } + try { + return await retryCodexCliJobStoreOperation(() => work.finish(revision, result) ?? work.read(), undefined, true); + } catch { throw new CodexCliJobResultPersistenceError(result); } + } finally { if (!released) work.release(); } +} diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts index 519086077c..bf987357ed 100644 --- a/src/codex/cli-update-job.ts +++ b/src/codex/cli-update-job.ts @@ -4,19 +4,14 @@ import { chmodSync, closeSync, constants, fsyncSync, fstatSync, lstatSync, mkdir import { dirname, join, resolve } from "node:path"; import { getConfigDir } from "../config"; import type { CodexCliApplyResult } from "./cli-update-apply"; +import { CODEX_CLI_JOB_REASONS as SAFE_REASONS, CODEX_CLI_JOB_CLEANUP_ERRORS, + CodexCliJobStoreBusyError, normalizeCodexCliJobResult, runCodexCliUpdateJobWork } from "./cli-update-job-execution"; const MAX_BYTES = 256 * 1024; const MAX_JOBS = 64; const JOB_DIR = "codex-cli-update-jobs"; const FILE = "jobs.json"; const LOCK = "admission.lock"; -const SAFE_REASONS = new Set(["invalid_plan_id", "windows_inspection_deferred", "cancelled", "mutation_evidence_unverified", - "installation_prefix_changed", "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", - "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"]); type JobStatus = "queued" | "running" | "applied" | "not_applied" | "refused" | "ambiguous"; interface StoredJob { @@ -130,7 +125,7 @@ function readStore(configDir?: string): Store { || typeof job.planId !== "string" || !/^[0-9a-f]{32}$/.test(job.planId) || (job.reason !== null && (typeof job.reason !== "string" || !SAFE_REASONS.has(job.reason))) || !Array.isArray(job.cleanupErrors) || job.cleanupErrors.length > 8 - || job.cleanupErrors.some((item: unknown) => !["lease_release_unverified", "lease_release_exception", "staging_cleanup_failed"].includes(String(item))) + || job.cleanupErrors.some((item: unknown) => typeof item !== "string" || !CODEX_CLI_JOB_CLEANUP_ERRORS.has(item)) || typeof job.leaseRetained !== "boolean" || typeof job.createdAt !== "string" || typeof job.updatedAt !== "string")) throw new Error("invalid_store"); return value; @@ -143,7 +138,11 @@ function withLock(configDir: string | undefined, fn: (p: ReturnType job.id === jobId); if (index < 0) return null; const current = store.jobs[index]!; + const safe = normalizeCodexCliJobResult(result); + // A rename can succeed before a directory-fsync error. Retry the same + // completed revision while this generation still owns it, never the engine. + if (current.generation === generation && current.revision === expectedRevision + 1 + && live.get(jobId) === generation && started.has(jobId) + && current.outcome === safe.outcome && current.reason === safe.reason + && current.leaseRetained === safe.leaseRetained + && JSON.stringify(current.cleanupErrors) === JSON.stringify(safe.cleanupErrors)) { + writeStore(p, store); live.delete(jobId); started.delete(jobId); return dto(current); + } if (current.generation !== generation || current.revision !== expectedRevision - || !live.has(jobId) || !started.has(jobId)) return dto(current); - const next: StoredJob = { ...current, revision: current.revision + 1, status: result.outcome, - outcome: result.outcome, reason: result.reason, cleanupErrors: result.cleanupErrors, leaseRetained: result.leaseRetained, updatedAt: now() }; + || live.get(jobId) !== generation || !started.has(jobId)) return dto(current); + const next: StoredJob = { ...current, revision: current.revision + 1, status: safe.outcome, + outcome: safe.outcome, reason: safe.reason, cleanupErrors: safe.cleanupErrors, leaseRetained: safe.leaseRetained, updatedAt: now() }; store.jobs[index] = next; writeStore(p, store); live.delete(jobId); started.delete(jobId); return dto(next); }); } -export async function runCodexCliUpdateJob(jobId: string, generation: string, run: () => Promise, configDir?: string): Promise { - let expectedRevision: number | undefined; - try { - expectedRevision = withLock(configDir, p => { +export async function runCodexCliUpdateJob(jobId: string, generation: string, run: () => Promise, configDir?: string): Promise { + let ownsExecution = false; + return runCodexCliUpdateJobWork(run, { + begin: () => withLock(configDir, p => { const store = validStore(configDir); const index = store.jobs.findIndex(job => job.id === jobId); if (index < 0 || store.jobs[index]!.generation !== generation) throw new Error("job_generation_changed"); - if (!live.has(jobId) || started.has(jobId)) throw new Error("job_not_owned"); + if (live.get(jobId) !== generation || started.has(jobId)) throw new Error("job_not_owned"); const current = store.jobs[index]!; const next = { ...current, revision: current.revision + 1, status: "running" as const, updatedAt: now() }; - store.jobs[index] = next; writeStore(p, store); started.add(jobId); return next.revision; - }); - const result = await run(); - return finishCodexCliUpdateJob(jobId, generation, expectedRevision, result, configDir) ?? readCodexCliUpdateJob(jobId, configDir)!; - } catch { - if (expectedRevision === undefined) return readCodexCliUpdateJob(jobId, configDir)!; - const result: CodexCliApplyResult = { outcome: "ambiguous", reason: "job_result_unknown", planId: "", - installedVersion: null, targetVersion: null, installerExitCode: null, leaseRetained: true, - verificationPid: null, cleanupErrors: [] }; - return finishCodexCliUpdateJob(jobId, generation, expectedRevision, result, configDir) ?? readCodexCliUpdateJob(jobId, configDir)!; - } finally { - live.delete(jobId); - started.delete(jobId); - } + store.jobs[index] = next; writeStore(p, store); started.add(jobId); ownsExecution = true; return next.revision; + }), + finish: (revision, result) => finishCodexCliUpdateJob(jobId, generation, revision, result, configDir), + read: () => readCodexCliUpdateJob(jobId, configDir), + release: () => { + if (live.get(jobId) === generation && (ownsExecution || !started.has(jobId))) { + live.delete(jobId); if (ownsExecution) started.delete(jobId); + } + }, + }); } diff --git a/src/codex/cli-update-lease.ts b/src/codex/cli-update-lease.ts index b88e06ef63..90de2d5e70 100644 --- a/src/codex/cli-update-lease.ts +++ b/src/codex/cli-update-lease.ts @@ -3,8 +3,10 @@ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFi realpathSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +export type CodexCliApplyLeaseResult = { readonly kind: "acquired"; release(): boolean } + | { readonly kind: "busy" | "prefix_unsupported" | "unavailable" }; /** Installation-unit mutex. No stale/empty-owner recovery and no token delegation. */ -export function acquireCodexCliApplyLease(prefix: string): { release(): boolean } | null { +export function acquireCodexCliApplyLease(prefix: string): CodexCliApplyLeaseResult { let fd: number | undefined; let lockIdentity: { dev: number; ino: number } | undefined; let ownerIdentity: { dev: number; ino: number } | undefined; @@ -39,8 +41,12 @@ export function acquireCodexCliApplyLease(prefix: string): { release(): boolean try { const root = lstatSync(prefix); if (!root.isDirectory() || root.isSymbolicLink() || realpathSync.native(prefix) !== prefix - || (root.mode & 0o022) !== 0 || root.uid !== process.geteuid?.()) return null; - mkdirSync(path, { mode: 0o700 }); + || (root.mode & 0o022) !== 0 || root.uid !== process.geteuid?.()) return { kind: "prefix_unsupported" }; + try { mkdirSync(path, { mode: 0o700 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return { kind: "busy" }; + throw error; + } lockIdentity = lstatSync(path); fd = openSync(owner, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); ownerIdentity = fstatSync(fd); @@ -48,15 +54,15 @@ export function acquireCodexCliApplyLease(prefix: string): { release(): boolean const completed = fd; fd = undefined; closeSync(completed); - return { release }; + return { kind: "acquired", release }; } catch { if (fd !== undefined) { const owned = fd; fd = undefined; - try { closeSync(owned); } catch { return null; } + try { closeSync(owned); } catch { return { kind: "unavailable" }; } } // Only a positively identified owner is eligible for cleanup. Empty or // suspicious directories intentionally remain busy for operator inspection. if (ownerIdentity) release(); - return null; + return { kind: "unavailable" }; } } diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 5b96bd15d3..70cc0cb849 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -7,7 +7,8 @@ import { join, resolve } from "node:path"; import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import { npmInvocation } from "../update/npm-invocation.mjs"; import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch-policy.mjs"; -import { codexCliArtifactSet, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; +import { CODEX_CLI_REGISTRY, isSha512Integrity, codexCliArtifactSet, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; +export { CODEX_CLI_REGISTRY } from "./cli-update-artifacts"; import { inspectCodexCliInstall, type CodexCliInstallKind, @@ -44,18 +45,6 @@ import { export const CODEX_CLI_PACKAGE = "@openai/codex"; export const CODEX_CLI_UPDATE_SCHEMA_VERSION = 1 as const; -/** - * The one registry this workflow is allowed to resolve, pack and install from. - * - * npm view/pack inherit the operator's npm configuration by default, so a project or - * user .npmrc — or an npm_config_* env var — can redirect "@openai/codex" to another - * registry whose version query, integrity query and tarball all agree with each - * other. Every npm call below pins this registry explicitly AND runs with the - * registry-affecting configuration isolated, so the pinned origin is the only source - * the evidence can come from. - */ -export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org"; - /** Only the stable channel is exposed; a moving dist-tag is resolved before it is used. */ export type CodexCliUpdateChannel = "latest"; @@ -130,16 +119,6 @@ export interface CodexCliUpdatePlanDeps { const PLAN_ID_LENGTH = 32; const PLAN_ID_RE = /^[0-9a-f]{32}$/; const REGISTRY_TIMEOUT_MS = 12_000; -const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/; - -function isSha512Integrity(token: string): boolean { - if (!SHA512_INTEGRITY_RE.test(token)) return false; - const encoded = token.slice("sha512-".length); - // Base64 decoders accept truncated input and nonzero unused padding bits. - // Require exactly 64 bytes and the canonical encoding of those same bytes. - const digest = Buffer.from(encoded, "base64"); - return digest.byteLength === 64 && digest.toString("base64") === encoded; -} /** The install argv this plan quotes for the operator to read, in argv form. */ export function codexCliUpdateCommand(version: string): readonly string[] { diff --git a/src/server/management/codex-cli-update-routes.ts b/src/server/management/codex-cli-update-routes.ts index 4ece7ed2ad..5151cf8ab3 100644 --- a/src/server/management/codex-cli-update-routes.ts +++ b/src/server/management/codex-cli-update-routes.ts @@ -49,6 +49,6 @@ export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promis } catch (error) { const code = error instanceof Error ? error.message : "admission_failed"; const status = code === "idempotency_conflict" ? 409 : code === "invalid_request" ? 400 : 503; - return jsonResponse({ error: ["invalid_request", "idempotency_conflict", "store_full", "unsupported_store", "unsafe_store"].includes(code) ? code : "admission_failed" }, status, ctx.req, ctx.config); + return jsonResponse({ error: ["invalid_request", "idempotency_conflict", "store_full", "job_store_busy", "unsupported_store", "unsafe_store"].includes(code) ? code : "admission_failed" }, status, ctx.req, ctx.config); } } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 9493ed85d7..ea59b0393b 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -109,3 +109,15 @@ Windows apply remains refused. Shims and 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. `src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed `gui-session` plus trusted-loopback apply admission, and authenticated status. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. + +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. +Capacity expansion and operator recovery require a separate design that preserves deduplication and job-status evidence. +`src/codex/cli-update-job-execution.ts` allows at most eight worker start/completion lock attempts, +with asynchronous 25 ms waits and no stale-lock reclamation. Admission reports `job_store_busy` separately. +Only engine exceptions synthesize `job_result_unknown`; persistence retries keep the same actual result. +Transient completion-write failures have the same bounded retry budget. Permanent failure reports +`job_result_persistence_failed` while retaining the computed result in the error; it does not establish durable completion. +Unknown result/cleanup codes are normalized to fixed-code uncertainty before writing, so they cannot poison later reads. +Unsupported prefixes and unavailable lease storage are distinct from a held installation lease. +Missing/unreadable CLI job status has a structured `job_unavailable` JSON error and a nonzero exit. diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index b04611792d..6356ce28d1 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { handleCodexCliUpdateCommand, parseCodexCliUpdateArgs } from "../../src/cli/codex-cli-update"; import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, projectCodexCliUpdateJobLifecycle, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../src/codex/cli-update-job"; +import { CodexCliJobStoreBusyError, CodexCliJobResultPersistenceError, normalizeCodexCliJobResult, + retryCodexCliJobStoreOperation, runCodexCliUpdateJobWork } from "../../src/codex/cli-update-job-execution"; +import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; +import type { CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, @@ -105,6 +110,150 @@ function hasTrustedStickyTmp(): boolean { } catch { return false; } } +describe("Codex CLI job persistence failures", () => { + const result: CodexCliApplyResult = { outcome: "applied", reason: "verified_target", planId: "a".repeat(32), + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }; + const terminal = { jobId: randomUUID(), status: "applied", outcome: "applied", reason: "verified_target" } as CodexCliUpdateJobDto; + + test("a save failure retries the actual applied result without rerunning the engine", async () => { + const saved: CodexCliApplyResult[] = []; let runs = 0, released = 0; + const value = await runCodexCliUpdateJobWork(async () => { runs++; return result; }, { + begin: () => 1, read: () => null, release: () => { released++; }, + finish: (_revision, value) => { saved.push(value); if (saved.length === 1) throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); return terminal; }, + }); + expect(value).toBe(terminal); expect(runs).toBe(1); expect(released).toBe(1); + expect(saved).toEqual([result, result]); expect(saved.every(value => value === result)).toBe(true); + }); + + test("persistent save failure carries the actual result and never invents a replacement", async () => { + const saved: CodexCliApplyResult[] = []; + let failure: unknown; + try { await runCodexCliUpdateJobWork(async () => result, { begin: () => 1, read: () => null, release: () => {}, + finish: (_revision, value) => { saved.push(value); throw new Error("unsafe_store"); } }); } + catch (error) { failure = error; } + expect(failure).toBeInstanceOf(CodexCliJobResultPersistenceError); + expect((failure as CodexCliJobResultPersistenceError).result).toBe(result); + expect(saved).toEqual([result]); + }); + + test("bounded lock contention yields asynchronously and does not reclaim the lock", async () => { + let attempts = 0; const waits: number[] = []; + expect(await retryCodexCliJobStoreOperation(() => { if (++attempts < 3) throw new CodexCliJobStoreBusyError(); return 7; }, + async ms => { waits.push(ms); })).toBe(7); + expect(attempts).toBe(3); expect(waits).toEqual([25, 25]); + attempts = 0; waits.length = 0; + await expect(retryCodexCliJobStoreOperation(() => { attempts++; throw new CodexCliJobStoreBusyError(); }, + async ms => { waits.push(ms); })).rejects.toThrow("job_store_busy"); + expect(attempts).toBe(8); expect(waits).toHaveLength(7); + }); + + test("unknown accepted result codes cannot poison a later store read", () => { + const safe = normalizeCodexCliJobResult({ ...result, reason: "private/unknown-reason", + cleanupErrors: ["private/cleanup"] as never }); + expect(safe).toMatchObject({ outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, cleanupErrors: [] }); + expect(JSON.stringify(safe)).not.toContain("private/"); + expect(normalizeCodexCliJobResult(result)).toEqual(result); + }); + + test("a missing generation returns null without running the engine", async () => { + let runs = 0, released = 0; + expect(await runCodexCliUpdateJobWork(async () => { runs++; return result; }, { + begin: () => { throw new Error("job_generation_changed"); }, finish: () => terminal, + read: () => null, release: () => { released++; }, + })).toBeNull(); + expect(runs).toBe(0); expect(released).toBe(1); + }); + + test("missing status is structured JSON and a nonzero exit", async () => { + const messages: string[] = [], previous = console.log; + try { + console.log = value => { messages.push(String(value)); }; + expect(await handleCodexCliUpdateCommand(["status", randomUUID(), "--json"], { readJob: () => null })).toBe(1); + expect(JSON.parse(messages.join("\n"))).toEqual({ error: "job_unavailable" }); + } finally { console.log = previous; } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a contended real store preserves the engine result until the owned lock is released", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-retry-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"); + let timer: ReturnType | undefined, runs = 0; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "lock-retry-00001", planId: result.planId, confirmed: true, configDir }); + const job = await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => { + runs++; mkdirSync(lock, { mode: 0o700 }); + timer = setTimeout(() => fs.rmdirSync(lock), 50); + return result; + }, configDir); + expect(job).toMatchObject({ status: "applied", outcome: "applied", reason: "verified_target" }); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ outcome: "applied" }); + expect(runs).toBe(1); + } finally { clearTimeout(timer); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a rename followed by fsync failure retries the same completed revision", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-fsync-retry-"); + let sync: ReturnType | undefined; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "fsync-retry-0001", planId: result.planId, confirmed: true, configDir }); + const nativeSync = fs.fsyncSync; let calls = 0; + const job = await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => { + sync = spyOn(fs, "fsyncSync").mockImplementation(fd => { + if (++calls === 3) throw Object.assign(new Error("injected directory sync failure"), { code: "EIO" }); + return nativeSync(fd); + }); + return result; + }, configDir); + expect(job).toMatchObject({ status: "applied", reason: "verified_target" }); + expect(calls).toBeGreaterThan(2); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ outcome: "applied", reason: "verified_target" }); + expect(JSON.parse(readFileSync(join(configDir, "codex-cli-update-jobs", "jobs.json"), "utf8")).jobs[0].revision).toBe(2); + } finally { sync?.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an accepted unknown result is persisted as readable fixed-code uncertainty", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-unknown-result-"); + try { + const admitted = admitCodexCliUpdateJob({ requestId: "unknown-result-01", planId: result.planId, confirmed: true, configDir }); + await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + async () => ({ ...result, reason: "late_result", cleanupErrors: ["private/path"] as never }), configDir); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ status: "ambiguous", + reason: "job_result_unknown", leaseState: "unknown", cleanupErrors: [] }); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a duplicate worker cannot clear the active generation's completion ownership", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-worker-fence-"); + let release = () => {}, pending: Promise | undefined; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "worker-fence-001", planId: result.planId, confirmed: true, configDir }); + pending = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => new Promise(resolve => { release = () => resolve(result); }), configDir); + await Promise.resolve(); + expect(await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + async () => { throw new Error("a duplicate worker must not invoke the engine"); }, configDir)) + .toMatchObject({ status: "running", ownerState: "active" }); + release(); + expect(await pending).toMatchObject({ status: "applied", reason: "verified_target" }); + } finally { release(); await pending?.catch(() => undefined); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("64 retained terminal jobs refuse new admission while preserving the first request dedupe", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-full-store-"); + try { + let first = ""; + for (let index = 0; index < 64; index++) { + const admitted = admitCodexCliUpdateJob({ requestId: `retained-key-${index}`, planId: result.planId, confirmed: true, configDir }); + if (index === 0) first = admitted.job.jobId; + expect(await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => result, configDir)).toMatchObject({ outcome: "applied" }); + } + expect(() => admitCodexCliUpdateJob({ requestId: "retained-key-65", planId: result.planId, confirmed: true, configDir })).toThrow("store_full"); + expect(admitCodexCliUpdateJob({ requestId: "retained-key-0", planId: result.planId, confirmed: true, configDir })) + .toMatchObject({ kind: "duplicate", job: { jobId: first, outcome: "applied" } }); + } finally { rmSync(outer, { recursive: true }); } + }, 30_000); +}); + describe("Codex CLI update CLI", () => { test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed admission creates one missing config root without making reads write", () => { const { outer, parent } = privateStoreParent("ocx-cli-update-missing-parent-"); @@ -190,6 +339,7 @@ describe("Codex CLI update CLI", () => { installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }); }), configDir); + await Promise.resolve(); // Worker admission now yields before invoking the engine. if (!release) throw new Error("fixture job owner did not enter its awaited operation"); const ledger = join(configDir, "codex-cli-update-jobs", "jobs.json"); const before = readFileSync(ledger); @@ -287,6 +437,7 @@ describe("Codex CLI update CLI", () => { installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, verificationPid: null, cleanupErrors: [] }); }), configDir); + await Promise.resolve(); expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)).toMatchObject({ status: "running", ownerState: "active" }); const modulePath = new URL("../../src/codex/cli-update-job.ts", import.meta.url).href; const script = `import { readCodexCliUpdateJob } from ${JSON.stringify(modulePath)}; console.log(JSON.stringify(readCodexCliUpdateJob(${JSON.stringify(accepted.job.jobId)})));`; diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index c64e35c25d..d4c5a28ff9 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -271,6 +271,19 @@ test("HTTP fixture npm wrapper returns all metadata under an ESM ancestor", () = expect(existsSync(installTrace)).toBe(false); }); describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { + test("a held lease and an unsupported prefix are distinct refusal inputs", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-lease-policy-"))); roots.push(root); + chmodSync(root, 0o700); + const first = acquireCodexCliApplyLease(root); + expect(first.kind).toBe("acquired"); + if (first.kind !== "acquired") throw new Error("owned private prefix lease unavailable"); + try { expect(acquireCodexCliApplyLease(root)).toEqual({ kind: "busy" }); } + finally { expect(first.release()).toBe(true); } + chmodSync(root, 0o770); + expect(acquireCodexCliApplyLease(root)).toEqual({ kind: "prefix_unsupported" }); + expect(existsSync(join(root, ".ocx-codex-cli-apply.lock"))).toBe(false); + }); + test("published Node launcher -> real resolver/provenance -> plan/apply -> verified new files", () => { const f = fixture(); const plan = cli(f, ["plan"]); @@ -301,7 +314,7 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { let jobId: string | undefined; let lostSocket: ReturnType | undefined; let finishOldOwner: (() => void) | undefined; - let oldOwnerWork: Promise | undefined; + let oldOwnerWork: Promise | undefined; try { await waitForManagementServer(base, server.child, server.closed, server.output); session = await issueFixtureGuiSession(base); @@ -627,7 +640,7 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { const result = await first; expect(result.outcome).toBe("ambiguous"); expect(result.leaseRetained).toBe(true); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); - expect(acquireCodexCliApplyLease(f.prefix)).toBeNull(); + expect(acquireCodexCliApplyLease(f.prefix)).toEqual({ kind: "busy" }); // The uncertain child is a test double; no real installer survives teardown. }, 120_000); test("original file mode damage is ambiguous, not not_applied", async () => { @@ -672,8 +685,8 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { const f = fixture(); const id = await approved(f); const result = await applyCodexCliUpdate(id, depsFor(f, { acquireLease: prefix => { - const owned = acquireCodexCliApplyLease(prefix); if (!owned) return null; - return { release: () => { owned.release(); throw new Error("injected cleanup failure"); } }; + const owned = acquireCodexCliApplyLease(prefix); if (owned.kind !== "acquired") return owned; + return { kind: "acquired", release: () => { owned.release(); throw new Error("injected cleanup failure"); } }; }, install: async () => ({ terminal: true, exitCode: 0 }), })); diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index fe749b3f46..d05805df2c 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -142,8 +142,9 @@ describe("Codex CLI apply artifacts", () => { fs.writeSync(sentinel, "sentinel"); for (let index = 0; index < 12; index++) { await verifyCodexCliArchive(path, f.artifacts.main); - const lease = acquireCodexCliApplyLease(root); expect(lease).not.toBeNull(); - expect(lease!.release()).toBe(true); + const lease = acquireCodexCliApplyLease(root); expect(lease.kind).toBe("acquired"); + if (lease.kind !== "acquired") throw new Error("fixture lease unavailable"); + expect(lease.release()).toBe(true); await new Promise(resolve => setImmediate(resolve)); expect(fs.fstatSync(sentinel).size).toBe(8); } @@ -152,7 +153,8 @@ describe("Codex CLI apply artifacts", () => { test.skipIf(process.platform === "win32")("lease descriptor-close failure is structured and still permits staging cleanup", () => { const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-lease-close-failure-"))); const stage = join(root, "stage"); mkdirSync(stage); - const lease = acquireCodexCliApplyLease(root); expect(lease).not.toBeNull(); + const lease = acquireCodexCliApplyLease(root); expect(lease.kind).toBe("acquired"); + if (lease.kind !== "acquired") throw new Error("fixture lease unavailable"); const close = fs.closeSync; const spy = spyOn(fs, "closeSync").mockImplementation(fd => { close(fd); throw Object.assign(new Error("injected post-close failure"), { code: "EBADF" }); diff --git a/tests/codex-integration/codex-cli-update-integrity.test.ts b/tests/codex-integration/codex-cli-update-integrity.test.ts index fbfe3e3160..e6982e206a 100644 --- a/tests/codex-integration/codex-cli-update-integrity.test.ts +++ b/tests/codex-integration/codex-cli-update-integrity.test.ts @@ -1,21 +1,28 @@ import { describe, expect, test } from "bun:test"; import type { spawnSync } from "node:child_process"; -import { resolveCodexCliUpdateTarget } from "../../src/codex/cli-update-plan"; +import { resolveCodexCliUpdateArtifactTarget } from "../../src/codex/cli-update-plan"; const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; function resolveWith(token: string) { let queries = 0; - const outputs = ["1.4.2", token, "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz"]; - const spawn = (() => ({ status: 0, stdout: outputs[queries++], stderr: "" })) as typeof spawnSync; - return { target: resolveCodexCliUpdateTarget("latest", spawn), queries }; + const spawn = ((_bin: string, args: string[]) => { + queries++; + return { status: 0, stderr: "", stdout: JSON.stringify({ + name: args.join(" ").includes("@openai/codex-linux-x64@") ? "@openai/codex-linux-x64" : "@openai/codex", + version: "1.4.2", optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity: token, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" }, + }) }; + }) as typeof spawnSync; + return { target: resolveCodexCliUpdateArtifactTarget("latest", spawn, undefined, "linux", "x64"), queries }; } describe("Codex CLI registry SHA-512 evidence", () => { - test("accepts a complete canonical SHA-512 digest in a multi-hash response", () => { - expect(resolveWith(`'sha1-old ${integrity}'\n`)).toEqual({ - target: { kind: "resolved", version: "1.4.2", integrity }, queries: 3, - }); + test("accepts a complete canonical SHA-512 digest for both production artifacts", () => { + const value = resolveWith(integrity); + expect(value.target).toMatchObject({ kind: "resolved", version: "1.4.2", integrity, + artifacts: { backing: { name: "@openai/codex-linux-x64", integrity } } }); + expect(value.queries).toBe(3); }); test.each([ @@ -26,6 +33,7 @@ describe("Codex CLI registry SHA-512 evidence", () => { // Node's permissive base64 decoder accepts nonzero unused padding bits. `sha512-${"A".repeat(85)}B==`, integrity.replace("==", ""), + `sha1-old ${integrity}`, ])("refuses malformed integrity %s before querying the tarball", token => { const result = resolveWith(token); expect(result.target.kind).toBe("unresolved"); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index d477d0ef7f..309d59a7e0 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -17,7 +17,7 @@ import { CODEX_CLI_REGISTRY, codexCliUpdatePlanId, createCodexCliUpdatePlan, - resolveCodexCliUpdateTarget, + resolveCodexCliUpdateArtifactTarget, type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, type CodexCliUpdateTarget, @@ -33,6 +33,9 @@ import { } from "../helpers/codex-cli-content-fs"; const REGISTRY_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; +function resolveArtifact(channel: "latest", spawn: typeof spawnSync = spawnSync, env?: NodeJS.ProcessEnv) { + return resolveCodexCliUpdateArtifactTarget(channel, spawn, env, "linux", "x64"); +} /** * Phase 2 of the Codex CLI update manager. @@ -332,23 +335,27 @@ describe("strict Codex app-server process scan", () => { describe("registry target resolution", () => { function spawnStub(outputs: { status: number | null; stdout: string }[]) { let call = 0; - return (() => { - const next = outputs[call++] ?? { status: 1, stdout: "" }; - return { status: next.status, stdout: next.stdout, stderr: "" }; + return ((_bin: string, args: string[]) => { + const index = call++, version = outputs[0]?.stdout.trim(); + const name = index === 2 ? "@openai/codex-linux-x64" : "@openai/codex"; + return { status: outputs[index === 0 ? 0 : 1]?.status ?? 1, + stdout: JSON.stringify({ name, version, optionalDependencies: { "@openai/codex-linux-x64": version }, + dist: { integrity: outputs[1]?.stdout.trim(), tarball: outputs[2]?.stdout.trim() } }), stderr: "" }; }) as never; } test("an exact version with a sha512 token resolves", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + const target = resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, - { status: 0, stdout: `'${REGISTRY_INTEGRITY}' sha1-old\n` }, + { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n" }, ])); - expect(target).toEqual({ kind: "resolved", version: "1.4.2", integrity: REGISTRY_INTEGRITY }); + expect(target).toMatchObject({ kind: "resolved", version: "1.4.2", integrity: REGISTRY_INTEGRITY, + artifacts: { platform: "linux", arch: "x64", backing: { name: "@openai/codex-linux-x64" } } }); }); test("a missing integrity token refuses instead of proceeding best-effort", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + const target = resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: "sha1-onlythis\n" }, ])); @@ -356,19 +363,19 @@ describe("registry target resolution", () => { }); test("a registry timeout refuses", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([{ status: null, stdout: "" }])); + const target = resolveArtifact("latest", spawnStub([{ status: null, stdout: "" }])); expect(target.kind).toBe("unresolved"); }); test("a non-version answer is not treated as a version", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + const target = resolveArtifact("latest", spawnStub([ { status: 0, stdout: "latest\n" }, ])); expect(target.kind).toBe("unresolved"); }); test("a tarball outside the pinned registry origin is refused even with a valid digest", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + const target = resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://evil.invalid/@openai/codex/-/codex-1.4.2.tgz\n" }, @@ -377,7 +384,7 @@ describe("registry target resolution", () => { }); test("a tarball answer that is not a URL at all is refused", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + const target = resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "not-a-url\n" }, @@ -482,7 +489,7 @@ describe("registry configuration isolation", () => { ownedRoot = options.cwd as string; return (capture.spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe(kind); + expect(resolveArtifact("latest", spawn).kind).toBe(kind); expect(cleanupAttempts).toBe(1); expect(existsSync(ownedRoot)).toBe(true); } finally { @@ -493,11 +500,12 @@ describe("registry configuration isolation", () => { test("every query pins the official registry and substitutes a controlled npmrc", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { expect(argvLine(call)).toContain("--registry=" + CODEX_CLI_REGISTRY); + expect(argvLine(call)).toContain("--json"); expect(call.options.maxBuffer).toBe(1024 * 1024); // Both file configs use distinct controlled empty files inside the // isolation dir — a user ~/.npmrc or a global $PREFIX/etc/npmrc cannot answer. expect(argvLine(call)).toContain("--userconfig="); @@ -519,7 +527,7 @@ describe("registry configuration isolation", () => { process.env["npm_config_@openai:registry"] = "https://evil.invalid/"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -537,7 +545,7 @@ describe("registry configuration isolation", () => { // A project .npmrc in the launch directory would otherwise redirect the scope; // the query must run from the sentinel-anchored isolation dir instead. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const cwd = call.options.cwd as string; @@ -565,7 +573,7 @@ describe("registry configuration isolation", () => { process.env.HTTPS_PROXY = "http://project-dotenv-proxy"; process.env.ALL_PROXY = "http://project-dotenv-fallback"; process.env.NODE_EXTRA_CA_CERTS = "/project/dotenv-ca.pem"; - expect(resolveCodexCliUpdateTarget("latest", spawn, boundEnv).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn, boundEnv).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; expect(env.PATH).toBe(boundEnv.PATH); @@ -610,7 +618,7 @@ describe("registry configuration isolation", () => { try { // Capture only: no loader, native child process or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -635,7 +643,7 @@ describe("registry configuration isolation", () => { process.env.NODE_REDIRECT_WARNINGS = "C:\\evil-warnings.log"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -669,7 +677,7 @@ describe("registry configuration isolation", () => { try { // Capture only: no child process, native provider or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -691,7 +699,7 @@ describe("registry configuration isolation", () => { try { Object.assign(process.env, sentinels); const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; for (const [key, value] of Object.entries(sentinels)) { @@ -757,9 +765,9 @@ describe("registry configuration isolation", () => { checked = true; } const field = queriedField({ bin, args, options } as CapturedCall); - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); expect(checked).toBe(true); expect(existsSync(ownedRoot)).toBe(false); } finally { @@ -774,7 +782,7 @@ describe("registry configuration isolation", () => { test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - resolveCodexCliUpdateTarget("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + resolveArtifact("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); // With no trusted PATH the ambient PATH must never reach the child: Windows // resolves the binary itself and spawns nothing, while POSIX spawns the bare // npm name whose execvp lookup then fails under the explicit empty PATH. @@ -785,13 +793,13 @@ describe("registry configuration isolation", () => { // spawn that honours the empty PATH fails and the target stays unresolved. const enoent = { status: null as number | null, stdout: "", stderr: "spawnSync npm ENOENT", error: new Error("spawnSync npm ENOENT") }; const failingSpawn = (() => ({ ...enoent })) as never; - const refused = resolveCodexCliUpdateTarget("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + const refused = resolveArtifact("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); expect(refused.kind).toBe("unresolved"); }); test("npm and Node caches and logs stay inside the owned temporary root", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { @@ -830,9 +838,9 @@ describe("registry configuration isolation", () => { observedCache = env.NODE_COMPILE_CACHE; } // Only registry evidence is synthetic; npm startup above is real. - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); expect(calls).toBe(3); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(temporaryRoot)).toEqual([]); @@ -872,9 +880,9 @@ describe("registry configuration isolation", () => { expect(result.stderr).not.toContain("double-loading config"); configReads++; } - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); expect(configReads).toBe(1); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(fakeHome)).toEqual([]); @@ -902,9 +910,12 @@ describe("registry configuration isolation", () => { }; calls.push(call); const field = queriedField(call); - if (realRan) return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + if (realRan) return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); const env = { ...(options.env as NodeJS.ProcessEnv), HOME: fakeHome, USERPROFILE: fakeHome, npm_config_offline: "true" }; - const result = spawnSync(bin, args, { ...options, env, timeout: 30_000, windowsHide: true, encoding: "utf8" }); + const offlineArgs = options.windowsVerbatimArguments + ? [...args.slice(0, 3), args[3]!.slice(0, -1) + ' --offline --fetch-retries=0 --fetch-timeout=1000"'] + : [...args, "--offline", "--fetch-retries=0", "--fetch-timeout=1000"]; + const result = spawnSync(bin, offlineArgs, { ...options, env, timeout: 10_000, windowsHide: true, encoding: "utf8" }); realRan = true; // A spawn that never started (ENOENT, timeout kill) proves nothing; require // a real process exit so the residue assertions actually bound npm's writes. @@ -914,9 +925,9 @@ describe("registry configuration isolation", () => { // The owned root is still live here, so residue inside it is observable // before the resolver's finally removes it. for (const name of readdirSync(cwd)) ownedDuringRun.push(name); - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - resolveCodexCliUpdateTarget("latest", spawn); + resolveArtifact("latest", spawn); expect(realRan).toBe(true); const cwd = calls[0]!.options.cwd as string; // Anything npm persisted was inside the owned root and is removed with it; @@ -929,5 +940,5 @@ describe("registry configuration isolation", () => { } finally { rmSync(fakeHome, { recursive: true, force: true }); } - }); + }, 15_000); }); From d617ec48b083dfe5ac9a2eefa26b858cb2105628 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 12:20:52 +0900 Subject: [PATCH 14/33] fix(api): advertise retry delay for CLI job admission contention --- .../src/content/docs/reference/cli/agents.md | 3 +- .../management/codex-cli-update-routes.ts | 4 ++- structure/codex-cli-update.md | 3 +- .../server/management-route-registry.test.ts | 36 ++++++++++++++++++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index b1a525417d..6c91106c8a 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1210,7 +1210,8 @@ a lost response: the same key and plan return the original job; a changed plan w The private ledger retains up to 64 jobs, including finished jobs, without automatic pruning. At that limit, new request IDs are refused with `store_full`; existing matching retries still return their original job. Capacity and operator recovery remain a separate follow-up that must preserve this -deduplication contract. A busy admission ledger reports `job_store_busy`; missing or unreadable CLI +deduplication contract. A busy admission ledger reports `job_store_busy` (HTTP 503 with `Retry-After: 1`); +permanent admission failures have no retry-delay header. Missing or unreadable CLI status returns `job_unavailable` and a nonzero exit. Completion persistence failures do not rerun the installer or turn an uncertain job into a confirmed success. An ambiguous result is never automatically retried or rolled back. After an owner restart, an unfinished diff --git a/src/server/management/codex-cli-update-routes.ts b/src/server/management/codex-cli-update-routes.ts index 5151cf8ab3..98e302e228 100644 --- a/src/server/management/codex-cli-update-routes.ts +++ b/src/server/management/codex-cli-update-routes.ts @@ -49,6 +49,8 @@ export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promis } catch (error) { const code = error instanceof Error ? error.message : "admission_failed"; const status = code === "idempotency_conflict" ? 409 : code === "invalid_request" ? 400 : 503; - return jsonResponse({ error: ["invalid_request", "idempotency_conflict", "store_full", "job_store_busy", "unsupported_store", "unsafe_store"].includes(code) ? code : "admission_failed" }, status, ctx.req, ctx.config); + const response = jsonResponse({ error: ["invalid_request", "idempotency_conflict", "store_full", "job_store_busy", "unsupported_store", "unsafe_store"].includes(code) ? code : "admission_failed" }, status, ctx.req, ctx.config); + if (code === "job_store_busy") response.headers.set("Retry-After", "1"); + return response; } } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index ea59b0393b..ae7f85c936 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -114,7 +114,8 @@ The ledger retains at most 64 jobs, including terminal jobs, and never prunes re At capacity it refuses new request IDs with `store_full`; existing matching retries still return the same job. Capacity expansion and operator recovery require a separate design that preserves deduplication and job-status evidence. `src/codex/cli-update-job-execution.ts` allows at most eight worker start/completion lock attempts, -with asynchronous 25 ms waits and no stale-lock reclamation. Admission reports `job_store_busy` separately. +with asynchronous 25 ms waits and no stale-lock reclamation. HTTP admission reports `job_store_busy` +with status 503 and `Retry-After: 1`; permanent admission failures do not advertise a retry delay. Only engine exceptions synthesize `job_result_unknown`; persistence retries keep the same actual result. Transient completion-write failures have the same bounded retry budget. Permanent failure reports `job_result_persistence_failed` while retaining the computed result in the error; it does not establish durable completion. diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index db3962dbcc..a1fe27cbec 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; +import * as updateJobs from "../../src/codex/cli-update-job"; import type { ManagementContext } from "../../src/server/management/context"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; import { SIBLING_REFUSED_MANAGEMENT_PATHS, siblingRefusesManagementRequest } from "../../src/server/management/sibling-guard"; @@ -112,6 +113,26 @@ describe("management route registry reconciliation", () => { expect(clientEvidence?.status).toBe(400); }); + test.each(["job_store_busy", "store_full"])("HTTP admission %s schedules no worker and only contention advertises retry", async code => { + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockImplementation(() => { throw new Error(code); }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob"); + try { + const req = new Request("http://localhost/api/codex/cli-update/apply", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "busy-response-01", planId: "e".repeat(32), confirmed: true }) }); + const ctx: ManagementContext = { req, url: new URL(req.url), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, deps: {}, version: "test", + principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ error: code }); + expect(response?.headers.get("Retry-After")).toBe(code === "job_store_busy" ? "1" : null); + expect(worker).not.toHaveBeenCalled(); + } finally { admission.mockRestore(); worker.mockRestore(); } + }); + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("HTTP apply retry returns the durable job without scheduling a second engine run", async () => { const outer = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-update-http-test-"))); const parent = join(outer, "private-parent"), configDir = join(parent, "ocx"); @@ -129,6 +150,19 @@ describe("management route registry reconciliation", () => { principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), syncClaudeAgentDefsBestEffort: async () => {} }; + const storeDir = join(configDir, "codex-cli-update-jobs"), heldLock = join(storeDir, "admission.lock"); + mkdirSync(storeDir, { mode: 0o700 }); chmodSync(storeDir, 0o700); + mkdirSync(heldLock, { mode: 0o700 }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob"); + try { + const busy = await handleCodexCliUpdateRoutes(ctx); + expect(busy?.status).toBe(503); + expect(await busy?.json()).toEqual({ error: "job_store_busy" }); + expect(busy?.headers.get("Retry-After")).toBe("1"); + expect(worker).not.toHaveBeenCalled(); + expect(existsSync(join(storeDir, "jobs.json"))).toBe(false); + } finally { worker.mockRestore(); rmSync(heldLock, { recursive: true }); } + ctx.req = makeRequest(); const first = await handleCodexCliUpdateRoutes(ctx); const firstBody = await first!.json(); ctx.req = makeRequest(); From 3c25ae9808348646cf1ecf00d339f043a5c74063 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 13:28:02 +0900 Subject: [PATCH 15/33] fix(codex): recover owned job locks and signal apply failures --- .../src/content/docs/reference/cli/agents.md | 3 +- .../references/01_surface_observe-system.md | 1 + src/cli/capabilities-base.ts | 2 +- src/cli/codex-cli-update.ts | 1 + src/codex/cli-update-job.ts | 33 +++++++- structure/INDEX.md | 1 + structure/codex-cli-update.md | 3 + structure/manifest.json | 1 + tests/cli/cli-codex-cli-update.test.ts | 77 +++++++++++++++++++ 9 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 6c91106c8a..a1dda3cc35 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1204,7 +1204,8 @@ offline with lifecycle scripts disabled, and checks installed files and the sele 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. -Apply returns a job with `applied`, `not_applied`, `refused` or `ambiguous` outcome. Status reads the +Apply exits 0 only for an `applied` job; `not_applied`, `refused`, `ambiguous` and pending duplicate jobs +exit 1, including with `--json`. Status reads the same private job record without a running proxy or GUI token. Reuse the same optional request ID after a lost response: the same key and plan return the original job; a changed plan with that key refuses. The private ledger retains up to 64 jobs, including finished jobs, without automatic pruning. At that diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index dd367fc923..d3cea04a9b 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -419,6 +419,7 @@ Drives no management route. - Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session. - Admission is stored under the protected current-user config root before the installer can run. +- Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json. - Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused. ### `ocx system codex-cli-update status` diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index c44ce4c34e..3b91b4dc5d 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -618,7 +618,7 @@ export const CAPABILITIES: readonly Capability[] = [ routes: [], flags: [{ name: "--plan-id", value: "string", summary: "Required 32-hex id from the current plan." }, { name: "--yes", value: "boolean", summary: "Required confirmation." }, { name: "--request-id", value: "string", summary: "Optional stable retry key." }, { name: "--json", value: "boolean", summary: "Emit the public job status." }], mutates: true, json: "envelope", - details: ["Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session.", "Admission is stored under the protected current-user config root before the installer can run.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], + details: ["Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session.", "Admission is stored under the protected current-user config root before the installer can run.", "Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], usage: "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", }, { diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 65182a908c..5faf4576cd 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -336,6 +336,7 @@ export async function handleCodexCliUpdateCommand( if (!job) { unavailableJob(); return; } printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); + resultCode = job.status === "applied" ? 0 : 1; } }); return code || resultCode; diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts index bf987357ed..0e8d1821c6 100644 --- a/src/codex/cli-update-job.ts +++ b/src/codex/cli-update-job.ts @@ -131,6 +131,29 @@ function readStore(configDir?: string): Store { return value; } finally { if (fd !== undefined) closeSync(fd); } } +function rollbackInitializingLock(p: ReturnType, lockIdentity: { dev: number; ino: number }, ownerIdentity?: { dev: number; ino: number }) { + try { + const ownsDirectory = () => { + const stat = lstatSync(p.lock); + return stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === lockIdentity.dev && stat.ino === lockIdentity.ino + && stat.uid === process.geteuid?.() && (stat.mode & 0o077) === 0 && realpathSync.native(p.lock) === p.lock; + }; + if (!ownsDirectory()) return; + if (ownerIdentity) { + const owner = join(p.lock, "owner.json"); + const fd = openSync(owner, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd), lexical = lstatSync(owner); + if (!stat.isFile() || stat.uid !== process.geteuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 + || stat.dev !== ownerIdentity.dev || stat.ino !== ownerIdentity.ino || lexical.isSymbolicLink() + || lexical.dev !== stat.dev || lexical.ino !== stat.ino || !ownsDirectory()) return; + } finally { closeSync(fd); } + unlinkSync(owner); + } + // This call created the directory. Never remove a replacement or nonempty lock. + if (ownsDirectory()) rmdirSync(p.lock); + } catch { /* Unprovable initialization ownership stays fail-closed. */ } +} function withLock(configDir: string | undefined, fn: (p: ReturnType) => T, createConfigRoot = false): T { const p = prepare(configDir, true, createConfigRoot); let lockIdentity: { dev: number; ino: number } | undefined; @@ -147,8 +170,14 @@ function withLock(configDir: string | undefined, fn: (p: ReturnType { verificationPid: null, cleanupErrors: [] }; const terminal = { jobId: randomUUID(), status: "applied", outcome: "applied", reason: "verified_target" } as CodexCliUpdateJobDto; + test.each(["applied", "refused", "ambiguous", "not_applied", "queued", "running"] as const)("apply exit code reflects %s, including JSON and pending duplicate jobs", async status => { + const job = { ...terminal, status, outcome: status === "queued" || status === "running" ? null : status, + ownerState: status === "queued" || status === "running" ? "active" : "finished" } as CodexCliUpdateJobDto; + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob"); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockImplementation(async (_id, _generation, run) => { await run(); return job; }); + const log = spyOn(console, "log").mockImplementation(() => {}); + let runs = 0; + try { + const kinds = status === "queued" || status === "running" ? ["duplicate"] as const : ["accepted", "duplicate"] as const; + for (const kind of kinds) { + admission.mockReturnValue({ kind, job, generation: randomUUID() }); runs = 0; worker.mockClear(); + for (const json of [false, true]) { + log.mockClear(); + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", ...(json ? ["--json"] : [])], + { applyUpdate: async () => { runs++; return result; } })).toBe(status === "applied" ? 0 : 1); + expect(log).toHaveBeenCalled(); + if (json) expect(JSON.parse(String(log.mock.calls[0]![0]))).toMatchObject({ jobId: job.jobId, status, outcome: job.outcome }); + } + expect(runs).toBe(kind === "accepted" ? 2 : 0); + expect(worker).toHaveBeenCalledTimes(kind === "accepted" ? 2 : 0); + } + } finally { log.mockRestore(); admission.mockRestore(); worker.mockRestore(); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined).each(["open", "partial-write"] as const)("owned lock initialization %s failure rolls back and permits later admission", failure => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-init-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); + const nativeOpen = fs.openSync, nativeWrite = fs.writeFileSync; + let ownerFd: number | undefined; + const open = spyOn(fs, "openSync").mockImplementation((...args: Parameters) => { + if (String(args[0]) === owner && failure === "open") throw Object.assign(new Error("injected owner open"), { code: "ENOSPC" }); + const fd = nativeOpen(...args); if (String(args[0]) === owner) ownerFd = fd; return fd; + }); + const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === ownerFd && failure === "partial-write") { + nativeWrite(ownerFd!, "{"); throw Object.assign(new Error("injected partial owner write"), { code: "ENOSPC" }); + } + return nativeWrite(...args); + }); + try { + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-fault-01", planId: result.planId, confirmed: true, configDir })).toThrow(); + expect(existsSync(lock)).toBe(false); + open.mockRestore(); write.mockRestore(); + expect(admitCodexCliUpdateJob({ requestId: "initializer-fault-01", planId: result.planId, confirmed: true, configDir }).kind).toBe("accepted"); + expect(existsSync(lock)).toBe(false); + } finally { open.mockRestore(); write.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined).each(["directory", "owner-file"] as const)("initialization rollback refuses a replaced %s identity", replaced => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-replaced-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); + const nativeOpen = fs.openSync, nativeWrite = fs.writeFileSync; + let ownerFd: number | undefined; + const open = spyOn(fs, "openSync").mockImplementation((...args: Parameters) => { + if (String(args[0]) === owner && replaced === "directory") { + fs.renameSync(lock, `${lock}-original`); mkdirSync(lock, { mode: 0o700 }); nativeWrite(owner, "replacement"); + throw Object.assign(new Error("injected owner open after replacement"), { code: "ENOSPC" }); + } + const fd = nativeOpen(...args); if (String(args[0]) === owner) ownerFd = fd; return fd; + }); + const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === ownerFd && replaced === "owner-file") { + nativeWrite(ownerFd!, "{"); fs.renameSync(owner, `${owner}-original`); nativeWrite(owner, "replacement"); + throw Object.assign(new Error("injected owner write after replacement"), { code: "ENOSPC" }); + } + return nativeWrite(...args); + }); + try { + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-01", planId: result.planId, confirmed: true, configDir })).toThrow(); + open.mockRestore(); write.mockRestore(); + expect(readFileSync(owner, "utf8")).toBe("replacement"); + expect(existsSync(replaced === "directory" ? `${lock}-original` : `${owner}-original`)).toBe(true); + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-02", planId: result.planId, confirmed: true, configDir })).toThrow("job_store_busy"); + } finally { open.mockRestore(); write.mockRestore(); rmSync(outer, { recursive: true }); } + }); + test("a save failure retries the actual applied result without rerunning the engine", async () => { const saved: CodexCliApplyResult[] = []; let runs = 0, released = 0; const value = await runCodexCliUpdateJobWork(async () => { runs++; return result; }, { From d67b167fedd86c2af5f0fc1ba7ab1eafb096f2ec Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 13:43:26 +0900 Subject: [PATCH 16/33] test(codex): isolate admission lock replacement identity guards --- tests/cli/cli-codex-cli-update.test.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index a2589b38b8..40e436b2d8 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -170,16 +170,19 @@ describe("Codex CLI job persistence failures", () => { const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); const nativeOpen = fs.openSync, nativeWrite = fs.writeFileSync; let ownerFd: number | undefined; + let originalLock: ReturnType | undefined, originalOwner: ReturnType | undefined; const open = spyOn(fs, "openSync").mockImplementation((...args: Parameters) => { - if (String(args[0]) === owner && replaced === "directory") { - fs.renameSync(lock, `${lock}-original`); mkdirSync(lock, { mode: 0o700 }); nativeWrite(owner, "replacement"); - throw Object.assign(new Error("injected owner open after replacement"), { code: "ENOSPC" }); - } const fd = nativeOpen(...args); if (String(args[0]) === owner) ownerFd = fd; return fd; }); const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { - if (args[0] === ownerFd && replaced === "owner-file") { - nativeWrite(ownerFd!, "{"); fs.renameSync(owner, `${owner}-original`); nativeWrite(owner, "replacement"); + if (args[0] === ownerFd) { + nativeWrite(ownerFd!, "{"); originalLock = lstatSync(lock); originalOwner = fs.fstatSync(ownerFd!); + if (replaced === "directory") { + fs.renameSync(lock, `${lock}-original`); mkdirSync(lock, { mode: 0o700 }); + // Keep the owner inode valid so only the changed directory identity refuses cleanup. + fs.renameSync(join(`${lock}-original`, "owner.json"), owner); + } else { fs.renameSync(owner, `${owner}-original`); } + nativeWrite(owner, "replacement", { mode: 0o600 }); throw Object.assign(new Error("injected owner write after replacement"), { code: "ENOSPC" }); } return nativeWrite(...args); @@ -187,6 +190,16 @@ describe("Codex CLI job persistence failures", () => { try { expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-01", planId: result.planId, confirmed: true, configDir })).toThrow(); open.mockRestore(); write.mockRestore(); + const replacementLock = lstatSync(lock), replacementOwner = lstatSync(owner); + expect(replacementLock.mode & 0o777).toBe(0o700); + expect(replacementOwner.mode & 0o777).toBe(0o600); + expect(replacementOwner.uid).toBe(process.geteuid!()); expect(replacementOwner.nlink).toBe(1); + expect(replacementLock.dev).toBe(originalLock!.dev); expect(replacementOwner.dev).toBe(originalOwner!.dev); + if (replaced === "directory") { + expect(replacementLock.ino).not.toBe(originalLock!.ino); expect(replacementOwner.ino).toBe(originalOwner!.ino); + } else { + expect(replacementLock.ino).toBe(originalLock!.ino); expect(replacementOwner.ino).not.toBe(originalOwner!.ino); + } expect(readFileSync(owner, "utf8")).toBe("replacement"); expect(existsSync(replaced === "directory" ? `${lock}-original` : `${owner}-original`)).toBe(true); expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-02", planId: result.planId, confirmed: true, configDir })).toThrow("job_store_busy"); From 4c2230821f31a9646aa4309cb7fe93c9f7b7f83a Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 14:05:45 +0900 Subject: [PATCH 17/33] test(codex): align unapplied CLI consumer exit expectation --- tests/codex-integration/codex-cli-update-artifacts.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index d05805df2c..6e1e761b41 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -270,7 +270,7 @@ describe("Codex CLI apply consumer", () => { console.log = value => { logs.push(String(value)); }; expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", planId, "--yes", "--json"], { applyUpdate: async (id, deps) => { calls++; expect(id).toBe(planId); expect(deps.inspectionDeps).toBeDefined(); return report; }, - })).toBe(0); + })).toBe(1); expect(calls).toBe(1); expect(logs).toHaveLength(1); expect(fs.existsSync(join(configDir, "codex-cli-update-jobs", "jobs.json"))).toBe(true); const dto = JSON.parse(logs[0]!) as Record; From c7dcd6577b72667f6e202b1c226c54876f419a5f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 19:00:16 +0900 Subject: [PATCH 18/33] fix(codex): preserve CLI results after persistence failure --- src/cli/codex-cli-update.ts | 26 +++++++++++-- structure/INDEX.md | 1 + structure/codex-cli-update.md | 2 + structure/manifest.json | 1 + tests/cli/cli-codex-cli-update.test.ts | 53 ++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 5faf4576cd..58030b3282 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -28,6 +28,7 @@ import { trustedNodeLauncherContext } from "./launcher-context"; import type { CodexCliApplyDeps, CodexCliApplyResult } from "../codex/cli-update-apply"; import { randomUUID } from "node:crypto"; import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../codex/cli-update-job"; +import { CodexCliJobResultPersistenceError, normalizeCodexCliJobResult } from "../codex/cli-update-job-execution"; export const CODEX_CLI_UPDATE_USAGE = `Usage: ocx system codex-cli-update check [--json] @@ -329,10 +330,27 @@ export async function handleCodexCliUpdateCommand( if (parsed.action === "apply") { const apply = deps.applyUpdate ?? (await import("../codex/cli-update-apply")).applyCodexCliUpdate; const admitted = admitCodexCliUpdateJob({ requestId: parsed.requestId, planId: parsed.planId, confirmed: true }); - const job = admitted.kind === "accepted" - ? await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, - () => apply(parsed.planId, { inspectionDeps: codexCliUpdateInspectionDeps(deps) })) - : admitted.job; + let job: Awaited>; + try { + job = admitted.kind === "accepted" + ? await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => apply(parsed.planId, { inspectionDeps: codexCliUpdateInspectionDeps(deps) })) + : admitted.job; + } catch (error) { + if (!(error instanceof CodexCliJobResultPersistenceError)) throw error; + // Preserve the computed outcome without claiming durable completion or + // retrying work. Project fixed fields, never the raw result/error object. + const safe = normalizeCodexCliJobResult(error.result); + const receipt = { error: "job_result_persistence_failed", jobId: admitted.job.jobId, + outcome: safe.outcome, reason: safe.reason, leaseRetained: safe.leaseRetained, + cleanupErrors: safe.cleanupErrors }; + printData(receipt, parsed.json, [`job: ${receipt.jobId}`, + "persistence: unverified (job_result_persistence_failed)", `outcome: ${receipt.outcome}`, + `reason: ${receipt.reason}`, `lease-retained: ${receipt.leaseRetained}`, + `cleanup-errors: ${receipt.cleanupErrors.join(", ") || "none"}`]); + resultCode = 1; + return; + } if (!job) { unavailableJob(); return; } printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); diff --git a/structure/INDEX.md b/structure/INDEX.md index 71e40bb351..5353e7e1ce 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -296,6 +296,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/inference/advisor-diagnostics.ts` | [`advisor.md`](advisor.md) | | `src/server/management/` | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md) | | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | +| `src/server/management/codex-cli-update-routes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 48869fc87f..52793b38a0 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -119,6 +119,8 @@ with status 503 and `Retry-After: 1`; permanent admission failures do not advert Only engine exceptions synthesize `job_result_unknown`; persistence retries keep the same actual result. Transient completion-write failures have the same bounded retry budget. Permanent failure reports `job_result_persistence_failed` while retaining the computed result in the error; it does not establish durable completion. +CLI apply prints that failure with the admitted job ID, normalized outcome/reason, lease retention and fixed cleanup codes +in both human and JSON output, and exits 1. It never publishes raw result/error fields or reruns the installer to recover the receipt. Unknown result/cleanup codes are normalized to fixed-code uncertainty before writing, so they cannot poison later reads. Unsupported prefixes and unavailable lease storage are distinct from a held installation lease. Missing/unreadable CLI job status has a structured `job_unavailable` JSON error and a nonzero exit. diff --git a/structure/manifest.json b/structure/manifest.json index 1a0722aa01..9c80f0de54 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -179,6 +179,7 @@ "src/codex/cli-update-apply.ts", "src/codex/cli-update-job.ts", "src/codex/cli-update-job-execution.ts", + "src/server/management/codex-cli-update-routes.ts", "src/codex/cli-update-inspection-context.ts", "bin/ocx.mjs", "src/codex/cli-update-artifacts.ts", diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 40e436b2d8..0156df8cef 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -141,6 +141,59 @@ describe("Codex CLI job persistence failures", () => { } finally { log.mockRestore(); admission.mockRestore(); worker.mockRestore(); } }); + test.each(["applied", "not_applied", "unknown-code"] as const)("CLI preserves the unpersisted %s result without rerunning work", async outcome => { + const unknown = outcome === "unknown-code"; + const actual = { ...result, outcome: outcome === "not_applied" ? outcome : "applied", + reason: unknown ? "private/unknown-reason" : outcome === "applied" ? "verified_target" : "original_install_unchanged", + leaseRetained: outcome !== "applied", cleanupErrors: unknown ? ["private/cleanup", "lease_release_exception"] + : outcome === "applied" ? ["staging_cleanup_failed"] : ["lease_release_exception"], + installedVersion: "private/installed", privateDetails: "private/internal-path" } as CodexCliApplyResult; + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockReturnValue({ + kind: "accepted", job: terminal, generation: randomUUID(), + }); + let runs = 0, finishes = 0, releases = 0; + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockImplementation(async (_id, _generation, run) => + runCodexCliUpdateJobWork(run, { begin: () => 1, read: () => null, release: () => { releases++; }, + finish: () => { finishes++; throw new Error("unsafe_store"); } })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const json of [false, true]) { + runs = finishes = releases = 0; worker.mockClear(); log.mockClear(); error.mockClear(); + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", ...(json ? ["--json"] : [])], + { applyUpdate: async () => { runs++; return actual; } })).toBe(1); + const safeOutcome = unknown ? "ambiguous" : outcome; + const safeReason = unknown ? "job_result_unknown" : actual.reason; + const cleanupErrors = unknown ? ["lease_release_exception"] : actual.cleanupErrors; + const output = log.mock.calls.map(call => String(call[0])).join("\n"); + if (json) expect(JSON.parse(output)).toEqual({ error: "job_result_persistence_failed", jobId: terminal.jobId, + outcome: safeOutcome, reason: safeReason, leaseRetained: actual.leaseRetained, cleanupErrors }); + else for (const line of [`job: ${terminal.jobId}`, "persistence: unverified (job_result_persistence_failed)", + `outcome: ${safeOutcome}`, `reason: ${safeReason}`, `lease-retained: ${actual.leaseRetained}`, + `cleanup-errors: ${cleanupErrors.join(", ")}`]) expect(output).toContain(line); + expect(output).not.toContain("private/"); + expect(error).not.toHaveBeenCalled(); expect(worker).toHaveBeenCalledTimes(1); + expect(runs).toBe(1); expect(finishes).toBe(1); expect(releases).toBe(1); + } + } finally { error.mockRestore(); log.mockRestore(); worker.mockRestore(); admission.mockRestore(); } + }); + + test("CLI leaves generic apply worker failures on the existing error path", async () => { + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockReturnValue({ + kind: "accepted", job: terminal, generation: randomUUID(), + }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockRejectedValue(new Error("job_store_busy")); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + let runs = 0; + try { + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", "--json"], + { applyUpdate: async () => { runs++; return result; } })).toBe(1); + expect(log).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith("Error: job_store_busy"); + expect(worker).toHaveBeenCalledTimes(1); expect(runs).toBe(0); + } finally { error.mockRestore(); log.mockRestore(); worker.mockRestore(); admission.mockRestore(); } + }); + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined).each(["open", "partial-write"] as const)("owned lock initialization %s failure rolls back and permits later admission", failure => { const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-init-"); const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); From 9e5a917eddb8d1f1ab32dc0421e415ddb71ff3e9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:25:44 +0900 Subject: [PATCH 19/33] fix(test): promote Bun 1.4.4 with independent runner role verification --- .github/actions/setup-project-bun/action.yml | 4 +-- .github/workflows/ci.yml | 10 +++--- CONTRIBUTING.md | 6 ++-- docs-site/src/content/docs/contributing.md | 6 ++-- package.json | 2 +- structure/runtime.md | 4 +-- tests/ci-workflows/ci-scope-gaps.test.ts | 2 +- tests/ci-workflows/install-scripts.test.ts | 6 ++-- tests/test-layout-tooling.test.ts | 34 ++++++++++++++++---- 9 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1ab22dd1dc..cf28f6f9d4 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -8,8 +8,8 @@ description: >- # (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to # 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after # finalizers ran inside an earlier call and spin past its own timeout (#4956). -# Rejoin only after the upstream runner crash is fixed and a green lane=all -# dispatch proves the unified version; then delete testRunnerBun and this role input. +# Both roles now default to the package's 1.4.4 security transition, retaining role validation. +# Promotion requires a green full cross-platform lane=all run on the exact candidate head. inputs: role: description: Bun role to install (runtime or test-runner). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028c28ca31..7bcd0eaa3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -578,12 +578,10 @@ jobs: printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" - # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; - # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 - # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs - # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). - # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch - # proves the unified version, then delete testRunnerBun and the action's role input. + # Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json. + # Keep role validation after the historical isolate preload and spawnSync failures + # (#6713, #4956). Promotion requires a green full cross-platform lane=all run on + # the exact candidate head; focused local results do not replace that proof. # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d4aa94f58..bbcd248f8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks -The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses +The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7fe0b09831..d0021b7f12 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` -The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. +The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. Install that version locally if it is missing; the resolver never downloads it. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` by default. If a full run is disproportionately expensive for the task size, available machine resources, or concurrent worktrees, you must at least run focused regression tests diff --git a/package.json b/package.json index 89176da285..a270427faf 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "trustedDependencies": [ "bun" ], - "testRunnerBun": "1.4.3", + "testRunnerBun": "1.4.4", "scripts": { "dev": "bun run src/cli/index.ts start", "dev:proxy": "bun run src/cli/index.ts start", diff --git a/structure/runtime.md b/structure/runtime.md index 43e071d457..763f3e90c3 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ The minute sweep checks persisted activation deadlines locally; only missing dea ## Bun runtime and test runner -`package.json` pins the shipped npm runtime (`dependencies.bun`) to 1.4.4 and the test runner (`testRunnerBun`) to 1.4.3; `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. +`package.json` pins both the shipped npm runtime (`dependencies.bun`) and the test runner (`testRunnerBun`) to 1.4.4; the roles remain independently validated and `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload segfault (`load_preloads -> JSModuleLoader::loadModule -> JSPromise::status`, address 0x10): #4064 introduced 1.4.2 on 2026-09-09 and #4821 reverted it on 2026-09-17. It left 1.4.0 for 1.4.3 because of oven-sh/bun#44581 (#4956). Bun 1.4.0's `Bun.spawnSync` pointed the VM at its private wait loop, so a finalizer that ran during one call (an earlier file's stdout/stderr sink, a stream reader) released its poll on that loop; the cached loop's poll count drifted to zero and a later call stopped polling for its child, spinning past its own `timeout` until the 120-second batch deadline. In a multi-file `--isolate` process that depends on what earlier files left to finalize, so single-file attribution stayed clean. `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` reproduces the mechanism in a child under a bounded parent and fails on 1.4.0. #6713 run 37631558263 reproduces that 1.4.2 preload segfault on Linux 1/4, macOS 2/2 and Windows 9/9; single-file success does not prove the multi-file runner works. @@ -12,7 +12,7 @@ The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload se Test jobs, including single-file development-version validation, select `test-runner`. They also build dashboard fixtures; `gates` uploads a dashboard preview and checks release-helper syntax on that pin. Release packaging, Docker, installed-artifact and service smoke jobs, desktop shell and widget preparation use `runtime`. Shipped binaries compile through `process.execPath` in `scripts/build-standalone.ts` and `desktop/scripts/prepare-sidecar.ts` on the runtime pin. -`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading; package-script PATH shadowing cannot select the shipped runtime for tests. Rejoin only after an upstream fix and green unified `lane=all` proof; then remove the runner pin, resolver and CI role split. +`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading. The caller need not already run the test pin; only a version match permits reusing its executable. `tests/test-layout-tooling.test.ts` verifies matching/different caller fixtures, the selected isolate command, and refusal before spawning tests for a wrong-version override. The 1.4.4 security transition aligns the default versions while retaining the role fields, resolver and CI role checks. Promotion requires successful full cross-platform `lane=all` proof on the exact candidate head; focused local tests do not establish that proof. ## Resolved static model policy diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index f780d71aa9..0729ab6945 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -65,7 +65,7 @@ describe("an edit to the setup action alone", () => { }); }); -// #6713: user runtimes and compiled artifacts need 1.4.2; isolate runners need the testRunnerBun pin (1.4.3, #4956). +// Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4. // Check the active setup at each test command, so a later role switch cannot evade the guard. const runsBunTests = (step: Step): boolean => /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 51e62b0b6f..23ed7b7461 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -94,10 +94,10 @@ describe("install scripts", () => { // and spun past its own timeout. Scratch run 38213764818 stalled 8 of 252 Linux shard 1/4 // batch-5 runs that way (CLI child a zombie, its pidfd armed but never polled). // tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts keeps the runner above it. - // Rejoin after the upstream runner crash is fixed and a green `lane=all` dispatch - // proves the unified version; then delete testRunnerBun and the setup action's role. + // The security transition now defaults both roles to 1.4.4. Keep their independent + // validation; promotion still requires a green full cross-platform lane=all candidate run. expect(pkg.dependencies?.bun).toBe("1.4.4"); - expect(pkg.testRunnerBun).toBe("1.4.3"); + expect(pkg.testRunnerBun).toBe("1.4.4"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index 354e3943fe..cb4a748281 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -7,6 +7,7 @@ import { repoPath, repoRoot } from "./helpers/repo-root"; import { listTestFiles, planMoves } from "../scripts/test-layout/plan"; import { runMove } from "../scripts/test-layout/move"; import { runVerify } from "../scripts/test-layout/verify"; +import * as runnerBun from "../scripts/lib/test-runner-bun"; import { testRunnerBun } from "../package.json"; import { anchors, @@ -469,24 +470,43 @@ describe("move end to end", () => { } }); - test("domain verification launches isolate tests through the pinned runner", () => { + const otherBunVersion = testRunnerBun === "1.4.3" ? "1.4.4" : "1.4.3"; + test.each([ + ["matching caller", testRunnerBun, testRunnerBun, false], + ["different caller", otherBunVersion, testRunnerBun, false], + ["wrong override version", otherBunVersion, otherBunVersion, true], + ] as const)("domain verification preserves the configured runner role: %s", (_name, currentVersion, overrideVersion, refused) => { const { root, cleanup } = scratchRepo(); const layoutPath = join(root, "scripts", "test-layout", "layout.json"); try { runMove({ root, domains: ["server", "providers"], dryRun: false, layoutPath, skipVerify: true, log: () => {} }); + const binaryName = process.platform === "win32" ? "bun.exe" : "bun"; + const pinnedRunner = join(root, "test-runner", binaryName); + const caller = currentVersion === testRunnerBun ? pinnedRunner : join(root, "runtime", binaryName); + const probes: string[] = []; + // Keep role resolution real, but version probes independent of the host and the spawn mock. + const select = spyOn(runnerBun, "getTestRunnerBun").mockImplementation(() => runnerBun.resolveTestRunnerBun({ + pin: testRunnerBun, currentVersion, execPath: caller, + env: { OCX_TEST_RUNNER_BUN: pinnedRunner }, pathEntries: [], homeDir: root, platform: process.platform, + probeVersion: binary => { probes.push(binary); return binary === pinnedRunner ? overrideVersion : undefined; }, + })); const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: Buffer.from(""), stderr: Buffer.from(""), } as ReturnType); try { - const report = runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); - expect(report.testExit).toBe(0); - // The test itself runs on the required pin, so its executable is the resolver's first choice. - expect(Bun.version).toBe(testRunnerBun); - expect(spawn.mock.calls.at(-1)?.[0]).toEqual([ - process.execPath, "test", "--isolate", "tests/server", "tests/providers", + const verify = () => runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); + if (refused) expect(verify).toThrow(`OCX_TEST_RUNNER_BUN reports ${overrideVersion}`); + else expect(verify()).toMatchObject({ ok: true, testExit: 0 }); + expect(select).toHaveBeenCalledTimes(1); + expect(probes).toEqual(currentVersion === testRunnerBun ? [] : [pinnedRunner]); + const testSpawns = spawn.mock.calls.filter(([command]) => Array.isArray(command) && command[1] === "test"); + expect(testSpawns.map(([command]) => command)).toEqual(refused ? [] : [ + [pinnedRunner, "test", "--isolate", "tests/server", "tests/providers"], ]); + if (!refused) expect(testSpawns[0]?.[1]).toMatchObject({ cwd: root, stdout: "inherit", stderr: "inherit" }); } finally { spawn.mockRestore(); + select.mockRestore(); } } finally { cleanup(); From 1deb67d274c8246df9ad539bff93f958782032cb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:38:09 +0900 Subject: [PATCH 20/33] docs: align Docker Bun comments with unified role defaults --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 82fe86f4cf..54e0153e0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,12 @@ # Keep the runtime aligned with package.json and pin the multi-platform image index. # Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS -# 3.131.1 roots without the compromised eMudhra CAs; CI tests run on 1.4.3 -# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; -# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync -# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the -# upstream crash fix passes a unified-version lane=all dispatch, then delete -# package.json's testRunnerBun pin and setup-project-bun's role input. +# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner +# defaults are both 1.4.4; keep package.json's testRunnerBun pin and +# setup-project-bun's role input independently validated. Promotion requires +# an exact-head cross-platform lane=all dispatch, including multi-file --isolate: +# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0 +# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks. ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d FROM ${BUN_IMAGE} AS manifest From 41f08fffd3e7dea64a6eed79af918aed74a153dd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:07:35 +0900 Subject: [PATCH 21/33] test: use a draining TLS origin for early upload replies --- .../claude-picker-upload.test.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d185..95c6351f04 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -3,14 +3,39 @@ import { Agent, createServer, request } from "node:https"; import { connect, constants, type ClientHttp2Session, type ClientHttp2Stream } from "node:http2"; import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; +import { createServer as createTlsServer } from "node:tls"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; import { startPickerListener, type PickerListenerOptions } from "../../src/claude/intercept/picker-listener"; -async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => void, +async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => void) | "early-fin", options: Partial = {}) { const ca = createLocalInterceptCa(); const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); - const upstream = createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); + const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean }> = []; + // A wire-level origin sends the reply and FIN while continuing to drain input. Bun's HTTP + // server also tears down unfinished requests; overriding destroySoon alone does not prevent + // that reset racing ahead of the reply on Windows with a buffered upload. + const upstream = handler === "early-fin" ? createTlsServer({ cert: leaf.certPem, key: leaf.keyPem, allowHalfOpen: true }, socket => { + socket.on("error", () => socket.destroy()); + let headers = ""; + let replied = false; + socket.on("data", chunk => { + if (replied) return; + headers += chunk.toString("latin1"); + const end = headers.indexOf("\r\n\r\n"); + if (end < 0) return; + replied = true; + const early = headers.startsWith("POST /early "); + const body = early ? "early" : "ordinary"; + const length = /\r\ncontent-length: (\d+)\r\n/i.exec(headers.slice(0, end + 2)); + const requestBody = headers.slice(end + 4); + const observation = { requestComplete: length ? requestBody.length >= Number(length[1]) + : requestBody.endsWith("0\r\n\r\n"), flushed: false }; + if (early) earlyReplies.push(observation); + socket.once("finish", () => { observation.flushed = true; }); + socket.end(`HTTP/1.1 ${early ? "413 Payload Too Large" : "200 OK"}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`); + }); + }) : createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); const sockets = new Set(); for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); @@ -34,7 +59,7 @@ async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => v rejectUnauthorized: true, agent: h1Agent, path, method, headers: { Host: "claude.ai", ...headers } }); clients.push(req); return req; }; - return { logs, h2, h1, relay, + return { logs, h2, h1, relay, earlyReplies, async close() { const closed = clients.map(client => client.destroyed ? Promise.resolve() : observeClose(client)); await relay.close(); @@ -353,16 +378,7 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture((req, res) => { - // Answer like a real origin that replies early: keep reading the body and close with a FIN. - // Bun's own server would close this unfinished request with destroySoon(), a reset that - // Windows delivers before the reply bytes, so the relay could never read what it should relay. - req.on("error", () => res.destroy()); req.resume(); - const socket = req.socket; - socket.destroySoon = () => { socket.end(); return socket; }; - if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } - else res.end("ordinary"); - }); + const f = await fixture("early-fin"); try { const session = f.h2(); const upload = protocol === "h2" @@ -373,6 +389,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); + expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From a9c3d127d3728f50533df32504b787823b5220ee Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:09:36 +0900 Subject: [PATCH 22/33] test: confine draining origin to buffered HTTP/1 upload --- .../claude-integration/claude-picker-upload.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 95c6351f04..d0bb998b87 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -378,7 +378,16 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture("early-fin"); + const f = await fixture(input === "buffered" ? "early-fin" : (req, res) => { + // Answer like a real origin that replies early: keep reading the body and close with a FIN. + // Bun's own server would close this unfinished request with destroySoon(), a reset that + // Windows delivers before the reply bytes, so the relay could never read what it should relay. + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } + else res.end("ordinary"); + }); try { const session = f.h2(); const upload = protocol === "h2" @@ -389,7 +398,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); - expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); + if (input === "buffered") expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From 1523ba32752903f0121c31759c435db4482d2c68 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:58:55 +0900 Subject: [PATCH 23/33] fix(codex): run cli-update registry queries and session scans async PR #7586 review P1: planning ran three sequential npm view spawnSync calls (12s deadline each) plus the synchronous process-table scan in the server process, parking the proxy event loop for unrelated requests and streaming; background apply repeats the same planning. - spawnCodexCliUpdateMetadata: async child runner preserving the deadline, 1 MiB stdout bound, SIGKILL + bounded reap, and the status-null refusal lanes of spawnSync - resolveCodexCliUpdateTarget/resolveCodexCliUpdateArtifactTarget are async; createCodexCliUpdatePlan awaits them; CodexCliUpdateSpawn keeps sync test injections assignable - scanCodexSessionProcessesAsync plus async darwin/Windows enumeration twins; plan and apply prefer the injected async seam, then sync dep, then the async default - gated-child regressions: a real npm-view-shaped child held on a release file proves timers and a concurrent management request stay responsive while planning waits; wall-clock bounds discriminate a parked loop and the child self-cap keeps the harness hang-free --- src/codex/app-server-processes.ts | 85 +++++++++- src/codex/cli-update-apply.ts | 8 +- src/codex/cli-update-artifacts.ts | 29 +++- src/codex/cli-update-plan.ts | 144 ++++++++++++++--- .../codex-app-server-processes.test.ts | 36 +++++ .../codex-cli-update-integrity.test.ts | 12 +- .../codex-cli-update-plan.test.ts | 145 ++++++++++++------ .../server/management-route-registry.test.ts | 79 ++++++++++ 8 files changed, 458 insertions(+), 80 deletions(-) diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index b0757976d5..31b1664fc2 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -395,7 +395,6 @@ function listUnixProcSnapshots(uid: number | undefined, requireComplete = true): } function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { - const out: ProcessSnapshot[] = []; const commandOutput = uid !== undefined ? execFileSync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, @@ -410,6 +409,28 @@ function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { : execFileSync("/bin/ps", ["-axo", "pid=,comm="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, }); + return parseDarwinSnapshotOutputs(commandOutput, executableOutput, uid); +} + +/** + * Request-path twin of {@link listDarwinSnapshots}: the two `ps` children run off the + * event loop, so a stalled process-table read cannot park the proxy. Failure still + * propagates as a rejected read, matching the synchronous contract. + */ +async function listDarwinSnapshotsAsync(uid: number | undefined): Promise { + const [commandOutput, executableOutput] = await Promise.all([ + uid !== undefined + ? execFileTextAsync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], 5_000) + : execFileTextAsync("/bin/ps", ["-axo", "pid=,uid=,command="], 5_000), + uid !== undefined + ? execFileTextAsync("/bin/ps", ["-u", String(uid), "-o", "pid=,comm="], 5_000) + : execFileTextAsync("/bin/ps", ["-axo", "pid=,comm="], 5_000), + ]); + return parseDarwinSnapshotOutputs(commandOutput, executableOutput, uid); +} + +function parseDarwinSnapshotOutputs(commandOutput: string, executableOutput: string, uid: number | undefined): ProcessSnapshot[] { + const out: ProcessSnapshot[] = []; const executableByPid = new Map(); for (const raw of executableOutput.split(/\r?\n/)) { const match = /^\s*(\d+)\s+(.+)$/.exec(raw); @@ -552,6 +573,22 @@ function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | return listUnixProcSnapshots(getuid(), requireComplete); } +/** + * Request-path twin of {@link defaultListSnapshots}: the child-process enumerations + * (Windows PowerShell, darwin ps) wait off the event loop. Linux /proc reads are + * bounded filesystem I/O rather than a subprocess deadline, so they stay synchronous + * underneath without reintroducing a multi-second park. + */ +async function defaultListSnapshotsAsync( + platform: NodeJS.Platform, + getuid: () => number | undefined, + requireComplete = true, +): Promise { + if (platform === "win32") return listWindowsSnapshotsAsync(); + if (platform === "darwin") return listDarwinSnapshotsAsync(getuid()); + return listUnixProcSnapshots(getuid(), requireComplete); +} + export interface ListProcessSnapshotsOptions { platform?: NodeJS.Platform; getuid?: () => number | undefined; @@ -639,6 +676,43 @@ function scanProcessSnapshots( } catch { return Object.freeze({ kind: "unavailable" as const }); } + return matchProcessSnapshots(snapshots, matches); +} + +/** + * Request-path twin of {@link scanProcessSnapshots}: an injected async source, or the + * real enumeration's child processes, resolve off the event loop. The fail-closed + * contract is unchanged — an unreadable process table is still `unavailable`. + */ +async function scanProcessSnapshotsAsync( + io: CodexAppServerProcessIo, + matches: (commandLine: string, executable?: string) => boolean, +): Promise { + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + let snapshots: ProcessSnapshot[]; + try { + snapshots = io.listSnapshotsAsync + ? await io.listSnapshotsAsync() + : io.listSnapshots + ? io.listSnapshots() + : await defaultListSnapshotsAsync(platform, getuid); + } catch { + return Object.freeze({ kind: "unavailable" as const }); + } + return matchProcessSnapshots(snapshots, matches); +} + +function matchProcessSnapshots( + snapshots: readonly ProcessSnapshot[], + matches: (commandLine: string, executable?: string) => boolean, +): CodexAppServerProcessScan { const seen = new Set(); const matched: CodexAppServerProcess[] = []; for (const snapshot of snapshots) { @@ -671,6 +745,15 @@ export function scanCodexSessionProcesses(io: CodexAppServerProcessIo = {}): Cod return scanProcessSnapshots({ ...io, getuid: io.getuid ?? (() => undefined) }, isCodexSessionCommandLine); } +/** + * Async twin of {@link scanCodexSessionProcesses} for request paths: planning and the + * apply re-scan must not park the proxy event loop on a process-table child wait. + * Same broader read-only scope, same fail-closed contract. + */ +export function scanCodexSessionProcessesAsync(io: CodexAppServerProcessIo = {}): Promise { + return scanProcessSnapshotsAsync({ ...io, getuid: io.getuid ?? (() => undefined) }, isCodexSessionCommandLine); +} + export function formatStaleCodexAppServerWarning( processes: readonly { pid: number }[], ): string { diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index 026f3da21a..5c4b0efe6f 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -11,7 +11,7 @@ import { codexCliPlatformBacking } from "./cli-install-provenance"; import { verifyCodexCliArchive, verifiedCodexCliContent, type CodexCliArtifact } from "./cli-update-artifacts"; import { acquireCodexCliApplyLease } from "./cli-update-lease"; -import { scanCodexSessionProcesses } from "./app-server-processes"; +import { scanCodexSessionProcessesAsync } from "./app-server-processes"; import { observeCodexCliFileSet, observeCodexCliPreimage, sameCodexCliPreimage, codexCliExpectedFiles, sameCodexCliFiles } from "./cli-update-files"; export interface CodexCliApplyResult { @@ -210,7 +210,11 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep `${artifacts.main.installName}@file:${paths[0]}`, `${artifacts.backing.installName}@file:${paths[1]}`]; const invocation = isolatedNpmTarget(args, isolation); if (!invocation) return result("refused", "npm_unavailable"); - const lastScan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); + const lastScan = deps.scanProcessesAsync + ? await deps.scanProcessesAsync(deps.processIo ?? {}) + : deps.scanProcesses + ? deps.scanProcesses(deps.processIo ?? {}) + : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); if (lastScan.kind !== "observed") return result("refused", "blocked_process_state_unknown"); if (lastScan.processes.length) return result("refused", "blocked_active_session"); started = true; diff --git a/src/codex/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts index 7c660610a3..7fae08b6e5 100644 --- a/src/codex/cli-update-artifacts.ts +++ b/src/codex/cli-update-artifacts.ts @@ -49,16 +49,39 @@ export function backingReference(main: Metadata, installName: string): { name: s const version = direct ? main.version : alias.slice("npm:@openai/codex@".length); return { name, version, spec: `${name}@${version}` }; } -export function codexCliArtifactSet(main: Metadata, query: (spec: string) => Metadata, - platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { +/** Main-metadata guard + reference derivation shared by the sync and async set builders. */ +function codexCliArtifactMain(main: Metadata, platform: NodeJS.Platform, arch: string) { const layout = codexCliPlatformBacking(platform, arch); if (!layout || platform === "win32" || main.name !== "@openai/codex") throw new Error("artifact_platform_unverified"); const mainArtifact = artifact(main, "@openai/codex"); const reference = backingReference(main, layout.package); - const backing = artifact(query(reference.spec), layout.package); + return { layout, mainArtifact, reference }; +} +function codexCliArtifactFinish(mainArtifact: CodexCliArtifact, + reference: { name: string; version: string; spec: string }, + layout: { package: string }, backingMeta: Metadata, + platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { + const backing = artifact(backingMeta, layout.package); if (backing.name !== reference.name || backing.version !== reference.version) throw new Error("backing_metadata_unverified"); return Object.freeze({ platform, arch, main: mainArtifact, backing }); } +export function codexCliArtifactSet(main: Metadata, query: (spec: string) => Metadata, + platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { + // Main integrity is refused BEFORE the backing query is issued — the tarball query + // must never run on already-unverified metadata (integrity gate, 2 queries total). + const { layout, mainArtifact, reference } = codexCliArtifactMain(main, platform, arch); + return codexCliArtifactFinish(mainArtifact, reference, layout, query(reference.spec), platform, arch); +} +/** + * The request-path twin of {@link codexCliArtifactSet}: the backing query resolves off the + * event loop, so a waiting registry child cannot stall the proxy. The platform guard and + * main-artifact check still run before any backing query is issued. + */ +export async function codexCliArtifactSetAsync(main: Metadata, query: (spec: string) => Promise, + platform: NodeJS.Platform, arch: string): Promise { + const { layout, mainArtifact, reference } = codexCliArtifactMain(main, platform, arch); + return codexCliArtifactFinish(mainArtifact, reference, layout, await query(reference.spec), platform, arch); +} export function codexCliArtifactSetDigest(set: CodexCliArtifactSet): string { return createHash("sha256").update(JSON.stringify([set.platform, set.arch, ...[set.main, set.backing].map(file => [file.name, file.installName, file.version, file.integrity, file.tarball])])).digest("hex"); diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 70cc0cb849..5774394393 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -1,4 +1,4 @@ -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -7,7 +7,7 @@ import { join, resolve } from "node:path"; import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import { npmInvocation } from "../update/npm-invocation.mjs"; import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch-policy.mjs"; -import { CODEX_CLI_REGISTRY, isSha512Integrity, codexCliArtifactSet, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; +import { CODEX_CLI_REGISTRY, isSha512Integrity, codexCliArtifactSetAsync, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; export { CODEX_CLI_REGISTRY } from "./cli-update-artifacts"; import { inspectCodexCliInstall, @@ -16,7 +16,7 @@ import { type CodexCliInstallReport, } from "./cli-install-provenance"; import { - scanCodexSessionProcesses, + scanCodexSessionProcessesAsync, type CodexAppServerProcessIo, type CodexAppServerProcessScan, } from "./app-server-processes"; @@ -110,9 +110,11 @@ export interface CodexCliUpdatePlanDeps { readonly platform?: NodeJS.Platform; readonly channel?: CodexCliUpdateChannel; readonly scanProcesses?: (io?: CodexAppServerProcessIo) => CodexAppServerProcessScan; + /** Preferred request-path seam: the real session scan runs its child processes async. */ + readonly scanProcessesAsync?: (io?: CodexAppServerProcessIo) => Promise; readonly processIo?: CodexAppServerProcessIo; - readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget; - readonly spawnProcess?: typeof spawnSync; + readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget | Promise; + readonly spawnProcess?: CodexCliUpdateSpawn; } @@ -345,6 +347,96 @@ export function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigI }; } +/** The option fields one registry-metadata query passes to its spawn. */ +export interface CodexCliUpdateSpawnOptions { + readonly encoding: "utf8"; + readonly timeout: number; + readonly maxBuffer?: number; + readonly windowsHide: true; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly windowsVerbatimArguments?: boolean; +} + +/** The fields a resolver reads off one spawn result; sync injections may carry more. */ +export interface CodexCliUpdateSpawnResult { + readonly status: number | null; + readonly stdout: string; +} + +/** + * One registry-metadata invocation. The production runner is asynchronous so a slow + * npm cannot park the proxy event loop; a synchronous SpawnSync-shaped injection + * remains assignable, so tests asserting argv/env without real I/O are unchanged. + */ +export type CodexCliUpdateSpawn = ( + bin: string, + args: readonly string[], + options: CodexCliUpdateSpawnOptions, +) => CodexCliUpdateSpawnResult | Promise; + +/** + * A single bounded registry child whose wait happens off the event loop. + * + * The previous spawnSync blocked the whole proxy for up to the per-query deadline: + * a slow registry stalled unrelated requests and streaming for seconds at a time, + * and the background apply repeated the same stall. The observable contract is + * unchanged — status null on timeout or spawn failure, stdout bounded by + * maxBuffer — but only the awaited caller observes the child. + */ +export function spawnCodexCliUpdateMetadata( + bin: string, + args: readonly string[], + options: CodexCliUpdateSpawnOptions, +): Promise { + const maxBuffer = options.maxBuffer ?? 1024 * 1024; + return new Promise(resolve => { + let deadline: ReturnType | undefined; + let reap: ReturnType | undefined; + let settled = false; + let stdout = ""; + let overflow = false; + const finish = (status: number | null) => { + if (settled) return; + settled = true; + if (deadline) clearTimeout(deadline); + if (reap) clearTimeout(reap); + resolve(Object.freeze({ status, stdout })); + }; + let child; + try { + child = spawn(bin, [...args], { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + }); + } catch { + finish(null); + return; + } + child.stdout?.on("data", (chunk: Buffer) => { + if (stdout.length + chunk.length > maxBuffer) overflow = true; + else stdout += chunk.toString("utf8"); + // spawnSync killed the child once maxBuffer was exceeded; preserve the bound. + if (overflow) { try { child.kill("SIGKILL"); } catch { /* close still settles */ } } + }); + // Drained only so a noisy stderr cannot stall the child on a full pipe; the + // synchronous result's stderr was never read by the resolvers either. + child.stderr?.on("data", () => undefined); + child.once("error", () => finish(null)); + // The close event is the child's terminal proof, matching spawnSync's + // status === null for both a timeout kill and a spawn failure. + child.once("close", code => finish(overflow ? null : code)); + deadline = setTimeout(() => { + try { child.kill("SIGKILL"); } catch { /* the close event still settles */ } + // A child that never closes still settles, like the synchronous timeout did. + reap = setTimeout(() => finish(null), 1_000); + }, options.timeout); + }); +} + /** * Resolve the channel to one exact version plus its sha512 integrity token. * @@ -353,12 +445,12 @@ export function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigI * acceptable for a foreign package we are about to install on the operator's behalf, so * every failure lane here is a refusal. */ -export function resolveCodexCliUpdateTarget( +export async function resolveCodexCliUpdateTarget( channel: CodexCliUpdateChannel, - spawn: typeof spawnSync = spawnSync, + spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, /** Proof-bound pre-dotenv PATH/PATHEXT and proxy/CA env; absent settings stay absent. */ boundEnv?: NodeJS.ProcessEnv, -): CodexCliUpdateTarget { +): Promise { // The pinned registry and the isolation directory together are the boundary: the // version, the integrity token and the tarball URL all come from npmjs or the // target is unresolved — a redirected answer can never be the evidence. @@ -371,7 +463,7 @@ export function resolveCodexCliUpdateTarget( return Object.freeze({ kind: "unresolved" as const, reason: "npm configuration isolation setup failed" }); } try { - const runView = (field: string, spec: string): SpawnSyncReturns | null => { + const runView = async (field: string, spec: string): Promise => { const target = isolatedNpmTarget(["view", spec, field], isolation); if (!target) return null; return spawn(target.bin, target.args, { @@ -382,7 +474,7 @@ export function resolveCodexCliUpdateTarget( }); }; - const versionRun = runView("version", `${CODEX_CLI_PACKAGE}@${channel}`); + const versionRun = await runView("version", `${CODEX_CLI_PACKAGE}@${channel}`); if (!versionRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); // status === null covers a timeout as well as a spawn failure. if (versionRun.status !== 0) { @@ -393,7 +485,7 @@ export function resolveCodexCliUpdateTarget( return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no exact version" }); } - const integrityRun = runView("dist.integrity", `${CODEX_CLI_PACKAGE}@${version}`); + const integrityRun = await runView("dist.integrity", `${CODEX_CLI_PACKAGE}@${version}`); if (!integrityRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); if (integrityRun.status !== 0) { return Object.freeze({ kind: "unresolved" as const, reason: `registry integrity query failed (status ${integrityRun.status ?? "timeout"})` }); @@ -408,7 +500,7 @@ export function resolveCodexCliUpdateTarget( // The digest binds the tarball's CONTENT; this binds its ORIGIN. A registry // answer whose tarball lives outside the pinned registry is not the artifact // the plan approved, even if its sha512 matched. - const tarballRun = runView("dist.tarball", `${CODEX_CLI_PACKAGE}@${version}`); + const tarballRun = await runView("dist.tarball", `${CODEX_CLI_PACKAGE}@${version}`); if (!tarballRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); if (tarballRun.status !== 0) { return Object.freeze({ kind: "unresolved" as const, reason: `registry tarball query failed (status ${tarballRun.status ?? "timeout"})` }); @@ -432,25 +524,25 @@ export function resolveCodexCliUpdateTarget( } /** Resolve both exact archives, including the publisher's platform-package alias. */ -export function resolveCodexCliUpdateArtifactTarget(channel: CodexCliUpdateChannel, - spawn: typeof spawnSync = spawnSync, boundEnv?: NodeJS.ProcessEnv, - platform: NodeJS.Platform = process.platform, arch: string = process.arch): CodexCliUpdateTarget { +export async function resolveCodexCliUpdateArtifactTarget(channel: CodexCliUpdateChannel, + spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, boundEnv?: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, arch: string = process.arch): Promise { let isolation: NpmConfigIsolation | undefined; try { isolation = createNpmConfigIsolation(undefined, boundEnv); - const query = (spec: string) => { + const query = async (spec: string) => { const invocation = isolatedNpmTarget(["view", spec, "--json"], isolation!); if (!invocation) throw new Error("npm unavailable"); - const result = spawn(invocation.bin, invocation.args, { encoding: "utf8", timeout: REGISTRY_TIMEOUT_MS, + const result = await spawn(invocation.bin, invocation.args, { encoding: "utf8", timeout: REGISTRY_TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true, ...invocation.options }); if (result.status !== 0) throw new Error("registry query failed"); return JSON.parse(result.stdout); }; - const latest = query(`${CODEX_CLI_PACKAGE}@${channel}`); + const latest = await query(`${CODEX_CLI_PACKAGE}@${channel}`); if (!parseStrictSemver(latest.version)) throw new Error("target version unavailable"); - const pinned = query(`${CODEX_CLI_PACKAGE}@${latest.version}`); + const pinned = await query(`${CODEX_CLI_PACKAGE}@${latest.version}`); if (pinned.version !== latest.version) throw new Error("target version drift"); - const artifacts = codexCliArtifactSet(pinned, query, platform, arch); + const artifacts = await codexCliArtifactSetAsync(pinned, query, platform, arch); return Object.freeze({ kind: "resolved", version: artifacts.main.version, integrity: artifacts.main.integrity, artifacts }); } catch { @@ -538,9 +630,9 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} // When the caller had no trusted snapshot, inspectionDeps.env is the fail-closed // { PATH: "" } form, so this resolution cannot silently fall back to ambient. const resolveTarget = deps.resolveTarget - ?? (ch => resolveCodexCliUpdateArtifactTarget(ch, deps.spawnProcess ?? spawnSync, deps.inspectionDeps?.env, + ?? (ch => resolveCodexCliUpdateArtifactTarget(ch, deps.spawnProcess ?? spawnCodexCliUpdateMetadata, deps.inspectionDeps?.env, platform, deps.inspectionDeps?.arch ?? process.arch)); - const target = resolveTarget(channel); + const target = await resolveTarget(channel); if (target.kind !== "resolved") { return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED); } @@ -559,7 +651,13 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} return refusedPlan("target_not_newer", report, channel, target, NOT_EVALUATED); } - const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); + // The request-path scan keeps registry and process-table waits off the event loop; + // a synchronous injection remains honored for tests that never spawn a real child. + const scan = deps.scanProcessesAsync + ? await deps.scanProcessesAsync(deps.processIo ?? {}) + : deps.scanProcesses + ? deps.scanProcesses(deps.processIo ?? {}) + : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); if (scan.kind !== "observed") { // An unreadable process table is not "no sessions". `listCodexAppServerProcesses` // maps that failure to an empty list because its kill contract must never signal a diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index e60b9f9ecb..69cd2be480 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -26,6 +26,7 @@ import { resetCodexAppServerCatalogStateCache, restartCodexAppServers, scanCodexSessionProcesses, + scanCodexSessionProcessesAsync, STALE_CODEX_APP_SERVER_HINT, warnIfStaleCodexAppServersAfterStartupWrite, WINDOWS_CODEX_BASENAME_CANDIDATE_RE, @@ -842,6 +843,41 @@ describe("Codex session process matching (#2811)", () => { expect(scan.processes.map(process => process.pid)).toEqual([12, 13]); } }); + + test("scanCodexSessionProcessesAsync prefers the async seam and keeps the fail-closed contract", async () => { + // The request-path scan must wait on an injected async source rather than a + // synchronous enumeration, so a stalled process-table read cannot park the loop. + const unavailable = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 12, commandLine: "codex" }], + listSnapshotsAsync: async () => { throw new Error("procfs unreadable"); }, + }); + expect(unavailable.kind).toBe("unavailable"); + const scan = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 99, commandLine: "codex" }], + listSnapshotsAsync: async () => [ + { pid: 11, commandLine: "vim notes.txt" }, + { pid: 12, commandLine: "codex" }, + { pid: 12, commandLine: "codex" }, + { pid: 13, commandLine: "codex exec --json" }, + ], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") { + // The async source answered (pid 99 was the sync-only decoy) and dedupe holds. + expect(scan.processes.map(process => process.pid)).toEqual([12, 13]); + } + }); + + test("scanCodexSessionProcessesAsync falls back to the injected sync seam", async () => { + const scan = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 21, commandLine: "codex app-server" }], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") expect(scan.processes.map(process => process.pid)).toEqual([21]); + }); }); describe("CLI /api sync wiring for stale app-servers (#476)", () => { diff --git a/tests/codex-integration/codex-cli-update-integrity.test.ts b/tests/codex-integration/codex-cli-update-integrity.test.ts index e6982e206a..0f5dd29ca5 100644 --- a/tests/codex-integration/codex-cli-update-integrity.test.ts +++ b/tests/codex-integration/codex-cli-update-integrity.test.ts @@ -4,7 +4,7 @@ import { resolveCodexCliUpdateArtifactTarget } from "../../src/codex/cli-update- const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; -function resolveWith(token: string) { +async function resolveWith(token: string) { let queries = 0; const spawn = ((_bin: string, args: string[]) => { queries++; @@ -14,12 +14,12 @@ function resolveWith(token: string) { dist: { integrity: token, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" }, }) }; }) as typeof spawnSync; - return { target: resolveCodexCliUpdateArtifactTarget("latest", spawn, undefined, "linux", "x64"), queries }; + return { target: await resolveCodexCliUpdateArtifactTarget("latest", spawn, undefined, "linux", "x64"), queries }; } describe("Codex CLI registry SHA-512 evidence", () => { - test("accepts a complete canonical SHA-512 digest for both production artifacts", () => { - const value = resolveWith(integrity); + test("accepts a complete canonical SHA-512 digest for both production artifacts", async () => { + const value = await resolveWith(integrity); expect(value.target).toMatchObject({ kind: "resolved", version: "1.4.2", integrity, artifacts: { backing: { name: "@openai/codex-linux-x64", integrity } } }); expect(value.queries).toBe(3); @@ -34,8 +34,8 @@ describe("Codex CLI registry SHA-512 evidence", () => { `sha512-${"A".repeat(85)}B==`, integrity.replace("==", ""), `sha1-old ${integrity}`, - ])("refuses malformed integrity %s before querying the tarball", token => { - const result = resolveWith(token); + ])("refuses malformed integrity %s before querying the tarball", async token => { + const result = await resolveWith(token); expect(result.target.kind).toBe("unresolved"); expect(result.queries).toBe(2); }); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 309d59a7e0..0c4447c3fa 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; @@ -18,8 +18,10 @@ import { codexCliUpdatePlanId, createCodexCliUpdatePlan, resolveCodexCliUpdateArtifactTarget, + spawnCodexCliUpdateMetadata, type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, + type CodexCliUpdateSpawn, type CodexCliUpdateTarget, } from "../../src/codex/cli-update-plan"; import { @@ -33,7 +35,7 @@ import { } from "../helpers/codex-cli-content-fs"; const REGISTRY_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; -function resolveArtifact(channel: "latest", spawn: typeof spawnSync = spawnSync, env?: NodeJS.ProcessEnv) { +function resolveArtifact(channel: "latest", spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, env?: NodeJS.ProcessEnv) { return resolveCodexCliUpdateArtifactTarget(channel, spawn, env, "linux", "x64"); } @@ -344,8 +346,8 @@ describe("registry target resolution", () => { }) as never; } - test("an exact version with a sha512 token resolves", () => { - const target = resolveArtifact("latest", spawnStub([ + test("an exact version with a sha512 token resolves", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n" }, @@ -354,28 +356,28 @@ describe("registry target resolution", () => { artifacts: { platform: "linux", arch: "x64", backing: { name: "@openai/codex-linux-x64" } } }); }); - test("a missing integrity token refuses instead of proceeding best-effort", () => { - const target = resolveArtifact("latest", spawnStub([ + test("a missing integrity token refuses instead of proceeding best-effort", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: "sha1-onlythis\n" }, ])); expect(target.kind).toBe("unresolved"); }); - test("a registry timeout refuses", () => { - const target = resolveArtifact("latest", spawnStub([{ status: null, stdout: "" }])); + test("a registry timeout refuses", async () => { + const target = await resolveArtifact("latest", spawnStub([{ status: null, stdout: "" }])); expect(target.kind).toBe("unresolved"); }); - test("a non-version answer is not treated as a version", () => { - const target = resolveArtifact("latest", spawnStub([ + test("a non-version answer is not treated as a version", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "latest\n" }, ])); expect(target.kind).toBe("unresolved"); }); - test("a tarball outside the pinned registry origin is refused even with a valid digest", () => { - const target = resolveArtifact("latest", spawnStub([ + test("a tarball outside the pinned registry origin is refused even with a valid digest", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://evil.invalid/@openai/codex/-/codex-1.4.2.tgz\n" }, @@ -383,14 +385,67 @@ describe("registry target resolution", () => { expect(target.kind).toBe("unresolved"); }); - test("a tarball answer that is not a URL at all is refused", () => { - const target = resolveArtifact("latest", spawnStub([ + test("a tarball answer that is not a URL at all is refused", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "not-a-url\n" }, ])); expect(target.kind).toBe("unresolved"); }); + + const GATED_MAIN_JSON = JSON.stringify({ + name: "@openai/codex", version: "1.4.2", + optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity: REGISTRY_INTEGRITY, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" }, + }); + const GATED_BACKING_JSON = JSON.stringify({ + name: "@openai/codex-linux-x64", version: "1.4.2", + dist: { integrity: REGISTRY_INTEGRITY, tarball: "https://registry.npmjs.org/@openai/codex-linux-x64/-/codex-linux-x64-1.4.2.tgz" }, + }); + + /** + * A REAL child that answers like npm view --json but only after the gate file + * appears — or after its own cap, so a failed assertion can never hang the suite. + * Runs through the production async spawn runner, not a mocked event loop. + */ + const gatedNpmSpawn = (gateFile: string, capMs: number): CodexCliUpdateSpawn => + (_bin, args, options) => spawnCodexCliUpdateMetadata(process.execPath, [ + "-e", + 'const fs=require("node:fs"),g=process.env.OCX_GATE_FILE,a=process.env.OCX_GATE_ANSWER,d=Date.now()+Number(process.env.OCX_GATE_CAP_MS);' + + 'const t=setInterval(()=>{if(!fs.existsSync(g)&&Date.now()<=d)return;clearInterval(t);process.stdout.write(a+"\\n",()=>process.exit(0))},15)', + ], { + ...options, + // The resolver's Windows verbatim flag must not leak onto our own child argv. + windowsVerbatimArguments: false, + env: { ...options.env, OCX_GATE_FILE: gateFile, OCX_GATE_CAP_MS: String(capMs), + OCX_GATE_ANSWER: args.join(" ").includes("@openai/codex-linux-x64@") ? GATED_BACKING_JSON : GATED_MAIN_JSON }, + }); + + test("a gated registry child keeps the event loop responsive while planning waits", async () => { + const dir = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-gated-npm-"))); + const gateFile = join(dir, "release"); + try { + // The real resolver wiring: registry queries spawn the gated child through the + // production async runner. Under the old spawnSync this parked the event loop + // for the whole per-query deadline. + const pending = createCodexCliUpdatePlan(planDeps({ + resolveTarget: undefined, + spawnProcess: gatedNpmSpawn(gateFile, 4_000), + })); + const firedAt = Date.now(); + await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise(resolve => setTimeout(resolve, 40)); + // A parked event loop defers the probe until the gated child self-caps, so the + // wall clock from the plan call is the discriminator — not probe latency alone. + expect(Date.now() - firedAt).toBeLessThan(2_000); + writeFileSync(gateFile, "release"); + const plan = await pending; + expect(plan).toMatchObject({ applicable: true, targetVersion: "1.4.2" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("registry configuration isolation", () => { @@ -472,7 +527,7 @@ describe("registry configuration isolation", () => { "dist.tarball": "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n", }; - test.each(["resolved", "unresolved"] as const)("cleanup failure preserves the %s resolver outcome", kind => { + test.each(["resolved", "unresolved"] as const)("cleanup failure preserves the %s resolver outcome", async kind => { let ownedRoot = ""; let cleanupAttempts = 0; const originalRemove = fs.rmSync; @@ -489,7 +544,7 @@ describe("registry configuration isolation", () => { ownedRoot = options.cwd as string; return (capture.spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveArtifact("latest", spawn).kind).toBe(kind); + expect((await resolveArtifact("latest", spawn)).kind).toBe(kind); expect(cleanupAttempts).toBe(1); expect(existsSync(ownedRoot)).toBe(true); } finally { @@ -498,9 +553,9 @@ describe("registry configuration isolation", () => { } }); - test("every query pins the official registry and substitutes a controlled npmrc", () => { + test("every query pins the official registry and substitutes a controlled npmrc", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveArtifact("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { @@ -520,14 +575,14 @@ describe("registry configuration isolation", () => { expect(existsSync(calls[0]!.options.cwd as string)).toBe(false); }); - test("a hostile npm_config_* env cannot reach the spawned npm", () => { + test("a hostile npm_config_* env cannot reach the spawned npm", async () => { const prior = process.env.npm_config_registry; const priorScoped = process.env["npm_config_@openai:registry"]; process.env.npm_config_registry = "https://evil.invalid"; process.env["npm_config_@openai:registry"] = "https://evil.invalid/"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveArtifact("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -541,11 +596,11 @@ describe("registry configuration isolation", () => { } }); - test("the controlled cwd never is the operator's project directory", () => { + test("the controlled cwd never is the operator's project directory", async () => { // A project .npmrc in the launch directory would otherwise redirect the scope; // the query must run from the sentinel-anchored isolation dir instead. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const cwd = call.options.cwd as string; @@ -564,7 +619,7 @@ describe("registry configuration isolation", () => { }; } - test("the bound launcher snapshot supplies executable and transport trust inputs", () => { + test("the bound launcher snapshot supplies executable and transport trust inputs", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const boundEnv = { ...markerBoundEnv(), HTTPS_PROXY: "http://trusted-proxy", NODE_EXTRA_CA_CERTS: "/trusted/ca.pem" }; const previous = { HTTPS_PROXY: process.env.HTTPS_PROXY, ALL_PROXY: process.env.ALL_PROXY, @@ -573,7 +628,7 @@ describe("registry configuration isolation", () => { process.env.HTTPS_PROXY = "http://project-dotenv-proxy"; process.env.ALL_PROXY = "http://project-dotenv-fallback"; process.env.NODE_EXTRA_CA_CERTS = "/project/dotenv-ca.pem"; - expect(resolveArtifact("latest", spawn, boundEnv).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn, boundEnv)).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; expect(env.PATH).toBe(boundEnv.PATH); @@ -612,13 +667,13 @@ describe("registry configuration isolation", () => { "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "LD_DEBUG_OUTPUT", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "ld_preload", "dyld_insert_libraries", - ])("ambient loader control %s cannot reach the spawned npm", key => { + ])("ambient loader control %s cannot reach the spawned npm", async key => { const previous = process.env[key]; process.env[key] = "/synthetic/nonexistent-loader-fixture"; try { // Capture only: no loader, native child process or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -630,7 +685,7 @@ describe("registry configuration isolation", () => { } }); - test("hostile Node startup channels cannot reach the spawned npm", () => { + test("hostile Node startup channels cannot reach the spawned npm", async () => { const priorOptions = process.env.NODE_OPTIONS; const priorPath = process.env.NODE_PATH; const priorTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED; @@ -643,7 +698,7 @@ describe("registry configuration isolation", () => { process.env.NODE_REDIRECT_WARNINGS = "C:\\evil-warnings.log"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveArtifact("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -671,13 +726,13 @@ describe("registry configuration isolation", () => { test.each([ "OPENSSL_CONF", "OPENSSL_CONF_INCLUDE", "OPENSSL_MODULES", "OPENSSL_ENGINES", "openssl_conf", "openssl_modules", - ])("ambient OpenSSL startup control %s cannot reach the spawned npm", key => { + ])("ambient OpenSSL startup control %s cannot reach the spawned npm", async key => { const previous = process.env[key]; process.env[key] = join(tmpdir(), "ocx-ambient-openssl.cnf"); try { // Capture only: no child process, native provider or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -689,7 +744,7 @@ describe("registry configuration isolation", () => { } }); - test("supported proxy and CA channels still reach the spawned npm", () => { + test("supported proxy and CA channels still reach the spawned npm", async () => { const sentinels = { HTTP_PROXY: "http://127.0.0.1:9/ocx-proxy-sentinel", NO_PROXY: "registry.npmjs.org.invalid,127.0.0.1", @@ -699,7 +754,7 @@ describe("registry configuration isolation", () => { try { Object.assign(process.env, sentinels); const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; for (const [key, value] of Object.entries(sentinels)) { @@ -717,7 +772,7 @@ describe("registry configuration isolation", () => { } }); - test("real Node cannot write output sentinels outside the owned npm root", () => { + test("real Node cannot write output sentinels outside the owned npm root", async () => { const fixture = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-node-output-"))); const sentinels = { NODE_V8_COVERAGE: join(fixture, "escaped-coverage"), @@ -767,7 +822,7 @@ describe("registry configuration isolation", () => { const field = queriedField({ bin, args, options } as CapturedCall); return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(checked).toBe(true); expect(existsSync(ownedRoot)).toBe(false); } finally { @@ -780,9 +835,9 @@ describe("registry configuration isolation", () => { } }); - test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", () => { + test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - resolveArtifact("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + await resolveArtifact("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); // With no trusted PATH the ambient PATH must never reach the child: Windows // resolves the binary itself and spawns nothing, while POSIX spawns the bare // npm name whose execvp lookup then fails under the explicit empty PATH. @@ -793,13 +848,13 @@ describe("registry configuration isolation", () => { // spawn that honours the empty PATH fails and the target stays unresolved. const enoent = { status: null as number | null, stdout: "", stderr: "spawnSync npm ENOENT", error: new Error("spawnSync npm ENOENT") }; const failingSpawn = (() => ({ ...enoent })) as never; - const refused = resolveArtifact("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + const refused = await resolveArtifact("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); expect(refused.kind).toBe("unresolved"); }); - test("npm and Node caches and logs stay inside the owned temporary root", () => { + test("npm and Node caches and logs stay inside the owned temporary root", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveArtifact("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { @@ -811,7 +866,7 @@ describe("registry configuration isolation", () => { } }); - test("real npm startup through the resolver seam cannot recreate an ambient Node compile cache", () => { + test("real npm startup through the resolver seam cannot recreate an ambient Node compile cache", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-update-node-cache-")); const home = join(root, "home"), temporaryRoot = join(root, "temp"), ambientCache = join(root, "ambient-cache"); const previous = process.env.NODE_COMPILE_CACHE; @@ -840,7 +895,7 @@ describe("registry configuration isolation", () => { // Only registry evidence is synthetic; npm startup above is real. return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls).toBe(3); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(temporaryRoot)).toEqual([]); @@ -854,7 +909,7 @@ describe("registry configuration isolation", () => { } }); - test.skipIf(process.platform === "win32")("real npm accepts both isolated config paths without any registry access", () => { + test.skipIf(process.platform === "win32")("real npm accepts both isolated config paths without any registry access", async () => { // POSIX uses a flat argv. Windows planning remains deferred; its quoted command // and file isolation are covered above without treating a shell line as argv. const fakeHome = mkdtempSync(join(tmpdir(), "ocx-update-config-test-")); @@ -882,7 +937,7 @@ describe("registry configuration isolation", () => { } return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveArtifact("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(configReads).toBe(1); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(fakeHome)).toEqual([]); @@ -891,7 +946,7 @@ describe("registry configuration isolation", () => { } }); - test("a real npm call under the isolated argv leaves the operator HOME untouched", () => { + test("a real npm call under the isolated argv leaves the operator HOME untouched", async () => { // Runs real npm exactly once: the first captured query executes for real with a // disposable HOME while the rest stay stubbed. npm_config_offline makes the // registry leg fail fast without network; the residue assertions hold either way. @@ -927,7 +982,7 @@ describe("registry configuration isolation", () => { for (const name of readdirSync(cwd)) ownedDuringRun.push(name); return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - resolveArtifact("latest", spawn); + await resolveArtifact("latest", spawn); expect(realRan).toBe(true); const cwd = calls[0]!.options.cwd as string; // Anything npm persisted was inside the owned root and is removed with it; diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index a1fe27cbec..0fe53f295f 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; import * as updateJobs from "../../src/codex/cli-update-job"; +import * as cliUpdatePlanModule from "../../src/codex/cli-update-plan"; import type { ManagementContext } from "../../src/server/management/context"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; import { SIBLING_REFUSED_MANAGEMENT_PATHS, siblingRefusesManagementRequest } from "../../src/server/management/sibling-guard"; @@ -83,6 +84,84 @@ describe("management route registry reconciliation", () => { } finally { initializeNodeLauncherContext(["bun", "test"], {}); } }); + test("a gated registry child leaves concurrent management requests responsive", async () => { + // Regression for the planning P1: a slow npm query must not park the proxy event + // loop. The gated child answers like npm view --json but only once the release + // file appears — with its own cap so a regression cannot hang the suite — and it + // runs through the production async spawn runner, not a mocked event loop. + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-update-gated-npm-"))); + const gateFile = join(dir, "release"); + const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; + const mainJson = JSON.stringify({ name: "@openai/codex", version: "1.4.2", + optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" } }); + const backingJson = JSON.stringify({ name: "@openai/codex-linux-x64", version: "1.4.2", + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex-linux-x64/-/codex-linux-x64-1.4.2.tgz" } }); + const gatedSpawn: cliUpdatePlanModule.CodexCliUpdateSpawn = (_bin, args, options) => + cliUpdatePlanModule.spawnCodexCliUpdateMetadata(process.execPath, [ + "-e", + 'const fs=require("node:fs"),g=process.env.OCX_GATE_FILE,a=process.env.OCX_GATE_ANSWER,d=Date.now()+Number(process.env.OCX_GATE_CAP_MS);' + + 'const t=setInterval(()=>{if(!fs.existsSync(g)&&Date.now()<=d)return;clearInterval(t);process.stdout.write(a+"\\n",()=>process.exit(0))},15)', + ], { ...options, windowsVerbatimArguments: false, + env: { ...options.env, OCX_GATE_FILE: gateFile, OCX_GATE_CAP_MS: "4000", + OCX_GATE_ANSWER: args.join(" ").includes("@openai/codex-linux-x64@") ? backingJson : mainJson } }); + const managed = { schemaVersion: 1, candidateAvailable: true, candidateVersion: "1.0.0", + candidateSource: "environment", selectionAttested: true, + versionEvidence: { kind: "package-manifest" }, provenance: "npm-global", managed: true, + reason: "managed_npm_global", location: "/@openai/codex", + installDigest: "a".repeat(64), installContentDigest: "c".repeat(64), + packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, + evidence: ["package_manifest", "global_npm_layout"] }; + const realPlan = cliUpdatePlanModule.createCodexCliUpdatePlan; + // Wrap the REAL planner so the route still runs the production resolver and async + // spawn path; only the install report, platform, and scan are fixed fixtures. + const planner = spyOn(cliUpdatePlanModule, "createCodexCliUpdatePlan").mockImplementation(deps => + realPlan({ ...deps, platform: "linux", inspectionDeps: undefined, inspect: async () => managed as never, + spawnProcess: gatedSpawn, + scanProcessesAsync: async () => ({ kind: "observed", processes: [] }) })); + const oldHome = process.env.OPENCODEX_HOME; + const emptyHome = join(dir, "empty-home"); + mkdirSync(emptyHome, { recursive: true }); + process.env.OPENCODEX_HOME = emptyHome; + try { + const makeCtx = (pathname: string): ManagementContext => { + const req = new Request(`http://localhost${pathname}`); + return { req, url: new URL(req.url), config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + }; + const planPromise = handleCodexCliUpdateRoutes(makeCtx("/api/codex/cli-update/plan")); + // Let the first gated child spawn, then an unrelated request must still answer. + const firedAt = Date.now(); + await new Promise(resolve => setTimeout(resolve, 150)); + const concurrent = await Promise.race([ + handleCodexCliUpdateRoutes(makeCtx("/api/codex/cli-update/jobs/11111111-2222-3333-4444-555555555555")) + .then(response => ({ kind: "responded" as const, response })), + new Promise<{ kind: "blocked" }>(resolve => setTimeout(() => resolve({ kind: "blocked" }), 3_000)), + ]); + // Under a synchronous spawn this race can only settle after the child cap. + expect(concurrent.kind).toBe("responded"); + // A parked event loop defers BOTH arms past the child cap, so the response + // arriving "first" is not enough — the wall clock is the discriminator. + expect(Date.now() - firedAt).toBeLessThan(2_500); + if (concurrent.kind === "responded") { + // POSIX reads an empty store (404); Windows refuses the unsupported store + // lane (503). Either way the unrelated request answered while planning waited. + expect([404, 503]).toContain(concurrent.response?.status ?? 0); + } + writeFileSync(gateFile, "release"); + const response = await planPromise; + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ applicable: true, targetVersion: "1.4.2" }); + } finally { + planner.mockRestore(); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(dir, { recursive: true, force: true }); + } + }); + test("Codex CLI HTTP apply requires a GUI session and trusted loopback before admission", async () => { const config = { port: 10100, providers: {} } as import("../../src/types").OcxConfig; const request = new Request("http://localhost/api/codex/cli-update/apply", { From d36b8041770ff75c6841f7379da2fcc8a1db076e Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 13:44:25 +0900 Subject: [PATCH 24/33] fix(codex): settle cli-update metadata on the child's terminal event Independent review found real defects the first async revision carried over from spawnSync semantics: - spawnCodexCliUpdateMetadata is now an execFile callback runner: the result resolves only after the child terminated and its piped stdio was consumed, so the caller's isolation cleanup can never outrun a live child; encoding utf8 decodes the buffered output once so a multibyte character split across OS pipe chunks stays intact; maxBuffer bounds both captured streams in bytes; the per-query deadline is latched so a late code-0 close after the kill still refuses, and the arbitrary 1 s reap that settled without close evidence is gone. - applyCodexCliUpdate: the awaited final process scan is the last yield before install, and a synchronous guard in the same event-loop turn as the spawn re-checks the signal, prefix identity, the byte-level preimage taken before the downloads and the launcher's live canonical selection, so an earlier await can no longer hide a mutation between the final check and install. Regressions: real-child lanes prove status null on deadline kill, maxBuffer overflow and spawn ENOENT, exact exit codes, split-multibyte decode, and settlement following the child's own terminal event; an injected pending final scan refuses a signal abort, a preimage drift and a selection change with zero install calls (POSIX). --- src/codex/cli-update-apply.ts | 18 ++++- src/codex/cli-update-plan.ts | 66 +++++++++---------- structure/codex-cli-update.md | 14 +++- .../codex-cli-update-apply.test.ts | 30 +++++++++ .../codex-cli-update-plan.test.ts | 52 +++++++++++++++ 5 files changed, 143 insertions(+), 37 deletions(-) diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index 5c4b0efe6f..24a6e5c71d 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -191,7 +191,9 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep const originalRoots = [evidence.packageRoot, ...(evidence.backingRoot ? [evidence.backingRoot] : []), targetMainRoot, backingRoot]; const original = observeCodexCliPreimage(originalRoots); // Metadata, content, selection and process observation are renewed after - // potentially slow downloads; a final synchronous process check precedes spawn. + // potentially slow downloads; the awaited process scan is the last yield + // before install, and a synchronous identity/preimage/selection guard runs + // in the same event-loop turn as the spawn. const finalPlan = await freshPlan(); if (!finalPlan.applicable) return result("refused", finalPlan.refusal ?? "plan_refused"); if (finalPlan.planId !== planId) return result("refused", "plan_changed"); @@ -217,6 +219,20 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); if (lastScan.kind !== "observed") return result("refused", "blocked_process_state_unknown"); if (lastScan.processes.length) return result("refused", "blocked_active_session"); + // Every earlier await could have yielded the event loop to a mutation of + // the install tree, the prefix or the selected command, so the last turn + // before spawn re-verifies them synchronously: prefix identity, the + // byte-level preimage taken before the downloads, the launcher's live + // canonical selection and the signal. Nothing can interleave between this + // block and the install invocation on the same event-loop turn. + if (deps.signal?.aborted) return result("refused", "cancelled"); + const lastPrefix = lstatSync(prefix); + if (realpathSync.native(prefix) !== prefix || lastPrefix.dev !== prefixIdentity.dev + || lastPrefix.ino !== prefixIdentity.ino + || !sameCodexCliPreimage(original, observeCodexCliPreimage(originalRoots)) + || !codexCliMutationSelectionMatches(evidence, freshInspection(deps))) { + return result("refused", "installation_changed"); + } started = true; keepLease = true; const installed = await (deps.install ?? runNpm)({ bin: invocation.bin, args: invocation.args, diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 5774394393..3ba99bcfe9 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { execFile, type ExecFileException } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -389,50 +389,50 @@ export function spawnCodexCliUpdateMetadata( args: readonly string[], options: CodexCliUpdateSpawnOptions, ): Promise { - const maxBuffer = options.maxBuffer ?? 1024 * 1024; return new Promise(resolve => { + let timedOut = false; let deadline: ReturnType | undefined; - let reap: ReturnType | undefined; - let settled = false; - let stdout = ""; - let overflow = false; - const finish = (status: number | null) => { - if (settled) return; - settled = true; - if (deadline) clearTimeout(deadline); - if (reap) clearTimeout(reap); - resolve(Object.freeze({ status, stdout })); - }; - let child; + let child: ReturnType; try { - child = spawn(bin, [...args], { + // execFile's callback is the only terminal observation: it runs after the + // child terminated and its piped stdio was fully consumed, so this result — + // and the caller's isolation cleanup in its finally — never outrun a live + // child, and no arbitrary reap window can fake completion. encoding "utf8" + // decodes the buffered output once, so a multibyte character split across + // OS pipe chunks stays intact, while maxBuffer bounds both captured + // streams in bytes exactly like spawnSync did. This proves only the + // child's own termination; any descendants it left behind are outside the + // observation, as they were under spawnSync. + child = execFile(bin, [...args], { + encoding: "utf8", + timeout: options.timeout, + maxBuffer: options.maxBuffer ?? 1024 * 1024, + killSignal: "SIGKILL", + windowsHide: true, cwd: options.cwd, env: options.env, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + }, (error: ExecFileException | null, stdout: string) => { + if (deadline) clearTimeout(deadline); + // spawnSync reported status null whenever the child was signal-killed + // (timeout or maxBuffer) or never spawned; a numeric error.code is the + // plain exit code. timedOut is latched so a late code-0 close after the + // deadline still refuses, matching the synchronous timeout lane. + const status = timedOut ? null + : error === null ? 0 + : typeof error.code === "number" ? error.code : null; + resolve(Object.freeze({ status, stdout })); }); } catch { - finish(null); + resolve(Object.freeze({ status: null, stdout: "" })); return; } - child.stdout?.on("data", (chunk: Buffer) => { - if (stdout.length + chunk.length > maxBuffer) overflow = true; - else stdout += chunk.toString("utf8"); - // spawnSync killed the child once maxBuffer was exceeded; preserve the bound. - if (overflow) { try { child.kill("SIGKILL"); } catch { /* close still settles */ } } - }); - // Drained only so a noisy stderr cannot stall the child on a full pipe; the - // synchronous result's stderr was never read by the resolvers either. - child.stderr?.on("data", () => undefined); - child.once("error", () => finish(null)); - // The close event is the child's terminal proof, matching spawnSync's - // status === null for both a timeout kill and a spawn failure. - child.once("close", code => finish(overflow ? null : code)); + // A deadline of our own keeps the same per-query bound even on a runtime + // that does not honor execFile's timeout option. It only latches the + // refusal and signals the child; resolution still waits for the callback. deadline = setTimeout(() => { + timedOut = true; try { child.kill("SIGKILL"); } catch { /* the close event still settles */ } - // A child that never closes still settles, like the synchronous timeout did. - reap = setTimeout(() => finish(null), 1_000); }, options.timeout); }); } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 52793b38a0..613e0ea263 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -49,8 +49,14 @@ provider/engine search variables; proxy settings and `NODE_EXTRA_CA_CERTS` remai Ambient NODE_COMPILE_CACHE is replaced with an owned path inside the isolation directory because npm's own enableCompileCache() otherwise recreates a default cache outside that directory. Distinct npm config files, npm/Node caches and logs stay under the owned temporary root with best-effort -cleanup; a cleanup error does not replace the computed plan/refusal. The POSIX update -scan includes all readable users by default, retaining an explicitly injected UID scope. +cleanup; a cleanup error does not replace the computed plan/refusal. Registry metadata +and the request-path process-table scan wait off the server event loop: the bounded npm +children and the darwin/Windows enumerations resolve asynchronously while keeping the +per-query deadline, bounded output, termination and status-null refusal lanes of the +synchronous contract, so a stalled registry cannot park unrelated proxy requests or +streaming; the apply re-scan uses the same async scan. Linux /proc observation remains +synchronous underneath as bounded filesystem reads rather than a child-process wait. +The POSIX update scan includes all readable users by default, retaining an explicitly injected UID scope. Linux per-process `status`/`cmdline` read failures invalidate a complete observation except for `ENOENT` (a process that disappeared); incomplete observation refuses with `blocked_process_state_unknown`; the restart/kill path skips unreadable entries while retaining independently verified same-user app-server targets. This plan neither installs a runtime nor signals @@ -84,7 +90,9 @@ precedes explicit stream destruction, and verification waits for the close event pipeline settlement alone is not close completion and the caller never closes that raw fd again. After artifact preparation, selection/content, the approved metadata and active/unknown process blockers -are re-observed. npm receives only two verified local tarball specs and an explicit canonical prefix, +are re-observed. The awaited process scan is the last yield before install; in the same event-loop turn +as the spawn a synchronous guard re-checks the signal, prefix identity, the byte-level preimage and the +launcher's live canonical selection. npm receives only two verified local tarball specs and an explicit canonical prefix, with offline mode, optional dependencies omitted, lifecycle scripts disabled and no audit/fund/lockfile side effects. Main manifests are not rewritten to make installation succeed. Cancelled preparation refuses; cancellation during an admitted npm child waits for its close rather than killing it. The lease diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index d4c5a28ff9..70b419b81f 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -541,6 +541,36 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { expect(result.outcome).toBe("refused"); expect(installs).toBe(0); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); }, 120_000); + test.each(["preimage-drift", "selection-change", "signal-abort"] as const)( + "a %s while the process scan is pending refuses before install", async lane => { + const f = fixture(); const id = await approved(f); let installs = 0; + const controller = new AbortController(); + let scans = 0, scanPending = false; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + // apply calls the async scan seam three times — once inside each plan and + // once as the final pre-install scan. Only the last one is held open, so + // the mutation below lands strictly inside the awaited final-scan window. + const pending = applyCodexCliUpdate(id, depsFor(f, { + signal: controller.signal, + scanProcessesAsync: async () => { + if (++scans < 3) return { kind: "observed", processes: [] } as never; + scanPending = true; + await gate; + return { kind: "observed", processes: [] } as never; + }, + install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + })); + while (!scanPending) await new Promise(resolve => setTimeout(resolve, 10)); + if (lane === "preimage-drift") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), + "#!/usr/bin/env node\n// changed while the final scan was pending\n"); + if (lane === "selection-change") { unlinkSync(f.launcher); symlinkSync(f.target.main.path, f.launcher); } + if (lane === "signal-abort") controller.abort(); + release(); + const result = await pending; + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + }, 120_000); test("exit zero with original full files is not_applied", async () => { const f = fixture(); const id = await approved(f); const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => ({ terminal: true, exitCode: 0 }) })); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 0c4447c3fa..ac0f2700c3 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -448,6 +448,58 @@ describe("registry target resolution", () => { }); }); +describe("registry metadata runner terminal lanes", () => { + const OPTS = { encoding: "utf8" as const, timeout: 15_000, maxBuffer: 1024 * 1024, windowsHide: true as const }; + test("a non-zero exit keeps its code", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, ["-e", "process.exit(3)"], OPTS); + expect(result.status).toBe(3); + }); + test("a child past the per-query deadline settles null on its terminal event", async () => { + const startedAt = Date.now(); + const result = await spawnCodexCliUpdateMetadata(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], + { ...OPTS, timeout: 250 }); + expect(result.status).toBeNull(); + // The deadline kill is followed by the child's real close, not a reap guess + // long enough to matter; a 30 s sleeper proves settlement came from the kill. + expect(Date.now() - startedAt).toBeLessThan(5_000); + }); + test("stdout past maxBuffer refuses instead of truncating", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", 'process.stdout.write("x".repeat(2 * 1024 * 1024))'], + { ...OPTS, maxBuffer: 64 * 1024 }); + expect(result.status).toBeNull(); + }); + test("multibyte output split across OS pipe chunks decodes intact", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", 'process.stdout.write("\uac00".repeat(200_000))'], { ...OPTS, maxBuffer: 4 * 1024 * 1024 }); + expect(result.status).toBe(0); + expect(result.stdout).toBe("가".repeat(200_000)); + expect(result.stdout).not.toContain("�"); + }); + test("a binary that cannot be spawned settles null like spawnSync", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-meta-spawn-")); + try { + const result = await spawnCodexCliUpdateMetadata(join(dir, "no-such-npm"), ["view"], OPTS); + expect(result.status).toBeNull(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + test("the result waits for the child's real terminal event before settling", async () => { + const dir = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-meta-terminal-"))); + const marker = join(dir, "terminal"); + try { + const startedAt = Date.now(); + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", "setTimeout(() => require('node:fs').writeFileSync(" + JSON.stringify(marker) + ", 'x'), 300)"], OPTS); + expect(result.status).toBe(0); + // The child's final write is on disk when the runner settles: the resolve + // followed the terminal event, so the caller's isolation cleanup in its + // finally can never run underneath a still-live child. + expect(existsSync(marker)).toBe(true); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(250); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); + describe("registry configuration isolation", () => { interface CapturedCall { bin: string; From 7e7c5531715f04bd0593c29b0050a234eaea7141 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 18:54:06 +0900 Subject: [PATCH 25/33] test(codex): pin gated registry fixtures to x64 --- tests/codex-integration/codex-cli-update-plan.test.ts | 1 + tests/server/management-route-registry.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index ac0f2700c3..982a59ca82 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -431,6 +431,7 @@ describe("registry target resolution", () => { // for the whole per-query deadline. const pending = createCodexCliUpdatePlan(planDeps({ resolveTarget: undefined, + inspectionDeps: { arch: "x64" }, spawnProcess: gatedNpmSpawn(gateFile, 4_000), })); const firedAt = Date.now(); diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index 0fe53f295f..7cbb18e271 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -116,7 +116,7 @@ describe("management route registry reconciliation", () => { // Wrap the REAL planner so the route still runs the production resolver and async // spawn path; only the install report, platform, and scan are fixed fixtures. const planner = spyOn(cliUpdatePlanModule, "createCodexCliUpdatePlan").mockImplementation(deps => - realPlan({ ...deps, platform: "linux", inspectionDeps: undefined, inspect: async () => managed as never, + realPlan({ ...deps, platform: "linux", inspectionDeps: { arch: "x64" }, inspect: async () => managed as never, spawnProcess: gatedSpawn, scanProcessesAsync: async () => ({ kind: "observed", processes: [] }) })); const oldHome = process.env.OPENCODEX_HOME; From bebc5a9424302f4a3938cf2e3838573ed2302ffe Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 00:16:37 +0900 Subject: [PATCH 26/33] ci: record measured Codex CLI apply batch weight --- scripts/ci/test-durations.tsv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/test-durations.tsv b/scripts/ci/test-durations.tsv index 7d97845ae3..3ba1b2c3c8 100644 --- a/scripts/ci/test-durations.tsv +++ b/scripts/ci/test-durations.tsv @@ -1,7 +1,7 @@ # Per-file Bun test durations in milliseconds, read from hosted CI job logs. # Consumed by scripts/ci/run-bun-test-batches.sh to balance shards; a file without a row weighs the median. # Regenerate with scripts/ci/test-durations.ts (usage in its header); do not edit by hand. -# Source: run 38250055508 (dev dc45bb2ca9, lane=all), Linux test 1/4-4/4 and macos 1/3-3/3 +# Source: run 38250055508 (dev dc45bb2ca9, lane=all), Linux test 1/4-4/4 and macos 1/3-3/3; selected row tests/codex-integration/codex-cli-update-apply.test.ts from run 40084641010 @700dc1d8bfe3 job 120850286088 (test 2/4, Bun 1.4.4); other rows retained 271 tests/adapters/abort-race.test.ts 643 tests/adapters/adapter-buffered-tool-conformance.test.ts 270 tests/adapters/adapter-error-inline.test.ts @@ -693,6 +693,7 @@ 169 tests/codex-integration/codex-cli-install-provenance.test.ts 100 tests/codex-integration/codex-cli-installation-identity.test.ts 127 tests/codex-integration/codex-cli-installation-targets.test.ts +94979 tests/codex-integration/codex-cli-update-apply.test.ts 104 tests/codex-integration/codex-cli-update-launcher-policy.test.ts 1197 tests/codex-integration/codex-cli-update-zero-effect.test.ts 92 tests/codex-integration/codex-cli-windows-installation-files.test.ts From abc2643990a7328e8ed5920c9240f399550d6fb7 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 04:28:44 +0900 Subject: [PATCH 27/33] fix(codex): release apply lease when version verifier setup fails before spawn --- src/codex/cli-update-apply.ts | 22 +++++++++++++++---- structure/codex-cli-update.md | 3 ++- .../codex-cli-update-apply.test.ts | 16 ++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index 24a6e5c71d..d7c0239ee5 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -54,13 +54,27 @@ export interface CodexCliApplyDeps extends CodexCliUpdatePlanDeps { */ export function verifyInstalledCodexVersion(command: string, version: string, cwd: string, env: NodeJS.ProcessEnv): Promise { const home = join(cwd, "version-home"); - mkdirSync(home, { mode: 0o700 }); + // A setup failure before any child exists is terminal evidence, not an + // unknown lifetime: return it so the caller's lease stays releasable. + try { + mkdirSync(home, { mode: 0o700 }); + } catch { + return Promise.resolve({ terminal: true, ok: false, pid: null }); + } return new Promise(resolve => { let terminal = false, failed = false, stopping = false, output = ""; let deadline: ReturnType | undefined, reap: ReturnType | undefined; - const child = spawn(command, ["--version"], { cwd, env: { ...env, CODEX_HOME: home, HOME: home, TMPDIR: home, - XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_DATA_HOME: home }, - stdio: ["ignore", "pipe", "ignore"], windowsHide: true, detached: process.platform !== "win32" }); + // A synchronous spawn throw created no child, so it is the same terminal + // setup evidence; asynchronous spawn errors still arrive as error+close. + let child: ReturnType; + try { + child = spawn(command, ["--version"], { cwd, env: { ...env, CODEX_HOME: home, HOME: home, TMPDIR: home, + XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_DATA_HOME: home }, + stdio: ["ignore", "pipe", "ignore"], windowsHide: true, detached: process.platform !== "win32" }); + } catch { + resolve({ terminal: true, ok: false, pid: null }); + return; + } const groupGone = () => { if (process.platform === "win32" || !child.pid) return true; try { process.kill(-child.pid, 0); return false; } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 613e0ea263..b9a618567e 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -78,7 +78,8 @@ platform and inspection architecture contribute to plan identity. Platform alias manifest name is also `@openai/codex` are modeled explicitly. The existing isolated npm environment, cwd, cache and distinct npmrc boundary is reused for metadata, exact-version pack and install calls. The version verification child inherits the owned Node compile-cache path, which is removed with -terminal apply staging; an uncertain live child retains that staging and its lease. +terminal apply staging; an uncertain live child retains that staging and its lease. A verifier setup +failure before any child exists is terminal evidence instead, so the lease is released and later applies are not blocked. The owned staging directory is canonicalized immediately after creation, so symlinked temporary roots and macOS's /var alias are supported without accepting a linked archive file. `src/codex/cli-update-artifacts.ts` streams compressed integrity and tar validation with independent diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index 70b419b81f..34377cecc4 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -724,4 +724,20 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { expect(result.cleanupErrors).toEqual(["lease_release_exception"]); expect(result.leaseRetained).toBe(true); expect(readdirSync(f.temporaryRoot)).toEqual([]); }, 120_000); + test("a default verifier setup failure before any child releases the lease", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); + const staging = readdirSync(f.temporaryRoot).find(name => name.startsWith("ocx-codex-cli-apply-")); + expect(staging).toBeDefined(); + writeFileSync(join(f.temporaryRoot, staging!, "version-home"), "not a directory\n"); // mkdirSync fails EEXIST + return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome, JSON.stringify(result)).toBe("ambiguous"); + expect(result.reason).toBe("version_readback_unverified"); + expect(result.leaseRetained).toBe(false); expect(result.verificationPid).toBeNull(); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + const again = await applyCodexCliUpdate(id, depsFor(f)); + expect(again.reason, JSON.stringify(again)).not.toBe("installation_busy"); + }, 120_000); }); From b0e2d8d8da883cede0a322891c6012f3efcfc7cb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 04:52:08 +0900 Subject: [PATCH 28/33] test(codex): cover terminal pre-child verifier setup on every platform --- .../codex-cli-update-apply.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index 34377cecc4..b69ef49a34 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -5,7 +5,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, import { createServer, connect } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { applyCodexCliUpdate, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import { applyCodexCliUpdate, verifyInstalledCodexVersion, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, runCodexCliUpdateJob, type CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; @@ -270,6 +270,27 @@ test("HTTP fixture npm wrapper returns all metadata under an ESM ancestor", () = expect(views.every(value => value.reason === "metadata_returned")).toBe(true); expect(existsSync(installTrace)).toBe(false); }); +describe("default version verifier pre-child setup", () => { + test("a mkdir failure is terminal without creating a child", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-version-setup-")); roots.push(root); + const home = join(root, "version-home"); + writeFileSync(home, "not a directory\n"); + expect(await verifyInstalledCodexVersion("", "1.1.0", root, {})).toEqual({ + terminal: true, ok: false, pid: null, + }); + expect(readFileSync(home, "utf8")).toBe("not a directory\n"); + }); + test("a synchronous spawn throw is terminal without creating a child", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-version-setup-")); roots.push(root); + const command = "ocx-verifier\0must-not-spawn"; + expect(() => spawn(command, ["--version"], { cwd: root, env: {} })).toThrow(/null bytes/); + expect(await verifyInstalledCodexVersion(command, "1.1.0", root, {})).toEqual({ + terminal: true, ok: false, pid: null, + }); + expect(lstatSync(join(root, "version-home")).isDirectory()).toBe(true); + }); +}); + describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { test("a held lease and an unsupported prefix are distinct refusal inputs", () => { const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-lease-policy-"))); roots.push(root); From f250ad75f10e7b29795d2745e6a71d72bd68fcfb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 05:58:43 +0900 Subject: [PATCH 29/33] fix(codex): run CLI update plan/apply hashing in a dedicated worker OpenCodex #7586: the management plan/apply paths ran synchronous full-preimage hashing, file-set walks and process scans on the HTTP event loop. Each admitted job now runs in a fresh Worker realm (cli-update-worker in WORKER_ENTRIES) while admission and the generation-fenced durable ledger stay in the parent. - per-job SAB fence PREPARING->CANCELLED/COMMITTED plus a real AbortSignal driven by a cancel message; the commit CAS is the last guard before the installer spawn, and a post-COMMITTED cancel defers - one process-wide shared selection gate covers the short commit window: the five mutable selection writers, the remote-workspace spawn and the desktop restart ladder report busy instead of racing - the run message carries the allowlisted frozen launcher snapshot; the worker rebuilds inspection deps and derives fresh selection evidence in its own realm - settlement requires a valid reply AND the worker close; the host never terminates a worker, so post-delivery loss settles ambiguous with the lease retained and live workers tracked Base: b0e2d8d8da883cede0a322891c6012f3efcfc7cb --- scripts/build-standalone.ts | 1 + .../test-layout/layout-codex-collision.json | 1 + src/cli/codex-cli-update.ts | 8 +- src/codex/cli-update-apply.ts | 105 +++- src/codex/cli-update-inspection-context.ts | 36 +- src/codex/cli-update-job-worker.ts | 378 +++++++++++++ src/codex/cli-update-selection-gate.ts | 171 ++++++ src/codex/cli-update-worker.ts | 143 +++++ src/codex/desktop-app-restart.ts | 25 +- src/codex/runtime.ts | 76 ++- src/codex/shim-overlay.ts | 25 +- src/remote-control/workspace-codex-runtime.ts | 23 + .../management/codex-cli-update-routes.ts | 8 +- structure/INDEX.md | 3 + structure/clients/codex-desktop.md | 4 + structure/codex-cli-update.md | 64 ++- structure/manifest.json | 3 + structure/remote-workspace.md | 1 + tests/clients/desktop-app-restart.test.ts | 85 ++- tests/clients/desktop-restart-handoff.test.ts | 108 +++- .../remote-workspace-codex-runtime.test.ts | 109 +++- .../codex-cli-update-apply.test.ts | 121 ++++- .../codex-cli-update-worker.test.ts | 507 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/helpers/cli-update-fixture-worker.ts | 131 +++++ 25 files changed, 2032 insertions(+), 105 deletions(-) create mode 100644 src/codex/cli-update-job-worker.ts create mode 100644 src/codex/cli-update-selection-gate.ts create mode 100644 src/codex/cli-update-worker.ts create mode 100644 tests/codex-integration/codex-cli-update-worker.test.ts create mode 100644 tests/helpers/cli-update-fixture-worker.ts diff --git a/scripts/build-standalone.ts b/scripts/build-standalone.ts index 8f1f145429..1c104a5c2c 100644 --- a/scripts/build-standalone.ts +++ b/scripts/build-standalone.ts @@ -37,6 +37,7 @@ const WORKER_ENTRIES: Record = { "policy-worker": join(repoRoot, "src", "storage", "policy-worker.ts"), "restore-worker": join(repoRoot, "src", "storage", "restore-worker.ts"), "history-worker": join(repoRoot, "src", "codex", "history-worker.ts"), + "cli-update-worker": join(repoRoot, "src", "codex", "cli-update-worker.ts"), "request-history-rebuild-worker": join(repoRoot, "src", "routing", "history", "rebuild-worker.ts"), "windows-native-principal-worker": join(repoRoot, "src", "lib", "windows-native-principal-worker.ts"), }; diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index d29c100694..3e24647dec 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,6 +1,7 @@ { "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-worker.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", "codex-account-slot-identity.test.ts": "codex-integration", diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 58030b3282..fed27c06ea 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -12,11 +12,11 @@ import type { CodexCliInstallationTargetDerivation, } from "../codex/cli-installation-targets"; import { - createCodexCliUpdatePlan, type CodexCliUpdateChannel, type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, } from "../codex/cli-update-plan"; +import { runCodexCliUpdateApplyInWorker, runCodexCliUpdatePlanInWorker } from "../codex/cli-update-job-worker"; import { type ResolveCodexRuntimeDeps, type ResolveCodexRuntimeResult, @@ -319,7 +319,8 @@ export async function handleCodexCliUpdateCommand( return; } if (parsed.action === "plan") { - const plan = await (deps.createPlan ?? createCodexCliUpdatePlan)({ + const plan = await (deps.createPlan ?? ((planDeps: CodexCliUpdatePlanDeps) => + runCodexCliUpdatePlanInWorker({ channel: planDeps.channel })))({ channel: parsed.channel, inspectionDeps: codexCliUpdateInspectionDeps(deps), inspect: deps.inspectInstall, @@ -328,8 +329,9 @@ export async function handleCodexCliUpdateCommand( return; } if (parsed.action === "apply") { - const apply = deps.applyUpdate ?? (await import("../codex/cli-update-apply")).applyCodexCliUpdate; const admitted = admitCodexCliUpdateJob({ requestId: parsed.requestId, planId: parsed.planId, confirmed: true }); + const apply = deps.applyUpdate + ?? ((planId: string) => runCodexCliUpdateApplyInWorker(planId, { jobId: admitted.job.jobId })); let job: Awaited>; try { job = admitted.kind === "accepted" diff --git a/src/codex/cli-update-apply.ts b/src/codex/cli-update-apply.ts index d7c0239ee5..dc6d46b789 100644 --- a/src/codex/cli-update-apply.ts +++ b/src/codex/cli-update-apply.ts @@ -13,6 +13,7 @@ import { verifyCodexCliArchive, import { acquireCodexCliApplyLease } from "./cli-update-lease"; import { scanCodexSessionProcessesAsync } from "./app-server-processes"; import { observeCodexCliFileSet, observeCodexCliPreimage, sameCodexCliPreimage, codexCliExpectedFiles, sameCodexCliFiles } from "./cli-update-files"; +import type { CodexCliUpdateCommitWindow } from "./cli-update-selection-gate"; export interface CodexCliApplyResult { readonly outcome: "refused" | "applied" | "not_applied" | "ambiguous"; @@ -41,6 +42,20 @@ 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 { readonly signal?: AbortSignal; + /** + * Shared-fence cancellation checkpoint. Unlike signal.aborted this can flip + * DURING a synchronous section: the commit fence lives in shared memory the + * parent CASes, so a cancel landing mid-hash is seen at the next checkpoint + * rather than only on the next event-loop turn. + */ + readonly isCancelled?: () => boolean; + /** + * The worker's short selection commit window. When present, the final + * process scan and the synchronous guard block run while the shared gate is + * held, and commit()'s fence CAS is the last check before the installer + * spawn. Absent preserves the ungated in-process behavior tests rely on. + */ + readonly commitWindow?: CodexCliUpdateCommitWindow; readonly temporaryRoot?: string; readonly fetchArtifact?: (artifact: CodexCliArtifact, signal?: AbortSignal) => Promise; readonly install?: (invocation: { bin: string; args: readonly string[]; cwd: string; env: NodeJS.ProcessEnv }) => Promise<{ terminal: boolean; exitCode: number | null }>; @@ -159,10 +174,14 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep let output: CodexCliApplyResult | undefined; const result = (outcome: CodexCliApplyResult["outcome"], reason: string): CodexCliApplyResult => output = { outcome, reason, planId, installedVersion, targetVersion, installerExitCode: exitCode, leaseRetained: keepLease, verificationPid, cleanupErrors: [] }; + // Both cancellation channels are consulted at every checkpoint: the plain + // AbortSignal for in-process callers and the shared-fence isCancelled for a + // worker whose cancel lands mid-synchronous-section on the parent's loop. + const cancelled = (): boolean => deps.signal?.aborted === true || deps.isCancelled?.() === true; 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.signal?.aborted) return result("refused", "cancelled"); + if (cancelled()) return result("refused", "cancelled"); const inspect = deps.inspect ?? inspectCodexCliInstall; const first = await inspect(freshInspection(deps)); installedVersion = first.packageVersion; @@ -196,7 +215,7 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep } else path = await packArtifact(artifact, staging, deps.inspectionDeps?.env); paths.push(path); } - if (deps.signal?.aborted) return result("refused", "cancelled"); + if (cancelled()) return result("refused", "cancelled"); const main = await verifyCodexCliArchive(paths[0]!, artifacts.main); const backing = await verifyCodexCliArchive(paths[1]!, artifacts.backing); const expectedContent = verifiedCodexCliContent(artifacts, main, backing); @@ -219,40 +238,68 @@ export async function applyCodexCliUpdate(planId: string, deps: CodexCliApplyDep || beforeInstall.installContentDigest !== plan.installContentDigest || !beforeInstall.managed || realpathSync.native(prefix) !== prefix || currentPrefix.dev !== prefixIdentity.dev || currentPrefix.ino !== prefixIdentity.ino) return result("refused", "installation_changed"); - if (deps.signal?.aborted) return result("refused", "cancelled"); + if (cancelled()) return result("refused", "cancelled"); const isolation = createNpmConfigIsolation(staging, deps.inspectionDeps?.env); const args = ["install", "-g", "--prefix", prefix, "--offline", "--ignore-scripts", "--omit=optional", "--no-audit", "--no-fund", "--package-lock=false", "--update-notifier=false", `${artifacts.main.installName}@file:${paths[0]}`, `${artifacts.backing.installName}@file:${paths[1]}`]; const invocation = isolatedNpmTarget(args, isolation); if (!invocation) return result("refused", "npm_unavailable"); - const lastScan = deps.scanProcessesAsync - ? await deps.scanProcessesAsync(deps.processIo ?? {}) - : deps.scanProcesses - ? deps.scanProcesses(deps.processIo ?? {}) - : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); - if (lastScan.kind !== "observed") return result("refused", "blocked_process_state_unknown"); - if (lastScan.processes.length) return result("refused", "blocked_active_session"); - // Every earlier await could have yielded the event loop to a mutation of - // the install tree, the prefix or the selected command, so the last turn - // before spawn re-verifies them synchronously: prefix identity, the - // byte-level preimage taken before the downloads, the launcher's live - // canonical selection and the signal. Nothing can interleave between this - // block and the install invocation on the same event-loop turn. - if (deps.signal?.aborted) return result("refused", "cancelled"); - const lastPrefix = lstatSync(prefix); - if (realpathSync.native(prefix) !== prefix || lastPrefix.dev !== prefixIdentity.dev - || lastPrefix.ino !== prefixIdentity.ino - || !sameCodexCliPreimage(original, observeCodexCliPreimage(originalRoots)) - || !codexCliMutationSelectionMatches(evidence, freshInspection(deps))) { - return result("refused", "installation_changed"); + // The shared selection gate serializes this commit window against the + // parent's own selection writers and session-spawn paths, which a worker + // realm can no longer exclude just by occupying its own event-loop turn. + // waitEnter can yield while a parent writer holds the gate, so the final + // process observation is taken only after the window is held; a gate that + // stays busy or a cancel that lands first refuses before the spawn. + const commitWindow = deps.commitWindow; + let windowHeld = false; + if (commitWindow) { + windowHeld = commitWindow.tryEnter() || await commitWindow.waitEnter(); + if (!windowHeld) return result("refused", cancelled() ? "cancelled" : "installation_busy"); + } + try { + const lastScan = deps.scanProcessesAsync + ? await deps.scanProcessesAsync(deps.processIo ?? {}) + : deps.scanProcesses + ? deps.scanProcesses(deps.processIo ?? {}) + : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); + if (lastScan.kind !== "observed") return result("refused", "blocked_process_state_unknown"); + if (lastScan.processes.length) return result("refused", "blocked_active_session"); + // Every earlier await could have yielded the event loop to a mutation of + // the install tree, the prefix or the selected command, so the last turn + // before spawn re-verifies them synchronously: prefix identity, the + // byte-level preimage taken before the downloads, the launcher's live + // canonical selection and the signal. Nothing can interleave between + // this block and the install invocation on the same event-loop turn, + // and the held gate keeps the gated parent operations out of the window. + if (cancelled()) return result("refused", "cancelled"); + const lastPrefix = lstatSync(prefix); + if (realpathSync.native(prefix) !== prefix || lastPrefix.dev !== prefixIdentity.dev + || lastPrefix.ino !== prefixIdentity.ino + || !sameCodexCliPreimage(original, observeCodexCliPreimage(originalRoots)) + || !codexCliMutationSelectionMatches(evidence, freshInspection(deps))) { + return result("refused", "installation_changed"); + } + // The commit CAS is the final synchronous guard: a cancel already + // landed wins and no spawn happens; once COMMITTED, a later cancel is + // deferred to the live installer's close and readback. + if (commitWindow && !commitWindow.commit()) return result("refused", "cancelled"); + started = true; + keepLease = true; + // The install implementation contract requires the child spawn to start + // synchronously inside this call (runNpm's spawn runs in the Promise + // executor), so returning means the spawn already happened under the + // held gate and the window may close while the child runs. This is a + // property of these two callables, not of arbitrary async callbacks. + const installPromise = (deps.install ?? runNpm)({ bin: invocation.bin, args: invocation.args, + cwd: isolation.dir, env: isolation.env }); + if (commitWindow) { commitWindow.exit(); windowHeld = false; } + const installed = await installPromise; + exitCode = installed.exitCode; + if (!installed.terminal) return result("ambiguous", "installer_state_unknown"); + } finally { + if (windowHeld) commitWindow?.exit(); } - started = true; - keepLease = true; - const installed = await (deps.install ?? runNpm)({ bin: invocation.bin, args: invocation.args, - cwd: isolation.dir, env: isolation.env }); - exitCode = installed.exitCode; - if (!installed.terminal) return result("ambiguous", "installer_state_unknown"); keepLease = false; // Readback outranks exit code. Partial or unreadable installs are never retried. const after: CodexCliInstallReport = await inspect(freshInspection(deps)); diff --git a/src/codex/cli-update-inspection-context.ts b/src/codex/cli-update-inspection-context.ts index 1f960142b6..e325e43e49 100644 --- a/src/codex/cli-update-inspection-context.ts +++ b/src/codex/cli-update-inspection-context.ts @@ -2,12 +2,31 @@ import type { CodexCliInstallProvenanceDeps } from "./cli-install-provenance"; import { observeCodexCliSelectedCommand } from "./cli-install-provenance"; import type { ResolveCodexRuntimeDeps, ResolveCodexRuntimeResult } from "./runtime"; import { trustedNodeLauncherContext } from "../cli/launcher-context"; +import type { TrustedNodeLaunchContext } from "../cli/launcher-context"; -/** Build the updater's only production inspection context from the Node launcher's proof-bound snapshot. */ -export function codexCliUpdateInspectionDeps(deps: { - readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; -} = {}): CodexCliInstallProvenanceDeps { - const trusted = trustedNodeLauncherContext()?.codexCliInspectionEnv; +/** + * The allowlisted, immutable launcher snapshot an update worker may receive. + * It is the proof-bound pre-dotenv capture validated and frozen by + * initializeNodeLauncherContext; a Worker realm never reads the parent's + * trustedContext, so the snapshot itself is the structured-clone payload. + */ +export type CodexCliUpdateInspectionSnapshot = TrustedNodeLaunchContext["codexCliInspectionEnv"]; + +/** + * Build the updater's production inspection context from one supplied + * launcher snapshot. The selected-command observation runs in the CALLING + * realm: a worker receives only the frozen snapshot and must derive its own + * fresh selector evidence rather than trust a parent's pre-computed value. + * Missing fields are never backfilled from the ambient environment; an absent + * or untrusted snapshot fails closed exactly like a direct Bun/source launch. + */ +export function codexCliUpdateInspectionDepsFromSnapshot( + snapshot: CodexCliUpdateInspectionSnapshot, + deps: { + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; + } = {}, +): CodexCliInstallProvenanceDeps { + const trusted = snapshot; if (!trusted || trusted.managerRoots === null) return { env: { PATH: "" }, configDir: "." }; const selectedCommand = observeCodexCliSelectedCommand({ env: { CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, PATH: trusted.path ?? undefined, PATHEXT: trusted.pathExt ?? undefined }, @@ -21,3 +40,10 @@ export function codexCliUpdateInspectionDeps(deps: { selectionAttested: true, selectedCommand, }; } + +/** Build the updater's only production inspection context from the Node launcher's proof-bound snapshot. */ +export function codexCliUpdateInspectionDeps(deps: { + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; +} = {}): CodexCliInstallProvenanceDeps { + return codexCliUpdateInspectionDepsFromSnapshot(trustedNodeLauncherContext()?.codexCliInspectionEnv ?? null, deps); +} diff --git a/src/codex/cli-update-job-worker.ts b/src/codex/cli-update-job-worker.ts new file mode 100644 index 0000000000..f19c01ac75 --- /dev/null +++ b/src/codex/cli-update-job-worker.ts @@ -0,0 +1,378 @@ +/** + * Parent-side dispatch for Codex CLI update plan/apply workers. + * + * The HTTP route and CLI keep their existing contracts: admission and the + * generation-fenced durable ledger stay in this realm, the spawned Worker + * realm owns the heavy inspection, hashing and installer lifetime. A job is + * one fresh Worker plus one unique requestId plus the ledger generation; no + * wire generation is needed because the Worker runs once, ignores later + * messages and closes. + * + * Cancellation uses BOTH channels: the per-job shared fence's + * PREPARING -> CANCELLED compare-exchange (the only word a synchronous + * worker section can observe) and a "cancel" message that aborts the real + * AbortSignal handed to apply's fetchArtifact path. The message is posted + * only when the fence CAS won, so a cancel arriving after COMMITTED defers + * to the live installer's close exactly like the in-process contract. + * + * Settlement requires BOTH a valid reply AND the Worker's close. A reply + * alone is never terminal evidence: a legitimate result can still report + * installer_state_unknown or version_process_state_unknown with a retained + * lease and a live verification child, so done-without-close means the Worker's + * own exit could not be observed and the job resolves ambiguous with its lease + * state unknown. This host never calls terminate(): an unresponsive Worker may + * legitimately own a running npm child, and killing the thread would orphan + * the very lifetime the ledger must keep reporting as unknown. Workers that + * settle ambiguous while still alive are tracked in the unjoined set so their + * count is observable evidence rather than a silent leak. + */ +import { randomUUID } from "node:crypto"; +import type { CodexCliApplyResult } from "./cli-update-apply"; +import type { CodexCliUpdateChannel, CodexCliUpdatePlan } from "./cli-update-plan"; +import type { CodexCliUpdateInspectionSnapshot } from "./cli-update-inspection-context"; +import { trustedNodeLauncherContext } from "../cli/launcher-context"; +import { spawnWorker } from "../lib/worker-embed"; +import { + CODEX_CLI_UPDATE_GATE_FREE, + CODEX_CLI_UPDATE_GATE_PARENT_WRITE, + CODEX_CLI_UPDATE_GATE_WORKER_COMMIT, + cancelCodexCliUpdateFence, + codexCliUpdateSelectionGate, + createCodexCliUpdateFence, +} from "./cli-update-selection-gate"; +import type { CodexCliUpdateWorkerReply, CodexCliUpdateWorkerRunMessage } from "./cli-update-worker"; + +/** Grace for the Worker's close after a valid reply before the run goes ambiguous. */ +const WORKER_CLOSE_GRACE_MS = 10_000; + +interface LiveJobRecord { + /** Fence CAS plus async cancel delivery; false when the fence already moved. */ + readonly cancel: () => boolean; + readonly settled: () => boolean; + readonly workerClosed: () => boolean; +} + +const liveJobs = new Map(); +const unjoinedWorkers = new Set(); +let unjoinedWorkerClosedTotal = 0; + +/** + * Parent cancellation for one in-flight job: CASes that job's fence + * PREPARING -> CANCELLED and, only when that word was won, posts the async + * "cancel" message that aborts the worker's real AbortSignal. False when the + * job is unknown here or its fence already committed/cancelled — a lost CAS + * never reaches the worker, so a post-COMMITTED cancel stays deferred. + */ +export function cancelCodexCliUpdateWorkerJob(jobId: string): boolean { + return liveJobs.get(jobId)?.cancel() ?? false; +} + +/** Workers that settled ambiguous while still alive; evidence, not a leak counter. */ +export function codexCliUpdateUnjoinedWorkerCount(): number { + return unjoinedWorkers.size; +} + +/** + * Cumulative count of workers that settled without close evidence and were + * later observed closed — removed from the live set but retained as evidence. + */ +export function codexCliUpdateUnjoinedWorkerClosedTotal(): number { + return unjoinedWorkerClosedTotal; +} + +/** + * Operator-facing snapshot for diagnosing a held selection gate. The gate + * cell is one Int32 word with no owner field, so this reports CORRELATION + * evidence only: which update jobs are in flight here and whether each + * Worker's close was actually observed. A job listed beside a worker_commit + * gate is a candidate holder, never a proven one; an empty list beside a + * held gate means the holder is unknown to this process — possibly a dead + * Worker whose window never released (the gate is never auto-released). + */ +export interface CodexCliUpdateWorkerDiagnostics { + readonly gate: "free" | "parent_write" | "worker_commit" | "unrecognized"; + readonly liveJobs: readonly { + readonly jobId: string; + readonly settled: boolean; + readonly workerClosed: boolean; + }[]; + readonly unjoinedWorkers: number; + readonly unjoinedWorkerClosedTotal: number; +} + +export function codexCliUpdateWorkerDiagnostics(): CodexCliUpdateWorkerDiagnostics { + const raw = Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0); + const gate = raw === CODEX_CLI_UPDATE_GATE_FREE ? "free" + : raw === CODEX_CLI_UPDATE_GATE_PARENT_WRITE ? "parent_write" + : raw === CODEX_CLI_UPDATE_GATE_WORKER_COMMIT ? "worker_commit" + : "unrecognized"; + return { + gate, + liveJobs: [...liveJobs.entries()].map(([jobId, record]) => ({ + jobId, settled: record.settled(), workerClosed: record.workerClosed(), + })), + unjoinedWorkers: unjoinedWorkers.size, + unjoinedWorkerClosedTotal, + }; +} + +export interface CodexCliUpdateWorkerSpawnOptions { + /** Frozen launcher snapshot; default captures the trusted context at call time. */ + readonly snapshot?: CodexCliUpdateInspectionSnapshot; + /** Optional external abort: fires the same fence CAS as cancelCodexCliUpdateWorkerJob. */ + readonly signal?: AbortSignal; + /** Test seam: spawn a fixture Worker instead of the bundled entrypoint. */ + readonly spawnWorkerFn?: (devUrl: string, key: string) => Worker; + readonly closeGraceMs?: number; + readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; +} + +type DispatchSettlement = + | { readonly kind: "reply"; readonly reply: CodexCliUpdateWorkerReply } + | { readonly kind: "spawn_failed" } + | { readonly kind: "unsettled" }; + +const APPLY_OUTCOMES: ReadonlySet = new Set(["refused", "applied", "not_applied", "ambiguous"]); + +/** + * Reject a reply that names our ids but lacks its payload. Accepting a + * shapeless "done" would read success out of work that may not have happened; + * the ledger's downstream normalizer is a second clamp, not the only check. + */ +function isPlausibleWorkerReply( + data: unknown, + requestId: string, + jobId: string, + operation: "plan" | "apply", +): data is CodexCliUpdateWorkerReply { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + if (o.requestId !== requestId || o.jobId !== jobId) return false; + if (o.type === "error") return typeof o.message === "string"; + if (o.type !== "done" || o.operation !== operation) return false; + if (operation === "plan") { + return !!o.plan && typeof o.plan === "object" && !Array.isArray(o.plan); + } + const result = o.result; + return !!result && typeof result === "object" && !Array.isArray(result) + && APPLY_OUTCOMES.has((result as { outcome?: unknown }).outcome as string) + && typeof (result as { reason?: unknown }).reason === "string"; +} + +function trackUnjoined(worker: Worker, alreadyClosed: boolean): void { + if (unjoinedWorkers.has(worker)) return; + if (alreadyClosed) { + // The close is already evidenced; keep the diagnostic without pretending + // a dead Worker is a live leak. + unjoinedWorkerClosedTotal++; + warnIfCommitGateHeld(); + return; + } + unjoinedWorkers.add(worker); + worker.addEventListener("close", () => { + if (!unjoinedWorkers.delete(worker)) return; + unjoinedWorkerClosedTotal++; + warnIfCommitGateHeld(); + }, { once: true }); +} + +/** + * A Worker that dies while holding the commit gate leaves the cell at + * worker_commit forever — there is no auto-release by design. Report the + * correlation honestly: an observed close does not prove that Worker held it. + */ +function warnIfCommitGateHeld(): void { + if (Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0) + !== CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) return; + console.warn( + "[opencodex] a Codex CLI update worker closed while the shared selection gate still reads" + + " worker_commit; the holder is unknown and the gate is never auto-released — inspect" + + " codexCliUpdateWorkerDiagnostics() before treating a persist/shim/restart busy as stuck", + ); +} + +interface DispatchState { + settled: boolean; + closed: boolean; +} + +function dispatchWorker( + worker: Worker, + message: CodexCliUpdateWorkerRunMessage, + options: CodexCliUpdateWorkerSpawnOptions, + state: DispatchState, +): Promise { + return new Promise(resolve => { + let reply: CodexCliUpdateWorkerReply | undefined; + let grace: ReturnType | undefined; + const finish = (value: DispatchSettlement) => { + if (state.settled) return; + state.settled = true; + if (grace) clearTimeout(grace); + resolve(value); + }; + // Every non-reply terminal signal keeps the possibly-live Worker tracked: + // it can still own an installer child the ledger must report as unknown. + const unsettled = () => { + if (state.settled) return; + trackUnjoined(worker, state.closed); + finish({ kind: "unsettled" }); + }; + worker.onmessage = (event: MessageEvent) => { + // A settled or already-closed dispatch ignores late traffic entirely: + // a duplicate or post-close message must not re-arm the grace timer, + // attach listeners or re-add the Worker to the unjoined set. Once a + // valid reply is stored it is authoritative: a run-once Worker cannot + // legitimately send a second result, so a late "better" duplicate or a + // trailing error reply never replaces the first correlated payload. + if (state.settled || state.closed || reply !== undefined) return; + const data = event.data; + if (!data || typeof data !== "object" || Array.isArray(data)) { + unsettled(); + return; + } + const m = data as Record; + // A reply naming different ids belongs to someone else; ignoring it is + // not the same as accepting it. + if (m.requestId !== message.requestId || m.jobId !== message.jobId) return; + if (!isPlausibleWorkerReply(data, message.requestId, message.jobId, message.operation)) { + unsettled(); + return; + } + // done alone is not terminal evidence: the Worker's own close still has + // to be observed, and within the grace a late close still settles reply. + // The reply is stored once; the single close handler below decides. + reply = data as CodexCliUpdateWorkerReply; + grace = setTimeout(() => unsettled(), options.closeGraceMs ?? WORKER_CLOSE_GRACE_MS); + }; + worker.addEventListener("messageerror", () => unsettled()); + worker.addEventListener("close", () => { + state.closed = true; + if (grace) clearTimeout(grace); + // One close handler decides everything: a stored valid reply wins, any + // other close — before a reply, or after only implausible traffic — is + // unknown work, not a clean refusal. + if (reply !== undefined) finish({ kind: "reply", reply }); + else unsettled(); + }, { once: true }); + worker.onerror = () => unsettled(); + try { + worker.postMessage(message); + } catch { + // A synchronous post failure leaves delivery uncertain: the thread is + // alive and may (or may not) have received the run. Unknown work must + // never be downgraded to a provable pre-run refusal. + unsettled(); + } + }); +} + +function applyFallback(planId: string, settled: DispatchSettlement): CodexCliApplyResult { + return Object.freeze( + settled.kind === "spawn_failed" + ? { + outcome: "refused" as const, reason: "preparation_failed", planId, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: false, verificationPid: null, cleanupErrors: Object.freeze([]), + } + : { + outcome: "ambiguous" as const, reason: "job_result_unknown", planId, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: true, verificationPid: null, cleanupErrors: Object.freeze([]), + }, + ); +} + +async function runInWorker( + operation: "plan" | "apply", + fields: { readonly planId?: string; readonly channel?: CodexCliUpdateChannel }, + options: CodexCliUpdateWorkerSpawnOptions & { readonly jobId?: string }, +): Promise { + const jobId = options.jobId ?? randomUUID(); + const requestId = randomUUID(); + const fence = createCodexCliUpdateFence(); + const snapshot = options.snapshot !== undefined + ? options.snapshot + : trustedNodeLauncherContext()?.codexCliInspectionEnv ?? null; + let worker: Worker; + try { + worker = (options.spawnWorkerFn ?? spawnWorker)( + new URL("./cli-update-worker.ts", import.meta.url).href, + "cli-update-worker", + ); + } catch { + // Spawn threw before the run message existed: provably no job code ran. + return { kind: "spawn_failed" }; + } + const state: DispatchState = { settled: false, closed: false }; + const cancel = () => { + if (!cancelCodexCliUpdateFence(fence)) return false; + // Async cancel delivery only rides a won CAS: the worker's real + // AbortSignal aborts its in-flight fetchArtifact download, while the + // fence remains the synchronous-section channel. A post-COMMITTED cancel + // loses the CAS and is never posted, so the live child keeps its close. + try { worker.postMessage({ type: "cancel", requestId, jobId }); } catch { /* already closing */ } + return true; + }; + liveJobs.set(jobId, { + cancel, + settled: () => state.settled, + workerClosed: () => state.closed, + }); + const signal = options.signal; + const onAbort = () => { cancel(); }; + try { + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + const message: CodexCliUpdateWorkerRunMessage = { + type: "run", + requestId, + jobId, + operation, + ...(fields.planId !== undefined ? { planId: fields.planId } : {}), + ...(fields.channel !== undefined ? { channel: fields.channel } : {}), + snapshot, + gate: codexCliUpdateSelectionGate(), + fence, + ...(options.env ? { env: options.env } : {}), + }; + return await dispatchWorker(worker, message, options, state); + } finally { + liveJobs.delete(jobId); + if (signal) signal.removeEventListener("abort", onAbort); + } +} + +/** + * Run one confirmed apply in a fresh Worker. Results are the worker realm's + * own CodexCliApplyResult verbatim; dispatch failures synthesize the two + * honest fallbacks (refused preparation_failed only when no job code provably + * ran, otherwise ambiguous job_result_unknown with the lease unknown). + */ +export async function runCodexCliUpdateApplyInWorker( + planId: string, + options: CodexCliUpdateWorkerSpawnOptions & { readonly jobId?: string } = {}, +): Promise { + const settled = await runInWorker("apply", { planId }, options); + if (settled.kind === "reply" && settled.reply.type === "done" && settled.reply.operation === "apply") { + return settled.reply.result; + } + return applyFallback(planId, settled); +} + +/** + * Run one read-only plan in a fresh Worker. There is no ledger for a plan, so + * a dispatch failure surfaces exactly like today's thrown inspection error: + * the route translates it into plan_unavailable. + */ +export async function runCodexCliUpdatePlanInWorker( + options: CodexCliUpdateWorkerSpawnOptions & { readonly channel?: CodexCliUpdateChannel } = {}, +): Promise { + const settled = await runInWorker("plan", { channel: options.channel ?? "latest" }, options); + if (settled.kind === "reply" && settled.reply.type === "done" && settled.reply.operation === "plan") { + return settled.reply.plan; + } + throw new Error("plan_unavailable"); +} diff --git a/src/codex/cli-update-selection-gate.ts b/src/codex/cli-update-selection-gate.ts new file mode 100644 index 0000000000..5b0b0eeda1 --- /dev/null +++ b/src/codex/cli-update-selection-gate.ts @@ -0,0 +1,171 @@ +/** + * Shared commit fence and selection gate for the Codex CLI update worker. + * + * The update's last synchronous turn (final process scan, prefix/preimage/ + * selection guards, installer spawn) runs inside a Worker realm now, so the + * old same-event-loop-turn argument no longer excludes the parent's own + * selection-mutating operations or its session-spawn paths. Two shared-memory + * primitives carry that exclusion across realms: + * + * - FENCE (one per dispatched job): the parent's only cancellation word is + * 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. + * + * - 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 + * 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 + * a different configDir can also see a brief busy. + * + * This module claims nothing about processes it does not own: an external + * installer, a late descendant of an earlier child, or an OS snapshot race + * stays the documented residual it always was. + */ + +export const CODEX_CLI_UPDATE_FENCE_PREPARING = 0; +export const CODEX_CLI_UPDATE_FENCE_CANCELLED = 1; +export const CODEX_CLI_UPDATE_FENCE_COMMITTED = 2; + +export const CODEX_CLI_UPDATE_GATE_FREE = 0; +export const CODEX_CLI_UPDATE_GATE_PARENT_WRITE = 1; +export const CODEX_CLI_UPDATE_GATE_WORKER_COMMIT = 2; + +/** Bounded wait for a parent writer's window; the worker then refuses busy. */ +const WORKER_COMMIT_WAIT_DEADLINE_MS = 30_000; + +export class CodexCliSelectionMutationBusyError extends Error { + constructor(message = "codex_cli_selection_busy") { + super(message); + this.name = "CodexCliSelectionMutationBusyError"; + } +} + +let selectionGate: SharedArrayBuffer | null = null; + +/** The single process-wide gate cell. Both realms wrap it in an Int32Array. */ +export function codexCliUpdateSelectionGate(): SharedArrayBuffer { + selectionGate ??= new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + return selectionGate; +} + +/** Test seam: drop the cell so each case starts from FREE on a fresh buffer. */ +export function resetCodexCliSelectionGatesForTests(): void { + selectionGate = null; +} + +/** Fresh per-job fence; SharedArrayBuffer zero-initializes to PREPARING. */ +export function createCodexCliUpdateFence(): SharedArrayBuffer { + return new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); +} + +/** Parent cancel word. True only when this CAS took PREPARING -> CANCELLED. */ +export function cancelCodexCliUpdateFence(fence: SharedArrayBuffer): boolean { + return Atomics.compareExchange(new Int32Array(fence), 0, + CODEX_CLI_UPDATE_FENCE_PREPARING, CODEX_CLI_UPDATE_FENCE_CANCELLED) === CODEX_CLI_UPDATE_FENCE_PREPARING; +} + +/** + * The worker-side cancellation checkpoint fed to apply's isCancelled hook. + * COMMITTED is deliberately not "cancelled": once the commit CAS lands the + * installer is a live child whose close must be observed, not a cancel. + */ +export function codexCliUpdateIsCancelled(fence: SharedArrayBuffer): () => boolean { + const view = new Int32Array(fence); + return () => Atomics.load(view, 0) === CODEX_CLI_UPDATE_FENCE_CANCELLED; +} + +export interface CodexCliSelectionParentWrite { + readonly entered: boolean; + /** Idempotent release; safe to call from both branches of a spawn attempt. */ + exit(): void; +} + +/** + * Non-blocking acquisition for the parent selection writers and session-spawn + * paths. entered === false means a commit window (or another writer) is in + * flight and the caller must surface busy immediately, never wait. + */ +export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrite { + const view = new Int32Array(codexCliUpdateSelectionGate()); + if (Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_PARENT_WRITE) + !== CODEX_CLI_UPDATE_GATE_FREE) { + return Object.freeze({ entered: false, exit() { /* nothing held */ } }); + } + let held = true; + return Object.freeze({ + entered: true, + exit() { + if (!held) return; + held = false; + Atomics.store(view, 0, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} + +/** + * 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; + * exit() releases the gate once the spawn call has returned. + */ +export interface CodexCliUpdateCommitWindow { + tryEnter(): boolean; + waitEnter(): Promise; + commit(): boolean; + exit(): void; +} + +export function createWorkerCommitWindow(options: { + readonly gate: SharedArrayBuffer; + readonly fence: SharedArrayBuffer; + readonly deadlineMs?: number; + /** Test seam: observe/back-off without real sleeps. */ + readonly sleep?: (ms: number) => Promise; +}): CodexCliUpdateCommitWindow { + const gate = new Int32Array(options.gate); + const fence = new Int32Array(options.fence); + const deadlineMs = options.deadlineMs ?? WORKER_COMMIT_WAIT_DEADLINE_MS; + const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); + const cancelled = () => Atomics.load(fence, 0) === CODEX_CLI_UPDATE_FENCE_CANCELLED; + let held = false; + return Object.freeze({ + tryEnter() { + if (!held + && Atomics.compareExchange(gate, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) + === CODEX_CLI_UPDATE_GATE_FREE) { + held = true; + } + return held; + }, + async waitEnter() { + const deadline = Date.now() + deadlineMs; + while (!held && !cancelled()) { + if (Atomics.compareExchange(gate, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) + === CODEX_CLI_UPDATE_GATE_FREE) { + held = true; + break; + } + if (Date.now() >= deadline) break; + await sleep(1); + } + return held; + }, + commit() { + return Atomics.compareExchange(fence, 0, CODEX_CLI_UPDATE_FENCE_PREPARING, CODEX_CLI_UPDATE_FENCE_COMMITTED) + === CODEX_CLI_UPDATE_FENCE_PREPARING; + }, + exit() { + if (!held) return; + held = false; + Atomics.store(gate, 0, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} diff --git a/src/codex/cli-update-worker.ts b/src/codex/cli-update-worker.ts new file mode 100644 index 0000000000..7878d5b59c --- /dev/null +++ b/src/codex/cli-update-worker.ts @@ -0,0 +1,143 @@ +/** + * Worker-thread entry for one Codex CLI update plan or apply job. + * + * The parent owns admission and the durable job ledger; this realm owns the + * heavy inspection, hashing, staging and installer lifetime. The mutation + * evidence WeakMap is per-realm, which is exactly why the whole plan/apply + * must run here rather than only its hashing: report objects and their + * internal evidence cannot cross the realm boundary together. + * + * The message carries the allowlisted launcher snapshot (never the parent's + * trusted context) plus two shared-memory cells: the process-wide selection + * GATE the worker holds for its short final window, and the per-job FENCE + * whose PREPARING -> CANCELLED / PREPARING -> COMMITTED compare-exchanges + * decide cancel-vs-spawn atomically. A "cancel" message aborts the real + * AbortSignal handed to apply's fetchArtifact path — the asynchronous half + * of cancellation; the fence remains the synchronous-section half, and a + * cancel that loses the commit CAS is never posted. Any other second + * message is ignored: one worker runs exactly one job and then closes, so + * a late duplicate or a stale generation cannot attach new work to a dying + * realm. + */ +import { createCodexCliUpdatePlan, type CodexCliUpdateChannel, type CodexCliUpdatePlan } from "./cli-update-plan"; +import { applyCodexCliUpdate, type CodexCliApplyResult } from "./cli-update-apply"; +import { + codexCliUpdateInspectionDepsFromSnapshot, + type CodexCliUpdateInspectionSnapshot, +} from "./cli-update-inspection-context"; +import { codexCliUpdateIsCancelled, createWorkerCommitWindow } from "./cli-update-selection-gate"; + +export interface CodexCliUpdateWorkerRunMessage { + readonly type: "run"; + readonly requestId: string; + readonly jobId: string; + readonly operation: "plan" | "apply"; + readonly planId?: string; + readonly channel?: CodexCliUpdateChannel; + /** Frozen allowlisted launcher snapshot; never supplemented from ambient env. */ + readonly snapshot: CodexCliUpdateInspectionSnapshot; + /** Process-wide selection gate cell (see cli-update-selection-gate). */ + readonly gate: SharedArrayBuffer; + /** Per-job cancel/commit fence cell. */ + readonly fence: SharedArrayBuffer; + /** Env snapshot: a Worker may not observe parent mutations on every platform. */ + readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; +} + +/** Async half of cancellation; the fence CAS is the synchronous half. */ +export interface CodexCliUpdateWorkerCancelMessage { + readonly type: "cancel"; + readonly requestId?: string; + readonly jobId?: string; +} + +export type CodexCliUpdateWorkerReply = + | { readonly type: "done"; readonly requestId: string; readonly jobId: string; + readonly operation: "plan"; readonly plan: CodexCliUpdatePlan } + | { readonly type: "done"; readonly requestId: string; readonly jobId: string; + readonly operation: "apply"; readonly result: CodexCliApplyResult } + | { readonly type: "error"; readonly requestId: string; readonly jobId: string; + readonly message: string }; + +function isSharedBuffer(value: unknown): value is SharedArrayBuffer { + return typeof SharedArrayBuffer !== "undefined" + && value instanceof SharedArrayBuffer + && value.byteLength >= Int32Array.BYTES_PER_ELEMENT; +} + +function isRunMessage(data: unknown): data is CodexCliUpdateWorkerRunMessage { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + return o.type === "run" + && typeof o.requestId === "string" + && typeof o.jobId === "string" + && (o.operation === "plan" || o.operation === "apply") + && (o.planId === undefined || typeof o.planId === "string") + && (o.channel === undefined || o.channel === "latest") + && (o.snapshot === null || (typeof o.snapshot === "object" && !Array.isArray(o.snapshot))) + && isSharedBuffer(o.gate) + && isSharedBuffer(o.fence); +} + +declare const self: Worker; + +// One real AbortSignal per realm for apply's fetchArtifact path; aborting it +// is idempotent, so a cancel that arrives before the run still refuses the +// apply at its first checkpoint via signal.aborted. +const applyAbort = new AbortController(); +let ran = false; + +// Guarded so the module can be imported directly by tests without a Worker host. +if (typeof self !== "undefined" && typeof (self as { onmessage?: unknown }) === "object") { + self.onmessage = async (event: MessageEvent) => { + const data = event.data; + if (data && typeof data === "object" && !Array.isArray(data) + && (data as CodexCliUpdateWorkerCancelMessage).type === "cancel") { + applyAbort.abort(); + return; + } + if (ran || !isRunMessage(data)) return; + ran = true; + const message = data; + try { + // Home overrides apply before anything resolves a home-derived path in + // this realm; they are transport for the job, never snapshot fields. + if (message.env?.CODEX_HOME) process.env.CODEX_HOME = message.env.CODEX_HOME; + if (message.env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = message.env.OPENCODEX_HOME; + const inspectionDeps = codexCliUpdateInspectionDepsFromSnapshot(message.snapshot); + if (message.operation === "plan") { + const plan = await createCodexCliUpdatePlan({ + channel: message.channel ?? "latest", + inspectionDeps, + }); + self.postMessage({ + type: "done", requestId: message.requestId, jobId: message.jobId, + operation: "plan", plan, + } satisfies CodexCliUpdateWorkerReply); + } else { + if (typeof message.planId !== "string") throw new Error("cli_update_worker_missing_plan_id"); + const result = await applyCodexCliUpdate(message.planId, { + inspectionDeps, + signal: applyAbort.signal, + isCancelled: codexCliUpdateIsCancelled(message.fence), + commitWindow: createWorkerCommitWindow({ gate: message.gate, fence: message.fence }), + }); + self.postMessage({ + type: "done", requestId: message.requestId, jobId: message.jobId, + operation: "apply", result, + } satisfies CodexCliUpdateWorkerReply); + } + } catch (error) { + self.postMessage({ + type: "error", requestId: message.requestId, jobId: message.jobId, + message: error instanceof Error ? error.message : "cli_update_worker_failed", + } satisfies CodexCliUpdateWorkerReply); + } finally { + // Close from inside the Worker so the thread begins exiting on its own; + // the parent requires this close AND the result before settling done. + try { + (self as unknown as { close?: () => void }).close?.(); + } catch { /* already closing */ } + } + }; +} diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index 68a6f27858..91d7c7d391 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -41,6 +41,7 @@ import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux"; import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; +import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; export type { DesktopAppExecOptions } from "./desktop-app/types"; @@ -90,6 +91,7 @@ export type DesktopAppRestartReason = | "restart_in_flight" | "handoff_started" | "relaunch_context_failed" + | "selection_mutation_busy" | "targets_survived" | "relaunch_failed"; @@ -248,11 +250,27 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp // first quits and relaunches, the second sees the freshly started shell as a target // and kills it. Own-pid reentrancy means the wp5 helper runs this same step and // finds the lock its caller made out to it. - const acquisition = acquireDesktopRestartLock(io.lock); - if (!acquisition.acquired) return skipped("restart_in_flight"); + // A cli-update worker holds this gate from its final process scan through + // its installer spawn, so no new shell may appear inside that window. The + // admission check runs BEFORE the restart lock and every mutation this + // function performs: a busy refusal must leave the app, the lock and any + // handoff all untouched. Held through relaunch or a failed attempt's + // cleanup; a handoff helper's own delayed descendants stay the documented + // pre-existing residual. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return skipped("selection_mutation_busy"); let handedOff = false; + // The lock attempt sits inside the protected scope too: a throw from the + // lock's own mkdir/write before the try would otherwise leak this + // process-wide gate for the rest of the process. lockHeld distinguishes + // "acquisition threw/refused" from "lock taken" so a failed attempt never + // releases a lock this call does not hold. + let lockHeld = false; try { + const acquisition = acquireDesktopRestartLock(io.lock); + if (!acquisition.acquired) return skipped("restart_in_flight"); + lockHeld = true; const install = adapter.discover(exec); if (!install) return skipped("package_discovery_failed"); @@ -366,6 +384,7 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp } finally { // On the handoff path ownership was transferred to the helper, so releasing here // would drop a lock that is still protecting a restart about to happen. - if (!handedOff) releaseDesktopRestartLock(io.lock); + selectionWrite.exit(); + if (lockHeld && !handedOff) releaseDesktopRestartLock(io.lock); } } diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 65f9e16638..e1a37f3b4d 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -4,6 +4,10 @@ import { homedir, tmpdir } from "node:os"; import { delimiter, join, posix } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "./cli-update-selection-gate"; import { resolveCodexHomeDir } from "./home"; import { decodeOverlayState } from "./shim-state-file"; import { redactSecretString, redactUserPath } from "../lib/redact"; @@ -382,19 +386,30 @@ export function persistCodexRuntime( deps: ResolveCodexRuntimeDeps = {}, origin: CodexRuntimePinOrigin = "pinned", ): void { - const configDir = deps.configDir ?? getConfigDir(); - mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const payload: PersistedCodexRuntimeState = { - version: 1, - command: runtime.command, - source: runtime.source, - selectedVersion: runtime.version, - updatedAt: new Date((deps.now ?? Date.now)()).toISOString(), - origin, - }; - // Invalidate process authority before the persisted replacement is visible. - clearCodexRuntimeResolveCache(); - atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); + // A cli-update worker holds the shared selection gate from its final process + // scan through its installer spawn; this write must not slip into that + // window, so a held gate is an immediate busy rather than a wait. Checked + // BEFORE the mkdir and the resolve-cache invalidation so a busy refusal + // changes nothing at all. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); + try { + const configDir = deps.configDir ?? getConfigDir(); + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const payload: PersistedCodexRuntimeState = { + version: 1, + command: runtime.command, + source: runtime.source, + selectedVersion: runtime.version, + updatedAt: new Date((deps.now ?? Date.now)()).toISOString(), + origin, + }; + // Invalidate process authority before the persisted replacement is visible. + clearCodexRuntimeResolveCache(); + atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); + } finally { + selectionWrite.exit(); + } } /** @@ -405,20 +420,29 @@ export function persistCodexRuntime( * folds the persisted `updatedAt` into its key, and a removed file has no stamp to fold. */ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): void { - const configDir = deps.configDir ?? getConfigDir(); - clearCodexRuntimeResolveCache(); + // Same gate as persistCodexRuntime, checked before the cache invalidation + // and outside the swallowing catch: a busy refusal must stay an error, not + // become a silent "already removed". + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); try { - unlinkSync(codexRuntimeStatePath(configDir)); - } catch (error) { - // An already-missing file is the success case: the pin is gone, which is the point. - // Anything else means the pin SURVIVES and stays authoritative, so every later - // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once. - const code = (error as NodeJS.ErrnoException | null)?.code; - if (code === "ENOENT") return; - console.warn( - `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}` - + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`, - ); + const configDir = deps.configDir ?? getConfigDir(); + clearCodexRuntimeResolveCache(); + try { + unlinkSync(codexRuntimeStatePath(configDir)); + } catch (error) { + // An already-missing file is the success case: the pin is gone, which is the point. + // Anything else means the pin SURVIVES and stays authoritative, so every later + // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once. + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT") return; + console.warn( + `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}` + + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`, + ); + } + } finally { + selectionWrite.exit(); } } diff --git a/src/codex/shim-overlay.ts b/src/codex/shim-overlay.ts index 12630347ba..4eb5f4c308 100644 --- a/src/codex/shim-overlay.ts +++ b/src/codex/shim-overlay.ts @@ -5,6 +5,10 @@ import { } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "./cli-update-selection-gate"; import { durableBunRuntime } from "../lib/bun-runtime"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { truncateRetainedUtf8 } from "../lib/admission"; @@ -442,6 +446,12 @@ function installLocked( return publishOverlay(launcher, overlay, check, pinnedState); } export function installUnixOverlay(discover: () => string | null, describeMiss?: () => string | null): InstallResult { + // The update worker's commit window re-observes selection evidence right up + // to its installer spawn; a shim install must not cross that window. Busy is + // surfaced before namespace creation, the restore lock and any journal + // mutation, so a held gate refuses without touching state. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return failure(new CodexCliSelectionMutationBusyError()); let lock: ReturnType = null; try { const check = namespace(true); @@ -450,7 +460,7 @@ export function installUnixOverlay(discover: () => string | null, describeMiss?: check(); return installLocked(discover, false, check, describeMiss); } catch (error) { return failure(error); } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRestoreResult { const result = readStateResult(); @@ -468,6 +478,11 @@ export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRe } catch (error) { return { status: "ineligible", message: failure(error).message }; } if (!nativeProbe(state.launcherPath)) return { status: "ineligible", message: "Recorded native Codex launcher is unusable; repair it with its package manager" }; if (!options.enabled()) return { status: "disabled" }; + // Same selection gate as install: acquired before namespace creation, the + // restore lock and journal mutation; a held update commit window defers the + // restore rather than racing it. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return { status: "deferred", message: "a Codex CLI update is committing an install; shim restore deferred" }; let lock: ReturnType = null; try { const check = namespace(true); @@ -484,9 +499,13 @@ export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRe const installed = installLocked(() => null, true, check); return installed.installed ? { status: "restored", message: installed.message } : { status: "healthy" }; } catch (error) { return { status: "deferred", message: failure(error).message }; } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } export function uninstallUnixCodexShim(): { removed: boolean; message: string } { + // Same selection gate: removal mutates the same state the update worker + // re-observes in its commit window, so a held gate reports busy unchanged. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return { removed: false, message: new CodexCliSelectionMutationBusyError().message }; let lock: ReturnType = null; try { const check = namespace(false); @@ -522,5 +541,5 @@ export function uninstallUnixCodexShim(): { removed: boolean; message: string } if (!removePinned(statePath(), stateFile)) throw new Error("Concurrent Codex state replacement preserved"); return { removed: true, message: "Codex PATH shim removed. Native Codex launcher was preserved. Remove the codex-shell-env source line from your shell startup file." }; } catch (error) { return { removed: false, message: failure(error).message }; } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts index 5e6c2472d6..97cc2f4511 100644 --- a/src/remote-control/workspace-codex-runtime.ts +++ b/src/remote-control/workspace-codex-runtime.ts @@ -2,6 +2,10 @@ import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import { resolveCodexRuntimeAsync } from "../codex/runtime"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "../codex/cli-update-selection-gate"; import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; @@ -366,6 +370,23 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim "app-server", "--listen", "stdio://", ], { env: childEnv }); let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + // A cli-update worker holds this gate from its final process scan through + // its installer spawn; a new session shell must not appear inside that + // window. Busy is the same explicit pre-spawn failure path as a spawn + // throw, so the bridge and isolation are reclaimed identically. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) { + // Both teardown steps are attempted before the refusal leaves: the + // ordered runner keeps a rejected bridge stop from skipping isolation + // removal or replacing the explicit busy signal the caller maps to a + // retryable refusal. The raw cleanup error stays internal; the caller + // gets the same fixed busy evidence as every other pre-spawn refusal. + await runRemoteWorkspaceCleanupSteps([ + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]).catch(() => {}); + throw new CodexCliSelectionMutationBusyError(); + } try { child = Bun.spawn([invocation.file, ...invocation.args], { cwd: isolation, @@ -376,10 +397,12 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim ...invocation.options, }); } catch (error) { + selectionWrite.exit(); await bridge.stop(); removeRemoteWorkspaceIsolation(isolation); throw error; } + selectionWrite.exit(); const peer = new JsonLineRpcProcess(child); let activeTurn: ActiveTurn | null = null; let stopped = false; diff --git a/src/server/management/codex-cli-update-routes.ts b/src/server/management/codex-cli-update-routes.ts index 98e302e228..3b780c5bca 100644 --- a/src/server/management/codex-cli-update-routes.ts +++ b/src/server/management/codex-cli-update-routes.ts @@ -1,7 +1,5 @@ -import { createCodexCliUpdatePlan } from "../../codex/cli-update-plan"; -import { applyCodexCliUpdate } from "../../codex/cli-update-apply"; import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../codex/cli-update-job"; -import { codexCliUpdateInspectionDeps } from "../../codex/cli-update-inspection-context"; +import { runCodexCliUpdateApplyInWorker, runCodexCliUpdatePlanInWorker } from "../../codex/cli-update-job-worker"; import { jsonResponse } from "../auth-cors"; import { trackBackgroundWork } from "../../lib/background-work"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -13,7 +11,7 @@ export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promis if (ctx.url.pathname === "/api/codex/cli-update/plan") { if (ctx.req.method !== "GET") return jsonResponse({ error: "method_not_allowed" }, 405, ctx.req, ctx.config); try { - const plan = await createCodexCliUpdatePlan({ inspectionDeps: codexCliUpdateInspectionDeps() }); + const plan = await runCodexCliUpdatePlanInWorker(); return jsonResponse(plan, 200, ctx.req, ctx.config); } catch { return jsonResponse({ error: "plan_unavailable" }, 503, ctx.req, ctx.config); } } @@ -42,7 +40,7 @@ export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promis const admitted = admitCodexCliUpdateJob({ requestId: value.requestId, planId: value.planId, confirmed: true }); if (admitted.kind === "accepted") { const work = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, - () => applyCodexCliUpdate(value.planId as string, { inspectionDeps: codexCliUpdateInspectionDeps() })); + () => runCodexCliUpdateApplyInWorker(value.planId as string, { jobId: admitted.job.jobId })); trackBackgroundWork(`codex-cli-update:${admitted.job.jobId}`, work).catch(() => undefined); } return jsonResponse({ job: admitted.job }, 202, ctx.req, ctx.config); diff --git a/structure/INDEX.md b/structure/INDEX.md index aebe28258d..2a4d9b9f92 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -219,9 +219,12 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/cli-update-files.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-inspection-context.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-job-execution.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-job-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `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-selection-gate.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `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/signed-in-identity.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 1471016161..dd6d08ff76 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -24,6 +24,10 @@ Under the test preload's `OCX_TEST_HOME_GUARD=1`, `src/codex/desktop-app-restart.ts` skips real restart without an injected executor. `src/cli/restart-scope.ts` reports that skip. `NODE_ENV=test` alone does not control this boundary. `tests/clients/desktop-app-restart.test.ts` covers the contract. +The same function refuses with the `selection_mutation_busy` skipped reason while a cli-update +worker holds its commit window: the refusal precedes the restart lock, package discovery, any app +quit and the handoff, and a lock-acquisition throw still releases the shared gate. See the +[update commit window](../codex-cli-update.md#worker-realm-and-the-selection-commit-window). ## Windows launch context diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index b9a618567e..3e33348682 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -91,9 +91,12 @@ precedes explicit stream destruction, and verification waits for the close event pipeline settlement alone is not close completion and the caller never closes that raw fd again. After artifact preparation, selection/content, the approved metadata and active/unknown process blockers -are re-observed. The awaited process scan is the last yield before install; in the same event-loop turn -as the spawn a synchronous guard re-checks the signal, prefix identity, the byte-level preimage and the -launcher's live canonical selection. npm receives only two verified local tarball specs and an explicit canonical prefix, +are re-observed. Inside the worker realm the awaited process scan is the last yield before install; in +the same worker event-loop turn as the spawn a synchronous guard re-checks the cancellation state, +prefix identity, the byte-level preimage and the launcher's live canonical selection, and the per-job +commit fence's PREPARING-to-COMMITTED compare-exchange is the final guard before the installer spawn — +a parent cancel that already landed wins, while a cancel landing after COMMITTED defers to the live +child's close and readback. npm receives only two verified local tarball specs and an explicit canonical prefix, with offline mode, optional dependencies omitted, lifecycle scripts disabled and no audit/fund/lockfile side effects. Main manifests are not rewritten to make installation succeed. Cancelled preparation refuses; cancellation during an admitted npm child waits for its close rather than killing it. The lease @@ -115,6 +118,61 @@ lease release marks retention unverified without preventing staging cleanup; cle 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. +## Worker realm and the selection commit window + +The plan and apply bodies execute inside a dedicated Worker realm so their synchronous file hashing and +directory walks never occupy the parent's HTTP/event loop. `src/codex/cli-update-worker.ts` is the +per-job worker entrypoint, registered as `cli-update-worker` in the standalone build's +`WORKER_ENTRIES` (`scripts/build-standalone.ts`); `src/codex/cli-update-job-worker.ts` is the +parent-side dispatch the CLI and the management route call. The run message carries the allowlisted +frozen launcher snapshot — never the parent's trustedContext and never supplemented from ambient +environment — plus two shared-memory cells from `src/codex/cli-update-selection-gate.ts` and at most +the two home overrides as transport. `src/codex/cli-update-inspection-context.ts` exposes the shared +factory that rebuilds production inspection deps from that snapshot inside whichever realm calls it, so +the worker derives its own fresh selected-command evidence; the installer inspector's mutation-evidence +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 +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 +and a live verification child. The host never calls terminate(): an unresponsive worker may still own a +running npm child, so every post-delivery loss — worker death, message error, an implausible or missing +reply, or done without close inside the bounded grace — settles `ambiguous`/`job_result_unknown` with +the lease reported retained and the still-live worker tracked rather than silently leaked. A spawn +throw before the run message exists is the only `refused`/`preparation_failed` path, because no job +code provably ran. Ledger completion writes keep their existing bounded retry. + +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 +synchronous worker section, signal-based cancellation alone is not equivalent and is not used as the +worker path's only channel. + +The worker's final window is also serialized against the parent's own selection mutations through one +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 — +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 +cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal), and +`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 +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. +Ordinary config writes, account operations, model requests, existing session interrupt/close and +arbitrary subprocesses are not gated. The gate claims nothing about processes the parent does not own: +an external installer, a handoff helper's delayed descendants, or an OS-level snapshot race stays the +documented residual it always was. + `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. `src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed `gui-session` plus trusted-loopback apply admission, and authenticated status. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. diff --git a/structure/manifest.json b/structure/manifest.json index 9baee2482d..8d04db9084 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -179,6 +179,9 @@ "src/codex/cli-update-apply.ts", "src/codex/cli-update-job.ts", "src/codex/cli-update-job-execution.ts", + "src/codex/cli-update-job-worker.ts", + "src/codex/cli-update-selection-gate.ts", + "src/codex/cli-update-worker.ts", "src/server/management/codex-cli-update-routes.ts", "src/codex/cli-update-inspection-context.ts", "bin/ocx.mjs", diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 93e43c4122..fbe0889439 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -57,6 +57,7 @@ A track that holds a nested split reserves that subtree's minimum width (`minLay side-by-side splits never scroll inside a pane column; an oversized tree scrolls the pane surface once. Hub runtime admission counts pending create/resume starts as well as live handles against global and per-device limits; every outcome releases its reservation. Stop and shutdown reclaim late resumed handles before clearing ownership. Coordinator result size limits normalize both response text and success, so bridge and MCP callers receive consistent errors. +`src/remote-control/workspace-codex-runtime.ts` takes the shared Codex-CLI-update selection gate immediately before its app-server `Bun.spawn`; while a cli-update worker holds its short commit window the launch refuses with `codex_cli_selection_busy` after running the same bridge/isolation cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal. See the [update commit window](codex-cli-update.md#worker-realm-and-the-selection-commit-window). Prompt HTTP admission returns 202 with the existing session/event cursor; tracked operations publish terminal status for polling and attach a rejection observer immediately. Reconnect/resume cannot publish ready while a turn is active. The dashboard prefers newer local event cursors over stale polling, favors authoritative polling on ties, retains unconfirmed draft text with an uncertainty notice, and never retries prompt POSTs automatically. diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index 87dd958b75..187ecef6b1 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { listCodexDesktopShellPids, restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; import { classifyDesktopPicker, readCodexCatalogBinding, readCodexDesktopPickerState } from "../../src/codex/desktop-app/picker-freshness"; import type { DesktopAppAdapter, DesktopProcess } from "../../src/codex/desktop-app/types"; import { windowsDesktopAppAdapter } from "../../src/codex/desktop-app/windows"; @@ -624,3 +629,79 @@ describe("desktop picker freshness inputs (#4761)", () => { } }); }); + +describe("the cli-update selection gate covers the whole restart ladder", () => { + test("CLI commit window refuses the real desktop restart caller before quit, then releases it", () => { + const calls: Call[] = []; + const io = scriptedIo({ + discovery: DISCOVERY, + processes: "1000 900 2026-08-22T10:00:00.000Z", + calls, + }); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + try { + held = window.tryEnter(); + expect(held).toBe(true); + const denied = withTrustedExes(() => restartCodexDesktopApp(io)); + expect(denied).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "selection_mutation_busy", + }); + expect(existsSync(io.lock!.lockPath!)).toBe(false); + expect(calls.some(call => call.file === TASKKILL || + /CloseMainWindow|Start-Process|OpenCodexPackageActivation/.test(call.args.join(" ")))).toBe(false); + + window.exit(); + held = false; + calls.length = 0; + // Same caller/lock path, plus explicit absence checks: same-PID reentry alone + // would NOT prove lock release. + const allowed = withTrustedExes(() => restartCodexDesktopApp(io)); + expect(allowed).toEqual({ + attempted: true, stopped: [1000], surviving: [], relaunch: "started", + }); + expect(calls.filter(call => call.args.join(" ").includes("CloseMainWindow"))).toHaveLength(1); + expect(calls.some(call => call.args.join(" ").includes("Start-Process"))).toBe(true); + expect(calls.some(call => call.file === TASKKILL)).toBe(false); + + expect(existsSync(io.lock!.lockPath!)).toBe(false); + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + removeTreeWithRetry(dirname(io.lock!.lockPath!)); + } + }); + + test("a lock-acquisition throw still releases the process-wide selection gate", () => { + // Regression for the gate-then-lock ordering: the lock's own mkdir/write + // can throw, and before the fix that throw sat outside the try/finally and + // left the shared gate held for the rest of the process. A lock path whose + // parent is a regular file makes that acquisition throw deterministically. + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-restart-gate-")); + try { + const blocker = join(dir, "not-a-directory"); + writeFileSync(blocker, "fixture"); + const calls: Call[] = []; + const io = scriptedIo({ + discovery: DISCOVERY, + processes: "1000 900 2026-08-22T10:00:00.000Z", + calls, + }); + io.lock = { lockPath: join(blocker, "lock") }; + expect(() => withTrustedExes(() => restartCodexDesktopApp(io))).toThrow(); + // No discovery/quit/relaunch ran, and the gate is free again. + expect(calls).toHaveLength(0); + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + removeTreeWithRetry(dir); + } + }); +}); diff --git a/tests/clients/desktop-restart-handoff.test.ts b/tests/clients/desktop-restart-handoff.test.ts index f299bbba0a..4191d071e4 100644 --- a/tests/clients/desktop-restart-handoff.test.ts +++ b/tests/clients/desktop-restart-handoff.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; import { handoffLogPath, resolveHelperCommand, @@ -9,7 +10,12 @@ import { startDesktopRestartHandoff, type HandoffIo, } from "../../src/codex/desktop-app/handoff"; -import { acquireDesktopRestartLock } from "../../src/codex/desktop-app/lock"; +import { acquireDesktopRestartLock, releaseDesktopRestartLock } from "../../src/codex/desktop-app/lock"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; /** * The handoff exists because the self-ancestry refusal fires in the NORMAL case on a @@ -92,6 +98,104 @@ describe("starting a handoff", () => { expect(planArg).toBeDefined(); expect(existsSync(planArg as string)).toBe(false); }); + + test("worker commit gate refuses self-ancestry handoff before restart lock acquisition", () => { + const dir = home(); + const lockPath = join(dir, "lock"); + const spawned: { command?: string; args?: string[] } = {}; + const handoffIo = startIo(dir, spawned, 9001); + const calls = { handoff: 0, helper: 0, quit: 0, force: 0, relaunch: 0, exec: 0 }; + let lockPathReads = 0; + const lock = { + ...handoffIo.lock!, + get lockPath() { lockPathReads++; return lockPath; }, + }; + handoffIo.lock = lock; + const spawnHelper = handoffIo.spawnHelper!; + handoffIo.spawnHelper = (command, args) => { + calls.helper++; + return spawnHelper(command, args); + }; + const io: DesktopAppRestartIo = { + platform: "win32", + lock, + allowHandoff: true, + ancestryPids: () => [4242, 1000], + execFile: () => { calls.exec++; throw new Error("unexpected fixture exec"); }, + isAlive: () => false, + sleep: () => {}, + now: () => 1_000, + adapter: { + discover: () => ({ id: "fixture", root: dir, relaunch: "fixture" }), + listProcesses: () => [{ + pid: 1000, parentPid: 1, createdAt: "fixture-start", + executable: join(dir, "fixture-shell"), + }], + isShell: () => true, + ancestryPids: () => [4242, 1000], + captureRelaunchContext: () => ({}), + requestQuit: () => { calls.quit++; }, + forceStop: () => { calls.force++; }, + relaunch: () => { calls.relaunch++; }, + }, + startHandoff: () => { + calls.handoff++; + const result = startDesktopRestartHandoff(handoffIo); + return result.kind === "started" + ? { helperPid: result.helperPid, logPath: result.logPath } + : null; + }, + }; + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + try { + expect(existsSync(lockPath)).toBe(false); + held = window.tryEnter(); + expect(held).toBe(true); + // Calls the production restart ladder, not a wrapper that pre-checks the gate. + expect(restartCodexDesktopApp(io)).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "selection_mutation_busy", + }); + expect(calls).toEqual({ handoff: 0, helper: 0, quit: 0, force: 0, relaunch: 0, exec: 0 }); + // Final absence alone would miss create-then-unlink. The existing lock + // implementation reads io.lock.lockPath before any acquisition side effect. + expect(lockPathReads).toBe(0); + expect(existsSync(lockPath)).toBe(false); + expect(spawned.command).toBeUndefined(); + + window.exit(); + held = false; + expect(restartCodexDesktopApp(io)).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "handoff_started", + handoff: { helperPid: 9001, logPath: handoffLogPath({ homeDir: dir }) }, + }); + expect(calls).toEqual({ handoff: 1, helper: 1, quit: 0, force: 0, relaunch: 0, exec: 0 }); + expect(lockPathReads).toBeGreaterThan(0); + expect(spawned.command).toBe("/usr/local/bin/ocx"); + expect(spawned.args?.slice(0, 3)).toEqual(["internal", "desktop-restart-handoff", "--plan"]); + expect(JSON.parse(readFileSync(lockPath, "utf8")).ownerPid).toBe(9001); + // Handoff deliberately keeps the filesystem lock, but must release the + // process-wide selection gate in the restart function's finally. + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + // These are synthetic fixture owners; no helper process was created. + releaseDesktopRestartLock({ lockPath, pid: 9001 }); + releaseDesktopRestartLock({ lockPath, pid: 4242 }); + const planPath = spawned.args?.[3]; + if (planPath && existsSync(planPath)) unlinkSync(planPath); + const logPath = handoffLogPath({ homeDir: dir }); + if (existsSync(logPath)) unlinkSync(logPath); + rmdirSync(dir); + } + }); }); describe("running the handoff", () => { diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts index b9d259c2eb..9525b1357e 100644 --- a/tests/clients/remote-workspace-codex-runtime.test.ts +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -1,5 +1,10 @@ import { repoPath } from "../helpers/repo-root"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; import { resolve } from "node:path"; import { CodexRemoteWorkspaceRuntimeFactory, @@ -118,3 +123,105 @@ test("Codex Remote Workspace resumes the persisted App Server thread ID", async expect(handle.threadId).toBe("thread-persisted"); await handle.stop(); }); + +function launchGateStartOptions(emit: (type: RemoteWorkspaceSessionEvent["type"], text: string) => void = () => {}): Parameters[0] { + return { + sessionId: "launch-gate-session", + deviceId: "launch-gate-device", + deviceName: "Fixture", + rootId: "launch-gate-root", + rootLabel: "Fixture", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + coordinator: new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }), + emit, + }; +} + +// Uses the REAL factory and its existing explicit command seam. A broken gate +// reaches the spawn spy, which throws; it can never launch a real Codex app. +test("CLI commit window blocks Remote Workspace native spawn and release restores the caller", async () => { + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { FAKE_CODEX_SCRIPT: JSON.stringify({ turns: [{}] }) }, + }); + const options = launchGateStartOptions(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("unexpected native spawn while worker holds the gate"); + }); + try { + held = window.tryEnter(); + expect(held).toBe(true); + await expect(factory.start(options)).rejects.toThrow("codex_cli_selection_busy"); + expect(spawn).toHaveBeenCalledTimes(0); + } finally { + if (held) window.exit(); + spawn.mockRestore(); + } + // No native Codex fallback: command remains the repository's fake JSON-RPC server. + const handle = await factory.start(options); + try { + await handle.prompt("Complete this fixture turn"); + } finally { + await handle.stop(); + } + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } +}); + +// Unlike a gate-wrapper unit test, this reaches the existing prompt/interrupt/ +// close implementation. The emitted marker replaces a scheduling sleep. +test("an existing Remote Workspace turn can interrupt and close while the worker gate is held", async () => { + let ready!: () => void; + const sawHeldTurn = new Promise(resolveReady => { ready = resolveReady; }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { + FAKE_CODEX_SCRIPT: JSON.stringify({ + turns: [{ + heldUntilInterrupt: true, + notifications: [{ + method: "item/completed", + params: { item: { id: "gate-ready", type: "agentMessage", text: "gate-ready" } }, + }], + }], + }), + }, + }); + const handle = await factory.start(launchGateStartOptions((type, text) => { + if (type === "assistant" && text === "gate-ready") ready(); + })); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + let stopped = false; + let turn: Promise | undefined; + try { + turn = handle.prompt("Hold until interrupted").then(() => "resolved", () => "rejected"); + await sawHeldTurn; + held = window.tryEnter(); + expect(held).toBe(true); + await handle.stop(); + stopped = true; + expect(await turn).toBe("rejected"); + // The stop path must NOT steal/release the worker's ownership. + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(false); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + if (!stopped) await handle.stop(); + if (turn) await turn; + } +}); diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index b69ef49a34..c396e17f0a 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -15,6 +15,11 @@ import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { codexCliPlatformBacking } from "../../src/codex/cli-install-provenance"; import { observeCodexCliFileSet, sameCodexCliFiles } from "../../src/codex/cli-update-files"; +import { + createCodexCliUpdateFence, cancelCodexCliUpdateFence, codexCliUpdateIsCancelled, + codexCliUpdateSelectionGate, beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); @@ -562,36 +567,106 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { expect(result.outcome).toBe("refused"); expect(installs).toBe(0); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); }, 120_000); - test.each(["preimage-drift", "selection-change", "signal-abort"] as const)( + test("artifact fetch keeps the actual AbortSignal identity and event-listener contract", async () => { + const f = fixture(), id = await approved(f), controller = new AbortController(); + const fence = createCodexCliUpdateFence(); + const signals: AbortSignal[] = [], listeners: Array<() => void> = []; + let abortEvents = 0, installs = 0; + try { + const result = await applyCodexCliUpdate(id, depsFor(f, { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), + fetchArtifact: async (artifact, signal) => { + expect(signal).toBe(controller.signal); + expect(signal).toBeInstanceOf(AbortSignal); + const listener = () => { abortEvents++; }; + signal!.addEventListener("abort", listener, { once: true }); + signals.push(signal!); listeners.push(listener); + return artifact.installName === f.target.artifacts.main.installName ? f.target.main.bytes : f.target.backing.bytes; + }, + install: () => { installs++; return Promise.resolve({ terminal: true, exitCode: 0 }); }, + })); + expect(result).toMatchObject({ outcome: "not_applied", reason: "original_install_unchanged", leaseRetained: false }); + expect(signals).toHaveLength(2); expect(installs).toBe(1); expect(abortEvents).toBe(0); + controller.abort(); expect(abortEvents).toBe(2); + expect(codexCliUpdateIsCancelled(fence)()).toBe(false); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + } finally { + for (let i = 0; i < signals.length; i++) signals[i]!.removeEventListener("abort", listeners[i]!); + } + }, 120_000); + test.each(["preimage-drift", "selection-change", "signal-abort", "sab-cancel"] as const)( "a %s while the process scan is pending refuses before install", async lane => { const f = fixture(); const id = await approved(f); let installs = 0; - const controller = new AbortController(); - let scans = 0, scanPending = false; - let release!: () => void; - const gate = new Promise(resolve => { release = resolve; }); - // apply calls the async scan seam three times — once inside each plan and - // once as the final pre-install scan. Only the last one is held open, so - // the mutation below lands strictly inside the awaited final-scan window. + const controller = new AbortController(), fence = createCodexCliUpdateFence(); + const commitWindow = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + let scans = 0, release!: () => void, entered!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const scanEntered = new Promise(resolve => { entered = resolve; }); + // Two plan scans precede the final scan. Only that final awaited scan is held. const pending = applyCodexCliUpdate(id, depsFor(f, { - signal: controller.signal, + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), commitWindow, scanProcessesAsync: async () => { - if (++scans < 3) return { kind: "observed", processes: [] } as never; - scanPending = true; - await gate; - return { kind: "observed", processes: [] } as never; + if (++scans === 3) { entered(); await held; } + return { kind: "observed", processes: [] }; }, - install: async () => { installs++; return { terminal: true, exitCode: 0 }; }, + install: () => { installs++; return Promise.resolve({ terminal: true, exitCode: 0 }); }, })); - while (!scanPending) await new Promise(resolve => setTimeout(resolve, 10)); - if (lane === "preimage-drift") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), - "#!/usr/bin/env node\n// changed while the final scan was pending\n"); - if (lane === "selection-change") { unlinkSync(f.launcher); symlinkSync(f.target.main.path, f.launcher); } - if (lane === "signal-abort") controller.abort(); - release(); - const result = await pending; - expect(result.outcome).toBe("refused"); expect(installs).toBe(0); - expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + try { + await waitForApplyAdmission(scanEntered, pending); expect(scans).toBe(3); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + if (lane === "preimage-drift") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), + "#!/usr/bin/env node\n// changed while the final scan was pending\n"); + if (lane === "selection-change") { unlinkSync(f.launcher); symlinkSync(f.target.main.path, f.launcher); } + if (lane === "signal-abort") controller.abort(); + if (lane === "sab-cancel") { + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(controller.signal.aborted).toBe(false); + } + release(); const result = await pending; + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + if (lane === "sab-cancel" || lane === "signal-abort") expect(result.reason).toBe("cancelled"); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + const next = beginCodexCliSelectionParentWrite(); + try { expect(next.entered).toBe(true); } finally { next.exit(); } + } finally { release(); await pending.catch(() => {}); } }, 120_000); + test("actual apply commits while install is called synchronously, then frees gate before awaiting it", async () => { + const f = fixture(), id = await approved(f), fence = createCodexCliUpdateFence(); + const commitWindow = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + let entered!: () => void, finish!: () => void, installs = 0, settled = false; + const started = new Promise(resolve => { entered = resolve; }); + const terminal = new Promise<{ terminal: boolean; exitCode: number }>(resolve => { + finish = () => resolve({ terminal: true, exitCode: 0 }); + }); + const pending = applyCodexCliUpdate(id, depsFor(f, { + isCancelled: codexCliUpdateIsCancelled(fence), commitWindow, + // Deliberately NOT async: this is the documented same-turn child-start seam. + // It starts only this fake's admission marker, never the production installer. + install: () => { + installs++; + expect(cancelCodexCliUpdateFence(fence)).toBe(false); // commit won before invocation + expect(codexCliUpdateIsCancelled(fence)()).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + entered(); return terminal; + }, + })).then(result => { settled = true; return result; }); + try { + await waitForApplyAdmission(started, pending); + expect(installs).toBe(1); expect(settled).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(true); } finally { writer.exit(); } + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + } finally { finish(); await pending.catch(() => {}); } + expect(await pending).toMatchObject({ outcome: "not_applied", reason: "original_install_unchanged", leaseRetained: false }); + expect(installs).toBe(1); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); test("exit zero with original full files is not_applied", async () => { const f = fixture(); const id = await approved(f); const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => ({ terminal: true, exitCode: 0 }) })); diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts new file mode 100644 index 0000000000..1c2bfa8fbb --- /dev/null +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -0,0 +1,507 @@ +/** + * Worker-dispatch integration coverage for the Codex CLI update offload. + * + * The generic gate/fence and fixture-worker cases below prove the dispatch + * contract; they do not by themselves prove the production callers are + * protected — the real plan/apply worker path and the seven gated parent + * operations have their own regression cases elsewhere. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import type { TrustedNodeLaunchContext } from "../../src/cli/launcher-context"; +import { + runCodexCliUpdatePlanInWorker, runCodexCliUpdateApplyInWorker, + cancelCodexCliUpdateWorkerJob, codexCliUpdateUnjoinedWorkerCount, + codexCliUpdateUnjoinedWorkerClosedTotal, codexCliUpdateWorkerDiagnostics, +} from "../../src/codex/cli-update-job-worker"; +import { + createCodexCliUpdateFence, cancelCodexCliUpdateFence, codexCliUpdateIsCancelled, + codexCliUpdateSelectionGate, beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, resetCodexCliSelectionGatesForTests, +} from "../../src/codex/cli-update-selection-gate"; +import { codexCliUpdateInspectionDepsFromSnapshot } from "../../src/codex/cli-update-inspection-context"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { + FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, + FIXTURE_CONTROL_WORDS, type FixtureConfiguration, +} from "../helpers/cli-update-fixture-worker"; +type Snapshot = TrustedNodeLaunchContext["codexCliInspectionEnv"]; +type RunMessage = { + type: "run"; requestId: string; jobId: string; operation: "plan" | "apply"; + planId?: string; snapshot: Snapshot; gate: SharedArrayBuffer; fence: SharedArrayBuffer; + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +}; +const planId = "a".repeat(32); +const roots: string[] = []; +let jobSequence = 0; +const workers: OwnedWorker[] = []; +function snapshot(configDir: string): NonNullable { + return { + codexCliPath: join(configDir, "selected", "codex.cmd"), + path: "", pathExt: ".EXE;.CMD", home: configDir, nodePath: null, + managerRoots: {}, transportEnv: {}, arch: process.arch, configDir, + }; +} +function home() { + const root = mkdtempSync(join(tmpdir(), "ocx-update-worker-")); + roots.push(root); + return root; +} +// Deadline is only a stuck-fixture watchdog, never a performance threshold: +// a cold production-worker module import on Windows legitimately takes +// seconds, so the bound is generous and only failure paths ever reach it. +// No main-thread Atomics.wait: it would invalidate the responsiveness test. +async function until(check: () => boolean, label: string): Promise { + const deadline = performance.now() + 15_000; + while (!check()) { + if (performance.now() >= deadline) throw new Error("fixture did not reach " + label); + await new Promise(resolve => setTimeout(resolve, 0)); + } +} +interface OwnedWorker { + worker: Worker; + control: Int32Array; + runMessages: RunMessage[]; + messages: unknown[]; + terminateCalls: number; + closed: boolean; + errors: string[]; + finished: Promise; + detach(): void; + release(): void; +} +function ownWorker(url: URL, config?: Omit): OwnedWorker { + const worker = new Worker(url, { type: "module" }); + const control = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * FIXTURE_CONTROL_WORDS)); + let complete!: () => void; + const finished = new Promise(resolve => { complete = resolve; }); + const owner: OwnedWorker = { + worker, control, runMessages: [], messages: [], terminateCalls: 0, + closed: false, errors: [], finished, + release() { Atomics.store(control, FIXTURE_RELEASE, 1); Atomics.notify(control, FIXTURE_RELEASE); }, + detach() { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + worker.removeEventListener("close", onClose); + }, + }; + const onMessage = (event: MessageEvent) => { owner.messages.push(event.data); }; + const onError = (event: ErrorEvent) => { + owner.errors.push(event.message); + event.preventDefault(); // host's own listener still observes the same error + }; + const onClose = () => { owner.closed = true; complete(); }; + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); + worker.addEventListener("close", onClose); + const post = worker.postMessage.bind(worker); + Object.defineProperty(worker, "postMessage", { + configurable: true, + value: (...args: Parameters) => { + const message = args[0] as Partial | undefined; + if (message?.type === "run") owner.runMessages.push(message as RunMessage); + return Reflect.apply(post, worker, args); + }, + }); + const terminate = worker.terminate.bind(worker); + Object.defineProperty(worker, "terminate", { + configurable: true, + value: (...args: Parameters) => { + owner.terminateCalls++; + return Reflect.apply(terminate, worker, args); + }, + }); + workers.push(owner); + if (config) post({ type: "fixture-config", control: control.buffer, ...config }); + return owner; +} +function fixture(config: Omit = {}) { + const owner = ownWorker(new URL("../helpers/cli-update-fixture-worker.ts", import.meta.url), config); + const configDir = home(), jobId = "fixture-job-" + (++jobSequence); + const options = { + jobId, snapshot: snapshot(configDir), + env: { CODEX_HOME: join(configDir, "codex"), OPENCODEX_HOME: configDir }, + spawnWorkerFn: (_url: string, _key: string) => owner.worker, + }; + return { owner, options, jobId, configDir }; +} +async function joined(owner: OwnedWorker) { + await until(() => owner.closed, "worker close"); + await owner.finished; + expect(owner.terminateCalls).toBe(0); + expect(owner.runMessages).toHaveLength(1); +} +afterEach(async () => { + // Release every exact fixture even if an assertion failed. Never terminate a + // dispatched worker whose done result might conceal an owned native child. + // A failed close wait must not lose the remaining owners: every worker gets + // its release+join attempt, and one that still will not close is re-queued + // so a later afterEach still owns it instead of leaking it untracked. + const owned = workers.splice(0); + const abandoned: OwnedWorker[] = []; + let firstError: unknown; + for (const owner of owned) { + try { + owner.release(); + if (!owner.closed) { + // A fixture before its RunMsg may be idle; FIFO close is harmless after run. + try { owner.worker.postMessage({ type: "fixture-close" }); } + catch { /* A concurrently closing worker still must emit the close observed below. */ } + } + await until(() => owner.closed, "owned worker close during cleanup"); + await owner.finished; + expect(owner.terminateCalls).toBe(0); + } catch (error) { + if (firstError === undefined) firstError = error; + if (!owner.closed) abandoned.push(owner); + } finally { + owner.detach(); + } + } + workers.push(...abandoned); + // Do not erase gate state or remove homes until every owned worker has closed. + resetCodexCliSelectionGatesForTests(); + for (const root of roots.splice(0)) await removeTreeAfterBackgroundWork(root); + if (firstError !== undefined) throw firstError; +}); +describe("process-wide selection commit gate and cancellation fence", () => { + test("parent write is nonblocking busy; release restores admission", async () => { + const first = beginCodexCliSelectionParentWrite(); + expect(first.entered).toBe(true); + try { + const second = beginCodexCliSelectionParentWrite(); + try { expect(second.entered).toBe(false); } finally { second.exit(); } + const fence = createCodexCliUpdateFence(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), fence, deadlineMs: 0, + }); + try { + expect(window.tryEnter()).toBe(false); + expect(await window.waitEnter()).toBe(false); + } finally { window.exit(); } + } finally { first.exit(); } + const next = beginCodexCliSelectionParentWrite(); + try { expect(next.entered).toBe(true); } finally { next.exit(); } + }); + test("worker waitEnter wins the gate once the parent writer releases", async () => { + const writer = beginCodexCliSelectionParentWrite(); + expect(writer.entered).toBe(true); + const fence = createCodexCliUpdateFence(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), fence, + // The sleep seam releases the parent write mid-wait deterministically. + sleep: async () => { writer.exit(); }, + }); + try { + expect(await window.waitEnter()).toBe(true); + const blocked = beginCodexCliSelectionParentWrite(); + try { expect(blocked.entered).toBe(false); } finally { blocked.exit(); } + } finally { window.exit(); writer.exit(); } + }); + test("all callers share one gate, including aliased and different directory snapshots", () => { + const root = home(); + const selections = [snapshot(root), snapshot(join(root, ".")), snapshot(join(root, "other"))]; + const gate = codexCliUpdateSelectionGate(); + const window = createWorkerCommitWindow({ gate, fence: createCodexCliUpdateFence() }); + expect(window.tryEnter()).toBe(true); + try { + for (const selected of selections) { + // Gate intentionally takes no configDir: aliases cannot form a second lock. + expect(selected.configDir).toBeDefined(); + expect(codexCliUpdateSelectionGate()).toBe(gate); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + } + } finally { window.exit(); } + // This tests shared gate behavior, not filesystem alias resolution or callers. + }); + test("cancel CAS wins before commit and cannot be overwritten", () => { + const fence = createCodexCliUpdateFence(), cancelled = codexCliUpdateIsCancelled(fence); + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + expect(window.tryEnter()).toBe(true); + try { + expect(cancelled()).toBe(false); + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(cancelCodexCliUpdateFence(fence)).toBe(false); + expect(window.commit()).toBe(false); + expect(cancelled()).toBe(true); + } finally { window.exit(); } + }); + test("committed CAS wins; later cancellation is deferred, not reported aborted", () => { + const fence = createCodexCliUpdateFence(), cancelled = codexCliUpdateIsCancelled(fence); + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + expect(window.tryEnter()).toBe(true); + try { + expect(window.commit()).toBe(true); + expect(cancelCodexCliUpdateFence(fence)).toBe(false); + expect(cancelled()).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + } finally { window.exit(); } + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(true); } finally { writer.exit(); } + }); +}); +describe("actual Worker dispatch and parent responsiveness", () => { + test("parent HTTP health and stream advance while the Worker is synchronously blocked", async () => { + const f = fixture({ block: "before-reply" }); + let push!: (bytes: Uint8Array) => void; + let finishStream!: () => void; + const encoder = new TextEncoder(); + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/health") return new Response("healthy"); + return new Response(new ReadableStream({ + start(controller) { + push = bytes => controller.enqueue(bytes); + finishStream = () => controller.close(); + controller.enqueue(encoder.encode("first")); + }, + })); + }, + }); + const operation = runCodexCliUpdateApplyInWorker(planId, f.options); + // Install a rejection consumer immediately; finally still joins the same run. + void operation.catch(() => {}); + let reader: ReadableStreamDefaultReader | undefined; + const write = beginCodexCliSelectionParentWrite(); + expect(write.entered).toBe(true); + try { + await until(() => Atomics.load(f.owner.control, FIXTURE_ENTERED) === 1, "sync worker barrier"); + expect(Atomics.load(f.owner.control, FIXTURE_RELEASE)).toBe(0); + expect(await (await fetch(new URL("/health", server.url))).text()).toBe("healthy"); + reader = (await fetch(new URL("/stream", server.url))).body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("first"); + push(encoder.encode("second")); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("second"); + expect(Atomics.load(f.owner.control, FIXTURE_FINISHED)).toBe(0); + // General parent HTTP/stream work continues even with the selection gate busy. + finishStream(); + expect((await reader.read()).done).toBe(true); + } finally { + write.exit(); + await reader?.cancel(); + server.stop(true); + f.owner.release(); + } + expect(await operation).toMatchObject({ outcome: "refused", reason: "preparation_failed" }); + await joined(f.owner); + }); + test("done is not sufficient: result stays pending until Worker close", async () => { + const f = fixture({ block: "after-reply" }); + let settled = false; + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 1_000 }); + void operation.then(() => { settled = true; }, () => { settled = true; }); + try { + await until(() => f.owner.messages.some(m => (m as { type?: string }).type === "done"), "done message"); + await Promise.resolve(); + expect(settled).toBe(false); + expect(f.owner.closed).toBe(false); + } finally { f.owner.release(); } + expect(await operation).toMatchObject({ outcome: "refused", reason: "preparation_failed" }); + await joined(f.owner); + }); + test("unknown-child done without close becomes ambiguous and NEVER terminates worker", async () => { + const f = fixture({ reply: "ambiguous", block: "after-reply" }); + const initial = codexCliUpdateUnjoinedWorkerCount(); + const initialClosed = codexCliUpdateUnjoinedWorkerClosedTotal(); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 10 }); + try { + const result = await operation; + expect(result).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + expect(f.owner.closed).toBe(false); + expect(f.owner.messages).toContainEqual(expect.objectContaining({ + type: "done", result: expect.objectContaining({ + reason: "installer_state_unknown", leaseRetained: true, verificationPid: 424242, + }), + })); + expect(Atomics.load(f.owner.control, FIXTURE_RELEASE)).toBe(0); + expect(f.owner.terminateCalls).toBe(0); + expect(codexCliUpdateUnjoinedWorkerCount()).toBe(initial + 1); + expect(f.owner.runMessages).toHaveLength(1); + expect(cancelCodexCliUpdateWorkerJob(f.jobId)).toBe(false); + // The settled job is off the live ledger but its Worker is still tracked. + const diagnostics = codexCliUpdateWorkerDiagnostics(); + expect(diagnostics.gate).toBe("free"); + expect(diagnostics.liveJobs.some(job => job.jobId === f.jobId)).toBe(false); + expect(diagnostics.unjoinedWorkers).toBe(initial + 1); + } finally { f.owner.release(); } + await joined(f.owner); + await until(() => codexCliUpdateUnjoinedWorkerCount() === initial, "unjoined set removal on actual close"); + // Removed from the live set but kept as evidence: closed-after-unknown total. + await until(() => codexCliUpdateUnjoinedWorkerClosedTotal() === initialClosed + 1, + "closed-after-unknown diagnostic"); + }); + test("real AbortSignal listener and job cancellation reach the same preparing fence", async () => { + const f = fixture({ block: "before-reply" }); + const controller = new AbortController(); + let abortEvents = 0; + const onAbort = () => { abortEvents++; }; + controller.signal.addEventListener("abort", onAbort, { once: true }); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, signal: controller.signal }); + void operation.catch(() => {}); + try { + await until(() => Atomics.load(f.owner.control, FIXTURE_ENTERED) === 1, "cancellable worker"); + const run = f.owner.runMessages[0]!; + expect(controller.signal).toBeInstanceOf(AbortSignal); + controller.abort(); + expect(abortEvents).toBe(1); + expect(codexCliUpdateIsCancelled(run.fence)()).toBe(true); + expect(cancelCodexCliUpdateWorkerJob(f.jobId)).toBe(false); + const window = createWorkerCommitWindow({ gate: run.gate, fence: run.fence }); + try { expect(window.commit()).toBe(false); } finally { window.exit(); } + } finally { + controller.signal.removeEventListener("abort", onAbort); + f.owner.release(); + } + await operation; + await joined(f.owner); + }); + test("AbortSignal remains a real native-fetch signal, separate from synchronous SAB cancellation", async () => { + const fence = createCodexCliUpdateFence(), controller = new AbortController(); + const deps: Pick = { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), + }; + let requestSeen = false; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch() { requestSeen = true; return new Promise(() => {}); }, + }); + const request = fetch(server.url, { signal: deps.signal }); + // Consume rejection immediately; abort is intentionally issued after request admission. + const outcome = request.then(() => ({ rejected: false }), error => ({ rejected: true, error })); + try { + await until(() => requestSeen, "native fetch admission"); + expect(deps.signal).toBeInstanceOf(AbortSignal); + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(deps.isCancelled!()).toBe(true); + expect(deps.signal!.aborted).toBe(false); // SAB hook did not widen/replace the signal. + controller.abort(); + expect((await outcome).rejected).toBe(true); + expect(deps.signal!.aborted).toBe(true); + } finally { controller.abort(); server.stop(true); await outcome; } + }); +}); +describe("reply correlation, loss and run-once ownership", () => { + for (const correlation of ["stale-first", "duplicate"] as const) { + test(correlation + " cannot replace the first matching reply or replay apply", async () => { + const f = fixture({ correlation }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "refused", reason: "preparation_failed", leaseRetained: false, + }); + await joined(f.owner); + expect(Atomics.load(f.owner.control, FIXTURE_RUNS)).toBe(1); + }); + } + for (const mode of ["none", "die", "throw", "malformed", "error"] as const) { + test(mode + " after run delivery is ambiguous, never a safe preparation refusal", async () => { + const f = fixture({ reply: mode }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + expect(Atomics.load(f.owner.control, FIXTURE_RUNS)).toBe(1); + }); + } + for (const staleField of ["requestId", "jobId", "both"] as const) { + test("wrong " + staleField + " followed by close does not qualify as done", async () => { + const f = fixture({ correlation: "stale-only", staleField }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + }); + } + test("spawn failure before any run delivery is refused once", async () => { + let attempts = 0; + const result = await runCodexCliUpdateApplyInWorker(planId, { + jobId: "never-started", snapshot: snapshot(home()), + spawnWorkerFn() { attempts++; throw new Error("fixture-spawn-failed"); }, + }); + expect(result).toMatchObject({ outcome: "refused", reason: "preparation_failed", leaseRetained: false }); + expect(attempts).toBe(1); + }); + test("postMessage throwing AFTER possible delivery must not falsely claim not started", async () => { + const f = fixture({}); + const post = f.owner.worker.postMessage.bind(f.owner.worker); + Object.defineProperty(f.owner.worker, "postMessage", { + configurable: true, + value: (...args: Parameters) => { + Reflect.apply(post, f.owner.worker, args); + if ((args[0] as { type?: string }).type === "run") throw new Error("after-delivery-fixture-error"); + }, + }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + }); + test("plan loss rejects plan_unavailable without a retry", async () => { + const f = fixture({ reply: "none" }); + await expect(runCodexCliUpdatePlanInWorker(f.options)).rejects.toThrow("plan_unavailable"); + await joined(f.owner); + }); + test("production entry ignores duplicate/late RunMsg and emits one correlated reply", async () => { + const owner = ownWorker(new URL("../../src/codex/cli-update-worker.ts", import.meta.url)); + const run: RunMessage = { + type: "run", operation: "plan", requestId: "run-once-request", jobId: "run-once-job", + snapshot: null, gate: codexCliUpdateSelectionGate(), fence: createCodexCliUpdateFence(), + env: { CODEX_HOME: home(), OPENCODEX_HOME: home() }, + }; + // Null trusted snapshot fails closed and must never query a real provider. + owner.worker.postMessage(run); + owner.worker.postMessage({ ...run, requestId: "late-request", jobId: "late-job" }); + await until(() => owner.closed, "production worker close"); + const replies = owner.messages.filter(m => ["done", "error"].includes((m as { type: string }).type)); + expect(replies).toHaveLength(1); + expect(replies[0]).toMatchObject({ requestId: run.requestId, jobId: run.jobId }); + expect(owner.terminateCalls).toBe(0); + // Two input messages are intentional here. No host retry/install replay occurred. + }); +}); +describe("snapshot target does not fall back to ambient homes", () => { + test("explicit snapshot and worker-home override remain distinct", async () => { + const f = fixture({}), ambient = home(); + const original = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = ambient; + try { + const captured = f.options.snapshot; + expect(await runCodexCliUpdateApplyInWorker(planId, { + ...f.options, env: { OPENCODEX_HOME: ambient, CODEX_HOME: ambient }, + })).toMatchObject({ outcome: "refused" }); + await joined(f.owner); + expect(f.owner.runMessages[0]!.snapshot).toEqual(captured); + expect(f.owner.runMessages[0]!.snapshot!.configDir).toBe(f.configDir); + expect(f.owner.runMessages[0]!.env).toEqual({ OPENCODEX_HOME: ambient, CODEX_HOME: ambient }); + const deps = codexCliUpdateInspectionDepsFromSnapshot(captured); + expect(deps.configDir).toBe(f.configDir); + expect(deps.env?.CODEX_CLI_PATH).toBe(captured.codexCliPath!); + expect(deps.env?.HOME).toBe(captured.home!); + } finally { + if (original === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = original; + } + }); + test("null or missing manager-root proof fails closed without ambient backfill", () => { + const original = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home(); + try { + for (const captured of [null, { ...snapshot(home()), managerRoots: null }]) { + const deps = codexCliUpdateInspectionDepsFromSnapshot(captured); + expect(deps.env?.PATH).toBe(""); + expect(deps.configDir).toBe("."); + expect(deps.env?.CODEX_CLI_PATH).toBeUndefined(); + } + } finally { + if (original === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = original; + } + }); +}); + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 80b2ff1bc1..0bfa46500c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -3,6 +3,7 @@ "bridge-terminal-refusal.test.ts": "adapters", "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-worker.test.ts": "codex-integration", "responses-replay-metadata-principal.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", "responses-translated-replay-principal.test.ts": "responses", diff --git a/tests/helpers/cli-update-fixture-worker.ts b/tests/helpers/cli-update-fixture-worker.ts new file mode 100644 index 0000000000..3b52f4ca61 --- /dev/null +++ b/tests/helpers/cli-update-fixture-worker.ts @@ -0,0 +1,131 @@ +/** + * Draft fixture only. No provider, installer, filesystem, or production worker entry runs. + * Configure before the host's RunMsg via postMessage; Worker FIFO preserves this order. + * Every blocking fixture must be released through its SAB in the test's finally block. + */ +export const FIXTURE_ENTERED = 0; +export const FIXTURE_RELEASE = 1; +export const FIXTURE_RUNS = 2; +export const FIXTURE_REPLIES = 3; +export const FIXTURE_FINISHED = 4; +export const FIXTURE_COMMIT_RESULT = 5; +export const FIXTURE_CONTROL_WORDS = 6; + +export interface FixtureConfiguration { + type: "fixture-config"; + control: SharedArrayBuffer; + block?: "before-reply" | "after-reply"; + reply?: "refused" | "ambiguous" | "none" | "malformed" | "error" | "throw" | "die"; + correlation?: "matching" | "stale-first" | "stale-only" | "duplicate"; + staleField?: "requestId" | "jobId" | "both"; + /** Uses only the frozen 0/1/2 CAS protocol, not an invented production export. */ + commitFenceBeforeReply?: boolean; +} +interface RunMessage { + type: "run"; + requestId: string; + jobId: string; + operation: "plan" | "apply"; + planId?: string; + channel?: unknown; + snapshot: unknown; + fence: SharedArrayBuffer; + gate: SharedArrayBuffer; + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +} +type ControlMessage = FixtureConfiguration | { type: "fixture-close" }; +type WorkerScope = { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(value: unknown): void; + close(): void; +}; +const scope = globalThis as unknown as WorkerScope; + +if (typeof document === "undefined" && typeof scope.close === "function" + && typeof scope.postMessage === "function") { + let configuration: FixtureConfiguration | undefined; + let running = false; + const send = (view: Int32Array, value: unknown) => { + Atomics.add(view, FIXTURE_REPLIES, 1); + scope.postMessage(value); + }; + const barrier = (view: Int32Array) => { + Atomics.store(view, FIXTURE_ENTERED, 1); + Atomics.notify(view, FIXTURE_ENTERED); + while (Atomics.load(view, FIXTURE_RELEASE) === 0) { + Atomics.wait(view, FIXTURE_RELEASE, 0); + } + }; + scope.onmessage = event => { + const message = event.data; + if (message.type === "fixture-config") { + if (configuration || running) throw new Error("fixture configured twice"); + configuration = message; + return; + } + if (message.type === "fixture-close") { + scope.close(); + return; + } + if (message.type !== "run") return; + if (!configuration) throw new Error("fixture must be configured before run"); + const config = configuration, view = new Int32Array(config.control); + Atomics.add(view, FIXTURE_RUNS, 1); + if (running) return; // Match production run-once: never execute a late second run. + running = true; + try { + if (config.block === "before-reply") barrier(view); + if (config.commitFenceBeforeReply) { + // Commit cannot overwrite a parent cancellation that has already won. + const observed = Atomics.compareExchange(new Int32Array(message.fence), 0, 0, 2); + Atomics.store(view, FIXTURE_COMMIT_RESULT, observed); + } + if (config.reply === "throw") throw new Error("fixture-worker-error"); + if (config.reply === "die") return; + if (config.reply === "none") return; + const envelope = { + requestId: message.requestId, jobId: message.jobId, operation: message.operation, + }; + if (config.reply === "error") { + send(view, { type: "error", ...envelope, message: "fixture-error" }); + return; + } + if (config.reply === "malformed") { + send(view, { type: "done", ...envelope, result: { outcome: "invented" } }); + return; + } + // A plan test must supply a confirmed frozen plan payload after API handoff. + if (message.operation !== "apply") throw new Error("fixture plan payload not configured"); + const ambiguous = config.reply === "ambiguous"; + const result = { + outcome: ambiguous ? "ambiguous" : "refused", + reason: ambiguous ? "installer_state_unknown" : "preparation_failed", + planId: message.planId!, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: ambiguous, verificationPid: ambiguous ? 424242 : null, + cleanupErrors: [], + }; + if (config.correlation === "stale-first" || config.correlation === "stale-only") { + send(view, { type: "done", ...envelope, + requestId: config.staleField === "jobId" ? message.requestId : "stale-" + message.requestId, + jobId: config.staleField === "requestId" ? message.jobId : "stale-" + message.jobId, + result: { ...result, outcome: "applied", reason: "verified_target" } }); + } + if (config.correlation === "stale-only") return; + send(view, { type: "done", ...envelope, result }); + if (config.correlation === "duplicate") { + // Conflicting late duplicate must not replace the first correlated result. + send(view, { type: "done", ...envelope, + result: { ...result, outcome: "applied", reason: "verified_target" } }); + send(view, { type: "error", ...envelope, message: "late-duplicate-error" }); + } + if (config.block === "after-reply") barrier(view); + } finally { + Atomics.store(view, FIXTURE_FINISHED, 1); + Atomics.notify(view, FIXTURE_FINISHED); + // "none"/"die" deliberately close without replying; after-reply blocks until released. + scope.close(); + } + }; +} + From d352a8eda3cd7ec2de9af7c06077d47d9deea43c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 06:57:56 +0900 Subject: [PATCH 30/33] fix(codex): drain CLI update workers on Bun 1.4.4 Guard worker entry installation with Bun.isMainThread, remove message handlers for natural drain, and preserve explicit spawnWorker coverage. Add fresh-main import and real Bun child-drain regressions without forced worker termination. --- src/codex/cli-update-job-worker.ts | 8 ++--- src/codex/cli-update-worker.ts | 12 +++---- structure/codex-cli-update.md | 9 +++++ .../codex-cli-update-worker.test.ts | 34 ++++++++++++++++++- tests/helpers/cli-update-fixture-worker.ts | 29 ++++++++++++---- 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/codex/cli-update-job-worker.ts b/src/codex/cli-update-job-worker.ts index f19c01ac75..05de9e6cac 100644 --- a/src/codex/cli-update-job-worker.ts +++ b/src/codex/cli-update-job-worker.ts @@ -296,10 +296,10 @@ async function runInWorker( : trustedNodeLauncherContext()?.codexCliInspectionEnv ?? null; let worker: Worker; try { - worker = (options.spawnWorkerFn ?? spawnWorker)( - new URL("./cli-update-worker.ts", import.meta.url).href, - "cli-update-worker", - ); + const injectedSpawn = options.spawnWorkerFn; + worker = injectedSpawn === undefined || injectedSpawn === null + ? spawnWorker(new URL("./cli-update-worker.ts", import.meta.url).href, "cli-update-worker") + : injectedSpawn(new URL("./cli-update-worker.ts", import.meta.url).href, "cli-update-worker"); } catch { // Spawn threw before the run message existed: provably no job code ran. return { kind: "spawn_failed" }; diff --git a/src/codex/cli-update-worker.ts b/src/codex/cli-update-worker.ts index 7878d5b59c..aba494fa3c 100644 --- a/src/codex/cli-update-worker.ts +++ b/src/codex/cli-update-worker.ts @@ -88,7 +88,8 @@ const applyAbort = new AbortController(); let ran = false; // Guarded so the module can be imported directly by tests without a Worker host. -if (typeof self !== "undefined" && typeof (self as { onmessage?: unknown }) === "object") { +if (typeof Bun !== "undefined" && Bun.isMainThread === false + && typeof self !== "undefined" && typeof self.postMessage === "function") { self.onmessage = async (event: MessageEvent) => { const data = event.data; if (data && typeof data === "object" && !Array.isArray(data) @@ -133,11 +134,10 @@ if (typeof self !== "undefined" && typeof (self as { onmessage?: unknown }) === message: error instanceof Error ? error.message : "cli_update_worker_failed", } satisfies CodexCliUpdateWorkerReply); } finally { - // Close from inside the Worker so the thread begins exiting on its own; - // the parent requires this close AND the result before settling done. - try { - (self as unknown as { close?: () => void }).close?.(); - } catch { /* already closing */ } + // 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. + // The parent's close event is not proof that every OS descendant exited. + self.onmessage = null; } }; } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 3e33348682..20815f0dca 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -144,6 +144,15 @@ the lease reported retained and the still-live worker tracked rather than silent throw before the run message exists is the only `refused`/`preparation_failed` path, because no job 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 +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. + 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 diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts index 1c2bfa8fbb..4045fa19ba 100644 --- a/tests/codex-integration/codex-cli-update-worker.test.ts +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -7,7 +7,7 @@ * operations have their own regression cases elsewhere. */ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; @@ -246,6 +246,38 @@ describe("process-wide selection commit gate and cancellation fence", () => { }); }); describe("actual Worker dispatch and parent responsiveness", () => { + test("fresh main-thread entry and fixture imports preserve the message sentinel", async () => { + expect(Bun.isMainThread).toBe(true); + const scope = globalThis as unknown as { onmessage: unknown }; + const previous = scope.onmessage, sentinel = () => {}; + scope.onmessage = sentinel; + try { + for (const path of ["../../src/codex/cli-update-worker.ts", "../helpers/cli-update-fixture-worker.ts"]) { + const url = new URL(path, import.meta.url); + url.searchParams.set("fresh-main-import", crypto.randomUUID()); + await import(url.href); // Query defeats earlier static-import module caching. + expect(scope.onmessage).toBe(sentinel); + } + } finally { scope.onmessage = previous; } + }); + test("detached message handler keeps a real Bun child alive until observed exit", async () => { + const releasePath = join(home(), "release-child"); + const f = fixture({ reply: "ambiguous", childDrain: { releasePath } }); + const initial = codexCliUpdateUnjoinedWorkerCount(); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 10 }); + try { + expect(await operation).toMatchObject({ outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true }); + const started = f.owner.messages.find(m => (m as { type?: string }).type === "fixture-child-started") as { pid: number }; + expect(started?.pid).toBeGreaterThan(0); + expect(f.owner.closed).toBe(false); + expect(f.owner.terminateCalls).toBe(0); + expect(codexCliUpdateUnjoinedWorkerCount()).toBe(initial + 1); + expect(f.owner.messages.some(m => (m as { type?: string }).type === "fixture-child-exited")).toBe(false); + } finally { writeFileSync(releasePath, "release"); } + await joined(f.owner); + expect(f.owner.messages).toContainEqual(expect.objectContaining({ type: "fixture-child-exited", exitCode: 0 })); + await until(() => codexCliUpdateUnjoinedWorkerCount() === initial, "real child owner drained"); + }); test("parent HTTP health and stream advance while the Worker is synchronously blocked", async () => { const f = fixture({ block: "before-reply" }); let push!: (bytes: Uint8Array) => void; diff --git a/tests/helpers/cli-update-fixture-worker.ts b/tests/helpers/cli-update-fixture-worker.ts index 3b52f4ca61..9e361dd13c 100644 --- a/tests/helpers/cli-update-fixture-worker.ts +++ b/tests/helpers/cli-update-fixture-worker.ts @@ -1,5 +1,5 @@ /** - * Draft fixture only. No provider, installer, filesystem, or production worker entry runs. + * Test fixture only. No provider, installer, or production worker entry runs. * Configure before the host's RunMsg via postMessage; Worker FIFO preserves this order. * Every blocking fixture must be released through its SAB in the test's finally block. */ @@ -20,6 +20,8 @@ export interface FixtureConfiguration { staleField?: "requestId" | "jobId" | "both"; /** Uses only the frozen 0/1/2 CAS protocol, not an invented production export. */ commitFenceBeforeReply?: boolean; + /** Real Bun child waits for a test-owned release file, not a simulated SAB child. */ + childDrain?: { releasePath: string }; } interface RunMessage { type: "run"; @@ -37,12 +39,11 @@ type ControlMessage = FixtureConfiguration | { type: "fixture-close" }; type WorkerScope = { onmessage: ((event: MessageEvent) => void) | null; postMessage(value: unknown): void; - close(): void; }; const scope = globalThis as unknown as WorkerScope; -if (typeof document === "undefined" && typeof scope.close === "function" - && typeof scope.postMessage === "function") { +if (typeof Bun !== "undefined" && Bun.isMainThread === false + && typeof self !== "undefined" && typeof scope.postMessage === "function") { let configuration: FixtureConfiguration | undefined; let running = false; const send = (view: Int32Array, value: unknown) => { @@ -56,7 +57,7 @@ if (typeof document === "undefined" && typeof scope.close === "function" Atomics.wait(view, FIXTURE_RELEASE, 0); } }; - scope.onmessage = event => { + scope.onmessage = async event => { const message = event.data; if (message.type === "fixture-config") { if (configuration || running) throw new Error("fixture configured twice"); @@ -64,7 +65,7 @@ if (typeof document === "undefined" && typeof scope.close === "function" return; } if (message.type === "fixture-close") { - scope.close(); + scope.onmessage = null; return; } if (message.type !== "run") return; @@ -73,7 +74,17 @@ if (typeof document === "undefined" && typeof scope.close === "function" Atomics.add(view, FIXTURE_RUNS, 1); if (running) return; // Match production run-once: never execute a late second run. running = true; + let child: ReturnType | undefined; try { + if (config.childDrain) { + child = Bun.spawn([process.execPath, "--no-env-file", "--no-install", "-e", + 'const {existsSync}=await import("node:fs");console.log("ready");const timer=setInterval(()=>{if(existsSync(process.argv[1]))clearInterval(timer)},5);', + config.childDrain.releasePath], { stdin: "ignore", stdout: "pipe", stderr: "ignore" }); + const reader = (child.stdout as ReadableStream).getReader(); + const ready = await reader.read(); reader.releaseLock(); + if (new TextDecoder().decode(ready.value).trim() !== "ready") throw new Error("fixture child not ready"); + scope.postMessage({ type: "fixture-child-started", pid: child.pid }); + } if (config.block === "before-reply") barrier(view); if (config.commitFenceBeforeReply) { // Commit cannot overwrite a parent cancellation that has already won. @@ -124,7 +135,11 @@ if (typeof document === "undefined" && typeof scope.close === "function" Atomics.store(view, FIXTURE_FINISHED, 1); Atomics.notify(view, FIXTURE_FINISHED); // "none"/"die" deliberately close without replying; after-reply blocks until released. - scope.close(); + scope.onmessage = null; + if (child) { + const exitCode = await child.exited; + scope.postMessage({ type: "fixture-child-exited", pid: child.pid, exitCode }); + } } }; } From a8743e131e383a32e489e0fb9cbd497ed2c74b22 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 07:06:54 +0900 Subject: [PATCH 31/33] test(codex): prove worker imports preserve fresh main handler --- .../codex-cli-update-worker.test.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts index 4045fa19ba..ba4b970e1b 100644 --- a/tests/codex-integration/codex-cli-update-worker.test.ts +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -24,6 +24,8 @@ import { } from "../../src/codex/cli-update-selection-gate"; import { codexCliUpdateInspectionDepsFromSnapshot } from "../../src/codex/cli-update-inspection-context"; import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { spawnTestChild } from "../helpers/spawn-test-child"; +import { repoRoot } from "../helpers/repo-root"; import { FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, FIXTURE_CONTROL_WORDS, type FixtureConfiguration, @@ -247,18 +249,22 @@ describe("process-wide selection commit gate and cancellation fence", () => { }); describe("actual Worker dispatch and parent responsiveness", () => { test("fresh main-thread entry and fixture imports preserve the message sentinel", async () => { - expect(Bun.isMainThread).toBe(true); - const scope = globalThis as unknown as { onmessage: unknown }; - const previous = scope.onmessage, sentinel = () => {}; - scope.onmessage = sentinel; - try { - for (const path of ["../../src/codex/cli-update-worker.ts", "../helpers/cli-update-fixture-worker.ts"]) { - const url = new URL(path, import.meta.url); - url.searchParams.set("fresh-main-import", crypto.randomUUID()); - await import(url.href); // Query defeats earlier static-import module caching. - expect(scope.onmessage).toBe(sentinel); - } - } finally { scope.onmessage = previous; } + const urls = ["../../src/codex/cli-update-worker.ts", "../helpers/cli-update-fixture-worker.ts"] + .map(path => new URL(path, import.meta.url).href); + // A new main process has imported neither module before installing the sentinel; + // no query-string cache assumption or extra production export is needed. + const program = 'if(Bun.isMainThread!==true)throw new Error("not main");' + + 'const sentinel=()=>{};globalThis.onmessage=sentinel;let imports=0;' + + `try{for(const url of ${JSON.stringify(urls)}){await import(url);imports++;` + + 'if(globalThis.onmessage!==sentinel)throw new Error("main handler replaced");}' + + 'console.log(JSON.stringify({main:Bun.isMainThread,imports,sentinelPreserved:globalThis.onmessage===sentinel}));' + + '}finally{globalThis.onmessage=null;}'; + const result = await spawnTestChild([process.execPath, "--no-env-file", "--no-install", "-e", program], + { cwd: repoRoot(), env: process.env, deadlineMs: 2_500 }); + expect(result.timedOut, `${result.stdout}\n${result.stderr}`).toBe(false); + expect(result.reaped && result.pipesClosed).toBe(true); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ main: true, imports: 2, sentinelPreserved: true }); }); test("detached message handler keeps a real Bun child alive until observed exit", async () => { const releasePath = join(home(), "release-child"); From b52f5029a10db7e70724c25432bd9e6be75313e5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 07:13:43 +0900 Subject: [PATCH 32/33] test(codex): align route planning fixture with worker dispatch --- .../cli/cli-codex-cli-update-boundary.test.ts | 7 +++-- .../server/management-route-registry.test.ts | 28 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts index c8e0742e32..09a45813f6 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -81,14 +81,17 @@ describe("Codex CLI selection inspection boundary", () => { for (const key of Object.keys(env)) { if (["home", "node_path"].includes(key.toLowerCase())) delete env[key]; } + const startedAt = performance.now(); const result = spawnSync("node", [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "check", "--json"], { cwd: repoRoot(), encoding: "utf8", timeout: 15_000, windowsHide: true, env: { ...env, Home: "C:\\" + "h".repeat(8 * 1024), Node_Path: "C:\\" + "n".repeat(8 * 1024), OPENCODEX_HOME: root, OPENCODEX_BUN_PATH: process.execPath, CODEX_CLI_PATH: "C:\\fixture\\codex.exe", OCX_NO_DESKTOP_HANDOFF: "1" }, }); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); + const diagnostic = JSON.stringify({ elapsedMs: performance.now() - startedAt, status: result.status, + signal: result.signal, error: result.error?.message, stdout: result.stdout?.slice(-2_048), stderr: result.stderr?.slice(-2_048) }); + expect(result.error, diagnostic).toBeUndefined(); + expect(result.status, diagnostic).toBe(0); const report = JSON.parse(result.stdout); expect(report.candidateSource).toBe("environment"); expect(report.reason).toBe("windows_inspection_deferred"); diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index 7cbb18e271..3aac1d2f26 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -7,6 +7,7 @@ import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; import * as updateJobs from "../../src/codex/cli-update-job"; import * as cliUpdatePlanModule from "../../src/codex/cli-update-plan"; +import * as cliUpdateWorkerModule from "../../src/codex/cli-update-job-worker"; import type { ManagementContext } from "../../src/server/management/context"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; import { SIBLING_REFUSED_MANAGEMENT_PATHS, siblingRefusesManagementRequest } from "../../src/server/management/sibling-guard"; @@ -113,10 +114,11 @@ describe("management route registry reconciliation", () => { packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, evidence: ["package_manifest", "global_npm_layout"] }; const realPlan = cliUpdatePlanModule.createCodexCliUpdatePlan; - // Wrap the REAL planner so the route still runs the production resolver and async - // spawn path; only the install report, platform, and scan are fixed fixtures. - const planner = spyOn(cliUpdatePlanModule, "createCodexCliUpdatePlan").mockImplementation(deps => - realPlan({ ...deps, platform: "linux", inspectionDeps: { arch: "x64" }, inspect: async () => managed as never, + // The route dispatches into another realm: parent planner spies cannot reach it. + // This dispatch seam tests HTTP + real async metadata-child behavior only; + // cli-update-worker.test.ts separately exercises the actual Worker boundary. + const planner = spyOn(cliUpdateWorkerModule, "runCodexCliUpdatePlanInWorker").mockImplementation(() => + realPlan({ platform: "linux", inspectionDeps: { arch: "x64" }, inspect: async () => managed as never, spawnProcess: gatedSpawn, scanProcessesAsync: async () => ({ kind: "observed", processes: [] }) })); const oldHome = process.env.OPENCODEX_HOME; @@ -154,6 +156,7 @@ describe("management route registry reconciliation", () => { const response = await planPromise; expect(response?.status).toBe(200); expect(await response?.json()).toMatchObject({ applicable: true, targetVersion: "1.4.2" }); + expect(planner).toHaveBeenCalledTimes(1); } finally { planner.mockRestore(); if (oldHome === undefined) delete process.env.OPENCODEX_HOME; @@ -162,6 +165,23 @@ describe("management route registry reconciliation", () => { } }); + test("plan dispatch failure returns HTTP 503 without a second dispatch", async () => { + const dispatch = spyOn(cliUpdateWorkerModule, "runCodexCliUpdatePlanInWorker") + .mockRejectedValue(new Error("plan_unavailable")); + const req = new Request("http://localhost/api/codex/cli-update/plan"); + const ctx: ManagementContext = { req, url: new URL(req.url), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + try { + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ error: "plan_unavailable" }); + expect(dispatch).toHaveBeenCalledTimes(1); + } finally { dispatch.mockRestore(); } + }); + test("Codex CLI HTTP apply requires a GUI session and trusted loopback before admission", async () => { const config = { port: 10100, providers: {} } as import("../../src/types").OcxConfig; const request = new Request("http://localhost/api/codex/cli-update/apply", { From f6b238b6d8a8eb27ee59d55db47a7f61c8eb6051 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 09:48:54 +0900 Subject: [PATCH 33/33] test(codex): isolate selector fixtures from runtime memo expiry --- ...odex-convergence-account-selectors.test.ts | 6 ++ .../codex-convergence-contract.test.ts | 83 ++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 7b5286692c..1ef7916625 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -40,6 +40,7 @@ import type { OcxConfig } from "../../src/types"; import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; import { catalogEntryEfforts } from "../../src/codex/catalog/effort"; import { + loadPersistedCodexRuntime, resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests, } from "../../src/codex/runtime"; @@ -693,6 +694,9 @@ test("retained sync removes a deleted pre-marker custom row while discovery is d test("retained and convergence writers resolve, clear, reject, and recover auto-review selectors", async () => { primeCodexRuntimeFixture(); + const observedRuntime = loadPersistedCodexRuntime(); + expect(observedRuntime?.command === process.env.CODEX_CLI_PATH).toBe(true); + expect(observedRuntime?.selectedVersion).toBe("0.145.0"); for (const writer of ["retained", "convergence"] as const) { const write = async (nextConfig: OcxConfig): Promise => { @@ -703,6 +707,8 @@ test("retained and convergence writers resolve, clear, reject, and recover auto- const result = await syncCatalogModels(nextConfig); expect(result.catalogWritten).toBe(true); } else { + // Selector behavior needs the observed persisted runtime, not an incidental 15s process memo. + resetCodexRuntimeResolveCacheForTests(); const disposition = await convergeCatalogDisposition(nextConfig); expect(disposition).toMatchObject({ status: "committed" }); } diff --git a/tests/codex-integration/codex-convergence-contract.test.ts b/tests/codex-integration/codex-convergence-contract.test.ts index b7e9b69c9a..5d38840e69 100644 --- a/tests/codex-integration/codex-convergence-contract.test.ts +++ b/tests/codex-integration/codex-convergence-contract.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { existsSync, @@ -23,11 +23,15 @@ import { } from "../../src/codex/convergence"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; import { + codexRuntimeStateEpoch, + loadPersistedCodexRuntime, + peekCodexRuntimeProcessCache, persistCodexRuntime, resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests, } from "../../src/codex/runtime"; import { + bundledCatalogCacheState, invalidateBundledCatalogCache, setBundledCatalogCacheForTests, } from "../../src/codex/catalog/bundled"; @@ -210,6 +214,83 @@ test("used process-local authority drift rejects before every catalog target wri expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); }); +// No await or fake timers inside this scope: even a busy commit restores real time before yielding. +function atObservedNow(now: number, read: (restore: () => void) => T): T { + const clock = spyOn(Date, "now").mockReturnValue(now); + let restored = false; + const restore = () => { if (!restored) { restored = true; clock.mockRestore(); } }; + try { return read(restore); } finally { restore(); } +} + +function seedBoundaryRuntime(now: number) { + const runtime = { command: join(root, "observed-runtime"), version: "0.146.0", source: "environment" as const }; + persistCodexRuntime(runtime); + atObservedNow(now, () => { + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }); + setBundledCatalogCacheForTests(runtime, JSON.parse(sourceCatalog("bundled-boundary")) as never); + }); + expect(loadPersistedCodexRuntime()?.command === runtime.command).toBe(true); + expect(loadPersistedCodexRuntime()?.selectedVersion).toBe(runtime.version); + return runtime; +} + +test.each([14_999, 15_000])("adopted runtime memo at age %i preserves the convergence stale boundary", async age => { + const start = Date.now(); seedBoundaryRuntime(start); + const memo = atObservedNow(start + 14_999, () => peekCodexRuntimeProcessCache()); + expect(memo.kind).toBe("available"); + const epoch = codexRuntimeStateEpoch(), bundled = bundledCatalogCacheState(); + // The real gather selects its process memo synchronously before its first await. + const gathered = await atObservedNow(start + 14_999, () => candidate()); + expect(codexRuntimeStateEpoch()).toBe(epoch); + const before = manifest(codexHome); + let guardedInClockTurn = false; + // Zero deadline means one real synchronous acquisition attempt; busy is a failure, never a retry. + const result = await atObservedNow(start + age, restore => commitCodexCatalogCandidate(gathered, 0, { + beforeCommit: () => { guardedInClockTurn = Date.now() === start + age; restore(); return true; }, + })); + expect(codexRuntimeStateEpoch()).toBe(epoch); + expect(bundledCatalogCacheState()).toEqual(bundled); + if (age === 14_999) { + expect(guardedInClockTurn).toBe(true); + expect(result.kind).toBe("committed"); + } else { + expect(guardedInClockTurn).toBe(false); + expect(result).toEqual({ kind: "stale", reason: "process-local" }); + expect(manifest(codexHome)).toEqual(before); + } +}); + +test("persisted runtime authority commits across memo expiry after a pre-admission reset", async () => { + const start = Date.now(); seedBoundaryRuntime(start); + const bundled = bundledCatalogCacheState(); + resetCodexRuntimeResolveCacheForTests(); + expect(atObservedNow(start + 14_999, () => peekCodexRuntimeProcessCache()).kind).toBe("unavailable"); + const epoch = codexRuntimeStateEpoch(); + const gathered = await atObservedNow(start + 14_999, () => candidate()); + let guardedInClockTurn = false; + const result = await atObservedNow(start + 15_000, restore => commitCodexCatalogCandidate(gathered, 0, { + beforeCommit: () => { guardedInClockTurn = Date.now() === start + 15_000; restore(); return true; }, + })); + expect(guardedInClockTurn).toBe(true); + expect(result.kind).toBe("committed"); + expect(codexRuntimeStateEpoch()).toBe(epoch); + expect(bundledCatalogCacheState()).toEqual(bundled); +}); + +test("persisted runtime replacement after gather still refuses without catalog writes", async () => { + const start = Date.now(), runtime = seedBoundaryRuntime(start); + resetCodexRuntimeResolveCacheForTests(); + const gathered = await atObservedNow(start + 14_999, () => candidate()); + const before = manifest(codexHome); + persistCodexRuntime({ ...runtime, command: join(root, "replacement-runtime"), version: "0.147.0" }); + expect(loadPersistedCodexRuntime()?.selectedVersion).toBe("0.147.0"); + expect(await atObservedNow(start + 15_000, restore => commitCodexCatalogCandidate(gathered, 0, { + beforeCommit: () => { restore(); return true; }, + }))) + .toEqual({ kind: "stale", reason: "source-observation" }); + expect(manifest(codexHome)).toEqual(before); +}); + test("custom catalog runtime-support drift rejects before every catalog target write", async () => { const runtime = { command: "/tmp/codex", version: "0.146.0", source: "environment" as const }; const customPath = join(codexHome, "custom-catalog.json");