Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@
"codex-cli-installation-targets.test.ts": "codex-integration",
"codex-cli-windows-installation-files.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-composed-acceptance.test.ts": "codex-integration",
"codex-config-generation.test.ts": "codex-integration",
Expand Down
42 changes: 40 additions & 2 deletions skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,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.
Expand Down Expand Up @@ -824,6 +843,25 @@ JSON mode: `payload`.
- `policy set` never enables implicitly: omitting `--enabled` keeps the stored value.
- `policy run` forces a run regardless of schedule, so it needs `--yes`.

### `ocx system codex-cli-update apply`

Install the exact Codex CLI version bound into a plan id from a dry-run.

Drives no management route.

| Flag | Value | Meaning |
|---|---|---|
| `--plan` | string | Required: the plan id printed by a dry-run the operator read. |
| `--json` | boolean | Emit the apply result as JSON. |

JSON mode: `envelope`.

- --plan is mandatory because the operator must approve a target they have read. The plan is recomputed from live evidence and refused unless the id still matches.
- Resolves and packs only from the pinned official npm registry with project/user npm configuration isolated, verifies the tarball sha512 against the plan-bound integrity, and installs only that verified file; never stops, restarts or signals Codex, the app-server, the desktop app or the tray.
- Holds one cross-process update lease from the final session scan through the install and readback; a concurrent apply is refused, and Codex startup paths that observe the lease wait or refuse rather than load a half-replaced install.
- The outcome is classified from a fresh inspection rather than the installer exit code, and is never retried or rolled back automatically.
- Repairs the shim only when this installation owned a matched shim before the update.

### `ocx system codex-restart`

Restart the Codex desktop app and app-servers.
Expand Down Expand Up @@ -930,6 +968,6 @@ JSON mode: `payload`.

## Counts

- declared capabilities: 50
- of those, state-changing: 25
- declared capabilities: 52
- of those, state-changing: 26
- head-resolved invocations: 2
36 changes: 36 additions & 0 deletions src/cli/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,42 @@ 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-cli-update", "apply"],
summary: "Install the exact Codex CLI version bound into a plan id from a dry-run.",
routes: [],
flags: [
{ name: "--plan", value: "string", required: true, summary: "Required: the plan id printed by a dry-run the operator read." },
{ name: "--json", value: "boolean", summary: "Emit the apply result as JSON." },
],
mutates: true,
json: "envelope",
details: [
"--plan is mandatory because the operator must approve a target they have read. The plan is recomputed from live evidence and refused unless the id still matches.",
"Resolves and packs only from the pinned official npm registry with project/user npm configuration isolated, verifies the tarball sha512 against the plan-bound integrity, and installs only that verified file; never stops, restarts or signals Codex, the app-server, the desktop app or the tray.",
"Holds one cross-process update lease from the final session scan through the install and readback; a concurrent apply is refused, and Codex startup paths that observe the lease wait or refuse rather than load a half-replaced install.",
"The outcome is classified from a fresh inspection rather than the installer exit code, and is never retried or rolled back automatically.",
"Repairs the shim only when this installation owned a matched shim before the update.",
],
},
{
command: ["system", "codex-restart"],
summary: "Restart the Codex desktop app and app-servers.",
Expand Down
210 changes: 169 additions & 41 deletions src/cli/codex-cli-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,41 @@ import type {
CodexCliInstallationSnapshot,
CodexCliInstallationTargetDerivation,
} from "../codex/cli-installation-targets";
import {
applyCodexCliUpdatePlan,
createCodexCliUpdatePlan,
type CodexCliUpdateApplyDeps,
type CodexCliUpdateApplyResult,
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 <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]`;
ocx system codex-cli-update attest --candidate <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]
ocx system codex-cli-update plan [--channel latest] [--json]
ocx system codex-cli-update apply --plan <id> [--json]`;

const PLAN_ID_RE = /^[0-9a-f]{32}$/;

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<{ action: "apply"; json: boolean; planId: string }>
| Readonly<{ json: boolean; attest: CodexCliInstallationIdentityInput | "selected" }>;

export interface CodexCliUpdateCommandDeps {
readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise<CodexCliInstallReport>;
readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise<CodexCliInstallationIdentityReport>;
readonly deriveInstallationInput?: (
snapshot: CodexCliInstallationSnapshot,
) => CodexCliInstallationTargetDerivation;
readonly createPlan?: (deps: CodexCliUpdatePlanDeps) => Promise<CodexCliUpdatePlan>;
readonly applyPlan?: (planId: string, deps: CodexCliUpdateApplyDeps) => Promise<CodexCliUpdateApplyResult>;
}

function identitySummary(report: CodexCliInstallationIdentityReport): string[] {
Expand Down Expand Up @@ -68,37 +82,84 @@ 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 — apply verifies the packed tarball and installs the verified file)`);
return lines;
}

function applySummary(result: CodexCliUpdateApplyResult): string[] {
return [
`status: ${result.status}`,
`reason: ${result.refusal ?? "none"}`,
`plan: ${result.planId ?? "unavailable"}`,
`target-version: ${result.targetVersion ?? "unavailable"}`,
`installed-before: ${result.installedVersionBefore ?? "unavailable"}`,
`installed-after: ${result.installedVersionAfter ?? "unavailable"}`,
`installer-exit: ${result.installerExitCode ?? "unavailable"}`,
];
}

/** 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<string, keyof CodexCliInstallationIdentityInput>([
["--candidate", "candidate"], ["--npm-prefix", "npmPrefix"],
["--npm-cli", "npmCli"], ["--node", "node"],
]);
const input: Partial<Record<keyof CodexCliInstallationIdentityInput, string>> = {};
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);
Expand All @@ -112,13 +173,76 @@ 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" && action !== "apply") {
throw new CliUsageError("codex-cli-update action must be check, attest, plan or apply", 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 });
}

let planId: string | null = null;
let index = 1;
while (index < rest.length) {
const token = rest[index]!;
if (!isOption(token, "plan")) {
throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE);
}
if (planId !== null) throw new CliUsageError("--plan may be specified only once", CODEX_CLI_UPDATE_USAGE);
const read = optionValue(rest, index, "plan");
if (!PLAN_ID_RE.test(read.value)) {
throw new CliUsageError("--plan must be a plan id from a dry-run", CODEX_CLI_UPDATE_USAGE);
}
planId = read.value;
index = read.next;
}
// Apply is never implicit: the operator quotes a plan id they read in a dry-run.
if (planId === null) throw new CliUsageError("apply requires --plan <id>", CODEX_CLI_UPDATE_USAGE);
return Object.freeze({ action, json, planId });
}

/**
* 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,
};
}

export async function handleCodexCliUpdateCommand(
Expand All @@ -136,7 +260,10 @@ 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.
let outcome = 0;
const code = await runCliAction(async () => {
if ("attest" in parsed) {
let report: CodexCliInstallationIdentityReport;
try {
Expand Down Expand Up @@ -167,25 +294,26 @@ 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;
}
const result = await (deps.applyPlan ?? applyCodexCliUpdatePlan)(parsed.planId, {
inspectionDeps: inspectionDeps(),
inspect: deps.inspectInstall,
});
printData(result, parsed.json, applySummary(result));
outcome = result.status === "applied" ? 0 : 1;
});
return code === 0 ? outcome : code;
}
Loading
Loading