diff --git a/docs/installing.md b/docs/installing.md index 523b308..d310c91 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -2,7 +2,7 @@ Use this guide when changing a production plugin installation in Claude Code or Codex. -Consumers need Claude Code or Codex and Git access to the repository. They do not need a user-managed Bun, Node.js, Python, npm, or setup command. First use with a missing runtime requires one approved repair; warm use works offline. +Consumers need Claude Code or Codex and Git access to the repository. They do not need a user-managed Bun, Node.js, Python, npm, or setup command. First use with a missing runtime requires one approved repair; warm use works offline. Maintainers running `bun run update -- --harness codex --target latest` also need GitHub CLI read access for Release discovery. Explicit target tags use the configured Git credential path directly. The verification recipes also use a POSIX shell, `curl`, `jq`, `awk`, and `diff`. @@ -12,9 +12,9 @@ Inspect a release before changing either client. Set `FETCH_URL` to the same Git ```sh set -eu -TAG=vX.Y.Z -FETCH_URL=https://github.com/OWNER/REPOSITORY.git -PREFLIGHT_ROOT=$(mktemp -d) +: "${TAG:=vX.Y.Z}" +: "${FETCH_URL:=https://github.com/OWNER/REPOSITORY.git}" +: "${PREFLIGHT_ROOT:=$(mktemp -d)}" git clone --filter=blob:none --no-checkout "$FETCH_URL" "$PREFLIGHT_ROOT/repository" git -C "$PREFLIGHT_ROOT/repository" fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG" REMOTE_SHA=$(git -C "$PREFLIGHT_ROOT/repository" rev-parse "refs/tags/$TAG^{commit}") @@ -24,8 +24,8 @@ test "$(git -C "$PREFLIGHT_ROOT/repository" rev-parse HEAD)" = "$REMOTE_SHA" VERSION=${TAG#v} test "$(jq -r .version "$PREFLIGHT_ROOT/repository/plugin/.claude-plugin/plugin.json")" = "$VERSION" test "$(jq -r .version "$PREFLIGHT_ROOT/repository/plugin/.codex-plugin/plugin.json")" = "$VERSION" -jq -e '.plugins[0].defaultEnabled == false' "$PREFLIGHT_ROOT/repository/.claude-plugin/marketplace.json" -jq -e '.plugins[0].policy.installation == "AVAILABLE" and .plugins[0].policy.authentication == "ON_INSTALL"' "$PREFLIGHT_ROOT/repository/.agents/plugins/marketplace.json" +jq -e '.plugins | length == 1 and .[0].defaultEnabled == false' "$PREFLIGHT_ROOT/repository/.claude-plugin/marketplace.json" +jq -e '.plugins | length == 1 and .[0].policy.installation == "AVAILABLE" and .[0].policy.authentication == "ON_INSTALL"' "$PREFLIGHT_ROOT/repository/.agents/plugins/marketplace.json" test -z "$(git -C "$PREFLIGHT_ROOT/repository" ls-tree -r "$REMOTE_SHA" plugin | awk '$1 == "120000"')" git -C "$PREFLIGHT_ROOT/repository" ls-tree -r "$REMOTE_SHA" plugin > "$PREFLIGHT_ROOT/payload-inventory.txt" ``` @@ -164,7 +164,14 @@ Official references: [build Codex plugins](https://developers.openai.com/plugins ## Upgrade and roll back -An upgrade and a rollback use the same replacement operation. Set the target to the newer tag for an upgrade or the older tag for a rollback. First capture current JSON state. Run the detached preflight above for both the target tag and the current restoration tag. Stop before uninstalling anything if either tag, commit, credential path, policy, payload, prior cache, or removal authority cannot be proved. Managed, workspace-installed, or non-removable plugins require an administrator. +An upgrade and a rollback use the same replacement operation. Set the target to the newer tag for an upgrade or the older tag for a rollback. First capture current JSON state. Run the detached preflight above twice: retain the selected target under `TARGET_PREFLIGHT_ROOT`, and retain the current restoration Release under `RESTORE_PREFLIGHT_ROOT`. Stop before uninstalling anything if either tag, commit, credential path, policy, payload, prior cache, or removal authority cannot be proved. Managed, workspace-installed, or non-removable plugins require an administrator. + +```sh +TARGET_PREFLIGHT_ROOT=$(mktemp -d) +RESTORE_PREFLIGHT_ROOT=$(mktemp -d) +# Run the complete preflight with PREFLIGHT_ROOT=$TARGET_PREFLIGHT_ROOT and TAG=$TARGET_TAG. +# Run it again with PREFLIGHT_ROOT=$RESTORE_PREFLIGHT_ROOT and TAG=$RESTORE_TAG. +``` ### Claude Code replacement @@ -183,7 +190,7 @@ claude plugin enable PLUGIN_NAME@PLUGIN_NAME --scope "$SCOPE" claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-target-active.json" ``` -Inspect the target marketplace JSON before install. After enablement, require the target version, intended scope, host-selected active cache path, and `diff -qr` equality with the target checkout. Ignore orphan cache directories that the host did not select. +Inspect the target marketplace JSON before install. After enablement, require the target version, intended scope, host-selected active cache path, and `diff -qr` equality with `$TARGET_PREFLIGHT_ROOT/repository/plugin`. Ignore orphan cache directories that the host did not select. If any step after uninstall fails, restore before doing other work: @@ -200,40 +207,34 @@ fi claude plugin list --json > "$PREFLIGHT_ROOT/claude-plugins-restored.json" ``` -Verify the restored version, scope, active cache bytes, enabled state, and persistent plugin data. Keep the persistent plugin data directory. Private background refresh uses the configured Git credential path. Set `CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1` in Claude Code's launch environment before starting the client. With it, a failed marketplace pull retains the last-known-good clone. Without it, Claude Code deletes and re-clones the marketplace after a failed pull, so prior marketplace cache retention is not guaranteed. Run `claude plugin marketplace update PLUGIN_NAME` for a manual same-source refresh, then inspect before replacement. +Verify the restored version, scope, active cache bytes against `$RESTORE_PREFLIGHT_ROOT/repository/plugin`, enabled state, and persistent plugin data. Keep the persistent plugin data directory. Private background refresh uses the configured Git credential path. Set `CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1` in Claude Code's launch environment before starting the client. With it, a failed marketplace pull retains the last-known-good clone. Without it, Claude Code deletes and re-clones the marketplace after a failed pull, so prior marketplace cache retention is not guaranteed. Run `claude plugin marketplace update PLUGIN_NAME` for a manual same-source refresh, then inspect before replacement. -### Codex replacement +### Codex production update -Capture marketplace and plugin JSON first. Record `PRIOR_CODEX_SOURCE`, `PRIOR_CODEX_REF`, and `PRIOR_ENABLED`. Preflight both the target and restoration refs before removal: +Use the repository-owned command for every production upgrade or rollback. No arguments show concise help. A normal invocation is read-only and prints the prior ref, selected Release, and next action. JSON mode provides the detailed preview contract, including captured prior state, exact side effects, and recovery plan: ```sh -codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-before.json" -codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-before.json" -codex plugin remove PLUGIN_NAME@PLUGIN_NAME --json -codex plugin marketplace remove PLUGIN_NAME --json -codex plugin marketplace add "$TARGET_CODEX_SOURCE" --ref "$TARGET_CODEX_REF" --json > "$PREFLIGHT_ROOT/codex-marketplace-target-add.json" -codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-target.json" -codex plugin add PLUGIN_NAME@PLUGIN_NAME --json > "$PREFLIGHT_ROOT/codex-plugin-target-add.json" -codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-target.json" +bun run update -- --harness codex +bun run update -- --harness codex --target vX.Y.Z ``` -Inspect the marketplace JSON and installed root before plugin add. Then inspect the plugin JSON and installed path. Require the target source/ref, version, and byte equality with the detached target checkout. +`--target latest` is the default. It selects the highest stable GitHub Release and excludes drafts and prereleases. An explicit stable `vX.Y.Z` tag keeps the run deterministic. The command resolves the selector once, peels the tag to one commit, and preflights both target and restoration Releases through the current Git transport before removal. -If any step after removal fails, restore the exact prior source and ref immediately: +Review the preview, then authorize that exact target: ```sh -codex plugin remove PLUGIN_NAME@PLUGIN_NAME --json || true -codex plugin marketplace remove PLUGIN_NAME --json || true -codex plugin marketplace add "$PRIOR_CODEX_SOURCE" --ref "$PRIOR_CODEX_REF" --json > "$PREFLIGHT_ROOT/codex-marketplace-restored-add.json" -codex plugin add PLUGIN_NAME@PLUGIN_NAME --json > "$PREFLIGHT_ROOT/codex-plugin-restored-add.json" -codex plugin marketplace list --json > "$PREFLIGHT_ROOT/codex-marketplaces-restored.json" -codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-restored.json" +bun run update -- --harness codex --target vX.Y.Z --apply +bun run update -- --harness codex --target vX.Y.Z --apply --json --no-input ``` -Verify the restored source, ref, version, cache bytes, and enabled state. Codex CLI currently has no documented plugin enable/disable subcommand; restore a differing enabled state in the Codex plugin settings and confirm it with `codex plugin list --json` before continuing. +Apply removes the prior Plugin Installation and Marketplace, adds the same source pinned to the selected tag, verifies the Marketplace checkout before installation, installs the Plugin Payload, then checks configured ref, exact tag, peeled commit, manifest version, installed path, policy, enabled state, payload bytes, and selected-Release functional proof. An already-current Release returns `changed: false` without native remove or add commands. + +The command blocks before mutation for unowned, ambiguous, sparse, disabled, non-stable, uncredentialed, unsafe, or unrestorable state. Codex CLI has no supported plugin enable/disable subcommand, so a disabled installation needs an administrator-owned replacement path. After a recoverable post-removal failure, the command attempts one exact restoration and verifies it. An unverified state returns `transactionState: "unknown"`, `retrySafety: "inspect_required"`, and never retries automatically. + +JSON mode emits one result on stdout. Diagnostics stay on stderr. The result includes run correlation, selected and prior Release evidence, resulting ref/version/path, transaction state, completed side effects, retry safety, proof lineage, and one next safe action. Preview reports `proof.status: "target_preflight"` and leaves the live Marketplace and Plugin Installation on their prior Release; only a no-op or verified apply reports `proof.status: "installed_match"`. Fresh-install qualification stays separate from in-place-update proof. -For the target install, start a fresh isolated task, confirm skill discovery, and exercise the missing-runtime repair/retry journey when the reviewed Bun identity changed. A new Bun version plus executable digest requires fresh approval; archive-only metadata changes do not change the approved runtime identity. +`codex plugin marketplace upgrade PLUGIN_NAME` refreshes the configured Git snapshot only. It does not select a newer Release. A pinned immutable tag should resolve to the same bytes. Automatic Codex marketplace refresh is unspecified; never rely on a zero-error refresh result as release-selection evidence. -`codex plugin marketplace upgrade PLUGIN_NAME` is the documented explicit CLI operation for refreshing the configured Git snapshot. A pinned immutable tag should resolve to the same bytes. Automatic Codex marketplace refresh is unspecified; never rely on it to move or restore a release. +After a successful update, start a fresh isolated task, confirm skill discovery, and exercise the missing-runtime repair/retry journey when the reviewed Bun identity changed. A new Bun version plus executable digest requires fresh approval; archive-only metadata changes do not change the approved runtime identity. -Replacement completes when the target or restored source, immutable ref, version, enabled state, installed bytes, and skill invocation all match the selected preflight checkout. +Replacement completes when the target state matches `$TARGET_PREFLIGHT_ROOT` or the restored state matches `$RESTORE_PREFLIGHT_ROOT`: source, immutable ref, version, enabled state, installed bytes, and skill invocation must agree with that retained checkout. diff --git a/package.json b/package.json index fe51500..d025370 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ ], "scripts": { "test": "bun test", + "update": "bun run scripts/update.ts", "init": "bun run scripts/init.ts", "generate": "bun run scripts/generate.ts", "generate:check": "bun run scripts/generate.ts --check", diff --git a/scripts/cli-contract.test.ts b/scripts/cli-contract.test.ts index 65d3318..b6676cd 100644 --- a/scripts/cli-contract.test.ts +++ b/scripts/cli-contract.test.ts @@ -17,8 +17,17 @@ test("template CLI discovery and rendered help expose the same public commands", init: "bun run scripts/init.ts", "release:validate": "bun run scripts/release-validate.ts", "ship:canary": "bun run scripts/ship-canary.ts", + update: "bun run scripts/update.ts", }) + const updateHelp = run(["update", "--", "--help"]) + expect(updateHelp.exitCode).toBe(0) + expect(updateHelp.stdout.toString()).toContain("Preview by default") + expect(updateHelp.stdout.toString()).toContain("--target latest|vX.Y.Z") + expect(updateHelp.stdout.toString()).toContain("--apply") + expect(updateHelp.stdout.toString()).toContain("refreshes the configured ref") + expect(updateHelp.stdout.toString()).toContain("selects a newer immutable Release") + const initHelp = run(["init", "--", "--help"]) expect(initHelp.exitCode).toBe(0) expect(initHelp.stdout.toString()).toContain("--dry-run") diff --git a/scripts/codex-production-update.ts b/scripts/codex-production-update.ts new file mode 100644 index 0000000..11c3e7f --- /dev/null +++ b/scripts/codex-production-update.ts @@ -0,0 +1,1500 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs" +import { homedir, tmpdir } from "node:os" +import { isAbsolute, join, relative, sep } from "node:path" + +import { + assertReplacementAdmission, + comparePayload, + nativeHarnessEnvironment, + regularFileInventory, + type TaggedCheckout, +} from "./prove-harness-install" +import { + assertExactHarnessRecovery, + type HarnessRecoverySnapshot, +} from "./harness-install-recovery" +import { loadPluginConfig } from "./plugin-config" +import { payloadInventorySha256, pluginPayloadInventory } from "./plugin-files" + +const STABLE_RELEASE_TAG = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/ +const FULL_COMMIT = /^[a-f0-9]{40}$/ + +type CommandPhase = + | "local_inspection" + | "remote_fetch" + | "functional_proof" + | "native_mutation" + | "recovery" + +const COMMAND_TIMEOUT_MS: Readonly> = { + local_inspection: 10_000, + remote_fetch: 60_000, + functional_proof: 120_000, + native_mutation: 60_000, + recovery: 60_000, +} + +const COMMAND_PHASE_LABEL: Readonly> = { + local_inspection: "local inspection", + remote_fetch: "remote discovery or fetch", + functional_proof: "functional proof", + native_mutation: "native mutation", + recovery: "recovery", +} + +interface CommandResult { + exitCode: number + stdout: string + stderr: string +} + +interface CodexMarketplaceList { + marketplaces: Array<{ + name: string + root: string + marketplaceSource: { sourceType: string; source: string } + }> +} + +interface CodexInstalledPlugin { + pluginId: string + name: string + marketplaceName: string + version: string + installed: boolean + enabled: boolean + source: { source: string; path: string } + marketplaceSource: { sourceType: string; source: string } + installPolicy: string + authPolicy: string +} + +interface CodexPluginList { + installed: CodexInstalledPlugin[] + available: unknown[] +} + +interface CodexMarketplaceAddResult { + marketplaceName: string + installedRoot: string + alreadyAdded: boolean +} + +interface CodexPluginAddResult { + pluginId: string + name: string + marketplaceName: string + version: string + installedPath: string + authPolicy: string +} + +interface MarketplaceInstallMetadata { + source_type: string + source: string + ref_name: string + sparse_paths: string[] + revision: string +} + +interface CurrentCodexState { + pluginName: string + pluginId: string + marketplaceName: string + source: string + ref: string + commit: string + version: string + installedPath: string + marketplaceRoot: string + enabled: boolean + installPolicy: string + authPolicy: string + payloadHash: string +} + +interface PreflightRelease extends TaggedCheckout { + payloadHash: string +} + +/** Input accepted by the one Codex production-update owner. */ +export interface CodexProductionUpdateInput { + /** Selected immutable tag or the `latest` Release selector. */ + target: string + /** Explicit mutation authority. Preview remains the default. */ + apply: boolean + /** Correlation identifier supplied by the CLI contract. */ + runId: string + /** Template repository whose plugin identity selects the installation. */ + repositoryRoot: string + /** Process environment containing the operator's existing Codex and Git lanes. */ + environment: Record +} + +/** One selected immutable Release bound to its peeled commit and manifest. */ +export interface SelectedReleaseResult { + /** Original user selector. */ + requested: string + /** Stable immutable version tag selected once for the transaction. */ + tag: string + /** Peeled commit behind either an annotated or lightweight tag. */ + commit: string + /** Manifest version proven from the detached checkout. */ + manifestVersion: string + /** SHA-256 over the admitted Plugin Payload inventory and bytes. */ + payloadHash: string +} + +/** Captured Plugin Installation state that owns recovery after mutation. */ +export interface PriorCodexUpdateResult { + /** Configured Git source with URL credentials removed. */ + source: string + /** Configured immutable Marketplace ref. */ + ref: string + /** Commit verified across config, metadata, tag, and checkout. */ + commit: string + /** Installed manifest version. */ + version: string + /** Host-owned installed Plugin Payload path. */ + installedPath: string + /** Host-reported Marketplace checkout root. */ + marketplaceRoot: string + /** Prior enablement state. */ + enabled: boolean + /** Native installation policy. */ + installPolicy: string + /** Native authentication policy. */ + authPolicy: string + /** SHA-256 over the installed payload inventory and bytes. */ + payloadHash: string +} + +/** Successful machine result for preview, no-op, or applied update. */ +export interface CodexProductionUpdateResult { + /** Contract revision for additive consumer validation. */ + schemaVersion: 1 + /** Package-owned result vocabulary. */ + contractId: "plugin.production-update" + /** Correlation identifier for one invocation. */ + runId: string + /** Discriminator for successful results. */ + ok: true + /** Whether the invocation previewed or applied the transaction. */ + mode: "preview" | "apply" + /** Only supported production-update harness. */ + harness: "codex" + /** Whether this invocation changed native state. */ + changed: boolean + /** Whether selected and prior Releases differ. */ + wouldChange: boolean + /** Stable completed state. */ + transactionState: "previewed" | "no_op" | "updated" + /** Same-input retry judgment. */ + retrySafety: "safe" + /** Selected target Release evidence. */ + selectedRelease: SelectedReleaseResult + /** Exact prior state retained for recovery. */ + prior: PriorCodexUpdateResult + /** Actual post-run state, equal to prior for preview and no-op. */ + resulting: PriorCodexUpdateResult + /** Proof lineage kept separate from fresh-install qualification. */ + proof: { + kind: "in_place_update" + status: "target_preflight" | "installed_match" + selectedRelease: string + marketplaceRelease: string + installationRelease: string + functionalProofRelease: string + lineageMatched: boolean + freshInstall: "not_run" + } + /** Bounded completed side effects. */ + sideEffects: string[] + /** One current safe continuation. */ + nextAction: string +} + +/** Structured operational failure without raw command output or credentials. */ +export class CodexProductionUpdateError extends Error { + /** Stable failure family for scripts and agents. */ + readonly category: string + /** Whether any native update state changed. */ + readonly changed: boolean + /** Terminal transaction state. */ + readonly transactionState: "blocked" | "restored" | "unknown" + /** Same-input retry judgment. */ + readonly retrySafety: "safe" | "unsafe" | "inspect_required" + /** Bounded completed side effects. */ + readonly sideEffects: string[] + /** One current safe continuation. */ + readonly nextAction: string + /** Internal command phase whose bounded execution expired. */ + readonly timedOutPhase?: CommandPhase + + /** + * Create one redacted production-update failure. + * + * @param category - Stable machine failure family + * @param message - Safe human summary + * @param options - Transaction state and continuation evidence + * + * @example + * ```ts + * throw new CodexProductionUpdateError("current_state", "Plugin Installation missing") + * ``` + */ + constructor( + category: string, + message: string, + options: { + changed?: boolean + transactionState?: "blocked" | "restored" | "unknown" + retrySafety?: "safe" | "unsafe" | "inspect_required" + sideEffects?: string[] + nextAction?: string + timedOutPhase?: CommandPhase + } = {}, + ) { + super(message) + this.name = "CodexProductionUpdateError" + this.category = category + this.changed = options.changed ?? false + this.transactionState = options.transactionState ?? "blocked" + this.retrySafety = options.retrySafety ?? "safe" + this.sideEffects = options.sideEffects ?? [] + this.nextAction = options.nextAction ?? "Inspect the current Codex Plugin Installation." + this.timedOutPhase = options.timedOutPhase + } +} + +function command( + commandArguments: string[], + options: { + cwd: string + environment: Record + category: string + label: string + phase: CommandPhase + }, +): CommandResult { + const timeout = COMMAND_TIMEOUT_MS[options.phase] + const result = Bun.spawnSync({ + cmd: commandArguments, + cwd: options.cwd, + env: options.environment, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout, + }) + const output = { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + } + if (result.exitedDueToTimeout) { + const diagnostic = + options.phase === "recovery" + ? "exact restoration remains unverified" + : options.phase === "native_mutation" + ? "recovery must verify the exact prior Release before retry" + : "the command stopped before its phase completed" + throw new CodexProductionUpdateError( + options.category, + `${options.label} timed out during ${COMMAND_PHASE_LABEL[options.phase]} after ${timeout / 1_000} seconds; ${diagnostic}`, + { + timedOutPhase: options.phase, + retrySafety: options.phase === "recovery" ? "inspect_required" : "safe", + nextAction: + options.phase === "recovery" + ? "Inspect the same Codex Marketplace and Plugin Installation before retrying." + : undefined, + }, + ) + } + if (output.exitCode !== 0) { + throw new CodexProductionUpdateError( + options.category, + `${options.label} failed without changing the active Plugin Installation`, + ) + } + return output +} + +function jsonCommand( + commandArguments: string[], + options: Parameters[1], +): T { + const result = command(commandArguments, options) + try { + return JSON.parse(result.stdout) as T + } catch { + throw new CodexProductionUpdateError( + options.category, + `${options.label} returned unreadable JSON`, + ) + } +} + +function objectValue(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new CodexProductionUpdateError("current_state", `${label} is missing or unreadable`) + } + return value as Record +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new CodexProductionUpdateError("current_state", `${label} is missing or unreadable`) + } + return value +} + +function safeSource(source: string): string { + if (!/^https?:\/\//.test(source)) return source + try { + const parsed = new URL(source) + parsed.username = "" + parsed.password = "" + return parsed.toString() + } catch { + return "[redacted invalid Git URL]" + } +} + +function canonicalPath(path: string): string { + return realpathSync(path) +} + +function pathOwnedBy(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate) + return ( + relativePath.length > 0 && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath) + ) +} + +function readJson(path: string, category: string, label: string): T { + try { + return JSON.parse(readFileSync(path, "utf8")) as T + } catch { + throw new CodexProductionUpdateError(category, `${label} is missing or unreadable`) + } +} + +function assertPayloadMatches( + release: PreflightRelease, + installedPath: string, + category: string, +): string { + try { + const installedInventory = comparePayload(release, installedPath) + return payloadInventorySha256(installedPath, installedInventory) + } catch { + throw new CodexProductionUpdateError( + category, + "installed Plugin Payload inventory or bytes differ from the selected Release", + ) + } +} + +function inspectCurrentState( + repositoryRoot: string, + environment: Record, + commandPhase: CommandPhase = "local_inspection", +): CurrentCodexState { + const codexExecutable = Bun.which("codex") + if (!codexExecutable) { + throw new CodexProductionUpdateError("current_state", "Codex CLI is not available") + } + const pluginName = loadPluginConfig(repositoryRoot).name + const pluginId = `${pluginName}@${pluginName}` + const marketplaceList = jsonCommand( + [codexExecutable, "plugin", "marketplace", "list", "--json"], + { + cwd: repositoryRoot, + environment, + category: "current_state", + label: "Codex Marketplace inspection", + phase: commandPhase, + }, + ) + const pluginList = jsonCommand( + [codexExecutable, "plugin", "list", "--json"], + { + cwd: repositoryRoot, + environment, + category: "current_state", + label: "Codex Plugin Installation inspection", + phase: commandPhase, + }, + ) + const marketplace = marketplaceList.marketplaces.find((entry) => entry.name === pluginName) + const plugin = pluginList.installed.find((entry) => entry.pluginId === pluginId) + if (!marketplace || !plugin) { + throw new CodexProductionUpdateError( + "current_state", + "The configured Codex Marketplace or Plugin Installation is missing", + ) + } + if (!plugin.installed || plugin.marketplaceName !== pluginName || plugin.name !== pluginName) { + throw new CodexProductionUpdateError("current_state", "Codex reported ambiguous plugin identity") + } + const codeHome = environment.CODEX_HOME ?? join(homedir(), ".codex") + const ownedCodeHome = canonicalPath(codeHome) + const marketplaceRoot = canonicalPath(marketplace.root) + if (!pathOwnedBy(ownedCodeHome, marketplaceRoot)) { + throw new CodexProductionUpdateError( + "mutation_blocked", + "Marketplace state is outside the user-owned Codex home and cannot be replaced locally", + { nextAction: "Ask the workspace or managed-environment administrator to replace it." }, + ) + } + const configPath = join(codeHome, "config.toml") + let configDocument: Record + try { + configDocument = objectValue(Bun.TOML.parse(readFileSync(configPath, "utf8")), "Codex config") + } catch (error) { + if (error instanceof CodexProductionUpdateError) throw error + throw new CodexProductionUpdateError("current_state", "Codex config is missing or unreadable") + } + const marketplaceConfigs = objectValue(configDocument.marketplaces, "Codex Marketplace config") + const pluginConfigs = objectValue(configDocument.plugins, "Codex Plugin config") + const marketplaceConfig = objectValue( + marketplaceConfigs[pluginName], + "owned Codex Marketplace config", + ) + const pluginConfig = objectValue(pluginConfigs[pluginId], "owned Codex Plugin config") + const metadataPath = join(marketplace.root, ".codex-marketplace-install.json") + const metadata = existsSync(metadataPath) + ? readJson( + metadataPath, + "current_state", + "Codex Marketplace install metadata", + ) + : undefined + const source = stringValue(marketplaceConfig.source, "configured Marketplace source") + const ref = stringValue(marketplaceConfig.ref, "configured Marketplace ref") + const configuredCommit = + typeof marketplaceConfig.last_revision === "string" + ? marketplaceConfig.last_revision + : undefined + if (!STABLE_RELEASE_TAG.test(ref)) { + throw new CodexProductionUpdateError( + "current_state", + "The active Marketplace is not pinned to an immutable stable Release tag", + ) + } + if ( + marketplace.marketplaceSource.sourceType !== "git" || + marketplaceConfig.source_type !== "git" || + (metadata !== undefined && metadata.source_type !== "git") + ) { + throw new CodexProductionUpdateError( + "current_state", + "Only user-owned Git Marketplace installations can be updated", + ) + } + if ( + marketplace.marketplaceSource.source !== source || + plugin.marketplaceSource.source !== source || + (metadata !== undefined && metadata.source !== source) + ) { + throw new CodexProductionUpdateError("current_state", "Marketplace source evidence disagrees") + } + if (metadata !== undefined && metadata.ref_name !== ref) { + throw new CodexProductionUpdateError("current_state", "Marketplace ref evidence disagrees") + } + if ( + metadata !== undefined && + (!Array.isArray(metadata.sparse_paths) || metadata.sparse_paths.length > 0) + ) { + throw new CodexProductionUpdateError( + "current_state", + "Sparse Marketplace installations need administrator replacement", + ) + } + const checkoutCommit = command(["git", "rev-parse", "HEAD"], { + cwd: marketplace.root, + environment, + category: "current_state", + label: "Marketplace checkout revision inspection", + phase: commandPhase, + }).stdout.trim() + const peeledTagCommit = command(["git", "rev-parse", `refs/tags/${ref}^{commit}`], { + cwd: marketplace.root, + environment, + category: "current_state", + label: "Marketplace tag inspection", + phase: commandPhase, + }).stdout.trim() + const capturedCommit = configuredCommit ?? metadata?.revision ?? checkoutCommit + if ( + !FULL_COMMIT.test(capturedCommit) || + (configuredCommit !== undefined && configuredCommit !== checkoutCommit) || + (metadata !== undefined && metadata.revision !== checkoutCommit) || + checkoutCommit !== peeledTagCommit + ) { + throw new CodexProductionUpdateError("current_state", "Marketplace commit evidence disagrees") + } + if (pluginConfig.enabled !== plugin.enabled) { + throw new CodexProductionUpdateError("current_state", "Plugin enabled-state evidence disagrees") + } + if (!plugin.enabled) { + throw new CodexProductionUpdateError( + "mutation_blocked", + "Codex cannot restore the disabled state through a supported CLI surface", + { nextAction: "Use an administrator-owned replacement path that preserves disabled state." }, + ) + } + if (plugin.installPolicy !== "AVAILABLE" || plugin.authPolicy !== "ON_INSTALL") { + throw new CodexProductionUpdateError( + "current_state", + "Plugin Installation policy does not permit the supported replacement path", + ) + } + if (canonicalPath(plugin.source.path) !== canonicalPath(join(marketplace.root, "plugin"))) { + throw new CodexProductionUpdateError("current_state", "Plugin source path is ambiguous") + } + const installedPath = join(codeHome, "plugins", "cache", pluginName, pluginName, plugin.version) + if (!existsSync(installedPath)) { + throw new CodexProductionUpdateError("current_state", "Installed Plugin Payload path is missing") + } + const installedManifest = readJson<{ version?: unknown }>( + join(installedPath, ".codex-plugin", "plugin.json"), + "current_state", + "installed Codex manifest", + ) + if (installedManifest.version !== plugin.version) { + throw new CodexProductionUpdateError("current_state", "Installed manifest version disagrees") + } + const installedRoot = canonicalPath(installedPath) + if (!pathOwnedBy(ownedCodeHome, installedRoot)) { + throw new CodexProductionUpdateError( + "mutation_blocked", + "Plugin Installation state is outside the user-owned Codex home and cannot be replaced locally", + { nextAction: "Ask the workspace or managed-environment administrator to replace it." }, + ) + } + let payloadInventory: string[] + try { + payloadInventory = regularFileInventory(installedRoot) + } catch { + throw new CodexProductionUpdateError( + "current_state", + "Installed Plugin Payload inventory is unsafe or unreadable", + ) + } + return { + pluginName, + pluginId, + marketplaceName: pluginName, + source, + ref, + commit: capturedCommit, + version: plugin.version, + installedPath: installedRoot, + marketplaceRoot, + enabled: plugin.enabled, + installPolicy: plugin.installPolicy, + authPolicy: plugin.authPolicy, + payloadHash: payloadInventorySha256(installedRoot, payloadInventory), + } +} + +function preflightRelease( + source: string, + tag: string, + temporaryRoot: string, + environment: Record, + pluginName: string, + category: "release_preflight" | "restoration_preflight", +): PreflightRelease { + if (!STABLE_RELEASE_TAG.test(tag)) { + throw new CodexProductionUpdateError(category, "Target must be an immutable stable vX.Y.Z tag") + } + const checkoutRoot = join(temporaryRoot, category) + command(["git", "init", "--quiet", checkoutRoot], { + cwd: temporaryRoot, + environment, + category, + label: "Detached Release checkout initialization", + phase: "local_inspection", + }) + command(["git", "remote", "add", "origin", source], { + cwd: checkoutRoot, + environment, + category, + label: "Release Git source configuration", + phase: "local_inspection", + }) + command(["git", "fetch", "--quiet", "--no-tags", "origin", `refs/tags/${tag}:refs/tags/${tag}`], { + cwd: checkoutRoot, + environment, + category, + label: "Immutable Release fetch", + phase: "remote_fetch", + }) + const resolvedSha = command(["git", "rev-parse", `refs/tags/${tag}^{commit}`], { + cwd: checkoutRoot, + environment, + category, + label: "Release tag peeling", + phase: "local_inspection", + }).stdout.trim() + if (!FULL_COMMIT.test(resolvedSha)) { + throw new CodexProductionUpdateError(category, "Release tag did not peel to one commit") + } + command(["git", "-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", resolvedSha], { + cwd: checkoutRoot, + environment, + category, + label: "Detached Release checkout", + phase: "local_inspection", + }) + const codexManifest = readJson<{ name?: unknown; version?: unknown }>( + join(checkoutRoot, "plugin", ".codex-plugin", "plugin.json"), + category, + "Codex Release manifest", + ) + const claudeManifest = readJson<{ name?: unknown; version?: unknown; defaultEnabled?: unknown }>( + join(checkoutRoot, "plugin", ".claude-plugin", "plugin.json"), + category, + "Claude Release manifest", + ) + if ( + codexManifest.name !== pluginName || + claudeManifest.name !== pluginName || + typeof codexManifest.version !== "string" || + codexManifest.version !== claudeManifest.version || + tag !== `v${codexManifest.version}` || + claudeManifest.defaultEnabled !== false + ) { + throw new CodexProductionUpdateError(category, "Release tag and manifest identity disagree") + } + const marketplace = readJson<{ + name?: unknown + plugins?: Array<{ + name?: unknown + source?: { source?: unknown; path?: unknown } + policy?: { installation?: unknown; authentication?: unknown } + }> + }>( + join(checkoutRoot, ".agents", "plugins", "marketplace.json"), + category, + "Codex Marketplace policy", + ) + const marketplacePlugin = marketplace.plugins?.[0] + if ( + marketplace.name !== pluginName || + marketplacePlugin?.name !== pluginName || + marketplacePlugin.source?.source !== "local" || + marketplacePlugin.source.path !== "./plugin" || + marketplacePlugin.policy?.installation !== "AVAILABLE" || + marketplacePlugin.policy.authentication !== "ON_INSTALL" + ) { + throw new CodexProductionUpdateError(category, "Release Marketplace policy is not admissible") + } + let inventory: string[] + try { + inventory = pluginPayloadInventory(checkoutRoot) + } catch (error) { + if (error instanceof CodexProductionUpdateError) throw error + throw new CodexProductionUpdateError( + category, + "Release Plugin Payload inventory failed safety admission", + ) + } + return { + requestedRef: tag, + resolvedSha, + checkoutRoot, + manifestVersion: codexManifest.version, + inventory, + payloadHash: payloadInventorySha256(join(checkoutRoot, "plugin"), inventory), + } +} + +function assertTagStillBound( + source: string, + release: PreflightRelease, + temporaryRoot: string, + environment: Record, +): void { + const verificationRoot = join(temporaryRoot, "tag-binding") + command(["git", "init", "--quiet", verificationRoot], { + cwd: temporaryRoot, + environment, + category: "release_preflight", + label: "Release binding verification initialization", + phase: "local_inspection", + }) + command(["git", "remote", "add", "origin", source], { + cwd: verificationRoot, + environment, + category: "release_preflight", + label: "Release binding source configuration", + phase: "local_inspection", + }) + command( + ["git", "fetch", "--quiet", "--no-tags", "origin", `refs/tags/${release.requestedRef}:refs/tags/${release.requestedRef}`], + { + cwd: verificationRoot, + environment, + category: "release_preflight", + label: "Release binding verification fetch", + phase: "remote_fetch", + }, + ) + const currentCommit = command( + ["git", "rev-parse", `refs/tags/${release.requestedRef}^{commit}`], + { + cwd: verificationRoot, + environment, + category: "release_preflight", + label: "Release binding verification peel", + phase: "local_inspection", + }, + ).stdout.trim() + if (currentCommit !== release.resolvedSha) { + throw new CodexProductionUpdateError( + "release_preflight", + "Selected Release tag moved between discovery and preflight", + ) + } +} + +function runSelectedReleaseFunctionalProof( + release: PreflightRelease, + environment: Record, +): void { + const proofPath = join(release.checkoutRoot, "runtime", "src", "portable-command.test.ts") + if (!existsSync(proofPath)) { + throw new CodexProductionUpdateError( + "release_lineage", + "Selected Release does not contain its focused functional proof", + ) + } + const manifest = readJson<{ version?: unknown }>( + join(release.checkoutRoot, "plugin", ".codex-plugin", "plugin.json"), + "release_lineage", + "selected Release proof manifest", + ) + if ( + manifest.version !== release.manifestVersion || + release.requestedRef !== `v${release.manifestVersion}` + ) { + throw new CodexProductionUpdateError( + "release_lineage", + "Functional proof Release differs from the selected Marketplace Release", + ) + } + command([process.execPath, "test", "runtime/src/portable-command.test.ts"], { + cwd: release.checkoutRoot, + environment: { + ...nativeHarnessEnvironment(environment), + CI: "1", + NO_COLOR: "1", + }, + category: "release_lineage", + label: "Selected Release functional proof", + phase: "functional_proof", + }) +} + +function runMatchedInstalledFunctionalProof( + release: PreflightRelease, + installed: CurrentCodexState, + environment: Record, +): void { + if ( + installed.ref !== release.requestedRef || + installed.commit !== release.resolvedSha || + installed.version !== release.manifestVersion || + installed.payloadHash !== release.payloadHash + ) { + throw new CodexProductionUpdateError( + "release_lineage", + "Selected Marketplace, Plugin Installation, and functional proof Releases differ", + ) + } + runSelectedReleaseFunctionalProof(release, environment) +} + +function selectedReleaseResult(requested: string, release: PreflightRelease): SelectedReleaseResult { + return { + requested, + tag: release.requestedRef, + commit: release.resolvedSha, + manifestVersion: release.manifestVersion, + payloadHash: release.payloadHash, + } +} + +function priorResult(current: CurrentCodexState): PriorCodexUpdateResult { + return { + source: safeSource(current.source), + ref: current.ref, + commit: current.commit, + version: current.version, + installedPath: current.installedPath, + marketplaceRoot: current.marketplaceRoot, + enabled: current.enabled, + installPolicy: current.installPolicy, + authPolicy: current.authPolicy, + payloadHash: current.payloadHash, + } +} + +function recoverySnapshot(current: CurrentCodexState): HarnessRecoverySnapshot { + return { + source: current.source, + ref: current.ref, + commit: current.commit, + version: current.version, + installedPath: current.installedPath, + marketplaceRoot: current.marketplaceRoot, + payloadInventory: regularFileInventory(current.installedPath), + payloadHash: current.payloadHash, + enabled: current.enabled, + installPolicy: current.installPolicy, + authPolicy: current.authPolicy, + } +} + +function sameCapturedState(left: CurrentCodexState, right: CurrentCodexState): boolean { + return ( + left.pluginId === right.pluginId && + left.source === right.source && + left.ref === right.ref && + left.commit === right.commit && + left.version === right.version && + left.installedPath === right.installedPath && + left.marketplaceRoot === right.marketplaceRoot && + left.enabled === right.enabled && + left.installPolicy === right.installPolicy && + left.authPolicy === right.authPolicy && + left.payloadHash === right.payloadHash + ) +} + +function nativeJson( + codexExecutable: string, + arguments_: string[], + repositoryRoot: string, + environment: Record, + label: string, + phase: "native_mutation" | "recovery" = "native_mutation", +): T { + return jsonCommand([codexExecutable, ...arguments_], { + cwd: repositoryRoot, + environment, + category: phase === "recovery" ? "recovery" : "native_mutation", + label, + phase, + }) +} + +function bestEffortNativeJson( + codexExecutable: string, + arguments_: string[], + repositoryRoot: string, + environment: Record, +): void { + try { + command([codexExecutable, ...arguments_], { + cwd: repositoryRoot, + environment, + category: "recovery", + label: "Recovery cleanup", + phase: "recovery", + }) + } catch { + // Cleanup is opportunistic. Exact restoration and verification below remain authoritative. + } +} + +function verifyReleaseState( + state: CurrentCodexState, + release: PreflightRelease, + expectedSource: string, + expectedEnabled: boolean, + installedPath: string, +): void { + if ( + state.source !== expectedSource || + state.ref !== release.requestedRef || + state.commit !== release.resolvedSha || + state.version !== release.manifestVersion || + state.enabled !== expectedEnabled || + state.installPolicy !== "AVAILABLE" || + state.authPolicy !== "ON_INSTALL" || + state.installedPath !== canonicalPath(installedPath) + ) { + throw new CodexProductionUpdateError( + "postcondition", + "Codex post-install identity or policy differs from the selected Release", + ) + } + const installedHash = assertPayloadMatches(release, state.installedPath, "postcondition") + if (installedHash !== release.payloadHash || state.payloadHash !== release.payloadHash) { + throw new CodexProductionUpdateError( + "postcondition", + "Installed Plugin Payload bytes differ from the selected Release", + ) + } +} + +function verifySelectedMarketplace( + codexExecutable: string, + current: CurrentCodexState, + target: PreflightRelease, + addResult: CodexMarketplaceAddResult, + repositoryRoot: string, + environment: Record, +): void { + const marketplaceList = nativeJson( + codexExecutable, + ["plugin", "marketplace", "list", "--json"], + repositoryRoot, + environment, + "Target Marketplace inspection", + ) + const marketplace = marketplaceList.marketplaces.find( + (entry) => entry.name === current.marketplaceName, + ) + if ( + !marketplace || + addResult.marketplaceName !== current.marketplaceName || + marketplace.marketplaceSource.sourceType !== "git" || + marketplace.marketplaceSource.source !== current.source || + canonicalPath(marketplace.root) !== canonicalPath(addResult.installedRoot) + ) { + throw new CodexProductionUpdateError( + "postcondition", + "Target Marketplace identity or source differs before Plugin Installation", + ) + } + const codeHome = environment.CODEX_HOME ?? join(homedir(), ".codex") + let configDocument: Record + try { + configDocument = objectValue( + Bun.TOML.parse(readFileSync(join(codeHome, "config.toml"), "utf8")), + "Codex config", + ) + } catch (error) { + if (error instanceof CodexProductionUpdateError) throw error + throw new CodexProductionUpdateError("postcondition", "Target Codex config is unreadable") + } + const marketplaceConfigs = objectValue(configDocument.marketplaces, "Codex Marketplace config") + const marketplaceConfig = objectValue( + marketplaceConfigs[current.marketplaceName], + "target Marketplace config", + ) + if ( + marketplaceConfig.source !== current.source || + marketplaceConfig.ref !== target.requestedRef + ) { + throw new CodexProductionUpdateError( + "postcondition", + "Configured Marketplace source or ref differs from the selected Release", + ) + } + const checkoutCommit = command(["git", "rev-parse", "HEAD"], { + cwd: marketplace.root, + environment, + category: "postcondition", + label: "Target Marketplace checkout inspection", + phase: "local_inspection", + }).stdout.trim() + const tagCommit = command( + ["git", "rev-parse", `refs/tags/${target.requestedRef}^{commit}`], + { + cwd: marketplace.root, + environment, + category: "postcondition", + label: "Target Marketplace tag inspection", + phase: "local_inspection", + }, + ).stdout.trim() + const exactTag = command(["git", "describe", "--tags", "--exact-match", "HEAD"], { + cwd: marketplace.root, + environment, + category: "postcondition", + label: "Target Marketplace exact-tag inspection", + phase: "local_inspection", + }).stdout.trim() + if ( + checkoutCommit !== target.resolvedSha || + tagCommit !== target.resolvedSha || + exactTag !== target.requestedRef + ) { + throw new CodexProductionUpdateError( + "postcondition", + "Marketplace checkout tag or commit differs from the selected Release", + ) + } +} + +function restorePriorRelease( + current: CurrentCodexState, + restoration: PreflightRelease, + repositoryRoot: string, + environment: Record, +): CurrentCodexState { + const codexExecutable = Bun.which("codex") + if (!codexExecutable) { + throw new CodexProductionUpdateError("recovery", "Codex CLI disappeared during recovery") + } + bestEffortNativeJson( + codexExecutable, + ["plugin", "remove", current.pluginId, "--json"], + repositoryRoot, + environment, + ) + bestEffortNativeJson( + codexExecutable, + ["plugin", "marketplace", "remove", current.marketplaceName, "--json"], + repositoryRoot, + environment, + ) + nativeJson( + codexExecutable, + ["plugin", "marketplace", "add", current.source, "--ref", current.ref, "--json"], + repositoryRoot, + environment, + "Prior Marketplace restoration", + "recovery", + ) + const addResult = nativeJson( + codexExecutable, + ["plugin", "add", current.pluginId, "--json"], + repositoryRoot, + environment, + "Prior Plugin Installation restoration", + "recovery", + ) + const restored = inspectCurrentState(repositoryRoot, environment, "recovery") + verifyReleaseState(restored, restoration, current.source, current.enabled, addResult.installedPath) + try { + assertExactHarnessRecovery(recoverySnapshot(current), recoverySnapshot(restored), "Codex") + } catch { + throw new CodexProductionUpdateError( + "recovery", + "Restored Plugin Installation differs from the captured prior state", + ) + } + return restored +} + +function githubRepositoryFromSource(source: string): string | undefined { + const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/.exec( + source, + ) + if (ssh) return `${ssh[1]}/${ssh[2]}` + try { + const parsed = new URL(source) + if (parsed.hostname !== "github.com") return undefined + const segments = parsed.pathname.replace(/^\//, "").replace(/\.git$/, "").split("/") + if (segments.length !== 2 || segments.some((segment) => !/^[A-Za-z0-9_.-]+$/.test(segment))) { + return undefined + } + return `${segments[0]}/${segments[1]}` + } catch { + return undefined + } +} + +function compareStableTags(left: string, right: string): number { + const leftParts = left.slice(1).split(".").map(Number) + const rightParts = right.slice(1).split(".").map(Number) + for (let index = 0; index < 3; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0) + if (difference !== 0) return difference + } + return 0 +} + +function resolveLatestStableRelease( + source: string, + repositoryRoot: string, + environment: Record, +): string { + const repository = githubRepositoryFromSource(source) + if (!repository) { + throw new CodexProductionUpdateError( + "release_selection", + "Latest selection requires a GitHub Marketplace source", + { nextAction: "Retry with one explicit immutable vX.Y.Z tag." }, + ) + } + const ghExecutable = Bun.which("gh") + if (!ghExecutable) { + throw new CodexProductionUpdateError( + "release_selection", + "Latest selection requires authenticated or public GitHub Release discovery", + { nextAction: "Configure GitHub CLI read access or retry with an explicit immutable tag." }, + ) + } + const response = jsonCommand( + [ + ghExecutable, + "api", + "--paginate", + "--slurp", + `repos/${repository}/releases?per_page=100`, + ], + { + cwd: repositoryRoot, + environment, + category: "release_selection", + label: "GitHub Release discovery", + phase: "remote_fetch", + }, + ) + const pages = Array.isArray(response) ? response : [] + const releases = pages.flatMap((page) => (Array.isArray(page) ? page : [page])) + const stableTags = releases.flatMap((release) => { + if (typeof release !== "object" || release === null || Array.isArray(release)) return [] + const candidate = release as Record + return candidate.draft === false && + candidate.prerelease === false && + typeof candidate.tag_name === "string" && + STABLE_RELEASE_TAG.test(candidate.tag_name) + ? [candidate.tag_name] + : [] + }) + stableTags.sort(compareStableTags).reverse() + const selected = stableTags[0] + if (!selected) { + throw new CodexProductionUpdateError( + "release_selection", + "GitHub reported no stable immutable Release", + { nextAction: "Publish a stable vX.Y.Z GitHub Release, then rerun preview." }, + ) + } + return selected +} + +/** + * Preview or apply one release-bound Codex Marketplace replacement. + * + * @param input - Correlated selector, authority, repository, and process environment + * @returns Stable machine result after independent release and installation checks + * @throws {CodexProductionUpdateError} When any identity, policy, byte, or transaction check fails + * + * @example + * ```ts + * const result = runCodexProductionUpdate({ + * target: "v1.2.3", + * apply: false, + * runId: crypto.randomUUID(), + * repositoryRoot: process.cwd(), + * environment: process.env, + * }) + * ``` + */ +export function runCodexProductionUpdate( + input: CodexProductionUpdateInput, +): CodexProductionUpdateResult { + const current = inspectCurrentState(input.repositoryRoot, input.environment) + const targetTag = + input.target === "latest" + ? resolveLatestStableRelease(current.source, input.repositoryRoot, input.environment) + : input.target + const completedSideEffects = ["read Codex Marketplace and Plugin Installation state"] + const temporaryRoot = mkdtempSync(join(tmpdir(), "plugin-production-update-")) + try { + const target = preflightRelease( + current.source, + targetTag, + temporaryRoot, + input.environment, + current.pluginName, + "release_preflight", + ) + completedSideEffects.push( + "fetch and admit the target Release in a temporary detached checkout", + ) + const restoration = preflightRelease( + current.source, + current.ref, + temporaryRoot, + input.environment, + current.pluginName, + "restoration_preflight", + ) + completedSideEffects.push( + "fetch and admit the restoration Release in a temporary detached checkout", + ) + assertReplacementAdmission({ + target, + restoration, + allowedRefs: [target.requestedRef, restoration.requestedRef], + managed: false, + removable: true, + }) + const installedPayloadHash = assertPayloadMatches( + restoration, + current.installedPath, + "restoration_preflight", + ) + if (installedPayloadHash !== current.payloadHash) { + throw new CodexProductionUpdateError( + "restoration_preflight", + "Captured installed Plugin Payload changed during preflight", + ) + } + runSelectedReleaseFunctionalProof(target, input.environment) + completedSideEffects.push("run functional proof from the selected Release checkout") + assertTagStillBound(current.source, target, temporaryRoot, input.environment) + const wouldChange = current.ref !== target.requestedRef || current.commit !== target.resolvedSha + if (input.apply && !wouldChange) { + return { + schemaVersion: 1, + contractId: "plugin.production-update", + runId: input.runId, + ok: true, + mode: "apply", + harness: "codex", + changed: false, + wouldChange: false, + transactionState: "no_op", + retrySafety: "safe", + selectedRelease: selectedReleaseResult(input.target, target), + prior: priorResult(current), + resulting: priorResult(current), + proof: { + kind: "in_place_update", + status: "installed_match", + selectedRelease: target.requestedRef, + marketplaceRelease: current.ref, + installationRelease: `v${current.version}`, + functionalProofRelease: target.requestedRef, + lineageMatched: true, + freshInstall: "not_run", + }, + sideEffects: [...completedSideEffects], + nextAction: "No update is required; the configured Release is already selected.", + } + } + if (input.apply) { + const revalidated = inspectCurrentState(input.repositoryRoot, input.environment) + if (!sameCapturedState(current, revalidated)) { + throw new CodexProductionUpdateError( + "mutation_blocked", + "Codex state changed after preview preflight; rerun preview against current state", + ) + } + const codexExecutable = Bun.which("codex") + if (!codexExecutable) { + throw new CodexProductionUpdateError( + "mutation_blocked", + "Codex CLI disappeared before mutation", + ) + } + const mutationSideEffects = [...completedSideEffects] + try { + nativeJson( + codexExecutable, + ["plugin", "remove", current.pluginId, "--json"], + input.repositoryRoot, + input.environment, + "Prior Plugin Installation removal", + ) + mutationSideEffects.push("removed the prior Plugin Installation") + nativeJson( + codexExecutable, + ["plugin", "marketplace", "remove", current.marketplaceName, "--json"], + input.repositoryRoot, + input.environment, + "Prior Marketplace removal", + ) + mutationSideEffects.push("removed the prior Marketplace") + const marketplaceAdd = nativeJson( + codexExecutable, + [ + "plugin", + "marketplace", + "add", + current.source, + "--ref", + target.requestedRef, + "--json", + ], + input.repositoryRoot, + input.environment, + "Target Marketplace add", + ) + mutationSideEffects.push("added the Marketplace pinned to the selected Release") + verifySelectedMarketplace( + codexExecutable, + current, + target, + marketplaceAdd, + input.repositoryRoot, + input.environment, + ) + mutationSideEffects.push("verified the selected Marketplace before installation") + const pluginAdd = nativeJson( + codexExecutable, + ["plugin", "add", current.pluginId, "--json"], + input.repositoryRoot, + input.environment, + "Target Plugin Installation add", + ) + if ( + pluginAdd.pluginId !== current.pluginId || + pluginAdd.marketplaceName !== current.marketplaceName || + pluginAdd.version !== target.manifestVersion + ) { + throw new CodexProductionUpdateError( + "postcondition", + "Codex add result differs from the selected Release", + ) + } + mutationSideEffects.push("installed the selected Plugin Payload") + const resulting = inspectCurrentState(input.repositoryRoot, input.environment) + verifyReleaseState( + resulting, + target, + current.source, + current.enabled, + pluginAdd.installedPath, + ) + mutationSideEffects.push( + "verified Release lineage, policy, path, and payload bytes", + ) + runMatchedInstalledFunctionalProof(target, resulting, input.environment) + mutationSideEffects.push( + "ran matched in-place functional proof from the selected Release checkout", + ) + return { + schemaVersion: 1, + contractId: "plugin.production-update", + runId: input.runId, + ok: true, + mode: "apply", + harness: "codex", + changed: true, + wouldChange: true, + transactionState: "updated", + retrySafety: "safe", + selectedRelease: selectedReleaseResult(input.target, target), + prior: priorResult(current), + resulting: priorResult(resulting), + proof: { + kind: "in_place_update", + status: "installed_match", + selectedRelease: target.requestedRef, + marketplaceRelease: resulting.ref, + installationRelease: `v${resulting.version}`, + functionalProofRelease: target.requestedRef, + lineageMatched: true, + freshInstall: "not_run", + }, + sideEffects: mutationSideEffects, + nextAction: "Start a fresh Codex task and exercise the selected Plugin Release.", + } + } catch (mutationError) { + try { + restorePriorRelease( + current, + restoration, + input.repositoryRoot, + input.environment, + ) + throw new CodexProductionUpdateError( + "mutation_failed_restored", + mutationError instanceof CodexProductionUpdateError && + mutationError.timedOutPhase === "native_mutation" + ? "Target update timed out during native mutation; the exact prior Release was restored and verified" + : "Target update failed; the exact prior Release was restored and verified", + { + changed: true, + transactionState: "restored", + retrySafety: "safe", + sideEffects: [ + ...mutationSideEffects, + "restored and verified the exact prior Release", + ], + nextAction: `Rerun the preview for ${target.requestedRef} before another apply.`, + }, + ) + } catch (recoveryError) { + if ( + recoveryError instanceof CodexProductionUpdateError && + recoveryError.category === "mutation_failed_restored" + ) { + throw recoveryError + } + throw new CodexProductionUpdateError( + "mutation_state_unknown", + recoveryError instanceof CodexProductionUpdateError && + recoveryError.timedOutPhase === "recovery" + ? "Recovery timed out; target update and exact restoration could not be verified; automatic retry stopped" + : "Target update and exact restoration could not be verified; automatic retry stopped", + { + changed: true, + transactionState: "unknown", + retrySafety: "inspect_required", + sideEffects: mutationSideEffects, + nextAction: "Inspect the same Codex Marketplace and Plugin Installation before retrying.", + }, + ) + } + } + } + return { + schemaVersion: 1, + contractId: "plugin.production-update", + runId: input.runId, + ok: true, + mode: "preview", + harness: "codex", + changed: false, + wouldChange, + transactionState: "previewed", + retrySafety: "safe", + selectedRelease: selectedReleaseResult(input.target, target), + prior: priorResult(current), + resulting: priorResult(current), + proof: { + kind: "in_place_update", + status: wouldChange ? "target_preflight" : "installed_match", + selectedRelease: target.requestedRef, + marketplaceRelease: current.ref, + installationRelease: `v${current.version}`, + functionalProofRelease: target.requestedRef, + lineageMatched: !wouldChange, + freshInstall: "not_run", + }, + sideEffects: [...completedSideEffects], + nextAction: wouldChange + ? `bun run update -- --harness codex --target ${target.requestedRef} --apply` + : "No update is required; the configured Release is already selected.", + } + } catch (error) { + if (error instanceof CodexProductionUpdateError && error.sideEffects.length === 0) { + throw new CodexProductionUpdateError(error.category, error.message, { + changed: error.changed, + transactionState: error.transactionState, + retrySafety: error.retrySafety, + sideEffects: [...completedSideEffects], + nextAction: error.nextAction, + timedOutPhase: error.timedOutPhase, + }) + } + throw error + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +} diff --git a/scripts/harness-install-recovery.test.ts b/scripts/harness-install-recovery.test.ts index 1672383..7e5218c 100644 --- a/scripts/harness-install-recovery.test.ts +++ b/scripts/harness-install-recovery.test.ts @@ -14,6 +14,12 @@ const prior: HarnessRecoverySnapshot = { enabled: false, scope: "project", persistentData: "project marker\n", + commit: "a".repeat(40), + installedPath: "/host/cache/plugin/0.1.0", + marketplaceRoot: "/host/marketplaces/plugin", + installPolicy: "AVAILABLE", + authPolicy: "ON_INSTALL", + payloadHash: "b".repeat(64), } test("post-mutation failure enters the recovery handler and returns its restored value", () => { @@ -56,6 +62,12 @@ test.each([ ["enabled", true], ["scope", "user"], ["persistentData", "changed\n"], + ["commit", "c".repeat(40)], + ["installedPath", "/wrong/cache"], + ["marketplaceRoot", "/wrong/marketplace"], + ["installPolicy", "REQUIRED"], + ["authPolicy", "ON_USE"], + ["payloadHash", "d".repeat(64)], ] as const)("exact recovery rejects mismatched %s", (field, value) => { expect(() => provePostMutationRecovery(prior, { diff --git a/scripts/harness-install-recovery.ts b/scripts/harness-install-recovery.ts index 9085cb6..31bafa3 100644 --- a/scripts/harness-install-recovery.ts +++ b/scripts/harness-install-recovery.ts @@ -27,6 +27,18 @@ export interface HarnessRecoverySnapshot { scope?: "user" | "project" | "local" /** Exact persistent marker contents when the harness owns persistent data. */ persistentData?: string + /** Peeled immutable commit when the harness exposes Git lineage. */ + commit?: string + /** Host-selected installed payload path when reported. */ + installedPath?: string + /** Host-selected Marketplace checkout path when reported. */ + marketplaceRoot?: string + /** Native installation policy when reported. */ + installPolicy?: string + /** Native authentication policy when reported. */ + authPolicy?: string + /** Hash of the complete installed payload inventory and bytes when available. */ + payloadHash?: string } /** @@ -89,6 +101,12 @@ export function assertExactHarnessRecovery( "enabled", "scope", "persistentData", + "commit", + "installedPath", + "marketplaceRoot", + "installPolicy", + "authPolicy", + "payloadHash", ] as const) { if (restored[field] !== prior[field]) { throw new Error(`${harness} recovery did not restore prior ${field}`) diff --git a/scripts/prove-harness-install.ts b/scripts/prove-harness-install.ts index 495a830..25a7d9f 100644 --- a/scripts/prove-harness-install.ts +++ b/scripts/prove-harness-install.ts @@ -536,7 +536,8 @@ function jsonCommand( } } -function regularFiles(root: string): string[] { +/** List one tree as sorted, regular-file-only relative paths. */ +export function regularFileInventory(root: string): string[] { const inventory: string[] = [] function walk(directory: string): void { for (const entry of readdirSync(directory).sort()) { @@ -573,7 +574,7 @@ function snapshotFixture(repositoryRoot: string, requestedRef: string, message: const indexPath = join(repositoryRoot, `.fixture-index-${requestedRef}`) const environment = gitEnvironment(indexPath) rmSync(indexPath, { force: true }) - for (const relativePath of regularFiles(repositoryRoot).filter((path) => path !== ".git")) { + for (const relativePath of regularFileInventory(repositoryRoot).filter((path) => path !== ".git")) { if (relativePath.startsWith(".git/")) continue const absolutePath = join(repositoryRoot, relativePath) const blob = command(["git", "hash-object", "-w", "--", absolutePath], { @@ -712,8 +713,9 @@ function createFixtureRelease(sourceRoot: string, temporaryRoot: string): Fixtur } } -function comparePayload(checkout: TaggedCheckout, installedPath: string): string[] { - const installedInventory = regularFiles(installedPath) +/** Compare one installed payload byte-for-byte with its detached tagged checkout. */ +export function comparePayload(checkout: TaggedCheckout, installedPath: string): string[] { + const installedInventory = regularFileInventory(installedPath) if (installedInventory.join("\n") !== checkout.inventory.join("\n")) { throw new Error("installed payload inventory differs from tagged plugin inventory") } @@ -1328,7 +1330,7 @@ export function assertReplacementAdmission( */ export function runtimeClosureEvidence( pluginRoot: string, - inventory: string[] = regularFiles(pluginRoot), + inventory: string[] = regularFileInventory(pluginRoot), ): { version: string inventoryHash: string @@ -1384,7 +1386,7 @@ export function proveInstalledCapabilityEvidence( if (!fixtureSource.equals(fixtureProjection)) { throw new Error(`${client} installed lifecycle mechanics proof fixture differs`) } - const installedInventory = regularFiles(pluginRoot) + const installedInventory = regularFileInventory(pluginRoot) const installed = runtimeClosureEvidence(pluginRoot, installedInventory) if (installed.payloadHash !== candidatePayloadHash) { throw new Error(`${client} installed payload hash differs from the candidate payload`) diff --git a/scripts/readme-release-pin.test.ts b/scripts/readme-release-pin.test.ts index e214f3a..f70f4b7 100644 --- a/scripts/readme-release-pin.test.ts +++ b/scripts/readme-release-pin.test.ts @@ -74,6 +74,8 @@ test("replacement guidance preserves the documented refresh operations", async ( const installing = await Bun.file(installingUrl).text() expect(installing).toContain("claude plugin marketplace update PLUGIN_NAME") expect(installing).toContain("codex plugin marketplace upgrade PLUGIN_NAME") + expect(installing).toContain("bun run update -- --harness codex") + expect(installing).toContain("It does not select a newer Release") expect(installing).toContain("Automatic Codex marketplace refresh is unspecified") expect(installing).toContain("A pinned immutable tag should resolve to the same bytes") expect(installing).toContain("`CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1`") diff --git a/scripts/update-hosted.test.ts b/scripts/update-hosted.test.ts new file mode 100644 index 0000000..596aeec --- /dev/null +++ b/scripts/update-hosted.test.ts @@ -0,0 +1,147 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { expect, test } from "bun:test" + +import { loadPluginConfig } from "./plugin-config" + +const root = resolve(import.meta.dir, "..") +const hostedTest = process.env.RUN_HOSTED_CODEX_UPDATE === "1" ? test : test.skip + +function jsonCommand( + arguments_: string[], + cwd: string, + environment: Record, +): T { + const codexExecutable = Bun.which("codex") + if (!codexExecutable) throw new Error("hosted update proof requires the native Codex CLI") + const result = Bun.spawnSync({ + cmd: [codexExecutable, ...arguments_], + cwd, + env: environment, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + }) + if (result.exitCode !== 0) throw new Error("hosted native Codex command failed") + return JSON.parse(result.stdout.toString()) as T +} + +function publicUpdate( + arguments_: string[], + environment: Record, +): { exitCode: number; stdout: string; stderr: string } { + const result = Bun.spawnSync({ + cmd: [process.execPath, "run", "update", "--", ...arguments_], + cwd: root, + env: environment, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }) + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + } +} + +hostedTest.each([1, 2])( + "hosted immutable-tag update run %d selects v0.1.1 from a fresh v0.1.0 installation", + (runNumber) => { + const pluginConfig = loadPluginConfig(root) + const source = + process.env.CODEX_UPDATE_HOSTED_SOURCE ?? `${pluginConfig.repository.replace(/\.git$/, "")}.git` + const fromTag = process.env.CODEX_UPDATE_HOSTED_FROM ?? "v0.1.0" + const targetTag = process.env.CODEX_UPDATE_HOSTED_TARGET ?? "v0.1.1" + const temporaryRoot = mkdtempSync(join(tmpdir(), `hosted-codex-update-${runNumber}-`)) + const codeHome = join(temporaryRoot, "codex") + const project = join(temporaryRoot, "project") + mkdirSync(codeHome) + mkdirSync(project) + const environment = { + ...process.env, + CODEX_HOME: codeHome, + CI: "1", + NO_COLOR: "1", + } + try { + jsonCommand( + ["plugin", "marketplace", "add", source, "--ref", fromTag, "--json"], + project, + environment, + ) + const freshInstall = jsonCommand<{ version: string; installedPath: string }>( + ["plugin", "add", `${pluginConfig.name}@${pluginConfig.name}`, "--json"], + project, + environment, + ) + expect(freshInstall.version).toBe(fromTag.slice(1)) + + const previewResult = publicUpdate( + ["--harness", "codex", "--target", targetTag, "--json", "--no-input"], + environment, + ) + expect(previewResult.exitCode, previewResult.stderr).toBe(0) + const preview = JSON.parse(previewResult.stdout) + expect(preview).toMatchObject({ + ok: true, + mode: "preview", + changed: false, + wouldChange: true, + prior: { ref: fromTag, version: fromTag.slice(1) }, + selectedRelease: { tag: targetTag, manifestVersion: targetTag.slice(1) }, + proof: { + status: "target_preflight", + marketplaceRelease: fromTag, + installationRelease: fromTag, + functionalProofRelease: targetTag, + lineageMatched: false, + }, + }) + + const updateResult = publicUpdate( + [ + "--harness", + "codex", + "--target", + targetTag, + "--apply", + "--json", + "--no-input", + ], + environment, + ) + expect(updateResult.exitCode, updateResult.stderr).toBe(0) + const update = JSON.parse(updateResult.stdout) + expect(update).toMatchObject({ + ok: true, + mode: "apply", + changed: true, + transactionState: "updated", + prior: { ref: fromTag, version: fromTag.slice(1) }, + selectedRelease: { tag: targetTag, manifestVersion: targetTag.slice(1) }, + resulting: { ref: targetTag, version: targetTag.slice(1) }, + proof: { + kind: "in_place_update", + status: "installed_match", + freshInstall: "not_run", + lineageMatched: true, + }, + }) + + const installed = jsonCommand<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + project, + environment, + ) + expect(installed.installed[0]?.version).toBe(targetTag.slice(1)) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } + }, + 240_000, +) diff --git a/scripts/update.test.ts b/scripts/update.test.ts new file mode 100644 index 0000000..3149d65 --- /dev/null +++ b/scripts/update.test.ts @@ -0,0 +1,941 @@ +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" + +import { afterEach, expect, setDefaultTimeout, test } from "bun:test" + +const root = new URL("..", import.meta.url).pathname +const temporaryRoots: string[] = [] +const hermeticBunPath = `${dirname(process.execPath)}:/usr/bin:/bin` +const helperProcessTimeoutMs = 30_000 +const updateProcessTimeoutMs = 90_000 +const updateTestTimeoutMs = 120_000 +const nativeCodexTest = Bun.which("codex") ? test : test.skip + +setDefaultTimeout(updateTestTimeoutMs) + +afterEach(() => { + for (const temporaryRoot of temporaryRoots.splice(0)) { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +function run(arguments_: string[], environment: Record = {}): ReturnType { + return Bun.spawnSync({ + cmd: [process.execPath, "run", "update", "--", ...arguments_], + cwd: root, + env: { ...process.env, ...environment }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: updateProcessTimeoutMs, + }) +} + +function git(arguments_: string[], cwd: string): string { + const result = Bun.spawnSync({ + cmd: ["git", ...arguments_], + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: helperProcessTimeoutMs, + }) + if (result.exitCode !== 0) throw new Error(result.stderr.toString()) + return result.stdout.toString().trim() +} + +function writeRelease(repositoryRoot: string, version: string, runtimeMarker: string): void { + cpSync(join(root, "plugin"), join(repositoryRoot, "plugin"), { recursive: true, force: true }) + cpSync(join(root, ".agents"), join(repositoryRoot, ".agents"), { recursive: true, force: true }) + cpSync(join(root, ".claude-plugin"), join(repositoryRoot, ".claude-plugin"), { + recursive: true, + force: true, + }) + cpSync(join(root, "runtime"), join(repositoryRoot, "runtime"), { recursive: true, force: true }) + cpSync(join(root, "plugin.config.json"), join(repositoryRoot, "plugin.config.json"), { + force: true, + }) + const pluginConfig = JSON.parse(readFileSync(join(repositoryRoot, "plugin.config.json"), "utf8")) + pluginConfig.version = version + writeFileSync( + join(repositoryRoot, "plugin.config.json"), + `${JSON.stringify(pluginConfig, null, 2)}\n`, + ) + const manifest = JSON.parse( + readFileSync(join(repositoryRoot, "plugin", ".codex-plugin", "plugin.json"), "utf8"), + ) + manifest.version = version + writeFileSync( + join(repositoryRoot, "plugin", ".codex-plugin", "plugin.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + const claudeManifest = JSON.parse( + readFileSync(join(repositoryRoot, "plugin", ".claude-plugin", "plugin.json"), "utf8"), + ) + claudeManifest.version = version + writeFileSync( + join(repositoryRoot, "plugin", ".claude-plugin", "plugin.json"), + `${JSON.stringify(claudeManifest, null, 2)}\n`, + ) + writeFileSync(join(repositoryRoot, "plugin", "runtime", "hello-world.js"), runtimeMarker) + const claudeMarketplace = JSON.parse( + readFileSync(join(repositoryRoot, ".claude-plugin", "marketplace.json"), "utf8"), + ) + claudeMarketplace.plugins[0].version = version + writeFileSync( + join(repositoryRoot, ".claude-plugin", "marketplace.json"), + `${JSON.stringify(claudeMarketplace, null, 2)}\n`, + ) + const proofPath = join(repositoryRoot, "runtime", "src", "portable-command.test.ts") + writeFileSync( + proofPath, + `${readFileSync(proofPath, "utf8")}\ntest("functional proof stays bound to Release ${version}", async () => {\n\tconst releaseConfig = await Bun.file(new URL("../../plugin.config.json", import.meta.url)).json()\n\texpect(releaseConfig.version).toBe(${JSON.stringify(version)})\n})\n`, + ) +} + +function createReleaseRepository(temporaryRoot: string): { + priorCommit: string + repositoryRoot: string + targetCommit: string +} { + const repositoryRoot = join(temporaryRoot, "repository") + mkdirSync(repositoryRoot) + git(["init", "--quiet"], repositoryRoot) + git(["config", "user.name", "Update Test"], repositoryRoot) + git(["config", "user.email", "update-test@example.invalid"], repositoryRoot) + writeRelease(repositoryRoot, "0.1.0", "old runtime\n") + git(["add", "."], repositoryRoot) + git(["commit", "--quiet", "-m", "release 0.1.0"], repositoryRoot) + git(["tag", "-a", "v0.1.0", "-m", "v0.1.0"], repositoryRoot) + writeRelease(repositoryRoot, "0.1.1", "new runtime\n") + git(["add", "."], repositoryRoot) + git(["commit", "--quiet", "-m", "release 0.1.1"], repositoryRoot) + git(["tag", "v0.1.1"], repositoryRoot) + return { + priorCommit: git(["rev-parse", "refs/tags/v0.1.0^{commit}"], repositoryRoot), + repositoryRoot, + targetCommit: git(["rev-parse", "refs/tags/v0.1.1^{commit}"], repositoryRoot), + } +} + +function checkoutRelease(repositoryRoot: string, tag: string, destination: string): void { + git(["clone", "--quiet", repositoryRoot, destination], join(destination, "..")) + git(["checkout", "--quiet", "--detach", tag], destination) +} + +function releaseProof(checkoutRoot: string): ReturnType { + return Bun.spawnSync({ + cmd: [process.execPath, "test", "runtime/src/portable-command.test.ts"], + cwd: checkoutRoot, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: helperProcessTimeoutMs, + }) +} + +function updateFixture(): { + codeHome: string + installedPath: string + marketplaceRoot: string + mutationMarker: string + path: string + priorCommit: string + repositoryRoot: string + statePath: string + targetCommit: string +} { + const temporaryRoot = mkdtempSync(join(tmpdir(), "production-update-test-")) + temporaryRoots.push(temporaryRoot) + const { priorCommit, repositoryRoot, targetCommit } = createReleaseRepository(temporaryRoot) + + const codeHome = join(temporaryRoot, "codex") + const marketplaceRoot = join(codeHome, ".tmp", "marketplaces", "harness-native-plugin-prototype") + mkdirSync(join(marketplaceRoot, ".."), { recursive: true }) + git(["clone", "--quiet", repositoryRoot, marketplaceRoot], temporaryRoot) + git(["checkout", "--quiet", "--detach", priorCommit], marketplaceRoot) + writeFileSync( + join(marketplaceRoot, ".codex-marketplace-install.json"), + `${JSON.stringify( + { + source_type: "git", + source: repositoryRoot, + ref_name: "v0.1.0", + sparse_paths: [], + revision: priorCommit, + }, + null, + 2, + )}\n`, + ) + const installedPath = join( + codeHome, + "plugins", + "cache", + "harness-native-plugin-prototype", + "harness-native-plugin-prototype", + "0.1.0", + ) + cpSync(join(marketplaceRoot, "plugin"), installedPath, { recursive: true }) + writeFileSync( + join(codeHome, "config.toml"), + `[marketplaces.harness-native-plugin-prototype]\nlast_revision = "${priorCommit}"\nsource_type = "git"\nsource = ${JSON.stringify(repositoryRoot)}\nref = "v0.1.0"\n\n[plugins."harness-native-plugin-prototype@harness-native-plugin-prototype"]\nenabled = true\n`, + ) + + const statePath = join(temporaryRoot, "codex-state.json") + writeFileSync( + statePath, + JSON.stringify({ + marketplaces: { + marketplaces: [ + { + name: "harness-native-plugin-prototype", + root: marketplaceRoot, + marketplaceSource: { sourceType: "git", source: repositoryRoot }, + }, + ], + }, + plugins: { + installed: [ + { + pluginId: "harness-native-plugin-prototype@harness-native-plugin-prototype", + name: "harness-native-plugin-prototype", + marketplaceName: "harness-native-plugin-prototype", + version: "0.1.0", + installed: true, + enabled: true, + source: { source: "local", path: join(marketplaceRoot, "plugin") }, + marketplaceSource: { sourceType: "git", source: repositoryRoot }, + installPolicy: "AVAILABLE", + authPolicy: "ON_INSTALL", + }, + ], + available: [], + }, + }), + ) + const binRoot = join(temporaryRoot, "bin") + mkdirSync(binRoot) + const codexExecutable = join(binRoot, "codex") + writeFileSync( + codexExecutable, + `#!/usr/bin/env bun\nconst state = await Bun.file(process.env.UPDATE_TEST_STATE).json()\nconst command = process.argv.slice(2).join(" ")\nif (command === "plugin marketplace list --json") console.log(JSON.stringify(state.marketplaces))\nelse if (command === "plugin list --json") console.log(JSON.stringify(state.plugins))\nelse { await Bun.write(process.env.UPDATE_TEST_MUTATION_MARKER, command); console.error("unexpected mutation: " + command); process.exit(99) }\n`, + ) + chmodSync(codexExecutable, 0o755) + return { + codeHome, + installedPath: realpathSync(installedPath), + marketplaceRoot, + mutationMarker: join(temporaryRoot, "mutation-marker"), + path: `${binRoot}:${process.env.PATH ?? "/usr/bin:/bin"}`, + priorCommit, + repositoryRoot, + statePath, + targetCommit, + } +} + +function nativeCodexJson( + arguments_: string[], + cwd: string, + environment: Record, +): T { + const codexExecutable = Bun.which("codex") + if (!codexExecutable) throw new Error("native Codex CLI is required for update tests") + const result = Bun.spawnSync({ + cmd: [codexExecutable, ...arguments_], + cwd, + env: { ...process.env, ...environment }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: helperProcessTimeoutMs, + }) + if (result.exitCode !== 0) throw new Error(result.stderr.toString()) + return JSON.parse(result.stdout.toString()) as T +} + +function nativeUpdateFixture(): { + codeHome: string + environment: Record + marketplaceRoot: string + project: string + repositoryRoot: string + source: string + targetCommit: string +} { + const temporaryRoot = mkdtempSync(join(tmpdir(), "native-production-update-test-")) + temporaryRoots.push(temporaryRoot) + const { repositoryRoot, targetCommit } = createReleaseRepository(temporaryRoot) + const codeHome = join(temporaryRoot, "codex") + const project = join(temporaryRoot, "project") + mkdirSync(codeHome) + mkdirSync(project) + const source = "https://github.com/update-fixture/agent-plugin-template.git" + const environment = { + CODEX_HOME: codeHome, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.file://${repositoryRoot}.insteadOf`, + GIT_CONFIG_VALUE_0: source, + PATH: process.env.PATH ?? "/usr/bin:/bin", + } + nativeCodexJson( + ["plugin", "marketplace", "add", source, "--ref", "v0.1.0", "--json"], + project, + environment, + ) + nativeCodexJson( + ["plugin", "add", "harness-native-plugin-prototype@harness-native-plugin-prototype", "--json"], + project, + environment, + ) + const marketplaceList = nativeCodexJson<{ + marketplaces: Array<{ root: string }> + }>(["plugin", "marketplace", "list", "--json"], project, environment) + const marketplaceRoot = marketplaceList.marketplaces[0]?.root + if (!marketplaceRoot) throw new Error("native Codex fixture did not report a Marketplace root") + return { codeHome, environment, marketplaceRoot, project, repositoryRoot, source, targetCommit } +} + +function faultingCodexEnvironment( + environment: Record, + phase: + | "after_plugin_remove" + | "after_marketplace_remove" + | "after_marketplace_add" + | "after_plugin_add" + | "stale_marketplace_add", + failRecovery = false, +): { environment: Record; logPath: string } { + const temporaryRoot = mkdtempSync(join(tmpdir(), "faulting-codex-update-test-")) + temporaryRoots.push(temporaryRoot) + const realCodex = Bun.which("codex") + if (!realCodex) throw new Error("native Codex CLI is required for update tests") + const markerPath = join(temporaryRoot, "injected") + const logPath = join(temporaryRoot, "commands.log") + const wrapperPath = join(temporaryRoot, "codex") + writeFileSync( + wrapperPath, + `#!/usr/bin/env bun\nimport { appendFileSync, existsSync } from "node:fs"\nconst args = process.argv.slice(2)\nconst command = args.join(" ")\nappendFileSync(process.env.UPDATE_TEST_COMMAND_LOG, command + "\\n")\nconst phase = process.env.UPDATE_TEST_FAIL_PHASE\nconst injected = existsSync(process.env.UPDATE_TEST_FAIL_MARKER)\nconst matches = (phase === "after_plugin_remove" && command.startsWith("plugin remove ")) || (phase === "after_marketplace_remove" && command.startsWith("plugin marketplace remove ")) || (phase === "after_marketplace_add" && command.startsWith("plugin marketplace add ") && args.includes("v0.1.1")) || (phase === "after_plugin_add" && command.startsWith("plugin add "))\nif (injected && process.env.UPDATE_TEST_FAIL_RECOVERY === "1" && command.startsWith("plugin marketplace add ") && args.includes("v0.1.0")) { console.error("injected recovery failure"); process.exit(75) }\nlet delegatedArgs = args\nif (!injected && phase === "stale_marketplace_add" && command.startsWith("plugin marketplace add ") && args.includes("v0.1.1")) { delegatedArgs = args.map((value) => value === "v0.1.1" ? "v0.1.0" : value); await Bun.write(process.env.UPDATE_TEST_FAIL_MARKER, phase) }\nconst result = Bun.spawnSync({ cmd: [process.env.UPDATE_TEST_REAL_CODEX, ...delegatedArgs], env: process.env, stdin: "ignore", stdout: "pipe", stderr: "pipe", timeout: 30_000 })\nif (!injected && matches && result.exitCode === 0) { await Bun.write(process.env.UPDATE_TEST_FAIL_MARKER, phase); console.error("injected failure " + phase); process.exit(74) }\nprocess.stdout.write(result.stdout)\nprocess.stderr.write(result.stderr)\nprocess.exit(result.exitCode)\n`, + ) + chmodSync(wrapperPath, 0o755) + return { + environment: { + ...environment, + PATH: `${temporaryRoot}:${environment.PATH}`, + UPDATE_TEST_COMMAND_LOG: logPath, + UPDATE_TEST_FAIL_MARKER: markerPath, + UPDATE_TEST_FAIL_PHASE: phase, + UPDATE_TEST_FAIL_RECOVERY: failRecovery ? "1" : "0", + UPDATE_TEST_REAL_CODEX: realCodex, + }, + logPath, + } +} + +function releaseApiEnvironment( + environment: Record, + releases: Array<{ tag_name: string; draft: boolean; prerelease: boolean }>, +): Record { + const temporaryRoot = mkdtempSync(join(tmpdir(), "release-api-update-test-")) + temporaryRoots.push(temporaryRoot) + const ghExecutable = join(temporaryRoot, "gh") + writeFileSync( + ghExecutable, + `#!/usr/bin/env bun\nconst releases = JSON.parse(process.env.UPDATE_TEST_RELEASES)\nconsole.log(JSON.stringify([releases]))\n`, + ) + chmodSync(ghExecutable, 0o755) + return { + ...environment, + PATH: `${temporaryRoot}:${environment.PATH}`, + UPDATE_TEST_RELEASES: JSON.stringify(releases), + } +} + +function movingTagEnvironment( + environment: Record, + repositoryRoot: string, +): Record { + const temporaryRoot = mkdtempSync(join(tmpdir(), "moving-tag-update-test-")) + temporaryRoots.push(temporaryRoot) + const realGit = Bun.which("git") + if (!realGit) throw new Error("Git is required for moving-tag update tests") + const wrapperPath = join(temporaryRoot, "git") + writeFileSync( + wrapperPath, + `#!/usr/bin/env bun\nimport { existsSync, readFileSync, writeFileSync } from "node:fs"\nconst args = process.argv.slice(2)\nlet fetchCount = existsSync(process.env.UPDATE_TEST_GIT_COUNT) ? Number(readFileSync(process.env.UPDATE_TEST_GIT_COUNT, "utf8")) : 0\nif (args[0] === "fetch") { fetchCount += 1; writeFileSync(process.env.UPDATE_TEST_GIT_COUNT, String(fetchCount)); if (fetchCount === 3) { const moved = Bun.spawnSync({ cmd: [process.env.UPDATE_TEST_REAL_GIT, "-C", process.env.UPDATE_TEST_MOVING_REPO, "tag", "--force", "v0.1.1", "v0.1.0^{commit}"], stdin: "ignore", stdout: "pipe", stderr: "pipe" }); if (moved.exitCode !== 0) process.exit(moved.exitCode) } }\nconst result = Bun.spawnSync({ cmd: [process.env.UPDATE_TEST_REAL_GIT, ...args], env: process.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" })\nprocess.stdout.write(result.stdout)\nprocess.stderr.write(result.stderr)\nprocess.exit(result.exitCode)\n`, + ) + chmodSync(wrapperPath, 0o755) + return { + ...environment, + PATH: `${temporaryRoot}:${environment.PATH}`, + UPDATE_TEST_GIT_COUNT: join(temporaryRoot, "fetch-count"), + UPDATE_TEST_MOVING_REPO: repositoryRoot, + UPDATE_TEST_REAL_GIT: realGit, + } +} + +test("public update command shows concise preview-first help with no arguments", () => { + const result = run([], { PATH: hermeticBunPath }) + + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toContain("bun run update -- --harness codex") + expect(result.stdout.toString()).toContain("Preview by default") + expect(result.stdout.toString()).toContain("--apply") + expect(result.stdout.toString()).toContain("refreshes the configured ref") + expect(result.stdout.toString()).toContain("selects a newer immutable Release") +}) + +test("JSON usage failures stay machine-readable and make retry safety explicit", () => { + const result = run(["--unknown", "--json"], { PATH: hermeticBunPath }) + + expect(result.exitCode).toBe(2) + const failure = JSON.parse(result.stdout.toString()) + expect(failure).toMatchObject({ + schemaVersion: 1, + contractId: "plugin.production-update", + ok: false, + category: "usage", + changed: false, + transactionState: "blocked", + retrySafety: "safe", + sideEffects: [], + nextAction: "bun run update -- --help", + }) + expect(failure.runId).toBeString() + expect(result.stderr.toString()).toContain("unknown option") +}) + +test("conflicting target selectors fail before state discovery", () => { + const result = run( + ["--harness", "codex", "--target", "v0.1.0", "--target", "v0.1.1", "--json"], + { PATH: hermeticBunPath }, + ) + + expect(result.exitCode).toBe(2) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "usage", + changed: false, + transactionState: "blocked", + }) + expect(result.stderr.toString()).toContain("--target may be provided once") +}) + +test("matched Release proofs pass while old tests reject the new Release checkout", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "release-lineage-update-test-")) + temporaryRoots.push(temporaryRoot) + const { repositoryRoot } = createReleaseRepository(temporaryRoot) + const baseCheckout = join(temporaryRoot, "base-checkout") + const targetCheckout = join(temporaryRoot, "target-checkout") + checkoutRelease(repositoryRoot, "v0.1.0", baseCheckout) + checkoutRelease(repositoryRoot, "v0.1.1", targetCheckout) + + expect(releaseProof(baseCheckout).exitCode).toBe(0) + expect(releaseProof(targetCheckout).exitCode).toBe(0) + writeFileSync( + join(targetCheckout, "runtime", "src", "portable-command.test.ts"), + readFileSync(join(baseCheckout, "runtime", "src", "portable-command.test.ts")), + ) + expect(releaseProof(targetCheckout).exitCode).toBe(1) +}) + +test("explicit-target preview binds target and restoration releases without mutation", () => { + const fixture = updateFixture() + const result = run(["--harness", "codex", "--target", "v0.1.1", "--json", "--no-input"], { + CODEX_HOME: fixture.codeHome, + PATH: fixture.path, + UPDATE_TEST_MUTATION_MARKER: fixture.mutationMarker, + UPDATE_TEST_STATE: fixture.statePath, + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const preview = JSON.parse(result.stdout.toString()) + expect(preview).toMatchObject({ + schemaVersion: 1, + contractId: "plugin.production-update", + ok: true, + mode: "preview", + harness: "codex", + changed: false, + wouldChange: true, + transactionState: "previewed", + retrySafety: "safe", + selectedRelease: { + requested: "v0.1.1", + tag: "v0.1.1", + commit: fixture.targetCommit, + manifestVersion: "0.1.1", + }, + prior: { + ref: "v0.1.0", + version: "0.1.0", + installedPath: fixture.installedPath, + enabled: true, + }, + proof: { + kind: "in_place_update", + status: "target_preflight", + selectedRelease: "v0.1.1", + marketplaceRelease: "v0.1.0", + installationRelease: "v0.1.0", + functionalProofRelease: "v0.1.1", + lineageMatched: false, + freshInstall: "not_run", + }, + }) + expect(preview.sideEffects).toEqual([ + "read Codex Marketplace and Plugin Installation state", + "fetch and admit the target Release in a temporary detached checkout", + "fetch and admit the restoration Release in a temporary detached checkout", + "run functional proof from the selected Release checkout", + ]) + expect(existsSync(fixture.mutationMarker)).toBe(false) +}) + +test("apply is a successful no-op when the selected immutable Release is current", () => { + const fixture = updateFixture() + const result = run( + ["--harness", "codex", "--target", "v0.1.0", "--apply", "--json", "--no-input"], + { + CODEX_HOME: fixture.codeHome, + PATH: fixture.path, + UPDATE_TEST_MUTATION_MARKER: fixture.mutationMarker, + UPDATE_TEST_STATE: fixture.statePath, + }, + ) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const noOp = JSON.parse(result.stdout.toString()) + expect(noOp).toMatchObject({ + ok: true, + mode: "apply", + changed: false, + wouldChange: false, + transactionState: "no_op", + retrySafety: "safe", + selectedRelease: { + tag: "v0.1.0", + commit: fixture.priorCommit, + manifestVersion: "0.1.0", + }, + prior: { ref: "v0.1.0", commit: fixture.priorCommit, version: "0.1.0" }, + }) + expect(noOp.nextAction).toContain("No update is required") + expect(existsSync(fixture.mutationMarker)).toBe(false) +}) + +test("apply human output reports an unchanged current immutable Release", () => { + const fixture = updateFixture() + const result = run(["--harness", "codex", "--target", "v0.1.0", "--apply", "--no-input"], { + CODEX_HOME: fixture.codeHome, + PATH: fixture.path, + UPDATE_TEST_MUTATION_MARKER: fixture.mutationMarker, + UPDATE_TEST_STATE: fixture.statePath, + }) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(result.stdout.toString()).toStartWith("Unchanged: v0.1.0 -> v0.1.0\n") + expect(result.stdout.toString()).not.toContain("Updated:") + expect(existsSync(fixture.mutationMarker)).toBe(false) +}) + +nativeCodexTest("apply replaces the old immutable Release through the real native Codex CLI", () => { + const fixture = nativeUpdateFixture() + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--apply", "--json", "--no-input"], + fixture.environment, + ) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const applied = JSON.parse(result.stdout.toString()) + expect(applied).toMatchObject({ + ok: true, + mode: "apply", + changed: true, + wouldChange: true, + transactionState: "updated", + retrySafety: "safe", + selectedRelease: { + tag: "v0.1.1", + commit: fixture.targetCommit, + manifestVersion: "0.1.1", + }, + prior: { ref: "v0.1.0", version: "0.1.0" }, + resulting: { + source: fixture.source, + ref: "v0.1.1", + commit: fixture.targetCommit, + version: "0.1.1", + enabled: true, + }, + proof: { + status: "installed_match", + selectedRelease: "v0.1.1", + marketplaceRelease: "v0.1.1", + installationRelease: "v0.1.1", + functionalProofRelease: "v0.1.1", + lineageMatched: true, + freshInstall: "not_run", + }, + }) + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.1") + expect(readFileSync(join(applied.resulting.installedPath, "runtime", "hello-world.js"), "utf8")).toBe( + "new runtime\n", + ) +}) + +nativeCodexTest("latest selects the highest stable GitHub Release and excludes drafts and prereleases", () => { + const fixture = nativeUpdateFixture() + const environment = releaseApiEnvironment(fixture.environment, [ + { tag_name: "v9.0.0", draft: true, prerelease: false }, + { tag_name: "v1.0.0-rc.1", draft: false, prerelease: true }, + { tag_name: "v0.1.0", draft: false, prerelease: false }, + { tag_name: "v0.1.1", draft: false, prerelease: false }, + ]) + const result = run( + ["--harness", "codex", "--target", "latest", "--json", "--no-input"], + environment, + ) + + expect(result.exitCode, result.stderr.toString()).toBe(0) + const preview = JSON.parse(result.stdout.toString()) + expect(preview.selectedRelease).toMatchObject({ + requested: "latest", + tag: "v0.1.1", + commit: fixture.targetCommit, + manifestVersion: "0.1.1", + }) + expect(preview.transactionState).toBe("previewed") + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.0") +}) + +nativeCodexTest("latest fails closed when GitHub reports no stable Release", () => { + const fixture = nativeUpdateFixture() + const environment = releaseApiEnvironment(fixture.environment, [ + { tag_name: "v9.0.0", draft: true, prerelease: false }, + { tag_name: "v1.0.0-rc.1", draft: false, prerelease: true }, + ]) + const result = run( + ["--harness", "codex", "--target", "latest", "--json", "--no-input"], + environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "release_selection", + changed: false, + transactionState: "blocked", + retrySafety: "safe", + }) + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.0") +}) + +nativeCodexTest("disabled Plugin Installation blocks before mutation because Codex cannot restore it", () => { + const fixture = nativeUpdateFixture() + const configPath = join(fixture.codeHome, "config.toml") + writeFileSync( + configPath, + readFileSync(configPath, "utf8").replace("enabled = true", "enabled = false"), + ) + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--apply", "--json", "--no-input"], + fixture.environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "mutation_blocked", + changed: false, + transactionState: "blocked", + retrySafety: "safe", + }) + const pluginList = nativeCodexJson<{ installed: Array<{ version: string; enabled: boolean }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]).toMatchObject({ version: "0.1.0", enabled: false }) +}) + +test("workspace or managed Marketplace state outside CODEX_HOME requires administrator handoff", () => { + const fixture = updateFixture() + const externalMarketplace = join(fixture.codeHome, "..", "managed-marketplace") + cpSync(fixture.marketplaceRoot, externalMarketplace, { recursive: true }) + const state = JSON.parse(readFileSync(fixture.statePath, "utf8")) + state.marketplaces.marketplaces[0].root = externalMarketplace + state.plugins.installed[0].source.path = join(externalMarketplace, "plugin") + writeFileSync(fixture.statePath, JSON.stringify(state)) + + const result = run(["--harness", "codex", "--target", "v0.1.1", "--json", "--no-input"], { + CODEX_HOME: fixture.codeHome, + PATH: fixture.path, + UPDATE_TEST_MUTATION_MARKER: fixture.mutationMarker, + UPDATE_TEST_STATE: fixture.statePath, + }) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "mutation_blocked", + changed: false, + transactionState: "blocked", + }) + expect(result.stderr.toString()).toContain("outside the user-owned Codex home") + expect(existsSync(fixture.mutationMarker)).toBe(false) +}) + +nativeCodexTest("missing explicit Release tag fails before changing the active installation", () => { + const fixture = nativeUpdateFixture() + const result = run( + ["--harness", "codex", "--target", "v9.9.9", "--json", "--no-input"], + fixture.environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "release_preflight", + changed: false, + transactionState: "blocked", + }) + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.0") +}) + +nativeCodexTest("explicit tag whose manifest version differs fails before mutation", () => { + const fixture = nativeUpdateFixture() + git(["tag", "v0.1.2", fixture.targetCommit], fixture.repositoryRoot) + const result = run( + ["--harness", "codex", "--target", "v0.1.2", "--json", "--no-input"], + fixture.environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "release_preflight", + changed: false, + transactionState: "blocked", + }) +}) + +nativeCodexTest("selected tag movement between preflight and mutation binding fails closed", () => { + const fixture = nativeUpdateFixture() + const environment = movingTagEnvironment(fixture.environment, fixture.repositoryRoot) + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--json", "--no-input"], + environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "release_preflight", + changed: false, + transactionState: "blocked", + }) + expect(result.stderr.toString()).toContain("tag moved") + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.0") +}) + +nativeCodexTest("unsafe target Plugin Payload fails admission before mutation", () => { + const fixture = nativeUpdateFixture() + writeRelease(fixture.repositoryRoot, "0.1.2", "unsafe runtime\n") + symlinkSync( + "runtime/hello-world.js", + join(fixture.repositoryRoot, "plugin", "unsafe-runtime-link"), + ) + git(["add", "."], fixture.repositoryRoot) + git(["commit", "--quiet", "-m", "release 0.1.2 with unsafe payload"], fixture.repositoryRoot) + git(["tag", "v0.1.2"], fixture.repositoryRoot) + const result = run( + ["--harness", "codex", "--target", "v0.1.2", "--json", "--no-input"], + fixture.environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "release_preflight", + changed: false, + transactionState: "blocked", + }) + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.0") +}) + +nativeCodexTest("missing current Marketplace ref blocks before target preflight", () => { + const fixture = nativeUpdateFixture() + const configPath = join(fixture.codeHome, "config.toml") + writeFileSync( + configPath, + readFileSync(configPath, "utf8").replace(/^ref = .*\n/m, ""), + ) + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--json", "--no-input"], + fixture.environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "current_state", + changed: false, + transactionState: "blocked", + }) +}) + +nativeCodexTest("unproved restoration Release blocks before removing the active installation", () => { + const fixture = nativeUpdateFixture() + git(["tag", "--delete", "v0.1.0"], fixture.repositoryRoot) + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--json", "--no-input"], + fixture.environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "restoration_preflight", + changed: false, + transactionState: "blocked", + sideEffects: [ + "read Codex Marketplace and Plugin Installation state", + "fetch and admit the target Release in a temporary detached checkout", + ], + }) + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.0") +}) + +nativeCodexTest("zero-error native add that leaves the old ref is rejected and restored", () => { + const fixture = nativeUpdateFixture() + const fault = faultingCodexEnvironment(fixture.environment, "stale_marketplace_add") + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--apply", "--json", "--no-input"], + fault.environment, + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + ok: false, + category: "mutation_failed_restored", + changed: true, + transactionState: "restored", + }) + const pluginList = nativeCodexJson<{ installed: Array<{ version: string }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]?.version).toBe("0.1.0") + expect(readFileSync(fault.logPath, "utf8").match(/plugin marketplace add .*v0\.1\.1/g)).toHaveLength( + 1, + ) +}) + +nativeCodexTest.each([ + "after_plugin_remove", + "after_marketplace_remove", + "after_marketplace_add", + "after_plugin_add", +] as const)("a failure %s restores and verifies the exact prior Release once", (phase) => { + const fixture = nativeUpdateFixture() + const fault = faultingCodexEnvironment(fixture.environment, phase) + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--apply", "--json", "--no-input"], + fault.environment, + ) + + expect(result.exitCode).toBe(1) + const failure = JSON.parse(result.stdout.toString()) + expect(failure).toMatchObject({ + ok: false, + category: "mutation_failed_restored", + changed: true, + transactionState: "restored", + retrySafety: "safe", + }) + expect(failure.sideEffects).toContain("restored and verified the exact prior Release") + const pluginList = nativeCodexJson<{ installed: Array<{ version: string; enabled: boolean }> }>( + ["plugin", "list", "--json"], + fixture.project, + fixture.environment, + ) + expect(pluginList.installed[0]).toMatchObject({ version: "0.1.0", enabled: true }) + const targetAdds = readFileSync(fault.logPath, "utf8").match( + /plugin marketplace add .*v0\.1\.1/g, + ) ?? [] + expect(targetAdds).toHaveLength( + phase === "after_marketplace_add" || phase === "after_plugin_add" ? 1 : 0, + ) +}) + +nativeCodexTest("unverified restoration returns unknown state and never retries the target mutation", () => { + const fixture = nativeUpdateFixture() + const fault = faultingCodexEnvironment(fixture.environment, "after_marketplace_add", true) + const result = run( + ["--harness", "codex", "--target", "v0.1.1", "--apply", "--json", "--no-input"], + fault.environment, + ) + + expect(result.exitCode).toBe(1) + const failure = JSON.parse(result.stdout.toString()) + expect(failure).toMatchObject({ + ok: false, + category: "mutation_state_unknown", + changed: true, + transactionState: "unknown", + retrySafety: "inspect_required", + }) + const commandLog = readFileSync(fault.logPath, "utf8") + expect(commandLog.match(/plugin marketplace add .*v0\.1\.1/g)).toHaveLength(1) + expect(commandLog.match(/plugin marketplace add .*v0\.1\.0/g)).toHaveLength(1) +}) diff --git a/scripts/update.ts b/scripts/update.ts new file mode 100644 index 0000000..dbd9529 --- /dev/null +++ b/scripts/update.ts @@ -0,0 +1,255 @@ +import { randomUUID } from "node:crypto" +import { resolve } from "node:path" + +import { + CodexProductionUpdateError, + runCodexProductionUpdate, +} from "./codex-production-update" + +/** + * Rendered command contract for the production Plugin Installation update workflow. + * + * @example + * ```ts + * process.stdout.write(UPDATE_HELP) + * ``` + */ +export const UPDATE_HELP = `Select and verify one immutable Codex Plugin Release. + +Usage: + bun run update -- --harness codex [--target latest|vX.Y.Z] [--apply] [--json] [--no-input] + bun run update -- --help + +Flow: + Preview by default. Inspect the current Marketplace and Plugin Installation, + select one stable immutable Release, and preflight update plus recovery. + Add --apply to authorize the previewed remove, repin, install, and verify transaction. + +Examples: + bun run update -- --harness codex + bun run update -- --harness codex --target v1.2.3 --apply + bun run update -- --harness codex --target latest --apply --json --no-input + +Options: + --harness codex Update one Codex CLI Marketplace installation. + --target Select latest stable Release or an explicit immutable vX.Y.Z tag. + --apply Authorize the previewed mutation. Omit for read-only preview. + --json Emit one stable machine result on stdout. + --no-input Disable prompts and fail when authority or input is absent. + -h, --help Show this help. + +Side effects: + Preview: GitHub and Git reads plus temporary detached checkouts. + Apply: removes and reinstalls one Codex Marketplace and Plugin Installation. + +Codex distinction: + codex plugin marketplace upgrade refreshes the configured ref. + This command selects a newer immutable Release, then repins the Marketplace. +` + +/** Stable retry judgment for the production update transaction. */ +export type UpdateRetrySafety = "safe" | "unsafe" | "inspect_required" + +/** Stable transaction states exposed to scripts and agents. */ +export type UpdateTransactionState = + | "blocked" + | "previewed" + | "no_op" + | "updated" + | "restored" + | "unknown" + +/** Machine-readable failed update result emitted without raw subprocess output. */ +export interface UpdateFailureResult { + /** Contract revision for additive consumer validation. */ + schemaVersion: 1 + /** Package-owned result vocabulary. */ + contractId: "plugin.production-update" + /** Correlation identifier for one invocation. */ + runId: string + /** Discriminator for failed results. */ + ok: false + /** Stable failure family. */ + category: string + /** Safe human summary without credentials or command output. */ + message: string + /** Whether the Plugin Installation changed during this run. */ + changed: boolean + /** Terminal transaction state. */ + transactionState: UpdateTransactionState + /** Same-input retry judgment. */ + retrySafety: UpdateRetrySafety + /** Bounded side effects completed before failure. */ + sideEffects: string[] + /** One current safe continuation. */ + nextAction: string +} + +interface UpdateInvocation { + harness?: string + target: string + apply: boolean + json: boolean + noInput: boolean +} + +class UsageError extends Error {} + +function valueAfter(arguments_: string[], index: number, option: string): string { + const value = arguments_[index + 1] + if (!value || value.startsWith("--")) throw new UsageError(`${option} requires a value`) + return value +} + +function parseInvocation(arguments_: string[]): UpdateInvocation { + const invocation: UpdateInvocation = { + target: "latest", + apply: false, + json: false, + noInput: false, + } + const seen = new Set() + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if (seen.has(argument)) throw new UsageError(`${argument} may be provided once`) + switch (argument) { + case "--harness": + seen.add(argument) + invocation.harness = valueAfter(arguments_, index, argument) + index += 1 + break + case "--target": + seen.add(argument) + invocation.target = valueAfter(arguments_, index, argument) + index += 1 + break + case "--apply": + seen.add(argument) + invocation.apply = true + break + case "--json": + seen.add(argument) + invocation.json = true + break + case "--no-input": + seen.add(argument) + invocation.noInput = true + break + default: + throw new UsageError(`unknown option: ${argument}`) + } + } + return invocation +} + +function failureResult(runId: string, category: string, message: string): UpdateFailureResult { + return { + schemaVersion: 1, + contractId: "plugin.production-update", + runId, + ok: false, + category, + message, + changed: false, + transactionState: "blocked", + retrySafety: "safe", + sideEffects: [], + nextAction: "bun run update -- --help", + } +} + +function operationalFailureResult( + runId: string, + error: CodexProductionUpdateError, +): UpdateFailureResult { + return { + schemaVersion: 1, + contractId: "plugin.production-update", + runId, + ok: false, + category: error.category, + message: error.message, + changed: error.changed, + transactionState: error.transactionState, + retrySafety: error.retrySafety, + sideEffects: error.sideEffects, + nextAction: error.nextAction, + } +} + +/** + * Execute the thin update command dispatcher. + * + * @param arguments_ - Public arguments after the package script separator + * @returns POSIX process exit status + * + * @example + * ```ts + * process.exit(main(["--harness", "codex"])) + * ``` + */ +export function main(arguments_: string[]): number { + if (arguments_.length === 0 || arguments_.includes("--help") || arguments_.includes("-h")) { + process.stdout.write(UPDATE_HELP) + return 0 + } + const runId = randomUUID() + try { + const invocation = parseInvocation(arguments_) + if (invocation.harness !== "codex") throw new UsageError("--harness must be codex") + if ( + invocation.target !== "latest" && + !/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(invocation.target) + ) { + throw new UsageError("--target must be latest or an immutable vX.Y.Z tag") + } + const result = runCodexProductionUpdate({ + target: invocation.target, + apply: invocation.apply, + runId, + repositoryRoot: resolve(import.meta.dir, ".."), + environment: process.env, + }) + if (invocation.json) process.stdout.write(`${JSON.stringify(result)}\n`) + else { + const status = + result.transactionState === "no_op" ? "Unchanged" : result.mode === "preview" ? "Preview" : "Updated" + process.stdout.write( + `${status}: ${result.prior.ref} -> ${result.selectedRelease.tag}\n${result.nextAction}\n`, + ) + } + return 0 + } catch (error) { + if (error instanceof CodexProductionUpdateError) { + const result = operationalFailureResult(runId, error) + if (arguments_.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`) + process.stderr.write(`update: ${error.category}: ${error.message} [run ${runId}]\n`) + return 1 + } + if (!(error instanceof UsageError)) { + const result: UpdateFailureResult = { + schemaVersion: 1, + contractId: "plugin.production-update", + runId, + ok: false, + category: "internal", + message: "Unexpected update failure; native state was not proven changed", + changed: false, + transactionState: "blocked", + retrySafety: "inspect_required", + sideEffects: [], + nextAction: "Inspect the current Codex Plugin Installation before retrying.", + } + if (arguments_.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`) + process.stderr.write(`update: internal: unexpected failure [run ${runId}]\n`) + return 1 + } + const message = error instanceof Error ? error.message : "invalid command usage" + const result = failureResult(runId, "usage", message) + if (arguments_.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`) + process.stderr.write(`update: ${message}\n`) + return 2 + } +} + +if (import.meta.main) process.exit(main(process.argv.slice(2)))