From f83256b274b02ec89d7ce64b8f2fb3331f9947cf Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 07:20:49 +0900 Subject: [PATCH 01/12] fix(codex): keep npm Node cache within inspection lifetime (cherry picked from commit c129c1e164a9b97030f127182d084befda2525c7) --- 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 b15986fad272cba7c8f1abd7716d7618d3fe2961 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 05:31:45 +0900 Subject: [PATCH 02/12] 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 34580d6352..3c87a1283e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,8 @@ { "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", "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 3364668183d785af8e563d8581ca29b11ae7f698 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 05:49:02 +0900 Subject: [PATCH 03/12] 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 286c200a57441afdff3a386e81d7482b05c336da Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 06:32:08 +0900 Subject: [PATCH 04/12] 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 707922941ced3e84f38e62a4a4d9ff039ba1bded Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 07:12:35 +0900 Subject: [PATCH 05/12] 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 72cb0febe0..a5f1f2114f 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 91fa51d1813bbd096dab6d78db52790bcf26dbdd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 07:52:19 +0900 Subject: [PATCH 06/12] 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 0b749aece5c4c6b278a562c2b42b842a3b149052 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:32:35 +0900 Subject: [PATCH 07/12] 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 8172f121c2aa30280d6071e084237f4974a1f4aa Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 08:52:27 +0900 Subject: [PATCH 08/12] 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 199a8f883e232fcb7d8614250b0791af16bd5a95 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:11:00 +0900 Subject: [PATCH 09/12] 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 4055fc1198ac9051ad36106f38ace79715bcf62f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:29:18 +0900 Subject: [PATCH 10/12] 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 acd1792c2f5835f3cb417e20831cbc0f75b03ceb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:39:44 +0900 Subject: [PATCH 11/12] 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 31c87b8a1f..0ce127767e 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1179,12 +1179,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 334d665abbb46e5072e8e32ec87d2e471920286c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 09:42:07 +0900 Subject: [PATCH 12/12] 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([]); });