From c607fb0540b850f13d9319390acc76f14ec9a8f9 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:22:06 +0900 Subject: [PATCH 01/24] feat(codex): attest the selected npm-global CLI and add a read-only update plan Refs lidge-jun/opencodex#2811: the maintainer asked for a reachable, proven installation-provenance predicate and a read-only plan before any apply step. --- scripts/test-layout/layout.json | 1 + .../ocx/references/01_management_surface.md | 25 +- src/cli/capabilities.ts | 18 + src/cli/codex-cli-update.ts | 175 +++++-- src/cli/registry.ts | 5 +- src/cli/system-command.ts | 1 + src/codex/app-server-processes.ts | 39 ++ src/codex/cli-install-provenance.ts | 54 ++- src/codex/cli-update-plan.ts | 433 ++++++++++++++++++ tests/cli/cli-codex-cli-update.test.ts | 45 +- tests/cli/cli-registry.test.ts | 3 +- .../codex-cli-install-provenance.test.ts | 77 +++- .../codex-cli-update-plan.test.ts | 386 ++++++++++++++++ .../codex-cli-update-zero-effect.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + 15 files changed, 1195 insertions(+), 70 deletions(-) create mode 100644 src/codex/cli-update-plan.ts create mode 100644 tests/codex-integration/codex-cli-update-plan.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 56a94114dca..cc691081c49 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -466,6 +466,7 @@ "codex-cli-installation-identity.test.ts": "codex-integration", "codex-cli-installation-targets.test.ts": "codex-integration", "codex-cli-update-launcher-policy.test.ts": "codex-integration", + "codex-cli-update-plan.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", "codex-forward-access-programs-writer.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 636a2b88e8e..472b5362ae0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -110,7 +110,7 @@ Drives no management route. JSON mode: `envelope`. -- Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess — unknown liveness never reads as absent. +- Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess ??unknown liveness never reads as absent. - Built for embedding shells (desktop app): the liveness budgets stay owned by src/server/proxy-liveness.ts. ### `ocx capabilities` @@ -447,6 +447,25 @@ JSON mode: `envelope`. - selectionAttested, managed and applyAllowed remain false. The digest is an observation, not a durable update permit. - Does not run the named Codex/npm/Node files, query a registry, install software, control processes or persist state. +### `ocx system codex-cli-update plan` + +Dry-run a Codex CLI update and print the plan id that authorizes applying it. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--channel` | string | Registry channel to resolve. Only the stable latest channel is offered. | +| `--json` | boolean | Emit the plan as JSON. | + +JSON mode: `envelope`. + +- 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. +- Writes nothing and installs nothing. 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, not a stored job. There is no plan state on disk to expire, collide or clean up. +- An unreadable process table refuses rather than reading as no live session. + ### `ocx claude desktop status` Applied-vs-desired Claude Desktop state, including staleness, drift, and health. @@ -661,7 +680,7 @@ Rotate the connected client's data key against the hub, with commit and abort. JSON mode: `payload`. - Requires transient authority on stdin; the credential is never persisted or echoed. -- A rotation left pending by a crash is resumed here — startup and status stop rather than guess which key generation is live. +- A rotation left pending by a crash is resumed here ??startup and status stop rather than guess which key generation is live. ### `ocx provider keychain` @@ -1202,6 +1221,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 67 +- declared capabilities: 68 - of those, state-changing: 37 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 04d802e56ff..5e582ebe1ee 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -876,6 +876,24 @@ export const CAPABILITIES: readonly Capability[] = [ "Does not run the named Codex/npm/Node files, query a registry, install software, control processes or persist state.", ], }, + { + command: ["system", "codex-cli-update", "plan"], + summary: "Dry-run a Codex CLI update and print the plan id that authorizes applying it.", + routes: [], + flags: [ + { name: "--channel", value: "string", summary: "Registry channel to resolve. Only the stable latest channel is offered." }, + { name: "--json", value: "boolean", summary: "Emit the plan as JSON." }, + ], + mutates: false, + json: "envelope", + details: [ + "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.", + "Writes nothing and installs nothing. 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, not a stored job. There is no plan state on disk to expire, collide or clean up.", + "An unreadable process table refuses rather than reading as no live session.", + ], + }, { command: ["system", "codex-restart"], summary: "Restart the Codex desktop app and app-servers.", diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 6e224954709..7da467cbf47 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -11,20 +11,25 @@ import type { CodexCliInstallationSnapshot, CodexCliInstallationTargetDerivation, } from "../codex/cli-installation-targets"; +import { + createCodexCliUpdatePlan, + type CodexCliUpdateChannel, + type CodexCliUpdatePlan, + type CodexCliUpdatePlanDeps, +} from "../codex/cli-update-plan"; import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api"; import { trustedNodeLauncherContext } from "./launcher-context"; 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 attest --candidate --npm-prefix --npm-cli --node [--json] + ocx system codex-cli-update plan [--channel latest] [--json]`; -export type ParsedCodexCliUpdateArgs = Readonly<{ - json: boolean; -}> | Readonly<{ - json: boolean; - attest: CodexCliInstallationIdentityInput | "selected"; -}>; +export type ParsedCodexCliUpdateArgs = + | Readonly<{ action: "check"; json: boolean }> + | Readonly<{ action: "plan"; json: boolean; channel: CodexCliUpdateChannel }> + | Readonly<{ json: boolean; attest: CodexCliInstallationIdentityInput | "selected" }>; export interface CodexCliUpdateCommandDeps { readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise; @@ -32,7 +37,8 @@ export interface CodexCliUpdateCommandDeps { readonly deriveInstallationInput?: ( snapshot: CodexCliInstallationSnapshot, ) => Promise; -} + readonly createPlan?: (deps: CodexCliUpdatePlanDeps) => Promise; + } function identitySummary(report: CodexCliInstallationIdentityReport): string[] { return [ @@ -68,24 +74,60 @@ function installSummary(report: CodexCliInstallReport): string[] { ]; } +function planSummary(plan: CodexCliUpdatePlan): string[] { + const lines = [ + `applicable: ${plan.applicable ? "yes" : "no"}`, + `reason: ${plan.refusal ?? "none"}`, + `provenance: ${plan.provenance}`, + `installed-version: ${plan.installedVersion ?? "unavailable"}`, + `version-evidence: ${plan.versionEvidence}`, + `channel: ${plan.channel}`, + `target-version: ${plan.targetVersion ?? "unresolved"}`, + `target-integrity: ${plan.targetIntegrity ?? "unresolved"}`, + `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)`); + return lines; +} + +/** + * Read ``--name value`` and ``--name=value`` alike; both spellings reach this CLI. */ +function optionValue(tokens: readonly string[], index: number, name: string): { value: string; next: number } { + const token = tokens[index]!; + const inline = `--${name}=`; + if (token.startsWith(inline)) { + const value = token.slice(inline.length); + if (!value) throw new CliUsageError(`--${name} requires a value`, CODEX_CLI_UPDATE_USAGE); + return { value, next: index + 1 }; + } + const value = tokens[index + 1]; + if (value === undefined) throw new CliUsageError(`--${name} requires a value`, CODEX_CLI_UPDATE_USAGE); + return { value, next: index + 2 }; +} + +function isOption(token: string, name: string): boolean { + return token === `--${name}` || token.startsWith(`--${name}=`); +} + export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCliUpdateArgs { // `--json` is accepted in any argv position CLI-wide, so remove it before positional - // validation. Requiring `check` at index 0 first would reject `--json check`, which + // validation. Requiring the action at index 0 first would reject `--json check`, which // automation that puts output flags ahead of the subcommand legitimately produces. let json = false; - const positional: string[] = []; + const rest: string[] = []; for (const token of argv) { if (isJsonOption(token)) { if (json) throw new CliUsageError("--json may be specified only once", CODEX_CLI_UPDATE_USAGE); json = true; continue; } - positional.push(token); + rest.push(token); } - if (positional[0] === "attest") { + if (rest[0] === "attest") { // No options: attest the selected candidate identified from the proof-bound // launcher snapshot. The four explicit paths remain all-or-none. - if (positional.length === 1) { + if (rest.length === 1) { return Object.freeze({ json, attest: "selected" as const }); } const options = new Map([ @@ -93,12 +135,12 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli ["--npm-cli", "npmCli"], ["--node", "node"], ]); const input: Partial> = {}; - for (let index = 1; index < positional.length; index += 2) { - const key = options.get(positional[index]!); + for (let index = 1; index < rest.length; index += 2) { + const key = options.get(rest[index]!); if (!key || input[key] !== undefined) { throw new CliUsageError("unsupported or duplicate attest option", CODEX_CLI_UPDATE_USAGE); } - const value = positional[index + 1]; + const value = rest[index + 1]; if (!value || !value.trim() || /[\0\r\n]/.test(value) || !(value.startsWith("/") || /^[a-z]:[\\/]/i.test(value))) { throw new CliUsageError("attest options require explicit absolute paths", CODEX_CLI_UPDATE_USAGE); @@ -112,13 +154,65 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli candidate: input.candidate, npmPrefix: input.npmPrefix, npmCli: input.npmCli, node: input.node, }) }); } - if (positional[0] !== "check") { - throw new CliUsageError("codex-cli-update action must be check or attest", CODEX_CLI_UPDATE_USAGE); + 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 (positional.length > 1) { - throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE); + + if (action === "check") { + if (rest.length > 1) throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE); + return Object.freeze({ action, json }); } - return Object.freeze({ json }); + + if (action === "plan") { + let channel: CodexCliUpdateChannel = "latest"; + let seen = false; + let index = 1; + while (index < rest.length) { + const token = rest[index]!; + if (!isOption(token, "channel")) { + throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE); + } + if (seen) throw new CliUsageError("--channel may be specified only once", CODEX_CLI_UPDATE_USAGE); + const read = optionValue(rest, index, "channel"); + // Only the stable channel is offered. A preview channel would need its own + // provenance story before it may install anything on the operator's behalf. + if (read.value !== "latest") throw new CliUsageError("--channel must be latest", CODEX_CLI_UPDATE_USAGE); + channel = read.value; + seen = true; + index = read.next; + } + return Object.freeze({ action, json, channel }); + } + + // Unreachable: the action check above already rejected every other verb, but the + // fallthrough keeps the parser total for the type checker. + throw new CliUsageError("unsupported codex-cli-update action", CODEX_CLI_UPDATE_USAGE); +} + +/** + * Inspection inputs for this one-shot CLI process. + * + * The published Node launcher supplies a proof-bound snapshot of configured candidate + * 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(): CodexCliInstallProvenanceDeps { + const trusted = trustedNodeLauncherContext()?.codexCliInspectionEnv; + if (!trusted || trusted.managerRoots === null) return { env: { PATH: "" }, configDir: "." }; + return { + env: { + ...trusted.managerRoots, + CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, + PATH: trusted.path ?? undefined, + PATHEXT: trusted.pathExt ?? undefined, + }, + configDir: trusted.configDir, + // The snapshot is proof-bound to the launcher, so a codexCliPath it carries is + // the runtime that was actually selected — the predicate that turns an + // npm-global report into a managed one. + selectionAttested: true, + }; } export async function handleCodexCliUpdateCommand( @@ -136,7 +230,9 @@ export async function handleCodexCliUpdateCommand( } throw error; } - return runCliAction(async () => { + // 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. + const code = await runCliAction(async () => { if ("attest" in parsed) { let report: CodexCliInstallationIdentityReport; try { @@ -167,25 +263,20 @@ export async function handleCodexCliUpdateCommand( printData(report, parsed.json, identitySummary(report)); return; } - const trustedInspectionEnv = trustedNodeLauncherContext()?.codexCliInspectionEnv; - const inspectionDeps: CodexCliInstallProvenanceDeps = trustedInspectionEnv - && trustedInspectionEnv.managerRoots !== null ? { - env: { - ...trustedInspectionEnv.managerRoots, - CODEX_CLI_PATH: trustedInspectionEnv.codexCliPath ?? undefined, - PATH: trustedInspectionEnv.path ?? undefined, - PATHEXT: trustedInspectionEnv.pathExt ?? undefined, - }, - configDir: trustedInspectionEnv.configDir, - // This is a fresh one-shot CLI process. Its proof-bound launcher snapshot - // supplies configured candidate evidence, not selected-runtime admission. - } : { - // Direct Bun/source launches have no pre-dotenv proof. Do not inspect - // ambient or persisted candidate state at all. - env: { PATH: "" }, - configDir: ".", - }; - const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps); - printData(report, parsed.json, installSummary(report)); + if (parsed.action === "check") { + const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps()); + printData(report, parsed.json, installSummary(report)); + return; + } + if (parsed.action === "plan") { + const plan = await (deps.createPlan ?? createCodexCliUpdatePlan)({ + channel: parsed.channel, + inspectionDeps: inspectionDeps(), + inspect: deps.inspectInstall, + }); + printData(plan, parsed.json, planSummary(plan)); + return; + } }); + return code; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3dcc639c7e2..f93bd85735f 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -443,13 +443,14 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "system", usage: "ocx system ...", - summary: "Manage headless runtime settings, startup, sync, diagnostics, OpenCodex updates, and read-only Codex CLI inspection.", + summary: "Manage headless runtime settings, startup, sync, diagnostics, OpenCodex updates, and the Codex CLI update manager.", details: [ "system update manages OpenCodex itself.", "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]", - "The Codex CLI inspection command makes no package-registry request, does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.", + "ocx system codex-cli-update plan [--channel latest] [--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 still writes nothing.", ], }, { diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index c9d447abaa4..f490da87be5 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -24,6 +24,7 @@ const 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 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 c7fa8b16fa5..b58b462a01b 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -581,6 +581,45 @@ export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C return matched; } +/** + * Enumeration outcome for callers that must DEFER on an unreadable process table. + * + * {@link listCodexAppServerProcesses} maps enumeration failure to an empty list because its + * kill contract must never signal a process it could not verify. The Codex CLI update + * workflow needs the opposite reading: "no matches" and "could not look" lead to different + * decisions, and only the first one may allow an install to proceed. + */ +export type CodexAppServerProcessScan = + | Readonly<{ kind: "observed"; processes: readonly CodexAppServerProcess[] }> + | Readonly<{ kind: "unavailable" }>; + +/** Same matcher and snapshot sources as {@link listCodexAppServerProcesses}, failing closed. */ +export function scanCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcessScan { + 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.listSnapshots ? io.listSnapshots() : defaultListSnapshots(platform, getuid); + } catch { + return Object.freeze({ kind: "unavailable" as const }); + } + const seen = new Set(); + const matched: CodexAppServerProcess[] = []; + for (const snapshot of snapshots) { + if (seen.has(snapshot.pid)) continue; + if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue; + seen.add(snapshot.pid); + matched.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); + } + return Object.freeze({ kind: "observed" as const, processes: Object.freeze(matched) }); +} + 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 e595205518c..a9693f9ec4d 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -46,12 +46,13 @@ export type CodexCliInstallReason = | "shim_state_unknown" | "shim_update_deferred" | "app_bundle" - | "version_manager_owned" - | "npm_global_unverified" - | "selection_unattested" - | "version_mismatch" - | "unverified_standalone" - | "inspection_failed"; + | "version_manager_owned" + | "npm_global_unverified" + | "managed_npm_global" + | "selection_unattested" + | "version_mismatch" + | "unverified_standalone" + | "inspection_failed"; export type CodexCliCandidateSource = "environment" | "persisted"; export type CodexCliInstallEvidence = @@ -105,6 +106,12 @@ export interface CodexCliInstallProvenanceDeps { platform: NodeJS.Platform, configDir: string, ) => CodexShimBackingForCommand; + /** + * Caller assertion that `env.CODEX_CLI_PATH` is the runtime the launcher actually + * selected, not merely a configured name. Only a proof-bound caller may set this; + * a persisted selection and an arbitrary environment stay unattested. + */ + readonly selectionAttested?: boolean; } interface PackageManifestEvidence { @@ -635,10 +642,16 @@ export async function inspectCodexCliInstall( evidence: Object.freeze([appBundle ? "app_bundle_path" : "version_manager_path"]), }); } - return unknownWindowsReport("windows_inspection_deferred", candidate, { - location: publicExecutableLocation(lexicalCandidatePath, platform), - }); - } + return unknownWindowsReport("windows_inspection_deferred", candidate, { + location: publicExecutableLocation(lexicalCandidatePath, platform), + }); + } + // Only a proof-bound caller may attest that the environment candidate is the + // runtime it actually selected. A persisted selection records intent, not the + // resolved binary, and an arbitrary environment is ambient — neither can become + // managed here. + const selectionAttested = + deps.selectionAttested === true && candidate.evidence === "environment"; const configDir = deps.configDir ?? getConfigDir(); if (!isSafeLocalInspectionPath(configDir, deps)) { return unknownReport("shim_state_unknown", candidate); @@ -658,6 +671,7 @@ export async function inspectCodexCliInstall( ...unknownReport("shim_state_unknown", candidate, { location: publicExecutableLocation(canonicalCandidatePath, platform), }), + selectionAttested, shim: shimReport(shim), }); } @@ -666,6 +680,7 @@ export async function inspectCodexCliInstall( ...unknownReport("shim_update_deferred", candidate, { location: publicExecutableLocation(canonicalCandidatePath, platform), }), + selectionAttested, provenance: "standalone-unverified", shim: shimReport(shim), evidence: Object.freeze(["canonical_path", "shim_backing"] as const), @@ -685,7 +700,7 @@ export async function inspectCodexCliInstall( candidateAvailable: true, candidateVersion: candidate.version, candidateSource: candidate.evidence, - selectionAttested: false, + selectionAttested, versionEvidence: Object.freeze({ kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, }), @@ -706,7 +721,7 @@ export async function inspectCodexCliInstall( candidateAvailable: true, candidateVersion: candidate.version, candidateSource: candidate.evidence, - selectionAttested: false, + selectionAttested, versionEvidence: Object.freeze({ kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, }), @@ -722,6 +737,7 @@ export async function inspectCodexCliInstall( if (/codex[.]opencodex-real(?:[.](?:cmd|bat|exe))?$/i.test(pathTools(platform).basename(candidatePath))) { return freezeReport({ ...unknownReport("shim_state_unknown", candidate, { location }), + selectionAttested, shim: shimReport(shim), }); } @@ -745,20 +761,24 @@ export async function inspectCodexCliInstall( const versionMatches = candidate.version === null || candidate.version === manifest.version; const reason: CodexCliInstallReason = !global || !linked ? "npm_global_unverified" - : !versionMatches ? "version_mismatch" : "selection_unattested"; + : !versionMatches ? "version_mismatch" + : selectionAttested ? "managed_npm_global" : "selection_unattested"; return freezeReport({ schemaVersion: 1, candidateAvailable: true, candidateVersion: candidate.version, candidateSource: candidate.evidence, - selectionAttested: false, + selectionAttested, versionEvidence: Object.freeze({ - kind: manifestOwned && candidate.version !== null && versionMatches + // The manifest is the on-disk record of what is installed. An absent advisory + // version (the launcher snapshot carries no version) cannot contradict it, + // while a reported one that disagrees already classified as version_mismatch. + kind: manifestOwned && versionMatches ? "package-manifest" as const : candidate.version !== null ? "advisory-runtime" as const : "unavailable" as const, }), provenance: manifestOwned ? "npm-global" : "standalone-unverified", - managed: false, + managed: manifestOwned && versionMatches && selectionAttested, reason, location, packageVersion: manifest.version, @@ -786,7 +806,7 @@ export async function inspectCodexCliInstall( candidateAvailable: true, candidateVersion: candidate.version, candidateSource: candidate.evidence, - selectionAttested: false, + selectionAttested, versionEvidence: Object.freeze({ kind: candidate.version ? "advisory-runtime" as const : "unavailable" as const, }), diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts new file mode 100644 index 00000000000..e682a8464af --- /dev/null +++ b/src/codex/cli-update-plan.ts @@ -0,0 +1,433 @@ +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; +import { npmInvocation } from "../update/npm-invocation.mjs"; +import { + inspectCodexCliInstall, + type CodexCliInstallKind, + type CodexCliInstallProvenanceDeps, + type CodexCliInstallReport, +} from "./cli-install-provenance"; +import { + scanCodexAppServerProcesses, + type CodexAppServerProcessIo, + type CodexAppServerProcessScan, +} from "./app-server-processes"; + +/** + * Phase 2 of the Codex CLI update manager: a deterministic dry-run plan. + * + * Phase 1 ({@link inspectCodexCliInstall}) answers who owns the installation. It never + * queries a registry, never enumerates processes and never writes. This module adds the + * three inputs it deliberately left out — an exact resolved target with registry + * integrity, live session blockers, and a decision — and nothing else. + * + * Two invariants shape the whole module: + * + * - No persisted state. The plan is not stored anywhere; {@link CodexCliUpdatePlan.planId} + * is a digest over the evidence the decision rests on, so an approved change to any + * bound field produces a different id. + * - Nothing is terminated or restarted. A live Codex session refuses the plan; it is + * never signalled, killed or waited on. The plan itself is read-only: it resolves + * registry metadata and reads the process table, but installs nothing. + */ + +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"; + +export type CodexCliUpdateRefusal = + | "windows_inspection_deferred" + | "not_managed" + | "installed_version_unverified" + | "target_unresolved" + | "already_current" + | "target_not_newer" + | "blocked_active_session" + | "blocked_process_state_unknown"; + +/** + * `not-evaluated` is not a weaker `unknown`: it records that the plan was already refused + * on ownership or target grounds, so the process table was never read. Only `unknown` + * means an enumeration attempt failed. + */ +export type CodexCliUpdateSessionState = "none" | "active" | "unknown" | "not-evaluated"; + +export interface CodexCliUpdateSession { + readonly state: CodexCliUpdateSessionState; + /** Match count only. Command lines and paths never leave the process scanner. */ + readonly matches: number | null; +} + +export type CodexCliUpdateTarget = + | Readonly<{ kind: "resolved"; version: string; integrity: string }> + | Readonly<{ kind: "unresolved"; reason: string }>; + +export interface CodexCliUpdatePlan { + readonly schemaVersion: typeof CODEX_CLI_UPDATE_SCHEMA_VERSION; + readonly package: typeof CODEX_CLI_PACKAGE; + readonly channel: CodexCliUpdateChannel; + readonly applicable: boolean; + readonly refusal: CodexCliUpdateRefusal | null; + /** Present only for an applicable plan; there is nothing to quote for a refusal. */ + readonly planId: string | null; + readonly provenance: CodexCliInstallKind; + readonly managed: boolean; + readonly installedVersion: string | null; + readonly versionEvidence: CodexCliInstallReport["versionEvidence"]["kind"]; + readonly location: string | null; + readonly targetVersion: string | null; + readonly targetIntegrity: string | null; + readonly session: CodexCliUpdateSession; + /** Indicative install argv for the operator to read; the apply phase this plan precedes verifies a packed tarball against the bound sha512 rather than trusting a moving spec. */ + readonly command: readonly string[] | null; +} + +export interface CodexCliUpdatePlanDeps { + readonly inspect?: (deps: CodexCliInstallProvenanceDeps) => Promise; + readonly inspectionDeps?: CodexCliInstallProvenanceDeps; + readonly platform?: NodeJS.Platform; + readonly channel?: CodexCliUpdateChannel; + readonly scanProcesses?: (io?: CodexAppServerProcessIo) => CodexAppServerProcessScan; + readonly processIo?: CodexAppServerProcessIo; + readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget; +} + + +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+/=]+$/; + +/** The install argv this plan quotes for the operator to read, in argv form. */ +export function codexCliUpdateCommand(version: string): readonly string[] { + return Object.freeze(["npm", "install", "-g", `${CODEX_CLI_PACKAGE}@${version}`]); +} + +/** + * Digest over exactly the evidence the decision rests on. + * + * Session blockers are deliberately absent: a session that starts or ends between dry-run + * and a later apply must not invalidate an otherwise identical plan; it is re-read where + * it can only ever refuse. Everything else — ownership, installed version, + * location, resolved target, integrity — is bound, so any drift produces a different id. + */ +export function codexCliUpdatePlanId(bound: { + readonly platform: NodeJS.Platform; + readonly provenance: CodexCliInstallKind; + readonly installedVersion: string; + readonly location: string | null; + readonly channel: CodexCliUpdateChannel; + readonly targetVersion: string; + readonly targetIntegrity: string; +}): string { + // Ordered pairs, not object key order: the digest must not depend on how a caller + // happened to build the record. + const fields: readonly (readonly [string, string])[] = [ + ["schemaVersion", String(CODEX_CLI_UPDATE_SCHEMA_VERSION)], + ["package", CODEX_CLI_PACKAGE], + ["platform", bound.platform], + ["provenance", bound.provenance], + ["installedVersion", bound.installedVersion], + ["location", bound.location ?? ""], + ["channel", bound.channel], + ["targetVersion", bound.targetVersion], + ["targetIntegrity", bound.targetIntegrity], + ]; + const hash = createHash("sha256"); + for (const [key, value] of fields) hash.update(`${key}=${value}\n`); + return hash.digest("hex").slice(0, PLAN_ID_LENGTH); +} + +interface NpmTarget { + readonly bin: string; + readonly args: string[]; + readonly options: { + readonly windowsVerbatimArguments?: boolean; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + }; +} + +function npmTarget(args: readonly string[]): NpmTarget | null { + const invocation = npmInvocation(args); + if (!invocation) return null; + return { bin: invocation.file, args: invocation.args, options: invocation.options }; +} + +/** + * The npm environment with every registry-affecting config channel removed. + * + * npm maps any npm_config_* env var into its configuration, so NPM_CONFIG_REGISTRY or + * npm_config_@openai:registry would defeat the pinned --registry flag (a scoped + * registry beats the default for that scope). They are all stripped. The settings + * this codebase intentionally supports survive untouched: the standard proxy + * variables npm itself honors (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY, any case) + * and NODE_EXTRA_CA_CERTS, which npm's own Node runtime reads for TLS. + */ +function codexCliUpdateNpmEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.toLowerCase().startsWith("npm_config_")) delete env[key]; + } + return env; +} + +interface NpmConfigIsolation { + /** Controlled cwd: contains the sentinel package.json, so npm's project-config walk stops here. */ + readonly dir: string; + /** Empty file substituted for both the user and the global npmrc. */ + readonly npmrc: string; + readonly env: NodeJS.ProcessEnv; +} + +/** + * A directory npm cannot read hostile configuration through. + * + * npm resolves project config from the nearest ancestor containing package.json (or + * node_modules/.git), so a bare temp dir is NOT enough — the walk would continue into + * the operator's real ancestors. The sentinel package.json anchors the walk here, + * where no .npmrc exists. --userconfig/--globalconfig replace the two file configs, + * and the env filter removes the env-var channel. What remains is exactly the pinned + * --registry flag plus deliberately supported proxy/CA env. + */ +function createNpmConfigIsolation(dir?: string): NpmConfigIsolation { + const root = dir ?? mkdtempSync(join(tmpdir(), "ocx-codex-cli-meta-")); + writeFileSync(join(root, "package.json"), "{}\n"); + const npmrc = join(root, "ocx-update.npmrc"); + writeFileSync(npmrc, ""); + return { dir: root, npmrc, env: codexCliUpdateNpmEnv() }; +} + +/** Registry-pinned, config-isolated argv for one npm call. */ +function isolatedNpmArgs(args: readonly string[], isolation: NpmConfigIsolation): readonly string[] { + return [ + ...args, + "--registry=" + CODEX_CLI_REGISTRY, + "--userconfig=" + isolation.npmrc, + "--globalconfig=" + isolation.npmrc, + ]; +} + +function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolation): NpmTarget | null { + const target = npmTarget(isolatedNpmArgs(args, isolation)); + if (!target) return null; + return { + bin: target.bin, + args: target.args, + options: { ...target.options, cwd: isolation.dir, env: isolation.env }, + }; +} + +/** + * Resolve the channel to one exact version plus its sha512 integrity token. + * + * OpenCodex's own updater treats a failed integrity query as `skipped` and proceeds + * best-effort. That is a defensible trade for the package we publish ourselves; it is not + * 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( + channel: CodexCliUpdateChannel, + spawn: typeof spawnSync = spawnSync, +): CodexCliUpdateTarget { + // 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. + const isolation = createNpmConfigIsolation(); + try { + const runView = (field: string, spec: string): SpawnSyncReturns | null => { + const target = isolatedNpmTarget(["view", spec, field], isolation); + if (!target) return null; + return spawn(target.bin, target.args, { + encoding: "utf8", + timeout: REGISTRY_TIMEOUT_MS, + windowsHide: true, + ...target.options, + }); + }; + + const versionRun = 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) { + return Object.freeze({ kind: "unresolved" as const, reason: `registry version query failed (status ${versionRun.status ?? "timeout"})` }); + } + const version = (versionRun.stdout ?? "").trim(); + if (!parseStrictSemver(version)) { + return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no exact version" }); + } + + const integrityRun = 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"})` }); + } + // `dist.integrity` may arrive quoted or as a space-separated multi-hash list. + const tokens = (integrityRun.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean); + const integrity = tokens.find(token => SHA512_INTEGRITY_RE.test(token)); + if (!integrity) { + return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no sha512 integrity token" }); + } + + // 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}`); + 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"})` }); + } + const tarball = (tarballRun.stdout ?? "").replace(/["']/g, "").trim(); + let tarballOrigin: string | null = null; + try { + tarballOrigin = new URL(tarball).origin; + } catch { + tarballOrigin = null; + } + if (tarballOrigin !== CODEX_CLI_REGISTRY) { + return Object.freeze({ kind: "unresolved" as const, reason: "registry returned a tarball outside the official registry origin" }); + } + return Object.freeze({ kind: "resolved" as const, version, integrity }); + } finally { + rmSync(isolation.dir, { recursive: true, force: true }); + } +} + +function refusedPlan( + refusal: CodexCliUpdateRefusal, + report: CodexCliInstallReport, + channel: CodexCliUpdateChannel, + target: CodexCliUpdateTarget | null, + session: CodexCliUpdateSession, +): CodexCliUpdatePlan { + return Object.freeze({ + schemaVersion: CODEX_CLI_UPDATE_SCHEMA_VERSION, + package: CODEX_CLI_PACKAGE, + channel, + applicable: false, + refusal, + planId: null, + provenance: report.provenance, + managed: report.managed, + installedVersion: report.packageVersion, + versionEvidence: report.versionEvidence.kind, + location: report.location, + targetVersion: target?.kind === "resolved" ? target.version : null, + targetIntegrity: target?.kind === "resolved" ? target.integrity : null, + session, + command: null, + }); +} + +const NOT_EVALUATED: CodexCliUpdateSession = Object.freeze({ state: "not-evaluated" as const, matches: null }); + +/** + * Build the dry-run plan. Reads only; nothing here writes, signals or installs. + * + * The refusal order is deliberate. Ownership and target questions are settled before the + * process table is read, so a machine that can never be updated by this workflow does not + * pay for an enumeration, and the reported refusal is the decisive one rather than + * whichever check happened to run first. + */ +export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {}): Promise { + const channel: CodexCliUpdateChannel = deps.channel ?? "latest"; + const platform = deps.platform ?? process.platform; + const inspect = deps.inspect ?? inspectCodexCliInstall; + const report = await inspect(deps.inspectionDeps ?? {}); + + // Phase 1 performs no candidate filesystem I/O at all on Windows, so there is no + // ownership evidence to build a plan on. This stays dormant until the handle-bound + // provenance layer exists; pretending otherwise would require exactly the reads phase 1 + // refused to perform. + if (platform === "win32" || report.reason === "windows_inspection_deferred") { + return refusedPlan("windows_inspection_deferred", report, channel, null, NOT_EVALUATED); + } + if (!report.managed || report.provenance !== "npm-global") { + return refusedPlan("not_managed", report, channel, null, NOT_EVALUATED); + } + // An advisory runtime string is what a candidate binary said about itself. Only + // package-manifest evidence states what is installed on disk, and only that can be + // compared with a registry version or read back after an install. + const installedVersion = report.versionEvidence.kind === "package-manifest" ? report.packageVersion : null; + const installedSemver = installedVersion ? parseStrictSemver(installedVersion) : null; + if (!installedVersion || !installedSemver) { + return refusedPlan("installed_version_unverified", report, channel, null, NOT_EVALUATED); + } + + const resolveTarget = deps.resolveTarget ?? (ch => resolveCodexCliUpdateTarget(ch)); + const target = resolveTarget(channel); + if (target.kind !== "resolved") { + return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED); + } + // Raw equality is not the gate: a resolved target must advance the installed + // version by semver precedence. Equal versions are already current and lower + // ones are refused rather than applied as a silent downgrade. + const targetSemver = parseStrictSemver(target.version); + if (!targetSemver) { + return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED); + } + const versionOrder = compareStrictSemver(targetSemver, installedSemver); + if (versionOrder === 0) { + return refusedPlan("already_current", report, channel, target, NOT_EVALUATED); + } + if (versionOrder < 0) { + return refusedPlan("target_not_newer", report, channel, target, NOT_EVALUATED); + } + + const scan = (deps.scanProcesses ?? scanCodexAppServerProcesses)(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 + // process it could not verify; the update contract has to defer instead. + return refusedPlan("blocked_process_state_unknown", report, channel, target, Object.freeze({ state: "unknown" as const, matches: null })); + } + const matches = scan.processes.length; + if (matches > 0) { + return refusedPlan("blocked_active_session", report, channel, target, Object.freeze({ state: "active" as const, matches })); + } + + return Object.freeze({ + schemaVersion: CODEX_CLI_UPDATE_SCHEMA_VERSION, + package: CODEX_CLI_PACKAGE, + channel, + applicable: true, + refusal: null, + planId: codexCliUpdatePlanId({ + platform, + provenance: report.provenance, + installedVersion, + location: report.location, + channel, + targetVersion: target.version, + targetIntegrity: target.integrity, + }), + provenance: report.provenance, + managed: report.managed, + installedVersion, + versionEvidence: report.versionEvidence.kind, + location: report.location, + targetVersion: target.version, + targetIntegrity: target.integrity, + session: Object.freeze({ state: "none" as const, matches: 0 }), + command: codexCliUpdateCommand(target.version), + }); +} diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 4e2161d406c..3aa820456fd 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -198,9 +198,9 @@ describe("Codex CLI update CLI", () => { }); test("parses the shared JSON flag spellings within the exact check grammar", () => { - expect(parseCodexCliUpdateArgs(["check"])).toEqual({ json: false }); + expect(parseCodexCliUpdateArgs(["check"])).toEqual({ action: "check", json: false }); for (const flag of ["--json", "--json=true", "-json", "—json"]) { - expect(parseCodexCliUpdateArgs(["check", flag])).toEqual({ json: true }); + expect(parseCodexCliUpdateArgs(["check", flag])).toEqual({ action: "check", json: true }); } for (const args of [ ["check", "--channel", "latest"], @@ -217,7 +217,7 @@ describe("Codex CLI update CLI", () => { */ test("the JSON flag is accepted before the check action", () => { for (const flag of ["--json", "--json=true", "-json", "—json"]) { - expect(parseCodexCliUpdateArgs([flag, "check"])).toEqual({ json: true }); + expect(parseCodexCliUpdateArgs([flag, "check"])).toEqual({ action: "check", json: true }); } // Duplicate detection and positional validation still hold in that order. expect(() => parseCodexCliUpdateArgs(["--json", "check", "--json"])).toThrow(); @@ -376,3 +376,42 @@ describe("Codex CLI update CLI", () => { } }); }); + +/** + * Phase 2 verb. The dry-run plan is the entire surface added here: it resolves an exact + * registry target and reports the decision, and this slice deliberately adds no apply. + */ +describe("Codex CLI update plan grammar", () => { + test("plan defaults to the stable channel and accepts both option spellings", () => { + expect(parseCodexCliUpdateArgs(["plan"])).toEqual({ action: "plan", json: false, channel: "latest" }); + expect(parseCodexCliUpdateArgs(["plan", "--channel", "latest"])).toEqual({ action: "plan", json: false, channel: "latest" }); + expect(parseCodexCliUpdateArgs(["plan", "--channel=latest", "--json"])).toEqual({ action: "plan", json: true, channel: "latest" }); + }); + + test("unsupported actions and plan arguments are rejected", () => { + for (const args of [ + ["apply", "--plan", "0".repeat(32)], + ["plan", "--plan", "0".repeat(32)], + ["plan", "--channel", "preview"], + ["plan", "extra"], + ]) expect(() => parseCodexCliUpdateArgs(args)).toThrow(); + }); + + test("a refused dry-run is a normal answer and still exits 0", async () => { + let inspected = 0; + const code = await handleCodexCliUpdateCommand(["plan", "--json"], { + inspectInstall: async () => { inspected += 1; return report; }, + createPlan: async () => ({ + schemaVersion: 1, package: "@openai/codex", channel: "latest", + applicable: false, refusal: "not_managed", planId: null, + provenance: "app-bundle", managed: false, installedVersion: null, + versionEvidence: "unavailable", location: null, + targetVersion: null, targetIntegrity: null, + session: { state: "not-evaluated", matches: null }, command: null, + }), + }); + expect(code).toBe(0); + // The plan engine owns inspection; the command must not run a second one. + expect(inspected).toBe(0); + }); +}); diff --git a/tests/cli/cli-registry.test.ts b/tests/cli/cli-registry.test.ts index c6417164ec4..e35d05de182 100644 --- a/tests/cli/cli-registry.test.ts +++ b/tests/cli/cli-registry.test.ts @@ -101,9 +101,10 @@ describe("CLI command registry parity", () => { expect(new Set(names).size).toBe(names.length); }); - test("system help exposes the exact Codex CLI inspection grammar", () => { + test("system help exposes the exact Codex CLI update grammar", () => { const details = findCommand("system")?.details ?? []; expect(details).toContain("ocx system codex-cli-update check [--json]"); + expect(details).toContain("ocx system codex-cli-update plan [--channel latest] [--json]"); expect(details.some(line => line.includes("dry-run"))).toBe(false); }); diff --git a/tests/codex-integration/codex-cli-install-provenance.test.ts b/tests/codex-integration/codex-cli-install-provenance.test.ts index 35971c389a9..024b54be1b5 100644 --- a/tests/codex-integration/codex-cli-install-provenance.test.ts +++ b/tests/codex-integration/codex-cli-install-provenance.test.ts @@ -256,6 +256,79 @@ describe("Codex CLI install provenance", () => { expect(JSON.stringify(report)).not.toContain("authority"); }); + test.skipIf(process.platform === "win32")("a proof-bound selection makes an npm-global install managed", async () => { + const prefix = tempRoot("ocx-codex-managed-"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + selectionAttested: true, + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report).toMatchObject({ + candidateSource: "environment", + selectionAttested: true, + provenance: "npm-global", + managed: true, + reason: "managed_npm_global", + packageVersion: "1.2.3", + versionEvidence: { kind: "package-manifest" }, + }); + // The attestation flag changes only the caller's assertion; paths stay redacted. + expect(JSON.stringify(report)).not.toContain(prefix); + }); + + test.skipIf(process.platform === "win32")("attestation never rescues an install that is not manifest-owned", async () => { + const prefix = tempRoot("ocx-codex-unmanaged-"); + const launcher = join(prefix, "bin", "codex"); + const packageRoot = join(prefix, "node_modules", "@openai", "codex"); + const entrypoint = join(packageRoot, "bin", "codex.js"); + mkdirSync(join(prefix, "bin"), { recursive: true }); + mkdirSync(join(packageRoot, "bin"), { recursive: true }); + writeFileSync(entrypoint, "#!/usr/bin/env node\n", "utf8"); + chmodSync(entrypoint, 0o755); + symlinkSync(entrypoint, launcher); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ + name: "@openai/codex", version: "1.2.3", bin: { codex: "bin/codex.js" }, + }), "utf8"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + selectionAttested: true, + inspectShim: () => ({ status: "not-tracked" }), + }); + // The selection is attested but the layout is not a global npm install, so + // ownership stays unproven and nothing becomes managed. + expect(report.selectionAttested).toBe(true); + expect(report.managed).toBe(false); + expect(report.provenance).not.toBe("npm-global"); + expect(report.reason).toBe("npm_global_unverified"); + }); + + test.skipIf(process.platform === "win32")("a persisted selection stays unmanaged even when the caller asserts it", async () => { + const root = tempRoot("ocx-codex-persisted-unmanaged-"); + const { launcher } = createPosixNpmGlobal(root); + writeFileSync(join(root, "codex-runtime.json"), `${JSON.stringify({ + version: 1, + command: launcher, + source: "path", + selectedVersion: "1.2.3", + updatedAt: "2026-08-28T00:00:00.000Z", + })}\n`, "utf8"); + const report = await inspectCodexCliInstall({ + platform: process.platform, + configDir: root, + env: { PATH: "" }, + selectionAttested: true, + inspectShim: () => ({ status: "not-tracked" }), + }); + // The flag attests the environment channel only; a persisted candidate records + // the configured intent, not the resolved runtime. + expect(report.selectionAttested).toBe(false); + expect(report.managed).toBe(false); + expect(report.reason).toBe("selection_unattested"); + }); + test.skipIf(process.platform !== "linux")("does not mistake a flatpak path component for an app bundle", async () => { const prefix = join(tempRoot("ocx-codex-flatpak-component-"), "flatpak", "tools"); const { launcher } = createPosixNpmGlobal(prefix); @@ -502,7 +575,9 @@ describe("Codex CLI install provenance", () => { selectionAttested: false, managed: false, reason: "selection_unattested", - versionEvidence: { kind: "unavailable" }, + // The manifest proves the on-disk version even though an environment + // candidate reports no advisory version of its own. + versionEvidence: { kind: "package-manifest" }, }); }); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts new file mode 100644 index 00000000000..30398b94f6d --- /dev/null +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, test } from "bun:test"; + +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { scanCodexAppServerProcesses } from "../../src/codex/app-server-processes"; +import type { CodexCliInstallReport } from "../../src/codex/cli-install-provenance"; +import { + CODEX_CLI_REGISTRY, + codexCliUpdatePlanId, + createCodexCliUpdatePlan, + resolveCodexCliUpdateTarget, + type CodexCliUpdatePlan, + type CodexCliUpdatePlanDeps, + type CodexCliUpdateTarget, +} from "../../src/codex/cli-update-plan"; + +/** + * Phase 2 of the Codex CLI update manager. + * + * Every case here is about one of three refusals to be casual: adopting an installation + * we do not own, installing a target the registry did not pin, and reading an unreadable + * process table as "nothing is running". + */ + +const MANAGED: CodexCliInstallReport = Object.freeze({ + 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", + packageVersion: "1.0.0", + shim: { status: "not-tracked", backingKind: null }, + evidence: ["package_manifest", "global_npm_layout"], +}); + +function report(overrides: Partial = {}): CodexCliInstallReport { + return { ...MANAGED, ...overrides } as CodexCliInstallReport; +} + +const RESOLVED: CodexCliUpdateTarget = Object.freeze({ + kind: "resolved", + version: "1.1.0", + integrity: "sha512-AAAA", +}); + +function planDeps(overrides: Partial = {}): CodexCliUpdatePlanDeps { + return { + platform: "linux", + inspect: async () => report(), + resolveTarget: () => RESOLVED, + scanProcesses: () => ({ kind: "observed", processes: [] }), + ...overrides, + }; +} + +async function applicablePlan(overrides: Partial = {}): Promise { + const plan = await createCodexCliUpdatePlan(planDeps(overrides)); + expect(plan.applicable).toBe(true); + return plan; +} + +describe("Codex CLI update dry-run plan", () => { + test("an applicable plan pins the exact resolved version and quotes the command it would run", async () => { + const plan = await applicablePlan(); + expect(plan.refusal).toBeNull(); + expect(plan.targetVersion).toBe("1.1.0"); + expect(plan.targetIntegrity).toBe("sha512-AAAA"); + expect(plan.installedVersion).toBe("1.0.0"); + expect(plan.session).toEqual({ state: "none", matches: 0 }); + // The dist-tag is resolved once and bound; the install can never widen back to it. + expect(plan.command).toEqual(["npm", "install", "-g", "@openai/codex@1.1.0"]); + expect(plan.planId).toMatch(/^[0-9a-f]{32}$/); + }); + + test("Windows defers without querying the registry or the process table", async () => { + let queries = 0; + let scans = 0; + const plan = await createCodexCliUpdatePlan(planDeps({ + platform: "win32", + inspect: async () => report({ reason: "windows_inspection_deferred", managed: false, provenance: "unknown" }), + resolveTarget: () => { queries += 1; return RESOLVED; }, + scanProcesses: () => { scans += 1; return { kind: "observed", processes: [] }; }, + })); + expect(plan.applicable).toBe(false); + expect(plan.refusal).toBe("windows_inspection_deferred"); + // Phase 1 reads nothing on Windows, so there is no ownership evidence to spend a + // registry request or a process enumeration on. + expect(queries).toBe(0); + expect(scans).toBe(0); + expect(plan.session.state).toBe("not-evaluated"); + }); + + test("an installation we do not own is never adopted", async () => { + for (const owned of [ + report({ managed: false, provenance: "app-bundle", reason: "app_bundle" }), + report({ managed: false, provenance: "version-manager", reason: "version_manager_owned" }), + report({ managed: false, provenance: "standalone-unverified", reason: "unverified_standalone" }), + report({ managed: true, provenance: "version-manager", reason: "version_manager_owned" }), + ]) { + const plan = await createCodexCliUpdatePlan(planDeps({ inspect: async () => owned })); + expect(plan.refusal).toBe("not_managed"); + expect(plan.planId).toBeNull(); + expect(plan.command).toBeNull(); + } + }); + + test("an advisory runtime version is not evidence of what is installed", async () => { + // A candidate binary reporting its own version cannot be compared with a registry + // version or read back after an install; only the package manifest can. + const plan = await createCodexCliUpdatePlan(planDeps({ + inspect: async () => report({ versionEvidence: { kind: "advisory-runtime" } }), + })); + expect(plan.refusal).toBe("installed_version_unverified"); + }); + + test("a version the registry did not pin with integrity is refused, not installed best-effort", async () => { + const plan = await createCodexCliUpdatePlan(planDeps({ + resolveTarget: () => ({ kind: "unresolved", reason: "registry integrity query failed (status timeout)" }), + })); + expect(plan.refusal).toBe("target_unresolved"); + expect(plan.targetVersion).toBeNull(); + expect(plan.command).toBeNull(); + }); + + test("an already current installation has nothing to apply", async () => { + const plan = await createCodexCliUpdatePlan(planDeps({ + resolveTarget: () => ({ kind: "resolved", version: "1.0.0", integrity: "sha512-AAAA" }), + })); + expect(plan.refusal).toBe("already_current"); + }); + + test("a resolved target lower than the install is refused, not applied as a downgrade", async () => { + const plan = await createCodexCliUpdatePlan(planDeps({ + resolveTarget: () => ({ kind: "resolved", version: "0.9.0", integrity: "sha512-AAAA" }), + })); + expect(plan.refusal).toBe("target_not_newer"); + expect(plan.planId).toBeNull(); + }); + + test("an unreadable process table defers instead of reading as no live session", async () => { + const plan = await createCodexCliUpdatePlan(planDeps({ scanProcesses: () => ({ kind: "unavailable" }) })); + expect(plan.refusal).toBe("blocked_process_state_unknown"); + expect(plan.session).toEqual({ state: "unknown", matches: null }); + }); + + test("a live Codex session refuses the plan and is never signalled", async () => { + const plan = await createCodexCliUpdatePlan(planDeps({ + scanProcesses: () => ({ kind: "observed", processes: [{ pid: 4242, commandLine: "node app-server --secret" }] }), + })); + expect(plan.refusal).toBe("blocked_active_session"); + expect(plan.session).toEqual({ state: "active", matches: 1 }); + // Only the count crosses the boundary. Command lines carry paths and arguments. + expect(JSON.stringify(plan)).not.toContain("secret"); + expect(JSON.stringify(plan)).not.toContain("4242"); + }); +}); + +describe("Codex CLI update plan identity", () => { + test("every bound field changes the id", async () => { + const base = await applicablePlan(); + const variants: Partial[] = [ + { resolveTarget: () => ({ kind: "resolved", version: "1.2.0", integrity: "sha512-AAAA" }) }, + { resolveTarget: () => ({ kind: "resolved", version: "1.1.0", integrity: "sha512-BBBB" }) }, + { inspect: async () => report({ packageVersion: "1.0.1", candidateVersion: "1.0.1" }) }, + { inspect: async () => report({ location: "/other/@openai/codex" }) }, + ]; + for (const variant of variants) { + const plan = await applicablePlan(variant); + expect(plan.planId).not.toBe(base.planId); + } + }); + + test("a session starting or ending between dry-run and apply does not invalidate the plan", async () => { + // Blockers are re-read at apply time where they can only refuse. Binding them into + // the id would expire a plan the operator read correctly, for a reason that cannot + // make the install wrong. + const first = await applicablePlan(); + const second = await applicablePlan({ + scanProcesses: () => ({ kind: "observed", processes: [] }), + }); + expect(second.planId).toBe(first.planId); + }); + + test("the id is a digest of the bound evidence, not a random handle", () => { + const bound = { + platform: "linux" as NodeJS.Platform, + provenance: "npm-global" as const, + installedVersion: "1.0.0", + location: "/@openai/codex", + channel: "latest" as const, + targetVersion: "1.1.0", + targetIntegrity: "sha512-AAAA", + }; + expect(codexCliUpdatePlanId(bound)).toBe(codexCliUpdatePlanId(bound)); + }); +}); + +describe("strict Codex app-server process scan", () => { + test("an enumeration failure is unavailable, not an empty list", () => { + const scan = scanCodexAppServerProcesses({ + platform: "linux", + listSnapshots: () => { throw new Error("procfs unreadable"); }, + }); + expect(scan).toEqual({ kind: "unavailable" }); + }); + + test("a readable but empty process table is observed with no matches", () => { + const scan = scanCodexAppServerProcesses({ platform: "linux", listSnapshots: () => [] }); + expect(scan).toEqual({ kind: "observed", processes: [] }); + }); + + test("the same matcher and de-duplication as the kill-path lister", () => { + const snapshot = { pid: 11, commandLine: "codex app-server" }; + const scan = scanCodexAppServerProcesses({ + platform: "linux", + listSnapshots: () => [snapshot, { ...snapshot }, { pid: 12, commandLine: "vim notes.txt" }], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind !== "observed") return; + expect(scan.processes.map(p => p.pid)).toEqual([11]); + }); +}); + +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: "" }; + }) as never; + } + + test("an exact version with a sha512 token resolves", () => { + const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + { status: 0, stdout: "1.4.2\n" }, + { status: 0, stdout: "'sha512-abc/DEF+123=' sha1-old\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: "sha512-abc/DEF+123=" }); + }); + + test("a missing integrity token refuses instead of proceeding best-effort", () => { + const target = resolveCodexCliUpdateTarget("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: "" }])); + expect(target.kind).toBe("unresolved"); + }); + + test("a non-version answer is not treated as a version", () => { + const target = resolveCodexCliUpdateTarget("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([ + { status: 0, stdout: "1.4.2\n" }, + { status: 0, stdout: "sha512-abc/DEF+123=\n" }, + { status: 0, stdout: "https://evil.invalid/@openai/codex/-/codex-1.4.2.tgz\n" }, + ])); + expect(target.kind).toBe("unresolved"); + }); + + test("a tarball answer that is not a URL at all is refused", () => { + const target = resolveCodexCliUpdateTarget("latest", spawnStub([ + { status: 0, stdout: "1.4.2\n" }, + { status: 0, stdout: "sha512-abc/DEF+123=\n" }, + { status: 0, stdout: "not-a-url\n" }, + ])); + expect(target.kind).toBe("unresolved"); + }); +}); + +describe("registry configuration isolation", () => { + interface CapturedCall { + bin: string; + args: string[]; + options: Record; + /** Snapshot taken while the call was live; the isolation dir is gone by return. */ + cwdHadSentinel: boolean; + cwdHadNpmrc: boolean; + } + + /** The npm argv, whether it reached spawn bare (POSIX) or inside a cmd /c line (Windows). */ + function argvLine(call: CapturedCall): string { + return call.args.join(" "); + } + + /** Which registry field this invocation queried, read off the argv line. */ + function queriedField(call: CapturedCall): string { + const line = argvLine(call); + if (line.includes("dist.tarball")) return "dist.tarball"; + if (line.includes("dist.integrity")) return "dist.integrity"; + return "version"; + } + + function capturingSpawn(outputs: Record): { calls: CapturedCall[]; spawn: never } { + const calls: CapturedCall[] = []; + const spawn = ((bin: string, args: string[], options: Record) => { + const cwd = options.cwd as string; + const call: CapturedCall = { + bin, + args, + options, + cwdHadSentinel: existsSync(join(cwd, "package.json")), + cwdHadNpmrc: existsSync(join(cwd, "ocx-update.npmrc")), + }; + calls.push(call); + const field = queriedField(call); + return { status: 0, stdout: outputs[field] ?? "", stderr: "" }; + }) as never; + return { calls, spawn }; + } + + const RESOLVE_OUTPUTS = { + version: "1.4.2\n", + "dist.integrity": "sha512-abc/DEF+123=\n", + "dist.tarball": "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n", + }; + + test("every query pins the official registry and substitutes a controlled npmrc", () => { + const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); + const target = resolveCodexCliUpdateTarget("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); + // Both file configs are substituted with the controlled empty npmrc inside the + // isolation dir — a user ~/.npmrc or a global $PREFIX/etc/npmrc cannot answer. + expect(argvLine(call)).toContain("--userconfig="); + expect(argvLine(call)).toContain("--globalconfig="); + expect(argvLine(call)).toContain("ocx-update.npmrc"); + expect(call.cwdHadSentinel).toBe(true); + expect(call.cwdHadNpmrc).toBe(true); + } + // The isolation directory is cleaned up after the resolve. + expect(existsSync(calls[0]!.options.cwd as string)).toBe(false); + }); + + test("a hostile npm_config_* env cannot reach the spawned npm", () => { + 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); + expect(target.kind).toBe("resolved"); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + expect(Object.keys(env).filter(key => key.toLowerCase().startsWith("npm_config_"))).toEqual([]); + } + } finally { + if (prior === undefined) delete process.env.npm_config_registry; + else process.env.npm_config_registry = prior; + if (priorScoped === undefined) delete process.env["npm_config_@openai:registry"]; + else process.env["npm_config_@openai:registry"] = priorScoped; + } + }); + + test("the controlled cwd never is the operator's project directory", () => { + // 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); + resolveCodexCliUpdateTarget("latest", spawn); + for (const call of calls) { + const cwd = call.options.cwd as string; + expect(cwd).not.toBe(process.cwd()); + expect(cwd.startsWith(tmpdir())).toBe(true); + } + }); +}); 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 11ca23c8add..b678f3d6ee6 100644 --- a/tests/codex-integration/codex-cli-update-zero-effect.test.ts +++ b/tests/codex-integration/codex-cli-update-zero-effect.test.ts @@ -195,7 +195,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"); + expect(result.stderr).toContain("codex-cli-update action must be check, attest or plan"); expect(readFileSync(statePath)).toEqual(before); expect(existsSync(marker)).toBe(false); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 59f2265d38d..0a6b664290c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -475,6 +475,7 @@ "codex-cli-installation-identity.test.ts": "codex-integration", "codex-cli-installation-targets.test.ts": "codex-integration", "codex-cli-update-launcher-policy.test.ts": "codex-integration", + "codex-cli-update-plan.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", "codex-forward-access-programs-writer.test.ts": "codex-integration", From 3de4024b35f3687ef7911ab3e1c6c758f8190c9b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:13:41 +0900 Subject: [PATCH 02/24] test(codex): expect platform-shaped selection attestation in the zero-effect check --- tests/codex-integration/codex-cli-update-zero-effect.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 b678f3d6ee6..76a9c43d05e 100644 --- a/tests/codex-integration/codex-cli-update-zero-effect.test.ts +++ b/tests/codex-integration/codex-cli-update-zero-effect.test.ts @@ -163,7 +163,10 @@ describe("Codex CLI updater zero-effect boundary", () => { expect(typeof report.reason).toBe("string"); expect(report.candidateAvailable).toBe(true); expect(report.candidateSource).toBe("environment"); - expect(report.selectionAttested).toBe(false); + // The launcher's proof-bound snapshot attests the environment-declared + // candidate on POSIX; the win32 inspection still defers selection + // attestation, so the flag is platform-dependent by design. + expect(report.selectionAttested).toBe(process.platform !== "win32"); for (const stale of ["selected", "selectedVersion", "selectionSource", "selectionEvidence"]) { expect(stale in report).toBe(false); } From 57b64d9dcd923fe2ebb12c4afc68c7875570219f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:34:40 +0900 Subject: [PATCH 03/24] fix(codex): bind codex-cli-update evidence to the resolved install - Displayed npm install command pins the official registry, matching the resolved target evidence (review major). - Plan identity binds a digest of the canonical install root instead of the redacted /codex display location, so distinct install roots can no longer share a plan id. - selectionAttested additionally requires the runtime resolver pick to match the inspected candidate; a snapshot the resolver would have rejected cannot make another install managed. - The npm config isolation directory cleans itself up on partial setup failure, and setup failure now refuses the resolve instead of throwing. - Help/management surface no longer implies a plan id authorizes an apply that does not exist. --- .../ocx/references/01_management_surface.md | 6 +-- src/cli/capabilities.ts | 6 +-- src/cli/codex-cli-update.ts | 42 ++++++++++++++--- src/codex/cli-install-provenance.ts | 45 ++++++++++++++++--- src/codex/cli-update-plan.ts | 42 +++++++++++++---- tests/cli/cli-codex-cli-update.test.ts | 8 ++++ .../codex-cli-install-provenance.test.ts | 19 ++++++++ .../codex-cli-update-plan.test.ts | 21 +++++++-- 8 files changed, 157 insertions(+), 32 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 472b5362ae0..fd82854cefc 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -420,7 +420,7 @@ Drives no management route. JSON mode: `envelope`. -- Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; this check does not attest or admit a selected runtime. +- Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; the selection is attested only when the runtime resolver picks the same canonical path, and even then this check reports rather than admits a runtime. - On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed. - Makes no package-registry request. - Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state. @@ -449,7 +449,7 @@ JSON mode: `envelope`. ### `ocx system codex-cli-update plan` -Dry-run a Codex CLI update and print the plan id that authorizes applying it. +Dry-run a Codex CLI update and print the plan id binding the decision's evidence. Drives no management route. @@ -463,7 +463,7 @@ JSON mode: `envelope`. - 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. - Writes nothing and installs nothing. 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, 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 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. - An unreadable process table refuses rather than reading as no live session. ### `ocx claude desktop status` diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 5e582ebe1ee..2719d5776af 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -849,7 +849,7 @@ export const CAPABILITIES: readonly Capability[] = [ mutates: false, json: "envelope", details: [ - "Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; this check does not attest or admit a selected runtime.", + "Proof-bound published-launcher context authenticates the configured candidate snapshot, not successful Codex execution; the selection is attested only when the runtime resolver picks the same canonical path, and even then this check reports rather than admits a runtime.", "On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed.", "Makes no package-registry request.", "Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.", @@ -878,7 +878,7 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["system", "codex-cli-update", "plan"], - summary: "Dry-run a Codex CLI update and print the plan id that authorizes applying it.", + summary: "Dry-run a Codex CLI update and print the plan id binding the decision's evidence.", routes: [], flags: [ { name: "--channel", value: "string", summary: "Registry channel to resolve. Only the stable latest channel is offered." }, @@ -890,7 +890,7 @@ export const CAPABILITIES: readonly Capability[] = [ "Adds the three inputs check leaves out: an exact registry version with its sha512 integrity, a fail-closed process-table read, and a decision.", "The registry evidence is pinned to the official npm registry with project/user npm configuration isolated, so a redirected .npmrc cannot supply the answer.", "Writes nothing and installs nothing. 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, 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 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.", "An unreadable process table refuses rather than reading as no live session.", ], }, diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 7da467cbf47..94da6b0ca39 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -17,6 +17,11 @@ import { type CodexCliUpdatePlan, type CodexCliUpdatePlanDeps, } from "../codex/cli-update-plan"; +import { + resolveCodexRuntime, + type ResolveCodexRuntimeDeps, + type ResolveCodexRuntimeResult, +} from "../codex/runtime"; import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api"; import { trustedNodeLauncherContext } from "./launcher-context"; @@ -38,7 +43,8 @@ export interface CodexCliUpdateCommandDeps { snapshot: CodexCliInstallationSnapshot, ) => Promise; readonly createPlan?: (deps: CodexCliUpdatePlanDeps) => Promise; - } + readonly resolveSelectedRuntime?: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult; +} function identitySummary(report: CodexCliInstallationIdentityReport): string[] { return [ @@ -197,9 +203,30 @@ 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(): CodexCliInstallProvenanceDeps { +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. + let selectedCommand: string | null = null; + try { + const resolve = commandDeps.resolveSelectedRuntime ?? resolveCodexRuntime; + selectedCommand = resolve({ + env: { + CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, + PATH: trusted.path ?? undefined, + PATHEXT: trusted.pathExt ?? undefined, + }, + configDir: trusted.configDir, + discoverAlternatives: false, + probeVersion: false, + }).runtime.command ?? null; + } catch { + // A resolver failure must not attest: fail closed with no selected command. + selectedCommand = null; + } return { env: { ...trusted.managerRoots, @@ -208,10 +235,11 @@ function inspectionDeps(): CodexCliInstallProvenanceDeps { PATHEXT: trusted.pathExt ?? undefined, }, configDir: trusted.configDir, - // The snapshot is proof-bound to the launcher, so a codexCliPath it carries is - // the runtime that was actually selected — the predicate that turns an - // npm-global report into a managed one. + // 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, }; } @@ -264,14 +292,14 @@ export async function handleCodexCliUpdateCommand( return; } if (parsed.action === "check") { - const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps()); + const report = await (deps.inspectInstall ?? inspectCodexCliInstall)(inspectionDeps(deps)); printData(report, parsed.json, installSummary(report)); return; } if (parsed.action === "plan") { const plan = await (deps.createPlan ?? createCodexCliUpdatePlan)({ channel: parsed.channel, - inspectionDeps: inspectionDeps(), + inspectionDeps: inspectionDeps(deps), inspect: deps.inspectInstall, }); printData(plan, parsed.json, planSummary(plan)); diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index a9693f9ec4d..c4bf5912621 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -83,6 +83,12 @@ export interface CodexCliInstallReport { readonly managed: boolean; readonly reason: CodexCliInstallReason; readonly location: string | null; + /** + * sha256 digest of the canonical package manifest root when one was observed, + * otherwise null. Digest-only so the public report still carries no path; plan + * identity binds this value so two different install roots cannot share a plan. + */ + readonly installDigest: string | null; readonly packageVersion: string | null; readonly shim: Readonly<{ status: "not-tracked" | "matched" | "unknown"; @@ -112,6 +118,13 @@ export interface CodexCliInstallProvenanceDeps { * a persisted selection and an arbitrary environment stay unattested. */ readonly selectionAttested?: boolean; + /** + * The command the runtime resolver actually selected under the same evidence. + * Attestation additionally requires its canonical path to match the inspected + * candidate, so a snapshot candidate the resolver would have rejected cannot + * turn a different installation managed. + */ + readonly selectedCommand?: string | null; } interface PackageManifestEvidence { @@ -166,6 +179,7 @@ function unknownReport( managed: false, reason, location: extra.location ?? null, + installDigest: null, packageVersion: null, shim: Object.freeze({ status: "not-tracked", backingKind: null }), evidence: Object.freeze([]), @@ -637,6 +651,7 @@ export async function inspectCodexCliInstall( managed: false, reason: appBundle ? "app_bundle" : "version_manager_owned", location: publicExecutableLocation(lexicalCandidatePath, platform), + installDigest: null, packageVersion: null, shim: Object.freeze({ status: "unknown", backingKind: null }), evidence: Object.freeze([appBundle ? "app_bundle_path" : "version_manager_path"]), @@ -646,12 +661,6 @@ export async function inspectCodexCliInstall( location: publicExecutableLocation(lexicalCandidatePath, platform), }); } - // Only a proof-bound caller may attest that the environment candidate is the - // runtime it actually selected. A persisted selection records intent, not the - // resolved binary, and an arbitrary environment is ambient — neither can become - // managed here. - const selectionAttested = - deps.selectionAttested === true && candidate.evidence === "environment"; const configDir = deps.configDir ?? getConfigDir(); if (!isSafeLocalInspectionPath(configDir, deps)) { return unknownReport("shim_state_unknown", candidate); @@ -664,6 +673,23 @@ export async function inspectCodexCliInstall( if (!canonicalCandidatePath) { return unknownReport("candidate_path_unsafe", candidate); } + // Only a proof-bound caller may attest that the environment candidate is the + // runtime it actually selected, and only when the runtime resolver picked the + // same canonical path under the same evidence. A persisted selection records + // intent, not the resolved binary, and an arbitrary environment is ambient — + // neither can become managed here; a resolver that would choose a different + // executable cannot attest the snapshot's installation either. + const resolverPicked = deps.selectedCommand + ? canonicalize( + resolveCandidateCommandPath(deps.selectedCommand, deps) ?? deps.selectedCommand, + deps, + ) + : null; + const selectionAttested = + deps.selectionAttested === true + && candidate.evidence === "environment" + && resolverPicked !== null + && samePath(resolverPicked, canonicalCandidatePath, platform); const inspectShim = deps.inspectShim ?? inspectCodexShimBackingForCommand; const shim = inspectShim(candidatePath, platform, configDir); if (shim.status === "unknown") { @@ -708,6 +734,7 @@ export async function inspectCodexCliInstall( managed: false, reason: "app_bundle", location, + installDigest: null, packageVersion: null, shim: shimReport(shim), evidence: Object.freeze(["canonical_path", "app_bundle_path"]), @@ -729,6 +756,7 @@ export async function inspectCodexCliInstall( managed: false, reason: "version_manager_owned", location, + installDigest: null, packageVersion: null, shim: shimReport(shim), evidence: Object.freeze(["canonical_path", "version_manager_path"]), @@ -781,6 +809,10 @@ export async function inspectCodexCliInstall( managed: manifestOwned && versionMatches && selectionAttested, reason, location, + // Plan identity binds this digest, not the redacted display location: two + // package roots that resolve to the same "/codex" string must still + // produce different plans. + installDigest: sha256("codex-cli-install-root-v1", normalizePath(manifest.root, platform)), packageVersion: manifest.version, shim: shimReport(shim), evidence: Object.freeze([ @@ -814,6 +846,7 @@ export async function inspectCodexCliInstall( managed: false, reason: "unverified_standalone", location, + installDigest: null, packageVersion: null, shim: shimReport(shim), evidence: Object.freeze(["canonical_path"]), diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index e682a8464af..2af6fe743e4 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -119,7 +119,16 @@ const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/=]+$/; /** The install argv this plan quotes for the operator to read, in argv form. */ export function codexCliUpdateCommand(version: string): readonly string[] { - return Object.freeze(["npm", "install", "-g", `${CODEX_CLI_PACKAGE}@${version}`]); + // The displayed command must reproduce the artifact the plan bound: without the + // pinned registry the operator's npmrc could silently substitute another origin + // for the same package@version string. + return Object.freeze([ + "npm", + "install", + "-g", + "--registry=" + CODEX_CLI_REGISTRY, + `${CODEX_CLI_PACKAGE}@${version}`, + ]); } /** @@ -134,7 +143,7 @@ export function codexCliUpdatePlanId(bound: { readonly platform: NodeJS.Platform; readonly provenance: CodexCliInstallKind; readonly installedVersion: string; - readonly location: string | null; + readonly installDigest: string | null; readonly channel: CodexCliUpdateChannel; readonly targetVersion: string; readonly targetIntegrity: string; @@ -147,7 +156,7 @@ export function codexCliUpdatePlanId(bound: { ["platform", bound.platform], ["provenance", bound.provenance], ["installedVersion", bound.installedVersion], - ["location", bound.location ?? ""], + ["installDigest", bound.installDigest ?? ""], ["channel", bound.channel], ["targetVersion", bound.targetVersion], ["targetIntegrity", bound.targetIntegrity], @@ -210,11 +219,19 @@ interface NpmConfigIsolation { * --registry flag plus deliberately supported proxy/CA env. */ function createNpmConfigIsolation(dir?: string): NpmConfigIsolation { + const ownsRoot = dir === undefined; const root = dir ?? mkdtempSync(join(tmpdir(), "ocx-codex-cli-meta-")); - writeFileSync(join(root, "package.json"), "{}\n"); - const npmrc = join(root, "ocx-update.npmrc"); - writeFileSync(npmrc, ""); - return { dir: root, npmrc, env: codexCliUpdateNpmEnv() }; + try { + writeFileSync(join(root, "package.json"), "{}\n"); + const npmrc = join(root, "ocx-update.npmrc"); + writeFileSync(npmrc, ""); + return { dir: root, npmrc, env: codexCliUpdateNpmEnv() }; + } catch (error) { + // A mid-setup failure must not leak a directory this call created; a + // caller-supplied directory stays the caller's responsibility. + if (ownsRoot) rmSync(root, { recursive: true, force: true }); + throw error; + } } /** Registry-pinned, config-isolated argv for one npm call. */ @@ -252,7 +269,14 @@ export function resolveCodexCliUpdateTarget( // 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. - const isolation = createNpmConfigIsolation(); + let isolation: NpmConfigIsolation; + try { + isolation = createNpmConfigIsolation(); + } catch { + // Setup failures are a refusal lane like every other resolve failure, not an + // exception escaping the dry-run. + return Object.freeze({ kind: "unresolved" as const, reason: "npm configuration isolation setup failed" }); + } try { const runView = (field: string, spec: string): SpawnSyncReturns | null => { const target = isolatedNpmTarget(["view", spec, field], isolation); @@ -415,7 +439,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} platform, provenance: report.provenance, installedVersion, - location: report.location, + installDigest: report.installDigest, channel, targetVersion: target.version, targetIntegrity: target.integrity, diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 3aa820456fd..268356089de 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -16,6 +16,7 @@ const report: CodexCliInstallReport = { selectionAttested: false, versionEvidence: { kind: "unavailable" }, provenance: "unknown", managed: false, reason: "candidate_unavailable", location: null, + installDigest: null, packageVersion: null, shim: { status: "not-tracked", backingKind: null }, evidence: [], }; @@ -267,6 +268,12 @@ describe("Codex CLI update CLI", () => { received = deps; return report; }, + // The command asks the resolver which executable it would select; the + // inspector then requires it to match the attested candidate. + resolveSelectedRuntime: () => ({ + runtime: { command: "C:\\managed\\codex.cmd", version: null, source: "environment" }, + failures: [], + }), })).toBe(0); expect(received?.env).toEqual({ FNM_DIR: "C:\\custom-manager", @@ -275,6 +282,7 @@ describe("Codex CLI update CLI", () => { PATHEXT: ".CMD", }); expect(received?.configDir).toBe("C:\\opencodex"); + expect(received?.selectedCommand).toBe("C:\\managed\\codex.cmd"); } finally { initializeNodeLauncherContext(["bun", "cli"], {}); } diff --git a/tests/codex-integration/codex-cli-install-provenance.test.ts b/tests/codex-integration/codex-cli-install-provenance.test.ts index 024b54be1b5..c6f810d1238 100644 --- a/tests/codex-integration/codex-cli-install-provenance.test.ts +++ b/tests/codex-integration/codex-cli-install-provenance.test.ts @@ -263,6 +263,7 @@ describe("Codex CLI install provenance", () => { platform: process.platform, env: { CODEX_CLI_PATH: launcher, PATH: "" }, selectionAttested: true, + selectedCommand: launcher, inspectShim: () => ({ status: "not-tracked" }), }); expect(report).toMatchObject({ @@ -278,6 +279,23 @@ describe("Codex CLI install provenance", () => { expect(JSON.stringify(report)).not.toContain(prefix); }); + test.skipIf(process.platform === "win32")("a resolver pick on another path clears the attestation", async () => { + const prefix = tempRoot("ocx-codex-resolver-mismatch-"); + const { launcher } = createPosixNpmGlobal(prefix); + const report = await inspectCodexCliInstall({ + platform: process.platform, + env: { CODEX_CLI_PATH: launcher, PATH: "" }, + selectionAttested: true, + // The resolver would settle on a different executable, so the snapshot's + // installation must not become managed even though the caller asserts it. + selectedCommand: join(prefix, "elsewhere", "codex"), + inspectShim: () => ({ status: "not-tracked" }), + }); + expect(report.selectionAttested).toBe(false); + expect(report.managed).toBe(false); + expect(report.reason).toBe("selection_unattested"); + }); + test.skipIf(process.platform === "win32")("attestation never rescues an install that is not manifest-owned", async () => { const prefix = tempRoot("ocx-codex-unmanaged-"); const launcher = join(prefix, "bin", "codex"); @@ -295,6 +313,7 @@ describe("Codex CLI install provenance", () => { platform: process.platform, env: { CODEX_CLI_PATH: launcher, PATH: "" }, selectionAttested: true, + selectedCommand: launcher, inspectShim: () => ({ status: "not-tracked" }), }); // The selection is attested but the layout is not a global npm install, so diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 30398b94f6d..b989e757e8a 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -35,6 +35,7 @@ const MANAGED: CodexCliInstallReport = Object.freeze({ managed: true, reason: "managed_npm_global", location: "/@openai/codex", + installDigest: "a".repeat(64), packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, evidence: ["package_manifest", "global_npm_layout"], @@ -75,7 +76,14 @@ describe("Codex CLI update dry-run plan", () => { expect(plan.installedVersion).toBe("1.0.0"); expect(plan.session).toEqual({ state: "none", matches: 0 }); // The dist-tag is resolved once and bound; the install can never widen back to it. - expect(plan.command).toEqual(["npm", "install", "-g", "@openai/codex@1.1.0"]); + // The quoted command pins the same registry the plan resolved its evidence + // from; a bare spec would let the operator's npmrc answer with a different + // artifact for the same version string. + expect(plan.command).toEqual([ + "npm", "install", "-g", + "--registry=" + CODEX_CLI_REGISTRY, + "@openai/codex@1.1.0", + ]); expect(plan.planId).toMatch(/^[0-9a-f]{32}$/); }); @@ -169,7 +177,9 @@ describe("Codex CLI update plan identity", () => { { resolveTarget: () => ({ kind: "resolved", version: "1.2.0", integrity: "sha512-AAAA" }) }, { resolveTarget: () => ({ kind: "resolved", version: "1.1.0", integrity: "sha512-BBBB" }) }, { inspect: async () => report({ packageVersion: "1.0.1", candidateVersion: "1.0.1" }) }, - { inspect: async () => report({ location: "/other/@openai/codex" }) }, + // Two install roots sharing the redacted "/codex" display location + // must still produce different plans. + { inspect: async () => report({ installDigest: "b".repeat(64) }) }, ]; for (const variant of variants) { const plan = await applicablePlan(variant); @@ -188,17 +198,20 @@ describe("Codex CLI update plan identity", () => { expect(second.planId).toBe(first.planId); }); - test("the id is a digest of the bound evidence, not a random handle", () => { + test("the id contains only the explicitly bound evidence", () => { const bound = { platform: "linux" as NodeJS.Platform, provenance: "npm-global" as const, installedVersion: "1.0.0", - location: "/@openai/codex", + installDigest: "a".repeat(64), channel: "latest" as const, targetVersion: "1.1.0", targetIntegrity: "sha512-AAAA", }; expect(codexCliUpdatePlanId(bound)).toBe(codexCliUpdatePlanId(bound)); + expect(codexCliUpdatePlanId(bound)).toMatch(/^[0-9a-f]{32}$/); + // A digest of the same evidence recomputed by a different build agrees. + expect(codexCliUpdatePlanId({ ...bound })).toBe(codexCliUpdatePlanId(bound)); }); }); From 319ea30bf3471a746762f7aa0572c10026831f68 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:13:04 +0900 Subject: [PATCH 04/24] test(cli): match the check capability's reporting contract --- tests/cli/cli-capabilities.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index dc5b1685b4c..e7592f9cfcb 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -93,7 +93,7 @@ describe("capability table is a leaf data module", () => { expect(cap?.json).toBe("envelope"); expect(cap?.flags.some(flag => flag.name === "--json")).toBe(true); expect(cap?.summary).toContain("configured Codex CLI candidate"); - expect(cap?.details.join(" ")).toContain("does not attest or admit a selected runtime"); + expect(cap?.details.join(" ")).toContain("reports rather than admits a runtime"); expect(cap?.details.join(" ")).not.toContain("dry-run"); }); }); From 814aa2fc53bb9425fdc5a55376c04ba7c3c2bfe1 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:21:08 +0900 Subject: [PATCH 05/24] fix(codex): bind update-plan npm evidence and session scan to launcher proof --- .../ocx/references/01_management_surface.md | 2 +- src/cli/capabilities.ts | 2 +- src/codex/app-server-processes.ts | 53 +++++++++++++++- src/codex/cli-update-plan.ts | 61 ++++++++++++++----- .../codex-app-server-processes.test.ts | 54 ++++++++++++++++ .../codex-cli-update-plan.test.ts | 56 ++++++++++++++++- 6 files changed, 208 insertions(+), 20 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index fd82854cefc..01160ce932a 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -462,7 +462,7 @@ JSON mode: `envelope`. - 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. -- Writes nothing and installs nothing. A refusal is a normal dry-run answer and still exits 0. +- Installs nothing and leaves no state behind; registry evidence is gathered under a temporary isolated npm config directory that is removed 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. - An unreadable process table refuses rather than reading as no live session. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 2719d5776af..9f24a02b6da 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -889,7 +889,7 @@ export const CAPABILITIES: readonly Capability[] = [ details: [ "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.", - "Writes nothing and installs nothing. A refusal is a normal dry-run answer and still exits 0.", + "Installs nothing and leaves no state behind; registry evidence is gathered under a temporary isolated npm config directory that is removed 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.", "An unreadable process table refuses rather than reading as no live session.", ], diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index b58b462a01b..9368ba51814 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -332,6 +332,32 @@ export function isCodexAppServerCommandLine(commandLine: string, executable?: st return false; } +/** + * True when the command line is ANY live Codex session, not just an app-server. + * + * The update-blocking contract needs the broader reading: an interactive TUI, a + * one-shot `codex exec`, or an app-server all have the package's binaries in use, + * so every subcommand counts. The executable identity stays exactly as strict as + * {@link isCodexAppServerCommandLine} - only the argv discipline is dropped. A + * coincidental codex-shaped argument can never open this matcher. + */ +export function isCodexSessionCommandLine(commandLine: string, executable?: string): boolean { + const trimmed = commandLine.trim(); + let tokens = tokenizeCommandLine(trimmed); + if (executable) { + if (isCodeModeHostToken(executable) || isCodexExecutableToken(executable)) return true; + } + if (tokens.length === 0) return false; + if (isCodeModeHostProcess(tokens)) return true; + // Same interpreter unwrap the app-server matcher uses: a codex-shaped token must + // immediately follow the interpreter so an interpreter flag value is never read + // as the entrypoint. + if (isInterpreterToken(tokens[0]!) && tokens.length > 1 && isCodexExecutableToken(tokens[1]!)) { + tokens = tokens.slice(1); + } + return isCodexExecutableToken(tokens[0]!); +} + function parseUnixProcStatusUid(status: string): number | undefined { const match = /^Uid:\s+(\d+)/m.exec(status); if (!match) return undefined; @@ -593,8 +619,10 @@ export type CodexAppServerProcessScan = | Readonly<{ kind: "observed"; processes: readonly CodexAppServerProcess[] }> | Readonly<{ kind: "unavailable" }>; -/** Same matcher and snapshot sources as {@link listCodexAppServerProcesses}, failing closed. */ -export function scanCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcessScan { +function scanProcessSnapshots( + io: CodexAppServerProcessIo, + matches: (commandLine: string, executable?: string) => boolean, +): CodexAppServerProcessScan { const platform = io.platform ?? process.platform; const getuid = io.getuid ?? (() => { try { @@ -613,13 +641,32 @@ export function scanCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C const matched: CodexAppServerProcess[] = []; for (const snapshot of snapshots) { if (seen.has(snapshot.pid)) continue; - if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue; + if (!matches(snapshot.commandLine, snapshot.executable)) continue; seen.add(snapshot.pid); matched.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); } return Object.freeze({ kind: "observed" as const, processes: Object.freeze(matched) }); } +/** Same matcher and snapshot sources as {@link listCodexAppServerProcesses}, failing closed. */ +export function scanCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcessScan { + return scanProcessSnapshots(io, isCodexAppServerCommandLine); +} + +/** + * {@link isCodexSessionCommandLine} over the same snapshot sources, failing closed. + * + * Any live Codex session counts - TUI, exec or app-server all hold the installed + * package open while npm would be replacing it, and a missed TUI was the reported + * source of a half-written install on Linux. The kill path keeps the narrower + * {@link isCodexAppServerCommandLine} because a signal must never leave its + * verified contract; this scan decides whether it is SAFE to install, so it may + * only err toward deferring. + */ +export function scanCodexSessionProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcessScan { + return scanProcessSnapshots(io, isCodexSessionCommandLine); +} + export function formatStaleCodexAppServerWarning( processes: readonly { pid: number }[], ): string { diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 2af6fe743e4..01d9e67a625 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -13,7 +13,7 @@ import { type CodexCliInstallReport, } from "./cli-install-provenance"; import { - scanCodexAppServerProcesses, + scanCodexSessionProcesses, type CodexAppServerProcessIo, type CodexAppServerProcessScan, } from "./app-server-processes"; @@ -32,8 +32,9 @@ import { * is a digest over the evidence the decision rests on, so an approved change to any * bound field produces a different id. * - Nothing is terminated or restarted. A live Codex session refuses the plan; it is - * never signalled, killed or waited on. The plan itself is read-only: it resolves - * registry metadata and reads the process table, but installs nothing. + * never signalled, killed or waited on. The plan installs nothing: it resolves + * registry metadata and reads the process table under a temporary isolated npm + * config directory that is removed afterwards, leaving no state behind. */ export const CODEX_CLI_PACKAGE = "@openai/codex"; @@ -109,6 +110,7 @@ export interface CodexCliUpdatePlanDeps { readonly scanProcesses?: (io?: CodexAppServerProcessIo) => CodexAppServerProcessScan; readonly processIo?: CodexAppServerProcessIo; readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget; + readonly spawnProcess?: typeof spawnSync; } @@ -176,8 +178,8 @@ interface NpmTarget { }; } -function npmTarget(args: readonly string[]): NpmTarget | null { - const invocation = npmInvocation(args); +function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget | null { + const invocation = npmInvocation(args, process.platform, env ?? process.env); if (!invocation) return null; return { bin: invocation.file, args: invocation.args, options: invocation.options }; } @@ -192,14 +194,35 @@ function npmTarget(args: readonly string[]): NpmTarget | null { * variables npm itself honors (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY, any case) * and NODE_EXTRA_CA_CERTS, which npm's own Node runtime reads for TLS. */ -function codexCliUpdateNpmEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; +function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...base }; for (const key of Object.keys(env)) { if (key.toLowerCase().startsWith("npm_config_")) delete env[key]; } return env; } +/** + * Ambient environment with the executable-selection channels replaced by the proof-bound + * launcher snapshot. HOME, proxy variables and NODE_EXTRA_CA_CERTS stay ambient because npm + * itself needs them; PATH/PATHEXT are the channels a project dotenv could use to supply a + * fake npm, so they come only from the captured snapshot. A snapshot without PATH means no + * trusted PATH at all — an empty one fails the spawn closed rather than falling back. + */ +function codexCliUpdateBoundEnv(bound: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + const merged = codexCliUpdateNpmEnv(); + if (bound === undefined) return merged; + for (const key of Object.keys(merged)) { + const k = key.toLowerCase(); + if (k === "path" || k === "pathext") delete merged[key]; + } + for (const [key, value] of Object.entries(bound)) { + const k = key.toLowerCase(); + if ((k === "path" || k === "pathext") && value !== undefined) merged[key] = value; + } + return merged; +} + interface NpmConfigIsolation { /** Controlled cwd: contains the sentinel package.json, so npm's project-config walk stops here. */ readonly dir: string; @@ -218,14 +241,14 @@ interface NpmConfigIsolation { * and the env filter removes the env-var channel. What remains is exactly the pinned * --registry flag plus deliberately supported proxy/CA env. */ -function createNpmConfigIsolation(dir?: string): NpmConfigIsolation { +function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation { const ownsRoot = dir === undefined; const root = dir ?? mkdtempSync(join(tmpdir(), "ocx-codex-cli-meta-")); try { writeFileSync(join(root, "package.json"), "{}\n"); const npmrc = join(root, "ocx-update.npmrc"); writeFileSync(npmrc, ""); - return { dir: root, npmrc, env: codexCliUpdateNpmEnv() }; + return { dir: root, npmrc, env: codexCliUpdateBoundEnv(boundEnv) }; } catch (error) { // A mid-setup failure must not leak a directory this call created; a // caller-supplied directory stays the caller's responsibility. @@ -245,7 +268,7 @@ function isolatedNpmArgs(args: readonly string[], isolation: NpmConfigIsolation) } function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolation): NpmTarget | null { - const target = npmTarget(isolatedNpmArgs(args, isolation)); + const target = npmTarget(isolatedNpmArgs(args, isolation), isolation.env); if (!target) return null; return { bin: target.bin, @@ -265,13 +288,15 @@ function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolatio export function resolveCodexCliUpdateTarget( channel: CodexCliUpdateChannel, spawn: typeof spawnSync = spawnSync, + /** Proof-bound launcher snapshot env; executable resolution reads only its PATH/PATHEXT. */ + boundEnv?: NodeJS.ProcessEnv, ): CodexCliUpdateTarget { // 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. let isolation: NpmConfigIsolation; try { - isolation = createNpmConfigIsolation(); + isolation = createNpmConfigIsolation(undefined, boundEnv); } catch { // Setup failures are a refusal lane like every other resolve failure, not an // exception escaping the dry-run. @@ -365,7 +390,9 @@ function refusedPlan( const NOT_EVALUATED: CodexCliUpdateSession = Object.freeze({ state: "not-evaluated" as const, matches: null }); /** - * Build the dry-run plan. Reads only; nothing here writes, signals or installs. + * Build the dry-run plan. Installs nothing and leaves no state behind; registry + * evidence is gathered under a temporary isolated npm config directory that is + * removed afterwards. * * The refusal order is deliberate. Ownership and target questions are settled before the * process table is read, so a machine that can never be updated by this workflow does not @@ -397,7 +424,13 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} return refusedPlan("installed_version_unverified", report, channel, null, NOT_EVALUATED); } - const resolveTarget = deps.resolveTarget ?? (ch => resolveCodexCliUpdateTarget(ch)); + // The npm evidence channels are bound to the same environment snapshot the + // ownership inspection ran against: PATH/PATHEXT from the proof-bound launcher + // context, never the ambient ones a project dotenv could point at a fake npm. + // 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); if (target.kind !== "resolved") { return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED); @@ -417,7 +450,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {} return refusedPlan("target_not_newer", report, channel, target, NOT_EVALUATED); } - const scan = (deps.scanProcesses ?? scanCodexAppServerProcesses)(deps.processIo ?? {}); + const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {}); if (scan.kind !== "observed") { // An unreadable process table is not "no sessions". `listCodexAppServerProcesses` // maps that failure to an empty list because its kill contract must never signal a diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index e36f91c22e0..27c80cf9e6a 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -17,12 +17,14 @@ import { collectCodexAppServerCatalogStateForRequest, formatStaleCodexAppServerWarning, isCodexAppServerCommandLine, + isCodexSessionCommandLine, isWindowsCodexCandidateCommandLine, listCodexAppServerProcesses, listWindowsSnapshots, parseWindowsSnapshotOutput, resetCodexAppServerCatalogStateCache, restartCodexAppServers, + scanCodexSessionProcesses, STALE_CODEX_APP_SERVER_HINT, warnIfStaleCodexAppServersAfterStartupWrite, WINDOWS_CODEX_BASENAME_CANDIDATE_RE, @@ -753,6 +755,58 @@ describe("Codex app-server process matching (#476)", () => { }); }); +describe("Codex session process matching (#2811)", () => { + test("any live codex session counts, regardless of the subcommand", () => { + expect(isCodexSessionCommandLine("codex")).toBe(true); + expect(isCodexSessionCommandLine("codex resume --last")).toBe(true); + expect(isCodexSessionCommandLine("codex exec \"debug app-server behavior\"")).toBe(true); + // "--" ends option parsing, but the prompt after it is still a live TUI session. + expect(isCodexSessionCommandLine("codex -- app-server")).toBe(true); + expect(isCodexSessionCommandLine("codex login")).toBe(true); + expect(isCodexSessionCommandLine("/usr/local/bin/codex --profile prod")).toBe(true); + expect(isCodexSessionCommandLine("codex-x86_64-unknown-linux-musl")).toBe(true); + expect(isCodexSessionCommandLine("codex-code-mode-host --session 1")).toBe(true); + }); + + test("matches the npm wrapper pair and the shim backup sessions", () => { + expect(isCodexSessionCommandLine("node /usr/local/bin/codex")).toBe(true); + expect(isCodexSessionCommandLine("bun /usr/local/bin/codex exec hi")).toBe(true); + expect(isCodexSessionCommandLine("/home/ubuntu/.local/bin/codex.opencodex-real")).toBe(true); + // A snapshot that reports the executable separately still identifies the session. + expect(isCodexSessionCommandLine("codex resume --last", "/usr/local/bin/codex")).toBe(true); + }); + + test("codex-shaped names in arguments or unrelated binaries stay unmatched", () => { + expect(isCodexSessionCommandLine("node worker.js codex")).toBe(false); + expect(isCodexSessionCommandLine("bash -c codex")).toBe(false); + expect(isCodexSessionCommandLine("opencodex")).toBe(false); + expect(isCodexSessionCommandLine("codex-bridge serve")).toBe(false); + expect(isCodexSessionCommandLine("hermes-codex-x86_64-unknown-linux-gnu")).toBe(false); + expect(isCodexSessionCommandLine("")).toBe(false); + }); + + test("scanCodexSessionProcesses fails closed and dedupes", () => { + const unavailable = scanCodexSessionProcesses({ + platform: "linux", + listSnapshots: () => { throw new Error("procfs unreadable"); }, + }); + expect(unavailable.kind).toBe("unavailable"); + const scan = scanCodexSessionProcesses({ + platform: "linux", + listSnapshots: () => [ + { pid: 11, commandLine: "vim notes.txt" }, + { pid: 12, commandLine: "codex" }, + { pid: 12, commandLine: "codex" }, + { pid: 13, commandLine: "codex app-server" }, + ], + }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") { + expect(scan.processes.map(process => process.pid)).toEqual([12, 13]); + } + }); +}); + describe("CLI /api sync wiring for stale app-servers (#476)", () => { const dispatchSource = readFileSync(repoPath("src", "cli", "dispatch.ts"), "utf8"); const configRoutesSource = readFileSync( diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index b989e757e8a..9627f65de39 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { scanCodexAppServerProcesses } from "../../src/codex/app-server-processes"; import type { CodexCliInstallReport } from "../../src/codex/cli-install-provenance"; @@ -168,6 +168,20 @@ describe("Codex CLI update dry-run plan", () => { expect(JSON.stringify(plan)).not.toContain("secret"); expect(JSON.stringify(plan)).not.toContain("4242"); }); + + test("a plain interactive Codex session blocks the plan, not just an app-server", async () => { + // The default session scan must see a TUI the app-server matcher deliberately + // rejects: a missed "codex" TUI is what half-wrote a managed install. + const plan = await createCodexCliUpdatePlan(planDeps({ + scanProcesses: undefined, + processIo: { + platform: "linux", + listSnapshots: () => [{ pid: 9, commandLine: "codex" }], + }, + })); + expect(plan.refusal).toBe("blocked_active_session"); + expect(plan.session).toEqual({ state: "active", matches: 1 }); + }); }); describe("Codex CLI update plan identity", () => { @@ -396,4 +410,44 @@ describe("registry configuration isolation", () => { expect(cwd.startsWith(tmpdir())).toBe(true); } }); + + // A bound PATH must still resolve a real npm: the marker prefix makes the value + // observably bound while the ambient entries appended after it keep npm reachable + // on Windows, where the resolver walks PATH on disk. + function markerBoundEnv(): NodeJS.ProcessEnv { + return { + PATH: "D:\\attested-marker" + delimiter + (process.env.PATH ?? ""), + PATHEXT: (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + ";.XYZ", + }; + } + + test("the bound launcher snapshot supplies npm's PATH and PATHEXT, not the ambient ones", () => { + const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); + const boundEnv = markerBoundEnv(); + const target = resolveCodexCliUpdateTarget("latest", spawn, boundEnv); + expect(target.kind).toBe("resolved"); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + expect(env.PATH).toBe(boundEnv.PATH); + expect(env.PATHEXT).toBe(boundEnv.PATHEXT); + } + }); + + test("the plan resolves registry evidence through the attested launcher environment", async () => { + 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 }, + spawnProcess: spawn, + }); + expect(plan.targetVersion).toBe("1.4.2"); + expect(calls.length).toBe(3); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + expect(env.PATH).toBe(boundEnv.PATH); + } + }); }); From bbd08022fa74df3bedc410df7330805254768941 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:41:40 +0900 Subject: [PATCH 06/24] fix(codex): restore generated management-surface reference (mojibake drift) --- skills/ocx/references/01_management_surface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 01160ce932a..031de633970 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -110,7 +110,7 @@ Drives no management route. JSON mode: `envelope`. -- Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess ??unknown liveness never reads as absent. +- Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess — unknown liveness never reads as absent. - Built for embedding shells (desktop app): the liveness budgets stay owned by src/server/proxy-liveness.ts. ### `ocx capabilities` @@ -680,7 +680,7 @@ Rotate the connected client's data key against the hub, with commit and abort. JSON mode: `payload`. - Requires transient authority on stdin; the credential is never persisted or echoed. -- A rotation left pending by a crash is resumed here ??startup and status stop rather than guess which key generation is live. +- A rotation left pending by a crash is resumed here — startup and status stop rather than guess which key generation is live. ### `ocx provider keychain` From 5c4c967d90a85387a4b66aa2d7cbbb81adcc5a18 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:21:02 +0900 Subject: [PATCH 07/24] fix(codex): isolate npm evidence env and cache under the owned temp root --- .../ocx/references/01_management_surface.md | 2 +- src/cli/capabilities.ts | 2 +- src/cli/registry.ts | 2 +- src/codex/cli-update-plan.ts | 48 ++++++-- .../codex-cli-update-plan.test.ts | 106 +++++++++++++++++- 5 files changed, 147 insertions(+), 13 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 031de633970..b733f928b8a 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -462,7 +462,7 @@ JSON mode: `envelope`. - 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 leaves no state behind; registry evidence is gathered under a temporary isolated npm config directory that is removed afterwards. A refusal is a normal dry-run answer and still exits 0. +- 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. - An unreadable process table refuses rather than reading as no live session. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 9f24a02b6da..232a622ec09 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -889,7 +889,7 @@ export const CAPABILITIES: readonly Capability[] = [ details: [ "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 leaves no state behind; registry evidence is gathered under a temporary isolated npm config directory that is removed afterwards. A refusal is a normal dry-run answer and still exits 0.", + "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.", "An unreadable process table refuses rather than reading as no live session.", ], diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f93bd85735f..1898e18b9f9 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -450,7 +450,7 @@ 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]", - "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 still writes nothing.", + "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/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 01d9e67a625..12f8378a6fa 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -32,9 +32,11 @@ import { * is a digest over the evidence the decision rests on, so an approved change to any * bound field produces a different id. * - Nothing is terminated or restarted. A live Codex session refuses the plan; it is - * never signalled, killed or waited on. The plan installs nothing: it resolves - * registry metadata and reads the process table under a temporary isolated npm - * config directory that is removed afterwards, leaving no state behind. + * never signalled, killed or waited on. The plan performs no application-state + * mutation: it resolves registry metadata and reads the process table under a + * temporary isolated npm root (cwd, npmrc, cache and logs) that is removed + * afterwards; a forcibly terminated run may leave that temp root behind, so the + * cleanup contract is best-effort rather than absolute. */ export const CODEX_CLI_PACKAGE = "@openai/codex"; @@ -193,11 +195,26 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget * this codebase intentionally supports survive untouched: the standard proxy * variables npm itself honors (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY, any case) * and NODE_EXTRA_CA_CERTS, which npm's own Node runtime reads for TLS. + * + * Node's own startup channels are stripped too. npm runs inside Node, so ambient + * NODE_OPTIONS injects code before the first npm line, NODE_PATH redirects module + * resolution, and NODE_TLS_REJECT_UNAUTHORIZED would make the pinned-registry TLS + * check void - each one reaches the evidence-producing process unless removed. + * NODE_EXTRA_CA_CERTS survives because it is the supported TLS extension, not an + * execution or trust bypass. */ +const NPM_ENV_DROP_KEYS: ReadonlySet = new Set([ + "node_options", + "node_path", + "node_tls_reject_unauthorized", + "node_compile_cache", +]); + function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...base }; for (const key of Object.keys(env)) { - if (key.toLowerCase().startsWith("npm_config_")) delete env[key]; + const lower = key.toLowerCase(); + if (lower.startsWith("npm_config_") || NPM_ENV_DROP_KEYS.has(lower)) delete env[key]; } return env; } @@ -216,10 +233,18 @@ function codexCliUpdateBoundEnv(bound: NodeJS.ProcessEnv | undefined): NodeJS.Pr const k = key.toLowerCase(); if (k === "path" || k === "pathext") delete merged[key]; } + let hasTrustedPath = false; for (const [key, value] of Object.entries(bound)) { const k = key.toLowerCase(); - if ((k === "path" || k === "pathext") && value !== undefined) merged[key] = value; + if ((k === "path" || k === "pathext") && value !== undefined) { + merged[key] = value; + if (k === "path") hasTrustedPath = true; + } } + // A trusted snapshot without PATH means "no trusted PATH", not "keep the ambient + // one". Install the empty value explicitly so executable resolution fails closed + // instead of silently re-inheriting a PATH the proof never attested. + if (!hasTrustedPath) merged.PATH = ""; return merged; } @@ -228,6 +253,9 @@ interface NpmConfigIsolation { readonly dir: string; /** Empty file substituted for both the user and the global npmrc. */ readonly npmrc: string; + /** npm cache root under dir: keeps _cacache and _logs inside the owned root so a + * successful query cannot leave residue in the ambient ~/.npm. */ + readonly cache: string; readonly env: NodeJS.ProcessEnv; } @@ -248,7 +276,8 @@ function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): N writeFileSync(join(root, "package.json"), "{}\n"); const npmrc = join(root, "ocx-update.npmrc"); writeFileSync(npmrc, ""); - return { dir: root, npmrc, env: codexCliUpdateBoundEnv(boundEnv) }; + const cache = join(root, "npm-cache"); + return { dir: root, npmrc, cache, env: codexCliUpdateBoundEnv(boundEnv) }; } catch (error) { // A mid-setup failure must not leak a directory this call created; a // caller-supplied directory stays the caller's responsibility. @@ -264,6 +293,7 @@ function isolatedNpmArgs(args: readonly string[], isolation: NpmConfigIsolation) "--registry=" + CODEX_CLI_REGISTRY, "--userconfig=" + isolation.npmrc, "--globalconfig=" + isolation.npmrc, + "--cache=" + isolation.cache, ]; } @@ -390,9 +420,9 @@ function refusedPlan( const NOT_EVALUATED: CodexCliUpdateSession = Object.freeze({ state: "not-evaluated" as const, matches: null }); /** - * Build the dry-run plan. Installs nothing and leaves no state behind; registry - * evidence is gathered under a temporary isolated npm config directory that is - * removed afterwards. + * Build the dry-run plan. Installs nothing and mutates no application state; + * registry evidence is gathered under a temporary isolated npm root whose cache, + * logs, npmrc and cwd are all inside it and removed best-effort afterwards. * * The refusal order is deliberate. Ownership and target questions are settled before the * process table is read, so a machine that can never be updated by this workflow does not diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 9627f65de39..05cdde37102 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; @@ -450,4 +451,107 @@ describe("registry configuration isolation", () => { expect(env.PATH).toBe(boundEnv.PATH); } }); + + test("hostile Node startup channels cannot reach the spawned npm", () => { + const priorOptions = process.env.NODE_OPTIONS; + const priorPath = process.env.NODE_PATH; + const priorTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_OPTIONS = "--require=evil"; + process.env.NODE_PATH = "C:\\evil-modules"; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + try { + const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); + const target = resolveCodexCliUpdateTarget("latest", spawn); + expect(target.kind).toBe("resolved"); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + const lower = new Set(Object.keys(env).map(key => key.toLowerCase())); + expect(lower.has("node_options")).toBe(false); + expect(lower.has("node_path")).toBe(false); + expect(lower.has("node_tls_reject_unauthorized")).toBe(false); + } + } finally { + if (priorOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = priorOptions; + if (priorPath === undefined) delete process.env.NODE_PATH; + else process.env.NODE_PATH = priorPath; + if (priorTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + else process.env.NODE_TLS_REJECT_UNAUTHORIZED = priorTls; + } + }); + + test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", () => { + const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); + const target = resolveCodexCliUpdateTarget("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); + // With no trusted PATH the resolver must refuse, never falling back to the + // ambient PATH that could resolve a hostile npm. Windows resolves the binary + // itself and spawns nothing; POSIX spawns the bare name whose execvp lookup + // must then run under the explicit empty PATH. + expect(target.kind).toBe("unresolved"); + for (const call of calls) { + expect((call.options.env as NodeJS.ProcessEnv).PATH).toBe(""); + } + }); + + test("npm cache and log state stay inside the owned temporary root", () => { + const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); + const target = resolveCodexCliUpdateTarget("latest", spawn); + expect(target.kind).toBe("resolved"); + for (const call of calls) { + const cwd = call.options.cwd as string; + const line = argvLine(call); + expect(line).toContain("--cache="); + const cacheFlag = line.match(/--cache=(\S+)/); + expect(cacheFlag).not.toBeNull(); + // The cache root (and therefore npm's _logs directory beneath it) must live + // inside the owned isolation dir, not under an ambient ~/.npm. + expect(cacheFlag![1]!.startsWith(cwd)).toBe(true); + } + }); + + test("a real npm call under the isolated argv leaves the operator HOME untouched", () => { + // 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. + const fakeHome = mkdtempSync(join(tmpdir(), "ocx-update-test-home-")); + try { + const calls: CapturedCall[] = []; + let realRan = false; + const spawn = ((bin: string, args: string[], options: Record) => { + const cwd = options.cwd as string; + const call: CapturedCall = { + bin, args, options, + cwdHadSentinel: existsSync(join(cwd, "package.json")), + cwdHadNpmrc: existsSync(join(cwd, "ocx-update.npmrc")), + }; + calls.push(call); + const field = queriedField(call); + if (realRan) return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + realRan = true; + const env = { ...(options.env as NodeJS.ProcessEnv), HOME: fakeHome, USERPROFILE: fakeHome, npm_config_offline: "true" }; + return spawnSync(bin, args, { ...options, env, timeout: 30_000, windowsHide: true, encoding: "utf8" }); + }) as never; + resolveCodexCliUpdateTarget("latest", spawn); + expect(realRan).toBe(true); + const cwd = calls[0]!.options.cwd as string; + // Anything npm persisted must be inside the owned root; the ambient HOME must + // stay completely empty (no ~/.npm, no ~/.npmrc, no _logs). + const collect = (root: string, base: string, out: string[]): void => { + let names: string[]; + try { names = readdirSync(root); } catch { return; } + for (const name of names) out.push(join(base, name)); + }; + const homeEntries: string[] = []; + collect(fakeHome, ".", homeEntries); + expect(homeEntries).toEqual([]); + const leaked: string[] = []; + collect(cwd, ".", leaked); + for (const entry of leaked) { + const normalized = entry.replace(/\\/g, "/"); + expect(normalized.startsWith("./package.json") || normalized.startsWith("./ocx-update.npmrc") || normalized.startsWith("./npm-cache")).toBe(true); + } + } finally { + rmSync(fakeHome, { recursive: true, force: true }); + } + }); }); From d05a5d990134456ae292407d6cf0953ec89329c5 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:25:00 +0900 Subject: [PATCH 08/24] test(codex): assert the fail-closed PATH instead of a spawn-stub outcome (#6152) --- tests/codex-integration/codex-cli-update-plan.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 05cdde37102..8a18584030a 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -482,12 +482,10 @@ describe("registry configuration isolation", () => { test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - const target = resolveCodexCliUpdateTarget("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); - // With no trusted PATH the resolver must refuse, never falling back to the - // ambient PATH that could resolve a hostile npm. Windows resolves the binary - // itself and spawns nothing; POSIX spawns the bare name whose execvp lookup - // must then run under the explicit empty PATH. - expect(target.kind).toBe("unresolved"); + resolveCodexCliUpdateTarget("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. for (const call of calls) { expect((call.options.env as NodeJS.ProcessEnv).PATH).toBe(""); } From aba8580752f71e80571e2a369c9f001024a53f13 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:57:16 +0900 Subject: [PATCH 09/24] fix(codex): drop ambient Node output paths and prove the fail-closed lanes (#6152) --- src/codex/cli-update-plan.ts | 11 +++- .../codex-cli-update-plan.test.ts | 50 ++++++++++++------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 12f8378a6fa..cc2c9e081aa 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -200,14 +200,21 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget * NODE_OPTIONS injects code before the first npm line, NODE_PATH redirects module * resolution, and NODE_TLS_REJECT_UNAUTHORIZED would make the pinned-registry TLS * check void - each one reaches the evidence-producing process unless removed. - * NODE_EXTRA_CA_CERTS survives because it is the supported TLS extension, not an - * execution or trust bypass. + * NODE_V8_COVERAGE and NODE_REDIRECT_WARNINGS are dropped for the same reason in + * reverse: they are output paths, so ambient values would write Node artifacts to + * arbitrary external directories on exit. The surviving allowlist is only the + * proxy variables plus NODE_EXTRA_CA_CERTS, the supported TLS extension. */ const NPM_ENV_DROP_KEYS: ReadonlySet = new Set([ "node_options", "node_path", "node_tls_reject_unauthorized", "node_compile_cache", + // Output-path channels: ambient values would make the Node process write + // coverage JSON or warning output to an arbitrary external directory on exit, + // escaping the owned temp root regardless of the isolated --cache. + "node_v8_coverage", + "node_redirect_warnings", ]); function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 8a18584030a..b4d1ed65e43 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -456,9 +456,13 @@ describe("registry configuration isolation", () => { const priorOptions = process.env.NODE_OPTIONS; const priorPath = process.env.NODE_PATH; const priorTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + const priorCoverage = process.env.NODE_V8_COVERAGE; + const priorWarnings = process.env.NODE_REDIRECT_WARNINGS; process.env.NODE_OPTIONS = "--require=evil"; process.env.NODE_PATH = "C:\\evil-modules"; process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + process.env.NODE_V8_COVERAGE = "C:\\evil-coverage"; + process.env.NODE_REDIRECT_WARNINGS = "C:\\evil-warnings.log"; try { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const target = resolveCodexCliUpdateTarget("latest", spawn); @@ -469,6 +473,8 @@ describe("registry configuration isolation", () => { expect(lower.has("node_options")).toBe(false); expect(lower.has("node_path")).toBe(false); expect(lower.has("node_tls_reject_unauthorized")).toBe(false); + expect(lower.has("node_v8_coverage")).toBe(false); + expect(lower.has("node_redirect_warnings")).toBe(false); } } finally { if (priorOptions === undefined) delete process.env.NODE_OPTIONS; @@ -477,6 +483,10 @@ describe("registry configuration isolation", () => { else process.env.NODE_PATH = priorPath; if (priorTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; else process.env.NODE_TLS_REJECT_UNAUTHORIZED = priorTls; + if (priorCoverage === undefined) delete process.env.NODE_V8_COVERAGE; + else process.env.NODE_V8_COVERAGE = priorCoverage; + if (priorWarnings === undefined) delete process.env.NODE_REDIRECT_WARNINGS; + else process.env.NODE_REDIRECT_WARNINGS = priorWarnings; } }); @@ -489,6 +499,12 @@ describe("registry configuration isolation", () => { for (const call of calls) { expect((call.options.env as NodeJS.ProcessEnv).PATH).toBe(""); } + // The fail-closed contract is the resolution outcome, not a stub answer: a + // 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" }); + expect(refused.kind).toBe("unresolved"); }); test("npm cache and log state stay inside the owned temporary root", () => { @@ -514,6 +530,7 @@ describe("registry configuration isolation", () => { const fakeHome = mkdtempSync(join(tmpdir(), "ocx-update-test-home-")); try { const calls: CapturedCall[] = []; + const ownedDuringRun: string[] = []; let realRan = false; const spawn = ((bin: string, args: string[], options: Record) => { const cwd = options.cwd as string; @@ -525,29 +542,28 @@ 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: "" }; - realRan = true; const env = { ...(options.env as NodeJS.ProcessEnv), HOME: fakeHome, USERPROFILE: fakeHome, npm_config_offline: "true" }; - return spawnSync(bin, args, { ...options, env, timeout: 30_000, windowsHide: true, encoding: "utf8" }); + const result = spawnSync(bin, args, { ...options, env, timeout: 30_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. + expect(result.error).toBeUndefined(); + expect(result.status === 0 || result.status === 1).toBe(true); + // 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: "" }; }) as never; resolveCodexCliUpdateTarget("latest", spawn); expect(realRan).toBe(true); const cwd = calls[0]!.options.cwd as string; - // Anything npm persisted must be inside the owned root; the ambient HOME must - // stay completely empty (no ~/.npm, no ~/.npmrc, no _logs). - const collect = (root: string, base: string, out: string[]): void => { - let names: string[]; - try { names = readdirSync(root); } catch { return; } - for (const name of names) out.push(join(base, name)); - }; - const homeEntries: string[] = []; - collect(fakeHome, ".", homeEntries); - expect(homeEntries).toEqual([]); - const leaked: string[] = []; - collect(cwd, ".", leaked); - for (const entry of leaked) { - const normalized = entry.replace(/\\/g, "/"); - expect(normalized.startsWith("./package.json") || normalized.startsWith("./ocx-update.npmrc") || normalized.startsWith("./npm-cache")).toBe(true); + // Anything npm persisted was inside the owned root and is removed with it; + // the disposable HOME stays completely empty (no ~/.npm, no ~/.npmrc, no _logs). + for (const name of ownedDuringRun) { + expect(["package.json", "ocx-update.npmrc", "npm-cache"].includes(name)).toBe(true); } + expect(existsSync(cwd)).toBe(false); + expect(readdirSync(fakeHome)).toEqual([]); } finally { rmSync(fakeHome, { recursive: true, force: true }); } From 462e47b947f076b881ab4b14885fbee226cd2262 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 29 Sep 2026 01:37:15 +0000 Subject: [PATCH 10/24] fix(codex): separate isolated npm configuration paths Use distinct empty user and global npmrc files so npm does not reject the read-only update plan with a double-loading config error. Add a real, network-free npm config regression and strengthen the residue test. Validated the original/fixed isolation function bodies with Node 22.16.0 and npm 10.9.2 in disposable directories; original exits 1, fixed exits 0. Repository Bun tests and full typecheck remain for exact-head CI. --- src/codex/cli-update-plan.ts | 9 ++-- .../codex-cli-update-plan.test.ts | 47 ++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index cc2c9e081aa..009edad667b 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -258,8 +258,9 @@ function codexCliUpdateBoundEnv(bound: NodeJS.ProcessEnv | undefined): NodeJS.Pr interface NpmConfigIsolation { /** Controlled cwd: contains the sentinel package.json, so npm's project-config walk stops here. */ readonly dir: string; - /** Empty file substituted for both the user and the global npmrc. */ + /** Distinct empty files: npm refuses to load one path at two configuration levels. */ readonly npmrc: string; + readonly globalNpmrc: string; /** npm cache root under dir: keeps _cacache and _logs inside the owned root so a * successful query cannot leave residue in the ambient ~/.npm. */ readonly cache: string; @@ -283,8 +284,10 @@ function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): N writeFileSync(join(root, "package.json"), "{}\n"); const npmrc = join(root, "ocx-update.npmrc"); writeFileSync(npmrc, ""); + const globalNpmrc = join(root, "ocx-update-global.npmrc"); + writeFileSync(globalNpmrc, ""); const cache = join(root, "npm-cache"); - return { dir: root, npmrc, cache, env: codexCliUpdateBoundEnv(boundEnv) }; + return { dir: root, npmrc, globalNpmrc, cache, env: codexCliUpdateBoundEnv(boundEnv) }; } catch (error) { // A mid-setup failure must not leak a directory this call created; a // caller-supplied directory stays the caller's responsibility. @@ -299,7 +302,7 @@ function isolatedNpmArgs(args: readonly string[], isolation: NpmConfigIsolation) ...args, "--registry=" + CODEX_CLI_REGISTRY, "--userconfig=" + isolation.npmrc, - "--globalconfig=" + isolation.npmrc, + "--globalconfig=" + isolation.globalNpmrc, "--cache=" + isolation.cache, ]; } diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index b4d1ed65e43..eda6016f725 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -321,6 +321,7 @@ describe("registry configuration isolation", () => { /** Snapshot taken while the call was live; the isolation dir is gone by return. */ cwdHadSentinel: boolean; cwdHadNpmrc: boolean; + cwdHadGlobalNpmrc: boolean; } /** The npm argv, whether it reached spawn bare (POSIX) or inside a cmd /c line (Windows). */ @@ -346,6 +347,7 @@ describe("registry configuration isolation", () => { options, cwdHadSentinel: existsSync(join(cwd, "package.json")), cwdHadNpmrc: existsSync(join(cwd, "ocx-update.npmrc")), + cwdHadGlobalNpmrc: existsSync(join(cwd, "ocx-update-global.npmrc")), }; calls.push(call); const field = queriedField(call); @@ -367,13 +369,15 @@ describe("registry configuration isolation", () => { expect(calls.length).toBe(3); for (const call of calls) { expect(argvLine(call)).toContain("--registry=" + CODEX_CLI_REGISTRY); - // Both file configs are substituted with the controlled empty npmrc inside the + // 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="); expect(argvLine(call)).toContain("--globalconfig="); expect(argvLine(call)).toContain("ocx-update.npmrc"); + expect(argvLine(call)).toContain("ocx-update-global.npmrc"); expect(call.cwdHadSentinel).toBe(true); expect(call.cwdHadNpmrc).toBe(true); + expect(call.cwdHadGlobalNpmrc).toBe(true); } // The isolation directory is cleaned up after the resolve. expect(existsSync(calls[0]!.options.cwd as string)).toBe(false); @@ -523,6 +527,43 @@ describe("registry configuration isolation", () => { } }); + test.skipIf(process.platform === "win32")("real npm accepts both isolated config paths without any registry access", () => { + // 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-")); + let configReads = 0; + let isolatedDir = ""; + try { + const spawn = ((bin: string, args: string[], options: Record) => { + const field = queriedField({ args } as CapturedCall); + isolatedDir = options.cwd as string; + if (configReads === 0) { + const flags = args.filter(arg => arg.startsWith("--")); + const user = flags.find(arg => arg.startsWith("--userconfig=")); + const global = flags.find(arg => arg.startsWith("--globalconfig=")); + expect(user?.slice("--userconfig=".length)).not.toBe(global?.slice("--globalconfig=".length)); + const result = spawnSync(bin, ["config", "get", "registry", ...flags], { + ...options, + env: { ...(options.env as NodeJS.ProcessEnv), HOME: fakeHome, USERPROFILE: fakeHome }, + timeout: 10_000, windowsHide: true, encoding: "utf8", + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(new URL(result.stdout.trim()).href).toBe(new URL(CODEX_CLI_REGISTRY).href); + expect(result.stderr).not.toContain("double-loading config"); + configReads++; + } + return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + }) as never; + expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(configReads).toBe(1); + expect(existsSync(isolatedDir)).toBe(false); + expect(readdirSync(fakeHome)).toEqual([]); + } finally { + rmSync(fakeHome, { recursive: true, force: true }); + } + }); + test("a real npm call under the isolated argv leaves the operator HOME untouched", () => { // 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 @@ -538,6 +579,7 @@ describe("registry configuration isolation", () => { bin, args, options, cwdHadSentinel: existsSync(join(cwd, "package.json")), cwdHadNpmrc: existsSync(join(cwd, "ocx-update.npmrc")), + cwdHadGlobalNpmrc: existsSync(join(cwd, "ocx-update-global.npmrc")), }; calls.push(call); const field = queriedField(call); @@ -549,6 +591,7 @@ describe("registry configuration isolation", () => { // a real process exit so the residue assertions actually bound npm's writes. expect(result.error).toBeUndefined(); expect(result.status === 0 || result.status === 1).toBe(true); + expect(result.stderr).not.toContain("double-loading config"); // 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); @@ -560,7 +603,7 @@ describe("registry configuration isolation", () => { // Anything npm persisted was inside the owned root and is removed with it; // the disposable HOME stays completely empty (no ~/.npm, no ~/.npmrc, no _logs). for (const name of ownedDuringRun) { - expect(["package.json", "ocx-update.npmrc", "npm-cache"].includes(name)).toBe(true); + expect(["package.json", "ocx-update.npmrc", "ocx-update-global.npmrc", "npm-cache"].includes(name)).toBe(true); } expect(existsSync(cwd)).toBe(false); expect(readdirSync(fakeHome)).toEqual([]); From fd6ad58652b8ac6c33d2d8a8535f5466858802ce Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:21:02 +0900 Subject: [PATCH 11/24] test(server): trace stalled admission stream fixture phases --- .../active-registry-admission.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/codex-integration/active-registry-admission.test.ts b/tests/codex-integration/active-registry-admission.test.ts index 16b375b9872..356f8cc35c6 100644 --- a/tests/codex-integration/active-registry-admission.test.ts +++ b/tests/codex-integration/active-registry-admission.test.ts @@ -93,6 +93,10 @@ describe("active registry admission", () => { }); test("non-SSE streamed response keeps its admitted turn until the body settles", async () => { + const phase = (name: string) => { + if (process.env.CI) process.stderr.write(`[active-registry-stream] phase=${name}\n`); + }; + phase("setup:start"); const previousHome = process.env.OPENCODEX_HOME; const home = mkdtempSync(join(tmpdir(), "ocx-non-sse-turn-")); process.env.OPENCODEX_HOME = home; @@ -113,6 +117,7 @@ describe("active registry admission", () => { }), { headers: { "content-type": "application/octet-stream" } }); }, }); + phase("setup:upstream-bound"); saveConfig({ port: 0, hostname: "127.0.0.1", @@ -121,7 +126,9 @@ describe("active registry admission", () => { fixture: { adapter: "openai-responses", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, apiKey: "test-key" }, }, } as OcxConfig); + phase("setup:config-saved"); const server = startServer(0); + phase("setup:proxy-bound"); const before = activeRegistryMetrics().activeTurns.active; try { const response = await fetch(new URL("/v1/responses", server.url), { @@ -133,20 +140,25 @@ describe("active registry admission", () => { }, body: JSON.stringify({ model: "fixture/model", input: "hello", stream: true }), }); + phase("request:headers"); expect(response.status).toBe(200); expect(activeRegistryMetrics().activeTurns.active).toBe(before + 1); expect(workflowBudgetSnapshot("stream-root")?.active).toBe(1); settle(); expect(await response.text()).toBe("chunk"); + phase("request:body-settled"); expect(activeRegistryMetrics().activeTurns.active).toBe(before); expect(workflowBudgetSnapshot("stream-root")?.active).toBe(0); } finally { + phase("cleanup:start"); settle?.(); await server.stop(true); + phase("cleanup:proxy-stopped"); upstream.stop(true); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; removeTreeWithRetry(home); + phase("cleanup:end"); } }); From 1cc7411f2f19242f2ef9a98d0998e69a12aa8df3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:36:58 +0900 Subject: [PATCH 12/24] test(server): own admission fixture setup and cancellation --- .../active-registry-admission.test.ts | 284 ++++++++++-------- 1 file changed, 158 insertions(+), 126 deletions(-) diff --git a/tests/codex-integration/active-registry-admission.test.ts b/tests/codex-integration/active-registry-admission.test.ts index 356f8cc35c6..18d79349cee 100644 --- a/tests/codex-integration/active-registry-admission.test.ts +++ b/tests/codex-integration/active-registry-admission.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync} from "node:fs"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MAX_ACTIVE_TURNS, abortAndReleaseAllTurns, activeRegistryMetrics, trackStreamLifetime, tryAdmitTurn, unregisterTurn } from "../../src/server/lifecycle"; @@ -22,144 +22,176 @@ import { bindAnthropicSessionAffinity, clearAnthropicAccountPoolState, } from "../../src/oauth/anthropic-routing"; -import { saveConfig } from "../../src/config"; -import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { currentServerFixtureConfig, settleServerAuthFixture, startManagementServerFixture, type ManagementServerFixture } from "../helpers/server-auth-fixture"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; describe("active registry admission", () => { - test("active turn 257 returns structured server_busy before handler work", async () => { - const leases = Array.from({ length: 256 }, () => tryAdmitTurn()); - const previousHome = process.env.OPENCODEX_HOME; - const home = mkdtempSync(join(tmpdir(), "ocx-active-turn-")); - process.env.OPENCODEX_HOME = home; - saveConfig({ - port: 0, - hostname: "127.0.0.1", - defaultProvider: "openai", - providers: { - openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "forward" }, - }, - } as OcxConfig); - const server = startServer(0); - try { - expect(leases.every(Boolean)).toBe(true); - const response = await fetch(new URL("/v1/responses", server.url), { - method: "POST", - headers: { "content-type": "application/json" }, - body: "not-json", - }); - expect(response.status).toBe(503); - expect(await response.json()).toMatchObject({ error: { code: "server_busy" } }); - } finally { - for (const lease of leases) lease?.release(); - await server.stop(true); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - removeTreeWithRetry(home); - } - }); + describe("real HTTP boundaries", () => { + let root: string; + let home: string; + let codexHome: string; + let previousHome: string | undefined; + let previousCodexHome: string | undefined; + let fixture: ManagementServerFixture | undefined; + let upstream: ReturnType | undefined; + let settle: (() => void) | undefined; + const phase = (name: string) => { + if (process.env.CI) process.stderr.write(`[active-registry-http] phase=${name}\n`); + }; - test("websocket 129 rejects at the real upgrade boundary without entering account registry", async () => { - const leases = Array.from({ length: MAX_TRACKED_CODEX_WEBSOCKETS }, () => tryReserveCodexWebSocket()); - const previousHome = process.env.OPENCODEX_HOME; - const home = mkdtempSync(join(tmpdir(), "ocx-websocket-cap-")); - process.env.OPENCODEX_HOME = home; - saveConfig({ - port: 0, - hostname: "127.0.0.1", - websockets: true, - defaultProvider: "openai", - providers: { - openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "forward" }, - }, - } as OcxConfig); - const server = startServer(0); - try { - expect(leases.every(Boolean)).toBe(true); - const response = await fetch(new URL("/v1/responses", server.url), { - headers: { connection: "Upgrade", upgrade: "websocket" }, - }); - expect(response.status).toBe(503); - expect(await response.json()).toMatchObject({ error: { code: "server_busy" } }); - expect(getTrackedCodexWebSocketCountForAccount("not-admitted")).toBe(0); - } finally { - for (const lease of leases) lease?.release(); - await server.stop(true); + async function closeFixture() { + phase("cleanup:start"); + settle?.(); + // Cancel and join the test body before restoring its homes. A Bun test timeout + // does not itself cancel fetch or wait for the body's finally to release leases. + await Promise.all([fixture?.close(), upstream?.stop(true)]); + await settleServerAuthFixture(home, codexHome); + fixture = undefined; + upstream = undefined; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; - removeTreeWithRetry(home); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(root); + phase("cleanup:end"); } - }); - test("non-SSE streamed response keeps its admitted turn until the body settles", async () => { - const phase = (name: string) => { - if (process.env.CI) process.stderr.write(`[active-registry-stream] phase=${name}\n`); - }; - phase("setup:start"); - const previousHome = process.env.OPENCODEX_HOME; - const home = mkdtempSync(join(tmpdir(), "ocx-non-sse-turn-")); - process.env.OPENCODEX_HOME = home; - let settle!: () => void; - let settled = false; - const upstream = Bun.serve({ - port: 0, - fetch() { - return new Response(new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("chunk")); - settle = () => { - if (settled) return; - settled = true; - controller.close(); - }; + beforeEach(async () => { + phase("setup:start"); + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-active-registry-")); + home = join(root, "ocx"); + codexHome = join(root, "codex"); + mkdirSync(codexHome); + process.env.CODEX_HOME = codexHome; + settle = undefined; + try { + upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + let settled = false; + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("chunk")); + settle = () => { + if (settled) return; + settled = true; + controller.close(); + }; + }, + cancel() { settled = true; }, + }), { headers: { "content-type": "application/octet-stream" } }); + }, + }); + phase("setup:upstream-bound"); + fixture = await startManagementServerFixture(home, currentServerFixtureConfig({ + port: 0, hostname: "127.0.0.1", websockets: true, defaultProvider: "fixture", + providers: { + fixture: { adapter: "openai-responses", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, apiKey: "test-key" }, }, - }), { headers: { "content-type": "application/octet-stream" } }); - }, + } as OcxConfig)); + phase("setup:proxy-bound"); + } catch (error) { + await closeFixture(); + throw error; + } + }, SERVER_BUDGET_MS); + + afterEach(async () => { + if (fixture || upstream) await closeFixture(); + }, SERVER_BUDGET_MS); + + test("active turn 257 returns structured server_busy before handler work", async () => { + await fixture!.run(async () => { + const leases = Array.from({ length: 256 }, () => tryAdmitTurn()); + try { + expect(leases.every(Boolean)).toBe(true); + const response = await fetch(new URL("/v1/responses", fixture!.server.url), { + signal: fixture!.signal, + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + }); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: { code: "server_busy" } }); + } finally { + for (const lease of leases) lease?.release(); + } + }); }); - phase("setup:upstream-bound"); - saveConfig({ - port: 0, - hostname: "127.0.0.1", - defaultProvider: "fixture", - providers: { - fixture: { adapter: "openai-responses", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, apiKey: "test-key" }, - }, - } as OcxConfig); - phase("setup:config-saved"); - const server = startServer(0); - phase("setup:proxy-bound"); - const before = activeRegistryMetrics().activeTurns.active; - try { - const response = await fetch(new URL("/v1/responses", server.url), { - method: "POST", - headers: { - "content-type": "application/json", - "x-codex-parent-thread-id": "stream-root", - "thread-id": "stream-child", - }, - body: JSON.stringify({ model: "fixture/model", input: "hello", stream: true }), + + test("websocket 129 rejects at the real upgrade boundary without entering account registry", async () => { + await fixture!.run(async () => { + const leases = Array.from({ length: MAX_TRACKED_CODEX_WEBSOCKETS }, () => tryReserveCodexWebSocket()); + try { + expect(leases.every(Boolean)).toBe(true); + const response = await fetch(new URL("/v1/responses", fixture!.server.url), { + signal: fixture!.signal, + headers: { connection: "Upgrade", upgrade: "websocket" }, + }); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: { code: "server_busy" } }); + expect(getTrackedCodexWebSocketCountForAccount("not-admitted")).toBe(0); + } finally { + for (const lease of leases) lease?.release(); + } + }); + }); + + test("non-SSE streamed response keeps its admitted turn until the body settles", async () => { + await fixture!.run(async () => { + const before = activeRegistryMetrics().activeTurns.active; + try { + const response = await fetch(new URL("/v1/responses", fixture!.server.url), { + signal: fixture!.signal, + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": "stream-root", + "thread-id": "stream-child", + }, + body: JSON.stringify({ model: "fixture/model", input: "hello", stream: true }), + }); + phase("request:headers"); + expect(response.status).toBe(200); + expect(activeRegistryMetrics().activeTurns.active).toBe(before + 1); + expect(workflowBudgetSnapshot("stream-root")?.active).toBe(1); + settle!(); + expect(await response.text()).toBe("chunk"); + phase("request:body-settled"); + expect(activeRegistryMetrics().activeTurns.active).toBe(before); + expect(workflowBudgetSnapshot("stream-root")?.active).toBe(0); + } finally { + settle?.(); + } + }); + }); + + test("fixture teardown cancels and joins an unfinished admitted response", async () => { + const before = activeRegistryMetrics().activeTurns.active; + let receivedHeaders!: () => void; + const headers = new Promise(resolve => { receivedHeaders = resolve; }); + const running = fixture!.run(async () => { + const response = await fetch(new URL("/v1/responses", fixture!.server.url), { + signal: fixture!.signal, + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", input: "hello", stream: true }), + }); + expect(response.status).toBe(200); + receivedHeaders(); + await response.text(); }); - phase("request:headers"); - expect(response.status).toBe(200); + await Promise.race([headers, running.then(() => { throw new Error("response ended before headers"); })]); expect(activeRegistryMetrics().activeTurns.active).toBe(before + 1); - expect(workflowBudgetSnapshot("stream-root")?.active).toBe(1); - settle(); - expect(await response.text()).toBe("chunk"); - phase("request:body-settled"); + await fixture!.close(); + await running; expect(activeRegistryMetrics().activeTurns.active).toBe(before); - expect(workflowBudgetSnapshot("stream-root")?.active).toBe(0); - } finally { - phase("cleanup:start"); - settle?.(); - await server.stop(true); - phase("cleanup:proxy-stopped"); - upstream.stop(true); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - removeTreeWithRetry(home); - phase("cleanup:end"); - } + }); }); test("storage home slot 33 returns storage_mutation_busy without dropping active slots", () => { From a5bbeb58377a0bb28fb935497123ff1314d42b94 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 07:38:45 -0700 Subject: [PATCH 13/24] fix(codex): exclude dynamic-loader controls from update evidence Drop LD_* and DYLD_* environment families before spawning npm. Keep supported proxy/TLS settings and document the denylist boundary. Add capture-only regressions without loading native code. --- src/codex/cli-update-plan.ts | 10 ++++++--- structure/codex-home.md | 6 +++++ .../codex-cli-update-plan.test.ts | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 009edad667b..076df68cd0a 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -202,8 +202,11 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget * check void - each one reaches the evidence-producing process unless removed. * NODE_V8_COVERAGE and NODE_REDIRECT_WARNINGS are dropped for the same reason in * reverse: they are output paths, so ambient values would write Node artifacts to - * arbitrary external directories on exit. The surviving allowlist is only the - * proxy variables plus NODE_EXTRA_CA_CERTS, the supported TLS extension. + * arbitrary external directories on exit. ELF/Darwin loader controls (LD_* and + * DYLD_*) are also removed: they can load code or redirect loader output before + * npm runs. This is a denylist for process-startup/output channels, not a claim + * that every ambient variable is allowlisted. Ordinary OS environment, proxy + * settings and the supported NODE_EXTRA_CA_CERTS extension remain available. */ const NPM_ENV_DROP_KEYS: ReadonlySet = new Set([ "node_options", @@ -221,7 +224,8 @@ function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.Pro const env: NodeJS.ProcessEnv = { ...base }; for (const key of Object.keys(env)) { const lower = key.toLowerCase(); - if (lower.startsWith("npm_config_") || NPM_ENV_DROP_KEYS.has(lower)) delete env[key]; + if (lower.startsWith("npm_config_") || lower.startsWith("ld_") + || lower.startsWith("dyld_") || NPM_ENV_DROP_KEYS.has(lower)) delete env[key]; } return env; } diff --git a/structure/codex-home.md b/structure/codex-home.md index 90ff9cf3912..5267c3d2b3e 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -10,6 +10,12 @@ CLI installation inspection reason codes, including Windows deferral, follow the Explicit Codex CLI installation observation does not discover a Codex home or read/write its state. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). +The read-only CLI update plan binds npm executable selection to the inspected launcher PATH. +Its child environment drops npm configuration overrides, named Node startup/output controls, +and the `LD_*`/`DYLD_*` loader families; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. +Distinct npm config files, cache and logs stay under the owned temporary root with best-effort +cleanup. This plan neither installs a runtime nor signals an existing Codex session. + ## Orca source-owned account import `src/codex/orca-import.ts` reads only accounts listed in an explicitly selected Orca registry. diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index eda6016f725..262855f3604 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -456,6 +456,28 @@ describe("registry configuration isolation", () => { } }); + test.each([ + "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 => { + 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(calls.length).toBe(3); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + expect(Object.keys(env).some(name => name.toLowerCase() === key.toLowerCase())).toBe(false); + } + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }); + test("hostile Node startup channels cannot reach the spawned npm", () => { const priorOptions = process.env.NODE_OPTIONS; const priorPath = process.env.NODE_PATH; From 0f61f985f0893d4445da7cee60d6f2e3f8cf43ba Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 15:38:00 -0700 Subject: [PATCH 14/24] fix(codex): preserve update-plan results and scan readable sessions --- src/codex/app-server-processes.ts | 4 +- src/codex/cli-update-plan.ts | 4 +- structure/codex-home.md | 5 ++- .../codex-app-server-processes.test.ts | 39 ++++++++++++++++++- .../codex-cli-update-plan.test.ts | 29 +++++++++++++- 5 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 9368ba51814..9661897bd91 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -664,7 +664,9 @@ export function scanCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C * only err toward deferring. */ export function scanCodexSessionProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcessScan { - return scanProcessSnapshots(io, isCodexSessionCommandLine); + // Shared installs may be held open by another readable user's session. This + // read-only scan is broader than the unchanged same-user restart/kill path. + return scanProcessSnapshots({ ...io, getuid: io.getuid ?? (() => undefined) }, isCodexSessionCommandLine); } export function formatStaleCodexAppServerWarning( diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 076df68cd0a..e98fb551950 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -401,7 +401,9 @@ export function resolveCodexCliUpdateTarget( } return Object.freeze({ kind: "resolved" as const, version, integrity }); } finally { - rmSync(isolation.dir, { recursive: true, force: true }); + // Cleanup is best-effort; a locked residue must not replace the plan result. + try { rmSync(isolation.dir, { recursive: true, force: true }); } + catch { /* the owned temporary root may remain, as documented */ } } } diff --git a/structure/codex-home.md b/structure/codex-home.md index 5267c3d2b3e..893d590e938 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -14,7 +14,10 @@ The read-only CLI update plan binds npm executable selection to the inspected la Its child environment drops npm configuration overrides, named Node startup/output controls, and the `LD_*`/`DYLD_*` loader families; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. Distinct npm config files, cache and logs stay under the owned temporary root with best-effort -cleanup. This plan neither installs a runtime nor signals an existing Codex session. +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; +the restart/kill path remains same-user. This plan neither installs a runtime nor signals +an existing Codex session. Windows enumeration/installation limits remain unchanged. ## Orca source-owned account import diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index 27c80cf9e6a..9bdd727b95f 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -1,5 +1,6 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { spawn } from "node:child_process"; +import * as childProcess from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; @@ -785,6 +786,42 @@ describe("Codex session process matching (#2811)", () => { expect(isCodexSessionCommandLine("")).toBe(false); }); + function withDarwinProcessFixture(check: (uid: number) => void) { + const uid = process.getuid!(); + const ps = spyOn(childProcess, "execFileSync").mockImplementation((file, args) => { + expect(file).toBe("/bin/ps"); + const argv = Array.isArray(args) ? args : []; + const scoped = argv.includes("-u"); + if (scoped) expect(argv[1]).toBe(String(uid)); + const executable = argv.includes("pid=,comm="); + if (executable) return (scoped + ? "321 /usr/local/bin/codex\n" + : "321 /usr/local/bin/codex\n322 /usr/local/bin/codex\n") as never; + return (scoped ? "321 /usr/local/bin/codex app-server\n" + : `321 ${uid} /usr/local/bin/codex app-server\n322 ${uid + 1} /usr/local/bin/codex exec fixture\n`) as never; + }); + try { check(uid); } finally { ps.mockRestore(); } + } + + test.skipIf(typeof process.getuid !== "function")("read-only session scan includes other readable users by default", () => { + withDarwinProcessFixture(() => { + for (const io of [{ platform: "darwin" as const }, { platform: "darwin" as const, getuid: undefined }]) { + const scan = scanCodexSessionProcesses(io); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") expect(scan.processes.map(item => item.pid)).toEqual([321, 322]); + } + }); + }); + + test.skipIf(typeof process.getuid !== "function")("explicit uid scan and kill-path enumeration retain their user boundary", () => { + withDarwinProcessFixture(uid => { + const scan = scanCodexSessionProcesses({ platform: "darwin", getuid: () => uid }); + expect(scan.kind).toBe("observed"); + if (scan.kind === "observed") expect(scan.processes.map(item => item.pid)).toEqual([321]); + expect(listCodexAppServerProcesses({ platform: "darwin" }).map(item => item.pid)).toEqual([321]); + }); + }); + test("scanCodexSessionProcesses fails closed and dedupes", () => { const unavailable = scanCodexSessionProcesses({ platform: "linux", diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 262855f3604..eadf69bf31d 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; @@ -362,6 +363,32 @@ 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 => { + let ownedRoot = ""; + let cleanupAttempts = 0; + const originalRemove = fs.rmSync; + const removal = spyOn(fs, "rmSync").mockImplementation((path, options) => { + if (ownedRoot && path === ownedRoot) { + cleanupAttempts += 1; + throw Object.assign(new Error("synthetic cleanup denial"), { code: "EACCES" }); + } + return originalRemove(path, options); + }); + try { + const capture = capturingSpawn(kind === "resolved" ? RESOLVE_OUTPUTS : { version: "invalid-version" }); + const spawn = ((bin: string, args: string[], options: Record) => { + ownedRoot = options.cwd as string; + return (capture.spawn as typeof spawnSync)(bin, args, options); + }) as never; + expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe(kind); + expect(cleanupAttempts).toBe(1); + expect(existsSync(ownedRoot)).toBe(true); + } finally { + removal.mockRestore(); + if (ownedRoot) rmSync(ownedRoot, { recursive: true, force: true }); + } + }); + test("every query pins the official registry and substitutes a controlled npmrc", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const target = resolveCodexCliUpdateTarget("latest", spawn); From 60e3fca4af8ebd9bd35a59784be5d14a38256e04 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:28:35 +0900 Subject: [PATCH 15/24] test(codex): verify Node output isolation with real sentinels --- .../codex-cli-update-plan.test.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index eadf69bf31d..ed2f4b5f664 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { scanCodexAppServerProcesses } from "../../src/codex/app-server-processes"; import type { CodexCliInstallReport } from "../../src/codex/cli-install-provenance"; @@ -543,6 +543,69 @@ describe("registry configuration isolation", () => { } }); + test("real Node cannot write output sentinels outside the owned npm root", () => { + const fixture = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-node-output-"))); + const sentinels = { + NODE_V8_COVERAGE: join(fixture, "escaped-coverage"), + NODE_REDIRECT_WARNINGS: join(fixture, "escaped-warnings.log"), + }; + const previous = Object.fromEntries(Object.keys(sentinels).map(key => [key, process.env[key]])); + const makeTemp = fs.mkdtempSync; + // Both the owned npm root and its external sentinels stay inside this fixture. + const ownedTemp = spyOn(fs, "mkdtempSync").mockImplementation(prefix => { + expect(String(prefix)).toBe(join(tmpdir(), "ocx-codex-cli-meta-")); + return makeTemp(join(fixture, "owned-")); + }); + let ownedRoot = ""; + let checked = false; + try { + Object.assign(process.env, sentinels); + const node = Bun.which("node"); + expect(node).not.toBeNull(); + const spawn = ((bin: string, args: string[], options: Record) => { + ownedRoot = options.cwd as string; + expect(dirname(ownedRoot)).toBe(fixture); + if (!checked) { + const childEnv = options.env as NodeJS.ProcessEnv; + const runNode = (env: NodeJS.ProcessEnv) => { + const result = spawnSync(node!, ["-e", + "process.emitWarning('ocx-output-sentinel'); console.log('ocx-runtime:' + process.release.name)", + ], { cwd: ownedRoot, env, encoding: "utf8", timeout: 5_000, windowsHide: true }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("ocx-runtime:node"); + }; + // Positive control: this actual Node supports both output channels. It + // writes to separate fixture paths, so missing Node/output cannot pass. + const controlCoverage = join(fixture, "control-coverage"); + const controlWarnings = join(fixture, "control-warnings.log"); + runNode({ ...childEnv, NODE_V8_COVERAGE: controlCoverage, NODE_REDIRECT_WARNINGS: controlWarnings }); + expect(readdirSync(controlCoverage).some(name => name.endsWith(".json"))).toBe(true); + expect(fs.readFileSync(controlWarnings, "utf8")).toContain("ocx-output-sentinel"); + // Exercise the exact environment supplied by the real resolver, with + // no registry access. Observe absence before its finally removes cwd. + runNode(childEnv); + expect(existsSync(ownedRoot)).toBe(true); + expect(existsSync(sentinels.NODE_V8_COVERAGE)).toBe(false); + expect(existsSync(sentinels.NODE_REDIRECT_WARNINGS)).toBe(false); + checked = true; + } + const field = queriedField({ bin, args, options } as CapturedCall); + return { status: 0, stdout: RESOLVE_OUTPUTS[field as keyof typeof RESOLVE_OUTPUTS] ?? "", stderr: "" }; + }) as never; + expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(checked).toBe(true); + expect(existsSync(ownedRoot)).toBe(false); + } finally { + ownedTemp.mockRestore(); + for (const key of Object.keys(sentinels)) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + rmSync(fixture, { recursive: true, force: true }); + } + }); + test("a trusted snapshot without PATH installs an explicit empty PATH and fails closed", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); resolveCodexCliUpdateTarget("latest", spawn, { PATHEXT: ".COM;.EXE;.BAT;.CMD" }); From 1b23000030b2eb11cf2522086bd89040c95ee9fc Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 1 Oct 2026 17:46:25 -0700 Subject: [PATCH 16/24] fix(codex): fail closed on unreadable procfs processes Ignore only ENOENT disappearance races while enumerating process status and command lines. Preserve uncertainty for update plans and diagnostics without widening restart targets. Regress default enumeration, partial results, planner refusal, and same-user signaling. --- scripts/test-layout/layout.json | 1 + src/codex/app-server-processes.ts | 6 +- structure/codex-home.md | 4 +- structure/runtime.md | 2 +- .../codex-proc-scan-errors.test.ts | 107 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 6 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/codex-integration/codex-proc-scan-errors.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9d16e06134c..c63843fe033 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -476,6 +476,7 @@ "codex-affinity-debug.test.ts": "codex-integration", "codex-app-server-path-spaces.test.ts": "codex-integration", "codex-app-server-processes.test.ts": "codex-integration", + "codex-proc-scan-errors.test.ts": "codex-integration", "codex-app-server-restart-service.test.ts": "codex-integration", "codex-auth-api.test.ts": "codex-integration", "codex-auth-cancel-ownership.test.ts": "codex-integration", diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 9661897bd91..91ac8ecc00d 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -385,8 +385,10 @@ function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] { const commandLine = argv.join(" ").trim(); if (!commandLine) continue; out.push({ pid, commandLine, executable: argv[0], uid: processUid }); - } catch { - /* process exited mid-scan */ + } catch (error) { + // Only disappearance proves a process exited mid-scan. Permission/I/O + // failures leave the table incomplete and must reach fail-closed callers. + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; } } return out; diff --git a/structure/codex-home.md b/structure/codex-home.md index ca05631a65e..3e340baa02f 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -15,7 +15,9 @@ Its child environment drops npm configuration overrides, named Node startup/outp and the `LD_*`/`DYLD_*` loader families; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. Distinct npm config files, cache and logs stay under the owned temporary root with best-effort 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; +scan includes all readable users by default, retaining an explicitly injected UID scope. +Linux per-process `status`/`cmdline` read failures invalidate the scan except for `ENOENT` +(a process that disappeared); incomplete observation refuses with `blocked_process_state_unknown`; the restart/kill path remains same-user. This plan neither installs a runtime nor signals an existing Codex session. Windows enumeration/installation limits remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 3039702b47f..79d9596d174 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -73,7 +73,7 @@ After a CLI catalog/cache write, advisory restart guidance compares each running start time with the written catalog mtime. It reports only processes proven stale; a fresh or unreadable observation does not claim that another restart is required. Explicit `--restart-codex` and `--restart-app-server-only` retain their operator-consent semantics and act on -verified matching processes regardless of the advisory freshness result. Read-only client diagnostics in `src/codex/native-profile-processes.ts` prefer full executable paths over inferred argv0; macOS comm and args are read separately and joined by PID, so unquoted Codex Framework helper paths do not become CLI clients. The initial split Codex.app framework-path token pair is also rejected when only a process basename is available; later path arguments do not override a client match. Quoted immediate script entrypoints remain supported for known interpreters. This classification does not change the app-server termination selector. +verified matching processes regardless of the advisory freshness result. Read-only client diagnostics in `src/codex/native-profile-processes.ts` prefer full executable paths over inferred argv0; macOS comm and args are read separately and joined by PID, so unquoted Codex Framework helper paths do not become CLI clients. The initial split Codex.app framework-path token pair is also rejected when only a process basename is available; later path arguments do not override a client match. Quoted immediate script entrypoints remain supported for known interpreters. This classification does not change the app-server termination selector. Linux procfs enumeration in `src/codex/app-server-processes.ts` ignores only per-process `ENOENT` disappearance races. Other read failures invalidate the whole observation: raw enumeration throws, catalog diagnostics report unknown, and restart selection returns no targets. The same-user and app-server-only signal boundary is unchanged. > Decision record: [ADR-0097](decisions/ADR-0097-post-write-app-server-freshness.md) diff --git a/tests/codex-integration/codex-proc-scan-errors.test.ts b/tests/codex-integration/codex-proc-scan-errors.test.ts new file mode 100644 index 00000000000..5ce3ffd98e9 --- /dev/null +++ b/tests/codex-integration/codex-proc-scan-errors.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { + collectCodexAppServerCatalogState, + listCodexAppServerProcesses, + listProcessSnapshots, + restartCodexAppServers, + scanCodexAppServerProcesses, + scanCodexSessionProcesses, +} from "../../src/codex/app-server-processes"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; + +const OWN_UID = 1000; +const io = { platform: "linux" as const, getuid: () => OWN_UID }; +const APP_SERVER = { pid: 41, commandLine: "/bin/codex app-server" }; +const restore: Array<() => void> = []; +afterEach(() => { + for (const undo of restore.splice(0).reverse()) undo(); +}); + +// Exercise the actual procfs enumeration and its callers, not listSnapshots. +// Only /proc reads are synthetic; never inspect or signal a real process. +function procFixture(failure?: { file: "status" | "cmdline"; error: Error }, pids = [41, 42, 43, 44]) { + const realExists = fs.existsSync; + const realReadDir = fs.readdirSync; + const realRead = fs.readFileSync; + const exists = spyOn(fs, "existsSync").mockImplementation(path => path === "/proc" || realExists(path)); + restore.push(() => exists.mockRestore()); + const readDir = spyOn(fs, "readdirSync").mockImplementation(((...args: Parameters) => + args[0] === "/proc" ? pids.map(String) : realReadDir(...args)) as typeof fs.readdirSync); + restore.push(() => readDir.mockRestore()); + const read = spyOn(fs, "readFileSync").mockImplementation(((...args: Parameters) => { + const path = String(args[0]); + if (!path.startsWith("/proc/")) return realRead(...args); + if (failure && path === `/proc/42/${failure.file}`) throw failure.error; + const match = /^\/proc\/(41|42|43|44)\/(status|cmdline)$/.exec(path); + if (!match) throw new Error(`Unexpected procfs read: ${path}`); + if (match[2] === "status") return `Uid:\t${match[1] === "43" ? 2000 : OWN_UID}\n`; + return Buffer.from(match[1] === "44" ? "/bin/codex\0" : "/bin/codex\0app-server\0"); + }) as typeof fs.readFileSync); + restore.push(() => read.mockRestore()); + return read; +} + +describe("procfs process read failures", () => { + for (const file of ["status", "cmdline"] as const) { + for (const code of ["EACCES", "EPERM", "EIO", undefined]) { + test(`${file} ${code ?? "uncoded error"} invalidates the whole scan`, () => { + const error = Object.assign(new Error("synthetic procfs failure"), { code }); + procFixture({ file, error }); + // PID 41 is observed before the failure; a partial result is not evidence. + expect(() => listProcessSnapshots(io)).toThrow(error); + expect(scanCodexSessionProcesses({ platform: "linux" })).toEqual({ kind: "unavailable" }); + expect(scanCodexAppServerProcesses(io)).toEqual({ kind: "unavailable" }); + expect(collectCodexAppServerCatalogState({ ...io, catalogMtimeMs: () => 1000 }).state).toBe("unknown"); + expect(listCodexAppServerProcesses(io)).toEqual([]); + const signals: number[] = []; + restartCodexAppServers([APP_SERVER], { + ...io, kill: pid => { signals.push(pid); }, isAlive: () => true, waitExit: () => true, + }); + expect(signals).toEqual([]); + }); + } + + test(`${file} ENOENT skips only the departed process`, () => { + procFixture({ file, error: Object.assign(new Error("departed"), { code: "ENOENT" }) }); + expect(listProcessSnapshots(io).map(row => row.pid)).toEqual([41, 44]); + expect(scanCodexSessionProcesses({ platform: "linux" })).toEqual({ + kind: "observed", processes: [APP_SERVER, { ...APP_SERVER, pid: 43 }, { pid: 44, commandLine: "/bin/codex" }], + }); + expect(scanCodexSessionProcesses(io)).toEqual({ + kind: "observed", processes: [APP_SERVER, { pid: 44, commandLine: "/bin/codex" }], + }); + expect(listCodexAppServerProcesses(io)).toEqual([APP_SERVER]); + const signals: number[] = []; + restartCodexAppServers([APP_SERVER, { ...APP_SERVER, pid: 43 }, { pid: 44, commandLine: "/bin/codex" }], { + ...io, kill: pid => { signals.push(pid); }, isAlive: () => true, waitExit: () => true, + }); + // Foreign-user and interactive sessions block updates, but remain outside the kill contract. + expect(signals).toEqual([41]); + }); + } + + test("an empty procfs observation remains distinct from an unreadable one", () => { + procFixture(undefined, []); + expect(scanCodexSessionProcesses({ platform: "linux" })).toEqual({ kind: "observed", processes: [] }); + }); + + test("the default planner refuses an unreadable candidate instead of accepting no sessions", async () => { + procFixture({ file: "cmdline", error: Object.assign(new Error("denied"), { code: "EACCES" }) }, [42]); + const plan = await createCodexCliUpdatePlan({ + platform: "linux", + processIo: { platform: "linux" }, + inspect: async () => ({ + 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), packageVersion: "1.0.0", shim: { status: "not-tracked", backingKind: null }, + evidence: ["package_manifest", "global_npm_layout"], + }), + resolveTarget: () => ({ kind: "resolved", version: "1.1.0", integrity: "sha512-AAAA" }), + }); + expect(plan.applicable).toBe(false); + expect(plan.refusal).toBe("blocked_process_state_unknown"); + expect(plan.session).toEqual({ state: "unknown", matches: null }); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e0616b26857..189a2b69fa0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -484,6 +484,7 @@ "codex-affinity-debug.test.ts": "codex-integration", "codex-app-server-path-spaces.test.ts": "codex-integration", "codex-app-server-processes.test.ts": "codex-integration", + "codex-proc-scan-errors.test.ts": "codex-integration", "codex-app-server-restart-service.test.ts": "codex-integration", "codex-auth-api.test.ts": "codex-integration", "codex-auth-cancel-ownership.test.ts": "codex-integration", From 6ae278f8bc6d026e3778aea036bdda629efa73ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Sat, 3 Oct 2026 14:13:39 +0000 Subject: [PATCH 17/24] test: split expected layout map under size limit --- tests/fixtures/test-layout-expected-cli.json | 5 +++++ tests/fixtures/test-layout-expected.json | 3 --- tests/test-layout-tooling.test.ts | 19 +++++++++++++++---- 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/test-layout-expected-cli.json diff --git a/tests/fixtures/test-layout-expected-cli.json b/tests/fixtures/test-layout-expected-cli.json new file mode 100644 index 00000000000..41a67d88bec --- /dev/null +++ b/tests/fixtures/test-layout-expected-cli.json @@ -0,0 +1,5 @@ +{ + "cli-head.test.ts": "cli", + "cli-headless-parity.test.ts": "cli", + "cli-help.test.ts": "cli" +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f40c53ab46b..dd684f44b7a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -444,9 +444,6 @@ "cli-client-start-owner-e2e.test.ts": "cli", "cli-dto-fidelity.test.ts": "cli", "cli-export-command.test.ts": "cli", - "cli-head.test.ts": "cli", - "cli-headless-parity.test.ts": "cli", - "cli-help.test.ts": "cli", "cli-help-paths.test.ts": "cli", "cli-help-navigation.test.ts": "cli", "cli-integer-options.test.ts": "cli", diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index c675c9ce639..b96c3b7715e 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -20,12 +20,23 @@ import { type Layout, } from "../scripts/test-layout/schema"; -// Independent oracle: the basename -> directory table from devlog 001 §2.D, committed as a -// fixture. The layout guard shares the resolver with the mover, so a resolver defect could move -// a file to the wrong place and bless it; this fixture is the second opinion that catches it. -const EXPECTED = JSON.parse(readFileSync(repoPath("tests", "fixtures", "test-layout-expected.json"), "utf8")) as Record; +// Independent oracle: the basename -> directory table from devlog 001 §2.D, committed as sorted +// fixture chunks. Keeping the CLI chunk separate leaves room under the file-size ratchet. The +// layout guard shares the resolver with the mover, so this remains a second opinion. +const EXPECTED_PARTS = [ + JSON.parse(readFileSync(repoPath("tests", "fixtures", "test-layout-expected.json"), "utf8")) as Record, + JSON.parse(readFileSync(repoPath("tests", "fixtures", "test-layout-expected-cli.json"), "utf8")) as Record, +]; +const EXPECTED = Object.assign({}, ...EXPECTED_PARTS) as Record; +const DUPLICATE_EXPECTED_NAMES = EXPECTED_PARTS.flatMap((part, index) => + Object.keys(part).filter((name) => EXPECTED_PARTS.slice(0, index).some((previous) => name in previous)) +); describe("rewriteSpecifier", () => { + test("expected-domain fixture chunks do not overlap", () => { + expect(DUPLICATE_EXPECTED_NAMES).toEqual([]); + }); + const forms = [ (s: string) => `import { x } from "${s}";`, (s: string) => `import "${s}";`, From b28fccfe67a0adef12ba8dd5464ddc7f951cf674 Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:47:00 +0000 Subject: [PATCH 18/24] Split client test mappings to preserve the layout size limit --- AGENTS.md | 2 +- scripts/test-layout/layout-clients.json | 120 ++++++++++++++++++++++++ scripts/test-layout/layout.json | 113 ++-------------------- scripts/test-layout/schema.ts | 25 ++++- structure/overview.md | 2 +- tests/test-layout-tooling.test.ts | 35 +++++++ 6 files changed, 185 insertions(+), 112 deletions(-) create mode 100644 scripts/test-layout/layout-clients.json diff --git a/AGENTS.md b/AGENTS.md index bf4048b86d5..725e7cb9340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Bun-native TypeScript with no separate server compile step. - `tests/` — Bun tests in domain directories that mirror `src/` (`tests//*.test.ts`; `providers/` and `adapters/` have one more level for the larger vendors). The explicit map is - `scripts/test-layout/layout.json`, with regex seeds and migration state in + `scripts/test-layout/layout.json` plus its `explicitShards` sibling maps, with regex seeds and migration state in `scripts/test-layout/seeds.json`; `tests/test-layout.test.ts` enforces that every file resolves to a domain and sits in it, and only the two layout guards live at the root. Shared helpers in `tests/helpers/`, fixtures in `tests/fixtures/`, broader diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json new file mode 100644 index 00000000000..28931bc5633 --- /dev/null +++ b/scripts/test-layout/layout-clients.json @@ -0,0 +1,120 @@ +{ + "droid-managed-reasoning-defaults.test.ts": "clients", + "droid-normalized-reasoning-defaults.test.ts": "clients", + "desktop-app-server-shim.test.ts": "clients", + "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-chatgpt-config.test.ts": "clients", + "desktop-supervised-restart.test.ts": "clients", + "link-relay-bound-transport.test.ts": "clients", + "aside-profile-identity.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", + "aside-profiles.test.ts": "clients", + "aside-profiles-catalog-refresh.test.ts": "clients", + "client-catalog-compatibility.test.ts": "clients", + "opencode-kilo-refresh.test.ts": "clients", + "client-export-reasoning-metadata.test.ts": "clients", + "client-export-reasoning-controls.test.ts": "clients", + "client-export-live-wire.test.ts": "clients", + "client-export-effective-metadata.test.ts": "clients", + "client-connect.test.ts": "clients", + "client-export-modality-enum.test.ts": "clients", + "client-fingerprint.test.ts": "clients", + "client-hub-relay.test.ts": "clients", + "client-hub-state.test.ts": "clients", + "client-hub-usage.test.ts": "clients", + "client-lifecycle-lock.test.ts": "clients", + "client-machine-listener.test.ts": "clients", + "client-runtime.test.ts": "clients", + "cline-client.test.ts": "clients", + "cline-writer.test.ts": "clients", + "command-code-client-contract.test.ts": "clients", + "command-code-client.test.ts": "clients", + "link-boundary.test.ts": "clients", + "link-ssh-argv.test.ts": "clients", + "link-ssh-runner.test.ts": "clients", + "link-ssh-argv-discovery.test.ts": "clients", + "link-ssh-config.test.ts": "clients", + "link-store.test.ts": "clients", + "link-tunnel-state.test.ts": "clients", + "desktop-3p-guard.test.ts": "clients", + "desktop-3p-removal.test.ts": "clients", + "desktop-3p.test.ts": "clients", + "desktop-app-restart.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", + "desktop-cli-command-surface.test.ts": "clients", + "desktop-exit-ownership.test.ts": "clients", + "desktop-host-visibility.test.ts": "clients", + "desktop-install-identity.test.ts": "clients", + "desktop-profile.test.ts": "clients", + "desktop-proxy-direct-transport.test.ts": "clients", + "desktop-remote-store.test.ts": "clients", + "desktop-runtime-identity.test.ts": "clients", + "desktop-start-at-login-default.test.ts": "clients", + "desktop-startup-surface.test.ts": "clients", + "desktop-update-surface.test.ts": "clients", + "desktop-tray-availability.test.ts": "clients", + "desktop-widget-entry.test.ts": "clients", + "droid-client.test.ts": "clients", + "integrations-current-store.test.ts": "clients", + "integrations-dsh-profile-patch.test.ts": "clients", + "integrations-hermes-affinity.test.ts": "clients", + "integrations-journal.test.ts": "clients", + "integrations-merge.test.ts": "clients", + "integrations-serialize.test.ts": "clients", + "integrations-state.test.ts": "clients", + "integrations-superseded-store.test.ts": "clients", + "integrations-writer-frozen-config.test.ts": "clients", + "integrations-writer.test.ts": "clients", + "integrations-writer-guard.test.ts": "clients", + "kilo-client.test.ts": "clients", + "mutation-plan.test.ts": "clients", + "omo-client.test.ts": "clients", + "omp-path-contract.test.ts": "clients", + "omp-yaml-source-inline-comments.test.ts": "clients", + "pi-path-contract.test.ts": "clients", + "prime-client.test.ts": "clients", + "raycast-client.test.ts": "clients", + "raycast-detect.test.ts": "clients", + "remote-catalog.test.ts": "clients", + "remote-control-prototype.test.ts": "clients", + "remote-workspace-activation.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-management.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-protocol.test.ts": "clients", + "remote-workspace-rpc-framing.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-server.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", + "omo-role-models.test.ts": "clients", + "lazycodex-detection.test.ts": "clients", + "sync-client-integrations-unattended.test.ts": "clients", + "sync-client-integrations.test.ts": "clients", + "client-link-connect.test.ts": "clients", + "client-link-relay.test.ts": "clients", + "client-link-runtime.test.ts": "clients", + "link-routes.test.ts": "clients", + "client-link-state.test.ts": "clients", + "client-link-status.test.ts": "clients", + "client-link-teardown.test.ts": "clients", + "client-link-tunnel.test.ts": "clients", + "link-ports.test.ts": "clients", + "link-supervisor.test.ts": "clients", + "link-status-projection.test.ts": "clients", + "link-admission-wait.test.ts": "clients", + "link-fingerprint.test.ts": "clients", + "link-compensation.test.ts": "clients" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 76999a4d2a2..4aed962c46e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,6 +1,9 @@ { "version": 1, "root": "tests", + "explicitShards": [ + "layout-clients.json" + ], "explicit": { "messages-request-id-headers.test.ts": "claude-integration", "messages-request-id-endpoint.test.ts": "claude-integration", @@ -62,8 +65,6 @@ "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", "claude-intercept-cleanup.test.ts": "claude-integration", - "droid-managed-reasoning-defaults.test.ts": "clients", - "droid-normalized-reasoning-defaults.test.ts": "clients", "management-droid-reasoning-defaults.test.ts": "server", "droid-reasoning-defaults.test.ts": "responses", "claude-intercept-on-demand.test.ts": "claude-integration", @@ -71,9 +72,6 @@ "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", "cli-combo-partial-update.test.ts": "cli", - "desktop-app-server-shim.test.ts": "clients", - "desktop-app-server-shim-launcher.test.ts": "clients", - "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", "minimax-combo-image-input.test.ts": "providers", "web-search-forced-declaration.test.ts": "web-search", @@ -100,10 +98,10 @@ "bun-runtime-preflight.test.ts": "lib", "responses-compaction-recovery.test.ts": "responses", "responses-canonical-nonstream.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", - "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", + "cli-restart-handoff.test.ts": "cli", "restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", - "link-relay-bound-transport.test.ts": "clients", "claude-desktop-system-proxy.test.ts": "claude-integration", "codex-config-drift-heal.test.ts": "codex-integration", + "claude-desktop-system-proxy.test.ts": "claude-integration", "codex-config-drift-heal.test.ts": "codex-integration", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", @@ -242,11 +240,7 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", - "aside-profile-identity.test.ts": "clients", - "aside-profile-paths.test.ts": "clients", - "aside-profile-sync-owner.test.ts": "clients", "aside-profiles-routes.test.ts": "server", - "aside-profiles.test.ts": "clients", "aside-profiles-catalog-refresh.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "audio-client.test.ts": "server", "audio-dictation.test.ts": "server", @@ -501,32 +495,14 @@ "cli-usage-hub.test.ts": "cli", "cli-usage-report.test.ts": "cli", "cli-version-skew.test.ts": "cli", - "client-catalog-compatibility.test.ts": "clients", "client-config-export-new-clients.test.ts": "config", "client-config-export.test.ts": "config", - "opencode-kilo-refresh.test.ts": "clients", - "client-export-reasoning-metadata.test.ts": "clients", - "client-export-reasoning-controls.test.ts": "clients", - "client-export-live-wire.test.ts": "clients", - "client-export-effective-metadata.test.ts": "clients", "client-config-new-clients.test.ts": "config", - "client-connect.test.ts": "clients", - "client-export-modality-enum.test.ts": "clients", - "client-fingerprint.test.ts": "clients", - "client-hub-relay.test.ts": "clients", - "client-hub-state.test.ts": "clients", - "client-hub-usage.test.ts": "clients", "client-injection-guard.test.ts": "codex-integration", - "client-lifecycle-lock.test.ts": "clients", - "client-machine-listener.test.ts": "clients", - "client-runtime.test.ts": "clients", - "cline-client.test.ts": "clients", "cline-pass-deepseek-v4-tool-replay.test.ts": "providers", "cline-pass-provider.test.ts": "providers", "cline-pass-reasoning-efforts.test.ts": "providers", "cline-provider.test.ts": "providers", - "cline-writer.test.ts": "clients", - "command-code-client-contract.test.ts": "clients", "command-code-client.test.ts": "clients", "closed-pr-branch-cleanup.test.ts": "ci-workflows", "codebuddy-adapter.test.ts": "providers", "codebuddy-live-acceptance.test.ts": "providers", @@ -755,11 +731,6 @@ "configured-native-models.test.ts": "codex-integration", "gui-desktop-sidecar-signing.test.ts": "gui", "linux-desktop-packaged-ci.test.ts": "ci-workflows", - "link-boundary.test.ts": "clients", - "link-ssh-argv.test.ts": "clients", "link-ssh-runner.test.ts": "clients", "link-ssh-argv-discovery.test.ts": "clients", - "link-ssh-config.test.ts": "clients", - "link-store.test.ts": "clients", - "link-tunnel-state.test.ts": "clients", "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", "consume-for-inspection-cancel.test.ts": "server", @@ -860,24 +831,6 @@ "deepseek-reasoning-replay-gaps.test.ts": "providers", "deepseek-reasoning-replay.test.ts": "providers", "deepseek-responses-item-id-repair.test.ts": "providers", - "desktop-3p-guard.test.ts": "clients", - "desktop-3p-removal.test.ts": "clients", - "desktop-3p.test.ts": "clients", - "desktop-app-restart.test.ts": "clients", - "desktop-cli-contracts.test.ts": "clients", - "desktop-cli-command-surface.test.ts": "clients", - "desktop-exit-ownership.test.ts": "clients", - "desktop-host-visibility.test.ts": "clients", - "desktop-install-identity.test.ts": "clients", - "desktop-profile.test.ts": "clients", - "desktop-proxy-direct-transport.test.ts": "clients", - "desktop-remote-store.test.ts": "clients", - "desktop-runtime-identity.test.ts": "clients", - "desktop-start-at-login-default.test.ts": "clients", - "desktop-startup-surface.test.ts": "clients", - "desktop-update-surface.test.ts": "clients", - "desktop-tray-availability.test.ts": "clients", - "desktop-widget-entry.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter-reset-wait.test.ts": "providers", "devin-adapter.test.ts": "providers", @@ -919,7 +872,6 @@ "doctor.test.ts": "codex-integration", "download-cap-default.test.ts": "images", "download-connect-deadline-default.test.ts": "images", - "droid-client.test.ts": "clients", "dsh-path-contract.test.ts": "ci-workflows", "dsh-rc6-compat-script.test.ts": "ci-workflows", "dsh-writer-lock.test.ts": "ci-workflows", @@ -1038,17 +990,7 @@ "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", - "integrations-current-store.test.ts": "clients", - "integrations-dsh-profile-patch.test.ts": "clients", - "integrations-hermes-affinity.test.ts": "clients", "integrations-invariants.test.ts": "gui", - "integrations-journal.test.ts": "clients", - "integrations-merge.test.ts": "clients", - "integrations-serialize.test.ts": "clients", - "integrations-state.test.ts": "clients", - "integrations-superseded-store.test.ts": "clients", - "integrations-writer-frozen-config.test.ts": "clients", - "integrations-writer.test.ts": "clients", "integrations-writer-guard.test.ts": "clients", "interactive-confirm.test.ts": "cli", "issue-452-empty-503.test.ts": "codex-integration", "issue-702-expired-replay-state.test.ts": "codex-integration", @@ -1063,7 +1005,6 @@ "jev-model-backend.test.ts": "routing", "jev-provider.test.ts": "providers", "keyring-smoke.test.ts": "ci-workflows", - "kilo-client.test.ts": "clients", "kimi-oauth-identity.test.ts": "providers", "kimi-responses-adjacency.test.ts": "providers", "kiro-account-load.test.ts": "providers/kiro", @@ -1254,7 +1195,6 @@ "muse-spark-web-search-compat.test.ts": "providers", "muse-subscription-usage.test.ts": "providers", "muse-tool-name-alias.test.ts": "providers", - "mutation-plan.test.ts": "clients", "namespace-tool-compat.test.ts": "responses", "native-alias-maintainer-regressions.test.ts": "codex-integration", "native-claude-code-toggle.test.ts": "codex-integration", @@ -1318,7 +1258,7 @@ "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-tool-continuations.test.ts": "providers/ollama", "ollama-native-v4.test.ts": "providers/ollama", "ollama-native.test.ts": "providers/ollama", "ollama-show-enrichment-v7.test.ts": "providers/ollama", "ollama-show-enrichment.test.ts": "providers/ollama", - "ollama-show-ignore-abort.test.ts": "providers/ollama", "omo-client.test.ts": "clients", "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", + "ollama-show-ignore-abort.test.ts": "providers/ollama", "opaque-blob-wrapped-rejection.test.ts": "responses", "openai-api-virtual-models.test.ts": "adapters/openai", "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", @@ -1371,7 +1311,6 @@ "passthrough-override.test.ts": "responses", "phase100-native-parity.test.ts": "e2e-style", "physical-send.test.ts": "adapters", - "pi-path-contract.test.ts": "clients", "pinned-http-content-coding.test.ts": "lib", "pinned-http.test.ts": "lib", "pinned-https-get.test.ts": "images", @@ -1385,7 +1324,6 @@ "port-reclaim.test.ts": "server", "ports.test.ts": "server", "pr-readiness-reattest.test.ts": "ci-workflows", - "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", "privacy-scan-asset-names.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", @@ -1453,8 +1391,6 @@ "qwen38-preserve-reasoning.test.ts": "providers", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", - "raycast-client.test.ts": "clients", - "raycast-detect.test.ts": "clients", "reasoning-effort-summary-default.test.ts": "responses", "reasoning-effort.test.ts": "codex-integration", "reasoning-envelope.test.ts": "responses", @@ -1473,29 +1409,6 @@ "release-resume-identity.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "release-version-sources.test.ts": "ci-workflows", - "remote-catalog.test.ts": "clients", - "remote-control-prototype.test.ts": "clients", - "remote-workspace-activation.test.ts": "clients", - "remote-workspace-agent-wire.test.ts": "clients", - "remote-workspace-app-server.integration.test.ts": "clients", - "remote-workspace-claude.integration.test.ts": "clients", - "remote-workspace-cli-runtimes.test.ts": "clients", - "remote-workspace-cli.test.ts": "clients", - "remote-workspace-codex-runtime.test.ts": "clients", - "remote-workspace-command-runner.test.ts": "clients", - "remote-workspace-device.test.ts": "clients", - "remote-workspace-hub.test.ts": "clients", - "remote-workspace-linux-confinement.test.ts": "clients", - "remote-workspace-management.test.ts": "clients", - "remote-workspace-platform.test.ts": "clients", - "remote-workspace-protocol.test.ts": "clients", - "remote-workspace-rpc-framing.test.ts": "clients", - "remote-workspace-secret-store.test.ts": "clients", - "remote-workspace-server.test.ts": "clients", - "remote-workspace-session-binding.test.ts": "clients", - "remote-workspace-sessions.test.ts": "clients", - "remote-workspace-tool-bridge.test.ts": "clients", - "remote-workspace.test.ts": "clients", "remove-tree-helper.test.ts": "lib", "fixture-teardown-helper.test.ts": "ci-workflows", "replay-refusal-parity.test.ts": "server", @@ -1796,14 +1709,11 @@ "codex-agent-role-models.test.ts": "routing", "codex-role-sizing.test.ts": "routing", "codex-role-auto-assign.test.ts": "routing", - "omo-role-models.test.ts": "clients", - "lazycodex-detection.test.ts": "clients", "codex-agent-role-routes.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", "subagent-roster-retention.test.ts": "routing", - "sync-client-integrations-unattended.test.ts": "clients", "sync-client-integrations.test.ts": "clients", "synced-state-location.test.ts": "lib", "synthetic-tool.test.ts": "images", "system-env.test.ts": "server", @@ -1977,20 +1887,9 @@ "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", - "client-link-connect.test.ts": "clients", "client-link-relay.test.ts": "clients", - "client-link-runtime.test.ts": "clients", "link-routes.test.ts": "clients", - "client-link-state.test.ts": "clients", "client-link-status.test.ts": "clients", - "client-link-teardown.test.ts": "clients", - "client-link-tunnel.test.ts": "clients", - "link-ports.test.ts": "clients", "injection-link-websocket.test.ts": "codex-integration", - "link-supervisor.test.ts": "clients", - "link-status-projection.test.ts": "clients", - "link-admission-wait.test.ts": "clients", - "link-fingerprint.test.ts": "clients", "cli-link.test.ts": "cli", "cli-link-enrollment.test.ts": "cli", "link-management-routes.test.ts": "server", "link-enrollment.test.ts": "server", - "link-compensation.test.ts": "clients", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", "server-combo-cooldown-recording.test.ts": "server", diff --git a/scripts/test-layout/schema.ts b/scripts/test-layout/schema.ts index 72e79f26bf1..8f03002c0d1 100644 --- a/scripts/test-layout/schema.ts +++ b/scripts/test-layout/schema.ts @@ -4,7 +4,7 @@ import { maskNonCode, specifierSites, tokenize } from "./tokens"; /** * The tests/ layout map merges layout.json's authoritative basename -> directory table with - * seeds.json beside it. Its `domains` regexes place a brand-new test before it joins `explicit`; + * optional explicit-map shards and seeds.json beside it. Its `domains` regexes place a brand-new test before it joins `explicit`; * `migrated` lists domains whose files have already left the root. */ export interface DomainSpec { @@ -27,7 +27,7 @@ export function seedsPathFor(path: string): string { } export function loadLayout(path: string = LAYOUT_PATH): Layout { - const parsed = JSON.parse(readFileSync(path, "utf8")) as Pick; + const parsed = JSON.parse(readFileSync(path, "utf8")) as Pick & { explicitShards?: string[] }; const seedsPath = seedsPathFor(path); const seeds = JSON.parse(readFileSync(seedsPath, "utf8")) as Pick; if (parsed.version !== 1 || parsed.root !== "tests") { @@ -42,7 +42,26 @@ export function loadLayout(path: string = LAYOUT_PATH): Layout { if (typeof parsed.explicit !== "object" || parsed.explicit === null || Array.isArray(parsed.explicit)) { throw new Error(`${path}: explicit must be an object`); } - return { ...parsed, ...seeds }; + if (parsed.explicitShards !== undefined && !Array.isArray(parsed.explicitShards)) { + throw new Error(`${path}: explicitShards must be an array`); + } + const explicit = { ...parsed.explicit }; + for (const name of parsed.explicitShards ?? []) { + if (typeof name !== "string" || !/^[a-z][a-z0-9-]*\.json$/.test(name)) { + throw new Error(`${path}: explicit shard must be a sibling JSON filename`); + } + const shardPath = join(dirname(path), name); + const shard: unknown = JSON.parse(readFileSync(shardPath, "utf8")); + if (typeof shard !== "object" || shard === null || Array.isArray(shard)) { + throw new Error(`${shardPath}: explicit shard must be an object`); + } + for (const [test, target] of Object.entries(shard)) { + if (typeof target !== "string") throw new Error(`${shardPath}: target for ${test} must be a string`); + if (Object.hasOwn(explicit, test)) throw new Error(`${shardPath}: duplicate explicit test ${test}`); + explicit[test] = target; + } + } + return { ...parsed, ...seeds, explicit }; } /** diff --git a/structure/overview.md b/structure/overview.md index 755df7c0f40..3330187c14a 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -137,7 +137,7 @@ still cover the rule, which is a judgement only review makes. separately in `tests/cli/restore-completes-shared-teardown.test.ts` and are not bound to this id. Enforced by `tests/codex-integration/codex-catalog-restore.test.ts`. - **INV-TESTS-01** — `tests/` is organised by domain (`tests//`, mirroring `src/`); the explicit map - is `scripts/test-layout/layout.json`, with regex seeds and migration state in + is `scripts/test-layout/layout.json` plus its `explicitShards` sibling maps, with regex seeds and migration state in `scripts/test-layout/seeds.json`, and `tests/test-layout.test.ts` rejects a test outside its domain. Only the two layout guards sit at the root. Source-oracle tests reach the repository through `tests/helpers/repo-root.ts`, never `import.meta.dir + "/.."`. Provider additions register diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index 01ead503993..80c2a1badbb 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -242,6 +242,41 @@ describe("resolver", () => { }); }); +describe("explicit layout shards", () => { + function fixture(explicitShards: unknown, shards: Record, explicit = { "inline.test.ts": "server" }) { + const root = mkdtempSync(join(tmpdir(), "ocx-layout-shards-")); + const path = join(root, "layout.json"); + writeFileSync(path, JSON.stringify({ version: 1, root: "tests", explicit, explicitShards })); + writeFileSync(join(root, "seeds.json"), JSON.stringify({ keepAtRoot: [], migrated: [], domains: {} })); + for (const [name, value] of Object.entries(shards)) writeFileSync(join(root, name), JSON.stringify(value)); + return { path, cleanup: () => removeTreeWithRetry(root) }; + } + + test("inline and sharded entries resolve together without changing legacy maps", () => { + const legacy = fixture(undefined, {}); + const sharded = fixture(["layout-clients.json"], { "layout-clients.json": { "desktop.test.ts": "clients" } }); + try { + expect(loadLayout(legacy.path).explicit).toEqual({ "inline.test.ts": "server" }); + const layout = loadLayout(sharded.path); + expect(layout.explicit).toEqual({ "inline.test.ts": "server", "desktop.test.ts": "clients" }); + expect(resolveTarget(layout, "desktop.test.ts")).toBe("clients"); + } finally { legacy.cleanup(); sharded.cleanup(); } + }); + + test.each([ + ["inline collision", ["layout-clients.json"], { "layout-clients.json": { "inline.test.ts": "clients" } }, /duplicate explicit test/], + ["shard collision", ["layout-clients.json", "layout-other.json"], { "layout-clients.json": { "same.test.ts": "clients" }, "layout-other.json": { "same.test.ts": "clients" } }, /duplicate explicit test/], + ["invalid list", "layout-clients.json", {}, /must be an array/], + ["path escape", ["../outside.json"], {}, /sibling JSON filename/], + ["invalid shard", ["layout-clients.json"], { "layout-clients.json": [] }, /must be an object/], + ["invalid target", ["layout-clients.json"], { "layout-clients.json": { "desktop.test.ts": 1 } }, /must be a string/], + ["missing shard", ["layout-clients.json"], {}, /ENOENT/], + ])("rejects %s before resolving membership", (_name, names, shards, error) => { + const { path, cleanup } = fixture(names, shards as Record); + try { expect(() => loadLayout(path)).toThrow(error as RegExp); } finally { cleanup(); } + }); +}); + describe("membership oracle", () => { const layout = loadLayout(); From 5d0708be94caf2dbc58a046c2e0bc3cadd020fed Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:36:27 +0000 Subject: [PATCH 19/24] Fix Windows npm command processor environment lookup --- src/update/npm-invocation.mjs | 14 ++++++- structure/ops/service-and-sidecars.md | 20 +++++----- .../codex-cli-update-plan.test.ts | 33 +++++++++++++--- tests/update/update-npm-invocation.test.ts | 38 +++++++++++++++++++ 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/update/npm-invocation.mjs b/src/update/npm-invocation.mjs index 662333a8264..da6156b082b 100644 --- a/src/update/npm-invocation.mjs +++ b/src/update/npm-invocation.mjs @@ -95,12 +95,22 @@ export function resolveNpmCommand( return null; } +function shellEnvValue(env, name) { + // Copying process.env loses Windows' case-insensitive property access. Keep + // the existing exact-key precedence; do not choose between conflicting aliases. + if (env[name] !== undefined) return env[name]; + const values = new Set(Object.entries(env) + .filter(([key, value]) => key.toLowerCase() === name.toLowerCase() && value !== undefined) + .map(([, value]) => value)); + return values.size === 1 ? values.values().next().value : undefined; +} + function systemCommandProcessor(env) { - const systemRoot = env.SystemRoot ?? env.windir; + const systemRoot = shellEnvValue(env, "SystemRoot") ?? shellEnvValue(env, "windir"); if (systemRoot && win32.isAbsolute(systemRoot)) { return win32.join(systemRoot, "System32", "cmd.exe"); } - const comSpec = env.ComSpec; + const comSpec = shellEnvValue(env, "ComSpec"); return comSpec && win32.isAbsolute(comSpec) ? win32.resolve(comSpec) : null; } diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 1c115c4c2a4..b2d3cfc98a0 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -454,16 +454,16 @@ mutating the port, because a claim could have landed during the now-unleased ref lease that stays claimed fails closed: nothing is started, and the job is marked failed, since the refresh before it produced no serving proxy; an ownership veto still ends as succeeded. -On Windows, `src/update/npm-invocation.mjs` admits only the exact -`%USERPROFILE%\scoop\apps\nodejs{,-lts}\current` and `current\bin` PATH entries -from outside that Node installation. It resolves the home, junction, PATH entry, npm candidate, -and cwd to physical paths; `current` must remain within its Scoop app directory and -the npm candidate within the admitted entry. `current\bin` may point to the default -`%USERPROFILE%\scoop\persist\nodejs{,-lts}\bin`; cwd inside that persistent bin -is excluded too. The fixed persist suffix is appended to the physical home, accepting 8.3 home -aliases without trusting a redirected persist subtree. Unreadable paths fail closed. Other Scoop apps, version-directory -PATH entries (`NO_JUNCTION`), custom home-root Scoop installs, arbitrary descendants, -and cwd inside the resolved Node installation are not admitted. +On Windows, `src/update/npm-invocation.mjs` reads copied shell-location keys case-insensitively, +with exact-key precedence, no ambiguous alias selection, and the same absolute-path checks. +Scoop admits only `%USERPROFILE%\scoop\apps\nodejs{,-lts}\current` and `current\bin` PATH entries +from outside the installation. Home, junction, PATH entry, npm candidate and cwd resolve to +physical paths: `current` stays inside its Scoop app and npm inside the admitted entry. +`current\bin` may resolve to `%USERPROFILE%\scoop\persist\nodejs{,-lts}\bin`; cwd inside that +persistent bin is excluded too. The fixed persist suffix uses the physical home, accepting 8.3 +home aliases without trusting a redirected persist subtree. Unreadable paths fail closed. +Other Scoop apps, version-directory entries (`NO_JUNCTION`), custom home-root Scoop installs, +arbitrary descendants and cwd inside the resolved Node installation are not admitted. The npm transaction creates each staging directory exclusively and may clean that fresh path while the creating process still owns it. On POSIX it also creates the stage's `lib` directory, diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index ed2f4b5f664..68629844d0e 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; +import { npmInvocation } from "../../src/update/npm-invocation.mjs"; import { scanCodexAppServerProcesses } from "../../src/codex/app-server-processes"; import type { CodexCliInstallReport } from "../../src/codex/cli-install-provenance"; @@ -330,6 +331,28 @@ describe("registry configuration isolation", () => { return call.args.join(" "); } + function cachePath(call: Pick): string | undefined { + if (!call.options.windowsVerbatimArguments) { + return call.args.find(arg => arg.startsWith("--cache="))?.slice("--cache=".length); + } + expect(call.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + // Decode one cmd escape layer, retaining spaces and literal carets in the + // quoted argument. Splitting on whitespace truncates Windows profile paths. + const line = call.args[3]?.replace(/\^([\s\S])/g, "$1"); + return line?.match(/"--cache=([^"]*)"/)?.[1]; + } + + test.each(["plain", "profile with spaces", "profile^with&metacharacters"])( + "the Windows cache argument preserves the entire %s path", profile => { + const cache = `C:\\Users\\${profile}\\Temp\\owned\\npm-cache`; + const invocation = npmInvocation(["view", "pkg@latest", "version", `--cache=${cache}`], "win32", { + PATH: "C:\\Program Files\\nodejs", PATHEXT: ".CMD", SystemRoot: "C:\\Windows", + }, { cwd: "C:\\project", exists: path => path === "C:\\Program Files\\nodejs\\npm.cmd" }); + expect(invocation).not.toBeNull(); + expect(cachePath(invocation!)).toBe(cache); + }, + ); + /** Which registry field this invocation queried, read off the argv line. */ function queriedField(call: CapturedCall): string { const line = argvLine(call); @@ -435,7 +458,8 @@ describe("registry configuration isolation", () => { // A project .npmrc in the launch directory would otherwise redirect the scope; // the query must run from the sentinel-anchored isolation dir instead. const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); - resolveCodexCliUpdateTarget("latest", spawn); + expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(calls.length).toBe(3); for (const call of calls) { const cwd = call.options.cwd as string; expect(cwd).not.toBe(process.cwd()); @@ -627,15 +651,12 @@ describe("registry configuration isolation", () => { const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); const target = resolveCodexCliUpdateTarget("latest", spawn); expect(target.kind).toBe("resolved"); + expect(calls.length).toBe(3); for (const call of calls) { const cwd = call.options.cwd as string; - const line = argvLine(call); - expect(line).toContain("--cache="); - const cacheFlag = line.match(/--cache=(\S+)/); - expect(cacheFlag).not.toBeNull(); // The cache root (and therefore npm's _logs directory beneath it) must live // inside the owned isolation dir, not under an ambient ~/.npm. - expect(cacheFlag![1]!.startsWith(cwd)).toBe(true); + expect(cachePath(call)).toBe(join(cwd, "npm-cache")); } }); diff --git a/tests/update/update-npm-invocation.test.ts b/tests/update/update-npm-invocation.test.ts index 1c935a2ebae..f96cb27092e 100644 --- a/tests/update/update-npm-invocation.test.ts +++ b/tests/update/update-npm-invocation.test.ts @@ -12,6 +12,44 @@ const trustedNpm = "C:\\Program Files\\nodejs\\npm.cmd"; const systemCmd = "C:\\Windows\\System32\\cmd.exe"; describe("Windows npm update invocation", () => { + test.each([ + "SystemRoot", "SYSTEMROOT", "sYsTeMrOoT", "windir", "WINDIR", "WiNdIr", + "ComSpec", "COMSPEC", "cOmSpEc", + ])("resolves the command processor from a copied %s environment key", key => { + // A sanitized environment is a plain object, without Windows process.env's + // case-insensitive property access. The npm path itself resolves in every case. + const env = { ...{ + PATH: "C:\\Program Files\\nodejs", PATHEXT: ".CMD", + [key]: key.toLowerCase() === "comspec" ? systemCmd : "C:\\Windows", + } }; + const deps = { cwd, exists: (path: string) => path === trustedNpm }; + expect(resolveNpmCommand("win32", env, deps)).toBe(trustedNpm); + expect(npmInvocation(["view", "pkg@latest", "version"], "win32", env, deps)?.file).toBe(systemCmd); + }); + + test("preserves exact shell-key and SystemRoot precedence in copied environments", () => { + const env = { + PATH: "C:\\Program Files\\nodejs", PATHEXT: ".CMD", + SystemRoot: "C:\\Windows", SYSTEMROOT: "D:\\OtherWindows", + windir: "E:\\OtherWindows", ComSpec: "F:\\OtherWindows\\cmd.exe", + }; + const deps = { cwd, exists: (path: string) => path === trustedNpm }; + expect(npmInvocation(["--version"], "win32", env, deps)?.file).toBe(systemCmd); + expect(npmInvocation(["--version"], "win32", { + PATH: env.PATH, PATHEXT: env.PATHEXT, ComSpec: systemCmd, COMSPEC: "D:\\OtherWindows\\cmd.exe", + }, deps)?.file).toBe(systemCmd); + }); + + test("refuses missing, relative or ambiguous copied command-processor locations", () => { + for (const shell of [{}, { SYSTEMROOT: "relative" }, { COMSPEC: "cmd.exe" }, + { SYSTEMROOT: "C:\\Windows", systemroot: "D:\\OtherWindows" }]) { + const env = { PATH: "C:\\Program Files\\nodejs", PATHEXT: ".CMD", ...shell }; + const deps = { cwd, exists: (path: string) => path === trustedNpm }; + expect(resolveNpmCommand("win32", env, deps)).toBe(trustedNpm); + expect(npmInvocation(["--version"], "win32", env, deps)).toBeNull(); + } + }); + test("ignores current-directory candidates and resolves npm from an absolute PATH entry", () => { const existing = new Set([ `${cwd}\\npm.cmd`, From 1e6d70974253668bba3f03aa764787ab569571c6 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:24:23 +0900 Subject: [PATCH 20/24] fix(codex): require complete SHA-512 update target evidence --- scripts/test-layout/layout.json | 1 + src/codex/cli-update-plan.ts | 13 +++++-- structure/codex-cli-update.md | 1 + .../codex-cli-update-integrity.test.ts | 34 +++++++++++++++++++ .../codex-cli-update-plan.test.ts | 12 ++++--- tests/fixtures/test-layout-expected.json | 3 +- 6 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 tests/codex-integration/codex-cli-update-integrity.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0528de6b993..1216dbba52f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -562,6 +562,7 @@ "codex-cli-installation-identity.test.ts": "codex-integration", "codex-cli-installation-targets.test.ts": "codex-integration", "codex-cli-update-launcher-policy.test.ts": "codex-integration", + "codex-cli-update-integrity.test.ts": "codex-integration", "codex-cli-update-plan.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index e98fb551950..190a1088001 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -119,7 +119,16 @@ export interface CodexCliUpdatePlanDeps { const PLAN_ID_LENGTH = 32; const PLAN_ID_RE = /^[0-9a-f]{32}$/; const REGISTRY_TIMEOUT_MS = 12_000; -const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/=]+$/; +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[] { @@ -376,7 +385,7 @@ export function resolveCodexCliUpdateTarget( } // `dist.integrity` may arrive quoted or as a space-separated multi-hash list. const tokens = (integrityRun.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean); - const integrity = tokens.find(token => SHA512_INTEGRITY_RE.test(token)); + const integrity = tokens.find(isSha512Integrity); if (!integrity) { return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no sha512 integrity token" }); } diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index bf92e3f11a9..ec33f9d7cbc 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -8,6 +8,7 @@ and explicit Windows observation follows its [separate read-only contract](runti Explicit Codex CLI installation observation does not discover a Codex home or read/write its state. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). The read-only CLI update plan binds npm executable selection to the inspected launcher PATH. +Registry integrity requires a canonical base64 SHA-512 digest of exactly 64 bytes; malformed or truncated tokens refuse the target. Its child environment drops npm configuration overrides, named Node startup/output controls, and the `LD_*`/`DYLD_*` loader families; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. Distinct npm config files, cache and logs stay under the owned temporary root with best-effort diff --git a/tests/codex-integration/codex-cli-update-integrity.test.ts b/tests/codex-integration/codex-cli-update-integrity.test.ts new file mode 100644 index 00000000000..fbfe3e3160e --- /dev/null +++ b/tests/codex-integration/codex-cli-update-integrity.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import type { spawnSync } from "node:child_process"; +import { resolveCodexCliUpdateTarget } from "../../src/codex/cli-update-plan"; + +const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; + +function resolveWith(token: string) { + let queries = 0; + const outputs = ["1.4.2", token, "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz"]; + const spawn = (() => ({ status: 0, stdout: outputs[queries++], stderr: "" })) as typeof spawnSync; + return { target: resolveCodexCliUpdateTarget("latest", spawn), queries }; +} + +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.each([ + "sha512-AAAA", + "sha512-====", + `sha512-${Buffer.alloc(63).toString("base64")}`, + `sha512-${Buffer.alloc(65).toString("base64")}`, + // 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); + expect(result.target.kind).toBe("unresolved"); + expect(result.queries).toBe(2); + }); +}); diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index 68629844d0e..d697596b55d 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -19,6 +19,8 @@ import { type CodexCliUpdateTarget, } from "../../src/codex/cli-update-plan"; +const REGISTRY_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; + /** * Phase 2 of the Codex CLI update manager. * @@ -270,10 +272,10 @@ describe("registry target resolution", () => { test("an exact version with a sha512 token resolves", () => { const target = resolveCodexCliUpdateTarget("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, - { status: 0, stdout: "'sha512-abc/DEF+123=' sha1-old\n" }, + { status: 0, stdout: `'${REGISTRY_INTEGRITY}' sha1-old\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: "sha512-abc/DEF+123=" }); + expect(target).toEqual({ kind: "resolved", version: "1.4.2", integrity: REGISTRY_INTEGRITY }); }); test("a missing integrity token refuses instead of proceeding best-effort", () => { @@ -299,7 +301,7 @@ describe("registry target resolution", () => { test("a tarball outside the pinned registry origin is refused even with a valid digest", () => { const target = resolveCodexCliUpdateTarget("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, - { status: 0, stdout: "sha512-abc/DEF+123=\n" }, + { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "https://evil.invalid/@openai/codex/-/codex-1.4.2.tgz\n" }, ])); expect(target.kind).toBe("unresolved"); @@ -308,7 +310,7 @@ describe("registry target resolution", () => { test("a tarball answer that is not a URL at all is refused", () => { const target = resolveCodexCliUpdateTarget("latest", spawnStub([ { status: 0, stdout: "1.4.2\n" }, - { status: 0, stdout: "sha512-abc/DEF+123=\n" }, + { status: 0, stdout: `${REGISTRY_INTEGRITY}\n` }, { status: 0, stdout: "not-a-url\n" }, ])); expect(target.kind).toBe("unresolved"); @@ -382,7 +384,7 @@ describe("registry configuration isolation", () => { const RESOLVE_OUTPUTS = { version: "1.4.2\n", - "dist.integrity": "sha512-abc/DEF+123=\n", + "dist.integrity": `${REGISTRY_INTEGRITY}\n`, "dist.tarball": "https://registry.npmjs.org/@openai/codex/-/codex-1.4.2.tgz\n", }; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d8aa5e7f69d..1a86cca8c61 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -416,7 +416,8 @@ "codex-cli-installation-identity.test.ts": "codex-integration", "codex-cli-installation-targets.test.ts": "codex-integration", "codex-cli-update-launcher-policy.test.ts": "codex-integration", - "codex-cli-update-plan.test.ts": "codex-integration", + "codex-cli-update-integrity.test.ts": "codex-integration", + "codex-cli-update-plan.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", "codex-forward-access-programs-writer.test.ts": "codex-integration", From ff68734b3aedb74f797a8fc20898b2872e3ca436 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:28:06 +0900 Subject: [PATCH 21/24] fix(codex): preserve bounded inspection and verified restart targets --- scripts/test-layout/layout.json | 1 + src/cli/codex-cli-update.ts | 27 +++------ src/codex/app-server-processes.ts | 14 ++--- src/codex/cli-install-provenance.ts | 35 ++++++++++++ structure/codex-cli-update.md | 5 +- structure/runtime.md | 6 +- .../cli/cli-codex-cli-update-boundary.test.ts | 55 +++++++++++++++++++ tests/cli/cli-codex-cli-update.test.ts | 7 ++- .../codex-proc-scan-errors.test.ts | 8 ++- tests/fixtures/test-layout-expected.json | 3 +- 10 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 tests/cli/cli-codex-cli-update-boundary.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1216dbba52f..0227c68fc3a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -442,6 +442,7 @@ "cli-api-protocols.test.ts": "cli", "cli-catalog-prewarm.test.ts": "cli", "cli-ensure-readiness.test.ts": "cli", + "cli-codex-cli-update-boundary.test.ts": "cli", "cli-codex-cli-update.test.ts": "cli", "cli-codex-log-guard-compact.test.ts": "cli", "cli-codex-log-guard-protection.test.ts": "cli", diff --git a/src/cli/codex-cli-update.ts b/src/cli/codex-cli-update.ts index 94da6b0ca39..2a786258630 100644 --- a/src/cli/codex-cli-update.ts +++ b/src/cli/codex-cli-update.ts @@ -1,5 +1,6 @@ import { inspectCodexCliInstall, + observeCodexCliSelectedCommand, type CodexCliInstallProvenanceDeps, type CodexCliInstallReport, } from "../codex/cli-install-provenance"; @@ -18,7 +19,6 @@ import { type CodexCliUpdatePlanDeps, } from "../codex/cli-update-plan"; import { - resolveCodexRuntime, type ResolveCodexRuntimeDeps, type ResolveCodexRuntimeResult, } from "../codex/runtime"; @@ -210,23 +210,14 @@ function inspectionDeps(commandDeps: CodexCliUpdateCommandDeps): CodexCliInstall // 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. - let selectedCommand: string | null = null; - try { - const resolve = commandDeps.resolveSelectedRuntime ?? resolveCodexRuntime; - selectedCommand = resolve({ - env: { - CODEX_CLI_PATH: trusted.codexCliPath ?? undefined, - PATH: trusted.path ?? undefined, - PATHEXT: trusted.pathExt ?? undefined, - }, - configDir: trusted.configDir, - discoverAlternatives: false, - probeVersion: false, - }).runtime.command ?? null; - } catch { - // A resolver failure must not attest: fail closed with no selected command. - selectedCommand = null; - } + 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, diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 91ac8ecc00d..000dfaf9873 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -365,7 +365,7 @@ function parseUnixProcStatusUid(status: string): number | undefined { return Number.isSafeInteger(uid) ? uid : undefined; } -function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] { +function listUnixProcSnapshots(uid: number | undefined, requireComplete = true): ProcessSnapshot[] { // procfs missing on a Linux-shaped platform is an enumeration failure, not // "no processes" — the staleness collector must not read it as not_running. if (!existsSync("/proc")) throw new Error("procfs_unavailable"); @@ -386,9 +386,9 @@ function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] { if (!commandLine) continue; out.push({ pid, commandLine, executable: argv[0], uid: processUid }); } catch (error) { - // Only disappearance proves a process exited mid-scan. Permission/I/O - // failures leave the table incomplete and must reach fail-closed callers. - if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + // Complete observations may ignore only disappearance. Restart target + // enumeration can skip an unreadable PID while retaining verified ones. + if (requireComplete && (error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; } } return out; @@ -546,10 +546,10 @@ async function listWindowsSnapshotsAsync(): Promise { return parseWindowsSnapshotOutput(output); } -function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] { +function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined, requireComplete = true): ProcessSnapshot[] { if (platform === "win32") return listWindowsSnapshots(); if (platform === "darwin") return listDarwinSnapshots(getuid()); - return listUnixProcSnapshots(getuid()); + return listUnixProcSnapshots(getuid(), requireComplete); } export interface ListProcessSnapshotsOptions { @@ -593,7 +593,7 @@ export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C // Restart/kill contract (#476): enumeration failure means no targets — // never signal a process we could not verify. try { - snapshots = defaultListSnapshots(platform, getuid); + snapshots = defaultListSnapshots(platform, getuid, false); } catch { snapshots = []; } diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index c4bf5912621..1fb16156453 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -18,6 +18,9 @@ import { isSpawnableCodexCandidate } from "./exec-invocation"; import { codexRuntimeStatePath, parsePersistedCodexRuntime, + resolveCodexRuntime, + type ResolveCodexRuntimeDeps, + type ResolveCodexRuntimeResult, } from "./runtime"; import { inspectCodexShimBackingForCommand, @@ -308,6 +311,38 @@ function resolveCandidateCommandPath( return null; } +/** Resolve selection under inspection's bounded, local-only filesystem policy. */ +export function observeCodexCliSelectedCommand( + deps: CodexCliInstallProvenanceDeps, + resolve: (deps: ResolveCodexRuntimeDeps) => ResolveCodexRuntimeResult = resolveCodexRuntime, +): string | null { + const platform = deps.platform ?? process.platform; + // Even the general resolver's existence checks can touch a remote Windows + // candidate. Deferred inspection must return before invoking it at all. + if (platform === "win32") return null; + const configDir = deps.configDir ?? getConfigDir(); + if (!isSafeLocalInspectionPath(configDir, deps)) return null; + try { + return resolve({ + platform, + env: deps.env, + configDir, + discoverAlternatives: false, + probeVersion: false, + // Runtime and shim state must use the same no-follow, nonblocking, + // size-bounded descriptor reads as provenance, including before selection. + readFileSync: path => { + const bytes = readBoundedFile(path, MAX_RUNTIME_STATE_BYTES, deps); + if (!bytes) throw new Error("inspection_state_unavailable"); + return bytes.toString("utf8"); + }, + existsSync: path => resolveCandidateCommandPath(path, deps) !== null, + }).runtime.command ?? null; + } catch { + return null; + } +} + function canonicalize(path: string, deps: CodexCliInstallProvenanceDeps): string | null { if (!isSafeLocalInspectionPath(path, deps)) return null; try { diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index ec33f9d7cbc..57b0bcf1758 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -9,12 +9,13 @@ Explicit Codex CLI installation observation does not discover a Codex home or re The read-only CLI update plan binds npm executable selection to the inspected launcher PATH. Registry integrity requires a canonical base64 SHA-512 digest of exactly 64 bytes; malformed or truncated tokens refuse the target. +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. Its child environment drops npm configuration overrides, named Node startup/output controls, and the `LD_*`/`DYLD_*` loader families; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. Distinct npm config files, cache and logs stay under the owned temporary root with best-effort cleanup; a cleanup error does not replace the computed plan/refusal. The POSIX update scan includes all readable users by default, retaining an explicitly injected UID scope. -Linux per-process `status`/`cmdline` read failures invalidate the scan except for `ENOENT` +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 remains same-user. This plan neither installs a runtime nor signals +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. diff --git a/structure/runtime.md b/structure/runtime.md index fd6ecfad658..6498b437909 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -73,7 +73,7 @@ After a CLI catalog/cache write, advisory restart guidance compares each running start time with the written catalog mtime. It reports only processes proven stale; a fresh or unreadable observation does not claim that another restart is required. Explicit `--restart-codex` and `--restart-app-server-only` retain their operator-consent semantics and act on -verified matching processes regardless of the advisory freshness result. Read-only client diagnostics in `src/codex/native-profile-processes.ts` prefer full executable paths over inferred argv0; macOS comm and args are read separately and joined by PID, so unquoted Codex Framework helper paths do not become CLI clients. The initial split Codex.app framework-path token pair is also rejected when only a process basename is available; later path arguments do not override a client match. Quoted immediate script entrypoints remain supported for known interpreters. This classification does not change the app-server termination selector. Linux procfs enumeration in `src/codex/app-server-processes.ts` ignores only per-process `ENOENT` disappearance races. Other read failures invalidate the whole observation: raw enumeration throws, catalog diagnostics report unknown, and restart selection returns no targets. The same-user and app-server-only signal boundary is unchanged. +verified matching processes regardless of the advisory freshness result. Read-only client diagnostics in `src/codex/native-profile-processes.ts` prefer full executable paths over inferred argv0; macOS comm and args are read separately and joined by PID, so unquoted Codex Framework helper paths do not become CLI clients. The initial split Codex.app framework-path token pair is also rejected when only a process basename is available; later path arguments do not override a client match. Quoted immediate script entrypoints remain supported for known interpreters. This classification does not change the app-server termination selector. Complete Linux procfs observation in `src/codex/app-server-processes.ts` ignores only per-process `ENOENT` disappearance races. Other read failures invalidate that observation: raw enumeration throws and catalog diagnostics report unknown. Restart enumeration skips unreadable entries and retains independently verified targets; its same-user and app-server-only signal boundary is unchanged. > Decision record: [ADR-0097](decisions/ADR-0097-post-write-app-server-freshness.md) @@ -323,14 +323,14 @@ Explicit `codex-shim install` (`src/cli/dispatch.ts`) refuses with nonzero exit; Codex CLI update inspection is split from mutation. `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; -environment and persisted candidates remain report-only and cannot produce a managed classification in this one-shot command. +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. On Windows this first slice performs no candidate/configuration filesystem I/O: it preserves only proof-captured absolute environment candidates for lexical app-bundle/version-manager reporting and otherwise fails closed. That fail-closed result records which observation was missing: a run with no proof-captured environment candidate reports `windows_inspection_deferred`, because persisted selection is never consulted there and the command cannot claim that no Codex CLI exists; a captured candidate whose path is not lexically eligible reports `candidate_path_unavailable`. POSIX keeps `candidate_unavailable` for an unobserved candidate. -This check does not attest or admit a selected runtime. The command exposes no private mutation authority and does not query +This check does not admit a runtime or authorize an install. The command exposes no private mutation authority and does not query a registry, execute Codex/npm, install, repair, stop, restart, or change configuration/cache state. The bridge enforces a heartbeat stall deadline. It defaults to 300 seconds sampled on a 2 s tick diff --git a/tests/cli/cli-codex-cli-update-boundary.test.ts b/tests/cli/cli-codex-cli-update-boundary.test.ts new file mode 100644 index 00000000000..ad6e47c786f --- /dev/null +++ b/tests/cli/cli-codex-cli-update-boundary.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { handleCodexCliUpdateCommand } from "../../src/cli/codex-cli-update"; +import { initializeNodeLauncherContext, NODE_LAUNCH_CONTEXT_ENV, NODE_LAUNCH_PROOF_PREFIX } from "../../src/cli/launcher-context"; +import { inspectCodexCliInstall } from "../../src/codex/cli-install-provenance"; + +const roots: string[] = []; +afterEach(() => { + initializeNodeLauncherContext(["bun", "cli"], {}); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Codex CLI selection inspection boundary", () => { + (process.platform === "win32" ? test : test.skip)("Windows never resolves a captured remote candidate", async () => { + const proof = "W".repeat(43); + initializeNodeLauncherContext(["bun", "cli", `${NODE_LAUNCH_PROOF_PREFIX}${proof}`], { + [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ version: 1, proof, anthropicEnvSlots: [], + codexCliInspectionEnv: { codexCliPath: "\\\\fixture.invalid\\share\\codex.exe", path: "", pathExt: ".EXE", managerRoots: {}, configDir: "C:\\fixture" }, + }), + }); + let resolutions = 0; + expect(await handleCodexCliUpdateCommand(["check", "--json"], { + resolveSelectedRuntime: () => { resolutions++; throw new Error("must not resolve on Windows"); }, + inspectInstall: async deps => { + expect(deps.selectedCommand).toBeNull(); + return inspectCodexCliInstall({ ...deps, exists: () => { throw new Error("must not read a Windows candidate"); } }); + }, + })).toBe(0); + expect(resolutions).toBe(0); + }); + + (process.platform === "win32" ? test.skip : test).each(["codex-runtime.json", "codex-shim.json"])( + "published check cannot block on a FIFO %s before bounded provenance inspection", state => { + const root = mkdtempSync(join(tmpdir(), "ocx-selection-boundary-")); + roots.push(root); + const home = join(root, "home"); + mkdirSync(home); + const candidate = join(root, "codex"); + writeFileSync(candidate, "#!/bin/sh\nexit 91\n"); + chmodSync(candidate, 0o755); + expect(spawnSync("mkfifo", [join(home, state)]).status).toBe(0); + const result = spawnSync("node", [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "check", "--json"], { + cwd: repoRoot(), encoding: "utf8", timeout: 15_000, + env: { ...process.env, OPENCODEX_HOME: home, CODEX_CLI_PATH: candidate }, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).managed).toBe(false); + }, + ); +}); diff --git a/tests/cli/cli-codex-cli-update.test.ts b/tests/cli/cli-codex-cli-update.test.ts index 268356089de..96048839975 100644 --- a/tests/cli/cli-codex-cli-update.test.ts +++ b/tests/cli/cli-codex-cli-update.test.ts @@ -246,6 +246,7 @@ describe("Codex CLI update CLI", () => { test("passes only proof-bound manager roots into production provenance inspection", async () => { const proof = "M".repeat(43); + const configDir = process.platform === "win32" ? "C:\\opencodex" : "/opencodex"; const env: NodeJS.ProcessEnv = { [NODE_LAUNCH_CONTEXT_ENV]: JSON.stringify({ version: 1, @@ -256,7 +257,7 @@ describe("Codex CLI update CLI", () => { path: "C:\\managed", pathExt: ".CMD", managerRoots: { FNM_DIR: "C:\\custom-manager" }, - configDir: "C:\\opencodex", + configDir, }, }), }; @@ -281,8 +282,8 @@ describe("Codex CLI update CLI", () => { PATH: "C:\\managed", PATHEXT: ".CMD", }); - expect(received?.configDir).toBe("C:\\opencodex"); - expect(received?.selectedCommand).toBe("C:\\managed\\codex.cmd"); + expect(received?.configDir).toBe(configDir); + expect(received?.selectedCommand).toBe(process.platform === "win32" ? null : "C:\\managed\\codex.cmd"); } finally { initializeNodeLauncherContext(["bun", "cli"], {}); } diff --git a/tests/codex-integration/codex-proc-scan-errors.test.ts b/tests/codex-integration/codex-proc-scan-errors.test.ts index 5ce3ffd98e9..d4916991d4e 100644 --- a/tests/codex-integration/codex-proc-scan-errors.test.ts +++ b/tests/codex-integration/codex-proc-scan-errors.test.ts @@ -53,12 +53,14 @@ describe("procfs process read failures", () => { expect(scanCodexSessionProcesses({ platform: "linux" })).toEqual({ kind: "unavailable" }); expect(scanCodexAppServerProcesses(io)).toEqual({ kind: "unavailable" }); expect(collectCodexAppServerCatalogState({ ...io, catalogMtimeMs: () => 1000 }).state).toBe("unknown"); - expect(listCodexAppServerProcesses(io)).toEqual([]); + // Unrelated unreadable processes block complete observations, but do + // not suppress verified same-user restart targets. + expect(listCodexAppServerProcesses(io)).toEqual([APP_SERVER]); const signals: number[] = []; - restartCodexAppServers([APP_SERVER], { + restartCodexAppServers([APP_SERVER, { ...APP_SERVER, pid: 42 }, { ...APP_SERVER, pid: 43 }], { ...io, kill: pid => { signals.push(pid); }, isAlive: () => true, waitExit: () => true, }); - expect(signals).toEqual([]); + expect(signals).toEqual([41]); }); } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1a86cca8c61..7b8821c0a95 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -336,7 +336,8 @@ "cli-account-threshold.test.ts": "cli", "cli-account-verify-status.test.ts": "cli", "cli-account.test.ts": "cli", "cli-capabilities-arguments.test.ts": "cli", "cli-capabilities.test.ts": "cli", "cli-api-protocols.test.ts": "cli", - "cli-catalog-prewarm.test.ts": "cli", "cli-codex-cli-update.test.ts": "cli", + "cli-catalog-prewarm.test.ts": "cli", "cli-codex-cli-update-boundary.test.ts": "cli", + "cli-codex-cli-update.test.ts": "cli", "cli-ensure-readiness.test.ts": "cli", "cli-codex-log-guard-compact.test.ts": "cli", "cli-codex-log-guard-protection.test.ts": "cli", "cli-codex-log-guard.test.ts": "cli", "cli-companion.test.ts": "cli", From 93ca8486d74644ce44c412d82dec242a30b5c9a3 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:28:36 +0900 Subject: [PATCH 22/24] docs(codex): explain attested selection and read-only update plans --- .../src/content/docs/fr/reference/cli.md | 2 +- .../content/docs/fr/reference/cli/agents.md | 2 +- .../src/content/docs/ja/reference/cli.md | 2 +- .../content/docs/ja/reference/cli/agents.md | 2 +- .../src/content/docs/ko/reference/cli.md | 2 +- .../content/docs/ko/reference/cli/agents.md | 2 +- docs-site/src/content/docs/reference/cli.md | 12 +++++++-- .../src/content/docs/reference/cli/agents.md | 26 ++++++++++++++++--- .../src/content/docs/ru/reference/cli.md | 2 +- .../content/docs/ru/reference/cli/agents.md | 2 +- .../src/content/docs/tr/reference/cli.md | 2 +- .../content/docs/tr/reference/cli/agents.md | 2 +- .../src/content/docs/zh-cn/reference/cli.md | 2 +- .../docs/zh-cn/reference/cli/agents.md | 2 +- .../src/content/docs/zh-tw/reference/cli.md | 2 +- .../docs/zh-tw/reference/cli/agents.md | 2 +- 16 files changed, 47 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 26211aae04d..511f1889106 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -17,7 +17,7 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. -`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sous POSIX ou `windows_inspection_deferred` sous Windows. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache. +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Pour observer une installation Windows x64, consultez [`attest`](/fr/reference/cli/agents/). Sans chemins explicites, la commande observe le candidat sélectionné identifié par l’instantané du lanceur lié à la preuve ; aucune autorité de mise à jour ni sélection du runtime n’est attestée. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 03019ca987d..797c78c0139 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -285,7 +285,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sur les systèmes POSIX. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. Comme cette étape ne consulte jamais l’état persistant, une exécution Windows pour laquelle aucun candidat d’environnement n’a été capturé signale `windows_inspection_deferred` plutôt que `candidate_unavailable` : la commande ne peut pas observer si une CLI Codex est installée, elle signale donc le report de l’inspection au lieu d’affirmer qu’aucun candidat n’existe. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés. +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Sous Windows, une commande simple capturée comme `CODEX_CLI_PATH=codex`, un chemin distant ou un chemin de périphérique produit plutôt `candidate_path_unavailable`. Le candidat a été capturé, mais son chemin ne convient pas à cette inspection. diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index deead3c02df..7bbfaf6f40c 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -20,7 +20,7 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 -`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable`、Windows では `windows_inspection_deferred` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認できれば `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows x64 インストールの観測は [`attest` コマンド](/ja/reference/cli/agents/)を参照してください。明示パスを省略すると、証明にバインドされたランチャースナップショットが特定した選択候補を観測します。更新権限やランタイム選択は証明しません。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 9c891b8b913..c42ce29b88f 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -216,7 +216,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このスライスは永続化された選択状態を一切読み取らないため、環境候補がキャプチャされていない Windows 実行では `candidate_unavailable` ではなく `windows_inspection_deferred` を報告します。コマンドは Codex CLI が導入されているかどうかを観測できないので、候補が存在しないと断定せず、検査が延期されたことを報告します。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認できれば `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows で `CODEX_CLI_PATH=codex` のような単純なコマンド名、リモートパス、デバイスパスが候補としてキャプチャされた場合は、`candidate_path_unavailable` を報告します。候補は取得されていますが、そのパスはこの検査の対象になりません。 diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 21a88093a16..f9d3ed1d4c1 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. -`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`, Windows에서는 `windows_inspection_deferred`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참조하세요. 명시적 경로 없이 증명에 바인딩된 런처 스냅샷이 식별한 선택 후보를 관측하며, 업데이트 권한이나 런타임 선택을 증명하지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 545e1e4a790..586330e357c 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -249,7 +249,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 조각은 저장된 선택 상태를 전혀 읽지 않으므로, 환경 후보가 캡처되지 않은 Windows 실행은 `candidate_unavailable`이 아니라 `windows_inspection_deferred`를 보고합니다. 명령이 Codex CLI 설치 여부를 관측할 수 없으므로, 후보가 없다고 단정하는 대신 검사가 연기되었음을 보고합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows에서 `CODEX_CLI_PATH=codex` 같은 단순 명령 이름이나 원격 경로·장치 경로가 후보로 캡처되면 `candidate_path_unavailable`을 보고합니다. 후보는 캡처됐지만 해당 경로가 이 검사 대상에 적합하지 않은 경우입니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 32368c3265f..d7df5da3799 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -146,8 +146,9 @@ proxy. `ocx system codex-cli-update check` needs no live proxy and makes no package-registry request. It inspects bounded provenance metadata for the configured install candidate, including its redacted executable location and ownership evidence. Trusted published-launcher context authenticates that candidate snapshot, -not a successful Codex execution. Because this one-shot command never executes Codex, environment and persisted candidates -remain report-only (`managed: false`, normally `selection_unattested`) and `selectionAttested` remains `false`. +not a successful Codex execution. On POSIX, `selectionAttested` can be `true` when the runtime resolver +selects the same canonical environment candidate without executing it. A linked npm-global package +manifest can then establish `managed: true`; persisted candidates remain unattested. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report @@ -156,6 +157,13 @@ only a proof-captured absolute environment candidate can receive lexical app-bun every other Windows candidate fails closed. The command does not install or repair software, execute Codex or npm, control a running process, or write configuration/cache state. +`ocx system codex-cli-update plan --json` adds a read-only update decision for an attested POSIX +npm-global installation. It resolves `latest` through the official npm registry, validates the +exact version and SHA-512 integrity, and refuses active sessions or incomplete process observation. +Registry queries use isolated temporary npm configuration, cache and logs with best-effort cleanup. +No software is installed and no process is signalled. Windows returns `windows_inspection_deferred`. +See [planning details](/reference/cli/agents/#read-only-codex-cli-update-plan). + 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 882c7ef59db..24687f1110c 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1077,9 +1077,9 @@ ocx system codex-cli-update check --json `check` makes no package-registry request and inspects bounded configured-candidate provenance evidence, including a redacted executable location and ownership evidence. Trusted published-launcher context authenticates -the candidate snapshot, not successful Codex execution. Because this one-shot command never executes Codex, -environment and persisted candidates remain report-only (`managed: false`, normally `selection_unattested`); -`selectionAttested` remains `false`. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, +the candidate snapshot, not successful Codex execution. On POSIX, selection is attested only when the runtime +resolver selects the same canonical environment candidate without executing it. A linked npm-global manifest +can then establish `managed: true`; persisted candidates remain unattested. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report `candidate_unavailable` on POSIX. On Windows this first slice performs no candidate or configuration filesystem I/O: @@ -1094,6 +1094,26 @@ and are never classified as managed. On Windows, a captured bare command such as `CODEX_CLI_PATH=codex`, a remote path, or a device path reports `candidate_path_unavailable` instead. Those cases have a captured candidate; its path is not eligible for this inspection. +#### Read-only Codex CLI update plan + +```bash +ocx system codex-cli-update plan --channel latest --json +``` + +Run through the published Node launcher with the intended `CODEX_CLI_PATH` in the shell environment. +The plan requires an attested POSIX npm-global installation and a linked package manifest version. +It queries the official npm registry for an exact version, SHA-512 integrity and same-origin tarball, +using the launcher's captured PATH and isolated temporary npm configuration, cache and logs. Proxy +settings and `NODE_EXTRA_CA_CERTS` remain supported; cleanup is best-effort. No Codex command runs. + +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 +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. + #### Explicit installation observation on Windows x64 ```text diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index c47b3a1d397..50154e5b8d3 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -31,7 +31,7 @@ runtime port и проверку identity, а не поддерживая вто явно документированные как offline-операции с конфигурацией, вместо этого могут валидировать и редактировать файл конфигурации без живого прокси. -`ocx system codex-cli-update check` не требует работающего прокси и не обращается к реестру пакетов. Команда в строго ограниченном объёме проверяет метаданные происхождения настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable` в POSIX или `windows_inspection_deferred` в Windows. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не устанавливает и не восстанавливает ПО, не запускает Codex или npm, не управляет работающими процессами и ничего не записывает в конфигурацию или кеш. +`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`; сохранённые кандидаты остаются неаттестованными. Windows откладывает проверку, не читая файлы кандидата и конфигурацию. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). Для наблюдения установки Windows x64 см. [`attest`](/ru/reference/cli/agents/). Без явных путей команда наблюдает выбранного кандидата, определённого привязанным к доказательству снимком средства запуска; полномочия на обновление и выбор runtime не подтверждаются. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 8ebf9402e23..92ea5db6b22 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -268,7 +268,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` не обращается к реестру пакетов и в строго ограниченном объёме проверяет данные о происхождении настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable` в POSIX-системах. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Поскольку этот этап вообще не читает сохранённое состояние выбора, запуск в Windows без захваченного кандидата из окружения возвращает `windows_inspection_deferred`, а не `candidate_unavailable`: команда не может определить, установлен ли Codex CLI, поэтому сообщает об отложенной проверке, а не утверждает, что кандидата нет. Команда не запускает Codex или менеджер пакетов, не восстанавливает shim, ничего не записывает в конфигурацию или кеш, не останавливает процессы и ничего не устанавливает. Кандидаты, входящие в комплект приложения, найденные в распознанных путях менеджеров версий, являющиеся непроверенными автономными установками или имеющие неоднозначное состояние shim, отображаются как `unmanaged` или `unknown` и никогда не классифицируются как `managed`. +`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`; сохранённые кандидаты остаются неаттестованными. Windows откладывает проверку, не читая файлы кандидата и конфигурацию. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). В Windows захваченная команда без полного пути, например `CODEX_CLI_PATH=codex`, удалённый путь или путь устройства возвращает `candidate_path_unavailable`. Кандидат захвачен, но его путь не подходит для этой проверки. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index 4a24b1bc2f0..adee59063e5 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -36,7 +36,7 @@ yönetim API'sine gidiş-dönüş yapar. Durdurulmuş veya erişilemeyen bir pro yapılandırma işlemleri olarak açıkça belgelenen komutlar, bunun yerine canlı bir proxy olmadan yapılandırma dosyasını doğrulayabilir ve düzenleyebilir. -`ocx system codex-cli-update check` canlı proxy gerektirmez ve paket kayıt defterine istek göndermez. Yapılandırmada belirtilen kurulum adayına ilişkin provenance meta verilerini, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik denetim Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve POSIX'te `candidate_unavailable`, Windows'ta ise `windows_inspection_deferred` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut yazılım kurmaz veya onarmaz, Codex ya da npm çalıştırmaz, çalışan bir sürece müdahale etmez ve yapılandırmaya ya da önbellek durumuna yazmaz. +`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows, aday veya yapılandırma dosyalarını okumadan incelemeyi erteler. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. Windows x64 kurulum gözlemi için [`attest`](/tr/reference/cli/agents/) komutuna bakın. Açık yollar verilmezse komut, kanıta bağlı başlatıcı anlık görüntüsünün belirlediği seçili adayı gözlemler; güncelleme yetkisi veya çalışma zamanı seçimi doğrulanmaz. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index e328f84b62c..6573efe84dd 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -317,7 +317,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` paket kayıt defterine istek göndermez ve yapılandırmada belirtilen kurulum adayına ilişkin provenance kanıtını, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik komut Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve POSIX sistemlerinde `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Bu parça kalıcı seçim durumunu hiç okumadığından, ortam adayı yakalanmamış olan Windows çalıştırmaları `candidate_unavailable` yerine `windows_inspection_deferred` bildirir: komut bir Codex CLI'nin kurulu olup olmadığını gözlemleyemez, bu yüzden aday bulunmadığını iddia etmek yerine incelemenin ertelendiğini bildirir. Komut Codex veya bir paket yöneticisi çalıştırmaz, shim'i onarmaz, yapılandırmaya ya da önbellek durumuna yazmaz, hiçbir süreci durdurmaz ve hiçbir şey kurmaz. Uygulamayla birlikte paketlenmiş adaylar, tanınan sürüm yöneticisi yollarında bulunan adaylar, doğrulanmamış bağımsız adaylar ve belirsiz shim durumları `unmanaged` veya `unknown` olarak raporlanır; hiçbir zaman `managed` olarak sınıflandırılmaz. +`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows, aday veya yapılandırma dosyalarını okumadan incelemeyi erteler. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. Windows üzerinde `CODEX_CLI_PATH=codex` gibi yalın bir komut, uzak yol veya aygıt yolu aday olarak yakalanırsa `candidate_path_unavailable` bildirilir. Aday yakalanmıştır; ancak yolu bu inceleme için uygun değildir. diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 87731f641e7..c6e89a3b4fa 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -17,7 +17,7 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 管理命令会通过实时代理的管理 API 往返调用,使用记录下来的运行时端口和身份检查,而不是维护第二条配置路径。已停止或不可达的代理会被表示为 HTTP 503,并导致 CLI 以非零状态退出。明确标注为离线配置操作的命令,则可以在没有实时代理的情况下验证并编辑配置文件。 -`ocx system codex-cli-update check` 不需要实时代理,也不会向软件包注册表发起请求。它只会在限定范围内检查已配置候选项的来源元数据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性检查命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 POSIX 下的 `candidate_unavailable` 或 Windows 下的 `windows_inspection_deferred`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会安装或修复软件,不会运行 Codex 或 npm,不会控制正在运行的进程,也不会写入配置或缓存状态。 +`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows 不读取候选项或配置文件,而是推迟检查。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 有关 Windows x64 安装观察,请参阅 [`attest` 命令](/zh-cn/reference/cli/agents/)。未提供显式路径时,该命令观察由受证明约束的启动器快照识别出的已选候选项;它不授予更新权限,也不证明运行时选择。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index d2c231de196..67fbc13088a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -220,7 +220,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不会向软件包注册表发起请求,只会在限定范围内检查已配置候选项的来源证据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能在 POSIX 系统上报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。由于这个切片完全不读取持久化的选择状态,在未捕获任何环境候选项的 Windows 运行中会报告 `windows_inspection_deferred` 而非 `candidate_unavailable`:该命令无法观测 Codex CLI 是否已安装,因此报告检查被推迟,而不是断言不存在候选项。该命令不会运行 Codex 或软件包管理器,不会修复 shim,不会写入配置或缓存,不会停止进程,也不会安装任何内容。随应用捆绑的候选项、位于已识别版本管理器路径中的候选项、未经验证的独立候选项以及 shim 状态不明确的候选项,都会报告为 `unmanaged` 或 `unknown`,绝不会归类为 `managed`。 +`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows 不读取候选项或配置文件,而是推迟检查。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 在 Windows 上,如果捕获到 `CODEX_CLI_PATH=codex` 这样的裸命令、远程路径或设备路径,则报告 `candidate_path_unavailable`。这些情况下候选项已被捕获,但其路径不适用于此检查。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index 37f9f01d563..acfdece2371 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -28,7 +28,7 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 設定路徑。停止或無法連線的代理以 HTTP 503 呈現,並產生非零的 CLI 離開碼。明確記載為 離線設定操作的命令,可以在沒有執行中代理的情況下驗證與編輯設定檔。 -`ocx system codex-cli-update check` 不需要執行中的代理,也不會向套件 registry 發出請求。它只會在限定範圍內檢查設定中的安裝候選項來源中繼資料,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次檢查命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 POSIX 下的 `candidate_unavailable` 或 Windows 下的 `windows_inspection_deferred`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會安裝或修復軟體、不會執行 Codex 或 npm、不會控制執行中的程序,也不會寫入設定或快取狀態。 +`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows 不讀取候選項或設定檔,而是延後檢查。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 觀測 Windows x64 安裝的方式請參閱 [`attest` 命令](/zh-tw/reference/cli/agents/)。未提供明確路徑時,該命令會觀測由受證明約束的啟動器快照識別出的已選候選項;它不授予更新權限,也不證明執行階段選擇。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 92bbc9fde20..ec3201d6337 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -221,7 +221,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不會向套件 registry 發出請求,只會在限定範圍內檢查設定中的安裝候選項來源證據,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能在 POSIX 系統上報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。由於這個切片完全不會讀取持久化的選擇狀態,在未擷取任何環境候選項的 Windows 執行中會報告 `windows_inspection_deferred` 而非 `candidate_unavailable`:該命令無法觀測 Codex CLI 是否已安裝,因此會報告檢查被延後,而不是斷言候選項不存在。此命令不會執行 Codex 或套件管理工具、不會修復 shim、不會寫入設定或快取、不會停止程序,也不會安裝任何內容。隨應用程式封裝的候選項、位於已識別版本管理工具路徑中的候選項、未經驗證的獨立候選項,以及 shim 狀態不明確的候選項,都會報告為 `unmanaged` 或 `unknown`,絕不會歸類為 `managed`。 +`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows 不讀取候選項或設定檔,而是延後檢查。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 在 Windows 上,如果擷取到 `CODEX_CLI_PATH=codex` 這類單純命令名稱、遠端路徑或裝置路徑,則回報 `candidate_path_unavailable`。這些情況已有擷取的候選項,但其路徑不適用於此檢查。 From d582d99f35dbb48321c6bcafaeaf19689c5b969b Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:34:35 +0900 Subject: [PATCH 23/24] test(codex): bind update plan identity to an independent digest oracle --- .../codex-cli-update-plan.test.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index d697596b55d..f3e4c2eac9b 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -206,15 +206,10 @@ describe("Codex CLI update plan identity", () => { } }); - test("a session starting or ending between dry-run and apply does not invalidate the plan", async () => { - // Blockers are re-read at apply time where they can only refuse. Binding them into - // the id would expire a plan the operator read correctly, for a reason that cannot - // make the install wrong. - const first = await applicablePlan(); - const second = await applicablePlan({ - scanProcesses: () => ({ kind: "observed", processes: [] }), - }); - expect(second.planId).toBe(first.planId); + test("an applicable plan binds only the declared evidence, excluding session state", async () => { + // Independently computed SHA-256 vector for MANAGED + RESOLVED, schema 1. + // Binding session state (or any undeclared field) changes this fixed oracle. + expect((await applicablePlan()).planId).toBe("65ce26ec550cbcd82dda68f0c86a0b4c"); }); test("the id contains only the explicitly bound evidence", () => { @@ -227,10 +222,10 @@ describe("Codex CLI update plan identity", () => { targetVersion: "1.1.0", targetIntegrity: "sha512-AAAA", }; - expect(codexCliUpdatePlanId(bound)).toBe(codexCliUpdatePlanId(bound)); - expect(codexCliUpdatePlanId(bound)).toMatch(/^[0-9a-f]{32}$/); - // A digest of the same evidence recomputed by a different build agrees. - expect(codexCliUpdatePlanId({ ...bound })).toBe(codexCliUpdatePlanId(bound)); + const expected = "65ce26ec550cbcd82dda68f0c86a0b4c"; + expect(codexCliUpdatePlanId(bound)).toBe(expected); + const withSession = { ...bound, session: { state: "active", matches: 1 } }; + expect(codexCliUpdatePlanId(withSession)).toBe(expected); }); }); From 52e71bc34e8d5b761af72411c869ca53056164ef Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 21:01:59 +0900 Subject: [PATCH 24/24] docs(cli): state the version condition for managed: true in every locale --- docs-site/src/content/docs/fr/reference/cli.md | 2 +- docs-site/src/content/docs/fr/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ja/reference/cli.md | 2 +- docs-site/src/content/docs/ja/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ko/reference/cli.md | 2 +- docs-site/src/content/docs/ko/reference/cli/agents.md | 2 +- docs-site/src/content/docs/reference/cli.md | 2 +- docs-site/src/content/docs/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ru/reference/cli.md | 2 +- docs-site/src/content/docs/ru/reference/cli/agents.md | 2 +- docs-site/src/content/docs/tr/reference/cli.md | 2 +- docs-site/src/content/docs/tr/reference/cli/agents.md | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 511f1889106..2563cdc0de9 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -17,7 +17,7 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. -`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Pour observer une installation Windows x64, consultez [`attest`](/fr/reference/cli/agents/). Sans chemins explicites, la commande observe le candidat sélectionné identifié par l’instantané du lanceur lié à la preuve ; aucune autorité de mise à jour ni sélection du runtime n’est attestée. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 797c78c0139..3143cdac277 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -285,7 +285,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan). Sous Windows, une commande simple capturée comme `CODEX_CLI_PATH=codex`, un chemin distant ou un chemin de périphérique produit plutôt `candidate_path_unavailable`. Le candidat a été capturé, mais son chemin ne convient pas à cette inspection. diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 7bbfaf6f40c..c766a1cdfaf 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -20,7 +20,7 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 -`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認できれば `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows x64 インストールの観測は [`attest` コマンド](/ja/reference/cli/agents/)を参照してください。明示パスを省略すると、証明にバインドされたランチャースナップショットが特定した選択候補を観測します。更新権限やランタイム選択は証明しません。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index c42ce29b88f..8b1e712473d 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -216,7 +216,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認できれば `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 +`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。 Windows で `CODEX_CLI_PATH=codex` のような単純なコマンド名、リモートパス、デバイスパスが候補としてキャプチャされた場合は、`candidate_path_unavailable` を報告します。候補は取得されていますが、そのパスはこの検査の対象になりません。 diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index f9d3ed1d4c1..3abe8fbd115 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. -`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참조하세요. 명시적 경로 없이 증명에 바인딩된 런처 스냅샷이 식별한 선택 후보를 관측하며, 업데이트 권한이나 런타임 선택을 증명하지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 586330e357c..55d10456c35 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -249,7 +249,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. +`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요. Windows에서 `CODEX_CLI_PATH=codex` 같은 단순 명령 이름이나 원격 경로·장치 경로가 후보로 캡처되면 `candidate_path_unavailable`을 보고합니다. 후보는 캡처됐지만 해당 경로가 이 검사 대상에 적합하지 않은 경우입니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index d7df5da3799..22214a24ed2 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -148,7 +148,7 @@ inspects bounded provenance metadata for the configured install candidate, inclu executable location and ownership evidence. Trusted published-launcher context authenticates that candidate snapshot, not a successful Codex execution. On POSIX, `selectionAttested` can be `true` when the runtime resolver selects the same canonical environment candidate without executing it. A linked npm-global package -manifest can then establish `managed: true`; persisted candidates remain unattested. +manifest can then establish `managed: true` when it proves ownership and, if a candidate version is reported, that version matches the manifest; persisted candidates remain unattested. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 24687f1110c..970116f4d26 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1079,7 +1079,7 @@ ocx system codex-cli-update check --json including a redacted executable location and ownership evidence. Trusted published-launcher context authenticates the candidate snapshot, not successful Codex execution. On POSIX, selection is attested only when the runtime resolver selects the same canonical environment candidate without executing it. A linked npm-global manifest -can then establish `managed: true`; persisted candidates remain unattested. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, +can then establish `managed: true` when it proves ownership and, if a candidate version is reported, that version matches the manifest; persisted candidates remain unattested. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report `candidate_unavailable` on POSIX. On Windows this first slice performs no candidate or configuration filesystem I/O: diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 50154e5b8d3..da9ff0c9096 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -31,7 +31,7 @@ runtime port и проверку identity, а не поддерживая вто явно документированные как offline-операции с конфигурацией, вместо этого могут валидировать и редактировать файл конфигурации без живого прокси. -`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`; сохранённые кандидаты остаются неаттестованными. Windows откладывает проверку, не читая файлы кандидата и конфигурацию. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`, если он доказывает владение и, когда версия кандидата сообщается, она совпадает с версией манифеста; сохранённые кандидаты остаются неаттестованными. Windows откладывает проверку, не читая файлы кандидата и конфигурацию. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). Для наблюдения установки Windows x64 см. [`attest`](/ru/reference/cli/agents/). Без явных путей команда наблюдает выбранного кандидата, определённого привязанным к доказательству снимком средства запуска; полномочия на обновление и выбор runtime не подтверждаются. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 92ea5db6b22..661980decd0 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -268,7 +268,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`; сохранённые кандидаты остаются неаттестованными. Windows откладывает проверку, не читая файлы кандидата и конфигурацию. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). +`check` не обращается к реестру и не запускает Codex. В POSIX `selectionAttested` может быть `true`, если разрешитель выбирает тот же канонический путь, что и кандидат окружения, зафиксированный опубликованным средством запуска. Связанный манифест npm-global может подтвердить `managed: true`, если он доказывает владение и, когда версия кандидата сообщается, она совпадает с версией манифеста; сохранённые кандидаты остаются неаттестованными. Windows откладывает проверку, не читая файлы кандидата и конфигурацию. Прямой запуск через Bun или исходный код не даёт доказательства средства запуска. `plan` обращается к официальному реестру с изолированными временными настройками, кешем и журналами npm, отказывая при активных сессиях или неполном наблюдении процессов. Он ничего не устанавливает и не посылает сигналы. См. [полное описание команд и причин отказа (англ.)](/reference/cli/agents/#read-only-codex-cli-update-plan). В Windows захваченная команда без полного пути, например `CODEX_CLI_PATH=codex`, удалённый путь или путь устройства возвращает `candidate_path_unavailable`. Кандидат захвачен, но его путь не подходит для этой проверки. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index adee59063e5..e8bcea6d8fc 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -36,7 +36,7 @@ yönetim API'sine gidiş-dönüş yapar. Durdurulmuş veya erişilemeyen bir pro yapılandırma işlemleri olarak açıkça belgelenen komutlar, bunun yerine canlı bir proxy olmadan yapılandırma dosyasını doğrulayabilir ve düzenleyebilir. -`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows, aday veya yapılandırma dosyalarını okumadan incelemeyi erteler. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. +`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa ve aday sürüm bildirildiyse bu sürüm bildirimdeki sürümle eşleşirse `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows, aday veya yapılandırma dosyalarını okumadan incelemeyi erteler. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. Windows x64 kurulum gözlemi için [`attest`](/tr/reference/cli/agents/) komutuna bakın. Açık yollar verilmezse komut, kanıta bağlı başlatıcı anlık görüntüsünün belirlediği seçili adayı gözlemler; güncelleme yetkisi veya çalışma zamanı seçimi doğrulanmaz. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 6573efe84dd..9e4afcc7cdf 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -317,7 +317,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows, aday veya yapılandırma dosyalarını okumadan incelemeyi erteler. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. +`check` kayıt defterini sorgulamaz ve Codex’i çalıştırmaz. POSIX üzerinde çözümleyicinin seçtiği kanonik yol, yayımlanmış başlatıcının yakaladığı ortam adayıyla aynıysa `selectionAttested: true` olabilir. Bağlantılı npm-global paket bildirimi sahipliği doğrularsa ve aday sürüm bildirildiyse bu sürüm bildirimdeki sürümle eşleşirse `managed: true` olabilir; kalıcı adaylar doğrulanmaz. Windows, aday veya yapılandırma dosyalarını okumadan incelemeyi erteler. Doğrudan Bun/kaynak çalıştırması başlatıcı kanıtı sağlamaz. `plan`, yalıtılmış geçici npm yapılandırması, önbelleği ve günlükleriyle resmî kayıt defterini sorgular; etkin oturumları veya eksik süreç gözlemini reddeder. Yazılım kurmaz ve süreçlere sinyal göndermez. [Komutların ve ret nedenlerinin tam açıklamasına (İngilizce)](/reference/cli/agents/#read-only-codex-cli-update-plan) bakın. Windows üzerinde `CODEX_CLI_PATH=codex` gibi yalın bir komut, uzak yol veya aygıt yolu aday olarak yakalanırsa `candidate_path_unavailable` bildirilir. Aday yakalanmıştır; ancak yolu bu inceleme için uygun değildir.