diff --git a/src/cli/index.ts b/src/cli/index.ts index 93c963833b..9d37cb5c21 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -732,6 +732,9 @@ 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") { + 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 { console.error("❌ Proxy restart request could not be confirmed; no fallback stop/start was attempted."); } diff --git a/src/cli/system-restart-client.ts b/src/cli/system-restart-client.ts index fbc7c4548f..7e0c06fdf7 100644 --- a/src/cli/system-restart-client.ts +++ b/src/cli/system-restart-client.ts @@ -22,6 +22,8 @@ import { type LiveProxy, } from "../server/proxy-liveness"; import type { ProxyRestartRequestOutcome } from "./tray-proxy"; +import { packageVersion } from "./help"; +import { computeVersionSkew } from "./version-skew"; export const SYSTEM_RESTART_REQUEST_TIMEOUT_MS = 5_000; export const SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS = 4_000; @@ -32,12 +34,23 @@ export interface BoundSystemRestartDeps { findLive?: typeof findLiveProxy; createChallenge?: () => string; now?: () => number; + /** Invoking CLI version for the skew guard; defaults to this bundle's package version. */ + cliVersion?: string; } function rejected(code: string): ProxyRestartRequestOutcome { return { accepted: false, uncertain: false, error: new Error(code) }; } +/** Own-bundle version for the skew comparison; an unreadable bundle is "cannot compare", not a crash. */ +function ownCliVersion(): string { + try { + return packageVersion(); + } catch { + return "unknown"; + } +} + function uncertain(code: string): ProxyRestartRequestOutcome { return { accepted: false, uncertain: true, error: new Error(code) }; } @@ -107,6 +120,18 @@ export async function requestBoundSystemRestart( return rejected("restart_capability_unsupported"); } + // An in-place restart respawns the live process from its own installation + // (selfLaunchArgv in server/management/system-restart.ts), so a restart accepted + // from a different-version CLI would keep the OLD build serving while reporting + // success (#4522). Both sides already publish exactly the data doctor's skew + // diagnosis compares (packageVersion vs the /healthz version), so reuse that + // comparison and refuse before POST. Placeholder versions (unknown/0.0.0) are + // "cannot compare", not mismatch, and keep the existing behavior. + const proxyVersion = typeof body.version === "string" ? body.version : undefined; + if (computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion).skewed) { + return rejected("restart_version_skew"); + } + let observed: LiveProxy | null; try { observed = await (deps.findLive ?? findLiveProxy)({ deadlineAt, nowFn: now }); diff --git a/structure/runtime.md b/structure/runtime.md index a09d007d7b..4743d070a4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -15,7 +15,7 @@ Shared parsing and streaming follow the [request-copy](transports/byte-accountin | --- | --- | | `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/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. 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. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary 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/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. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary 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/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. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/server/audio-transcriptions.ts` | Standalone multipart transcription; audio-specific key admission, bounded upload/response, stored OpenAI credential resolution and lease-bound cancellation. See [audio contracts](data-planes/inbound-compat.md#standalone-file-transcription). | diff --git a/tests/cli/system-restart-client.test.ts b/tests/cli/system-restart-client.test.ts index f7c8c7657a..5c1abe111c 100644 --- a/tests/cli/system-restart-client.test.ts +++ b/tests/cli/system-restart-client.test.ts @@ -52,6 +52,9 @@ function successfulDeps() { findLive: async () => target, createChallenge: () => challenge, now: () => 1_000, + // Matches the /healthz fixture version below so the skew guard stays out of the way; + // the dedicated skew tests override it explicitly. + cliVersion: "test", }, }; } @@ -144,6 +147,103 @@ describe("bound system restart client", () => { expect(setup.requests).toHaveLength(1); }); + 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"], + ] as const) { + const setup = successfulDeps(); + setup.deps.cliVersion = cliVersion; + setup.deps.fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + setup.requests.push({ url, init }); + if (url.endsWith("/healthz")) { + const response = successfulDepsResponse(setup.secret, setup.challenge); + const body = await response.json() as Record; + body.version = proxyVersion; + return new Response(JSON.stringify(body), { + status: 200, + headers: response.headers, + }); + } + throw new Error("POST must not be attempted"); + }) as typeof fetch; + + 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"); + expect(setup.requests).toHaveLength(1); + } + }); + + test("allows a restart when the invoking CLI matches the attested proxy version", async () => { + const setup = successfulDeps(); + setup.deps.cliVersion = "2.53.0"; + setup.deps.fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + setup.requests.push({ url, init }); + if (url.endsWith("/healthz")) { + const response = successfulDepsResponse(setup.secret, setup.challenge); + const body = await response.json() as Record; + body.version = "2.53.0"; + return new Response(JSON.stringify(body), { + status: 200, + headers: response.headers, + }); + } + return new Response(JSON.stringify({ success: true }), { status: 202 }); + }) as typeof fetch; + + expect(await requestBoundSystemRestart(target, 10_000, setup.deps)).toEqual({ accepted: true }); + expect(setup.requests).toHaveLength(2); + }); + + test("treats a placeholder proxy version as incomparable and keeps the restart path", async () => { + const setup = successfulDeps(); + setup.deps.cliVersion = "2.53.0"; + setup.deps.fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + setup.requests.push({ url, init }); + if (url.endsWith("/healthz")) { + const response = successfulDepsResponse(setup.secret, setup.challenge); + const body = await response.json() as Record; + body.version = "0.0.0"; + return new Response(JSON.stringify(body), { + status: 200, + headers: response.headers, + }); + } + return new Response(JSON.stringify({ success: true }), { status: 202 }); + }) as typeof fetch; + + expect(await requestBoundSystemRestart(target, 10_000, setup.deps)).toEqual({ accepted: true }); + expect(setup.requests).toHaveLength(2); + }); + + test("treats an unknown proxy version as incomparable and keeps the restart path", async () => { + const setup = successfulDeps(); + setup.deps.cliVersion = "2.53.0"; + setup.deps.fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + setup.requests.push({ url, init }); + if (url.endsWith("/healthz")) { + const response = successfulDepsResponse(setup.secret, setup.challenge); + const body = await response.json() as Record; + body.version = "unknown"; + return new Response(JSON.stringify(body), { + status: 200, + headers: response.headers, + }); + } + return new Response(JSON.stringify({ success: true }), { status: 202 }); + }) as typeof fetch; + + expect(await requestBoundSystemRestart(target, 10_000, setup.deps)).toEqual({ accepted: true }); + expect(setup.requests).toHaveLength(2); + }); + test("refuses to POST when the live target changes after attestation", async () => { const setup = successfulDeps(); setup.deps.findLive = async () => ({ ...target, pid: 4343 });