diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6f3d29a138b..689ade8bdc6 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/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index d6ba1d76c35..77086aa31bd 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 501f13e7bfb..3c4316e5daa 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1188,12 +1188,44 @@ 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 exits 0 only for an `applied` job; `not_applied`, `refused`, `ambiguous` and pending duplicate jobs +exit 1, including with `--json`. Status reads the +same private job record without a running proxy or GUI token. Reuse the same optional request ID after +a lost response: the same key and plan return the original job; a changed plan with that key refuses. +The private ledger retains up to 64 jobs, including finished jobs, without automatic pruning. At that +limit, new request IDs are refused with `store_full`; existing matching retries still return their +original job. Capacity and operator recovery remain a separate follow-up that must preserve this +deduplication contract. A busy admission ledger reports `job_store_busy` (HTTP 503 with `Retry-After: 1`); +permanent admission failures have no retry-delay header. Missing or unreadable CLI +status returns `job_unavailable` and a nonzero exit. Completion persistence failures do not rerun the +installer or turn an uncertain job into a confirmed success. +An ambiguous result is never automatically retried or rolled back. After an owner restart, an unfinished +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/scripts/build-standalone.ts b/scripts/build-standalone.ts index 8f1f145429a..1c104a5c2c6 100644 --- a/scripts/build-standalone.ts +++ b/scripts/build-standalone.ts @@ -37,6 +37,7 @@ const WORKER_ENTRIES: Record = { "policy-worker": join(repoRoot, "src", "storage", "policy-worker.ts"), "restore-worker": join(repoRoot, "src", "storage", "restore-worker.ts"), "history-worker": join(repoRoot, "src", "codex", "history-worker.ts"), + "cli-update-worker": join(repoRoot, "src", "codex", "cli-update-worker.ts"), "request-history-rebuild-worker": join(repoRoot, "src", "routing", "history", "rebuild-worker.ts"), "windows-native-principal-worker": join(repoRoot, "src", "lib", "windows-native-principal-worker.ts"), }; diff --git a/scripts/ci/test-durations.tsv b/scripts/ci/test-durations.tsv index 7d97845ae3d..3ba1b2c3c83 100644 --- a/scripts/ci/test-durations.tsv +++ b/scripts/ci/test-durations.tsv @@ -1,7 +1,7 @@ # Per-file Bun test durations in milliseconds, read from hosted CI job logs. # Consumed by scripts/ci/run-bun-test-batches.sh to balance shards; a file without a row weighs the median. # Regenerate with scripts/ci/test-durations.ts (usage in its header); do not edit by hand. -# Source: run 38250055508 (dev dc45bb2ca9, lane=all), Linux test 1/4-4/4 and macos 1/3-3/3 +# Source: run 38250055508 (dev dc45bb2ca9, lane=all), Linux test 1/4-4/4 and macos 1/3-3/3; selected row tests/codex-integration/codex-cli-update-apply.test.ts from run 40084641010 @700dc1d8bfe3 job 120850286088 (test 2/4, Bun 1.4.4); other rows retained 271 tests/adapters/abort-race.test.ts 643 tests/adapters/adapter-buffered-tool-conformance.test.ts 270 tests/adapters/adapter-error-inline.test.ts @@ -693,6 +693,7 @@ 169 tests/codex-integration/codex-cli-install-provenance.test.ts 100 tests/codex-integration/codex-cli-installation-identity.test.ts 127 tests/codex-integration/codex-cli-installation-targets.test.ts +94979 tests/codex-integration/codex-cli-update-apply.test.ts 104 tests/codex-integration/codex-cli-update-launcher-policy.test.ts 1197 tests/codex-integration/codex-cli-update-zero-effect.test.ts 92 tests/codex-integration/codex-cli-windows-installation-files.test.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 51022965cf2..3e24647dec1 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,7 @@ { + "codex-cli-update-artifacts.test.ts": "codex-integration", + "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-worker.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", "codex-account-slot-identity.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 48362d5a839..2c6cab6bca0 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) @@ -1501,6 +1509,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 363 -- of those, state-changing: 222 +- declared capabilities: 365 +- of those, state-changing: 223 - 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 7407f5bb74d..d3cea04a9b8 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` @@ -392,12 +392,53 @@ 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. -- 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. | + +- Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session. +- Admission is stored under the protected current-user config root before the installer can run. +- Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json. +- Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused. + +### `ocx system codex-cli-update status` + +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. | + +- 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` Usage: `ocx system codex-restart --yes [--json]` diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 3bda7c1e295..3b91b4dc5df 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -602,14 +602,31 @@ 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.", - "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: ["Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session.", "Admission is stored under the protected current-user config root before the installer can run.", "Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], + usage: "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", + }, + { + 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: ["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]", + }, { 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 11bb7b95f46..fed27c06ea4 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"; @@ -13,30 +12,41 @@ import type { CodexCliInstallationTargetDerivation, } from "../codex/cli-installation-targets"; import { - createCodexCliUpdatePlan, type CodexCliUpdateChannel, type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, } from "../codex/cli-update-plan"; +import { runCodexCliUpdateApplyInWorker, runCodexCliUpdatePlanInWorker } from "../codex/cli-update-job-worker"; import { type ResolveCodexRuntimeDeps, type ResolveCodexRuntimeResult, } 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"; +import { CodexCliJobResultPersistenceError, normalizeCodexCliJobResult } from "../codex/cli-update-job-execution"; 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 [--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; requestId: string }> + | Readonly<{ action: "status"; json: boolean; jobId: string }> | Readonly<{ json: boolean; attest: CodexCliInstallationIdentityInput | "selected" }>; export interface CodexCliUpdateCommandDeps { + readonly readJob?: typeof readCodexCliUpdateJob; readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise; readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise; readonly deriveInstallationInput?: ( @@ -44,6 +54,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 +104,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 +172,44 @@ 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" && 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]!; + 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; + } + 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); + } + 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") { @@ -203,44 +250,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[], @@ -259,6 +268,11 @@ export async function handleCodexCliUpdateCommand( } // A refusal or an unapplied update is a legitimate answer rather than a crash, so the // outcome exit code is decided here and only a thrown error is left to runCliAction. + let resultCode = 0; + const unavailableJob = () => { + printData({ error: "job_unavailable" }, parsed.json, ["Codex CLI update job was not found or is unavailable."]); + resultCode = 1; + }; const code = await runCliAction(async () => { if ("attest" in parsed) { let report: CodexCliInstallationIdentityReport; @@ -291,19 +305,59 @@ 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") { + let job: ReturnType; + try { job = (deps.readJob ?? readCodexCliUpdateJob)(parsed.jobId); } + catch { unavailableJob(); return; } + if (!job) { unavailableJob(); return; } + printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, + `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); + return; + } if (parsed.action === "plan") { - const plan = await (deps.createPlan ?? createCodexCliUpdatePlan)({ + const plan = await (deps.createPlan ?? ((planDeps: CodexCliUpdatePlanDeps) => + runCodexCliUpdatePlanInWorker({ channel: planDeps.channel })))({ channel: parsed.channel, - inspectionDeps: inspectionDeps(deps), + inspectionDeps: codexCliUpdateInspectionDeps(deps), inspect: deps.inspectInstall, }); printData(plan, parsed.json, planSummary(plan)); return; } + if (parsed.action === "apply") { + const admitted = admitCodexCliUpdateJob({ requestId: parsed.requestId, planId: parsed.planId, confirmed: true }); + const apply = deps.applyUpdate + ?? ((planId: string) => runCodexCliUpdateApplyInWorker(planId, { jobId: admitted.job.jobId })); + let job: Awaited>; + try { + job = admitted.kind === "accepted" + ? await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => apply(parsed.planId, { inspectionDeps: codexCliUpdateInspectionDeps(deps) })) + : admitted.job; + } catch (error) { + if (!(error instanceof CodexCliJobResultPersistenceError)) throw error; + // Preserve the computed outcome without claiming durable completion or + // retrying work. Project fixed fields, never the raw result/error object. + const safe = normalizeCodexCliJobResult(error.result); + const receipt = { error: "job_result_persistence_failed", jobId: admitted.job.jobId, + outcome: safe.outcome, reason: safe.reason, leaseRetained: safe.leaseRetained, + cleanupErrors: safe.cleanupErrors }; + printData(receipt, parsed.json, [`job: ${receipt.jobId}`, + "persistence: unverified (job_result_persistence_failed)", `outcome: ${receipt.outcome}`, + `reason: ${receipt.reason}`, `lease-retained: ${receipt.leaseRetained}`, + `cleanup-errors: ${receipt.cleanupErrors.join(", ") || "none"}`]); + resultCode = 1; + return; + } + if (!job) { unavailableJob(); return; } + printData(job, parsed.json, [`job: ${job.jobId}`, `status: ${job.status}`, `outcome: ${job.outcome ?? "pending"}`, + `reason: ${job.reason ?? "none"}`, `owner-state: ${job.ownerState}`]); + resultCode = job.status === "applied" ? 0 : 1; + } }); - return code; + return code || resultCode; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 2544078424b..d4b90290831 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 c109e93d8bd..def126a39a3 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/app-server-processes.ts b/src/codex/app-server-processes.ts index b0757976d57..31b1664fc24 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -395,7 +395,6 @@ function listUnixProcSnapshots(uid: number | undefined, requireComplete = true): } function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { - const out: ProcessSnapshot[] = []; const commandOutput = uid !== undefined ? execFileSync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, @@ -410,6 +409,28 @@ function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { : execFileSync("/bin/ps", ["-axo", "pid=,comm="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, }); + return parseDarwinSnapshotOutputs(commandOutput, executableOutput, uid); +} + +/** + * Request-path twin of {@link listDarwinSnapshots}: the two `ps` children run off the + * event loop, so a stalled process-table read cannot park the proxy. Failure still + * propagates as a rejected read, matching the synchronous contract. + */ +async function listDarwinSnapshotsAsync(uid: number | undefined): Promise { + const [commandOutput, executableOutput] = await Promise.all([ + uid !== undefined + ? execFileTextAsync("/bin/ps", ["-u", String(uid), "-o", "pid=,command="], 5_000) + : execFileTextAsync("/bin/ps", ["-axo", "pid=,uid=,command="], 5_000), + uid !== undefined + ? execFileTextAsync("/bin/ps", ["-u", String(uid), "-o", "pid=,comm="], 5_000) + : execFileTextAsync("/bin/ps", ["-axo", "pid=,comm="], 5_000), + ]); + return parseDarwinSnapshotOutputs(commandOutput, executableOutput, uid); +} + +function parseDarwinSnapshotOutputs(commandOutput: string, executableOutput: string, uid: number | undefined): ProcessSnapshot[] { + const out: ProcessSnapshot[] = []; const executableByPid = new Map(); for (const raw of executableOutput.split(/\r?\n/)) { const match = /^\s*(\d+)\s+(.+)$/.exec(raw); @@ -552,6 +573,22 @@ function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | return listUnixProcSnapshots(getuid(), requireComplete); } +/** + * Request-path twin of {@link defaultListSnapshots}: the child-process enumerations + * (Windows PowerShell, darwin ps) wait off the event loop. Linux /proc reads are + * bounded filesystem I/O rather than a subprocess deadline, so they stay synchronous + * underneath without reintroducing a multi-second park. + */ +async function defaultListSnapshotsAsync( + platform: NodeJS.Platform, + getuid: () => number | undefined, + requireComplete = true, +): Promise { + if (platform === "win32") return listWindowsSnapshotsAsync(); + if (platform === "darwin") return listDarwinSnapshotsAsync(getuid()); + return listUnixProcSnapshots(getuid(), requireComplete); +} + export interface ListProcessSnapshotsOptions { platform?: NodeJS.Platform; getuid?: () => number | undefined; @@ -639,6 +676,43 @@ function scanProcessSnapshots( } catch { return Object.freeze({ kind: "unavailable" as const }); } + return matchProcessSnapshots(snapshots, matches); +} + +/** + * Request-path twin of {@link scanProcessSnapshots}: an injected async source, or the + * real enumeration's child processes, resolve off the event loop. The fail-closed + * contract is unchanged — an unreadable process table is still `unavailable`. + */ +async function scanProcessSnapshotsAsync( + io: CodexAppServerProcessIo, + matches: (commandLine: string, executable?: string) => boolean, +): Promise { + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + let snapshots: ProcessSnapshot[]; + try { + snapshots = io.listSnapshotsAsync + ? await io.listSnapshotsAsync() + : io.listSnapshots + ? io.listSnapshots() + : await defaultListSnapshotsAsync(platform, getuid); + } catch { + return Object.freeze({ kind: "unavailable" as const }); + } + return matchProcessSnapshots(snapshots, matches); +} + +function matchProcessSnapshots( + snapshots: readonly ProcessSnapshot[], + matches: (commandLine: string, executable?: string) => boolean, +): CodexAppServerProcessScan { const seen = new Set(); const matched: CodexAppServerProcess[] = []; for (const snapshot of snapshots) { @@ -671,6 +745,15 @@ export function scanCodexSessionProcesses(io: CodexAppServerProcessIo = {}): Cod return scanProcessSnapshots({ ...io, getuid: io.getuid ?? (() => undefined) }, isCodexSessionCommandLine); } +/** + * Async twin of {@link scanCodexSessionProcesses} for request paths: planning and the + * apply re-scan must not park the proxy event loop on a process-table child wait. + * Same broader read-only scope, same fail-closed contract. + */ +export function scanCodexSessionProcessesAsync(io: CodexAppServerProcessIo = {}): Promise { + return scanProcessSnapshotsAsync({ ...io, getuid: io.getuid ?? (() => undefined) }, isCodexSessionCommandLine); +} + export function formatStaleCodexAppServerWarning( processes: readonly { pid: number }[], ): string { diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index 81822401ec3..5ca1ba3388a 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 00000000000..dc6d46b789a --- /dev/null +++ b/src/codex/cli-update-apply.ts @@ -0,0 +1,342 @@ +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 { scanCodexSessionProcessesAsync } from "./app-server-processes"; +import { observeCodexCliFileSet, observeCodexCliPreimage, sameCodexCliPreimage, codexCliExpectedFiles, sameCodexCliFiles } from "./cli-update-files"; +import type { CodexCliUpdateCommitWindow } from "./cli-update-selection-gate"; + +export interface CodexCliApplyResult { + readonly outcome: "refused" | "applied" | "not_applied" | "ambiguous"; + 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; + 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: { release(): boolean } | null, 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 { + readonly signal?: AbortSignal; + /** + * Shared-fence cancellation checkpoint. Unlike signal.aborted this can flip + * DURING a synchronous section: the commit fence lives in shared memory the + * parent CASes, so a cancel landing mid-hash is seen at the next checkpoint + * rather than only on the next event-loop turn. + */ + readonly isCancelled?: () => boolean; + /** + * The worker's short selection commit window. When present, the final + * process scan and the synchronous guard block run while the shared gate is + * held, and commit()'s fence CAS is the last check before the installer + * spawn. Absent preserves the ungated in-process behavior tests rely on. + */ + readonly commitWindow?: CodexCliUpdateCommitWindow; + readonly temporaryRoot?: string; + readonly fetchArtifact?: (artifact: CodexCliArtifact, signal?: AbortSignal) => Promise; + readonly install?: (invocation: { bin: string; args: readonly string[]; cwd: string; env: NodeJS.ProcessEnv }) => Promise<{ terminal: boolean; exitCode: number | null }>; + 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"); + // A setup failure before any child exists is terminal evidence, not an + // unknown lifetime: return it so the caller's lease stays releasable. + try { + mkdirSync(home, { mode: 0o700 }); + } catch { + return Promise.resolve({ terminal: true, ok: false, pid: null }); + } + return new Promise(resolve => { + let terminal = false, failed = false, stopping = false, output = ""; + let deadline: ReturnType | undefined, reap: ReturnType | undefined; + // A synchronous spawn throw created no child, so it is the same terminal + // setup evidence; asynchronous spawn errors still arrive as error+close. + let child: ReturnType; + try { + child = spawn(command, ["--version"], { cwd, env: { ...env, CODEX_HOME: home, HOME: home, TMPDIR: home, + XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, XDG_DATA_HOME: home }, + stdio: ["ignore", "pipe", "ignore"], windowsHide: true, detached: process.platform !== "win32" }); + } catch { + resolve({ terminal: true, ok: false, pid: null }); + return; + } + const groupGone = () => { + if (process.platform === "win32" || !child.pid) return true; + try { process.kill(-child.pid, 0); return false; } + 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: { release(): boolean } | null = 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, cleanupErrors: [] }; + // Both cancellation channels are consulted at every checkpoint: the plain + // AbortSignal for in-process callers and the shared-fence isCancelled for a + // worker whose cancel lands mid-synchronous-section on the parent's loop. + const cancelled = (): boolean => deps.signal?.aborted === true || deps.isCancelled?.() === true; + try { + if (!/^[0-9a-f]{32}$/.test(planId)) return result("refused", "invalid_plan_id"); + if ((deps.platform ?? process.platform) === "win32") return result("refused", "windows_inspection_deferred"); + if (cancelled()) 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); + const acquired = (deps.acquireLease ?? acquireCodexCliApplyLease)(prefix); + if (acquired.kind === "prefix_unsupported") return result("refused", "installation_prefix_unsupported"); + if (acquired.kind === "busy") return result("refused", "installation_busy"); + if (acquired.kind !== "acquired") return result("refused", "installation_lease_unavailable"); + lease = acquired; + const freshPlan = () => createCodexCliUpdatePlan({ ...deps, inspectionDeps: freshInspection(deps) }); + const plan = await freshPlan(); + targetVersion = plan.targetVersion; + 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-")); + staging = realpathSync.native(staging); + 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); + paths.push(path); + } + if (cancelled()) return result("refused", "cancelled"); + const main = await verifyCodexCliArchive(paths[0]!, artifacts.main); + const backing = await verifyCodexCliArchive(paths[1]!, artifacts.backing); + const expectedContent = verifiedCodexCliContent(artifacts, main, backing); + 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; the awaited process scan is the last yield + // before install, and a synchronous identity/preimage/selection guard runs + // in the same event-loop turn as the spawn. + const finalPlan = await freshPlan(); + if (!finalPlan.applicable) return result("refused", finalPlan.refusal ?? "plan_refused"); + if (finalPlan.planId !== planId) return result("refused", "plan_changed"); + 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 (cancelled()) return result("refused", "cancelled"); + const isolation = createNpmConfigIsolation(staging, deps.inspectionDeps?.env); + const args = ["install", "-g", "--prefix", prefix, "--offline", "--ignore-scripts", "--omit=optional", + "--no-audit", "--no-fund", "--package-lock=false", "--update-notifier=false", + `${artifacts.main.installName}@file:${paths[0]}`, `${artifacts.backing.installName}@file:${paths[1]}`]; + const invocation = isolatedNpmTarget(args, isolation); + if (!invocation) return result("refused", "npm_unavailable"); + // The shared selection gate serializes this commit window against the + // parent's own selection writers and session-spawn paths, which a worker + // realm can no longer exclude just by occupying its own event-loop turn. + // waitEnter can yield while a parent writer holds the gate, so the final + // process observation is taken only after the window is held; a gate that + // stays busy or a cancel that lands first refuses before the spawn. + const commitWindow = deps.commitWindow; + let windowHeld = false; + if (commitWindow) { + windowHeld = commitWindow.tryEnter() || await commitWindow.waitEnter(); + if (!windowHeld) return result("refused", cancelled() ? "cancelled" : "installation_busy"); + } + try { + const lastScan = deps.scanProcessesAsync + ? await deps.scanProcessesAsync(deps.processIo ?? {}) + : deps.scanProcesses + ? deps.scanProcesses(deps.processIo ?? {}) + : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); + if (lastScan.kind !== "observed") return result("refused", "blocked_process_state_unknown"); + if (lastScan.processes.length) return result("refused", "blocked_active_session"); + // Every earlier await could have yielded the event loop to a mutation of + // the install tree, the prefix or the selected command, so the last turn + // before spawn re-verifies them synchronously: prefix identity, the + // byte-level preimage taken before the downloads, the launcher's live + // canonical selection and the signal. Nothing can interleave between + // this block and the install invocation on the same event-loop turn, + // and the held gate keeps the gated parent operations out of the window. + if (cancelled()) return result("refused", "cancelled"); + const lastPrefix = lstatSync(prefix); + if (realpathSync.native(prefix) !== prefix || lastPrefix.dev !== prefixIdentity.dev + || lastPrefix.ino !== prefixIdentity.ino + || !sameCodexCliPreimage(original, observeCodexCliPreimage(originalRoots)) + || !codexCliMutationSelectionMatches(evidence, freshInspection(deps))) { + return result("refused", "installation_changed"); + } + // The commit CAS is the final synchronous guard: a cancel already + // landed wins and no spawn happens; once COMMITTED, a later cancel is + // deferred to the live installer's close and readback. + if (commitWindow && !commitWindow.commit()) return result("refused", "cancelled"); + started = true; + keepLease = true; + // The install implementation contract requires the child spawn to start + // synchronously inside this call (runNpm's spawn runs in the Promise + // executor), so returning means the spawn already happened under the + // held gate and the window may close while the child runs. This is a + // property of these two callables, not of arbitrary async callbacks. + const installPromise = (deps.install ?? runNpm)({ bin: invocation.bin, args: invocation.args, + cwd: isolation.dir, env: isolation.env }); + if (commitWindow) { commitWindow.exit(); windowHeld = false; } + const installed = await installPromise; + exitCode = installed.exitCode; + if (!installed.terminal) return result("ambiguous", "installer_state_unknown"); + } finally { + if (windowHeld) commitWindow?.exit(); + } + 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"); + } + 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 + && sameCodexCliPreimage(original, observeCodexCliPreimage(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) { + const cleanup = cleanupCodexCliApply(lease, staging); + if (output) Object.assign(output, cleanup); + } + 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 new file mode 100644 index 00000000000..7fae08b6e5b --- /dev/null +++ b/src/codex/cli-update-artifacts.ts @@ -0,0 +1,245 @@ +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}` }; +} +/** Main-metadata guard + reference derivation shared by the sync and async set builders. */ +function codexCliArtifactMain(main: Metadata, platform: NodeJS.Platform, arch: string) { + const layout = codexCliPlatformBacking(platform, arch); + if (!layout || platform === "win32" || main.name !== "@openai/codex") throw new Error("artifact_platform_unverified"); + const mainArtifact = artifact(main, "@openai/codex"); + const reference = backingReference(main, layout.package); + return { layout, mainArtifact, reference }; +} +function codexCliArtifactFinish(mainArtifact: CodexCliArtifact, + reference: { name: string; version: string; spec: string }, + layout: { package: string }, backingMeta: Metadata, + platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { + const backing = artifact(backingMeta, layout.package); + if (backing.name !== reference.name || backing.version !== reference.version) throw new Error("backing_metadata_unverified"); + return Object.freeze({ platform, arch, main: mainArtifact, backing }); +} +export function codexCliArtifactSet(main: Metadata, query: (spec: string) => Metadata, + platform: NodeJS.Platform, arch: string): CodexCliArtifactSet { + // Main integrity is refused BEFORE the backing query is issued — the tarball query + // must never run on already-unverified metadata (integrity gate, 2 queries total). + const { layout, mainArtifact, reference } = codexCliArtifactMain(main, platform, arch); + return codexCliArtifactFinish(mainArtifact, reference, layout, query(reference.spec), platform, arch); +} +/** + * The request-path twin of {@link codexCliArtifactSet}: the backing query resolves off the + * event loop, so a waiting registry child cannot stall the proxy. The platform guard and + * main-artifact check still run before any backing query is issued. + */ +export async function codexCliArtifactSetAsync(main: Metadata, query: (spec: string) => Promise, + platform: NodeJS.Platform, arch: string): Promise { + const { layout, mainArtifact, reference } = codexCliArtifactMain(main, platform, arch); + return codexCliArtifactFinish(mainArtifact, reference, layout, await query(reference.spec), platform, arch); +} +export function codexCliArtifactSetDigest(set: CodexCliArtifactSet): string { + return createHash("sha256").update(JSON.stringify([set.platform, set.arch, + ...[set.main, set.backing].map(file => [file.name, file.installName, file.version, file.integrity, file.tarball])])).digest("hex"); +} + +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"); } + } + 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; + 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; + 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; + 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"); + } + verified = true; + } finally { + 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"); + 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 00000000000..de7bf446b02 --- /dev/null +++ b/src/codex/cli-update-files.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from "node:fs"; +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. + */ +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-inspection-context.ts b/src/codex/cli-update-inspection-context.ts new file mode 100644 index 00000000000..e325e43e49f --- /dev/null +++ b/src/codex/cli-update-inspection-context.ts @@ -0,0 +1,49 @@ +import type { CodexCliInstallProvenanceDeps } from "./cli-install-provenance"; +import { observeCodexCliSelectedCommand } from "./cli-install-provenance"; +import type { ResolveCodexRuntimeDeps, ResolveCodexRuntimeResult } from "./runtime"; +import { trustedNodeLauncherContext } from "../cli/launcher-context"; +import type { TrustedNodeLaunchContext } from "../cli/launcher-context"; + +/** + * The allowlisted, immutable launcher snapshot an update worker may receive. + * It is the proof-bound pre-dotenv capture validated and frozen by + * initializeNodeLauncherContext; a Worker realm never reads the parent's + * trustedContext, so the snapshot itself is the structured-clone payload. + */ +export type CodexCliUpdateInspectionSnapshot = TrustedNodeLaunchContext["codexCliInspectionEnv"]; + +/** + * Build the updater's production inspection context from one supplied + * launcher snapshot. The selected-command observation runs in the CALLING + * realm: a worker receives only the frozen snapshot and must derive its own + * fresh selector evidence rather than trust a parent's pre-computed value. + * Missing fields are never backfilled from the ambient environment; an absent + * or untrusted snapshot fails closed exactly like a direct Bun/source launch. + */ +export function codexCliUpdateInspectionDepsFromSnapshot( + snapshot: CodexCliUpdateInspectionSnapshot, + deps: { + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; + } = {}, +): CodexCliInstallProvenanceDeps { + const trusted = snapshot; + if (!trusted || trusted.managerRoots === null) return { env: { PATH: "" }, configDir: "." }; + const selectedCommand = observeCodexCliSelectedCommand({ + env: { CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, PATH: trusted.path ?? undefined, PATHEXT: trusted.pathExt ?? undefined }, + 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, + }; +} + +/** Build the updater's only production inspection context from the Node launcher's proof-bound snapshot. */ +export function codexCliUpdateInspectionDeps(deps: { + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; +} = {}): CodexCliInstallProvenanceDeps { + return codexCliUpdateInspectionDepsFromSnapshot(trustedNodeLauncherContext()?.codexCliInspectionEnv ?? null, deps); +} diff --git a/src/codex/cli-update-job-execution.ts b/src/codex/cli-update-job-execution.ts new file mode 100644 index 00000000000..e3ae63ed765 --- /dev/null +++ b/src/codex/cli-update-job-execution.ts @@ -0,0 +1,72 @@ +import type { CodexCliApplyResult } from "./cli-update-apply"; +import type { CodexCliUpdateJobDto } from "./cli-update-job"; + +export const CODEX_CLI_JOB_REASONS: ReadonlySet = new Set(["invalid_plan_id", "windows_inspection_deferred", "cancelled", "mutation_evidence_unverified", + "installation_prefix_changed", "installation_prefix_unsupported", "installation_lease_unavailable", "installation_busy", "plan_refused", "plan_changed", "target_artifacts_unverified", + "installation_changed", "npm_unavailable", "blocked_process_state_unknown", "blocked_active_session", + "installer_state_unknown", "version_process_state_unknown", "version_readback_unverified", "verified_target", + "original_install_unchanged", "installation_readback_unverified", "installer_or_readback_error", + "preparation_failed", "job_result_unknown", "not_managed", "installed_version_unverified", + "install_content_unverified", "target_unresolved", "already_current", "target_not_newer"]); +export const CODEX_CLI_JOB_CLEANUP_ERRORS: ReadonlySet = new Set(["lease_release_unverified", "lease_release_exception", "staging_cleanup_failed"]); + +export class CodexCliJobStoreBusyError extends Error { + constructor() { super("job_store_busy"); } +} +export class CodexCliJobResultPersistenceError extends Error { + constructor(readonly result: CodexCliApplyResult) { super("job_result_persistence_failed"); } +} +export function normalizeCodexCliJobResult(result: CodexCliApplyResult): CodexCliApplyResult { + const cleanup = Array.isArray(result.cleanupErrors) ? result.cleanupErrors : []; + const safe = CODEX_CLI_JOB_REASONS.has(result.reason) + && ["applied", "not_applied", "refused", "ambiguous"].includes(result.outcome) + && typeof result.leaseRetained === "boolean" && Array.isArray(result.cleanupErrors) + && cleanup.length <= 8 && cleanup.every(error => CODEX_CLI_JOB_CLEANUP_ERRORS.has(error)); + return safe ? result : { ...result, outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + cleanupErrors: cleanup.filter(error => CODEX_CLI_JOB_CLEANUP_ERRORS.has(error)).slice(0, 8) }; +} +export async function retryCodexCliJobStoreOperation(operation: () => T, + delay: (milliseconds: number) => Promise = ms => new Promise(resolve => setTimeout(resolve, ms)), + persistence = false): Promise { + for (let attempt = 0; ; attempt++) { + try { return operation(); } + catch (error) { + const transientIo = persistence && ["EIO", "ENOSPC", "EBUSY", "EAGAIN", "EINTR", "EMFILE", "ENFILE"] + .includes((error as NodeJS.ErrnoException)?.code ?? ""); + if (attempt === 7 || (!(error instanceof CodexCliJobStoreBusyError) && !transientIo)) throw error; + await delay(25); + } + } +} +interface JobWork { + begin(): number; + finish(revision: number, result: CodexCliApplyResult): CodexCliUpdateJobDto | null; + read(): CodexCliUpdateJobDto | null; + release(): void; +} +/** Execution and persistence are separate ownership phases. */ +export async function runCodexCliUpdateJobWork(run: () => Promise, work: JobWork): Promise { + let released = false; + try { + let revision: number; + try { revision = await retryCodexCliJobStoreOperation(() => work.begin()); } + catch { + work.release(); released = true; + try { return work.read(); } + catch (error) { + const code = error instanceof Error ? error.message : ""; + throw new Error(["unsafe_store", "invalid_store", "unsupported_store"].includes(code) ? code : "job_state_unavailable"); + } + } + let result: CodexCliApplyResult; + try { result = await run(); } + catch { + result = { outcome: "ambiguous", reason: "job_result_unknown", planId: "", + installedVersion: null, targetVersion: null, installerExitCode: null, leaseRetained: true, + verificationPid: null, cleanupErrors: [] }; + } + try { + return await retryCodexCliJobStoreOperation(() => work.finish(revision, result) ?? work.read(), undefined, true); + } catch { throw new CodexCliJobResultPersistenceError(result); } + } finally { if (!released) work.release(); } +} diff --git a/src/codex/cli-update-job-worker.ts b/src/codex/cli-update-job-worker.ts new file mode 100644 index 00000000000..05de9e6cacd --- /dev/null +++ b/src/codex/cli-update-job-worker.ts @@ -0,0 +1,378 @@ +/** + * Parent-side dispatch for Codex CLI update plan/apply workers. + * + * The HTTP route and CLI keep their existing contracts: admission and the + * generation-fenced durable ledger stay in this realm, the spawned Worker + * realm owns the heavy inspection, hashing and installer lifetime. A job is + * one fresh Worker plus one unique requestId plus the ledger generation; no + * wire generation is needed because the Worker runs once, ignores later + * messages and closes. + * + * Cancellation uses BOTH channels: the per-job shared fence's + * PREPARING -> CANCELLED compare-exchange (the only word a synchronous + * worker section can observe) and a "cancel" message that aborts the real + * AbortSignal handed to apply's fetchArtifact path. The message is posted + * only when the fence CAS won, so a cancel arriving after COMMITTED defers + * to the live installer's close exactly like the in-process contract. + * + * Settlement requires BOTH a valid reply AND the Worker's close. A reply + * alone is never terminal evidence: a legitimate result can still report + * installer_state_unknown or version_process_state_unknown with a retained + * lease and a live verification child, so done-without-close means the Worker's + * own exit could not be observed and the job resolves ambiguous with its lease + * state unknown. This host never calls terminate(): an unresponsive Worker may + * legitimately own a running npm child, and killing the thread would orphan + * the very lifetime the ledger must keep reporting as unknown. Workers that + * settle ambiguous while still alive are tracked in the unjoined set so their + * count is observable evidence rather than a silent leak. + */ +import { randomUUID } from "node:crypto"; +import type { CodexCliApplyResult } from "./cli-update-apply"; +import type { CodexCliUpdateChannel, CodexCliUpdatePlan } from "./cli-update-plan"; +import type { CodexCliUpdateInspectionSnapshot } from "./cli-update-inspection-context"; +import { trustedNodeLauncherContext } from "../cli/launcher-context"; +import { spawnWorker } from "../lib/worker-embed"; +import { + CODEX_CLI_UPDATE_GATE_FREE, + CODEX_CLI_UPDATE_GATE_PARENT_WRITE, + CODEX_CLI_UPDATE_GATE_WORKER_COMMIT, + cancelCodexCliUpdateFence, + codexCliUpdateSelectionGate, + createCodexCliUpdateFence, +} from "./cli-update-selection-gate"; +import type { CodexCliUpdateWorkerReply, CodexCliUpdateWorkerRunMessage } from "./cli-update-worker"; + +/** Grace for the Worker's close after a valid reply before the run goes ambiguous. */ +const WORKER_CLOSE_GRACE_MS = 10_000; + +interface LiveJobRecord { + /** Fence CAS plus async cancel delivery; false when the fence already moved. */ + readonly cancel: () => boolean; + readonly settled: () => boolean; + readonly workerClosed: () => boolean; +} + +const liveJobs = new Map(); +const unjoinedWorkers = new Set(); +let unjoinedWorkerClosedTotal = 0; + +/** + * Parent cancellation for one in-flight job: CASes that job's fence + * PREPARING -> CANCELLED and, only when that word was won, posts the async + * "cancel" message that aborts the worker's real AbortSignal. False when the + * job is unknown here or its fence already committed/cancelled — a lost CAS + * never reaches the worker, so a post-COMMITTED cancel stays deferred. + */ +export function cancelCodexCliUpdateWorkerJob(jobId: string): boolean { + return liveJobs.get(jobId)?.cancel() ?? false; +} + +/** Workers that settled ambiguous while still alive; evidence, not a leak counter. */ +export function codexCliUpdateUnjoinedWorkerCount(): number { + return unjoinedWorkers.size; +} + +/** + * Cumulative count of workers that settled without close evidence and were + * later observed closed — removed from the live set but retained as evidence. + */ +export function codexCliUpdateUnjoinedWorkerClosedTotal(): number { + return unjoinedWorkerClosedTotal; +} + +/** + * Operator-facing snapshot for diagnosing a held selection gate. The gate + * cell is one Int32 word with no owner field, so this reports CORRELATION + * evidence only: which update jobs are in flight here and whether each + * Worker's close was actually observed. A job listed beside a worker_commit + * gate is a candidate holder, never a proven one; an empty list beside a + * held gate means the holder is unknown to this process — possibly a dead + * Worker whose window never released (the gate is never auto-released). + */ +export interface CodexCliUpdateWorkerDiagnostics { + readonly gate: "free" | "parent_write" | "worker_commit" | "unrecognized"; + readonly liveJobs: readonly { + readonly jobId: string; + readonly settled: boolean; + readonly workerClosed: boolean; + }[]; + readonly unjoinedWorkers: number; + readonly unjoinedWorkerClosedTotal: number; +} + +export function codexCliUpdateWorkerDiagnostics(): CodexCliUpdateWorkerDiagnostics { + const raw = Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0); + const gate = raw === CODEX_CLI_UPDATE_GATE_FREE ? "free" + : raw === CODEX_CLI_UPDATE_GATE_PARENT_WRITE ? "parent_write" + : raw === CODEX_CLI_UPDATE_GATE_WORKER_COMMIT ? "worker_commit" + : "unrecognized"; + return { + gate, + liveJobs: [...liveJobs.entries()].map(([jobId, record]) => ({ + jobId, settled: record.settled(), workerClosed: record.workerClosed(), + })), + unjoinedWorkers: unjoinedWorkers.size, + unjoinedWorkerClosedTotal, + }; +} + +export interface CodexCliUpdateWorkerSpawnOptions { + /** Frozen launcher snapshot; default captures the trusted context at call time. */ + readonly snapshot?: CodexCliUpdateInspectionSnapshot; + /** Optional external abort: fires the same fence CAS as cancelCodexCliUpdateWorkerJob. */ + readonly signal?: AbortSignal; + /** Test seam: spawn a fixture Worker instead of the bundled entrypoint. */ + readonly spawnWorkerFn?: (devUrl: string, key: string) => Worker; + readonly closeGraceMs?: number; + readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; +} + +type DispatchSettlement = + | { readonly kind: "reply"; readonly reply: CodexCliUpdateWorkerReply } + | { readonly kind: "spawn_failed" } + | { readonly kind: "unsettled" }; + +const APPLY_OUTCOMES: ReadonlySet = new Set(["refused", "applied", "not_applied", "ambiguous"]); + +/** + * Reject a reply that names our ids but lacks its payload. Accepting a + * shapeless "done" would read success out of work that may not have happened; + * the ledger's downstream normalizer is a second clamp, not the only check. + */ +function isPlausibleWorkerReply( + data: unknown, + requestId: string, + jobId: string, + operation: "plan" | "apply", +): data is CodexCliUpdateWorkerReply { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + if (o.requestId !== requestId || o.jobId !== jobId) return false; + if (o.type === "error") return typeof o.message === "string"; + if (o.type !== "done" || o.operation !== operation) return false; + if (operation === "plan") { + return !!o.plan && typeof o.plan === "object" && !Array.isArray(o.plan); + } + const result = o.result; + return !!result && typeof result === "object" && !Array.isArray(result) + && APPLY_OUTCOMES.has((result as { outcome?: unknown }).outcome as string) + && typeof (result as { reason?: unknown }).reason === "string"; +} + +function trackUnjoined(worker: Worker, alreadyClosed: boolean): void { + if (unjoinedWorkers.has(worker)) return; + if (alreadyClosed) { + // The close is already evidenced; keep the diagnostic without pretending + // a dead Worker is a live leak. + unjoinedWorkerClosedTotal++; + warnIfCommitGateHeld(); + return; + } + unjoinedWorkers.add(worker); + worker.addEventListener("close", () => { + if (!unjoinedWorkers.delete(worker)) return; + unjoinedWorkerClosedTotal++; + warnIfCommitGateHeld(); + }, { once: true }); +} + +/** + * A Worker that dies while holding the commit gate leaves the cell at + * worker_commit forever — there is no auto-release by design. Report the + * correlation honestly: an observed close does not prove that Worker held it. + */ +function warnIfCommitGateHeld(): void { + if (Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0) + !== CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) return; + console.warn( + "[opencodex] a Codex CLI update worker closed while the shared selection gate still reads" + + " worker_commit; the holder is unknown and the gate is never auto-released — inspect" + + " codexCliUpdateWorkerDiagnostics() before treating a persist/shim/restart busy as stuck", + ); +} + +interface DispatchState { + settled: boolean; + closed: boolean; +} + +function dispatchWorker( + worker: Worker, + message: CodexCliUpdateWorkerRunMessage, + options: CodexCliUpdateWorkerSpawnOptions, + state: DispatchState, +): Promise { + return new Promise(resolve => { + let reply: CodexCliUpdateWorkerReply | undefined; + let grace: ReturnType | undefined; + const finish = (value: DispatchSettlement) => { + if (state.settled) return; + state.settled = true; + if (grace) clearTimeout(grace); + resolve(value); + }; + // Every non-reply terminal signal keeps the possibly-live Worker tracked: + // it can still own an installer child the ledger must report as unknown. + const unsettled = () => { + if (state.settled) return; + trackUnjoined(worker, state.closed); + finish({ kind: "unsettled" }); + }; + worker.onmessage = (event: MessageEvent) => { + // A settled or already-closed dispatch ignores late traffic entirely: + // a duplicate or post-close message must not re-arm the grace timer, + // attach listeners or re-add the Worker to the unjoined set. Once a + // valid reply is stored it is authoritative: a run-once Worker cannot + // legitimately send a second result, so a late "better" duplicate or a + // trailing error reply never replaces the first correlated payload. + if (state.settled || state.closed || reply !== undefined) return; + const data = event.data; + if (!data || typeof data !== "object" || Array.isArray(data)) { + unsettled(); + return; + } + const m = data as Record; + // A reply naming different ids belongs to someone else; ignoring it is + // not the same as accepting it. + if (m.requestId !== message.requestId || m.jobId !== message.jobId) return; + if (!isPlausibleWorkerReply(data, message.requestId, message.jobId, message.operation)) { + unsettled(); + return; + } + // done alone is not terminal evidence: the Worker's own close still has + // to be observed, and within the grace a late close still settles reply. + // The reply is stored once; the single close handler below decides. + reply = data as CodexCliUpdateWorkerReply; + grace = setTimeout(() => unsettled(), options.closeGraceMs ?? WORKER_CLOSE_GRACE_MS); + }; + worker.addEventListener("messageerror", () => unsettled()); + worker.addEventListener("close", () => { + state.closed = true; + if (grace) clearTimeout(grace); + // One close handler decides everything: a stored valid reply wins, any + // other close — before a reply, or after only implausible traffic — is + // unknown work, not a clean refusal. + if (reply !== undefined) finish({ kind: "reply", reply }); + else unsettled(); + }, { once: true }); + worker.onerror = () => unsettled(); + try { + worker.postMessage(message); + } catch { + // A synchronous post failure leaves delivery uncertain: the thread is + // alive and may (or may not) have received the run. Unknown work must + // never be downgraded to a provable pre-run refusal. + unsettled(); + } + }); +} + +function applyFallback(planId: string, settled: DispatchSettlement): CodexCliApplyResult { + return Object.freeze( + settled.kind === "spawn_failed" + ? { + outcome: "refused" as const, reason: "preparation_failed", planId, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: false, verificationPid: null, cleanupErrors: Object.freeze([]), + } + : { + outcome: "ambiguous" as const, reason: "job_result_unknown", planId, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: true, verificationPid: null, cleanupErrors: Object.freeze([]), + }, + ); +} + +async function runInWorker( + operation: "plan" | "apply", + fields: { readonly planId?: string; readonly channel?: CodexCliUpdateChannel }, + options: CodexCliUpdateWorkerSpawnOptions & { readonly jobId?: string }, +): Promise { + const jobId = options.jobId ?? randomUUID(); + const requestId = randomUUID(); + const fence = createCodexCliUpdateFence(); + const snapshot = options.snapshot !== undefined + ? options.snapshot + : trustedNodeLauncherContext()?.codexCliInspectionEnv ?? null; + let worker: Worker; + try { + const injectedSpawn = options.spawnWorkerFn; + worker = injectedSpawn === undefined || injectedSpawn === null + ? spawnWorker(new URL("./cli-update-worker.ts", import.meta.url).href, "cli-update-worker") + : injectedSpawn(new URL("./cli-update-worker.ts", import.meta.url).href, "cli-update-worker"); + } catch { + // Spawn threw before the run message existed: provably no job code ran. + return { kind: "spawn_failed" }; + } + const state: DispatchState = { settled: false, closed: false }; + const cancel = () => { + if (!cancelCodexCliUpdateFence(fence)) return false; + // Async cancel delivery only rides a won CAS: the worker's real + // AbortSignal aborts its in-flight fetchArtifact download, while the + // fence remains the synchronous-section channel. A post-COMMITTED cancel + // loses the CAS and is never posted, so the live child keeps its close. + try { worker.postMessage({ type: "cancel", requestId, jobId }); } catch { /* already closing */ } + return true; + }; + liveJobs.set(jobId, { + cancel, + settled: () => state.settled, + workerClosed: () => state.closed, + }); + const signal = options.signal; + const onAbort = () => { cancel(); }; + try { + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + const message: CodexCliUpdateWorkerRunMessage = { + type: "run", + requestId, + jobId, + operation, + ...(fields.planId !== undefined ? { planId: fields.planId } : {}), + ...(fields.channel !== undefined ? { channel: fields.channel } : {}), + snapshot, + gate: codexCliUpdateSelectionGate(), + fence, + ...(options.env ? { env: options.env } : {}), + }; + return await dispatchWorker(worker, message, options, state); + } finally { + liveJobs.delete(jobId); + if (signal) signal.removeEventListener("abort", onAbort); + } +} + +/** + * Run one confirmed apply in a fresh Worker. Results are the worker realm's + * own CodexCliApplyResult verbatim; dispatch failures synthesize the two + * honest fallbacks (refused preparation_failed only when no job code provably + * ran, otherwise ambiguous job_result_unknown with the lease unknown). + */ +export async function runCodexCliUpdateApplyInWorker( + planId: string, + options: CodexCliUpdateWorkerSpawnOptions & { readonly jobId?: string } = {}, +): Promise { + const settled = await runInWorker("apply", { planId }, options); + if (settled.kind === "reply" && settled.reply.type === "done" && settled.reply.operation === "apply") { + return settled.reply.result; + } + return applyFallback(planId, settled); +} + +/** + * Run one read-only plan in a fresh Worker. There is no ledger for a plan, so + * a dispatch failure surfaces exactly like today's thrown inspection error: + * the route translates it into plan_unavailable. + */ +export async function runCodexCliUpdatePlanInWorker( + options: CodexCliUpdateWorkerSpawnOptions & { readonly channel?: CodexCliUpdateChannel } = {}, +): Promise { + const settled = await runInWorker("plan", { channel: options.channel ?? "latest" }, options); + if (settled.kind === "reply" && settled.reply.type === "done" && settled.reply.operation === "plan") { + return settled.reply.plan; + } + throw new Error("plan_unavailable"); +} diff --git a/src/codex/cli-update-job.ts b/src/codex/cli-update-job.ts new file mode 100644 index 00000000000..0e8d1821c6f --- /dev/null +++ b/src/codex/cli-update-job.ts @@ -0,0 +1,332 @@ +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 { dirname, join, resolve } from "node:path"; +import { getConfigDir } from "../config"; +import type { CodexCliApplyResult } from "./cli-update-apply"; +import { CODEX_CLI_JOB_REASONS as SAFE_REASONS, CODEX_CLI_JOB_CLEANUP_ERRORS, + CodexCliJobStoreBusyError, normalizeCodexCliJobResult, runCodexCliUpdateJobWork } from "./cli-update-job-execution"; + +const MAX_BYTES = 256 * 1024; +const MAX_JOBS = 64; +const JOB_DIR = "codex-cli-update-jobs"; +const FILE = "jobs.json"; +const LOCK = "admission.lock"; +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 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[]; +} + +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 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 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) { + 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) => typeof item !== "string" || !CODEX_CLI_JOB_CLEANUP_ERRORS.has(item)) + || typeof job.leaseRetained !== "boolean" + || typeof job.createdAt !== "string" || typeof job.updatedAt !== "string")) throw new Error("invalid_store"); + return value; + } finally { if (fd !== undefined) closeSync(fd); } +} +function rollbackInitializingLock(p: ReturnType, lockIdentity: { dev: number; ino: number }, ownerIdentity?: { dev: number; ino: number }) { + try { + const ownsDirectory = () => { + const stat = lstatSync(p.lock); + return stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === lockIdentity.dev && stat.ino === lockIdentity.ino + && stat.uid === process.geteuid?.() && (stat.mode & 0o077) === 0 && realpathSync.native(p.lock) === p.lock; + }; + if (!ownsDirectory()) return; + if (ownerIdentity) { + const owner = join(p.lock, "owner.json"); + const fd = openSync(owner, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd), lexical = lstatSync(owner); + if (!stat.isFile() || stat.uid !== process.geteuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 + || stat.dev !== ownerIdentity.dev || stat.ino !== ownerIdentity.ino || lexical.isSymbolicLink() + || lexical.dev !== stat.dev || lexical.ino !== stat.ino || !ownsDirectory()) return; + } finally { closeSync(fd); } + unlinkSync(owner); + } + // This call created the directory. Never remove a replacement or nonempty lock. + if (ownsDirectory()) rmdirSync(p.lock); + } catch { /* Unprovable initialization ownership stays fail-closed. */ } +} +function withLock(configDir: string | undefined, fn: (p: ReturnType) => T, createConfigRoot = false): T { + const p = prepare(configDir, true, createConfigRoot); + let lockIdentity: { dev: number; ino: number } | undefined; + let ownerIdentity: { dev: number; ino: number } | undefined; + const owner = join(p.lock, "owner.json"); + const record = JSON.stringify({ version: 1, token: randomUUID() }); + try { + try { mkdirSync(p.lock, { mode: 0o700 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new CodexCliJobStoreBusyError(); + throw error; + } + 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 }; + try { + 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); } + } catch (error) { + rollbackInitializingLock(p, lockIdentity, ownerIdentity); + lockIdentity = undefined; + throw error; + } + 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; + } +} +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 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, + 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; confirmed: true; configDir?: string }): + { kind: "accepted" | "duplicate"; job: CodexCliUpdateJobDto; generation: string } { + 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 => { + 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 }; + }, true); +} + +/** 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]!; + const safe = normalizeCodexCliJobResult(result); + // A rename can succeed before a directory-fsync error. Retry the same + // completed revision while this generation still owns it, never the engine. + if (current.generation === generation && current.revision === expectedRevision + 1 + && live.get(jobId) === generation && started.has(jobId) + && current.outcome === safe.outcome && current.reason === safe.reason + && current.leaseRetained === safe.leaseRetained + && JSON.stringify(current.cleanupErrors) === JSON.stringify(safe.cleanupErrors)) { + writeStore(p, store); live.delete(jobId); started.delete(jobId); return dto(current); + } + if (current.generation !== generation || current.revision !== expectedRevision + || live.get(jobId) !== generation || !started.has(jobId)) return dto(current); + const next: StoredJob = { ...current, revision: current.revision + 1, status: safe.outcome, + outcome: safe.outcome, reason: safe.reason, cleanupErrors: safe.cleanupErrors, leaseRetained: safe.leaseRetained, updatedAt: now() }; + store.jobs[index] = next; writeStore(p, store); live.delete(jobId); started.delete(jobId); + return dto(next); + }); +} + +export async function runCodexCliUpdateJob(jobId: string, generation: string, run: () => Promise, configDir?: string): Promise { + let ownsExecution = false; + return runCodexCliUpdateJobWork(run, { + begin: () => withLock(configDir, p => { + const store = validStore(configDir); const index = store.jobs.findIndex(job => job.id === jobId); + if (index < 0 || store.jobs[index]!.generation !== generation) throw new Error("job_generation_changed"); + if (live.get(jobId) !== generation || started.has(jobId)) throw new Error("job_not_owned"); + const current = store.jobs[index]!; const next = { ...current, revision: current.revision + 1, status: "running" as const, updatedAt: now() }; + store.jobs[index] = next; writeStore(p, store); started.add(jobId); ownsExecution = true; return next.revision; + }), + finish: (revision, result) => finishCodexCliUpdateJob(jobId, generation, revision, result, configDir), + read: () => readCodexCliUpdateJob(jobId, configDir), + release: () => { + if (live.get(jobId) === generation && (ownsExecution || !started.has(jobId))) { + live.delete(jobId); if (ownsExecution) started.delete(jobId); + } + }, + }); +} diff --git a/src/codex/cli-update-lease.ts b/src/codex/cli-update-lease.ts new file mode 100644 index 00000000000..90de2d5e705 --- /dev/null +++ b/src/codex/cli-update-lease.ts @@ -0,0 +1,68 @@ +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"; + +export type CodexCliApplyLeaseResult = { readonly kind: "acquired"; release(): boolean } + | { readonly kind: "busy" | "prefix_unsupported" | "unavailable" }; +/** Installation-unit mutex. No stale/empty-owner recovery and no token delegation. */ +export function acquireCodexCliApplyLease(prefix: string): CodexCliApplyLeaseResult { + 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; + let released = false; + 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; + released = true; + } catch { return false; } + finally { + if (current !== undefined) { try { closeSync(current); } catch { released = false; } } + } + return released; + }; + try { + const root = lstatSync(prefix); + if (!root.isDirectory() || root.isSymbolicLink() || realpathSync.native(prefix) !== prefix + || (root.mode & 0o022) !== 0 || root.uid !== process.geteuid?.()) return { kind: "prefix_unsupported" }; + try { mkdirSync(path, { mode: 0o700 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return { kind: "busy" }; + throw error; + } + lockIdentity = lstatSync(path); + fd = openSync(owner, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + ownerIdentity = fstatSync(fd); + writeFileSync(fd, record); + const completed = fd; + fd = undefined; + closeSync(completed); + return { kind: "acquired", release }; + } catch { + if (fd !== undefined) { + const owned = fd; fd = undefined; + try { closeSync(owned); } catch { return { kind: "unavailable" }; } + } + // Only a positively identified owner is eligible for cleanup. Empty or + // suspicious directories intentionally remain busy for operator inspection. + if (ownerIdentity) release(); + return { kind: "unavailable" }; + } +} diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index d3c4835b749..3ba99bcfe9c 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -1,4 +1,4 @@ -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { execFile, type ExecFileException } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -7,6 +7,8 @@ import { join, resolve } from "node:path"; import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import { npmInvocation } from "../update/npm-invocation.mjs"; import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch-policy.mjs"; +import { CODEX_CLI_REGISTRY, isSha512Integrity, codexCliArtifactSetAsync, codexCliArtifactSetDigest, type CodexCliArtifactSet } from "./cli-update-artifacts"; +export { CODEX_CLI_REGISTRY } from "./cli-update-artifacts"; import { inspectCodexCliInstall, type CodexCliInstallKind, @@ -14,7 +16,7 @@ import { type CodexCliInstallReport, } from "./cli-install-provenance"; import { - scanCodexSessionProcesses, + scanCodexSessionProcessesAsync, type CodexAppServerProcessIo, type CodexAppServerProcessScan, } from "./app-server-processes"; @@ -43,18 +45,6 @@ import { export const CODEX_CLI_PACKAGE = "@openai/codex"; export const CODEX_CLI_UPDATE_SCHEMA_VERSION = 1 as const; -/** - * The one registry this workflow is allowed to resolve, pack and install from. - * - * npm view/pack inherit the operator's npm configuration by default, so a project or - * user .npmrc — or an npm_config_* env var — can redirect "@openai/codex" to another - * registry whose version query, integrity query and tarball all agree with each - * other. Every npm call below pins this registry explicitly AND runs with the - * registry-affecting configuration isolated, so the pinned origin is the only source - * the evidence can come from. - */ -export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org"; - /** Only the stable channel is exposed; a moving dist-tag is resolved before it is used. */ export type CodexCliUpdateChannel = "latest"; @@ -83,7 +73,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 +91,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 @@ -119,25 +110,17 @@ export interface CodexCliUpdatePlanDeps { readonly platform?: NodeJS.Platform; readonly channel?: CodexCliUpdateChannel; readonly scanProcesses?: (io?: CodexAppServerProcessIo) => CodexAppServerProcessScan; + /** Preferred request-path seam: the real session scan runs its child processes async. */ + readonly scanProcessesAsync?: (io?: CodexAppServerProcessIo) => Promise; readonly processIo?: CodexAppServerProcessIo; - readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget; - readonly spawnProcess?: typeof spawnSync; + readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget | Promise; + readonly spawnProcess?: CodexCliUpdateSpawn; } const PLAN_ID_LENGTH = 32; const PLAN_ID_RE = /^[0-9a-f]{32}$/; const REGISTRY_TIMEOUT_MS = 12_000; -const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/; - -function isSha512Integrity(token: string): boolean { - if (!SHA512_INTEGRITY_RE.test(token)) return false; - const encoded = token.slice("sha512-".length); - // Base64 decoders accept truncated input and nonzero unused padding bits. - // Require exactly 64 bytes and the canonical encoding of those same bytes. - const digest = Buffer.from(encoded, "base64"); - return digest.byteLength === 64 && digest.toString("base64") === encoded; -} /** The install argv this plan quotes for the operator to read, in argv form. */ export function codexCliUpdateCommand(version: string): readonly string[] { @@ -170,6 +153,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 +171,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 +302,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 { @@ -330,6 +315,7 @@ function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): N 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) { @@ -351,7 +337,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 { @@ -361,6 +347,96 @@ function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolatio }; } +/** The option fields one registry-metadata query passes to its spawn. */ +export interface CodexCliUpdateSpawnOptions { + readonly encoding: "utf8"; + readonly timeout: number; + readonly maxBuffer?: number; + readonly windowsHide: true; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly windowsVerbatimArguments?: boolean; +} + +/** The fields a resolver reads off one spawn result; sync injections may carry more. */ +export interface CodexCliUpdateSpawnResult { + readonly status: number | null; + readonly stdout: string; +} + +/** + * One registry-metadata invocation. The production runner is asynchronous so a slow + * npm cannot park the proxy event loop; a synchronous SpawnSync-shaped injection + * remains assignable, so tests asserting argv/env without real I/O are unchanged. + */ +export type CodexCliUpdateSpawn = ( + bin: string, + args: readonly string[], + options: CodexCliUpdateSpawnOptions, +) => CodexCliUpdateSpawnResult | Promise; + +/** + * A single bounded registry child whose wait happens off the event loop. + * + * The previous spawnSync blocked the whole proxy for up to the per-query deadline: + * a slow registry stalled unrelated requests and streaming for seconds at a time, + * and the background apply repeated the same stall. The observable contract is + * unchanged — status null on timeout or spawn failure, stdout bounded by + * maxBuffer — but only the awaited caller observes the child. + */ +export function spawnCodexCliUpdateMetadata( + bin: string, + args: readonly string[], + options: CodexCliUpdateSpawnOptions, +): Promise { + return new Promise(resolve => { + let timedOut = false; + let deadline: ReturnType | undefined; + let child: ReturnType; + try { + // execFile's callback is the only terminal observation: it runs after the + // child terminated and its piped stdio was fully consumed, so this result — + // and the caller's isolation cleanup in its finally — never outrun a live + // child, and no arbitrary reap window can fake completion. encoding "utf8" + // decodes the buffered output once, so a multibyte character split across + // OS pipe chunks stays intact, while maxBuffer bounds both captured + // streams in bytes exactly like spawnSync did. This proves only the + // child's own termination; any descendants it left behind are outside the + // observation, as they were under spawnSync. + child = execFile(bin, [...args], { + encoding: "utf8", + timeout: options.timeout, + maxBuffer: options.maxBuffer ?? 1024 * 1024, + killSignal: "SIGKILL", + windowsHide: true, + cwd: options.cwd, + env: options.env, + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + }, (error: ExecFileException | null, stdout: string) => { + if (deadline) clearTimeout(deadline); + // spawnSync reported status null whenever the child was signal-killed + // (timeout or maxBuffer) or never spawned; a numeric error.code is the + // plain exit code. timedOut is latched so a late code-0 close after the + // deadline still refuses, matching the synchronous timeout lane. + const status = timedOut ? null + : error === null ? 0 + : typeof error.code === "number" ? error.code : null; + resolve(Object.freeze({ status, stdout })); + }); + } catch { + resolve(Object.freeze({ status: null, stdout: "" })); + return; + } + // A deadline of our own keeps the same per-query bound even on a runtime + // that does not honor execFile's timeout option. It only latches the + // refusal and signals the child; resolution still waits for the callback. + deadline = setTimeout(() => { + timedOut = true; + try { child.kill("SIGKILL"); } catch { /* the close event still settles */ } + }, options.timeout); + }); +} + /** * Resolve the channel to one exact version plus its sha512 integrity token. * @@ -369,12 +445,12 @@ function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolatio * acceptable for a foreign package we are about to install on the operator's behalf, so * every failure lane here is a refusal. */ -export function resolveCodexCliUpdateTarget( +export async function resolveCodexCliUpdateTarget( channel: CodexCliUpdateChannel, - spawn: typeof spawnSync = spawnSync, + spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, /** Proof-bound pre-dotenv PATH/PATHEXT and proxy/CA env; absent settings stay absent. */ boundEnv?: NodeJS.ProcessEnv, -): CodexCliUpdateTarget { +): Promise { // The pinned registry and the isolation directory together are the boundary: the // version, the integrity token and the tarball URL all come from npmjs or the // target is unresolved — a redirected answer can never be the evidence. @@ -387,7 +463,7 @@ export function resolveCodexCliUpdateTarget( return Object.freeze({ kind: "unresolved" as const, reason: "npm configuration isolation setup failed" }); } try { - const runView = (field: string, spec: string): SpawnSyncReturns | null => { + const runView = async (field: string, spec: string): Promise => { const target = isolatedNpmTarget(["view", spec, field], isolation); if (!target) return null; return spawn(target.bin, target.args, { @@ -398,7 +474,7 @@ export function resolveCodexCliUpdateTarget( }); }; - const versionRun = runView("version", `${CODEX_CLI_PACKAGE}@${channel}`); + const versionRun = await runView("version", `${CODEX_CLI_PACKAGE}@${channel}`); if (!versionRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); // status === null covers a timeout as well as a spawn failure. if (versionRun.status !== 0) { @@ -409,7 +485,7 @@ export function resolveCodexCliUpdateTarget( return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no exact version" }); } - const integrityRun = runView("dist.integrity", `${CODEX_CLI_PACKAGE}@${version}`); + const integrityRun = await runView("dist.integrity", `${CODEX_CLI_PACKAGE}@${version}`); if (!integrityRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); if (integrityRun.status !== 0) { return Object.freeze({ kind: "unresolved" as const, reason: `registry integrity query failed (status ${integrityRun.status ?? "timeout"})` }); @@ -424,7 +500,7 @@ export function resolveCodexCliUpdateTarget( // The digest binds the tarball's CONTENT; this binds its ORIGIN. A registry // answer whose tarball lives outside the pinned registry is not the artifact // the plan approved, even if its sha512 matched. - const tarballRun = runView("dist.tarball", `${CODEX_CLI_PACKAGE}@${version}`); + const tarballRun = await runView("dist.tarball", `${CODEX_CLI_PACKAGE}@${version}`); if (!tarballRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" }); if (tarballRun.status !== 0) { return Object.freeze({ kind: "unresolved" as const, reason: `registry tarball query failed (status ${tarballRun.status ?? "timeout"})` }); @@ -447,6 +523,35 @@ export function resolveCodexCliUpdateTarget( } } +/** Resolve both exact archives, including the publisher's platform-package alias. */ +export async function resolveCodexCliUpdateArtifactTarget(channel: CodexCliUpdateChannel, + spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, boundEnv?: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, arch: string = process.arch): Promise { + let isolation: NpmConfigIsolation | undefined; + try { + isolation = createNpmConfigIsolation(undefined, boundEnv); + const query = async (spec: string) => { + const invocation = isolatedNpmTarget(["view", spec, "--json"], isolation!); + if (!invocation) throw new Error("npm unavailable"); + const result = await spawn(invocation.bin, invocation.args, { encoding: "utf8", timeout: REGISTRY_TIMEOUT_MS, + maxBuffer: 1024 * 1024, windowsHide: true, ...invocation.options }); + if (result.status !== 0) throw new Error("registry query failed"); + return JSON.parse(result.stdout); + }; + const latest = await query(`${CODEX_CLI_PACKAGE}@${channel}`); + if (!parseStrictSemver(latest.version)) throw new Error("target version unavailable"); + const pinned = await query(`${CODEX_CLI_PACKAGE}@${latest.version}`); + if (pinned.version !== latest.version) throw new Error("target version drift"); + const artifacts = await codexCliArtifactSetAsync(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,8 +630,9 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} // When the caller had no trusted snapshot, inspectionDeps.env is the fail-closed // { PATH: "" } form, so this resolution cannot silently fall back to ambient. const resolveTarget = deps.resolveTarget - ?? (ch => resolveCodexCliUpdateTarget(ch, deps.spawnProcess ?? spawnSync, deps.inspectionDeps?.env)); - const target = resolveTarget(channel); + ?? (ch => resolveCodexCliUpdateArtifactTarget(ch, deps.spawnProcess ?? spawnCodexCliUpdateMetadata, deps.inspectionDeps?.env, + platform, deps.inspectionDeps?.arch ?? process.arch)); + const target = await resolveTarget(channel); if (target.kind !== "resolved") { return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED); } @@ -545,7 +651,13 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} return refusedPlan("target_not_newer", report, channel, target, NOT_EVALUATED); } - const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); + // The request-path scan keeps registry and process-table waits off the event loop; + // a synchronous injection remains honored for tests that never spawn a real child. + const scan = deps.scanProcessesAsync + ? await deps.scanProcessesAsync(deps.processIo ?? {}) + : deps.scanProcesses + ? deps.scanProcesses(deps.processIo ?? {}) + : await scanCodexSessionProcessesAsync(deps.processIo ?? {}); if (scan.kind !== "observed") { // An unreadable process table is not "no sessions". `listCodexAppServerProcesses` // maps that failure to an empty list because its kill contract must never signal a @@ -572,6 +684,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 +693,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/src/codex/cli-update-selection-gate.ts b/src/codex/cli-update-selection-gate.ts new file mode 100644 index 00000000000..5b0b0eeda16 --- /dev/null +++ b/src/codex/cli-update-selection-gate.ts @@ -0,0 +1,171 @@ +/** + * Shared commit fence and selection gate for the Codex CLI update worker. + * + * The update's last synchronous turn (final process scan, prefix/preimage/ + * selection guards, installer spawn) runs inside a Worker realm now, so the + * old same-event-loop-turn argument no longer excludes the parent's own + * selection-mutating operations or its session-spawn paths. Two shared-memory + * primitives carry that exclusion across realms: + * + * - FENCE (one per dispatched job): the parent's only cancellation word is + * PREPARING -> CANCELLED, and the worker's only commit word is + * PREPARING -> COMMITTED. Exactly one compareExchange wins. A cancel that + * lands first is observed by the worker's isCancelled checkpoints and by + * the commit CAS losing; a commit that lands first means the npm spawn + * already happened and a late cancel is deferred to readback. + * + * - GATE (one process-wide cell): FREE -> PARENT_WRITE is taken by the five + * parent selection writers (runtime pin persist/clear, shim install/ + * auto-restore/uninstall) and the two parent session-spawn paths (remote + * workspace child, desktop relaunch). FREE -> WORKER_COMMIT is taken by the + * update worker for only its short final window. Writers report busy rather + * than wait: the window is seconds at most and a synchronous parent writer + * must never park the event loop behind it. The gate is intentionally one + * cell for the whole process rather than per-configDir: alias/junction/case + * spellings of the same target must not split the exclusion, so a writer on + * a different configDir can also see a brief busy. + * + * This module claims nothing about processes it does not own: an external + * installer, a late descendant of an earlier child, or an OS snapshot race + * stays the documented residual it always was. + */ + +export const CODEX_CLI_UPDATE_FENCE_PREPARING = 0; +export const CODEX_CLI_UPDATE_FENCE_CANCELLED = 1; +export const CODEX_CLI_UPDATE_FENCE_COMMITTED = 2; + +export const CODEX_CLI_UPDATE_GATE_FREE = 0; +export const CODEX_CLI_UPDATE_GATE_PARENT_WRITE = 1; +export const CODEX_CLI_UPDATE_GATE_WORKER_COMMIT = 2; + +/** Bounded wait for a parent writer's window; the worker then refuses busy. */ +const WORKER_COMMIT_WAIT_DEADLINE_MS = 30_000; + +export class CodexCliSelectionMutationBusyError extends Error { + constructor(message = "codex_cli_selection_busy") { + super(message); + this.name = "CodexCliSelectionMutationBusyError"; + } +} + +let selectionGate: SharedArrayBuffer | null = null; + +/** The single process-wide gate cell. Both realms wrap it in an Int32Array. */ +export function codexCliUpdateSelectionGate(): SharedArrayBuffer { + selectionGate ??= new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + return selectionGate; +} + +/** Test seam: drop the cell so each case starts from FREE on a fresh buffer. */ +export function resetCodexCliSelectionGatesForTests(): void { + selectionGate = null; +} + +/** Fresh per-job fence; SharedArrayBuffer zero-initializes to PREPARING. */ +export function createCodexCliUpdateFence(): SharedArrayBuffer { + return new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); +} + +/** Parent cancel word. True only when this CAS took PREPARING -> CANCELLED. */ +export function cancelCodexCliUpdateFence(fence: SharedArrayBuffer): boolean { + return Atomics.compareExchange(new Int32Array(fence), 0, + CODEX_CLI_UPDATE_FENCE_PREPARING, CODEX_CLI_UPDATE_FENCE_CANCELLED) === CODEX_CLI_UPDATE_FENCE_PREPARING; +} + +/** + * The worker-side cancellation checkpoint fed to apply's isCancelled hook. + * COMMITTED is deliberately not "cancelled": once the commit CAS lands the + * installer is a live child whose close must be observed, not a cancel. + */ +export function codexCliUpdateIsCancelled(fence: SharedArrayBuffer): () => boolean { + const view = new Int32Array(fence); + return () => Atomics.load(view, 0) === CODEX_CLI_UPDATE_FENCE_CANCELLED; +} + +export interface CodexCliSelectionParentWrite { + readonly entered: boolean; + /** Idempotent release; safe to call from both branches of a spawn attempt. */ + exit(): void; +} + +/** + * Non-blocking acquisition for the parent selection writers and session-spawn + * paths. entered === false means a commit window (or another writer) is in + * flight and the caller must surface busy immediately, never wait. + */ +export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrite { + const view = new Int32Array(codexCliUpdateSelectionGate()); + if (Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_PARENT_WRITE) + !== CODEX_CLI_UPDATE_GATE_FREE) { + return Object.freeze({ entered: false, exit() { /* nothing held */ } }); + } + let held = true; + return Object.freeze({ + entered: true, + exit() { + if (!held) return; + held = false; + Atomics.store(view, 0, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} + +/** + * The worker's short final window. tryEnter covers the uncontended same-turn + * case; waitEnter is the bounded retry for a held parent write. commit() is + * the fence CAS issued in the same event-loop turn as the installer spawn; + * exit() releases the gate once the spawn call has returned. + */ +export interface CodexCliUpdateCommitWindow { + tryEnter(): boolean; + waitEnter(): Promise; + commit(): boolean; + exit(): void; +} + +export function createWorkerCommitWindow(options: { + readonly gate: SharedArrayBuffer; + readonly fence: SharedArrayBuffer; + readonly deadlineMs?: number; + /** Test seam: observe/back-off without real sleeps. */ + readonly sleep?: (ms: number) => Promise; +}): CodexCliUpdateCommitWindow { + const gate = new Int32Array(options.gate); + const fence = new Int32Array(options.fence); + const deadlineMs = options.deadlineMs ?? WORKER_COMMIT_WAIT_DEADLINE_MS; + const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); + const cancelled = () => Atomics.load(fence, 0) === CODEX_CLI_UPDATE_FENCE_CANCELLED; + let held = false; + return Object.freeze({ + tryEnter() { + if (!held + && Atomics.compareExchange(gate, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) + === CODEX_CLI_UPDATE_GATE_FREE) { + held = true; + } + return held; + }, + async waitEnter() { + const deadline = Date.now() + deadlineMs; + while (!held && !cancelled()) { + if (Atomics.compareExchange(gate, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) + === CODEX_CLI_UPDATE_GATE_FREE) { + held = true; + break; + } + if (Date.now() >= deadline) break; + await sleep(1); + } + return held; + }, + commit() { + return Atomics.compareExchange(fence, 0, CODEX_CLI_UPDATE_FENCE_PREPARING, CODEX_CLI_UPDATE_FENCE_COMMITTED) + === CODEX_CLI_UPDATE_FENCE_PREPARING; + }, + exit() { + if (!held) return; + held = false; + Atomics.store(gate, 0, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} diff --git a/src/codex/cli-update-worker.ts b/src/codex/cli-update-worker.ts new file mode 100644 index 00000000000..aba494fa3ce --- /dev/null +++ b/src/codex/cli-update-worker.ts @@ -0,0 +1,143 @@ +/** + * Worker-thread entry for one Codex CLI update plan or apply job. + * + * The parent owns admission and the durable job ledger; this realm owns the + * heavy inspection, hashing, staging and installer lifetime. The mutation + * evidence WeakMap is per-realm, which is exactly why the whole plan/apply + * must run here rather than only its hashing: report objects and their + * internal evidence cannot cross the realm boundary together. + * + * The message carries the allowlisted launcher snapshot (never the parent's + * trusted context) plus two shared-memory cells: the process-wide selection + * GATE the worker holds for its short final window, and the per-job FENCE + * whose PREPARING -> CANCELLED / PREPARING -> COMMITTED compare-exchanges + * decide cancel-vs-spawn atomically. A "cancel" message aborts the real + * AbortSignal handed to apply's fetchArtifact path — the asynchronous half + * of cancellation; the fence remains the synchronous-section half, and a + * cancel that loses the commit CAS is never posted. Any other second + * message is ignored: one worker runs exactly one job and then closes, so + * a late duplicate or a stale generation cannot attach new work to a dying + * realm. + */ +import { createCodexCliUpdatePlan, type CodexCliUpdateChannel, type CodexCliUpdatePlan } from "./cli-update-plan"; +import { applyCodexCliUpdate, type CodexCliApplyResult } from "./cli-update-apply"; +import { + codexCliUpdateInspectionDepsFromSnapshot, + type CodexCliUpdateInspectionSnapshot, +} from "./cli-update-inspection-context"; +import { codexCliUpdateIsCancelled, createWorkerCommitWindow } from "./cli-update-selection-gate"; + +export interface CodexCliUpdateWorkerRunMessage { + readonly type: "run"; + readonly requestId: string; + readonly jobId: string; + readonly operation: "plan" | "apply"; + readonly planId?: string; + readonly channel?: CodexCliUpdateChannel; + /** Frozen allowlisted launcher snapshot; never supplemented from ambient env. */ + readonly snapshot: CodexCliUpdateInspectionSnapshot; + /** Process-wide selection gate cell (see cli-update-selection-gate). */ + readonly gate: SharedArrayBuffer; + /** Per-job cancel/commit fence cell. */ + readonly fence: SharedArrayBuffer; + /** Env snapshot: a Worker may not observe parent mutations on every platform. */ + readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string }; +} + +/** Async half of cancellation; the fence CAS is the synchronous half. */ +export interface CodexCliUpdateWorkerCancelMessage { + readonly type: "cancel"; + readonly requestId?: string; + readonly jobId?: string; +} + +export type CodexCliUpdateWorkerReply = + | { readonly type: "done"; readonly requestId: string; readonly jobId: string; + readonly operation: "plan"; readonly plan: CodexCliUpdatePlan } + | { readonly type: "done"; readonly requestId: string; readonly jobId: string; + readonly operation: "apply"; readonly result: CodexCliApplyResult } + | { readonly type: "error"; readonly requestId: string; readonly jobId: string; + readonly message: string }; + +function isSharedBuffer(value: unknown): value is SharedArrayBuffer { + return typeof SharedArrayBuffer !== "undefined" + && value instanceof SharedArrayBuffer + && value.byteLength >= Int32Array.BYTES_PER_ELEMENT; +} + +function isRunMessage(data: unknown): data is CodexCliUpdateWorkerRunMessage { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + return o.type === "run" + && typeof o.requestId === "string" + && typeof o.jobId === "string" + && (o.operation === "plan" || o.operation === "apply") + && (o.planId === undefined || typeof o.planId === "string") + && (o.channel === undefined || o.channel === "latest") + && (o.snapshot === null || (typeof o.snapshot === "object" && !Array.isArray(o.snapshot))) + && isSharedBuffer(o.gate) + && isSharedBuffer(o.fence); +} + +declare const self: Worker; + +// One real AbortSignal per realm for apply's fetchArtifact path; aborting it +// is idempotent, so a cancel that arrives before the run still refuses the +// apply at its first checkpoint via signal.aborted. +const applyAbort = new AbortController(); +let ran = false; + +// Guarded so the module can be imported directly by tests without a Worker host. +if (typeof Bun !== "undefined" && Bun.isMainThread === false + && typeof self !== "undefined" && typeof self.postMessage === "function") { + self.onmessage = async (event: MessageEvent) => { + const data = event.data; + if (data && typeof data === "object" && !Array.isArray(data) + && (data as CodexCliUpdateWorkerCancelMessage).type === "cancel") { + applyAbort.abort(); + return; + } + if (ran || !isRunMessage(data)) return; + ran = true; + const message = data; + try { + // Home overrides apply before anything resolves a home-derived path in + // this realm; they are transport for the job, never snapshot fields. + if (message.env?.CODEX_HOME) process.env.CODEX_HOME = message.env.CODEX_HOME; + if (message.env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = message.env.OPENCODEX_HOME; + const inspectionDeps = codexCliUpdateInspectionDepsFromSnapshot(message.snapshot); + if (message.operation === "plan") { + const plan = await createCodexCliUpdatePlan({ + channel: message.channel ?? "latest", + inspectionDeps, + }); + self.postMessage({ + type: "done", requestId: message.requestId, jobId: message.jobId, + operation: "plan", plan, + } satisfies CodexCliUpdateWorkerReply); + } else { + if (typeof message.planId !== "string") throw new Error("cli_update_worker_missing_plan_id"); + const result = await applyCodexCliUpdate(message.planId, { + inspectionDeps, + signal: applyAbort.signal, + isCancelled: codexCliUpdateIsCancelled(message.fence), + commitWindow: createWorkerCommitWindow({ gate: message.gate, fence: message.fence }), + }); + self.postMessage({ + type: "done", requestId: message.requestId, jobId: message.jobId, + operation: "apply", result, + } satisfies CodexCliUpdateWorkerReply); + } + } catch (error) { + self.postMessage({ + type: "error", requestId: message.requestId, jobId: message.jobId, + message: error instanceof Error ? error.message : "cli_update_worker_failed", + } satisfies CodexCliUpdateWorkerReply); + } finally { + // Bun 1.4.4 has no self.close(). Remove this realm's message keepalive; + // pending referenced child/I/O work must drain before natural termination. + // The parent's close event is not proof that every OS descendant exited. + self.onmessage = null; + } + }; +} diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index 68a6f27858d..91d7c7d3913 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -41,6 +41,7 @@ import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux"; import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; +import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; export type { DesktopAppExecOptions } from "./desktop-app/types"; @@ -90,6 +91,7 @@ export type DesktopAppRestartReason = | "restart_in_flight" | "handoff_started" | "relaunch_context_failed" + | "selection_mutation_busy" | "targets_survived" | "relaunch_failed"; @@ -248,11 +250,27 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp // first quits and relaunches, the second sees the freshly started shell as a target // and kills it. Own-pid reentrancy means the wp5 helper runs this same step and // finds the lock its caller made out to it. - const acquisition = acquireDesktopRestartLock(io.lock); - if (!acquisition.acquired) return skipped("restart_in_flight"); + // A cli-update worker holds this gate from its final process scan through + // its installer spawn, so no new shell may appear inside that window. The + // admission check runs BEFORE the restart lock and every mutation this + // function performs: a busy refusal must leave the app, the lock and any + // handoff all untouched. Held through relaunch or a failed attempt's + // cleanup; a handoff helper's own delayed descendants stay the documented + // pre-existing residual. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return skipped("selection_mutation_busy"); let handedOff = false; + // The lock attempt sits inside the protected scope too: a throw from the + // lock's own mkdir/write before the try would otherwise leak this + // process-wide gate for the rest of the process. lockHeld distinguishes + // "acquisition threw/refused" from "lock taken" so a failed attempt never + // releases a lock this call does not hold. + let lockHeld = false; try { + const acquisition = acquireDesktopRestartLock(io.lock); + if (!acquisition.acquired) return skipped("restart_in_flight"); + lockHeld = true; const install = adapter.discover(exec); if (!install) return skipped("package_discovery_failed"); @@ -366,6 +384,7 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp } finally { // On the handoff path ownership was transferred to the helper, so releasing here // would drop a lock that is still protecting a restart about to happen. - if (!handedOff) releaseDesktopRestartLock(io.lock); + selectionWrite.exit(); + if (lockHeld && !handedOff) releaseDesktopRestartLock(io.lock); } } diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 65f9e166384..e1a37f3b4d5 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -4,6 +4,10 @@ import { homedir, tmpdir } from "node:os"; import { delimiter, join, posix } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "./cli-update-selection-gate"; import { resolveCodexHomeDir } from "./home"; import { decodeOverlayState } from "./shim-state-file"; import { redactSecretString, redactUserPath } from "../lib/redact"; @@ -382,19 +386,30 @@ export function persistCodexRuntime( deps: ResolveCodexRuntimeDeps = {}, origin: CodexRuntimePinOrigin = "pinned", ): void { - const configDir = deps.configDir ?? getConfigDir(); - mkdirSync(configDir, { recursive: true, mode: 0o700 }); - const payload: PersistedCodexRuntimeState = { - version: 1, - command: runtime.command, - source: runtime.source, - selectedVersion: runtime.version, - updatedAt: new Date((deps.now ?? Date.now)()).toISOString(), - origin, - }; - // Invalidate process authority before the persisted replacement is visible. - clearCodexRuntimeResolveCache(); - atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); + // A cli-update worker holds the shared selection gate from its final process + // scan through its installer spawn; this write must not slip into that + // window, so a held gate is an immediate busy rather than a wait. Checked + // BEFORE the mkdir and the resolve-cache invalidation so a busy refusal + // changes nothing at all. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); + try { + const configDir = deps.configDir ?? getConfigDir(); + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const payload: PersistedCodexRuntimeState = { + version: 1, + command: runtime.command, + source: runtime.source, + selectedVersion: runtime.version, + updatedAt: new Date((deps.now ?? Date.now)()).toISOString(), + origin, + }; + // Invalidate process authority before the persisted replacement is visible. + clearCodexRuntimeResolveCache(); + atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`); + } finally { + selectionWrite.exit(); + } } /** @@ -405,20 +420,29 @@ export function persistCodexRuntime( * folds the persisted `updatedAt` into its key, and a removed file has no stamp to fold. */ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): void { - const configDir = deps.configDir ?? getConfigDir(); - clearCodexRuntimeResolveCache(); + // Same gate as persistCodexRuntime, checked before the cache invalidation + // and outside the swallowing catch: a busy refusal must stay an error, not + // become a silent "already removed". + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); try { - unlinkSync(codexRuntimeStatePath(configDir)); - } catch (error) { - // An already-missing file is the success case: the pin is gone, which is the point. - // Anything else means the pin SURVIVES and stays authoritative, so every later - // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once. - const code = (error as NodeJS.ErrnoException | null)?.code; - if (code === "ENOENT") return; - console.warn( - `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}` - + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`, - ); + const configDir = deps.configDir ?? getConfigDir(); + clearCodexRuntimeResolveCache(); + try { + unlinkSync(codexRuntimeStatePath(configDir)); + } catch (error) { + // An already-missing file is the success case: the pin is gone, which is the point. + // Anything else means the pin SURVIVES and stays authoritative, so every later + // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once. + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT") return; + console.warn( + `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}` + + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`, + ); + } + } finally { + selectionWrite.exit(); } } diff --git a/src/codex/shim-overlay.ts b/src/codex/shim-overlay.ts index 12630347ba8..4eb5f4c3089 100644 --- a/src/codex/shim-overlay.ts +++ b/src/codex/shim-overlay.ts @@ -5,6 +5,10 @@ import { } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "./cli-update-selection-gate"; import { durableBunRuntime } from "../lib/bun-runtime"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { truncateRetainedUtf8 } from "../lib/admission"; @@ -442,6 +446,12 @@ function installLocked( return publishOverlay(launcher, overlay, check, pinnedState); } export function installUnixOverlay(discover: () => string | null, describeMiss?: () => string | null): InstallResult { + // The update worker's commit window re-observes selection evidence right up + // to its installer spawn; a shim install must not cross that window. Busy is + // surfaced before namespace creation, the restore lock and any journal + // mutation, so a held gate refuses without touching state. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return failure(new CodexCliSelectionMutationBusyError()); let lock: ReturnType = null; try { const check = namespace(true); @@ -450,7 +460,7 @@ export function installUnixOverlay(discover: () => string | null, describeMiss?: check(); return installLocked(discover, false, check, describeMiss); } catch (error) { return failure(error); } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRestoreResult { const result = readStateResult(); @@ -468,6 +478,11 @@ export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRe } catch (error) { return { status: "ineligible", message: failure(error).message }; } if (!nativeProbe(state.launcherPath)) return { status: "ineligible", message: "Recorded native Codex launcher is unusable; repair it with its package manager" }; if (!options.enabled()) return { status: "disabled" }; + // Same selection gate as install: acquired before namespace creation, the + // restore lock and journal mutation; a held update commit window defers the + // restore rather than racing it. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return { status: "deferred", message: "a Codex CLI update is committing an install; shim restore deferred" }; let lock: ReturnType = null; try { const check = namespace(true); @@ -484,9 +499,13 @@ export function autoRestoreUnixOverlay(options: RestoreOptions): CodexShimAutoRe const installed = installLocked(() => null, true, check); return installed.installed ? { status: "restored", message: installed.message } : { status: "healthy" }; } catch (error) { return { status: "deferred", message: failure(error).message }; } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } export function uninstallUnixCodexShim(): { removed: boolean; message: string } { + // Same selection gate: removal mutates the same state the update worker + // re-observes in its commit window, so a held gate reports busy unchanged. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) return { removed: false, message: new CodexCliSelectionMutationBusyError().message }; let lock: ReturnType = null; try { const check = namespace(false); @@ -522,5 +541,5 @@ export function uninstallUnixCodexShim(): { removed: boolean; message: string } if (!removePinned(statePath(), stateFile)) throw new Error("Concurrent Codex state replacement preserved"); return { removed: true, message: "Codex PATH shim removed. Native Codex launcher was preserved. Remove the codex-shell-env source line from your shell startup file." }; } catch (error) { return { removed: false, message: failure(error).message }; } - finally { lock?.release(); } + finally { selectionWrite.exit(); lock?.release(); } } diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts index 5e6c2472d6f..97cc2f45115 100644 --- a/src/remote-control/workspace-codex-runtime.ts +++ b/src/remote-control/workspace-codex-runtime.ts @@ -2,6 +2,10 @@ import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import { resolveCodexRuntimeAsync } from "../codex/runtime"; +import { + beginCodexCliSelectionParentWrite, + CodexCliSelectionMutationBusyError, +} from "../codex/cli-update-selection-gate"; import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; @@ -366,6 +370,23 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim "app-server", "--listen", "stdio://", ], { env: childEnv }); let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + // A cli-update worker holds this gate from its final process scan through + // its installer spawn; a new session shell must not appear inside that + // window. Busy is the same explicit pre-spawn failure path as a spawn + // throw, so the bridge and isolation are reclaimed identically. + const selectionWrite = beginCodexCliSelectionParentWrite(); + if (!selectionWrite.entered) { + // Both teardown steps are attempted before the refusal leaves: the + // ordered runner keeps a rejected bridge stop from skipping isolation + // removal or replacing the explicit busy signal the caller maps to a + // retryable refusal. The raw cleanup error stays internal; the caller + // gets the same fixed busy evidence as every other pre-spawn refusal. + await runRemoteWorkspaceCleanupSteps([ + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]).catch(() => {}); + throw new CodexCliSelectionMutationBusyError(); + } try { child = Bun.spawn([invocation.file, ...invocation.args], { cwd: isolation, @@ -376,10 +397,12 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim ...invocation.options, }); } catch (error) { + selectionWrite.exit(); await bridge.stop(); removeRemoteWorkspaceIsolation(isolation); throw error; } + selectionWrite.exit(); const peer = new JsonLineRpcProcess(child); let activeTurn: ActiveTurn | null = null; let stopped = false; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 177d66cc84b..c51b21b34e4 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 00000000000..3b780c5bcab --- /dev/null +++ b/src/server/management/codex-cli-update-routes.ts @@ -0,0 +1,54 @@ +import { admitCodexCliUpdateJob, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../codex/cli-update-job"; +import { runCodexCliUpdateApplyInWorker, runCodexCliUpdatePlanInWorker } from "../../codex/cli-update-job-worker"; +import { jsonResponse } from "../auth-cors"; +import { trackBackgroundWork } from "../../lib/background-work"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +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 runCodexCliUpdatePlanInWorker(); + 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, confirmed: true }); + if (admitted.kind === "accepted") { + const work = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => runCodexCliUpdateApplyInWorker(value.planId as string, { jobId: admitted.job.jobId })); + trackBackgroundWork(`codex-cli-update:${admitted.job.jobId}`, work).catch(() => undefined); + } + return jsonResponse({ job: admitted.job }, 202, ctx.req, ctx.config); + } catch (error) { + const code = error instanceof Error ? error.message : "admission_failed"; + const status = code === "idempotency_conflict" ? 409 : code === "invalid_request" ? 400 : 503; + const response = jsonResponse({ error: ["invalid_request", "idempotency_conflict", "store_full", "job_store_busy", "unsupported_store", "unsafe_store"].includes(code) ? code : "admission_failed" }, status, ctx.req, ctx.config); + if (code === "job_store_busy") response.headers.set("Retry-After", "1"); + return response; + } +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 4dcb19d7167..a7db3d46fb5 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -176,6 +176,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/src/update/codex-cli-update-launch-policy.mjs b/src/update/codex-cli-update-launch-policy.mjs index 8a1759d66d4..bef64366e27 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 f33039a1cfd..2a4d9b9f92b 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. | @@ -152,6 +152,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) | @@ -191,6 +192,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,7 +214,17 @@ 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-inspection-context.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-job-execution.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-job-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-job.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-lease.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-plan.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-selection-gate.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-update-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/signed-in-identity.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | @@ -288,6 +300,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/inference/advisor-diagnostics.ts` | [`advisor.md`](advisor.md) | | `src/server/management/` | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md) | | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | +| `src/server/management/codex-cli-update-routes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | diff --git a/structure/cli-management.md b/structure/cli-management.md index ba33a69f227..d89f28eacec 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -155,3 +155,7 @@ 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. + +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/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 1471016161a..dd6d08ff768 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -24,6 +24,10 @@ Under the test preload's `OCX_TEST_HOME_GUARD=1`, `src/codex/desktop-app-restart.ts` skips real restart without an injected executor. `src/cli/restart-scope.ts` reports that skip. `NODE_ENV=test` alone does not control this boundary. `tests/clients/desktop-app-restart.test.ts` covers the contract. +The same function refuses with the `selection_mutation_busy` skipped reason while a cli-update +worker holds its commit window: the refusal precedes the restart lock, package discovery, any app +quit and the handoff, and a lock-acquisition throw still releases the shared gate. See the +[update commit window](../codex-cli-update.md#worker-realm-and-the-selection-commit-window). ## Windows launch context diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 310445ddb3e..20815f0dcac 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. @@ -49,9 +49,157 @@ provider/engine search variables; proxy settings and `NODE_EXTRA_CA_CERTS` remai Ambient NODE_COMPILE_CACHE is replaced with an owned path inside the isolation directory because npm's own enableCompileCache() otherwise recreates a default cache outside that directory. Distinct npm config files, npm/Node caches and logs stay under the owned temporary root with best-effort -cleanup; a cleanup error does not replace the computed plan/refusal. The POSIX update -scan includes all readable users by default, retaining an explicitly injected UID scope. +cleanup; a cleanup error does not replace the computed plan/refusal. Registry metadata +and the request-path process-table scan wait off the server event loop: the bounded npm +children and the darwin/Windows enumerations resolve asynchronously while keeping the +per-query deadline, bounded output, termination and status-null refusal lanes of the +synchronous contract, so a stalled registry cannot park unrelated proxy requests or +streaming; the apply re-scan uses the same async scan. Linux /proc observation remains +synchronous underneath as bounded filesystem reads rather than a child-process wait. +The POSIX update scan includes all readable users by default, retaining an explicitly injected UID scope. Linux per-process `status`/`cmdline` read failures invalidate a complete observation except for `ENOENT` (a process that disappeared); incomplete observation refuses with `blocked_process_state_unknown`; the restart/kill path skips unreadable entries while retaining independently verified same-user app-server targets. This plan neither installs a runtime nor signals 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. +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. A verifier setup +failure before any child exists is terminal evidence instead, so the lease is released and later applies are not blocked. +The owned staging directory is canonicalized immediately after creation, so symlinked temporary roots +and macOS's /var alias are supported without accepting a linked archive file. +`src/codex/cli-update-artifacts.ts` streams compressed integrity and tar validation with independent +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. Inside the worker realm the awaited process scan is the last yield before install; in +the same worker event-loop turn as the spawn a synchronous guard re-checks the cancellation state, +prefix identity, the byte-level preimage and the launcher's live canonical selection, and the per-job +commit fence's PREPARING-to-COMMITTED compare-exchange is the final guard before the installer spawn — +a parent cancel that already landed wins, while a cancel landing after COMMITTED defers to the live +child's close and readback. npm receives only two verified local tarball specs and an explicit canonical prefix, +with offline mode, optional dependencies omitted, lifecycle scripts disabled and no audit/fund/lockfile +side effects. Main manifests are not rewritten to make installation succeed. Cancelled preparation +refuses; cancellation during an admitted npm child waits for its close rather than killing it. The lease +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. +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`. +Cleanup failures are path-free `cleanupErrors` alongside the computed outcome. A failed or thrown +lease release marks retention unverified without preventing staging cleanup; cleanup does not replace +the readback outcome. Uncertain live installer/version children still retain both lease and staging. +Windows apply remains refused. Shims and non-npm managers remain unsupported. + +## Worker realm and the selection commit window + +The plan and apply bodies execute inside a dedicated Worker realm so their synchronous file hashing and +directory walks never occupy the parent's HTTP/event loop. `src/codex/cli-update-worker.ts` is the +per-job worker entrypoint, registered as `cli-update-worker` in the standalone build's +`WORKER_ENTRIES` (`scripts/build-standalone.ts`); `src/codex/cli-update-job-worker.ts` is the +parent-side dispatch the CLI and the management route call. The run message carries the allowlisted +frozen launcher snapshot — never the parent's trustedContext and never supplemented from ambient +environment — plus two shared-memory cells from `src/codex/cli-update-selection-gate.ts` and at most +the two home overrides as transport. `src/codex/cli-update-inspection-context.ts` exposes the shared +factory that rebuilds production inspection deps from that snapshot inside whichever realm calls it, so +the worker derives its own fresh selected-command evidence; the installer inspector's mutation-evidence +WeakMap is per-realm, which is exactly why the whole job — not just its hashing — must run there. + +A job is one fresh Worker, one unique requestId and the parent ledger generation; no wire generation +exists. The worker runs one message, replies and closes itself; a second message is ignored, so a stale +or duplicate delivery cannot attach new work to a dying realm. Settlement requires BOTH a valid reply +and the worker's own close — a done payload alone is never terminal evidence because a legitimate +result may still report installer_state_unknown or version_process_state_unknown with a retained lease +and a live verification child. The host never calls terminate(): an unresponsive worker may still own a +running npm child, so every post-delivery loss — worker death, message error, an implausible or missing +reply, or done without close inside the bounded grace — settles `ambiguous`/`job_result_unknown` with +the lease reported retained and the still-live worker tracked rather than silently leaked. A spawn +throw before the run message exists is the only `refused`/`preparation_failed` path, because no job +code provably ran. Ledger completion writes keep their existing bounded retry. + +Entry installation is limited to `Bun.isMainThread === false`; importing either entry or its +test fixture in the main realm must not replace that realm's message handler. Bun 1.4.4 does not +provide `self.close()`: completion removes the worker's message handler and lets referenced work +drain naturally, without `process.exit()` or parent termination. A real Bun-subprocess regression +keeps the worker alive after handler removal until that exact child exits. This establishes the +referenced Bun-child path only, not arbitrary native handles or all OS descendants. Bun's close +event marks worker termination and can precede full thread exit; it is not independent proof that +every installer descendant or retained native owner has finished. + +Cancellation crosses realms through shared memory, not a message: the parent CASes the job fence +PREPARING-to-CANCELLED, the worker's `isCancelled` checkpoints read that word during its synchronous +sections, and the worker's own PREPARING-to-COMMITTED CAS immediately before the installer spawn is the +commit decision. COMMITTED is deliberately not "cancelled" — once it lands the installer is a live +child whose close must be observed. Because a plain AbortSignal message could not interrupt a +synchronous worker section, signal-based cancellation alone is not equivalent and is not used as the +worker path's only channel. + +The worker's final window is also serialized against the parent's own selection mutations through one +process-wide gate cell. The gate covers exactly seven parent operations: `persistCodexRuntime` and +`clearPersistedCodexRuntime` in `src/codex/runtime.ts` (busy refusal — the +`codex_cli_selection_busy` error — before mkdir, cache invalidation or the swallowing unlink catch), +the three shim overlay boundaries in `src/codex/shim-overlay.ts` (install, auto-restore, uninstall — +busy in their existing failure/deferred/removed:false shapes before namespace creation, the restore +lock and journal mutation), the remote-workspace app-server `Bun.spawn` in +`src/remote-control/workspace-codex-runtime.ts` (busy refuses after running the same bridge/isolation +cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal), and +`src/codex/desktop-app-restart.ts` (busy returns the new `selection_mutation_busy` skipped reason +before the restart lock, package discovery, any app quit and the handoff, so a queued relaunch — and +its own lock-acquisition failure — cannot slip inside the window). Writers never wait: the commit +window spans only the worker's final process scan through its installer-spawn call, and a synchronous +parent writer must not park the event loop behind it, so a held gate is an immediate busy. The gate is +one cell for the whole process rather than per-configDir because alias, junction and case spellings of +the same target must not split the exclusion; a writer on a different configDir can see a brief busy. +Ordinary config writes, account operations, model requests, existing session interrupt/close and +arbitrary subprocesses are not gated. The gate claims nothing about processes the parent does not own: +an external installer, a handoff helper's delayed descendants, or an OS-level snapshot race stays the +documented residual it always was. + +`bin/ocx.mjs` includes the proof-bound Codex candidate snapshot for the published `start` command as well as the updater commands; this makes the production management plan work without accepting request-supplied candidate evidence. `src/codex/cli-update-inspection-context.ts` is the shared bridge from that Node snapshot to the installer inspector. Direct Bun/source launches have no trusted candidate and fail closed; neither path reconstructs write targets from display data. `src/codex/cli-update-job.ts` owns the bounded durable admission ledger shared by CLI and management. Request IDs are scoped to the protected config root and payload digest; exact retries return the same opaque job, conflicts refuse, and a full ledger refuses new admission. Every store operation requires a canonical same-owner config parent with safe write permissions and canonical ancestry to filesystem root; each ancestor must be owned by root or the current user, and shared-writable ancestors are refused unless root-owned and sticky. Only confirmed apply admission may create one absent config-root level, which is revalidated as owner-private. Plan and status reads never create it, and an existing root must pass the same canonical/no-link checks. This path-based ancestry check prevents untrusted other-user writable ancestors from redirecting store operations; it does not defend against concurrent tampering by the same user or root. Atomic updates use a private same-owner POSIX directory, no-follow reads and a short exclusive lock. Unresolved records are not expired. An in-process generation and revision own completion writes; after restart a queued/running record projects as `ambiguous` with `owner_state_unknown`, without claiming the old owner stopped. A `retained` lease state requires an execution reason that proves acquisition and intentional preservation; synthetic job errors, unverified release and release exceptions project as `unknown`. The path-free DTO separates owner state, installer child state, lease retention and overall lifetime uncertainty with fixed outcome/reason/cleanup codes. + +`src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed `gui-session` plus trusted-loopback apply admission, and authenticated status. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. + +The ledger retains at most 64 jobs, including terminal jobs, and never prunes request IDs automatically. +At capacity it refuses new request IDs with `store_full`; existing matching retries still return the same job. +Capacity expansion and operator recovery require a separate design that preserves deduplication and job-status evidence. +`src/codex/cli-update-job-execution.ts` allows at most eight worker start/completion lock attempts, +with asynchronous 25 ms waits and no stale-lock reclamation. HTTP admission reports `job_store_busy` +with status 503 and `Retry-After: 1`; permanent admission failures do not advertise a retry delay. +Only engine exceptions synthesize `job_result_unknown`; persistence retries keep the same actual result. +Transient completion-write failures have the same bounded retry budget. Permanent failure reports +`job_result_persistence_failed` while retaining the computed result in the error; it does not establish durable completion. +CLI apply prints that failure with the admitted job ID, normalized outcome/reason, lease retention and fixed cleanup codes +in both human and JSON output, and exits 1. It never publishes raw result/error fields or reruns the installer to recover the receipt. +Unknown result/cleanup codes are normalized to fixed-code uncertainty before writing, so they cannot poison later reads. +Unsupported prefixes and unavailable lease storage are distinct from a held installation lease. +Missing/unreadable CLI job status has a structured `job_unavailable` JSON error and a nonzero exit. +CLI apply exits 0 only for an `applied` job; other outcomes and pending duplicates exit 1, including JSON output. +Failed lock initialization rolls back only the fresh directory and partial owner file whose identities this call proves; +replacement identities or unprovable ownership remain fail-closed. Fully initialized locks retain token-fenced cleanup. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 44729c1de4a..f9992a481d1 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 7b0d19d4260..8d04db90840 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -172,11 +172,24 @@ { "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/app-server-processes.ts" + "src/codex/cli-update-apply.ts", + "src/codex/cli-update-job.ts", + "src/codex/cli-update-job-execution.ts", + "src/codex/cli-update-job-worker.ts", + "src/codex/cli-update-selection-gate.ts", + "src/codex/cli-update-worker.ts", + "src/server/management/codex-cli-update-routes.ts", + "src/codex/cli-update-inspection-context.ts", + "bin/ocx.mjs", + "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/cli/codex-cli-update.ts" ] }, { diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 93e43c41221..fbe0889439d 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -57,6 +57,7 @@ A track that holds a nested split reserves that subtree's minimum width (`minLay side-by-side splits never scroll inside a pane column; an oversized tree scrolls the pane surface once. Hub runtime admission counts pending create/resume starts as well as live handles against global and per-device limits; every outcome releases its reservation. Stop and shutdown reclaim late resumed handles before clearing ownership. Coordinator result size limits normalize both response text and success, so bridge and MCP callers receive consistent errors. +`src/remote-control/workspace-codex-runtime.ts` takes the shared Codex-CLI-update selection gate immediately before its app-server `Bun.spawn`; while a cli-update worker holds its short commit window the launch refuses with `codex_cli_selection_busy` after running the same bridge/isolation cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal. See the [update commit window](codex-cli-update.md#worker-realm-and-the-selection-commit-window). Prompt HTTP admission returns 202 with the existing session/event cursor; tracked operations publish terminal status for polling and attach a rejection observer immediately. Reconnect/resume cannot publish ready while a turn is active. The dashboard prefers newer local event cursors over stale polling, favors authoritative polling on ties, retains unconfirmed draft text with an uncertainty notice, and never retries prompt POSTs automatically. diff --git a/structure/runtime.md b/structure/runtime.md index 15baf9348fd..a0b0a91ce9f 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 c0c82b7bb5f..09a45813f67 100644 --- a/tests/cli/cli-codex-cli-update-boundary.test.ts +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -81,14 +81,17 @@ describe("Codex CLI selection inspection boundary", () => { for (const key of Object.keys(env)) { if (["home", "node_path"].includes(key.toLowerCase())) delete env[key]; } + const startedAt = performance.now(); const result = spawnSync("node", [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "check", "--json"], { cwd: repoRoot(), encoding: "utf8", timeout: 15_000, windowsHide: true, env: { ...env, Home: "C:\\" + "h".repeat(8 * 1024), Node_Path: "C:\\" + "n".repeat(8 * 1024), OPENCODEX_HOME: root, OPENCODEX_BUN_PATH: process.execPath, CODEX_CLI_PATH: "C:\\fixture\\codex.exe", OCX_NO_DESKTOP_HANDOFF: "1" }, }); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); + const diagnostic = JSON.stringify({ elapsedMs: performance.now() - startedAt, status: result.status, + signal: result.signal, error: result.error?.message, stdout: result.stdout?.slice(-2_048), stderr: result.stderr?.slice(-2_048) }); + expect(result.error, diagnostic).toBeUndefined(); + expect(result.status, diagnostic).toBe(0); const report = JSON.parse(result.stdout); expect(report.candidateSource).toBe("environment"); expect(report.reason).toBe("windows_inspection_deferred"); @@ -247,9 +250,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" ;;', @@ -469,9 +482,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/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index df948dd06c6..0156df8cef5 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { handleCodexCliUpdateCommand, parseCodexCliUpdateArgs } from "../../src/cli/codex-cli-update"; +import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, projectCodexCliUpdateJobLifecycle, readCodexCliUpdateJob, runCodexCliUpdateJob } from "../../src/codex/cli-update-job"; +import * as updateJobs from "../../src/codex/cli-update-job"; +import { CodexCliJobStoreBusyError, CodexCliJobResultPersistenceError, normalizeCodexCliJobResult, + retryCodexCliJobStoreOperation, runCodexCliUpdateJobWork } from "../../src/codex/cli-update-job-execution"; +import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; +import type { CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, @@ -39,7 +51,617 @@ 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 job persistence failures", () => { + const result: CodexCliApplyResult = { outcome: "applied", reason: "verified_target", planId: "a".repeat(32), + installedVersion: "1.0.0", targetVersion: "1.1.0", installerExitCode: 0, leaseRetained: false, + verificationPid: null, cleanupErrors: [] }; + const terminal = { jobId: randomUUID(), status: "applied", outcome: "applied", reason: "verified_target" } as CodexCliUpdateJobDto; + + test.each(["applied", "refused", "ambiguous", "not_applied", "queued", "running"] as const)("apply exit code reflects %s, including JSON and pending duplicate jobs", async status => { + const job = { ...terminal, status, outcome: status === "queued" || status === "running" ? null : status, + ownerState: status === "queued" || status === "running" ? "active" : "finished" } as CodexCliUpdateJobDto; + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob"); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockImplementation(async (_id, _generation, run) => { await run(); return job; }); + const log = spyOn(console, "log").mockImplementation(() => {}); + let runs = 0; + try { + const kinds = status === "queued" || status === "running" ? ["duplicate"] as const : ["accepted", "duplicate"] as const; + for (const kind of kinds) { + admission.mockReturnValue({ kind, job, generation: randomUUID() }); runs = 0; worker.mockClear(); + for (const json of [false, true]) { + log.mockClear(); + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", ...(json ? ["--json"] : [])], + { applyUpdate: async () => { runs++; return result; } })).toBe(status === "applied" ? 0 : 1); + expect(log).toHaveBeenCalled(); + if (json) expect(JSON.parse(String(log.mock.calls[0]![0]))).toMatchObject({ jobId: job.jobId, status, outcome: job.outcome }); + } + expect(runs).toBe(kind === "accepted" ? 2 : 0); + expect(worker).toHaveBeenCalledTimes(kind === "accepted" ? 2 : 0); + } + } finally { log.mockRestore(); admission.mockRestore(); worker.mockRestore(); } + }); + + test.each(["applied", "not_applied", "unknown-code"] as const)("CLI preserves the unpersisted %s result without rerunning work", async outcome => { + const unknown = outcome === "unknown-code"; + const actual = { ...result, outcome: outcome === "not_applied" ? outcome : "applied", + reason: unknown ? "private/unknown-reason" : outcome === "applied" ? "verified_target" : "original_install_unchanged", + leaseRetained: outcome !== "applied", cleanupErrors: unknown ? ["private/cleanup", "lease_release_exception"] + : outcome === "applied" ? ["staging_cleanup_failed"] : ["lease_release_exception"], + installedVersion: "private/installed", privateDetails: "private/internal-path" } as CodexCliApplyResult; + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockReturnValue({ + kind: "accepted", job: terminal, generation: randomUUID(), + }); + let runs = 0, finishes = 0, releases = 0; + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockImplementation(async (_id, _generation, run) => + runCodexCliUpdateJobWork(run, { begin: () => 1, read: () => null, release: () => { releases++; }, + finish: () => { finishes++; throw new Error("unsafe_store"); } })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const json of [false, true]) { + runs = finishes = releases = 0; worker.mockClear(); log.mockClear(); error.mockClear(); + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", ...(json ? ["--json"] : [])], + { applyUpdate: async () => { runs++; return actual; } })).toBe(1); + const safeOutcome = unknown ? "ambiguous" : outcome; + const safeReason = unknown ? "job_result_unknown" : actual.reason; + const cleanupErrors = unknown ? ["lease_release_exception"] : actual.cleanupErrors; + const output = log.mock.calls.map(call => String(call[0])).join("\n"); + if (json) expect(JSON.parse(output)).toEqual({ error: "job_result_persistence_failed", jobId: terminal.jobId, + outcome: safeOutcome, reason: safeReason, leaseRetained: actual.leaseRetained, cleanupErrors }); + else for (const line of [`job: ${terminal.jobId}`, "persistence: unverified (job_result_persistence_failed)", + `outcome: ${safeOutcome}`, `reason: ${safeReason}`, `lease-retained: ${actual.leaseRetained}`, + `cleanup-errors: ${cleanupErrors.join(", ")}`]) expect(output).toContain(line); + expect(output).not.toContain("private/"); + expect(error).not.toHaveBeenCalled(); expect(worker).toHaveBeenCalledTimes(1); + expect(runs).toBe(1); expect(finishes).toBe(1); expect(releases).toBe(1); + } + } finally { error.mockRestore(); log.mockRestore(); worker.mockRestore(); admission.mockRestore(); } + }); + + test("CLI leaves generic apply worker failures on the existing error path", async () => { + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockReturnValue({ + kind: "accepted", job: terminal, generation: randomUUID(), + }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob").mockRejectedValue(new Error("job_store_busy")); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + let runs = 0; + try { + expect(await handleCodexCliUpdateCommand(["apply", "--plan-id", result.planId, "--yes", "--json"], + { applyUpdate: async () => { runs++; return result; } })).toBe(1); + expect(log).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith("Error: job_store_busy"); + expect(worker).toHaveBeenCalledTimes(1); expect(runs).toBe(0); + } finally { error.mockRestore(); log.mockRestore(); worker.mockRestore(); admission.mockRestore(); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined).each(["open", "partial-write"] as const)("owned lock initialization %s failure rolls back and permits later admission", failure => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-init-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); + const nativeOpen = fs.openSync, nativeWrite = fs.writeFileSync; + let ownerFd: number | undefined; + const open = spyOn(fs, "openSync").mockImplementation((...args: Parameters) => { + if (String(args[0]) === owner && failure === "open") throw Object.assign(new Error("injected owner open"), { code: "ENOSPC" }); + const fd = nativeOpen(...args); if (String(args[0]) === owner) ownerFd = fd; return fd; + }); + const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === ownerFd && failure === "partial-write") { + nativeWrite(ownerFd!, "{"); throw Object.assign(new Error("injected partial owner write"), { code: "ENOSPC" }); + } + return nativeWrite(...args); + }); + try { + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-fault-01", planId: result.planId, confirmed: true, configDir })).toThrow(); + expect(existsSync(lock)).toBe(false); + open.mockRestore(); write.mockRestore(); + expect(admitCodexCliUpdateJob({ requestId: "initializer-fault-01", planId: result.planId, confirmed: true, configDir }).kind).toBe("accepted"); + expect(existsSync(lock)).toBe(false); + } finally { open.mockRestore(); write.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined).each(["directory", "owner-file"] as const)("initialization rollback refuses a replaced %s identity", replaced => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-replaced-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"), owner = join(lock, "owner.json"); + const nativeOpen = fs.openSync, nativeWrite = fs.writeFileSync; + let ownerFd: number | undefined; + let originalLock: ReturnType | undefined, originalOwner: ReturnType | undefined; + const open = spyOn(fs, "openSync").mockImplementation((...args: Parameters) => { + const fd = nativeOpen(...args); if (String(args[0]) === owner) ownerFd = fd; return fd; + }); + const write = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === ownerFd) { + nativeWrite(ownerFd!, "{"); originalLock = lstatSync(lock); originalOwner = fs.fstatSync(ownerFd!); + if (replaced === "directory") { + fs.renameSync(lock, `${lock}-original`); mkdirSync(lock, { mode: 0o700 }); + // Keep the owner inode valid so only the changed directory identity refuses cleanup. + fs.renameSync(join(`${lock}-original`, "owner.json"), owner); + } else { fs.renameSync(owner, `${owner}-original`); } + nativeWrite(owner, "replacement", { mode: 0o600 }); + throw Object.assign(new Error("injected owner write after replacement"), { code: "ENOSPC" }); + } + return nativeWrite(...args); + }); + try { + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-01", planId: result.planId, confirmed: true, configDir })).toThrow(); + open.mockRestore(); write.mockRestore(); + const replacementLock = lstatSync(lock), replacementOwner = lstatSync(owner); + expect(replacementLock.mode & 0o777).toBe(0o700); + expect(replacementOwner.mode & 0o777).toBe(0o600); + expect(replacementOwner.uid).toBe(process.geteuid!()); expect(replacementOwner.nlink).toBe(1); + expect(replacementLock.dev).toBe(originalLock!.dev); expect(replacementOwner.dev).toBe(originalOwner!.dev); + if (replaced === "directory") { + expect(replacementLock.ino).not.toBe(originalLock!.ino); expect(replacementOwner.ino).toBe(originalOwner!.ino); + } else { + expect(replacementLock.ino).toBe(originalLock!.ino); expect(replacementOwner.ino).not.toBe(originalOwner!.ino); + } + expect(readFileSync(owner, "utf8")).toBe("replacement"); + expect(existsSync(replaced === "directory" ? `${lock}-original` : `${owner}-original`)).toBe(true); + expect(() => admitCodexCliUpdateJob({ requestId: "initializer-replaced-02", planId: result.planId, confirmed: true, configDir })).toThrow("job_store_busy"); + } finally { open.mockRestore(); write.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test("a save failure retries the actual applied result without rerunning the engine", async () => { + const saved: CodexCliApplyResult[] = []; let runs = 0, released = 0; + const value = await runCodexCliUpdateJobWork(async () => { runs++; return result; }, { + begin: () => 1, read: () => null, release: () => { released++; }, + finish: (_revision, value) => { saved.push(value); if (saved.length === 1) throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); return terminal; }, + }); + expect(value).toBe(terminal); expect(runs).toBe(1); expect(released).toBe(1); + expect(saved).toEqual([result, result]); expect(saved.every(value => value === result)).toBe(true); + }); + + test("persistent save failure carries the actual result and never invents a replacement", async () => { + const saved: CodexCliApplyResult[] = []; + let failure: unknown; + try { await runCodexCliUpdateJobWork(async () => result, { begin: () => 1, read: () => null, release: () => {}, + finish: (_revision, value) => { saved.push(value); throw new Error("unsafe_store"); } }); } + catch (error) { failure = error; } + expect(failure).toBeInstanceOf(CodexCliJobResultPersistenceError); + expect((failure as CodexCliJobResultPersistenceError).result).toBe(result); + expect(saved).toEqual([result]); + }); + + test("bounded lock contention yields asynchronously and does not reclaim the lock", async () => { + let attempts = 0; const waits: number[] = []; + expect(await retryCodexCliJobStoreOperation(() => { if (++attempts < 3) throw new CodexCliJobStoreBusyError(); return 7; }, + async ms => { waits.push(ms); })).toBe(7); + expect(attempts).toBe(3); expect(waits).toEqual([25, 25]); + attempts = 0; waits.length = 0; + await expect(retryCodexCliJobStoreOperation(() => { attempts++; throw new CodexCliJobStoreBusyError(); }, + async ms => { waits.push(ms); })).rejects.toThrow("job_store_busy"); + expect(attempts).toBe(8); expect(waits).toHaveLength(7); + }); + + test("unknown accepted result codes cannot poison a later store read", () => { + const safe = normalizeCodexCliJobResult({ ...result, reason: "private/unknown-reason", + cleanupErrors: ["private/cleanup"] as never }); + expect(safe).toMatchObject({ outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, cleanupErrors: [] }); + expect(JSON.stringify(safe)).not.toContain("private/"); + expect(normalizeCodexCliJobResult(result)).toEqual(result); + }); + + test("a missing generation returns null without running the engine", async () => { + let runs = 0, released = 0; + expect(await runCodexCliUpdateJobWork(async () => { runs++; return result; }, { + begin: () => { throw new Error("job_generation_changed"); }, finish: () => terminal, + read: () => null, release: () => { released++; }, + })).toBeNull(); + expect(runs).toBe(0); expect(released).toBe(1); + }); + + test("missing status is structured JSON and a nonzero exit", async () => { + const messages: string[] = [], previous = console.log; + try { + console.log = value => { messages.push(String(value)); }; + expect(await handleCodexCliUpdateCommand(["status", randomUUID(), "--json"], { readJob: () => null })).toBe(1); + expect(JSON.parse(messages.join("\n"))).toEqual({ error: "job_unavailable" }); + } finally { console.log = previous; } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a contended real store preserves the engine result until the owned lock is released", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-lock-retry-"); + const lock = join(configDir, "codex-cli-update-jobs", "admission.lock"); + let timer: ReturnType | undefined, runs = 0; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "lock-retry-00001", planId: result.planId, confirmed: true, configDir }); + const job = await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => { + runs++; mkdirSync(lock, { mode: 0o700 }); + timer = setTimeout(() => fs.rmdirSync(lock), 50); + return result; + }, configDir); + expect(job).toMatchObject({ status: "applied", outcome: "applied", reason: "verified_target" }); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ outcome: "applied" }); + expect(runs).toBe(1); + } finally { clearTimeout(timer); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a rename followed by fsync failure retries the same completed revision", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-fsync-retry-"); + let sync: ReturnType | undefined; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "fsync-retry-0001", planId: result.planId, confirmed: true, configDir }); + const nativeSync = fs.fsyncSync; let calls = 0; + const job = await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => { + sync = spyOn(fs, "fsyncSync").mockImplementation(fd => { + if (++calls === 3) throw Object.assign(new Error("injected directory sync failure"), { code: "EIO" }); + return nativeSync(fd); + }); + return result; + }, configDir); + expect(job).toMatchObject({ status: "applied", reason: "verified_target" }); + expect(calls).toBeGreaterThan(2); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ outcome: "applied", reason: "verified_target" }); + expect(JSON.parse(readFileSync(join(configDir, "codex-cli-update-jobs", "jobs.json"), "utf8")).jobs[0].revision).toBe(2); + } finally { sync?.mockRestore(); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an accepted unknown result is persisted as readable fixed-code uncertainty", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-unknown-result-"); + try { + const admitted = admitCodexCliUpdateJob({ requestId: "unknown-result-01", planId: result.planId, confirmed: true, configDir }); + await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + async () => ({ ...result, reason: "late_result", cleanupErrors: ["private/path"] as never }), configDir); + expect(readCodexCliUpdateJob(admitted.job.jobId, configDir)).toMatchObject({ status: "ambiguous", + reason: "job_result_unknown", leaseState: "unknown", cleanupErrors: [] }); + } finally { rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("a duplicate worker cannot clear the active generation's completion ownership", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-worker-fence-"); + let release = () => {}, pending: Promise | undefined; + try { + const admitted = admitCodexCliUpdateJob({ requestId: "worker-fence-001", planId: result.planId, confirmed: true, configDir }); + pending = runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + () => new Promise(resolve => { release = () => resolve(result); }), configDir); + await Promise.resolve(); + expect(await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, + async () => { throw new Error("a duplicate worker must not invoke the engine"); }, configDir)) + .toMatchObject({ status: "running", ownerState: "active" }); + release(); + expect(await pending).toMatchObject({ status: "applied", reason: "verified_target" }); + } finally { release(); await pending?.catch(() => undefined); rmSync(outer, { recursive: true }); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("64 retained terminal jobs refuse new admission while preserving the first request dedupe", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-full-store-"); + try { + let first = ""; + for (let index = 0; index < 64; index++) { + const admitted = admitCodexCliUpdateJob({ requestId: `retained-key-${index}`, planId: result.planId, confirmed: true, configDir }); + if (index === 0) first = admitted.job.jobId; + expect(await runCodexCliUpdateJob(admitted.job.jobId, admitted.generation, async () => result, configDir)).toMatchObject({ outcome: "applied" }); + } + expect(() => admitCodexCliUpdateJob({ requestId: "retained-key-65", planId: result.planId, confirmed: true, configDir })).toThrow("store_full"); + expect(admitCodexCliUpdateJob({ requestId: "retained-key-0", planId: result.planId, confirmed: true, configDir })) + .toMatchObject({ kind: "duplicate", job: { jobId: first, outcome: "applied" } }); + } finally { rmSync(outer, { recursive: true }); } + }, 30_000); +}); + describe("Codex CLI update CLI", () => { + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("confirmed admission creates one missing config root without making reads write", () => { + const { outer, parent } = privateStoreParent("ocx-cli-update-missing-parent-"); + 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); + await Promise.resolve(); // Worker admission now yields before invoking the engine. + if (!release) throw new Error("fixture job owner did not enter its awaited operation"); + const ledger = join(configDir, "codex-cli-update-jobs", "jobs.json"); + const before = readFileSync(ledger); + 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, + 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 { outer, configDir } = privateStoreRoot("ocx-cli-update-job-test-"); + try { + const requestId = "retry-key-0000001"; + const planId = "a".repeat(32); + 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), confirmed: true, 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(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 { 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), 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, + verificationPid: null, cleanupErrors: [] }); + }), configDir); + await Promise.resolve(); + expect(readCodexCliUpdateJob(accepted.job.jobId, configDir)).toMatchObject({ status: "running", ownerState: "active" }); + const modulePath = new URL("../../src/codex/cli-update-job.ts", import.meta.url).href; + const script = `import { readCodexCliUpdateJob } from ${JSON.stringify(modulePath)}; console.log(JSON.stringify(readCodexCliUpdateJob(${JSON.stringify(accepted.job.jobId)})));`; + 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" }); + 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(outer, { recursive: true }); + } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("an exception without close evidence preserves child and lease uncertainty", async () => { + const { outer, configDir } = privateStoreRoot("ocx-cli-update-unknown-child-test-"); + try { + 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(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 { 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), 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) => { + 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(outer, { 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/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index eea0c242764..e3bc522ac38 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([]); }); diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index 87dd958b75e..187ecef6b1c 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { listCodexDesktopShellPids, restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; import { classifyDesktopPicker, readCodexCatalogBinding, readCodexDesktopPickerState } from "../../src/codex/desktop-app/picker-freshness"; import type { DesktopAppAdapter, DesktopProcess } from "../../src/codex/desktop-app/types"; import { windowsDesktopAppAdapter } from "../../src/codex/desktop-app/windows"; @@ -624,3 +629,79 @@ describe("desktop picker freshness inputs (#4761)", () => { } }); }); + +describe("the cli-update selection gate covers the whole restart ladder", () => { + test("CLI commit window refuses the real desktop restart caller before quit, then releases it", () => { + const calls: Call[] = []; + const io = scriptedIo({ + discovery: DISCOVERY, + processes: "1000 900 2026-08-22T10:00:00.000Z", + calls, + }); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + try { + held = window.tryEnter(); + expect(held).toBe(true); + const denied = withTrustedExes(() => restartCodexDesktopApp(io)); + expect(denied).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "selection_mutation_busy", + }); + expect(existsSync(io.lock!.lockPath!)).toBe(false); + expect(calls.some(call => call.file === TASKKILL || + /CloseMainWindow|Start-Process|OpenCodexPackageActivation/.test(call.args.join(" ")))).toBe(false); + + window.exit(); + held = false; + calls.length = 0; + // Same caller/lock path, plus explicit absence checks: same-PID reentry alone + // would NOT prove lock release. + const allowed = withTrustedExes(() => restartCodexDesktopApp(io)); + expect(allowed).toEqual({ + attempted: true, stopped: [1000], surviving: [], relaunch: "started", + }); + expect(calls.filter(call => call.args.join(" ").includes("CloseMainWindow"))).toHaveLength(1); + expect(calls.some(call => call.args.join(" ").includes("Start-Process"))).toBe(true); + expect(calls.some(call => call.file === TASKKILL)).toBe(false); + + expect(existsSync(io.lock!.lockPath!)).toBe(false); + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + removeTreeWithRetry(dirname(io.lock!.lockPath!)); + } + }); + + test("a lock-acquisition throw still releases the process-wide selection gate", () => { + // Regression for the gate-then-lock ordering: the lock's own mkdir/write + // can throw, and before the fix that throw sat outside the try/finally and + // left the shared gate held for the rest of the process. A lock path whose + // parent is a regular file makes that acquisition throw deterministically. + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-restart-gate-")); + try { + const blocker = join(dir, "not-a-directory"); + writeFileSync(blocker, "fixture"); + const calls: Call[] = []; + const io = scriptedIo({ + discovery: DISCOVERY, + processes: "1000 900 2026-08-22T10:00:00.000Z", + calls, + }); + io.lock = { lockPath: join(blocker, "lock") }; + expect(() => withTrustedExes(() => restartCodexDesktopApp(io))).toThrow(); + // No discovery/quit/relaunch ran, and the gate is free again. + expect(calls).toHaveLength(0); + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + removeTreeWithRetry(dir); + } + }); +}); diff --git a/tests/clients/desktop-restart-handoff.test.ts b/tests/clients/desktop-restart-handoff.test.ts index f299bbba0a5..4191d071e4c 100644 --- a/tests/clients/desktop-restart-handoff.test.ts +++ b/tests/clients/desktop-restart-handoff.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; import { handoffLogPath, resolveHelperCommand, @@ -9,7 +10,12 @@ import { startDesktopRestartHandoff, type HandoffIo, } from "../../src/codex/desktop-app/handoff"; -import { acquireDesktopRestartLock } from "../../src/codex/desktop-app/lock"; +import { acquireDesktopRestartLock, releaseDesktopRestartLock } from "../../src/codex/desktop-app/lock"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; /** * The handoff exists because the self-ancestry refusal fires in the NORMAL case on a @@ -92,6 +98,104 @@ describe("starting a handoff", () => { expect(planArg).toBeDefined(); expect(existsSync(planArg as string)).toBe(false); }); + + test("worker commit gate refuses self-ancestry handoff before restart lock acquisition", () => { + const dir = home(); + const lockPath = join(dir, "lock"); + const spawned: { command?: string; args?: string[] } = {}; + const handoffIo = startIo(dir, spawned, 9001); + const calls = { handoff: 0, helper: 0, quit: 0, force: 0, relaunch: 0, exec: 0 }; + let lockPathReads = 0; + const lock = { + ...handoffIo.lock!, + get lockPath() { lockPathReads++; return lockPath; }, + }; + handoffIo.lock = lock; + const spawnHelper = handoffIo.spawnHelper!; + handoffIo.spawnHelper = (command, args) => { + calls.helper++; + return spawnHelper(command, args); + }; + const io: DesktopAppRestartIo = { + platform: "win32", + lock, + allowHandoff: true, + ancestryPids: () => [4242, 1000], + execFile: () => { calls.exec++; throw new Error("unexpected fixture exec"); }, + isAlive: () => false, + sleep: () => {}, + now: () => 1_000, + adapter: { + discover: () => ({ id: "fixture", root: dir, relaunch: "fixture" }), + listProcesses: () => [{ + pid: 1000, parentPid: 1, createdAt: "fixture-start", + executable: join(dir, "fixture-shell"), + }], + isShell: () => true, + ancestryPids: () => [4242, 1000], + captureRelaunchContext: () => ({}), + requestQuit: () => { calls.quit++; }, + forceStop: () => { calls.force++; }, + relaunch: () => { calls.relaunch++; }, + }, + startHandoff: () => { + calls.handoff++; + const result = startDesktopRestartHandoff(handoffIo); + return result.kind === "started" + ? { helperPid: result.helperPid, logPath: result.logPath } + : null; + }, + }; + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + try { + expect(existsSync(lockPath)).toBe(false); + held = window.tryEnter(); + expect(held).toBe(true); + // Calls the production restart ladder, not a wrapper that pre-checks the gate. + expect(restartCodexDesktopApp(io)).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "selection_mutation_busy", + }); + expect(calls).toEqual({ handoff: 0, helper: 0, quit: 0, force: 0, relaunch: 0, exec: 0 }); + // Final absence alone would miss create-then-unlink. The existing lock + // implementation reads io.lock.lockPath before any acquisition side effect. + expect(lockPathReads).toBe(0); + expect(existsSync(lockPath)).toBe(false); + expect(spawned.command).toBeUndefined(); + + window.exit(); + held = false; + expect(restartCodexDesktopApp(io)).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", + reason: "handoff_started", + handoff: { helperPid: 9001, logPath: handoffLogPath({ homeDir: dir }) }, + }); + expect(calls).toEqual({ handoff: 1, helper: 1, quit: 0, force: 0, relaunch: 0, exec: 0 }); + expect(lockPathReads).toBeGreaterThan(0); + expect(spawned.command).toBe("/usr/local/bin/ocx"); + expect(spawned.args?.slice(0, 3)).toEqual(["internal", "desktop-restart-handoff", "--plan"]); + expect(JSON.parse(readFileSync(lockPath, "utf8")).ownerPid).toBe(9001); + // Handoff deliberately keeps the filesystem lock, but must release the + // process-wide selection gate in the restart function's finally. + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + // These are synthetic fixture owners; no helper process was created. + releaseDesktopRestartLock({ lockPath, pid: 9001 }); + releaseDesktopRestartLock({ lockPath, pid: 4242 }); + const planPath = spawned.args?.[3]; + if (planPath && existsSync(planPath)) unlinkSync(planPath); + const logPath = handoffLogPath({ homeDir: dir }); + if (existsSync(logPath)) unlinkSync(logPath); + rmdirSync(dir); + } + }); }); describe("running the handoff", () => { diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts index b9d259c2ebb..9525b1357ea 100644 --- a/tests/clients/remote-workspace-codex-runtime.test.ts +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -1,5 +1,10 @@ import { repoPath } from "../helpers/repo-root"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import { + codexCliUpdateSelectionGate, + beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; import { resolve } from "node:path"; import { CodexRemoteWorkspaceRuntimeFactory, @@ -118,3 +123,105 @@ test("Codex Remote Workspace resumes the persisted App Server thread ID", async expect(handle.threadId).toBe("thread-persisted"); await handle.stop(); }); + +function launchGateStartOptions(emit: (type: RemoteWorkspaceSessionEvent["type"], text: string) => void = () => {}): Parameters[0] { + return { + sessionId: "launch-gate-session", + deviceId: "launch-gate-device", + deviceName: "Fixture", + rootId: "launch-gate-root", + rootLabel: "Fixture", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + coordinator: new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }), + emit, + }; +} + +// Uses the REAL factory and its existing explicit command seam. A broken gate +// reaches the spawn spy, which throws; it can never launch a real Codex app. +test("CLI commit window blocks Remote Workspace native spawn and release restores the caller", async () => { + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { FAKE_CODEX_SCRIPT: JSON.stringify({ turns: [{}] }) }, + }); + const options = launchGateStartOptions(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("unexpected native spawn while worker holds the gate"); + }); + try { + held = window.tryEnter(); + expect(held).toBe(true); + await expect(factory.start(options)).rejects.toThrow("codex_cli_selection_busy"); + expect(spawn).toHaveBeenCalledTimes(0); + } finally { + if (held) window.exit(); + spawn.mockRestore(); + } + // No native Codex fallback: command remains the repository's fake JSON-RPC server. + const handle = await factory.start(options); + try { + await handle.prompt("Complete this fixture turn"); + } finally { + await handle.stop(); + } + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(true); } + finally { if (parent.entered) parent.exit(); } +}); + +// Unlike a gate-wrapper unit test, this reaches the existing prompt/interrupt/ +// close implementation. The emitted marker replaces a scheduling sleep. +test("an existing Remote Workspace turn can interrupt and close while the worker gate is held", async () => { + let ready!: () => void; + const sawHeldTurn = new Promise(resolveReady => { ready = resolveReady; }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { + FAKE_CODEX_SCRIPT: JSON.stringify({ + turns: [{ + heldUntilInterrupt: true, + notifications: [{ + method: "item/completed", + params: { item: { id: "gate-ready", type: "agentMessage", text: "gate-ready" } }, + }], + }], + }), + }, + }); + const handle = await factory.start(launchGateStartOptions((type, text) => { + if (type === "assistant" && text === "gate-ready") ready(); + })); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), + fence: new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT), + }); + let held = false; + let stopped = false; + let turn: Promise | undefined; + try { + turn = handle.prompt("Hold until interrupted").then(() => "resolved", () => "rejected"); + await sawHeldTurn; + held = window.tryEnter(); + expect(held).toBe(true); + await handle.stop(); + stopped = true; + expect(await turn).toBe("rejected"); + // The stop path must NOT steal/release the worker's ownership. + const parent = beginCodexCliSelectionParentWrite(); + try { expect(parent.entered).toBe(false); } + finally { if (parent.entered) parent.exit(); } + } finally { + if (held) window.exit(); + if (!stopped) await handle.stop(); + if (turn) await turn; + } +}); diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index e60b9f9ecbf..69cd2be4808 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -26,6 +26,7 @@ import { resetCodexAppServerCatalogStateCache, restartCodexAppServers, scanCodexSessionProcesses, + scanCodexSessionProcessesAsync, STALE_CODEX_APP_SERVER_HINT, warnIfStaleCodexAppServersAfterStartupWrite, WINDOWS_CODEX_BASENAME_CANDIDATE_RE, @@ -842,6 +843,41 @@ describe("Codex session process matching (#2811)", () => { expect(scan.processes.map(process => process.pid)).toEqual([12, 13]); } }); + + test("scanCodexSessionProcessesAsync prefers the async seam and keeps the fail-closed contract", async () => { + // The request-path scan must wait on an injected async source rather than a + // synchronous enumeration, so a stalled process-table read cannot park the loop. + const unavailable = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 12, commandLine: "codex" }], + listSnapshotsAsync: async () => { throw new Error("procfs unreadable"); }, + }); + expect(unavailable.kind).toBe("unavailable"); + const scan = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 99, commandLine: "codex" }], + listSnapshotsAsync: async () => [ + { pid: 11, commandLine: "vim notes.txt" }, + { pid: 12, commandLine: "codex" }, + { pid: 12, commandLine: "codex" }, + { pid: 13, commandLine: "codex exec --json" }, + ], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") { + // The async source answered (pid 99 was the sync-only decoy) and dedupe holds. + expect(scan.processes.map(process => process.pid)).toEqual([12, 13]); + } + }); + + test("scanCodexSessionProcessesAsync falls back to the injected sync seam", async () => { + const scan = await scanCodexSessionProcessesAsync({ + platform: "linux", + listSnapshots: () => [{ pid: 21, commandLine: "codex app-server" }], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") expect(scan.processes.map(process => process.pid)).toEqual([21]); + }); }); describe("CLI /api sync wiring for stale app-servers (#476)", () => { diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts new file mode 100644 index 00000000000..c396e17f0aa --- /dev/null +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -0,0 +1,839 @@ +import { afterEach, describe, expect, test } from "bun:test"; +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 { dirname, join } from "node:path"; +import { applyCodexCliUpdate, verifyInstalledCodexVersion, type CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import { admitCodexCliUpdateJob, finishCodexCliUpdateJob, runCodexCliUpdateJob, type CodexCliUpdateJobDto } from "../../src/codex/cli-update-job"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; +import { acquireCodexCliApplyLease } from "../../src/codex/cli-update-lease"; +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"; +import { observeCodexCliFileSet, sameCodexCliFiles } from "../../src/codex/cli-update-files"; +import { + createCodexCliUpdateFence, cancelCodexCliUpdateFence, codexCliUpdateIsCancelled, + codexCliUpdateSelectionGate, beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, +} from "../../src/codex/cli-update-selection-gate"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); +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, JSON.stringify(plan)).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); +} +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: Pick, "root">) { + 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 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) {', + ' 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, tracedSource.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) }); +} +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("default version verifier pre-child setup", () => { + test("a mkdir failure is terminal without creating a child", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-version-setup-")); roots.push(root); + const home = join(root, "version-home"); + writeFileSync(home, "not a directory\n"); + expect(await verifyInstalledCodexVersion("", "1.1.0", root, {})).toEqual({ + terminal: true, ok: false, pid: null, + }); + expect(readFileSync(home, "utf8")).toBe("not a directory\n"); + }); + test("a synchronous spawn throw is terminal without creating a child", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-version-setup-")); roots.push(root); + const command = "ocx-verifier\0must-not-spawn"; + expect(() => spawn(command, ["--version"], { cwd: root, env: {} })).toThrow(/null bytes/); + expect(await verifyInstalledCodexVersion(command, "1.1.0", root, {})).toEqual({ + terminal: true, ok: false, pid: null, + }); + expect(lstatSync(join(root, "version-home")).isDirectory()).toBe(true); + }); +}); + +describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { + test("a held lease and an unsupported prefix are distinct refusal inputs", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-lease-policy-"))); roots.push(root); + chmodSync(root, 0o700); + const first = acquireCodexCliApplyLease(root); + expect(first.kind).toBe("acquired"); + if (first.kind !== "acquired") throw new Error("owned private prefix lease unavailable"); + try { expect(acquireCodexCliApplyLease(root)).toEqual({ kind: "busy" }); } + finally { expect(first.release()).toBe(true); } + chmodSync(root, 0o770); + expect(acquireCodexCliApplyLease(root)).toEqual({ kind: "prefix_unsupported" }); + expect(existsSync(join(root, ".ocx-codex-cli-apply.lock"))).toBe(false); + }); + + test("published Node launcher -> real resolver/provenance -> plan/apply -> verified new files", () => { + const f = fixture(); + const plan = cli(f, ["plan"]); + 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).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); + 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("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 { 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) { + // 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, + 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)}`); + } + + 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, 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, + 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"); + 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).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); + + 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("artifact fetch keeps the actual AbortSignal identity and event-listener contract", async () => { + const f = fixture(), id = await approved(f), controller = new AbortController(); + const fence = createCodexCliUpdateFence(); + const signals: AbortSignal[] = [], listeners: Array<() => void> = []; + let abortEvents = 0, installs = 0; + try { + const result = await applyCodexCliUpdate(id, depsFor(f, { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), + fetchArtifact: async (artifact, signal) => { + expect(signal).toBe(controller.signal); + expect(signal).toBeInstanceOf(AbortSignal); + const listener = () => { abortEvents++; }; + signal!.addEventListener("abort", listener, { once: true }); + signals.push(signal!); listeners.push(listener); + return artifact.installName === f.target.artifacts.main.installName ? f.target.main.bytes : f.target.backing.bytes; + }, + install: () => { installs++; return Promise.resolve({ terminal: true, exitCode: 0 }); }, + })); + expect(result).toMatchObject({ outcome: "not_applied", reason: "original_install_unchanged", leaseRetained: false }); + expect(signals).toHaveLength(2); expect(installs).toBe(1); expect(abortEvents).toBe(0); + controller.abort(); expect(abortEvents).toBe(2); + expect(codexCliUpdateIsCancelled(fence)()).toBe(false); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + } finally { + for (let i = 0; i < signals.length; i++) signals[i]!.removeEventListener("abort", listeners[i]!); + } + }, 120_000); + test.each(["preimage-drift", "selection-change", "signal-abort", "sab-cancel"] as const)( + "a %s while the process scan is pending refuses before install", async lane => { + const f = fixture(); const id = await approved(f); let installs = 0; + const controller = new AbortController(), fence = createCodexCliUpdateFence(); + const commitWindow = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + let scans = 0, release!: () => void, entered!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const scanEntered = new Promise(resolve => { entered = resolve; }); + // Two plan scans precede the final scan. Only that final awaited scan is held. + const pending = applyCodexCliUpdate(id, depsFor(f, { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), commitWindow, + scanProcessesAsync: async () => { + if (++scans === 3) { entered(); await held; } + return { kind: "observed", processes: [] }; + }, + install: () => { installs++; return Promise.resolve({ terminal: true, exitCode: 0 }); }, + })); + try { + await waitForApplyAdmission(scanEntered, pending); expect(scans).toBe(3); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + if (lane === "preimage-drift") writeFileSync(join(f.prefix, "lib/node_modules/@openai/codex/bin/codex.js"), + "#!/usr/bin/env node\n// changed while the final scan was pending\n"); + if (lane === "selection-change") { unlinkSync(f.launcher); symlinkSync(f.target.main.path, f.launcher); } + if (lane === "signal-abort") controller.abort(); + if (lane === "sab-cancel") { + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(controller.signal.aborted).toBe(false); + } + release(); const result = await pending; + expect(result.outcome).toBe("refused"); expect(installs).toBe(0); + if (lane === "sab-cancel" || lane === "signal-abort") expect(result.reason).toBe("cancelled"); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + const next = beginCodexCliSelectionParentWrite(); + try { expect(next.entered).toBe(true); } finally { next.exit(); } + } finally { release(); await pending.catch(() => {}); } + }, 120_000); + test("actual apply commits while install is called synchronously, then frees gate before awaiting it", async () => { + const f = fixture(), id = await approved(f), fence = createCodexCliUpdateFence(); + const commitWindow = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + let entered!: () => void, finish!: () => void, installs = 0, settled = false; + const started = new Promise(resolve => { entered = resolve; }); + const terminal = new Promise<{ terminal: boolean; exitCode: number }>(resolve => { + finish = () => resolve({ terminal: true, exitCode: 0 }); + }); + const pending = applyCodexCliUpdate(id, depsFor(f, { + isCancelled: codexCliUpdateIsCancelled(fence), commitWindow, + // Deliberately NOT async: this is the documented same-turn child-start seam. + // It starts only this fake's admission marker, never the production installer. + install: () => { + installs++; + expect(cancelCodexCliUpdateFence(fence)).toBe(false); // commit won before invocation + expect(codexCliUpdateIsCancelled(fence)()).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + entered(); return terminal; + }, + })).then(result => { settled = true; return result; }); + try { + await waitForApplyAdmission(started, pending); + expect(installs).toBe(1); expect(settled).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(true); } finally { writer.exit(); } + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(true); + expect(readdirSync(f.temporaryRoot).length).toBeGreaterThan(0); + } finally { finish(); await pending.catch(() => {}); } + expect(await pending).toMatchObject({ outcome: "not_applied", reason: "original_install_unchanged", leaseRetained: false }); + expect(installs).toBe(1); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + expect(readdirSync(f.temporaryRoot)).toEqual([]); + }, 120_000); + test("exit zero with original full files is not_applied", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async () => ({ terminal: true, exitCode: 0 }) })); + 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(); + 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); + 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, JSON.stringify(result)).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, 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 () => { + 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 }) })); + 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)).toEqual({ kind: "busy" }); + // The uncertain child is a test double; no real installer survives teardown. + }, 120_000); + test("original file mode damage is ambiguous, not not_applied", async () => { + 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; }); + 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 => { + 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); + 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.kind !== "acquired") return owned; + return { kind: "acquired", 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); + test("a default verifier setup failure before any child releases the lease", async () => { + const f = fixture(); const id = await approved(f); + const result = await applyCodexCliUpdate(id, depsFor(f, { install: async invocation => { + f.privateNpm([...invocation.args]); + const staging = readdirSync(f.temporaryRoot).find(name => name.startsWith("ocx-codex-cli-apply-")); + expect(staging).toBeDefined(); + writeFileSync(join(f.temporaryRoot, staging!, "version-home"), "not a directory\n"); // mkdirSync fails EEXIST + return { terminal: true, exitCode: 0 }; + } })); + expect(result.outcome, JSON.stringify(result)).toBe("ambiguous"); + expect(result.reason).toBe("version_readback_unverified"); + expect(result.leaseRetained).toBe(false); expect(result.verificationPid).toBeNull(); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + const again = await applyCodexCliUpdate(id, depsFor(f)); + expect(again.reason, JSON.stringify(again)).not.toBe("installation_busy"); + }, 120_000); +}); 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 00000000000..6e1e761b419 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -0,0 +1,308 @@ +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, 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 { 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"; +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"; + +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("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", + 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.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.kind).toBe("acquired"); + if (lease.kind !== "acquired") throw new Error("fixture lease unavailable"); + expect(lease.release()).toBe(true); + await new Promise(resolve => setImmediate(resolve)); + expect(fs.fstatSync(sentinel).size).toBe(8); + } + } 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.kind).toBe("acquired"); + if (lease.kind !== "acquired") throw new Error("fixture lease unavailable"); + const close = fs.closeSync; + const spy = spyOn(fs, "closeSync").mockImplementation(fd => { + close(fd); throw Object.assign(new Error("injected post-close failure"), { code: "EBADF" }); + }); + 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); + 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("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 { + 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); + 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(); + } + }); + 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.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(1); + expect(calls).toBe(1); expect(logs).toHaveLength(1); + expect(fs.existsSync(join(configDir, "codex-cli-update-jobs", "jobs.json"))).toBe(true); + const dto = JSON.parse(logs[0]!) as Record; + 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) }); + 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", + ]); + 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; + 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-integrity.test.ts b/tests/codex-integration/codex-cli-update-integrity.test.ts index fbfe3e3160e..0f5dd29ca58 100644 --- a/tests/codex-integration/codex-cli-update-integrity.test.ts +++ b/tests/codex-integration/codex-cli-update-integrity.test.ts @@ -1,21 +1,28 @@ import { describe, expect, test } from "bun:test"; import type { spawnSync } from "node:child_process"; -import { resolveCodexCliUpdateTarget } from "../../src/codex/cli-update-plan"; +import { resolveCodexCliUpdateArtifactTarget } from "../../src/codex/cli-update-plan"; const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; -function resolveWith(token: string) { +async function resolveWith(token: string) { let queries = 0; - const outputs = ["1.4.2", token, "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz"]; - const spawn = (() => ({ status: 0, stdout: outputs[queries++], stderr: "" })) as typeof spawnSync; - return { target: resolveCodexCliUpdateTarget("latest", spawn), queries }; + const spawn = ((_bin: string, args: string[]) => { + queries++; + return { status: 0, stderr: "", stdout: JSON.stringify({ + name: args.join(" ").includes("@openai/codex-linux-x64@") ? "@openai/codex-linux-x64" : "@openai/codex", + version: "1.4.2", optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity: token, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" }, + }) }; + }) as typeof spawnSync; + return { target: await resolveCodexCliUpdateArtifactTarget("latest", spawn, undefined, "linux", "x64"), queries }; } describe("Codex CLI registry SHA-512 evidence", () => { - test("accepts a complete canonical SHA-512 digest in a multi-hash response", () => { - expect(resolveWith(`'sha1-old ${integrity}'\n`)).toEqual({ - target: { kind: "resolved", version: "1.4.2", integrity }, queries: 3, - }); + test("accepts a complete canonical SHA-512 digest for both production artifacts", async () => { + const value = await resolveWith(integrity); + expect(value.target).toMatchObject({ kind: "resolved", version: "1.4.2", integrity, + artifacts: { backing: { name: "@openai/codex-linux-x64", integrity } } }); + expect(value.queries).toBe(3); }); test.each([ @@ -26,8 +33,9 @@ describe("Codex CLI registry SHA-512 evidence", () => { // Node's permissive base64 decoder accepts nonzero unused padding bits. `sha512-${"A".repeat(85)}B==`, integrity.replace("==", ""), - ])("refuses malformed integrity %s before querying the tarball", token => { - const result = resolveWith(token); + `sha1-old ${integrity}`, + ])("refuses malformed integrity %s before querying the tarball", async token => { + const result = await resolveWith(token); expect(result.target.kind).toBe("unresolved"); expect(result.queries).toBe(2); }); diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index aa833c55625..4cc117a15b5 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/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index b77188877ea..982a59ca822 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; @@ -17,9 +17,11 @@ import { CODEX_CLI_REGISTRY, codexCliUpdatePlanId, createCodexCliUpdatePlan, - resolveCodexCliUpdateTarget, + resolveCodexCliUpdateArtifactTarget, + spawnCodexCliUpdateMetadata, type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, + type CodexCliUpdateSpawn, type CodexCliUpdateTarget, } from "../../src/codex/cli-update-plan"; import { @@ -33,6 +35,9 @@ import { } from "../helpers/codex-cli-content-fs"; const REGISTRY_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; +function resolveArtifact(channel: "latest", spawn: CodexCliUpdateSpawn = spawnCodexCliUpdateMetadata, env?: NodeJS.ProcessEnv) { + return resolveCodexCliUpdateArtifactTarget(channel, spawn, env, "linux", "x64"); +} /** * Phase 2 of the Codex CLI update manager. @@ -332,43 +337,47 @@ describe("strict Codex app-server process scan", () => { describe("registry target resolution", () => { function spawnStub(outputs: { status: number | null; stdout: string }[]) { let call = 0; - return (() => { - const next = outputs[call++] ?? { status: 1, stdout: "" }; - return { status: next.status, stdout: next.stdout, stderr: "" }; + return ((_bin: string, args: string[]) => { + const index = call++, version = outputs[0]?.stdout.trim(); + const name = index === 2 ? "@openai/codex-linux-x64" : "@openai/codex"; + return { status: outputs[index === 0 ? 0 : 1]?.status ?? 1, + stdout: JSON.stringify({ name, version, optionalDependencies: { "@openai/codex-linux-x64": version }, + dist: { integrity: outputs[1]?.stdout.trim(), tarball: outputs[2]?.stdout.trim() } }), stderr: "" }; }) as never; } - test("an exact version with a sha512 token resolves", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("an exact version with a sha512 token resolves", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, - { status: 0, stdout: `'${REGISTRY_INTEGRITY}' sha1-old\n` }, + { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n" }, ])); - expect(target).toEqual({ kind: "resolved", version: "1.4.2", integrity: REGISTRY_INTEGRITY }); + expect(target).toMatchObject({ kind: "resolved", version: "1.4.2", integrity: REGISTRY_INTEGRITY, + artifacts: { platform: "linux", arch: "x64", backing: { name: "@openai/codex-linux-x64" } } }); }); - test("a missing integrity token refuses instead of proceeding best-effort", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a missing integrity token refuses instead of proceeding best-effort", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: "sha1-onlythis\n" }, ])); expect(target.kind).toBe("unresolved"); }); - test("a registry timeout refuses", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([{ status: null, stdout: "" }])); + test("a registry timeout refuses", async () => { + const target = await resolveArtifact("latest", spawnStub([{ status: null, stdout: "" }])); expect(target.kind).toBe("unresolved"); }); - test("a non-version answer is not treated as a version", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a non-version answer is not treated as a version", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "latest\n" }, ])); expect(target.kind).toBe("unresolved"); }); - test("a tarball outside the pinned registry origin is refused even with a valid digest", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a tarball outside the pinned registry origin is refused even with a valid digest", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://evil.invalid/@openai/codex/-/codex-1.4.2.tgz\n" }, @@ -376,14 +385,120 @@ describe("registry target resolution", () => { expect(target.kind).toBe("unresolved"); }); - test("a tarball answer that is not a URL at all is refused", () => { - const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + test("a tarball answer that is not a URL at all is refused", async () => { + const target = await resolveArtifact("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "not-a-url\n" }, ])); expect(target.kind).toBe("unresolved"); }); + + const GATED_MAIN_JSON = JSON.stringify({ + name: "@openai/codex", version: "1.4.2", + optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity: REGISTRY_INTEGRITY, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" }, + }); + const GATED_BACKING_JSON = JSON.stringify({ + name: "@openai/codex-linux-x64", version: "1.4.2", + dist: { integrity: REGISTRY_INTEGRITY, tarball: "https://registry.npmjs.org/@openai/codex-linux-x64/-/codex-linux-x64-1.4.2.tgz" }, + }); + + /** + * A REAL child that answers like npm view --json but only after the gate file + * appears — or after its own cap, so a failed assertion can never hang the suite. + * Runs through the production async spawn runner, not a mocked event loop. + */ + const gatedNpmSpawn = (gateFile: string, capMs: number): CodexCliUpdateSpawn => + (_bin, args, options) => spawnCodexCliUpdateMetadata(process.execPath, [ + "-e", + 'const fs=require("node:fs"),g=process.env.OCX_GATE_FILE,a=process.env.OCX_GATE_ANSWER,d=Date.now()+Number(process.env.OCX_GATE_CAP_MS);' + + 'const t=setInterval(()=>{if(!fs.existsSync(g)&&Date.now()<=d)return;clearInterval(t);process.stdout.write(a+"\\n",()=>process.exit(0))},15)', + ], { + ...options, + // The resolver's Windows verbatim flag must not leak onto our own child argv. + windowsVerbatimArguments: false, + env: { ...options.env, OCX_GATE_FILE: gateFile, OCX_GATE_CAP_MS: String(capMs), + OCX_GATE_ANSWER: args.join(" ").includes("@openai/codex-linux-x64@") ? GATED_BACKING_JSON : GATED_MAIN_JSON }, + }); + + test("a gated registry child keeps the event loop responsive while planning waits", async () => { + const dir = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-gated-npm-"))); + const gateFile = join(dir, "release"); + try { + // The real resolver wiring: registry queries spawn the gated child through the + // production async runner. Under the old spawnSync this parked the event loop + // for the whole per-query deadline. + const pending = createCodexCliUpdatePlan(planDeps({ + resolveTarget: undefined, + inspectionDeps: { arch: "x64" }, + spawnProcess: gatedNpmSpawn(gateFile, 4_000), + })); + const firedAt = Date.now(); + await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise(resolve => setTimeout(resolve, 40)); + // A parked event loop defers the probe until the gated child self-caps, so the + // wall clock from the plan call is the discriminator — not probe latency alone. + expect(Date.now() - firedAt).toBeLessThan(2_000); + writeFileSync(gateFile, "release"); + const plan = await pending; + expect(plan).toMatchObject({ applicable: true, targetVersion: "1.4.2" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("registry metadata runner terminal lanes", () => { + const OPTS = { encoding: "utf8" as const, timeout: 15_000, maxBuffer: 1024 * 1024, windowsHide: true as const }; + test("a non-zero exit keeps its code", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, ["-e", "process.exit(3)"], OPTS); + expect(result.status).toBe(3); + }); + test("a child past the per-query deadline settles null on its terminal event", async () => { + const startedAt = Date.now(); + const result = await spawnCodexCliUpdateMetadata(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], + { ...OPTS, timeout: 250 }); + expect(result.status).toBeNull(); + // The deadline kill is followed by the child's real close, not a reap guess + // long enough to matter; a 30 s sleeper proves settlement came from the kill. + expect(Date.now() - startedAt).toBeLessThan(5_000); + }); + test("stdout past maxBuffer refuses instead of truncating", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", 'process.stdout.write("x".repeat(2 * 1024 * 1024))'], + { ...OPTS, maxBuffer: 64 * 1024 }); + expect(result.status).toBeNull(); + }); + test("multibyte output split across OS pipe chunks decodes intact", async () => { + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", 'process.stdout.write("\uac00".repeat(200_000))'], { ...OPTS, maxBuffer: 4 * 1024 * 1024 }); + expect(result.status).toBe(0); + expect(result.stdout).toBe("가".repeat(200_000)); + expect(result.stdout).not.toContain("�"); + }); + test("a binary that cannot be spawned settles null like spawnSync", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-meta-spawn-")); + try { + const result = await spawnCodexCliUpdateMetadata(join(dir, "no-such-npm"), ["view"], OPTS); + expect(result.status).toBeNull(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + test("the result waits for the child's real terminal event before settling", async () => { + const dir = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-meta-terminal-"))); + const marker = join(dir, "terminal"); + try { + const startedAt = Date.now(); + const result = await spawnCodexCliUpdateMetadata(process.execPath, + ["-e", "setTimeout(() => require('node:fs').writeFileSync(" + JSON.stringify(marker) + ", 'x'), 300)"], OPTS); + expect(result.status).toBe(0); + // The child's final write is on disk when the runner settles: the resolve + // followed the terminal event, so the caller's isolation cleanup in its + // finally can never run underneath a still-live child. + expect(existsSync(marker)).toBe(true); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(250); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); }); describe("registry configuration isolation", () => { @@ -446,6 +561,14 @@ 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).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: "" }; }) as never; return { calls, spawn }; @@ -457,7 +580,7 @@ describe("registry configuration isolation", () => { "dist.tarball": "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n", }; - test.each(["resolved", "unresolved"] as const)("cleanup failure preserves the %s resolver outcome", kind => { + test.each(["resolved", "unresolved"] as const)("cleanup failure preserves the %s resolver outcome", async kind => { let ownedRoot = ""; let cleanupAttempts = 0; const originalRemove = fs.rmSync; @@ -474,7 +597,7 @@ describe("registry configuration isolation", () => { ownedRoot = options.cwd as string; return (capture.spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe(kind); + expect((await resolveArtifact("latest", spawn)).kind).toBe(kind); expect(cleanupAttempts).toBe(1); expect(existsSync(ownedRoot)).toBe(true); } finally { @@ -483,13 +606,14 @@ describe("registry configuration isolation", () => { } }); - test("every query pins the official registry and substitutes a controlled npmrc", () => { + test("every query pins the official registry and substitutes a controlled npmrc", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { expect(argvLine(call)).toContain("--registry=" + CODEX_CLI_REGISTRY); + expect(argvLine(call)).toContain("--json"); expect(call.options.maxBuffer).toBe(1024 * 1024); // Both file configs use distinct controlled empty files inside the // isolation dir — a user ~/.npmrc or a global $PREFIX/etc/npmrc cannot answer. expect(argvLine(call)).toContain("--userconfig="); @@ -504,14 +628,14 @@ describe("registry configuration isolation", () => { expect(existsSync(calls[0]!.options.cwd as string)).toBe(false); }); - test("a hostile npm_config_* env cannot reach the spawned npm", () => { + test("a hostile npm_config_* env cannot reach the spawned npm", async () => { const prior = process.env.npm_config_registry; const priorScoped = process.env["npm_config_@openai:registry"]; process.env.npm_config_registry = "https://evil.invalid"; process.env["npm_config_@openai:registry"] = "https://evil.invalid/"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -525,11 +649,11 @@ describe("registry configuration isolation", () => { } }); - test("the controlled cwd never is the operator's project directory", () => { + test("the controlled cwd never is the operator's project directory", async () => { // A project .npmrc in the launch directory would otherwise redirect the scope; // the query must run from the sentinel-anchored isolation dir instead. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const cwd = call.options.cwd as string; @@ -548,7 +672,7 @@ describe("registry configuration isolation", () => { }; } - test("the bound launcher snapshot supplies executable and transport trust inputs", () => { + test("the bound launcher snapshot supplies executable and transport trust inputs", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const boundEnv = { ...markerBoundEnv(), HTTPS_PROXY: "http://trusted-proxy", NODE_EXTRA_CA_CERTS: "/trusted/ca.pem" }; const previous = { HTTPS_PROXY: process.env.HTTPS_PROXY, ALL_PROXY: process.env.ALL_PROXY, @@ -557,7 +681,7 @@ describe("registry configuration isolation", () => { process.env.HTTPS_PROXY = "http://project-dotenv-proxy"; process.env.ALL_PROXY = "http://project-dotenv-fallback"; process.env.NODE_EXTRA_CA_CERTS = "/project/dotenv-ca.pem"; - expect(resolveCodexCliUpdateTarget("latest", spawn, boundEnv).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn, boundEnv)).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; expect(env.PATH).toBe(boundEnv.PATH); @@ -574,14 +698,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"); @@ -596,13 +720,13 @@ describe("registry configuration isolation", () => { "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "LD_DEBUG_OUTPUT", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "ld_preload", "dyld_insert_libraries", - ])("ambient loader control %s cannot reach the spawned npm", key => { + ])("ambient loader control %s cannot reach the spawned npm", async key => { const previous = process.env[key]; process.env[key] = "/synthetic/nonexistent-loader-fixture"; try { // Capture only: no loader, native child process or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -614,7 +738,7 @@ describe("registry configuration isolation", () => { } }); - test("hostile Node startup channels cannot reach the spawned npm", () => { + test("hostile Node startup channels cannot reach the spawned npm", async () => { const priorOptions = process.env.NODE_OPTIONS; const priorPath = process.env.NODE_PATH; const priorTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED; @@ -627,7 +751,7 @@ describe("registry configuration isolation", () => { process.env.NODE_REDIRECT_WARNINGS = "C:\\evil-warnings.log"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -655,13 +779,13 @@ describe("registry configuration isolation", () => { test.each([ "OPENSSL_CONF", "OPENSSL_CONF_INCLUDE", "OPENSSL_MODULES", "OPENSSL_ENGINES", "openssl_conf", "openssl_modules", - ])("ambient OpenSSL startup control %s cannot reach the spawned npm", key => { + ])("ambient OpenSSL startup control %s cannot reach the spawned npm", async key => { const previous = process.env[key]; process.env[key] = join(tmpdir(), "ocx-ambient-openssl.cnf"); try { // Capture only: no child process, native provider or network is invoked. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; @@ -673,7 +797,7 @@ describe("registry configuration isolation", () => { } }); - test("supported proxy and CA channels still reach the spawned npm", () => { + test("supported proxy and CA channels still reach the spawned npm", async () => { const sentinels = { HTTP_PROXY: "http://127.0.0.1:9/ocx-proxy-sentinel", NO_PROXY: "registry.npmjs.org.invalid,127.0.0.1", @@ -683,7 +807,7 @@ describe("registry configuration isolation", () => { try { Object.assign(process.env, sentinels); const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); for (const call of calls) { const env = call.options.env as NodeJS.ProcessEnv; for (const [key, value] of Object.entries(sentinels)) { @@ -701,7 +825,7 @@ describe("registry configuration isolation", () => { } }); - test("real Node cannot write output sentinels outside the owned npm root", () => { + test("real Node cannot write output sentinels outside the owned npm root", async () => { const fixture = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-node-output-"))); const sentinels = { NODE_V8_COVERAGE: join(fixture, "escaped-coverage"), @@ -749,9 +873,9 @@ describe("registry configuration isolation", () => { checked = true; } const field = queriedField({ bin, args, options } as CapturedCall); - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(checked).toBe(true); expect(existsSync(ownedRoot)).toBe(false); } finally { @@ -764,9 +888,9 @@ describe("registry configuration isolation", () => { } }); - test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", () => { + test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - resolveCodexCliUpdateTarget("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + await resolveArtifact("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); // With no trusted PATH the ambient PATH must never reach the child: Windows // resolves the binary itself and spawns nothing, while POSIX spawns the bare // npm name whose execvp lookup then fails under the explicit empty PATH. @@ -777,13 +901,13 @@ describe("registry configuration isolation", () => { // spawn that honours the empty PATH fails and the target stays unresolved. const enoent = { status: null as number | null, stdout: "", stderr: "spawnSync npm ENOENT", error: new Error("spawnSync npm ENOENT") }; const failingSpawn = (() => ({ ...enoent })) as never; - const refused = resolveCodexCliUpdateTarget("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + const refused = await resolveArtifact("latest", failingSpawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); expect(refused.kind).toBe("unresolved"); }); - test("npm and Node caches and logs stay inside the owned temporary root", () => { + test("npm and Node caches and logs stay inside the owned temporary root", async () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn); + const target = await resolveArtifact("latest", spawn); expect(target.kind).toBe("resolved"); expect(calls.length).toBe(3); for (const call of calls) { @@ -795,7 +919,7 @@ describe("registry configuration isolation", () => { } }); - test("real npm startup through the resolver seam cannot recreate an ambient Node compile cache", () => { + test("real npm startup through the resolver seam cannot recreate an ambient Node compile cache", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-update-node-cache-")); const home = join(root, "home"), temporaryRoot = join(root, "temp"), ambientCache = join(root, "ambient-cache"); const previous = process.env.NODE_COMPILE_CACHE; @@ -822,9 +946,9 @@ describe("registry configuration isolation", () => { observedCache = env.NODE_COMPILE_CACHE; } // Only registry evidence is synthetic; npm startup above is real. - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(calls).toBe(3); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(temporaryRoot)).toEqual([]); @@ -838,7 +962,7 @@ describe("registry configuration isolation", () => { } }); - test.skipIf(process.platform === "win32")("real npm accepts both isolated config paths without any registry access", () => { + test.skipIf(process.platform === "win32")("real npm accepts both isolated config paths without any registry access", async () => { // POSIX uses a flat argv. Windows planning remains deferred; its quoted command // and file isolation are covered above without treating a shell line as argv. const fakeHome = mkdtempSync(join(tmpdir(), "ocx-update-config-test-")); @@ -864,9 +988,9 @@ describe("registry configuration isolation", () => { expect(result.stderr).not.toContain("double-loading config"); configReads++; } - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect((await resolveArtifact("latest", spawn)).kind).toBe("resolved"); expect(configReads).toBe(1); expect(existsSync(isolatedDir)).toBe(false); expect(readdirSync(fakeHome)).toEqual([]); @@ -875,7 +999,7 @@ describe("registry configuration isolation", () => { } }); - test("a real npm call under the isolated argv leaves the operator HOME untouched", () => { + test("a real npm call under the isolated argv leaves the operator HOME untouched", async () => { // Runs real npm exactly once: the first captured query executes for real with a // disposable HOME while the rest stay stubbed. npm_config_offline makes the // registry leg fail fast without network; the residue assertions hold either way. @@ -894,9 +1018,12 @@ describe("registry configuration isolation", () => { }; calls.push(call); const field = queriedField(call); - if (realRan) return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + if (realRan) return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); const env = { ...(options.env as NodeJS.ProcessEnv), HOME: fakeHome, USERPROFILE: fakeHome, npm_config_offline: "true" }; - const result = spawnSync(bin, args, { ...options, env, timeout: 30_000, windowsHide: true, encoding: "utf8" }); + const offlineArgs = options.windowsVerbatimArguments + ? [...args.slice(0, 3), args[3]!.slice(0, -1) + ' --offline --fetch-retries=0 --fetch-timeout=1000"'] + : [...args, "--offline", "--fetch-retries=0", "--fetch-timeout=1000"]; + const result = spawnSync(bin, offlineArgs, { ...options, env, timeout: 10_000, windowsHide: true, encoding: "utf8" }); realRan = true; // A spawn that never started (ENOENT, timeout kill) proves nothing; require // a real process exit so the residue assertions actually bound npm's writes. @@ -906,9 +1033,9 @@ describe("registry configuration isolation", () => { // The owned root is still live here, so residue inside it is observable // before the resolver's finally removes it. for (const name of readdirSync(cwd)) ownedDuringRun.push(name); - return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + return (capturingSpawn(RESOLVE_OUTPUTS).spawn as typeof spawnSync)(bin, args, options); }) as never; - resolveCodexCliUpdateTarget("latest", spawn); + await resolveArtifact("latest", spawn); expect(realRan).toBe(true); const cwd = calls[0]!.options.cwd as string; // Anything npm persisted was inside the owned root and is removed with it; @@ -921,5 +1048,5 @@ describe("registry configuration isolation", () => { } finally { rmSync(fakeHome, { recursive: true, force: true }); } - }); + }, 15_000); }); diff --git a/tests/codex-integration/codex-cli-update-worker.test.ts b/tests/codex-integration/codex-cli-update-worker.test.ts new file mode 100644 index 00000000000..ba4b970e1b1 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-worker.test.ts @@ -0,0 +1,545 @@ +/** + * Worker-dispatch integration coverage for the Codex CLI update offload. + * + * The generic gate/fence and fixture-worker cases below prove the dispatch + * contract; they do not by themselves prove the production callers are + * protected — the real plan/apply worker path and the seven gated parent + * operations have their own regression cases elsewhere. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodexCliApplyDeps } from "../../src/codex/cli-update-apply"; +import type { TrustedNodeLaunchContext } from "../../src/cli/launcher-context"; +import { + runCodexCliUpdatePlanInWorker, runCodexCliUpdateApplyInWorker, + cancelCodexCliUpdateWorkerJob, codexCliUpdateUnjoinedWorkerCount, + codexCliUpdateUnjoinedWorkerClosedTotal, codexCliUpdateWorkerDiagnostics, +} from "../../src/codex/cli-update-job-worker"; +import { + createCodexCliUpdateFence, cancelCodexCliUpdateFence, codexCliUpdateIsCancelled, + codexCliUpdateSelectionGate, beginCodexCliSelectionParentWrite, + createWorkerCommitWindow, resetCodexCliSelectionGatesForTests, +} from "../../src/codex/cli-update-selection-gate"; +import { codexCliUpdateInspectionDepsFromSnapshot } from "../../src/codex/cli-update-inspection-context"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { spawnTestChild } from "../helpers/spawn-test-child"; +import { repoRoot } from "../helpers/repo-root"; +import { + FIXTURE_ENTERED, FIXTURE_RELEASE, FIXTURE_RUNS, FIXTURE_FINISHED, + FIXTURE_CONTROL_WORDS, type FixtureConfiguration, +} from "../helpers/cli-update-fixture-worker"; +type Snapshot = TrustedNodeLaunchContext["codexCliInspectionEnv"]; +type RunMessage = { + type: "run"; requestId: string; jobId: string; operation: "plan" | "apply"; + planId?: string; snapshot: Snapshot; gate: SharedArrayBuffer; fence: SharedArrayBuffer; + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +}; +const planId = "a".repeat(32); +const roots: string[] = []; +let jobSequence = 0; +const workers: OwnedWorker[] = []; +function snapshot(configDir: string): NonNullable { + return { + codexCliPath: join(configDir, "selected", "codex.cmd"), + path: "", pathExt: ".EXE;.CMD", home: configDir, nodePath: null, + managerRoots: {}, transportEnv: {}, arch: process.arch, configDir, + }; +} +function home() { + const root = mkdtempSync(join(tmpdir(), "ocx-update-worker-")); + roots.push(root); + return root; +} +// Deadline is only a stuck-fixture watchdog, never a performance threshold: +// a cold production-worker module import on Windows legitimately takes +// seconds, so the bound is generous and only failure paths ever reach it. +// No main-thread Atomics.wait: it would invalidate the responsiveness test. +async function until(check: () => boolean, label: string): Promise { + const deadline = performance.now() + 15_000; + while (!check()) { + if (performance.now() >= deadline) throw new Error("fixture did not reach " + label); + await new Promise(resolve => setTimeout(resolve, 0)); + } +} +interface OwnedWorker { + worker: Worker; + control: Int32Array; + runMessages: RunMessage[]; + messages: unknown[]; + terminateCalls: number; + closed: boolean; + errors: string[]; + finished: Promise; + detach(): void; + release(): void; +} +function ownWorker(url: URL, config?: Omit): OwnedWorker { + const worker = new Worker(url, { type: "module" }); + const control = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * FIXTURE_CONTROL_WORDS)); + let complete!: () => void; + const finished = new Promise(resolve => { complete = resolve; }); + const owner: OwnedWorker = { + worker, control, runMessages: [], messages: [], terminateCalls: 0, + closed: false, errors: [], finished, + release() { Atomics.store(control, FIXTURE_RELEASE, 1); Atomics.notify(control, FIXTURE_RELEASE); }, + detach() { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + worker.removeEventListener("close", onClose); + }, + }; + const onMessage = (event: MessageEvent) => { owner.messages.push(event.data); }; + const onError = (event: ErrorEvent) => { + owner.errors.push(event.message); + event.preventDefault(); // host's own listener still observes the same error + }; + const onClose = () => { owner.closed = true; complete(); }; + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); + worker.addEventListener("close", onClose); + const post = worker.postMessage.bind(worker); + Object.defineProperty(worker, "postMessage", { + configurable: true, + value: (...args: Parameters) => { + const message = args[0] as Partial | undefined; + if (message?.type === "run") owner.runMessages.push(message as RunMessage); + return Reflect.apply(post, worker, args); + }, + }); + const terminate = worker.terminate.bind(worker); + Object.defineProperty(worker, "terminate", { + configurable: true, + value: (...args: Parameters) => { + owner.terminateCalls++; + return Reflect.apply(terminate, worker, args); + }, + }); + workers.push(owner); + if (config) post({ type: "fixture-config", control: control.buffer, ...config }); + return owner; +} +function fixture(config: Omit = {}) { + const owner = ownWorker(new URL("../helpers/cli-update-fixture-worker.ts", import.meta.url), config); + const configDir = home(), jobId = "fixture-job-" + (++jobSequence); + const options = { + jobId, snapshot: snapshot(configDir), + env: { CODEX_HOME: join(configDir, "codex"), OPENCODEX_HOME: configDir }, + spawnWorkerFn: (_url: string, _key: string) => owner.worker, + }; + return { owner, options, jobId, configDir }; +} +async function joined(owner: OwnedWorker) { + await until(() => owner.closed, "worker close"); + await owner.finished; + expect(owner.terminateCalls).toBe(0); + expect(owner.runMessages).toHaveLength(1); +} +afterEach(async () => { + // Release every exact fixture even if an assertion failed. Never terminate a + // dispatched worker whose done result might conceal an owned native child. + // A failed close wait must not lose the remaining owners: every worker gets + // its release+join attempt, and one that still will not close is re-queued + // so a later afterEach still owns it instead of leaking it untracked. + const owned = workers.splice(0); + const abandoned: OwnedWorker[] = []; + let firstError: unknown; + for (const owner of owned) { + try { + owner.release(); + if (!owner.closed) { + // A fixture before its RunMsg may be idle; FIFO close is harmless after run. + try { owner.worker.postMessage({ type: "fixture-close" }); } + catch { /* A concurrently closing worker still must emit the close observed below. */ } + } + await until(() => owner.closed, "owned worker close during cleanup"); + await owner.finished; + expect(owner.terminateCalls).toBe(0); + } catch (error) { + if (firstError === undefined) firstError = error; + if (!owner.closed) abandoned.push(owner); + } finally { + owner.detach(); + } + } + workers.push(...abandoned); + // Do not erase gate state or remove homes until every owned worker has closed. + resetCodexCliSelectionGatesForTests(); + for (const root of roots.splice(0)) await removeTreeAfterBackgroundWork(root); + if (firstError !== undefined) throw firstError; +}); +describe("process-wide selection commit gate and cancellation fence", () => { + test("parent write is nonblocking busy; release restores admission", async () => { + const first = beginCodexCliSelectionParentWrite(); + expect(first.entered).toBe(true); + try { + const second = beginCodexCliSelectionParentWrite(); + try { expect(second.entered).toBe(false); } finally { second.exit(); } + const fence = createCodexCliUpdateFence(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), fence, deadlineMs: 0, + }); + try { + expect(window.tryEnter()).toBe(false); + expect(await window.waitEnter()).toBe(false); + } finally { window.exit(); } + } finally { first.exit(); } + const next = beginCodexCliSelectionParentWrite(); + try { expect(next.entered).toBe(true); } finally { next.exit(); } + }); + test("worker waitEnter wins the gate once the parent writer releases", async () => { + const writer = beginCodexCliSelectionParentWrite(); + expect(writer.entered).toBe(true); + const fence = createCodexCliUpdateFence(); + const window = createWorkerCommitWindow({ + gate: codexCliUpdateSelectionGate(), fence, + // The sleep seam releases the parent write mid-wait deterministically. + sleep: async () => { writer.exit(); }, + }); + try { + expect(await window.waitEnter()).toBe(true); + const blocked = beginCodexCliSelectionParentWrite(); + try { expect(blocked.entered).toBe(false); } finally { blocked.exit(); } + } finally { window.exit(); writer.exit(); } + }); + test("all callers share one gate, including aliased and different directory snapshots", () => { + const root = home(); + const selections = [snapshot(root), snapshot(join(root, ".")), snapshot(join(root, "other"))]; + const gate = codexCliUpdateSelectionGate(); + const window = createWorkerCommitWindow({ gate, fence: createCodexCliUpdateFence() }); + expect(window.tryEnter()).toBe(true); + try { + for (const selected of selections) { + // Gate intentionally takes no configDir: aliases cannot form a second lock. + expect(selected.configDir).toBeDefined(); + expect(codexCliUpdateSelectionGate()).toBe(gate); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + } + } finally { window.exit(); } + // This tests shared gate behavior, not filesystem alias resolution or callers. + }); + test("cancel CAS wins before commit and cannot be overwritten", () => { + const fence = createCodexCliUpdateFence(), cancelled = codexCliUpdateIsCancelled(fence); + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + expect(window.tryEnter()).toBe(true); + try { + expect(cancelled()).toBe(false); + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(cancelCodexCliUpdateFence(fence)).toBe(false); + expect(window.commit()).toBe(false); + expect(cancelled()).toBe(true); + } finally { window.exit(); } + }); + test("committed CAS wins; later cancellation is deferred, not reported aborted", () => { + const fence = createCodexCliUpdateFence(), cancelled = codexCliUpdateIsCancelled(fence); + const window = createWorkerCommitWindow({ gate: codexCliUpdateSelectionGate(), fence }); + expect(window.tryEnter()).toBe(true); + try { + expect(window.commit()).toBe(true); + expect(cancelCodexCliUpdateFence(fence)).toBe(false); + expect(cancelled()).toBe(false); + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(false); } finally { writer.exit(); } + } finally { window.exit(); } + const writer = beginCodexCliSelectionParentWrite(); + try { expect(writer.entered).toBe(true); } finally { writer.exit(); } + }); +}); +describe("actual Worker dispatch and parent responsiveness", () => { + test("fresh main-thread entry and fixture imports preserve the message sentinel", async () => { + const urls = ["../../src/codex/cli-update-worker.ts", "../helpers/cli-update-fixture-worker.ts"] + .map(path => new URL(path, import.meta.url).href); + // A new main process has imported neither module before installing the sentinel; + // no query-string cache assumption or extra production export is needed. + const program = 'if(Bun.isMainThread!==true)throw new Error("not main");' + + 'const sentinel=()=>{};globalThis.onmessage=sentinel;let imports=0;' + + `try{for(const url of ${JSON.stringify(urls)}){await import(url);imports++;` + + 'if(globalThis.onmessage!==sentinel)throw new Error("main handler replaced");}' + + 'console.log(JSON.stringify({main:Bun.isMainThread,imports,sentinelPreserved:globalThis.onmessage===sentinel}));' + + '}finally{globalThis.onmessage=null;}'; + const result = await spawnTestChild([process.execPath, "--no-env-file", "--no-install", "-e", program], + { cwd: repoRoot(), env: process.env, deadlineMs: 2_500 }); + expect(result.timedOut, `${result.stdout}\n${result.stderr}`).toBe(false); + expect(result.reaped && result.pipesClosed).toBe(true); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ main: true, imports: 2, sentinelPreserved: true }); + }); + test("detached message handler keeps a real Bun child alive until observed exit", async () => { + const releasePath = join(home(), "release-child"); + const f = fixture({ reply: "ambiguous", childDrain: { releasePath } }); + const initial = codexCliUpdateUnjoinedWorkerCount(); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 10 }); + try { + expect(await operation).toMatchObject({ outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true }); + const started = f.owner.messages.find(m => (m as { type?: string }).type === "fixture-child-started") as { pid: number }; + expect(started?.pid).toBeGreaterThan(0); + expect(f.owner.closed).toBe(false); + expect(f.owner.terminateCalls).toBe(0); + expect(codexCliUpdateUnjoinedWorkerCount()).toBe(initial + 1); + expect(f.owner.messages.some(m => (m as { type?: string }).type === "fixture-child-exited")).toBe(false); + } finally { writeFileSync(releasePath, "release"); } + await joined(f.owner); + expect(f.owner.messages).toContainEqual(expect.objectContaining({ type: "fixture-child-exited", exitCode: 0 })); + await until(() => codexCliUpdateUnjoinedWorkerCount() === initial, "real child owner drained"); + }); + test("parent HTTP health and stream advance while the Worker is synchronously blocked", async () => { + const f = fixture({ block: "before-reply" }); + let push!: (bytes: Uint8Array) => void; + let finishStream!: () => void; + const encoder = new TextEncoder(); + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/health") return new Response("healthy"); + return new Response(new ReadableStream({ + start(controller) { + push = bytes => controller.enqueue(bytes); + finishStream = () => controller.close(); + controller.enqueue(encoder.encode("first")); + }, + })); + }, + }); + const operation = runCodexCliUpdateApplyInWorker(planId, f.options); + // Install a rejection consumer immediately; finally still joins the same run. + void operation.catch(() => {}); + let reader: ReadableStreamDefaultReader | undefined; + const write = beginCodexCliSelectionParentWrite(); + expect(write.entered).toBe(true); + try { + await until(() => Atomics.load(f.owner.control, FIXTURE_ENTERED) === 1, "sync worker barrier"); + expect(Atomics.load(f.owner.control, FIXTURE_RELEASE)).toBe(0); + expect(await (await fetch(new URL("/health", server.url))).text()).toBe("healthy"); + reader = (await fetch(new URL("/stream", server.url))).body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("first"); + push(encoder.encode("second")); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("second"); + expect(Atomics.load(f.owner.control, FIXTURE_FINISHED)).toBe(0); + // General parent HTTP/stream work continues even with the selection gate busy. + finishStream(); + expect((await reader.read()).done).toBe(true); + } finally { + write.exit(); + await reader?.cancel(); + server.stop(true); + f.owner.release(); + } + expect(await operation).toMatchObject({ outcome: "refused", reason: "preparation_failed" }); + await joined(f.owner); + }); + test("done is not sufficient: result stays pending until Worker close", async () => { + const f = fixture({ block: "after-reply" }); + let settled = false; + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 1_000 }); + void operation.then(() => { settled = true; }, () => { settled = true; }); + try { + await until(() => f.owner.messages.some(m => (m as { type?: string }).type === "done"), "done message"); + await Promise.resolve(); + expect(settled).toBe(false); + expect(f.owner.closed).toBe(false); + } finally { f.owner.release(); } + expect(await operation).toMatchObject({ outcome: "refused", reason: "preparation_failed" }); + await joined(f.owner); + }); + test("unknown-child done without close becomes ambiguous and NEVER terminates worker", async () => { + const f = fixture({ reply: "ambiguous", block: "after-reply" }); + const initial = codexCliUpdateUnjoinedWorkerCount(); + const initialClosed = codexCliUpdateUnjoinedWorkerClosedTotal(); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, closeGraceMs: 10 }); + try { + const result = await operation; + expect(result).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + expect(f.owner.closed).toBe(false); + expect(f.owner.messages).toContainEqual(expect.objectContaining({ + type: "done", result: expect.objectContaining({ + reason: "installer_state_unknown", leaseRetained: true, verificationPid: 424242, + }), + })); + expect(Atomics.load(f.owner.control, FIXTURE_RELEASE)).toBe(0); + expect(f.owner.terminateCalls).toBe(0); + expect(codexCliUpdateUnjoinedWorkerCount()).toBe(initial + 1); + expect(f.owner.runMessages).toHaveLength(1); + expect(cancelCodexCliUpdateWorkerJob(f.jobId)).toBe(false); + // The settled job is off the live ledger but its Worker is still tracked. + const diagnostics = codexCliUpdateWorkerDiagnostics(); + expect(diagnostics.gate).toBe("free"); + expect(diagnostics.liveJobs.some(job => job.jobId === f.jobId)).toBe(false); + expect(diagnostics.unjoinedWorkers).toBe(initial + 1); + } finally { f.owner.release(); } + await joined(f.owner); + await until(() => codexCliUpdateUnjoinedWorkerCount() === initial, "unjoined set removal on actual close"); + // Removed from the live set but kept as evidence: closed-after-unknown total. + await until(() => codexCliUpdateUnjoinedWorkerClosedTotal() === initialClosed + 1, + "closed-after-unknown diagnostic"); + }); + test("real AbortSignal listener and job cancellation reach the same preparing fence", async () => { + const f = fixture({ block: "before-reply" }); + const controller = new AbortController(); + let abortEvents = 0; + const onAbort = () => { abortEvents++; }; + controller.signal.addEventListener("abort", onAbort, { once: true }); + const operation = runCodexCliUpdateApplyInWorker(planId, { ...f.options, signal: controller.signal }); + void operation.catch(() => {}); + try { + await until(() => Atomics.load(f.owner.control, FIXTURE_ENTERED) === 1, "cancellable worker"); + const run = f.owner.runMessages[0]!; + expect(controller.signal).toBeInstanceOf(AbortSignal); + controller.abort(); + expect(abortEvents).toBe(1); + expect(codexCliUpdateIsCancelled(run.fence)()).toBe(true); + expect(cancelCodexCliUpdateWorkerJob(f.jobId)).toBe(false); + const window = createWorkerCommitWindow({ gate: run.gate, fence: run.fence }); + try { expect(window.commit()).toBe(false); } finally { window.exit(); } + } finally { + controller.signal.removeEventListener("abort", onAbort); + f.owner.release(); + } + await operation; + await joined(f.owner); + }); + test("AbortSignal remains a real native-fetch signal, separate from synchronous SAB cancellation", async () => { + const fence = createCodexCliUpdateFence(), controller = new AbortController(); + const deps: Pick = { + signal: controller.signal, isCancelled: codexCliUpdateIsCancelled(fence), + }; + let requestSeen = false; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch() { requestSeen = true; return new Promise(() => {}); }, + }); + const request = fetch(server.url, { signal: deps.signal }); + // Consume rejection immediately; abort is intentionally issued after request admission. + const outcome = request.then(() => ({ rejected: false }), error => ({ rejected: true, error })); + try { + await until(() => requestSeen, "native fetch admission"); + expect(deps.signal).toBeInstanceOf(AbortSignal); + expect(cancelCodexCliUpdateFence(fence)).toBe(true); + expect(deps.isCancelled!()).toBe(true); + expect(deps.signal!.aborted).toBe(false); // SAB hook did not widen/replace the signal. + controller.abort(); + expect((await outcome).rejected).toBe(true); + expect(deps.signal!.aborted).toBe(true); + } finally { controller.abort(); server.stop(true); await outcome; } + }); +}); +describe("reply correlation, loss and run-once ownership", () => { + for (const correlation of ["stale-first", "duplicate"] as const) { + test(correlation + " cannot replace the first matching reply or replay apply", async () => { + const f = fixture({ correlation }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "refused", reason: "preparation_failed", leaseRetained: false, + }); + await joined(f.owner); + expect(Atomics.load(f.owner.control, FIXTURE_RUNS)).toBe(1); + }); + } + for (const mode of ["none", "die", "throw", "malformed", "error"] as const) { + test(mode + " after run delivery is ambiguous, never a safe preparation refusal", async () => { + const f = fixture({ reply: mode }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + expect(Atomics.load(f.owner.control, FIXTURE_RUNS)).toBe(1); + }); + } + for (const staleField of ["requestId", "jobId", "both"] as const) { + test("wrong " + staleField + " followed by close does not qualify as done", async () => { + const f = fixture({ correlation: "stale-only", staleField }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + }); + } + test("spawn failure before any run delivery is refused once", async () => { + let attempts = 0; + const result = await runCodexCliUpdateApplyInWorker(planId, { + jobId: "never-started", snapshot: snapshot(home()), + spawnWorkerFn() { attempts++; throw new Error("fixture-spawn-failed"); }, + }); + expect(result).toMatchObject({ outcome: "refused", reason: "preparation_failed", leaseRetained: false }); + expect(attempts).toBe(1); + }); + test("postMessage throwing AFTER possible delivery must not falsely claim not started", async () => { + const f = fixture({}); + const post = f.owner.worker.postMessage.bind(f.owner.worker); + Object.defineProperty(f.owner.worker, "postMessage", { + configurable: true, + value: (...args: Parameters) => { + Reflect.apply(post, f.owner.worker, args); + if ((args[0] as { type?: string }).type === "run") throw new Error("after-delivery-fixture-error"); + }, + }); + expect(await runCodexCliUpdateApplyInWorker(planId, f.options)).toMatchObject({ + outcome: "ambiguous", reason: "job_result_unknown", leaseRetained: true, + }); + await joined(f.owner); + }); + test("plan loss rejects plan_unavailable without a retry", async () => { + const f = fixture({ reply: "none" }); + await expect(runCodexCliUpdatePlanInWorker(f.options)).rejects.toThrow("plan_unavailable"); + await joined(f.owner); + }); + test("production entry ignores duplicate/late RunMsg and emits one correlated reply", async () => { + const owner = ownWorker(new URL("../../src/codex/cli-update-worker.ts", import.meta.url)); + const run: RunMessage = { + type: "run", operation: "plan", requestId: "run-once-request", jobId: "run-once-job", + snapshot: null, gate: codexCliUpdateSelectionGate(), fence: createCodexCliUpdateFence(), + env: { CODEX_HOME: home(), OPENCODEX_HOME: home() }, + }; + // Null trusted snapshot fails closed and must never query a real provider. + owner.worker.postMessage(run); + owner.worker.postMessage({ ...run, requestId: "late-request", jobId: "late-job" }); + await until(() => owner.closed, "production worker close"); + const replies = owner.messages.filter(m => ["done", "error"].includes((m as { type: string }).type)); + expect(replies).toHaveLength(1); + expect(replies[0]).toMatchObject({ requestId: run.requestId, jobId: run.jobId }); + expect(owner.terminateCalls).toBe(0); + // Two input messages are intentional here. No host retry/install replay occurred. + }); +}); +describe("snapshot target does not fall back to ambient homes", () => { + test("explicit snapshot and worker-home override remain distinct", async () => { + const f = fixture({}), ambient = home(); + const original = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = ambient; + try { + const captured = f.options.snapshot; + expect(await runCodexCliUpdateApplyInWorker(planId, { + ...f.options, env: { OPENCODEX_HOME: ambient, CODEX_HOME: ambient }, + })).toMatchObject({ outcome: "refused" }); + await joined(f.owner); + expect(f.owner.runMessages[0]!.snapshot).toEqual(captured); + expect(f.owner.runMessages[0]!.snapshot!.configDir).toBe(f.configDir); + expect(f.owner.runMessages[0]!.env).toEqual({ OPENCODEX_HOME: ambient, CODEX_HOME: ambient }); + const deps = codexCliUpdateInspectionDepsFromSnapshot(captured); + expect(deps.configDir).toBe(f.configDir); + expect(deps.env?.CODEX_CLI_PATH).toBe(captured.codexCliPath!); + expect(deps.env?.HOME).toBe(captured.home!); + } finally { + if (original === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = original; + } + }); + test("null or missing manager-root proof fails closed without ambient backfill", () => { + const original = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home(); + try { + for (const captured of [null, { ...snapshot(home()), managerRoots: null }]) { + const deps = codexCliUpdateInspectionDepsFromSnapshot(captured); + expect(deps.env?.PATH).toBe(""); + expect(deps.configDir).toBe("."); + expect(deps.env?.CODEX_CLI_PATH).toBeUndefined(); + } + } finally { + if (original === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = original; + } + }); +}); + diff --git a/tests/codex-integration/codex-cli-update-zero-effect.test.ts b/tests/codex-integration/codex-cli-update-zero-effect.test.ts index d985ea3144d..5b14b96ea2c 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, apply or status"); expect(readFileSync(statePath)).toEqual(before); expect(existsSync(marker)).toBe(false); }); diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 7b5286692c3..1ef7916625a 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -40,6 +40,7 @@ import type { OcxConfig } from "../../src/types"; import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; import { catalogEntryEfforts } from "../../src/codex/catalog/effort"; import { + loadPersistedCodexRuntime, resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests, } from "../../src/codex/runtime"; @@ -693,6 +694,9 @@ test("retained sync removes a deleted pre-marker custom row while discovery is d test("retained and convergence writers resolve, clear, reject, and recover auto-review selectors", async () => { primeCodexRuntimeFixture(); + const observedRuntime = loadPersistedCodexRuntime(); + expect(observedRuntime?.command === process.env.CODEX_CLI_PATH).toBe(true); + expect(observedRuntime?.selectedVersion).toBe("0.145.0"); for (const writer of ["retained", "convergence"] as const) { const write = async (nextConfig: OcxConfig): Promise => { @@ -703,6 +707,8 @@ test("retained and convergence writers resolve, clear, reject, and recover auto- const result = await syncCatalogModels(nextConfig); expect(result.catalogWritten).toBe(true); } else { + // Selector behavior needs the observed persisted runtime, not an incidental 15s process memo. + resetCodexRuntimeResolveCacheForTests(); const disposition = await convergeCatalogDisposition(nextConfig); expect(disposition).toMatchObject({ status: "committed" }); } diff --git a/tests/codex-integration/codex-convergence-contract.test.ts b/tests/codex-integration/codex-convergence-contract.test.ts index b7e9b69c9af..5d38840e690 100644 --- a/tests/codex-integration/codex-convergence-contract.test.ts +++ b/tests/codex-integration/codex-convergence-contract.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { existsSync, @@ -23,11 +23,15 @@ import { } from "../../src/codex/convergence"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; import { + codexRuntimeStateEpoch, + loadPersistedCodexRuntime, + peekCodexRuntimeProcessCache, persistCodexRuntime, resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests, } from "../../src/codex/runtime"; import { + bundledCatalogCacheState, invalidateBundledCatalogCache, setBundledCatalogCacheForTests, } from "../../src/codex/catalog/bundled"; @@ -210,6 +214,83 @@ test("used process-local authority drift rejects before every catalog target wri expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); }); +// No await or fake timers inside this scope: even a busy commit restores real time before yielding. +function atObservedNow(now: number, read: (restore: () => void) => T): T { + const clock = spyOn(Date, "now").mockReturnValue(now); + let restored = false; + const restore = () => { if (!restored) { restored = true; clock.mockRestore(); } }; + try { return read(restore); } finally { restore(); } +} + +function seedBoundaryRuntime(now: number) { + const runtime = { command: join(root, "observed-runtime"), version: "0.146.0", source: "environment" as const }; + persistCodexRuntime(runtime); + atObservedNow(now, () => { + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }); + setBundledCatalogCacheForTests(runtime, JSON.parse(sourceCatalog("bundled-boundary")) as never); + }); + expect(loadPersistedCodexRuntime()?.command === runtime.command).toBe(true); + expect(loadPersistedCodexRuntime()?.selectedVersion).toBe(runtime.version); + return runtime; +} + +test.each([14_999, 15_000])("adopted runtime memo at age %i preserves the convergence stale boundary", async age => { + const start = Date.now(); seedBoundaryRuntime(start); + const memo = atObservedNow(start + 14_999, () => peekCodexRuntimeProcessCache()); + expect(memo.kind).toBe("available"); + const epoch = codexRuntimeStateEpoch(), bundled = bundledCatalogCacheState(); + // The real gather selects its process memo synchronously before its first await. + const gathered = await atObservedNow(start + 14_999, () => candidate()); + expect(codexRuntimeStateEpoch()).toBe(epoch); + const before = manifest(codexHome); + let guardedInClockTurn = false; + // Zero deadline means one real synchronous acquisition attempt; busy is a failure, never a retry. + const result = await atObservedNow(start + age, restore => commitCodexCatalogCandidate(gathered, 0, { + beforeCommit: () => { guardedInClockTurn = Date.now() === start + age; restore(); return true; }, + })); + expect(codexRuntimeStateEpoch()).toBe(epoch); + expect(bundledCatalogCacheState()).toEqual(bundled); + if (age === 14_999) { + expect(guardedInClockTurn).toBe(true); + expect(result.kind).toBe("committed"); + } else { + expect(guardedInClockTurn).toBe(false); + expect(result).toEqual({ kind: "stale", reason: "process-local" }); + expect(manifest(codexHome)).toEqual(before); + } +}); + +test("persisted runtime authority commits across memo expiry after a pre-admission reset", async () => { + const start = Date.now(); seedBoundaryRuntime(start); + const bundled = bundledCatalogCacheState(); + resetCodexRuntimeResolveCacheForTests(); + expect(atObservedNow(start + 14_999, () => peekCodexRuntimeProcessCache()).kind).toBe("unavailable"); + const epoch = codexRuntimeStateEpoch(); + const gathered = await atObservedNow(start + 14_999, () => candidate()); + let guardedInClockTurn = false; + const result = await atObservedNow(start + 15_000, restore => commitCodexCatalogCandidate(gathered, 0, { + beforeCommit: () => { guardedInClockTurn = Date.now() === start + 15_000; restore(); return true; }, + })); + expect(guardedInClockTurn).toBe(true); + expect(result.kind).toBe("committed"); + expect(codexRuntimeStateEpoch()).toBe(epoch); + expect(bundledCatalogCacheState()).toEqual(bundled); +}); + +test("persisted runtime replacement after gather still refuses without catalog writes", async () => { + const start = Date.now(), runtime = seedBoundaryRuntime(start); + resetCodexRuntimeResolveCacheForTests(); + const gathered = await atObservedNow(start + 14_999, () => candidate()); + const before = manifest(codexHome); + persistCodexRuntime({ ...runtime, command: join(root, "replacement-runtime"), version: "0.147.0" }); + expect(loadPersistedCodexRuntime()?.selectedVersion).toBe("0.147.0"); + expect(await atObservedNow(start + 15_000, restore => commitCodexCatalogCandidate(gathered, 0, { + beforeCommit: () => { restore(); return true; }, + }))) + .toEqual({ kind: "stale", reason: "source-observation" }); + expect(manifest(codexHome)).toEqual(before); +}); + test("custom catalog runtime-support drift rejects before every catalog target write", async () => { const runtime = { command: "/tmp/codex", version: "0.146.0", source: "environment" as const }; const customPath = join(codexHome, "custom-catalog.json"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6f146abcd3c..499d17e1938 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,9 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "service-proxy-env.test.ts": "service", "bridge-terminal-refusal.test.ts": "adapters", + "codex-cli-update-artifacts.test.ts": "codex-integration", + "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-worker.test.ts": "codex-integration", "responses-replay-metadata-principal.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", "responses-translated-replay-principal.test.ts": "responses", diff --git a/tests/helpers/cli-update-fixture-worker.ts b/tests/helpers/cli-update-fixture-worker.ts new file mode 100644 index 00000000000..9e361dd13c0 --- /dev/null +++ b/tests/helpers/cli-update-fixture-worker.ts @@ -0,0 +1,146 @@ +/** + * Test fixture only. No provider, installer, or production worker entry runs. + * Configure before the host's RunMsg via postMessage; Worker FIFO preserves this order. + * Every blocking fixture must be released through its SAB in the test's finally block. + */ +export const FIXTURE_ENTERED = 0; +export const FIXTURE_RELEASE = 1; +export const FIXTURE_RUNS = 2; +export const FIXTURE_REPLIES = 3; +export const FIXTURE_FINISHED = 4; +export const FIXTURE_COMMIT_RESULT = 5; +export const FIXTURE_CONTROL_WORDS = 6; + +export interface FixtureConfiguration { + type: "fixture-config"; + control: SharedArrayBuffer; + block?: "before-reply" | "after-reply"; + reply?: "refused" | "ambiguous" | "none" | "malformed" | "error" | "throw" | "die"; + correlation?: "matching" | "stale-first" | "stale-only" | "duplicate"; + staleField?: "requestId" | "jobId" | "both"; + /** Uses only the frozen 0/1/2 CAS protocol, not an invented production export. */ + commitFenceBeforeReply?: boolean; + /** Real Bun child waits for a test-owned release file, not a simulated SAB child. */ + childDrain?: { releasePath: string }; +} +interface RunMessage { + type: "run"; + requestId: string; + jobId: string; + operation: "plan" | "apply"; + planId?: string; + channel?: unknown; + snapshot: unknown; + fence: SharedArrayBuffer; + gate: SharedArrayBuffer; + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +} +type ControlMessage = FixtureConfiguration | { type: "fixture-close" }; +type WorkerScope = { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(value: unknown): void; +}; +const scope = globalThis as unknown as WorkerScope; + +if (typeof Bun !== "undefined" && Bun.isMainThread === false + && typeof self !== "undefined" && typeof scope.postMessage === "function") { + let configuration: FixtureConfiguration | undefined; + let running = false; + const send = (view: Int32Array, value: unknown) => { + Atomics.add(view, FIXTURE_REPLIES, 1); + scope.postMessage(value); + }; + const barrier = (view: Int32Array) => { + Atomics.store(view, FIXTURE_ENTERED, 1); + Atomics.notify(view, FIXTURE_ENTERED); + while (Atomics.load(view, FIXTURE_RELEASE) === 0) { + Atomics.wait(view, FIXTURE_RELEASE, 0); + } + }; + scope.onmessage = async event => { + const message = event.data; + if (message.type === "fixture-config") { + if (configuration || running) throw new Error("fixture configured twice"); + configuration = message; + return; + } + if (message.type === "fixture-close") { + scope.onmessage = null; + return; + } + if (message.type !== "run") return; + if (!configuration) throw new Error("fixture must be configured before run"); + const config = configuration, view = new Int32Array(config.control); + Atomics.add(view, FIXTURE_RUNS, 1); + if (running) return; // Match production run-once: never execute a late second run. + running = true; + let child: ReturnType | undefined; + try { + if (config.childDrain) { + child = Bun.spawn([process.execPath, "--no-env-file", "--no-install", "-e", + 'const {existsSync}=await import("node:fs");console.log("ready");const timer=setInterval(()=>{if(existsSync(process.argv[1]))clearInterval(timer)},5);', + config.childDrain.releasePath], { stdin: "ignore", stdout: "pipe", stderr: "ignore" }); + const reader = (child.stdout as ReadableStream).getReader(); + const ready = await reader.read(); reader.releaseLock(); + if (new TextDecoder().decode(ready.value).trim() !== "ready") throw new Error("fixture child not ready"); + scope.postMessage({ type: "fixture-child-started", pid: child.pid }); + } + if (config.block === "before-reply") barrier(view); + if (config.commitFenceBeforeReply) { + // Commit cannot overwrite a parent cancellation that has already won. + const observed = Atomics.compareExchange(new Int32Array(message.fence), 0, 0, 2); + Atomics.store(view, FIXTURE_COMMIT_RESULT, observed); + } + if (config.reply === "throw") throw new Error("fixture-worker-error"); + if (config.reply === "die") return; + if (config.reply === "none") return; + const envelope = { + requestId: message.requestId, jobId: message.jobId, operation: message.operation, + }; + if (config.reply === "error") { + send(view, { type: "error", ...envelope, message: "fixture-error" }); + return; + } + if (config.reply === "malformed") { + send(view, { type: "done", ...envelope, result: { outcome: "invented" } }); + return; + } + // A plan test must supply a confirmed frozen plan payload after API handoff. + if (message.operation !== "apply") throw new Error("fixture plan payload not configured"); + const ambiguous = config.reply === "ambiguous"; + const result = { + outcome: ambiguous ? "ambiguous" : "refused", + reason: ambiguous ? "installer_state_unknown" : "preparation_failed", + planId: message.planId!, + installedVersion: null, targetVersion: null, installerExitCode: null, + leaseRetained: ambiguous, verificationPid: ambiguous ? 424242 : null, + cleanupErrors: [], + }; + if (config.correlation === "stale-first" || config.correlation === "stale-only") { + send(view, { type: "done", ...envelope, + requestId: config.staleField === "jobId" ? message.requestId : "stale-" + message.requestId, + jobId: config.staleField === "requestId" ? message.jobId : "stale-" + message.jobId, + result: { ...result, outcome: "applied", reason: "verified_target" } }); + } + if (config.correlation === "stale-only") return; + send(view, { type: "done", ...envelope, result }); + if (config.correlation === "duplicate") { + // Conflicting late duplicate must not replace the first correlated result. + send(view, { type: "done", ...envelope, + result: { ...result, outcome: "applied", reason: "verified_target" } }); + send(view, { type: "error", ...envelope, message: "late-duplicate-error" }); + } + if (config.block === "after-reply") barrier(view); + } finally { + Atomics.store(view, FIXTURE_FINISHED, 1); + Atomics.notify(view, FIXTURE_FINISHED); + // "none"/"die" deliberately close without replying; after-reply blocks until released. + scope.onmessage = null; + if (child) { + const exitCode = await child.exited; + scope.postMessage({ type: "fixture-child-exited", pid: child.pid, exitCode }); + } + } + }; +} + diff --git a/tests/helpers/codex-cli-apply-artifact.ts b/tests/helpers/codex-cli-apply-artifact.ts new file mode 100644 index 00000000000..ac6fde0e862 --- /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 00000000000..73371d5d17c --- /dev/null +++ b/tests/helpers/codex-cli-apply-fixture.ts @@ -0,0 +1,109 @@ +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"; +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[]) { + // 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; +} +/** 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. + */ +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 => { + 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") { + 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"), + 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) }; +} diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index ff380295506..3aac1d2f267 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -1,9 +1,15 @@ -import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; +import { handleCodexCliUpdateRoutes } from "../../src/server/management/codex-cli-update-routes"; +import * as updateJobs from "../../src/codex/cli-update-job"; +import * as cliUpdatePlanModule from "../../src/codex/cli-update-plan"; +import * as cliUpdateWorkerModule from "../../src/codex/cli-update-job-worker"; +import type { ManagementContext } from "../../src/server/management/context"; +import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; import { SIBLING_REFUSED_MANAGEMENT_PATHS, siblingRefusesManagementRequest } from "../../src/server/management/sibling-guard"; import { markSiblingStart, resetSiblingStartForTests } from "../../src/codex/sibling-start"; import { scanRoutes, distinctRoutes } from "../helpers/management-route-scan"; @@ -59,6 +65,230 @@ 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("a gated registry child leaves concurrent management requests responsive", async () => { + // Regression for the planning P1: a slow npm query must not park the proxy event + // loop. The gated child answers like npm view --json but only once the release + // file appears — with its own cap so a regression cannot hang the suite — and it + // runs through the production async spawn runner, not a mocked event loop. + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-update-gated-npm-"))); + const gateFile = join(dir, "release"); + const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; + const mainJson = JSON.stringify({ name: "@openai/codex", version: "1.4.2", + optionalDependencies: { "@openai/codex-linux-x64": "1.4.2" }, + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz" } }); + const backingJson = JSON.stringify({ name: "@openai/codex-linux-x64", version: "1.4.2", + dist: { integrity, tarball: "https://registry.npmjs.org/@openai/codex-linux-x64/-/codex-linux-x64-1.4.2.tgz" } }); + const gatedSpawn: cliUpdatePlanModule.CodexCliUpdateSpawn = (_bin, args, options) => + cliUpdatePlanModule.spawnCodexCliUpdateMetadata(process.execPath, [ + "-e", + 'const fs=require("node:fs"),g=process.env.OCX_GATE_FILE,a=process.env.OCX_GATE_ANSWER,d=Date.now()+Number(process.env.OCX_GATE_CAP_MS);' + + 'const t=setInterval(()=>{if(!fs.existsSync(g)&&Date.now()<=d)return;clearInterval(t);process.stdout.write(a+"\\n",()=>process.exit(0))},15)', + ], { ...options, windowsVerbatimArguments: false, + env: { ...options.env, OCX_GATE_FILE: gateFile, OCX_GATE_CAP_MS: "4000", + OCX_GATE_ANSWER: args.join(" ").includes("@openai/codex-linux-x64@") ? backingJson : mainJson } }); + const managed = { schemaVersion: 1, candidateAvailable: true, candidateVersion: "1.0.0", + candidateSource: "environment", selectionAttested: true, + versionEvidence: { kind: "package-manifest" }, provenance: "npm-global", managed: true, + reason: "managed_npm_global", location: "/@openai/codex", + installDigest: "a".repeat(64), installContentDigest: "c".repeat(64), + packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, + evidence: ["package_manifest", "global_npm_layout"] }; + const realPlan = cliUpdatePlanModule.createCodexCliUpdatePlan; + // The route dispatches into another realm: parent planner spies cannot reach it. + // This dispatch seam tests HTTP + real async metadata-child behavior only; + // cli-update-worker.test.ts separately exercises the actual Worker boundary. + const planner = spyOn(cliUpdateWorkerModule, "runCodexCliUpdatePlanInWorker").mockImplementation(() => + realPlan({ platform: "linux", inspectionDeps: { arch: "x64" }, inspect: async () => managed as never, + spawnProcess: gatedSpawn, + scanProcessesAsync: async () => ({ kind: "observed", processes: [] }) })); + const oldHome = process.env.OPENCODEX_HOME; + const emptyHome = join(dir, "empty-home"); + mkdirSync(emptyHome, { recursive: true }); + process.env.OPENCODEX_HOME = emptyHome; + try { + const makeCtx = (pathname: string): ManagementContext => { + const req = new Request(`http://localhost${pathname}`); + return { req, url: new URL(req.url), config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + }; + const planPromise = handleCodexCliUpdateRoutes(makeCtx("/api/codex/cli-update/plan")); + // Let the first gated child spawn, then an unrelated request must still answer. + const firedAt = Date.now(); + await new Promise(resolve => setTimeout(resolve, 150)); + const concurrent = await Promise.race([ + handleCodexCliUpdateRoutes(makeCtx("/api/codex/cli-update/jobs/11111111-2222-3333-4444-555555555555")) + .then(response => ({ kind: "responded" as const, response })), + new Promise<{ kind: "blocked" }>(resolve => setTimeout(() => resolve({ kind: "blocked" }), 3_000)), + ]); + // Under a synchronous spawn this race can only settle after the child cap. + expect(concurrent.kind).toBe("responded"); + // A parked event loop defers BOTH arms past the child cap, so the response + // arriving "first" is not enough — the wall clock is the discriminator. + expect(Date.now() - firedAt).toBeLessThan(2_500); + if (concurrent.kind === "responded") { + // POSIX reads an empty store (404); Windows refuses the unsupported store + // lane (503). Either way the unrelated request answered while planning waited. + expect([404, 503]).toContain(concurrent.response?.status ?? 0); + } + writeFileSync(gateFile, "release"); + const response = await planPromise; + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ applicable: true, targetVersion: "1.4.2" }); + expect(planner).toHaveBeenCalledTimes(1); + } finally { + planner.mockRestore(); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("plan dispatch failure returns HTTP 503 without a second dispatch", async () => { + const dispatch = spyOn(cliUpdateWorkerModule, "runCodexCliUpdatePlanInWorker") + .mockRejectedValue(new Error("plan_unavailable")); + const req = new Request("http://localhost/api/codex/cli-update/plan"); + const ctx: ManagementContext = { req, url: new URL(req.url), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, + deps: {}, version: "test", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + try { + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ error: "plan_unavailable" }); + expect(dispatch).toHaveBeenCalledTimes(1); + } finally { dispatch.mockRestore(); } + }); + + test("Codex CLI HTTP apply requires a GUI session and trusted loopback before admission", async () => { + const config = { port: 10100, providers: {} } as import("../../src/types").OcxConfig; + const request = new Request("http://localhost/api/codex/cli-update/apply", { + 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.each(["job_store_busy", "store_full"])("HTTP admission %s schedules no worker and only contention advertises retry", async code => { + const admission = spyOn(updateJobs, "admitCodexCliUpdateJob").mockImplementation(() => { throw new Error(code); }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob"); + try { + const req = new Request("http://localhost/api/codex/cli-update/apply", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId: "busy-response-01", planId: "e".repeat(32), confirmed: true }) }); + const ctx: ManagementContext = { req, url: new URL(req.url), + config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, deps: {}, version: "test", + principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", + convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), + syncClaudeAgentDefsBestEffort: async () => {} }; + const response = await handleCodexCliUpdateRoutes(ctx); + expect(response?.status).toBe(503); + expect(await response?.json()).toEqual({ error: code }); + expect(response?.headers.get("Retry-After")).toBe(code === "job_store_busy" ? "1" : null); + expect(worker).not.toHaveBeenCalled(); + } finally { admission.mockRestore(); worker.mockRestore(); } + }); + + test.skipIf(process.platform === "win32" || process.geteuid?.() === undefined)("HTTP apply retry returns the durable job without scheduling a second engine run", async () => { + const outer = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-update-http-test-"))); + const parent = join(outer, "private-parent"), configDir = join(parent, "ocx"); + const oldHome = process.env.OPENCODEX_HOME; + 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 }) }); + 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 storeDir = join(configDir, "codex-cli-update-jobs"), heldLock = join(storeDir, "admission.lock"); + mkdirSync(storeDir, { mode: 0o700 }); chmodSync(storeDir, 0o700); + mkdirSync(heldLock, { mode: 0o700 }); + const worker = spyOn(updateJobs, "runCodexCliUpdateJob"); + try { + const busy = await handleCodexCliUpdateRoutes(ctx); + expect(busy?.status).toBe(503); + expect(await busy?.json()).toEqual({ error: "job_store_busy" }); + expect(busy?.headers.get("Retry-After")).toBe("1"); + expect(worker).not.toHaveBeenCalled(); + expect(existsSync(join(storeDir, "jobs.json"))).toBe(false); + } finally { worker.mockRestore(); rmSync(heldLock, { recursive: true }); } + ctx.req = makeRequest(); + const first = await handleCodexCliUpdateRoutes(ctx); + const firstBody = await first!.json(); + ctx.req = makeRequest(); + const retry = await handleCodexCliUpdateRoutes(ctx); + const retryBody = await retry!.json(); + 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) { + 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(outer, { 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" } });