diff --git a/devlog/_plan/261004_cli_update_restart/000_plan.md b/devlog/_plan/261004_cli_update_restart/000_plan.md new file mode 100644 index 00000000000..c1b502df638 --- /dev/null +++ b/devlog/_plan/261004_cli_update_restart/000_plan.md @@ -0,0 +1,24 @@ +# Guarded update restart + +A newer CLI should be able to replace an older standalone proxy while preserving target ownership and confirming the exact launched replacement. This unit refines the public proposal in #6548; supervised cases remain explicit limitations until their replacement identity can be established. + +- Archetype: satisfy-spec, one cohesive implementation cycle (wp1). +- Trigger: next-release replacement of public PR #6548 at fe3cf0aa823afd4144ab240191939ec6145b1c45. +- Goal: attributed draft PR targeting dev with focused lifecycle tests, independent review and current-head CI evidence. +- Non-goals: merge, source closure, release, installed-runtime operations, account changes, provider calls, version changes or native stacks. +- Verifiers: focused Bun tests, typecheck, structure/privacy/layout/ratchet gates; docs build for changed public prose. Full/changed local suites are impractical alongside concurrent release/lane work; hosted CI supplies wider evidence. +- Stop: publish reviewable focused change, account for source coverage, fix applicable defects or bound missing native/hosted evidence in draft. +- Evidence: ignored scratch holds investigation, review and test receipts. Public prose records only the already-public feature and published outcomes. +- Outcomes: DONE means this delivery scope is met, not merged or all native environments validated. Unresolved ownership or required scope expansion remains explicit. +- Escalation: changes outside CLI orchestration/tests/docs require scope review. No token/cost/time limit was specified; no paid upstream probes are allowed. + +Base: dev 0818ea1812a028e1c14cd0b0511b44863407bc52. Source author: agentHits, 140916359+agentHits@users.noreply.github.com. Source consists of one commit and three files, with no native stack membership observed. + +Baseline: 44 tests pass across system-restart-client and cli-version-skew after frozen dependency installation. Original public review requests handling failed stop, missing replacement version and behavioral regressions. + +Current owner map: CLI index composes system-restart-client and tray-proxy; process-state owns runtime records; ownership-mutation-lease owns cooperative lifecycle serialization; proxy-liveness owns attestation/health; stop-approval owns settlement patterns; structure/runtime.md and public reference/cli/lifecycle.md describe lifecycle behavior. + +The no-code alternatives do not deliver the requested newer-install restart. Existing unrestricted stop/start orchestration cannot satisfy the new target contract. Reuse the existing owner primitives and place the narrow orchestration beside the CLI instead of enlarging its index. + +Architect consultation: Zeno (01a10493-8767-7422-8098-e28643decd60) proposed D1–D6. Main accepted eligibility, preserved snapshot, confirmed stop and exact replacement requirements; amended stop transport and child admission. Same architect reflected on the concrete scratch plan and returned ALIGNED, with transport/runtime evidence remaining implementation acceptance obligations. Independent audit follows. Family-level model independence is not claimed; the requested leaf model is gpt-6.1-sol. +The independent plan audit identified two lifecycle gaps; main amended the handoff and terminal result requirements. The architect rechecked D5/D6 and returned ALIGNED. Security analysis and detailed review remain in scratch. diff --git a/devlog/_plan/261004_cli_update_restart/010_delivery.md b/devlog/_plan/261004_cli_update_restart/010_delivery.md new file mode 100644 index 00000000000..0f95a7e31e9 --- /dev/null +++ b/devlog/_plan/261004_cli_update_restart/010_delivery.md @@ -0,0 +1,7 @@ +# wp1 — Publish the CLI restart replacement + +One implementation cycle produces an ordinary draft PR against dev. Preserve the newer-CLI upgrade intent and proxy-newer/incomparable refusal from #6548. Add focused lifecycle regression coverage, keep index composition minimal, and synchronize runtime and user lifecycle documentation. + +The scoped implementation and security audit remain in ignored scratch until the patch is public, per repository policy. Public delivery records source coverage, attribution, verification commands and native/live limits. No source PR is closed by this lane. + +Expected changed areas: src/cli restart siblings and index wiring; tests/cli lifecycle regressions; both test-layout registries; structure/runtime.md; docs-site lifecycle reference. No integration branch or version file changes. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 1d2fccae7e8..ea1eb252960 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -101,6 +101,19 @@ Missing or unreadable evidence blocks the guarded stop. ### `ocx restart` +When this CLI is newer than an attested standalone POSIX proxy, restart can stop the old +installation and launch this one. This guarded update requires an unclaimed physical home, +a detached proxy whose parent is PID 1, no installed or active service, and a known CLI version. +The stop uses the same connection that proved the old proxy's identity. The command launches +once only after confirmed shutdown, then requires the exact child PID, endpoint, fresh identity +proof and matching version. Missing version, uncertain stop, timeout or an unexpected replacement +reports failure without another stop or start. If the runtime this CLI would launch is still the +small placeholder an in-place npm install leaves before its postinstall, restart refuses before +stopping anything; after a confirmed stop it waits for the runtime within the same deadline and +launches nothing if it does not arrive. Windows, foreground, desktop-supervised, service, +connected-client and sibling runtimes do not use this update path; use their owning lifecycle +controls. A newer proxy or incomparable version still refuses an in-place downgrade. + When a proxy is running, ask that exact attested PID and port to restart in place, wait for its normal drain, and verify a different runtime PID on the same port. Managed routing and service supervision stay installed throughout; an uncertain request is observed rather than replayed as a @@ -122,7 +135,7 @@ the previous attempt never launched a child or the launched child is known to ha If a launch may still be running, restart reports the original failure without starting a second proxy, even when a health probe finds nothing. When an accepted restart never publishes a replacement, the command re-observes once before giving up — a proxy that crashed mid-restart reads absent and is started fresh, -while a replacement that landed just past the shortened replacement wait still proves success within the overall observation deadline. A live target is +while a replacement that landed just past the shortened replacement wait still proves success within the overall observation deadline. Outside the guarded newer-CLI update path, a live target is never stopped to make room, so a stale-but-listening process can not be replaced by a second proxy racing it for the port. Every failed start attempt is followed by a beat and a strong re-observation before the diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c5f561de82d..ca695c69cfd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,10 @@ "version": 1, "root": "tests", "explicit": { + "cli-update-restart.test.ts": "cli", + "cli-update-restart-child.test.ts": "cli", + "cli-update-restart-transport.test.ts": "cli", + "cli-update-restart-home.test.ts": "cli", "codex-shim-overlay.test.ts": "codex-integration", "codex-shim-migration.test.ts": "codex-integration", "codex-shim-overlay-transaction.test.ts": "codex-integration", diff --git a/src/cli/index.ts b/src/cli/index.ts index fd0ed56099f..9cd6775cccf 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,9 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { currentServingCommand, deferServiceChildToNewerRuntime, markDelegatedServiceReady, recordServingRuntime } from "../config/serving-runtimes"; import { packageVersion } from "../lib/package-version"; +import { admitUpdateRestartChild } from "./update-restart-child"; +import { UpdateRestartRequired } from "./update-restart-candidate"; +import { describeUpdateRestartFailure, restartFromCurrentInstallation } from "./update-restart"; import { findGuiDist } from "../server/gui-static"; import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness"; @@ -201,6 +204,7 @@ async function refreshOwnedRaycastCatalog( } } +const updateRestartChild = admitUpdateRestartChild(process.argv.slice(2)); initializeNodeLauncherContext(); // The compiled executable is also the capture-only MCP server's launcher. // Handle this private entrypoint before CLI preflight or command dispatch. @@ -372,6 +376,7 @@ async function findProxyOwnerBeforeJournalRecovery( } async function handleStart(options: { block?: boolean } = {}) { + updateRestartChild?.check(); // A supervised service child defers to a foreign recorded owner before doing // anything else. 'ocx service start' refuses this activation path already, but // the process managers below it — the Windows boot wrapper's restart loop and @@ -467,6 +472,7 @@ async function handleStart(options: { block?: boolean } = {}) { }, }); + updateRestartChild?.check(); const clientState = readClientConnectionState(); if (clientState.kind === "invalid" || clientState.kind === "mismatched") { throw new Error(`client startup refused: ${clientState.reason}`); @@ -551,7 +557,17 @@ async function handleStart(options: { block?: boolean } = {}) { let server: ReturnType; for (let attempt = 0; ; attempt++) { try { + if (updateRestartChild && siblingStart) throw new Error("update_restart_sibling_refused"); + updateRestartChild?.check(port, loadConfig().hostname ?? ""); server = serverModule.startServer(port, { localAttestationSecret, readinessGate }); + if (updateRestartChild) { + try { updateRestartChild.check(port, server.hostname); } + catch (error) { + try { await server.stop(true); } + catch (rollback) { throw new StartOwnershipRollbackUncertainError([error, rollback]); } + throw error; + } + } break; } catch (err) { try { await serverModule.waitForFailedStartRollback(err); } @@ -579,14 +595,14 @@ async function handleStart(options: { block?: boolean } = {}) { } return { server, serverModule, port, readinessGate, localAttestationSecret, config }; }, - writePid: () => writePid(process.pid), - writeRuntime: bound => writeRuntimePort({ + writePid: () => { updateRestartChild?.check(); writePid(process.pid); }, + writeRuntime: bound => { updateRestartChild?.check(bound.port, bound.config.hostname ?? ""); writeRuntimePort({ pid: process.pid, port: bound.port, hostname: bound.config.hostname, attestationSecret: bound.localAttestationSecret, ...siblingRuntimeField(), - }), + }); updateRestartChild?.complete(); }, stopBound: bound => bound.server.stop(true), removeRuntime: () => removeRuntimePortIfPidIs(process.pid), removePid: () => removePidIfValueIs(process.pid), @@ -947,6 +963,15 @@ async function handleProxyRestart( findLive: () => findLiveProxy({ deadlineAt: end, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= end, })), }); + if (!result.ok && result.phase === "request" && result.error instanceof UpdateRestartRequired) { + const candidate = result.error.candidate(); + console.log(`🔄 Running proxy ${candidate.target.version} is older than this CLI (${candidate.cliVersion}); restarting it from the current installation...`); + const update = await restartFromCurrentInstallation(candidate, deadlineAt, detachedStartEnvironment()); + if (update.ok) console.log(`✅ Proxy updated to ${update.live.version} (PID ${update.live.pid}).`); + else console.error(`❌ ${describeUpdateRestartFailure(update.code)} (${update.code})`); + process.exitCode = update.ok ? 0 : 1; + return update.ok; + } if (!result.ok) 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..5cffaf27238 100644 --- a/src/cli/system-restart-client.ts +++ b/src/cli/system-restart-client.ts @@ -26,6 +26,8 @@ import { import type { ProxyRestartRequestOutcome } from "./tray-proxy"; import { packageVersion } from "./help"; import { computeVersionSkew } from "./version-skew"; +import { readUpdateRestartHome } from "./update-restart-home"; +import { UpdateRestartRequired } from "./update-restart-candidate"; export const SYSTEM_RESTART_REQUEST_TIMEOUT_MS = 5_000; export const SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS = 4_000; @@ -38,6 +40,7 @@ export interface BoundSystemRestartDeps { now?: () => number; /** Invoking CLI version for the skew guard; defaults to this bundle's package version. */ cliVersion?: string; + readUpdateHome?: typeof readUpdateRestartHome; } function rejected(code: string): ProxyRestartRequestOutcome { @@ -90,6 +93,8 @@ export async function requestBoundSystemRestart( return rejected("restart_target_runtime_mismatch"); } + let updateHome: ReturnType | undefined; + try { updateHome = (deps.readUpdateHome ?? readUpdateRestartHome)(); } catch { /* normal in-place restart remains available */ } const attestationBudget = remaining(deadlineAt, now, SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS); if (attestationBudget <= 0) return rejected("restart_deadline_expired"); @@ -143,9 +148,9 @@ 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 cliVersion = deps.cliVersion ?? ownCliVersion(); + const skew = computeVersionSkew(cliVersion, proxyVersion); + if (skew.skewed && skew.relation !== "cli-newer") return rejected("restart_version_skew"); let observed: LiveProxy | null; try { @@ -154,6 +159,18 @@ export async function requestBoundSystemRestart( return rejected("restart_target_recheck_failed"); } if (!sameRestartTarget(target, observed)) return rejected("restart_target_changed"); + if (skew.relation === "cli-newer") { + if (!updateHome) return rejected("update_restart_home_unverified"); + const current = readRuntime(target.pid); + if (!current || current.pid !== runtime.pid || current.port !== runtime.port + || current.hostname !== runtime.hostname || current.attestationSecret !== runtime.attestationSecret) { + return rejected("restart_target_changed"); + } + return { accepted: false, uncertain: false, error: new UpdateRestartRequired({ + target: { ...target, pid: target.pid, version: proxyVersion }, + runtime: { ...runtime, attestationSecret: runtime.attestationSecret }, cliVersion, home: updateHome, + }) }; + } const capability = createSystemRestartCapability( runtime.attestationSecret, diff --git a/src/cli/update-restart-candidate.ts b/src/cli/update-restart-candidate.ts new file mode 100644 index 00000000000..f57104f87a2 --- /dev/null +++ b/src/cli/update-restart-candidate.ts @@ -0,0 +1,21 @@ +import type { RuntimePortState } from "../config/process-state"; +import type { LiveProxy } from "../server/proxy-liveness"; + +import type { UpdateRestartHome } from "./update-restart-home"; + +export interface UpdateRestartCandidate { + home: UpdateRestartHome; + target: LiveProxy & { pid: number }; + runtime: RuntimePortState & { attestationSecret: string }; + cliVersion: string; +} + +/** Private in-process evidence; diagnostics and JSON never expose the proof key. */ +export class UpdateRestartRequired extends Error { + #candidate: UpdateRestartCandidate; + constructor(candidate: UpdateRestartCandidate) { + super("restart_version_skew_cli_newer"); + this.#candidate = candidate; + } + candidate(): UpdateRestartCandidate { return this.#candidate; } +} diff --git a/src/cli/update-restart-child.ts b/src/cli/update-restart-child.ts new file mode 100644 index 00000000000..817364ea038 --- /dev/null +++ b/src/cli/update-restart-child.ts @@ -0,0 +1,81 @@ +import { existsSync } from "node:fs"; +import { getRuntimePortPath, readRuntimePort } from "../config/process-state"; +import { packageVersion } from "../lib/package-version"; +import { parseStrictSemver } from "../lib/strict-semver"; +import { acquireOwnershipMutationLease, OWNERSHIP_MUTATION_LEASE_TOKEN_ENV } from "../service/ownership-mutation-lease.mjs"; +import { serviceStatePaths } from "../service/state"; +import { assertUpdateRestartConfiguration, assertUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home"; + +export const UPDATE_RESTART_CHILD_ENV = "OCX_UPDATE_RESTART_CHILD"; +export interface UpdateRestartChildMarker { home: UpdateRestartHome; version: string; port: number; hostname: string; deadlineAt: number } +export interface UpdateRestartChildGuard { check(port?: number, hostname?: string): void; complete(): void } + +function parseMarker(raw: string): UpdateRestartChildMarker { + if (raw.length > 8192) throw new Error("update_restart_child_marker_invalid"); + const value = JSON.parse(raw) as UpdateRestartChildMarker; + if (!value || !Number.isSafeInteger(value.deadlineAt) || !Number.isInteger(value.port) + || typeof value.hostname !== "string" || value.port < 1 || value.port > 65535 || typeof value.version !== "string" + || value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home + || !Number.isSafeInteger(value.home.revision)) throw new Error("update_restart_child_marker_invalid"); + for (const directory of [value.home.config, value.home.codex]) { + if (!directory || typeof directory.path !== "string" || !directory.path + || !Number.isSafeInteger(directory.dev) || !Number.isSafeInteger(directory.ino)) { + throw new Error("update_restart_child_marker_invalid"); + } + } + return value; +} + +export interface UpdateRestartChildIo { + env?: NodeJS.ProcessEnv; + now?: () => number; + version?: () => string; + checkHome?: (home: UpdateRestartHome) => void; + acquire?: (waitMs: number) => { release(): void }; + onExit?: (release: () => void) => void; + checkState?: (marker: UpdateRestartChildMarker) => void; + armDeadline?: (remainingMs: number) => () => void; +} + +/** Admit the replacement before CLI preflight; custody belongs to this child PID. */ +export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo = {}): UpdateRestartChildGuard | undefined { + const env = io.env ?? process.env; + const raw = env[UPDATE_RESTART_CHILD_ENV]; + if (raw === undefined) return undefined; + delete env[UPDATE_RESTART_CHILD_ENV]; + delete env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + const marker = parseMarker(raw); + if (argv.length !== 3 || argv[0] !== "start" || argv[1] !== "--port" || argv[2] !== String(marker.port)) { + throw new Error("update_restart_child_command_changed"); + } + const now = io.now ?? Date.now; + const check = (port = marker.port, hostname = marker.hostname) => { + if (now() >= marker.deadlineAt) throw new Error("update_restart_deadline_expired"); + const bindHost = (host: string) => !host.trim() || /^localhost\.?$/i.test(host.trim()) ? "127.0.0.1" : host.trim(); + if (port !== marker.port || bindHost(hostname) !== bindHost(marker.hostname) || (io.version ?? packageVersion)() !== marker.version) throw new Error("update_restart_child_identity_changed"); + (io.checkHome ?? assertUpdateRestartHome)(marker.home); + (io.checkState ?? (expected => { + assertUpdateRestartConfiguration(expected.hostname); + const current = readRuntimePort(); + if (existsSync(getRuntimePortPath()) && (!current || current.pid !== process.pid + || current.port !== expected.port || current.siblingOfPort !== undefined)) { + throw new Error("update_restart_competing_runtime"); + } + }))(marker); + }; + check(); + const lease = (io.acquire ?? (waitMs => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs })))(Math.min(2000, marker.deadlineAt - now())); + let released = false; + let cancelDeadline: (() => void) | undefined; + const release = () => { if (!released) { released = true; cancelDeadline?.(); lease.release(); } }; + try { + check(); + cancelDeadline = (io.armDeadline ?? (remainingMs => { + const timer = setTimeout(() => process.exit(1), remainingMs); + timer.unref(); + return () => clearTimeout(timer); + }))(marker.deadlineAt - now()); + (io.onExit ?? (callback => { process.once("exit", callback); }))(release); + return { check, complete: () => { check(); release(); } }; + } catch (error) { release(); throw error; } +} diff --git a/src/cli/update-restart-home.ts b/src/cli/update-restart-home.ts new file mode 100644 index 00000000000..bec52b47ef5 --- /dev/null +++ b/src/cli/update-restart-home.ts @@ -0,0 +1,38 @@ +import { realpathSync, statSync } from "node:fs"; +import { readClientConnectionState } from "../client/state"; +import { readConfigDiagnostics } from "../config/diagnostics"; +import { getConfigDir } from "../config/paths"; +import { currentCodexHome, resolveServiceOwnership, resolveServiceState } from "../service/state"; + +export interface UpdateRestartHome { + config: { path: string; dev: number; ino: number }; + codex: { path: string; dev: number; ino: number }; + revision: number; +} + +function physicalDirectory(path: string): UpdateRestartHome["config"] { + const canonical = realpathSync.native(path); + const stat = statSync(canonical); + if (!stat.isDirectory()) throw new Error("update_restart_home_unverified"); + return { path: canonical, dev: stat.dev, ino: stat.ino }; +} + +/** Only an unclaimed standalone home is eligible for this narrow update path. */ +export function readUpdateRestartHome(): UpdateRestartHome { + const state = resolveServiceState(); + const owner = resolveServiceOwnership(); + if (state.kind !== "none" || owner.kind !== "none") throw new Error("update_restart_owner_unverified"); + return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision }; +} + +export function assertUpdateRestartHome(expected: UpdateRestartHome): void { + const current = readUpdateRestartHome(); + if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("update_restart_home_changed"); +} + +/** Read-only eligibility shared by parent pre-stop and child admission. */ +export function assertUpdateRestartConfiguration(hostname: string): void { + const diagnostics = readConfigDiagnostics(); + if (readClientConnectionState().kind !== "disconnected" || diagnostics.error + || (diagnostics.config.hostname ?? "") !== hostname) throw new Error("update_restart_configuration_changed"); +} diff --git a/src/cli/update-restart-transport.ts b/src/cli/update-restart-transport.ts new file mode 100644 index 00000000000..456189cbd61 --- /dev/null +++ b/src/cli/update-restart-transport.ts @@ -0,0 +1,193 @@ +import { Agent, request, type ClientRequest, type IncomingMessage } from "node:http"; +import { connect, type Socket } from "node:net"; +import type { Duplex } from "node:stream"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationChallenge, + isLocalAttestationSecret, + verifyLocalAttestationProof, +} from "../lib/local-management-attestation"; +import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract"; +import { isOpencodexHealthz, probeHostname, type LiveProxy } from "../server/proxy-liveness"; +import { computeVersionSkew } from "./version-skew"; + +const FAILURE = "update_restart_stop_failed"; +const MAX_RESPONSE_BYTES = 64 * 1024; + +/** This agent owns exactly one direct TCP connection, including on Bun's HTTP layer. */ +class AttestedAgent extends Agent { + socket?: Socket; + private connected = false; + + constructor(private readonly hostname: string, private readonly port: number) { + super({ keepAlive: true, maxSockets: 1, maxTotalSockets: 1, maxFreeSockets: 1 }); + } + + override createConnection( + _options: unknown, + callback?: (error: Error | null, stream: Duplex) => void, + ): Socket | undefined { + if (this.connected) { + // Returning no socket with an error also refuses queued reconnect attempts. + callback?.(new Error(FAILURE), this.socket!); + return undefined; + } + this.connected = true; + this.socket = connect({ host: this.hostname, port: this.port, autoSelectFamily: false }); + return this.socket; + } +} + +function checkDeadline(deadlineAt: number): void { + if (!Number.isFinite(deadlineAt) || Date.now() >= deadlineAt) throw new Error(FAILURE); +} + +function readJson(response: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + const fail = () => { reject(new Error(FAILURE)); response.destroy(); }; + response.on("error", fail); + response.on("aborted", fail); + response.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_RESPONSE_BYTES) { fail(); return; } + chunks.push(chunk); + }); + response.on("end", () => { + try { + const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!response.complete || value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(FAILURE); + } + resolve(value as Record); + } catch { reject(new Error(FAILURE)); } + }); + }); +} + +type AttestedTarget = { target: LiveProxy & { pid: number }; secret: string; deadlineAt: number }; +type JsonReply = { response: IncomingMessage; body: Record }; + +class AttestedSession { + readonly agent: AttestedAgent; + readonly challenge = createLocalAttestationChallenge(); + private readonly hostname: string; + private readonly timer: ReturnType; + private current?: ClientRequest; + + constructor(private readonly options: AttestedTarget) { + const { target, secret, deadlineAt } = options; + checkDeadline(deadlineAt); + if (target.source !== "runtime" || target.role !== undefined || target.packageTreeFenced + || !Number.isSafeInteger(target.pid) || target.pid <= 0 + || !Number.isInteger(target.port) || target.port < 1 || target.port > 65535 + || !isLocalAttestationSecret(secret)) throw new Error(FAILURE); + const host = probeHostname(target.hostname); + this.hostname = host.startsWith("[") ? host.slice(1, -1) : host; + this.agent = new AttestedAgent(this.hostname, target.port); + this.timer = setTimeout(() => { + this.current?.destroy(new Error(FAILURE)); + this.agent.socket?.destroy(new Error(FAILURE)); + }, Math.min(deadlineAt - Date.now(), 2_147_483_647)); + } + + exchange(adminToken?: string): Promise { + const stop = adminToken !== undefined; + return new Promise((resolve, reject) => { + const req = request({ + hostname: this.hostname, port: this.options.target.port, agent: this.agent, + method: stop ? "POST" : "GET", path: stop ? "/api/stop" : "/healthz", + maxHeaderSize: MAX_RESPONSE_BYTES, + headers: stop ? { "content-length": "0" } : { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: this.challenge }, + }); + this.current = req; + req.on("error", reject); + req.on("response", response => { + readJson(response).then(body => resolve({ response, body }), reject); + }); + req.once("socket", (socket: Socket) => { + try { + checkDeadline(this.options.deadlineAt); + if (socket !== this.agent.socket || socket.destroyed || !socket.writable + || (stop && (socket.connecting || socket.readableEnded || socket.writableEnded))) { + throw new Error(FAILURE); + } + // No end/flushHeaders/write, or credential header, before this socket check. + if (adminToken !== undefined) req.setHeader("X-OpenCodex-API-Key", adminToken); + req.end(); + } catch { req.destroy(new Error(FAILURE)); } + }); + }); + } + + validateHealth(health: JsonReply): void { + const { target, secret, deadlineAt } = this.options; + const proof = health.response.headers[LOCAL_ATTESTATION_PROOF_HEADER]; + checkDeadline(deadlineAt); + if (health.response.statusCode !== 200 || !isOpencodexHealthz(health.body) + || health.body.status !== "ok" || health.body.role !== undefined + || health.body.pid !== target.pid || health.body.port !== target.port + || !verifyLocalAttestationProof(secret, this.challenge, target.pid, target.port, typeof proof === "string" ? proof : null)) { + throw new Error(FAILURE); + } + } + + close(): void { + clearTimeout(this.timer); + this.current?.destroy(); + this.agent.destroy(); + this.agent.socket?.destroy(); + } +} + +/** Proof and credential delivery must remain on one established channel; never retry. */ +export async function stopAttestedUpdateTarget(options: AttestedTarget & { + cliVersion: string; + beforeStop: () => void; + adminToken: string; +}): Promise { + let session: AttestedSession | undefined; + try { + const { cliVersion, deadlineAt, beforeStop, adminToken } = options; + if (!adminToken) throw new Error(FAILURE); + session = new AttestedSession(options); + const health = await session.exchange(); + session.validateHealth(health); + if (health.body.restartCapability !== SYSTEM_RESTART_CAPABILITY_VERSION + || computeVersionSkew(cliVersion, typeof health.body.version === "string" ? health.body.version : undefined).relation !== "cli-newer" + || /\bclose\b/i.test(health.response.headers.connection ?? "")) throw new Error(FAILURE); + checkDeadline(deadlineAt); + beforeStop(); + // Synchronous candidate revalidation and POST enqueue: no intervening await. + const stopped = await session.exchange(adminToken); + checkDeadline(deadlineAt); + if (stopped.response.statusCode !== 200 || stopped.body.success !== true + || stopped.body.sharedTeardown !== "performed") throw new Error(FAILURE); + } catch { + // Never expose parser/network/callback errors (which may contain credentials). + throw new Error(FAILURE); + } finally { + session?.close(); + } +} + +/** Observe only the caller's exact newly started runtime; ordinary liveness is not proof. */ +export async function observeAttestedUpdateReplacement(options: AttestedTarget & { + version: string; +}): Promise { + let session: AttestedSession | undefined; + try { + session = new AttestedSession(options); + const health = await session.exchange(); + session.validateHealth(health); + if (health.body.version !== options.version) throw new Error(FAILURE); + return { pid: options.target.pid, port: options.target.port, hostname: options.target.hostname, + source: "runtime", version: options.version }; + } catch { + throw new Error("update_restart_replacement_observation_failed"); + } finally { + session?.close(); + } +} diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts new file mode 100644 index 00000000000..c8a300597b6 --- /dev/null +++ b/src/cli/update-restart.ts @@ -0,0 +1,211 @@ +import { execFileSync, spawn } from "node:child_process"; +import { isIP } from "node:net"; +import { existsSync } from "node:fs"; +import { getRuntimePortPath, readRuntimePort, readProcessCommandLine, isOcxStartCommandLine, type RuntimePortState } from "../config/process-state"; +import { configuredAdminToken } from "../lib/admin-secrets"; +import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; +import { isProcessAlive } from "../lib/process-control"; +import { parseStrictSemver } from "../lib/strict-semver"; +import { startArgv } from "../lib/self-launch-argv"; +import { diagnoseService } from "../service/diagnostics"; +import { inspectGuardedManagerTarget } from "../service/guarded-manager-target"; +import { acquireOwnershipMutationLease, unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs"; +import { serviceStatePaths } from "../service/state"; +import { findLiveProxy, probeEndpointLiveness, probeHostname, type LiveProxy } from "../server/proxy-liveness"; +import { waitForPortAvailable } from "../server/ports"; +import { UPDATE_RESTART_CHILD_ENV, type UpdateRestartChildMarker } from "./update-restart-child"; +import { assertUpdateRestartConfiguration, assertUpdateRestartHome, readUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home"; +import { observeAttestedUpdateReplacement, stopAttestedUpdateTarget } from "./update-restart-transport"; +import type { UpdateRestartCandidate } from "./update-restart-candidate"; +import { computeVersionSkew } from "./version-skew"; + +export interface UpdateRestartChild { pid?: number; exitCode: number | null; signalCode: string | null } +export interface UpdateRestartIo { + now(): number; + acquire(): { release(): void }; + home(): UpdateRestartHome; + checkHome(home: UpdateRestartHome): void; + runtime(): RuntimePortState | null; + standalone(target: UpdateRestartCandidate["target"]): boolean; + /** Whether the executable the replacement will run is complete (not the npm `bun` placeholder). */ + runtimeReady(): boolean; + stop(candidate: UpdateRestartCandidate, deadlineAt: number, beforeStop: () => void): Promise; + stopped(candidate: UpdateRestartCandidate, deadlineAt: number): Promise; + start(marker: UpdateRestartChildMarker): UpdateRestartChild; + observe(deadlineAt: number, childPid: number): Promise; + wait(ms: number): Promise; +} +export type UpdateRestartResult = { ok: true; live: LiveProxy } | { ok: false; code: string }; + +function sameRuntime(candidate: UpdateRestartCandidate, current: RuntimePortState | null): boolean { + const expected = candidate.runtime; + return !!current && current.pid === expected.pid && current.port === expected.port + && current.hostname === expected.hostname && current.attestationSecret === expected.attestationSecret + && current.siblingOfPort === undefined; +} + +/** A terminal update transaction: it never enters generic restart recovery. */ +export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadlineAt: number, io: UpdateRestartIo): Promise { + let phase = "eligibility"; + let lease: { release(): void } | undefined; + // Recorded here because the stop transport sanitizes anything thrown by beforeStop. + let runtimeRefused = false; + try { + if (!parseStrictSemver(candidate.cliVersion) || candidate.cliVersion === "0.0.0" + || computeVersionSkew(candidate.cliVersion, candidate.target.version).relation !== "cli-newer") { + throw new Error("version"); + } + const withinDeadline = () => { if (io.now() >= deadlineAt) throw new Error("deadline"); }; + withinDeadline(); + lease = io.acquire(); + const home = candidate.home; + if (JSON.stringify(io.home()) !== JSON.stringify(home)) throw new Error("home_changed"); + const revalidate = () => { + withinDeadline(); + io.checkHome(home); + if (!sameRuntime(candidate, io.runtime()) || !io.standalone(candidate.target)) throw new Error("target"); + if (!io.runtimeReady()) { + runtimeRefused = true; + throw new Error("runtime"); + } + withinDeadline(); + }; + revalidate(); + phase = "stop"; + await io.stop(candidate, deadlineAt, revalidate); + withinDeadline(); + phase = "settle"; + if (!await io.stopped(candidate, deadlineAt)) throw new Error("stop_unconfirmed"); + // Shutdown is confirmed from here on and nothing has launched yet. + phase = "prelaunch"; + withinDeadline(); + io.checkHome(home); + // An in-place npm install can still be swapping its Bun in; wait for it inside the deadline, + // then repeat the launch preconditions synchronously so the wait opens no gap before spawn. + phase = "runtime"; + while (!io.runtimeReady()) { + withinDeadline(); + await io.wait(Math.min(100, deadlineAt - io.now())); + } + phase = "prelaunch"; + withinDeadline(); + io.checkHome(home); + phase = "start"; + const child = io.start({ home, version: candidate.cliVersion, port: candidate.target.port, hostname: candidate.runtime.hostname ?? "", deadlineAt }); + // The child must acquire its own lease and validate this frozen handoff before preflight. + lease.release(); + lease = undefined; + if (!Number.isSafeInteger(child.pid) || !child.pid || child.pid === candidate.target.pid) throw new Error("launch_unknown"); + phase = "replacement"; + while (io.now() < deadlineAt) { + if (child.exitCode !== null || child.signalCode !== null) throw new Error("child_exited"); + io.checkHome(home); + const live = await io.observe(deadlineAt, child.pid); + withinDeadline(); + if (live) { + io.checkHome(home); + if (live.source !== "runtime" || live.pid !== child.pid || live.port !== candidate.target.port + || (live.hostname ?? "") !== (candidate.target.hostname ?? "") || live.role === "client" + || live.packageTreeFenced || live.version !== candidate.cliVersion) throw new Error("replacement_mismatch"); + return { ok: true, live }; + } + await io.wait(Math.min(100, deadlineAt - io.now())); + } + throw new Error("deadline"); + } catch { + return { ok: false, code: runtimeRefused ? "update_restart_runtime_incomplete" : `update_restart_${phase}_failed` }; + } + finally { lease?.release(); } +} + +/** Sanitized, actionable text for each terminal update-restart code; it claims only what that phase proved. */ +export function describeUpdateRestartFailure(code: string): string { + switch (code) { + case "update_restart_runtime_incomplete": + return "This installation's Bun runtime is still being installed; nothing was stopped. Wait for the install to finish, then run `ocx restart` again."; + case "update_restart_eligibility_failed": + return "The running proxy is not eligible for an update restart from this CLI (it is supervised, shared, or changed); nothing was stopped."; + case "update_restart_stop_failed": + return "The guarded stop of the old proxy could not be confirmed; inspect `ocx status` before retrying."; + case "update_restart_settle_failed": + return "The old proxy was not confirmed stopped, so no second proxy was started; inspect `ocx status` before retrying."; + case "update_restart_prelaunch_failed": + return "The old proxy stopped, but its home, ownership or deadline changed before launch, so nothing was launched. Check `ocx status`; if no proxy is running, run `ocx start`."; + case "update_restart_runtime_failed": + return "The old proxy stopped, but this installation's Bun runtime did not finish installing in time, so nothing was launched. Run `ocx start` once the install completes."; + case "update_restart_start_failed": + return "The old proxy stopped, but the new proxy's launch could not be confirmed. Check `ocx status`; if no proxy is running, run `ocx start`."; + case "update_restart_replacement_failed": + return "The new proxy did not verify as the expected replacement in time; inspect `ocx status` before retrying."; + default: + return "Update restart could not be confirmed; inspect `ocx status` before retrying."; + } +} + +function standalone(target: UpdateRestartCandidate["target"]): boolean { + assertUpdateRestartConfiguration(target.hostname ?? ""); + if (process.platform !== "darwin" && process.platform !== "linux") return false; + const host = probeHostname(target.hostname).replace(/^\[|\]$/g, ""); + if (!isIP(host) || target.source !== "runtime" || target.role === "client" || target.packageTreeFenced) return false; + const command = readProcessCommandLine(target.pid); + if (!command || !isOcxStartCommandLine(command) || diagnoseService().installed + || inspectGuardedManagerTarget(target.pid, target.port).kind !== "absent") return false; + try { + return execFileSync("ps", ["-o", "ppid=", "-p", String(target.pid)], { + encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"], + }).trim() === "1"; + } catch { return false; } +} + +/** Production composition keeps stop/start side effects out of cli/index.ts. */ +export function restartFromCurrentInstallation(candidate: UpdateRestartCandidate, deadlineAt: number, environment: NodeJS.ProcessEnv): Promise { + const argv = startArgv(candidate.target.port); + const executable = process.execPath; + return runUpdateRestart(candidate, deadlineAt, { + now: Date.now, + acquire: () => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs: Math.max(0, Math.min(2000, deadlineAt - Date.now())) }), + home: readUpdateRestartHome, checkHome: assertUpdateRestartHome, runtime: readRuntimePort, standalone, + runtimeReady: () => isRealBunBinary(executable), + stop: async (target, deadline, beforeStop) => { + const token = configuredAdminToken(); + if (!token) throw new Error("update_restart_credential_unavailable"); + await stopAttestedUpdateTarget({ target: target.target, secret: target.runtime.attestationSecret, + cliVersion: target.cliVersion, deadlineAt: deadline, beforeStop, adminToken: token }); + }, + stopped: async (target, deadline) => { + while (Date.now() < deadline && isProcessAlive(target.target.pid)) await Bun.sleep(Math.min(100, deadline - Date.now())); + if (Date.now() >= deadline || existsSync(getRuntimePortPath())) return false; + const endpoint = { port: target.target.port, hostname: target.target.hostname }; + if (!await waitForPortAvailable(endpoint.port, probeHostname(endpoint.hostname), + { timeoutMs: Math.max(0, Math.min(1000, deadline - Date.now())), intervalMs: 50 })) return false; + if (Date.now() >= deadline) return false; + return await probeEndpointLiveness(endpoint, { timeoutMs: Math.min(250, deadline - Date.now()) }) === "dead"; + }, + start: marker => { + const env = unprivilegedOwnershipMutationEnvironment(environment); + env.OPENCODEX_HOME = marker.home.config.path; + env.CODEX_HOME = marker.home.codex.path; + env[UPDATE_RESTART_CHILD_ENV] = JSON.stringify(marker); + const child = spawn(executable, argv, { detached: true, stdio: "ignore", windowsHide: true, env }); + child.on("error", () => {}); // exit/health evidence reports failure; never retry a possibly launched child. + child.unref(); + return child; + }, + observe: async (deadline, childPid) => { + const live = await findLiveProxy({ deadlineAt: deadline, attempts: 1 }); + if (!live) return null; + if (live.pid !== childPid) throw new Error("update_restart_replacement_changed"); + const runtime = readRuntimePort(childPid); + if (!runtime?.attestationSecret || runtime.port !== candidate.target.port + || runtime.hostname !== candidate.runtime.hostname || runtime.siblingOfPort !== undefined) { + throw new Error("update_restart_replacement_unattested"); + } + const attested = await observeAttestedUpdateReplacement({ target: { ...live, pid: childPid }, + secret: runtime.attestationSecret, version: candidate.cliVersion, deadlineAt: deadline }); + const current = readRuntimePort(childPid); + if (JSON.stringify(current) !== JSON.stringify(runtime)) throw new Error("update_restart_replacement_changed"); + return attested; + }, + wait: ms => Bun.sleep(ms), + }); +} diff --git a/structure/runtime.md b/structure/runtime.md index cabd20b4e58..dec7f605844 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. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` suppresses it 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 requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place 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. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` suppresses it 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. | @@ -142,7 +142,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes and route resolution. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | -| `src/cli/tray-proxy.ts` | Proxy restart coordination. Replacement re-observation captures one clock value for both its remaining budget and absolute end, so the observation window never extends the caller deadline. | +| `src/cli/tray-proxy.ts` | Proxy restart coordination. Replacement re-observation captures one clock value for both its remaining budget and absolute end, so the observation window never extends the caller deadline. CLI proxy update restart: `src/cli/system-restart-client.ts` preserves a private newer-CLI candidate with its original runtime record and physical home snapshot. `src/cli/update-restart.ts` accepts only known versions and positively classified standalone POSIX targets: PID-1 parent, no installed/active manager, no ownership record, no client/sibling role. It holds the existing ownership mutation lease through stop and spawn and revalidates the captured target before stop. `src/cli/update-restart-transport.ts` proves identity and sends stop on one direct TCP connection, refuses reconnects and requires complete successful teardown acknowledgement. It never signals a PID or reclaims a port. Confirmed predecessor exit, runtime-record removal, port availability and definitive endpoint absence precede the one launch. The executable that launch spawns must pass the shared `REAL_BUN_MIN_BYTES` gate: an incomplete runtime refuses before any stop byte, and after a confirmed stop the transaction waits for it within the deadline, then rechecks deadline and physical home synchronously before spawning. `src/cli/update-restart-child.ts` strips inherited lease delegation, acquires its own lease before CLI preflight and checks the frozen physical homes, ownership revision, CLI version, endpoint and deadline before startup, each bind and publication. It keeps custody through rollback; its deadline ends an unfinished child. `src/cli/update-restart-home.ts` owns physical-home and unclaimed-state checks. Success requires that launched child PID, fresh attestation and the exact known version; all incomplete outcomes terminate without generic restart recovery. Supervised, Windows and uncertain cases refuse this update path. | | `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration; `src/codex/shim-diagnostics.ts` owns diagnosis/status and distinguishes runnable from active overlays. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | diff --git a/tests/cli/cli-update-restart-child.test.ts b/tests/cli/cli-update-restart-child.test.ts new file mode 100644 index 00000000000..4043cac33ed --- /dev/null +++ b/tests/cli/cli-update-restart-child.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { admitUpdateRestartChild, UPDATE_RESTART_CHILD_ENV, type UpdateRestartChildIo } from "../../src/cli/update-restart-child"; +import { OWNERSHIP_MUTATION_LEASE_TOKEN_ENV } from "../../src/service/ownership-mutation-lease.mjs"; + +function setup() { + const calls: string[] = []; + let now = 1000; + let exit: (() => void) | undefined; + const marker = { home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0 }, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 }; + const env = { [UPDATE_RESTART_CHILD_ENV]: JSON.stringify(marker), [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "parent-token" }; + const io: UpdateRestartChildIo = { + checkState: () => {}, + env, now: () => now, version: () => "2.77.0", checkHome: () => { calls.push("home"); }, + acquire: () => { expect(env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]).toBeUndefined(); calls.push("acquire-own"); return { release: () => { calls.push("release-own"); } }; }, + onExit: release => { exit = release; }, armDeadline: () => () => { calls.push("cancel-deadline"); }, + }; + return { io, env, marker, calls, expire: () => { now = 5000; }, exit: () => exit?.() }; +} +const argv = ["start", "--port", "10100"]; +describe("update restart child admission", () => { + test("normal invocations do not acquire a lease", () => { + expect(admitUpdateRestartChild(argv, { env: {}, acquire: () => { throw new Error("unexpected"); } })).toBeUndefined(); + }); + test("strips delegation, owns lease until publication and releases once", () => { + const s = setup(); const guard = admitUpdateRestartChild(argv, s.io)!; + expect(s.env[UPDATE_RESTART_CHILD_ENV]).toBeUndefined(); + expect(s.calls).toEqual(["home", "acquire-own", "home"]); + guard.check(10100); expect(s.calls).not.toContain("release-own"); + guard.complete(); s.exit(); + expect(s.calls.filter(x => x === "release-own")).toHaveLength(1); + }); + test("expired or malformed marker and changed invocation refuse before acquisition", () => { + for (const kind of ["expired", "json", "version", "command", "port"] as const) { + const s = setup(); + if (kind === "expired") s.expire(); + if (kind === "json") s.env[UPDATE_RESTART_CHILD_ENV] = "{"; + if (kind === "version") s.env[UPDATE_RESTART_CHILD_ENV] = JSON.stringify({ ...s.marker, version: "unknown" }); + const args = kind === "command" ? ["ensure"] : kind === "port" ? ["start", "--port", "10101"] : argv; + expect(() => admitUpdateRestartChild(args, s.io)).toThrow(); expect(s.calls).not.toContain("acquire-own"); + } + }); + test("home/owner handoff drift after acquisition releases without admitting", () => { + const s = setup(); let n = 0; + s.io.checkHome = () => { if (++n === 2) throw new Error("foreign claim"); }; + expect(() => admitUpdateRestartChild(argv, s.io)).toThrow(); + expect(s.calls).toEqual(["acquire-own", "release-own"]); + }); + test("parent exit does not release child's independently owned lease", () => { + const s = setup(); const guard = admitUpdateRestartChild(argv, s.io)!; + // There is no parent token/callback in this guard. Failed bind/rollback leaves custody here. + s.expire(); expect(() => guard.check()).toThrow(); + expect(s.calls).not.toContain("release-own"); + s.exit(); expect(s.calls.at(-1)).toBe("release-own"); + }); + test("before-bind/publication checks reject deadline, version, port and ownership drift", () => { + for (const kind of ["deadline", "version", "port", "home"] as const) { + const s = setup(); const guard = admitUpdateRestartChild(argv, s.io)!; + if (kind === "deadline") s.expire(); + if (kind === "version") s.io.version = () => "2.76.0"; + if (kind === "home") s.io.checkHome = () => { throw new Error("home changed"); }; + expect(() => guard.check(kind === "port" ? 10101 : 10100)).toThrow(); + expect(s.calls).not.toContain("release-own"); s.exit(); + } + }); + test("lease timeout refuses without any admission or retry", () => { + const s = setup(); s.io.acquire = () => { s.calls.push("acquire-own"); throw new Error("busy"); }; + expect(() => admitUpdateRestartChild(argv, s.io)).toThrow(); expect(s.calls).toEqual(["home", "acquire-own"]); + }); +}); + +test("child rejects client or competing runtime state before admission and later bind", () => { + for (const phase of ["admission", "bind"] as const) { + const s = setup(); + if (phase === "admission") s.io.checkState = () => { throw new Error("connected client or foreign runtime"); }; + if (phase === "admission") { + expect(() => admitUpdateRestartChild(argv, s.io)).toThrow(); + expect(s.calls).not.toContain("acquire-own"); + } else { + const guard = admitUpdateRestartChild(argv, s.io)!; + s.io.checkState = () => { throw new Error("connected client or foreign runtime"); }; + expect(() => guard.check()).toThrow(); s.exit(); + } + } +}); diff --git a/tests/cli/cli-update-restart-home.test.ts b/tests/cli/cli-update-restart-home.test.ts new file mode 100644 index 00000000000..e82324b659b --- /dev/null +++ b/tests/cli/cli-update-restart-home.test.ts @@ -0,0 +1,75 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync, readFileSync, statSync, chmodSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { admitUpdateRestartChild, UPDATE_RESTART_CHILD_ENV } from "../../src/cli/update-restart-child"; +import { assertUpdateRestartConfiguration, readUpdateRestartHome, assertUpdateRestartHome } from "../../src/cli/update-restart-home"; + +const roots: string[] = []; +const initial = { ocx: process.env.OPENCODEX_HOME, codex: process.env.CODEX_HOME, state: process.env.OPENCODEX_SERVICE_STATE_PATH }; +afterEach(() => { + for (const [key, value] of [["OPENCODEX_HOME", initial.ocx], ["CODEX_HOME", initial.codex], ["OPENCODEX_SERVICE_STATE_PATH", initial.state]]) { + if (value === undefined) delete process.env[key!]; else process.env[key!] = value; + } + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function home() { + const root = mkdtempSync(join(tmpdir(), "ocx-update-home-")); roots.push(root); + const config = join(root, "ocx"); const codex = join(root, "codex"); mkdirSync(config); mkdirSync(codex); + process.env.OPENCODEX_HOME = config; process.env.CODEX_HOME = codex; + delete process.env.OPENCODEX_SERVICE_STATE_PATH; + return { root, config, codex }; +} +test("physical home identity detects same-path directory replacement", () => { + const h = home(); const captured = readUpdateRestartHome(); assertUpdateRestartHome(captured); + renameSync(h.config, h.config + "-old"); mkdirSync(h.config); + expect(() => assertUpdateRestartHome(captured)).toThrow(); +}); +test("canonical aliases identify the same directory", () => { + const h = home(); const captured = readUpdateRestartHome(); + const alias = join(h.root, "alias"); symlinkSync(h.config, alias, process.platform === "win32" ? "junction" : "dir"); + process.env.OPENCODEX_HOME = alias; + expect(readUpdateRestartHome()).toEqual(captured); +}); +test("unreadable ownership state fails closed", () => { + const h = home(); writeFileSync(join(h.config, "service-state.json"), "{bad"); + expect(() => readUpdateRestartHome()).toThrow(); +}); + +test("busy child lease leaves config bytes, permissions and files unchanged", () => { + const h = home(); const configPath = join(h.config, "config.json"); + writeFileSync(configPath, '{"hostname":"127.0.0.1"}\n'); chmodSync(configPath, 0o644); + const marker = { home: readUpdateRestartHome(), version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: Date.now() + 5000 }; + const before = { bytes: readFileSync(configPath, "utf8"), mode: statSync(configPath).mode, files: readdirSync(h.config) }; + let acquired = false; + expect(() => admitUpdateRestartChild(["start", "--port", "10100"], { + env: { [UPDATE_RESTART_CHILD_ENV]: JSON.stringify(marker) }, version: () => "2.77.0", + acquire: () => { acquired = true; throw new Error("lease busy"); }, + })).toThrow("lease busy"); + expect(acquired).toBe(true); + expect({ bytes: readFileSync(configPath, "utf8"), mode: statSync(configPath).mode, files: readdirSync(h.config) }).toEqual(before); +}); + +test("production parent guard rejects client and hostname drift before stop", () => { + const h = home(); const config = join(h.config, "config.json"); + writeFileSync(config, JSON.stringify({ hostname: "127.0.0.1" })); + expect(() => assertUpdateRestartConfiguration("127.0.0.1")).not.toThrow(); + writeFileSync(config, JSON.stringify({ hostname: "::1" })); + expect(() => assertUpdateRestartConfiguration("127.0.0.1")).toThrow(); + writeFileSync(config, JSON.stringify({ hostname: "127.0.0.1", runtimeRole: "client" })); + expect(() => assertUpdateRestartConfiguration("127.0.0.1")).toThrow(); +}); +test("production child checker refuses client and competing runtime before acquisition", () => { + for (const kind of ["client", "runtime", "malformed-runtime"] as const) { + const h = home(); + writeFileSync(join(h.config, "config.json"), JSON.stringify({ hostname: "127.0.0.1", ...(kind === "client" ? { runtimeRole: "client" } : {}) })); + if (kind !== "client") writeFileSync(join(h.config, "runtime-port.json"), kind === "runtime" ? JSON.stringify({ pid: process.pid + 1, port: 10100 }) : "{"); + const marker = { home: readUpdateRestartHome(), version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: Date.now() + 5000 }; + let acquired = false; + expect(() => admitUpdateRestartChild(["start", "--port", "10100"], { + env: { [UPDATE_RESTART_CHILD_ENV]: JSON.stringify(marker) }, version: () => "2.77.0", + acquire: () => { acquired = true; return { release() {} }; }, + })).toThrow(); + expect(acquired).toBe(false); + } +}); diff --git a/tests/cli/cli-update-restart-transport.test.ts b/tests/cli/cli-update-restart-transport.test.ts new file mode 100644 index 00000000000..98c8828a6bc --- /dev/null +++ b/tests/cli/cli-update-restart-transport.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, test } from "bun:test"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import { observeAttestedUpdateReplacement, stopAttestedUpdateTarget } from "../../src/cli/update-restart-transport"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, + createLocalAttestationSecret, +} from "../../src/lib/local-management-attestation"; +import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../../src/lib/system-restart-contract"; +import { repoPath } from "../helpers/repo-root"; + +const FAILURE = "update_restart_stop_failed"; +const ADMIN = "loopback-test-management-credential"; +type Reply = (req: IncomingMessage, res: ServerResponse, body: Record) => void; + +async function fixture(settings: { health?: Reply; stop?: Reply } = {}) { + const secret = createLocalAttestationSecret(); + const requests: Array<{ method: string; path: string; token?: string; socket: Socket; challenge?: string }> = []; + const sockets = new Set(); + let connections = 0; + let wire = ""; + let port = 0; + const server = createServer((req, res) => { + requests.push({ method: req.method!, path: req.url!, token: req.headers["x-opencodex-api-key"] as string | undefined, + socket: req.socket, challenge: req.headers[LOCAL_ATTESTATION_CHALLENGE_HEADER] as string | undefined }); + res.setHeader("content-type", "application/json"); + if (req.url === "/healthz") { + const challenge = req.headers[LOCAL_ATTESTATION_CHALLENGE_HEADER]; + const proof = createLocalAttestationProof(secret, String(challenge), process.pid, port); + if (proof) res.setHeader(LOCAL_ATTESTATION_PROOF_HEADER, proof); + const body = { service: "opencodex", status: "ok", pid: process.pid, port, version: "2.76.0", + restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION }; + if (settings.health) settings.health(req, res, body); + else res.end(JSON.stringify(body)); + } else if (req.url === "/api/stop") { + const body = { success: true, sharedTeardown: "performed" }; + if (settings.stop) settings.stop(req, res, body); + else res.end(JSON.stringify(body)); + } else { res.statusCode = 404; res.end("{}"); } + }); + server.on("connection", socket => { + connections++; + sockets.add(socket); + socket.on("data", bytes => { wire += bytes.toString("latin1"); }); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("ephemeral fixture missing address"); + port = address.port; + const options = { + target: { pid: process.pid, port, hostname: "127.0.0.1", source: "runtime" as const }, + secret, cliVersion: "2.77.0", deadlineAt: Date.now() + 2000, beforeStop: () => {}, adminToken: ADMIN, + }; + return { server, requests, options, sockets, connections: () => connections, wire: () => wire, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } }; +} + +describe("attested update stop transport over real loopback", () => { + test("proves fresh health then stops over exactly one TCP socket", async () => { + const f = await fixture(); + try { + let revalidated = 0; + f.options.beforeStop = () => { + expect(f.requests.map(r => r.path)).toEqual(["/healthz"]); + revalidated++; + }; + await stopAttestedUpdateTarget(f.options); + expect(f.requests.map(r => [r.method, r.path])).toEqual([["GET", "/healthz"], ["POST", "/api/stop"]]); + expect(f.connections()).toBe(1); + expect(f.requests[0]!.socket).toBe(f.requests[1]!.socket); + expect(f.requests[0]!.token).toBeUndefined(); + expect(f.requests[0]!.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(f.requests[1]!.token).toBe(ADMIN); + expect(revalidated).toBe(1); + } finally { await f.close(); } + }); + + for (const variant of ["missing proof", "wrong secret", "wrong PID", "wrong port", "client", "fenced", "unhealthy", "capability"] as const) { + test(`${variant} refuses before POST or credential bytes`, async () => { + const f = await fixture({ health: (_req, res, body) => { + if (variant === "missing proof") res.removeHeader(LOCAL_ATTESTATION_PROOF_HEADER); + if (variant === "wrong PID") body.pid = process.pid + 1; + if (variant === "wrong port") body.port = 1; + if (variant === "client") body.role = "client"; + if (variant === "fenced") { body.status = "restart_required"; body.error = { code: "package_tree_changed" }; res.statusCode = 503; } + if (variant === "unhealthy") body.status = "degraded"; + if (variant === "capability") delete body.restartCapability; + res.end(JSON.stringify(body)); + } }); + try { + let revalidated = 0; + if (variant === "wrong secret") f.options.secret = createLocalAttestationSecret(); + f.options.beforeStop = () => { revalidated++; }; + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(revalidated).toBe(0); + expect(f.requests.map(r => r.path)).toEqual(["/healthz"]); + expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + } + + for (const version of ["2.78.0", "not-semver", "unknown", "0.0.0", "2.77.0", "2.77.0+other"] as const) { + test(`proxy version ${version} is not admitted as CLI-newer`, async () => { + const f = await fixture({ health: (_req, res, body) => { body.version = version; res.end(JSON.stringify(body)); } }); + try { + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.requests).toHaveLength(1); + expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + } + + test("Connection close refuses any replacement socket and credential", async () => { + const f = await fixture({ health: (_req, res, body) => { + res.setHeader("connection", "close"); + res.end(JSON.stringify(body)); + } }); + try { + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.connections()).toBe(1); + expect(f.requests).toHaveLength(1); + expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + + test("a new listener taking the original port receives no connection or credentials", async () => { + let replacementConnections = 0; + let replacementBytes = ""; + const replacement = createServer((_req, res) => { res.end("{}"); }); + replacement.on("connection", socket => { + replacementConnections++; + socket.on("data", bytes => { replacementBytes += bytes.toString(); }); + }); + const f = await fixture({ health: (_req, res, body) => { + // Release the listen port while the established proof connection still exists. + f.server.close(); + replacement.listen(f.options.target.port, "127.0.0.1", () => { + res.setHeader("connection", "close"); + res.end(JSON.stringify(body)); + }); + } }); + try { + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.requests).toHaveLength(1); + expect(f.connections()).toBe(1); + expect(replacementConnections).toBe(0); + expect(replacementBytes).toBe(""); + expect(f.wire()).not.toContain(ADMIN); + } finally { + await f.close(); + await new Promise(resolve => replacement.close(() => resolve())); + } + }); + + test("a keepalive peer closing after health never opens another connection", async () => { + const f = await fixture({ health: (_req, res, body) => { + const socket = res.socket; + res.end(JSON.stringify(body)); + socket?.end(); + } }); + try { + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.connections()).toBe(1); + expect(f.requests).toHaveLength(1); + expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + + test("fresh sessions use different challenges", async () => { + const f = await fixture(); + try { + await stopAttestedUpdateTarget(f.options); + await stopAttestedUpdateTarget(f.options); + expect(f.requests[0]!.challenge).not.toBe(f.requests[2]!.challenge); + expect(f.connections()).toBe(2); + expect(f.requests[0]!.socket).not.toBe(f.requests[2]!.socket); + } finally { await f.close(); } + }); + + test("HTTP proxy environment cannot intercept health or stop", async () => { + const proxy = await fixture(); + const f = await fixture(); + const names = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "NO_PROXY", "no_proxy"]; + try { + // The proxied environment exists only in a child: Bun cannot unset an exported variable, so + // `delete process.env.X` would leave the proxy for later spawns and the next file in a worker. + const env: Record = { ...process.env }; + for (const name of names) env[name] = name.toLowerCase() === "no_proxy" ? "" : `http://127.0.0.1:${proxy.options.target.port}`; + const { beforeStop: _beforeStop, ...options } = f.options; + env.OCX_TEST_UPDATE_TRANSPORT_OPTIONS = JSON.stringify({ ...options, deadlineAt: Date.now() + 10_000 }); + const transport = repoPath("src", "cli", "update-restart-transport.ts").replaceAll("\\", "/"); + const child = Bun.spawn([process.execPath, "-e", [ + `import { stopAttestedUpdateTarget } from ${JSON.stringify(transport)};`, + "await stopAttestedUpdateTarget({ ...JSON.parse(process.env.OCX_TEST_UPDATE_TRANSPORT_OPTIONS), beforeStop: () => {} });", + ].join("\n")], { env, stdout: "pipe", stderr: "pipe" }); + const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]); + expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); + expect(f.requests).toHaveLength(2); + expect(f.connections()).toBe(1); + expect(proxy.connections()).toBe(0); + expect(proxy.wire()).toBe(""); + } finally { + await f.close(); + await proxy.close(); + } + }); + + test("Bun.serve health and stop reuse the same direct peer", async () => { + const secret = createLocalAttestationSecret(); + const peers: number[] = []; + const paths: string[] = []; + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req, live) { + paths.push(new URL(req.url).pathname); + peers.push(live.requestIP(req)!.port); + if (paths.at(-1) === "/healthz") { + expect(req.headers.has("X-OpenCodex-API-Key")).toBe(false); + return Response.json({ service: "opencodex", status: "ok", pid: process.pid, port: live.port, + version: "2.76.0", restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION }, { + headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof(secret, + req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!, process.pid, live.port!)! }, + }); + } + expect(req.headers.get("X-OpenCodex-API-Key")).toBe(ADMIN); + return Response.json({ success: true, sharedTeardown: "performed" }); + } }); + try { + await stopAttestedUpdateTarget({ target: { pid: process.pid, port: server.port!, hostname: "127.0.0.1", source: "runtime" }, + secret, cliVersion: "2.77.0", deadlineAt: Date.now() + 2000, beforeStop: () => {}, adminToken: ADMIN }); + expect(paths).toEqual(["/healthz", "/api/stop"]); + expect(peers[0]).toBe(peers[1]); + } finally { await server.stop(true); } + }); + + test("synchronous revalidation refuses before POST and sanitizes its error", async () => { + const f = await fixture(); + try { + f.options.beforeStop = () => { throw new Error(ADMIN); }; + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.requests).toHaveLength(1); + expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + + for (const variant of ["refused", "malformed", "false success", "deferred", "not-owned", "missing teardown", "202", "truncated"] as const) { + test(`${variant} stop response fails without retry`, async () => { + const f = await fixture({ stop: (_req, res, body) => { + if (variant === "refused") res.statusCode = 409; + if (variant === "202") res.statusCode = 202; + if (variant === "false success") body.success = false; + if (variant === "deferred" || variant === "not-owned") body.sharedTeardown = variant; + if (variant === "missing teardown") delete body.sharedTeardown; + if (variant === "truncated") { res.setHeader("content-length", "1000"); res.end(JSON.stringify(body)); res.socket?.end(); } + else res.end(variant === "malformed" ? "{bad json" : JSON.stringify(body)); + } }); + try { + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.requests.map(r => r.path)).toEqual(["/healthz", "/api/stop"]); + expect(f.connections()).toBe(1); + } finally { await f.close(); } + }); + } + + for (const phase of ["health", "stop"] as const) { + test(`${phase} body deadline covers unfinished response after headers`, async () => { + const hung: Reply = (_req, res) => { res.writeHead(200); res.write("{"); }; + const f = await fixture({ [phase]: hung }); + try { + f.options.deadlineAt = Date.now() + 150; + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.connections()).toBe(1); + expect(f.requests).toHaveLength(phase === "health" ? 1 : 2); + if (phase === "health") expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + test(`${phase} response exceeds 64 KiB and fails without retry`, async () => { + const huge: Reply = (_req, res) => { res.end(JSON.stringify({ padding: "x".repeat(65 * 1024) })); }; + const f = await fixture({ [phase]: huge }); + try { + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.connections()).toBe(1); + expect(f.requests).toHaveLength(phase === "health" ? 1 : 2); + if (phase === "health") expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + } + + test("expired deadline opens no socket", async () => { + const f = await fixture(); + try { + f.options.deadlineAt = Date.now() - 1; + await expect(stopAttestedUpdateTarget(f.options)).rejects.toThrow(FAILURE); + expect(f.connections()).toBe(0); + } finally { await f.close(); } + }); +}); + +describe("attested replacement observation", () => { + test("returns exact proved replacement identity over one GET without credentials", async () => { + const f = await fixture(); + try { + const live = await observeAttestedUpdateReplacement({ ...f.options, version: "2.76.0" }); + expect(live).toEqual({ ...f.options.target, version: "2.76.0" }); + expect(f.requests.map(r => [r.method, r.path])).toEqual([["GET", "/healthz"]]); + expect(f.connections()).toBe(1); + expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + + for (const variant of ["missing proof", "wrong secret", "version", "PID", "port", "client", "status", "HTTP"] as const) { + test(`${variant} cannot prove the replacement`, async () => { + const f = await fixture({ health: (_req, res, body) => { + if (variant === "missing proof") res.removeHeader(LOCAL_ATTESTATION_PROOF_HEADER); + if (variant === "version") body.version = "2.77.0"; + if (variant === "PID") body.pid = process.pid + 1; + if (variant === "port") body.port = 1; + if (variant === "client") body.role = "client"; + if (variant === "status") body.status = "degraded"; + if (variant === "HTTP") res.statusCode = 503; + res.end(JSON.stringify(body)); + } }); + try { + if (variant === "wrong secret") f.options.secret = createLocalAttestationSecret(); + await expect(observeAttestedUpdateReplacement({ ...f.options, version: "2.76.0" })) + .rejects.toThrow("update_restart_replacement_observation_failed"); + expect(f.requests).toHaveLength(1); + expect(f.connections()).toBe(1); + expect(f.wire()).not.toContain(ADMIN); + } finally { await f.close(); } + }); + } +}); diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts new file mode 100644 index 00000000000..0b94ec51662 --- /dev/null +++ b/tests/cli/cli-update-restart.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { describeUpdateRestartFailure, runUpdateRestart, type UpdateRestartIo } from "../../src/cli/update-restart"; +import type { UpdateRestartCandidate } from "../../src/cli/update-restart-candidate"; +import type { LiveProxy } from "../../src/server/proxy-liveness"; + +const candidate: UpdateRestartCandidate = { + home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0 }, + target: { pid: 123, port: 10100, hostname: "127.0.0.1", source: "runtime", version: "2.76.0" }, + runtime: { pid: 123, port: 10100, hostname: "127.0.0.1", attestationSecret: "a".repeat(43) }, + cliVersion: "2.77.0", +}; +function setup() { + const calls: string[] = []; + let now = 1000; + const home = { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0 }; + const live: LiveProxy = { ...candidate.target, pid: 456, version: "2.77.0" }; + const io: UpdateRestartIo = { + now: () => now, acquire: () => { calls.push("acquire"); return { release: () => { calls.push("release"); } }; }, + home: () => home, checkHome: () => {}, runtime: () => candidate.runtime, + standalone: () => true, runtimeReady: () => true, + stop: async (_target, _deadline, revalidate) => { revalidate(); calls.push("stop"); }, + stopped: async () => { calls.push("settle"); return true; }, + start: marker => { expect(marker).toEqual({ home, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 }); calls.push("start"); return { pid: 456, exitCode: null, signalCode: null }; }, + observe: async (_deadline, pid) => { expect(pid).toBe(456); calls.push("observe"); return live; }, + wait: async ms => { now += ms; }, + }; + return { calls, io, live, expire: () => { now = 5000; } }; +} +describe("CLI update restart transaction", () => { + test("stops only the captured target, settles, launches once and verifies expected child/version", async () => { + const s = setup(); + expect(await runUpdateRestart(candidate, 5000, s.io)).toEqual({ ok: true, live: s.live }); + expect(s.calls).toEqual(["acquire", "stop", "settle", "start", "release", "observe"]); + }); + test("rejects unknown, missing, incomparable and newer proxy versions without stopping", async () => { + for (const [cliVersion, version] of [["unknown", "2.76.0"], ["0.0.0", "2.76.0"], ["test", "2.76.0"], ["2.77.0", undefined], ["2.77.0", "2.78.0"], ["2.77.0", "test"]]) { + const s = setup(); + expect((await runUpdateRestart({ ...candidate, cliVersion: cliVersion!, target: { ...candidate.target, version } }, 5000, s.io)).ok).toBe(false); + expect(s.calls).toEqual([]); + } + }); + test("refuses PID, port, hostname, secret and sibling replacement before stop", async () => { + for (const change of [{ pid: 124 }, { port: 10101 }, { hostname: "::1" }, { attestationSecret: "b".repeat(43) }, { siblingOfPort: 10101 }]) { + const s = setup(); s.io.runtime = () => ({ ...candidate.runtime, ...change }); + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls).toEqual(["acquire", "release"]); + } + }); + test("refuses ambiguous ownership, home, supervisor and lease acquisition", async () => { + for (const seam of ["home", "checkHome", "standalone", "acquire"] as const) { + const s = setup(); + if (seam === "standalone") s.io.standalone = () => false; + else s.io[seam] = () => { throw new Error("uncertain"); }; + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls).not.toContain("stop"); expect(s.calls).not.toContain("start"); + } + }); + test("revalidates immediately before stop after asynchronous proof", async () => { + const s = setup(); + s.io.stop = async (_target, _deadline, revalidate) => { + s.io.runtime = () => ({ ...candidate.runtime, pid: 999 }); + revalidate(); s.calls.push("stop"); + }; + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls).toEqual(["acquire", "release"]); + }); + test("failed/refused/ambiguous stop and drain timeout never launch", async () => { + for (const seam of ["stop", "settle", "deadline"] as const) { + const s = setup(); + if (seam === "stop") s.io.stop = async () => { throw new Error("uncertain response"); }; + if (seam === "settle") s.io.stopped = async () => false; + if (seam === "deadline") s.io.stop = async () => { s.expire(); }; + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls).not.toContain("start"); expect(s.calls.at(-1)).toBe("release"); + } + }); + test("ownership change after settlement never launches", async () => { + const s = setup(); s.io.stopped = async () => { s.io.checkHome = () => { throw new Error("foreign claim"); }; return true; }; + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls).not.toContain("start"); + }); + test("failed and ambiguous child launches are terminal with no retry", async () => { + for (const mode of ["throw", "no-pid", "exited", "old-pid"] as const) { + const s = setup(); s.io.start = () => { s.calls.push("start"); if (mode === "throw") throw new Error("spawn"); return { pid: mode === "no-pid" ? undefined : mode === "old-pid" ? 123 : 456, exitCode: mode === "exited" ? 1 : null, signalCode: null }; }; + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls.filter(call => call === "start")).toHaveLength(1); + } + }); + test("unrelated PID, port, hostname, missing/wrong version and non-runtime replacement fail", async () => { + for (const change of [{ pid: 999 }, { port: 10101 }, { hostname: "::1" }, { version: undefined }, { version: "2.76.0" }, { source: "config" as const }, { role: "client" }, { packageTreeFenced: true as const }]) { + const s = setup(); s.io.observe = async () => ({ ...s.live, ...change }); + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls.filter(call => call === "start")).toHaveLength(1); + expect(s.calls.filter(call => call === "stop")).toHaveLength(1); + } + }); + test("no health and deadline crossed by health never report success or retry", async () => { + for (const late of [false, true]) { + const s = setup(); s.io.observe = async () => { if (late) { s.expire(); return s.live; } return null; }; + expect((await runUpdateRestart(candidate, 5000, s.io)).ok).toBe(false); + expect(s.calls.filter(call => call === "start")).toHaveLength(1); + } + }); + test("an incomplete runtime refuses before any stop, even through a transport that sanitizes beforeStop errors", async () => { + const early = setup(); early.io.runtimeReady = () => false; + expect(await runUpdateRestart(candidate, 5000, early.io)).toEqual({ ok: false, code: "update_restart_runtime_incomplete" }); + expect(early.calls).toEqual(["acquire", "release"]); + // The runtime turns into the npm placeholder between eligibility and the stop exchange. + const late = setup(); let checks = 0; + late.io.runtimeReady = () => ++checks === 1; + late.io.stop = async (_target, _deadline, beforeStop) => { + try { beforeStop(); } catch { throw new Error("update_restart_stop_failed"); } + late.calls.push("stop"); + }; + expect(await runUpdateRestart(candidate, 5000, late.io)).toEqual({ ok: false, code: "update_restart_runtime_incomplete" }); + expect(late.calls).toEqual(["acquire", "release"]); + }); + test("after a confirmed stop, waits for the runtime and launches once it is complete", async () => { + const s = setup(); let stopped = false; + s.io.stopped = async () => { s.calls.push("settle"); stopped = true; return true; }; + let polls = 0; + s.io.runtimeReady = () => !stopped || ++polls > 3; + expect(await runUpdateRestart(candidate, 5000, s.io)).toEqual({ ok: true, live: s.live }); + expect(s.calls).toEqual(["acquire", "stop", "settle", "start", "release", "observe"]); + }); + test("runtime timeout, late readiness and home change during the wait never launch", async () => { + for (const mode of ["never", "at-deadline", "home"] as const) { + const s = setup(); let stopped = false; + s.io.stopped = async () => { s.calls.push("settle"); stopped = true; return true; }; + s.io.runtimeReady = () => !stopped; + if (mode === "at-deadline") s.io.wait = async () => { s.expire(); s.io.runtimeReady = () => true; }; + if (mode === "home") s.io.wait = async () => { s.io.runtimeReady = () => true; s.io.checkHome = () => { throw new Error("foreign claim"); }; }; + const result = await runUpdateRestart(candidate, 5000, s.io); + expect(result).toEqual({ ok: false, code: mode === "never" ? "update_restart_runtime_failed" : "update_restart_prelaunch_failed" }); + expect(s.calls).not.toContain("start"); expect(s.calls.at(-1)).toBe("release"); + } + }); + test("failure text claims only what each phase proved", () => { + for (const code of ["update_restart_runtime_incomplete", "update_restart_eligibility_failed"]) { + expect(describeUpdateRestartFailure(code)).toContain("nothing was stopped"); + } + for (const code of ["update_restart_stop_failed", "update_restart_settle_failed", "update_restart_replacement_failed", "update_restart_unknown"]) { + expect(describeUpdateRestartFailure(code)).toContain("inspect `ocx status` before retrying"); + expect(describeUpdateRestartFailure(code)).not.toContain("run `ocx start`"); + } + expect(describeUpdateRestartFailure("update_restart_runtime_failed")).toContain("nothing was launched"); + expect(describeUpdateRestartFailure("update_restart_prelaunch_failed")).toContain("nothing was launched"); + expect(describeUpdateRestartFailure("update_restart_start_failed")).toContain("launch could not be confirmed"); + }); +}); diff --git a/tests/cli/system-restart-client.test.ts b/tests/cli/system-restart-client.test.ts index 5c1abe111c9..947d96725b3 100644 --- a/tests/cli/system-restart-client.test.ts +++ b/tests/cli/system-restart-client.test.ts @@ -55,6 +55,7 @@ function successfulDeps() { // Matches the /healthz fixture version below so the skew guard stays out of the way; // the dedicated skew tests override it explicitly. cliVersion: "test", + readUpdateHome: () => ({ config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0 }), }, }; } @@ -148,10 +149,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 +174,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); } }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e69a4a3cf7e..fa0816a1733 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,8 @@ { + "cli-update-restart.test.ts": "cli", + "cli-update-restart-child.test.ts": "cli", + "cli-update-restart-transport.test.ts": "cli", + "cli-update-restart-home.test.ts": "cli", "codex-shim-overlay.test.ts": "codex-integration", "codex-shim-migration.test.ts": "codex-integration", "codex-shim-overlay-transaction.test.ts": "codex-integration",