From 99b30610af12ff7efae478826a91d8e80dc44901 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:54:14 -0400 Subject: [PATCH 1/2] fix(cli): restart via stop/start when CLI is newer than proxy In-place respawn would keep serving the old build, so restart refused with restart_version_skew. Now cli-newer skew falls back to stop/start from the current installation and verifies the replacement version; other skew directions still refuse. --- src/cli/index.ts | 43 +++++++++++++++++++++++-- src/cli/system-restart-client.ts | 9 ++++-- tests/cli/system-restart-client.test.ts | 10 +++--- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index fd0ed56099f..ee9f5434107 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -911,7 +911,7 @@ function reportRestartFailure(result: Extract if (code === "restart_capability_unsupported") { console.error("❌ The running proxy predates process-bound restart support; no unsafe fallback was attempted."); console.error(" After confirming this home owns the proxy, run `ocx stop` and then `ocx start` once."); - } else if (code === "restart_version_skew") { + } else if (code === "restart_version_skew" || code === "restart_version_skew_cli_newer") { console.error("❌ The running proxy reports a different OpenCodex version than this CLI; restarting in place would respawn the old installation."); console.error(" Run `ocx stop` and then `ocx start` from this installation instead."); } else if (code === "restart_package_tree_unsettled") { @@ -925,6 +925,37 @@ function reportRestartFailure(result: Extract console.error("❌ Proxy was not running and the fallback start did not become healthy."); } } +/** Update restart: the CLI is newer than the attested proxy, so stop the old build + * and start the current installation instead of respawning the old one in place. + * Other skew directions keep refusing in reportRestartFailure. */ +async function restartViaStopStartForUpdate( + startWhenStopped: (recoveringLiveRestart: boolean) => Promise, +): Promise { + const cliVersion = packageVersion(); + console.log(`🔄 Running proxy is older than this CLI (${cliVersion}); restarting via stop/start from the current installation...`); + try { + await handleStop(); + } catch (error) { + console.error(`❌ Update restart failed to stop the old proxy: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + const startOutcome = await startWhenStopped(true); + if (startOutcome.status !== "started") { + console.error("❌ Update restart stopped the old proxy but the new proxy did not start."); + return false; + } + const live = await findLiveProxy({ deadlineAt: Date.now() + 15_000, attempts: 2 }).catch(() => null); + if (!live) { + console.error("❌ Update restart started a proxy but it did not become healthy."); + return false; + } + if (live.version && live.version !== cliVersion) { + console.error(`❌ Update restart is still serving ${live.version} instead of ${cliVersion}.`); + return false; + } + console.log(`✅ Proxy updated and running${live.version ? ` on ${live.version}` : ""} (PID ${live.pid ?? "unknown"}).`); + return true; +} async function handleProxyRestart( startWhenStopped: (recoveringLiveRestart: boolean) => Promise, ): Promise { @@ -947,7 +978,15 @@ async function handleProxyRestart( findLive: () => findLiveProxy({ deadlineAt: end, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= end, })), }); - if (!result.ok) reportRestartFailure(result); + if (!result.ok) { + const code = result.phase === "request" && result.error instanceof Error ? result.error.message : ""; + if (result.phase === "request" && code === "restart_version_skew_cli_newer") { + const updated = await restartViaStopStartForUpdate(startWhenStopped); + process.exitCode = updated ? 0 : 1; + return updated; + } + reportRestartFailure(result); + } process.exitCode = result.ok ? 0 : 1; return result.ok; } diff --git a/src/cli/system-restart-client.ts b/src/cli/system-restart-client.ts index 5a801d61248..a70db55194b 100644 --- a/src/cli/system-restart-client.ts +++ b/src/cli/system-restart-client.ts @@ -143,8 +143,13 @@ export async function requestBoundSystemRestart( const proxyVersion = fenced ? body.installedVersion as string : typeof body.version === "string" ? body.version : undefined; - if (computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion).skewed) { - return rejected("restart_version_skew"); + const skew = computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion); + if (skew.skewed) { + // cli-newer means this installation already holds the update the running proxy + // lacks: an in-place respawn would keep serving the old build (#4522), so the + // caller restarts via stop/start from here instead of refusing. Other skew + // directions (proxy-newer, incomparable) still refuse. + return rejected(skew.relation === "cli-newer" ? "restart_version_skew_cli_newer" : "restart_version_skew"); } let observed: LiveProxy | null; diff --git a/tests/cli/system-restart-client.test.ts b/tests/cli/system-restart-client.test.ts index 5c1abe111c9..04a4c7cad45 100644 --- a/tests/cli/system-restart-client.test.ts +++ b/tests/cli/system-restart-client.test.ts @@ -148,10 +148,10 @@ describe("bound system restart client", () => { }); test("refuses a restart through a CLI whose version differs from the attested proxy", async () => { - for (const [proxyVersion, cliVersion] of [ - ["2.49.0", "2.53.0"], - ["2.53.0", "2.49.0"], - ["test", "2.53.0"], + for (const [proxyVersion, cliVersion, expected] of [ + ["2.49.0", "2.53.0", "restart_version_skew_cli_newer"], + ["2.53.0", "2.49.0", "restart_version_skew"], + ["test", "2.53.0", "restart_version_skew"], ] as const) { const setup = successfulDeps(); setup.deps.cliVersion = cliVersion; @@ -173,7 +173,7 @@ describe("bound system restart client", () => { const outcome = await requestBoundSystemRestart(target, 10_000, setup.deps); expect(outcome).toMatchObject({ accepted: false, uncertain: false }); expect(outcome.accepted ? "" : (outcome.error as Error).message) - .toBe("restart_version_skew"); + .toBe(expected); expect(setup.requests).toHaveLength(1); } }); From a903fb566a33ff1ec09deb958901347a11d487d6 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:49:38 -0400 Subject: [PATCH 2/2] fix(cli): carry attested restart identity through guarded update restart Addresses Ingwannu review: the update fallback revalidates the attested PID/port under the ownership lease via runApprovedStop, refuses changed owners before any stop, starts only after confirmed stop, and accepts only a replacement serving this CLI version. Adds injected regressions (A-to-B, stop/start failures, identity/version checks, no real service) plus registry entries and structure/docs updates. --- .../content/docs/reference/cli/lifecycle.md | 5 + scripts/test-layout/layout.json | 2 +- src/cli/index.ts | 85 ++++--- src/cli/restart-update-coordinator.ts | 137 +++++++++++ src/cli/system-restart-client.ts | 6 +- structure/runtime.md | 4 +- tests/cli/restart-update-coordinator.test.ts | 223 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 426 insertions(+), 37 deletions(-) create mode 100644 src/cli/restart-update-coordinator.ts create mode 100644 tests/cli/restart-update-coordinator.test.ts diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 028986705c6..ad282abbd78 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -117,6 +117,11 @@ If a live listener cannot be attested to a runtime PID (including a pre-update p closed without an `ensure` or stop/start fallback. After confirming ownership, use `ocx stop` then `ocx start` for a standalone proxy. For a service-managed proxy, use `ocx stop` followed by `ocx service start` so supervision is restored. +When the invoking CLI is newer than the attested proxy, restart does not respawn in place (that +would keep serving the old build). Instead it carries the attested PID and port through a guarded +stop validated under the ownership lease — refusing if the target changed — and starts the +current installation only after the target shows stopped, accepting the replacement only when it +serves this CLI version on the expected port. One invocation re-observes transient discovery races. A failed start is retried only when the previous attempt never launched a child or the launched child is known to have exited. If a launch may still be running, restart reports the original failure without starting diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 49dbea99f4a..b59d4456fa5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -56,7 +56,7 @@ "low-quota-protection.test.ts": "codex-integration", "responses-compaction-recovery.test.ts": "responses", "responses-canonical-nonstream.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", - "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", + "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", "restart-update-coordinator.test.ts": "cli", "restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", "link-relay-bound-transport.test.ts": "clients", "claude-desktop-system-proxy.test.ts": "claude-integration", "codex-config-drift-heal.test.ts": "codex-integration", diff --git a/src/cli/index.ts b/src/cli/index.ts index ee9f5434107..af7d96a33a0 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -89,6 +89,7 @@ import { type ProxyRestartStartOutcome, } from "./tray-proxy"; import { requestBoundSystemRestart } from "./system-restart-client"; +import { runRestartUpdate, readResolveJson, type RestartUpdateResult } from "./restart-update-coordinator"; import { installCrashGuards } from "../lib/crash-guard"; import { SpendLedgerOwnerError } from "../lib/spend-ledger-owner"; import { redactUrlForLog } from "../lib/redact"; @@ -927,46 +928,47 @@ function reportRestartFailure(result: Extract } /** Update restart: the CLI is newer than the attested proxy, so stop the old build * and start the current installation instead of respawning the old one in place. - * Other skew directions keep refusing in reportRestartFailure. */ -async function restartViaStopStartForUpdate( - startWhenStopped: (recoveringLiveRestart: boolean) => Promise, -): Promise { - const cliVersion = packageVersion(); - console.log(`🔄 Running proxy is older than this CLI (${cliVersion}); restarting via stop/start from the current installation...`); - try { - await handleStop(); - } catch (error) { - console.error(`❌ Update restart failed to stop the old proxy: ${error instanceof Error ? error.message : String(error)}`); - return false; - } - const startOutcome = await startWhenStopped(true); - if (startOutcome.status !== "started") { - console.error("❌ Update restart stopped the old proxy but the new proxy did not start."); - return false; - } - const live = await findLiveProxy({ deadlineAt: Date.now() + 15_000, attempts: 2 }).catch(() => null); - if (!live) { - console.error("❌ Update restart started a proxy but it did not become healthy."); - return false; - } - if (live.version && live.version !== cliVersion) { - console.error(`❌ Update restart is still serving ${live.version} instead of ${cliVersion}.`); - return false; + * The attested PID/port travels into runRestartUpdate, which revalidates it under + * the ownership lease through the guarded-stop mechanism and refuses on any + * owner change instead of stopping blindly. Other skew directions keep refusing + * in reportRestartFailure. */ +function reportRestartUpdateRefusal(result: Extract): void { + switch (result.reason) { + case "target-changed": + console.error(`❌ Refusing the update restart: the attested proxy changed before the stop (${result.detail ?? "identity mismatch"}); nothing was stopped.`); + break; + case "stop-refused": + console.error(`❌ Refusing the update restart: the guarded stop declined (${result.detail ?? "ownership changed"}); nothing was stopped.`); + break; + case "stop-failed": + console.error(`❌ Update restart failed while stopping the old proxy (${result.detail ?? "unknown error"}).`); + break; + case "target-not-confirmed-stopped": + console.error("❌ Refusing the update restart: the old proxy did not confirm stopped; not starting a second proxy over it."); + break; + case "start-failed": + console.error("❌ Update restart stopped the old proxy but the new proxy did not start. Run `ocx start` to recover."); + break; + case "replacement-unverified": + console.error(`❌ Update restart could not verify a replacement serving this CLI (${result.detail ?? "unhealthy"}). Check ocx status before retrying.`); + break; } - console.log(`✅ Proxy updated and running${live.version ? ` on ${live.version}` : ""} (PID ${live.pid ?? "unknown"}).`); - return true; } async function handleProxyRestart( startWhenStopped: (recoveringLiveRestart: boolean) => Promise, ): Promise { const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS; + const stashed: { previous: ProxyRestartLive | null } = { previous: null }; const result = await runProxyRestart({ findLive: () => discoverStableProxyForRestart({ findLive: () => findLiveProxy({ deadlineAt, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= deadlineAt, }), startWhenStopped, - requestInPlaceRestart: previous => requestBoundSystemRestart(previous, deadlineAt), + requestInPlaceRestart: previous => { + stashed.previous = previous; + return requestBoundSystemRestart(previous, deadlineAt); + }, waitForReplacement: previous => waitForProxyReplacement(previous, deadlineAt - RESTART_REOBSERVE_RESERVE_MS, end => findLiveProxy({ deadlineAt: end })), reobserveAfterReplacement: previous => reobserveRestartReplacement(previous, deadlineAt, end => discoverStableProxyForRestart({ @@ -980,10 +982,29 @@ async function handleProxyRestart( }); if (!result.ok) { const code = result.phase === "request" && result.error instanceof Error ? result.error.message : ""; - if (result.phase === "request" && code === "restart_version_skew_cli_newer") { - const updated = await restartViaStopStartForUpdate(startWhenStopped); - process.exitCode = updated ? 0 : 1; - return updated; + const previous = stashed.previous; + if (result.phase === "request" && code === "restart_version_skew_cli_newer" + && previous !== null && previous.pid !== null) { + console.log(`🔄 Running proxy is older than this CLI (${packageVersion()}); restarting via guarded stop/start from the current installation...`); + const updated = await runRestartUpdate( + { pid: previous.pid, port: previous.port }, + deadlineAt, + { + findLive: () => findLiveProxy({ deadlineAt, attempts: 2 }), + resolve: readResolveJson, + stopGuarded: approval => handleStop(approval), + startWhenStopped, + cliVersion: packageVersion(), + }, + ); + if (updated.ok) { + console.log(`✅ Proxy updated and running on ${updated.live.version ?? packageVersion()} (PID ${updated.live.pid ?? "unknown"}).`); + process.exitCode = 0; + return true; + } + reportRestartUpdateRefusal(updated); + process.exitCode = 1; + return false; } reportRestartFailure(result); } diff --git a/src/cli/restart-update-coordinator.ts b/src/cli/restart-update-coordinator.ts new file mode 100644 index 00000000000..db8de9a1183 --- /dev/null +++ b/src/cli/restart-update-coordinator.ts @@ -0,0 +1,137 @@ +/** + * Identity-carrying coordinator for the CLI-newer update restart. + * + * An in-place restart respawns the live process from its own installation, so a + * restart accepted from a newer CLI would keep serving the old build. Instead of + * refusing (or stopping whatever happens to be live), this coordinator carries + * the attested restart identity (PID/port) through every step and revalidates it + * under the ownership lease through the existing guarded-stop mechanism + * (runApprovedStop): a changed, unknown, or non-runtime owner refuses before any + * stop is attempted, the start runs only after the intended target shows + * stopped, and the replacement is accepted only when it serves this exact CLI + * version on the expected port. Every boundary is injected for tests; no real + * service is touched by the regression suite. + */ +import type { LiveProxy } from "../server/proxy-liveness"; +import { runResolve, type ResolveJson } from "./resolve"; +import type { StopApproval } from "./stop-approval"; +import type { StopOutcome } from "./stop-report"; +import type { ProxyRestartStartOutcome } from "./tray-proxy"; + +/** Attested restart identity observed before the CLI-newer refusal. */ +export interface RestartUpdateTarget { + pid: number; + port: number; +} + +export type RestartUpdateRefusal = + | "target-changed" + | "stop-refused" + | "stop-failed" + | "target-not-confirmed-stopped" + | "start-failed" + | "replacement-unverified"; + +export type RestartUpdateResult = + | { ok: true; live: LiveProxy } + | { ok: false; reason: RestartUpdateRefusal; detail?: string }; + +export interface RestartUpdateIo { + findLive: () => Promise; + resolve: () => Promise; + stopGuarded: (approval: StopApproval) => Promise; + startWhenStopped: (recoveringLiveRestart: boolean) => Promise; + now?: () => number; + sleep?: (ms: number) => Promise; + cliVersion: string; +} + +/** Bounded re-observation shared by the stopped and replacement waits. */ +async function pollLive( + findLive: () => Promise, + deadlineAt: number, + now: () => number, + sleep: (ms: number) => Promise, + accept: (live: LiveProxy | null) => boolean, +): Promise { + for (;;) { + const live = await findLive(); + if (accept(live)) return live; + const remaining = deadlineAt - now(); + if (remaining <= 0) return null; + await sleep(Math.min(250, remaining)); + } +} + +/** Production resolve reader: one JSON document, null on any other outcome. */ +export async function readResolveJson(): Promise { + const lines: string[] = []; + const code = await runResolve({ json: true }, { + stdout: { log: (line: string) => { lines.push(line); } }, + stderr: { error: () => {} }, + }); + if (code !== 0 || lines.length !== 1) return null; + try { + return JSON.parse(lines[0] as string) as ResolveJson; + } catch { + return null; + } +} + +const STOPPED_CONFIRM_BUDGET_MS = 10_000; + +export async function runRestartUpdate( + target: RestartUpdateTarget, + deadlineAt: number, + io: RestartUpdateIo, +): Promise { + const now = io.now ?? Date.now; + const sleep = io.sleep ?? Bun.sleep; + const fail = (reason: RestartUpdateRefusal, detail?: string): RestartUpdateResult => + ({ ok: false, reason, ...(detail === undefined ? {} : { detail }) }); + const resolved = await io.resolve(); + if (resolved === null) return fail("target-changed", "the runtime could not be re-resolved"); + const liveness = resolved.liveness; + if (liveness.status !== "live" || liveness.pid === null || liveness.port === null) { + return fail("target-changed", "the approved runtime is no longer live"); + } + if (liveness.pid !== target.pid || liveness.port !== target.port) { + return fail("target-changed", "the live PID/port no longer match the attested restart target"); + } + if (liveness.source !== "runtime") { + return fail("target-changed", "the live runtime is no longer attested"); + } + if (liveness.role === "client") { + return fail("target-changed", "the runtime now routes as a client"); + } + const approval: StopApproval = { + pid: target.pid, + port: target.port, + hostname: liveness.hostname ?? "", + configHome: resolved.configHome, + cliVersion: io.cliVersion, + compatibilityToken: resolved.takeover.kind === "supported" ? resolved.takeover.token : "", + }; + const stopped = await io.stopGuarded(approval); + if (!stopped.ok) { + const refused = stopped.summary.outcome === "approval-changed"; + return fail(refused ? "stop-refused" : "stop-failed", stopped.summary.message); + } + const stoppedDeadline = Math.min(deadlineAt, now() + STOPPED_CONFIRM_BUDGET_MS); + const gone = await pollLive(io.findLive, stoppedDeadline, now, sleep, + live => live === null || live.port !== target.port); + if (gone === null && now() >= stoppedDeadline) { + return fail("target-not-confirmed-stopped", "the expected port still answers after the stop"); + } + const started = await io.startWhenStopped(true); + if (started.status !== "started") { + return fail("start-failed", "the replacement proxy did not start"); + } + const replacement = await pollLive(io.findLive, deadlineAt, now, sleep, + live => live !== null && live.source === "runtime" && live.port === target.port + && live.pid !== null && live.version === io.cliVersion); + if (replacement === null) { + return fail("replacement-unverified", "no healthy replacement serving this CLI version appeared in time"); + } + return { ok: true, live: replacement }; +} diff --git a/src/cli/system-restart-client.ts b/src/cli/system-restart-client.ts index a70db55194b..6ca7eb44e2d 100644 --- a/src/cli/system-restart-client.ts +++ b/src/cli/system-restart-client.ts @@ -146,8 +146,10 @@ export async function requestBoundSystemRestart( const skew = computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion); if (skew.skewed) { // cli-newer means this installation already holds the update the running proxy - // lacks: an in-place respawn would keep serving the old build (#4522), so the - // caller restarts via stop/start from here instead of refusing. Other skew + // lacks: an in-place respawn would keep serving the old build (#4522). The + // caller carries the attested identity into runRestartUpdate + // (restart-update-coordinator.ts), which revalidates it under the ownership + // lease through the guarded-stop mechanism instead of refusing. Other skew // directions (proxy-newer, incomparable) still refuse. return rejected(skew.relation === "cli-newer" ? "restart_version_skew_cli_newer" : "restart_version_skew"); } diff --git a/structure/runtime.md b/structure/runtime.md index eaaef86bb72..5e3ecfed926 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -123,7 +123,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart when the versions differ, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path, while a newer CLI restarts through the identity-carrying update coordinator in `src/cli/restart-update-coordinator.ts` instead of refusing. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | | `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` | The pure Bun-head parser retains command argv and derives transient help paths before shim preflight or command dispatch. Bare `help` is recognized only at root or immediately after the root command; nested paths use `--help`/`-h`, and exact `--` ends head help scanning. Later operand values such as `alias set demo help` stay commands. `runCli` exits help and rejects unknown roots before shim/stateful preflight, while preserving `parseCliHead` and its kinds. Registry names/aliases/hidden entries and the unregistered `internal` runner remain valid dispatch targets; hidden/internal names stay outside discovery and suggestions. `printUsage` renders the same compact index for bare `ocx`, `help`, `--help` and `-h`; static navigation groups reference registry names/summaries and add no configuration reads, width probes or color dependence. `printFullUsage` preserves the full reference behind `ocx help --all` and `ocx --help --all`. This contract concerns the Bun head; the Node launcher's updater interception has its own recognition rules. | | `src/cli/help-catalog.ts`, `src/cli/help-models-context.ts`, `src/cli/help-recovery.ts` | Pure help resolution uses `src/cli/registry.ts` and `src/cli/capabilities.ts`, preserving exact alias usage/details before canonical family navigation. Entry results carry transient canonical identity and declared descendants; family help lists partial child coverage and links the curated models-context topic separately. Hidden entries remain explicitly callable but absent from discovery. Declared capabilities render summaries, known flags/details and parent pointers without inventing operand grammar; declared-prefix groups mark incomplete coverage. Unknown roots and unavailable explicit `ocx help ` detail use concise stderr-only diagnostics/navigation, empty stdout and exit 1, without a full banner. Missing detail never establishes unsupported runtime grammar. Successful appended flag-help parent fallback runs before recovery and preserves its write sink; unresolved explicit help uses stderr regardless of sink. The resolver does not validate runtime command execution. The curated models-context topic shares `MODELS_CONTEXT_USAGE` with `src/cli/models-runtime.ts`, preserving its status/value/provider/all grammar and read-only status example. The catalog projects pure transient recovery candidates from visible registry names/aliases, immediate declared children and curated topics, deduplicated by canonical destination. The pure recovery formatter bounds input/depth/work, matches conservative edit distances including adjacent transpositions and sorts by distance/name; at most three metadata-derived destinations are suggested, never executed or extended with trailing argv. Distant/control/oversized input gets no guess. Root diagnostics echo only a bounded command-shaped first token accepted by `src/lib/redact.ts`; nested diagnostics never echo arbitrary operands. `src/cli/dispatch.ts` reuses the formatter for direct unknown-root dispatch. Capability JSON and provider-specific error semantics remain unchanged. Provider syntax/examples live in registry details; `src/cli/provider.ts` uses the shared renderer for bare/help success (stdout, exit 0) and unknown-action diagnostics/help (stderr, exit 1, no stdout). The transient optional `write` sink defaults to `console.log`, accepts `console.error` for that error path and propagates through parent fallback. Bare provider retains ordinary preflight; only head-help forms bypass it. | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | @@ -192,7 +192,7 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- until shutdown. Normal shutdown restores native Codex; a sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. -`src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. +`src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. When the CLI is newer than the attested proxy, the update path carries the observed PID/port into `src/cli/restart-update-coordinator.ts`, which revalidates the identity under the ownership lease through the guarded stop in `src/cli/stop-approval.ts` (a changed, unknown, or non-runtime owner refuses before any stop), starts only after the target shows stopped, and accepts the replacement only when it serves this CLI version on the expected port; `tests/cli/restart-update-coordinator.test.ts` pins every refusal with injected fakes and no real service. The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks diff --git a/tests/cli/restart-update-coordinator.test.ts b/tests/cli/restart-update-coordinator.test.ts new file mode 100644 index 00000000000..e314ac92d89 --- /dev/null +++ b/tests/cli/restart-update-coordinator.test.ts @@ -0,0 +1,223 @@ +/** + * Injected regressions for the CLI-newer update coordinator: the attested + * restart identity must survive every step, and anything else refuses before + * acting. No real service is touched — every boundary is a fake. + */ +import { describe, expect, test } from "bun:test"; +import { STOP_SUMMARY_SCHEMA, type StopOutcome } from "../../src/cli/stop-report"; +import type { StopApproval } from "../../src/cli/stop-approval"; +import type { ResolveJson, ResolveLivenessJson } from "../../src/cli/resolve"; +import type { ProxyRestartStartOutcome } from "../../src/cli/tray-proxy"; +import type { LiveProxy } from "../../src/server/proxy-liveness"; +import { + runRestartUpdate, + type RestartUpdateIo, + type RestartUpdateTarget, +} from "../../src/cli/restart-update-coordinator"; + +const CLI_VERSION = "2.77.0"; +const TARGET: RestartUpdateTarget = { pid: 4242, port: 10100 }; +const TOKEN = "a".repeat(64); +const TEST_CONFIG_HOME = "/tmp/ocx-test-config-home"; + +function live(over: Partial = {}): LiveProxy { + return { + pid: 4242, port: 10100, hostname: "127.0.0.1", + source: "runtime", version: "2.69.0", ...over, + }; +} + +function resolveOk( + livenessOver: Partial = {}, + topOver: Partial = {}, +): ResolveJson { + return { + schema: "ocx-resolve/1", + cliVersion: CLI_VERSION, + configHome: TEST_CONFIG_HOME, + port: { effective: 10100, configured: 10100, source: "runtime" }, + liveness: { + status: "live", pid: 4242, port: 10100, + hostname: "127.0.0.1", source: "runtime", version: "2.69.0", + ...livenessOver, + }, + ownership: { kind: "unknown", reason: "test" }, + takeover: { kind: "supported", protocolVersion: 1, minimumCliVersion: "0.0.0", token: TOKEN }, + ...topOver, + }; +} + +function stopSummary(outcome: StopOutcome["summary"]["outcome"], message: string): StopOutcome { + return { + ok: outcome === "stopped", + summary: { + schema: STOP_SUMMARY_SCHEMA, ok: outcome === "stopped", outcome, + exitCode: outcome === "stopped" ? 0 : 1, runtimeDown: outcome === "stopped", + service: "absent", proxy: outcome === "stopped" ? "stopped" : "unknown", + sharedTeardown: "skipped", message, + }, + }; +} + +interface ScriptedIo extends RestartUpdateIo { + stopCalls: StopApproval[]; + startCalls: boolean[]; +} + +function scriptIo(script: { + lives?: Array; + resolve?: ResolveJson | null; + stop?: StopOutcome; + start?: ProxyRestartStartOutcome; +}): ScriptedIo { + let now = 1_000_000; + const liveQueue = [...(script.lives ?? [])]; + let last: LiveProxy | null = null; + const stopCalls: StopApproval[] = []; + const startCalls: boolean[] = []; + return { + stopCalls, + startCalls, + cliVersion: CLI_VERSION, + now: () => now, + sleep: async ms => { now += ms; }, + findLive: async () => { + if (liveQueue.length > 0) last = liveQueue.shift()!; + return last; + }, + resolve: async () => script.resolve ?? null, + stopGuarded: async approval => { + stopCalls.push(approval); + return script.stop ?? stopSummary("stopped", "stopped"); + }, + startWhenStopped: async recovering => { + startCalls.push(recovering); + return script.start ?? { status: "started" }; + }, + }; +} + +function run(io: ScriptedIo) { + return runRestartUpdate(TARGET, 1_000_000 + 30_000, io); +} + +describe("restart update coordinator", () => { + test("updates through guarded stop/start when the attested target is stable", async () => { + const io = scriptIo({ + resolve: resolveOk(), + lives: [null, null, live({ version: CLI_VERSION })], + stop: stopSummary("stopped", "stopped"), + start: { status: "started" }, + }); + const result = await run(io); + expect(result).toEqual({ ok: true, live: live({ version: CLI_VERSION }) }); + expect(io.stopCalls).toEqual([{ + pid: 4242, port: 10100, hostname: "127.0.0.1", + configHome: TEST_CONFIG_HOME, + cliVersion: CLI_VERSION, compatibilityToken: TOKEN, + }]); + expect(io.startCalls).toEqual([true]); + }); + + test("refuses without stopping when a supervisor replaced the target (A-to-B)", async () => { + const io = scriptIo({ + resolve: resolveOk({ pid: 9999 }), + lives: [live({ pid: 9999 })], + }); + const result = await run(io); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("target-changed"); + expect(io.stopCalls).toHaveLength(0); + expect(io.startCalls).toHaveLength(0); + }); + + test("refuses unresolvable, unattested, or client-role runtimes without stopping", async () => { + for (const resolve of [ + null, + resolveOk({ status: "absent-proven", pid: null, port: null }), + resolveOk({ source: "config" }), + resolveOk({ role: "client" }), + ]) { + const io = scriptIo({ resolve }); + const result = await run(io); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("target-changed"); + expect(io.stopCalls).toHaveLength(0); + expect(io.startCalls).toHaveLength(0); + } + }); + + test("refuses the stop when the guarded stop declines under lease", async () => { + const io = scriptIo({ + resolve: resolveOk(), + stop: stopSummary("approval-changed", "the live PID is now 9999 (approved 4242)"), + }); + const result = await run(io); + expect(result).toEqual({ + ok: false, reason: "stop-refused", + detail: "the live PID is now 9999 (approved 4242)", + }); + expect(io.stopCalls).toHaveLength(1); + expect(io.startCalls).toHaveLength(0); + }); + + test("fails closed when the guarded stop fails", async () => { + const io = scriptIo({ + resolve: resolveOk(), + stop: stopSummary("failed", "stop exploded"), + }); + const result = await run(io); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("stop-failed"); + expect(result.detail).toBe("stop exploded"); + } + expect(io.startCalls).toHaveLength(0); + }); + + test("refuses to start over a port that still answers after the stop", async () => { + const io = scriptIo({ + resolve: resolveOk(), + lives: [live()], + stop: stopSummary("stopped", "stopped"), + }); + const result = await run(io); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("target-not-confirmed-stopped"); + expect(io.stopCalls).toHaveLength(1); + expect(io.startCalls).toHaveLength(0); + }); + + test("refuses when the replacement does not start", async () => { + const io = scriptIo({ + resolve: resolveOk(), + lives: [null], + stop: stopSummary("stopped", "stopped"), + start: { status: "failed", launch: "unknown" }, + }); + const result = await run(io); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("start-failed"); + }); + + test("refuses an unverified replacement identity or version", async () => { + const wrongVersion = scriptIo({ + resolve: resolveOk(), + lives: [null, live({ pid: 5555, version: "2.69.0" })], + stop: stopSummary("stopped", "stopped"), + start: { status: "started" }, + }); + const stale = await run(wrongVersion); + expect(stale.ok).toBe(false); + if (!stale.ok) expect(stale.reason).toBe("replacement-unverified"); + const never = scriptIo({ + resolve: resolveOk(), + lives: [], + stop: stopSummary("stopped", "stopped"), + start: { status: "started" }, + }); + const absent = await run(never); + expect(absent.ok).toBe(false); + if (!absent.ok) expect(absent.reason).toBe("replacement-unverified"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 83f39424021..a4cd5faa6a5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -67,6 +67,7 @@ "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", + "restart-update-coordinator.test.ts": "cli", "restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config",