From 53c9e333ab0e86cc4960b31c764bb5f2aebb412b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 11:16:28 +0900 Subject: [PATCH 1/5] fix(cli): guard standalone update restart ownership and replacement Refines the standalone POSIX slice of public PR #6548. Supervised and Windows update orchestration remain excluded. Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> --- .../261004_cli_update_restart/000_plan.md | 24 ++ .../261004_cli_update_restart/010_delivery.md | 7 + .../content/docs/reference/cli/lifecycle.md | 12 +- scripts/test-layout/layout.json | 4 + src/cli/index.ts | 29 +- src/cli/system-restart-client.ts | 23 +- src/cli/update-restart-candidate.ts | 21 ++ src/cli/update-restart-child.ts | 85 +++++ src/cli/update-restart-home.ts | 29 ++ src/cli/update-restart-transport.ts | 193 ++++++++++ src/cli/update-restart.ts | 162 +++++++++ structure/runtime.md | 4 +- tests/cli/cli-update-restart-child.test.ts | 84 +++++ tests/cli/cli-update-restart-home.test.ts | 49 +++ .../cli/cli-update-restart-transport.test.ts | 330 ++++++++++++++++++ tests/cli/cli-update-restart.test.ts | 104 ++++++ tests/cli/system-restart-client.test.ts | 11 +- tests/fixtures/test-layout-expected.json | 4 + 18 files changed, 1161 insertions(+), 14 deletions(-) create mode 100644 devlog/_plan/261004_cli_update_restart/000_plan.md create mode 100644 devlog/_plan/261004_cli_update_restart/010_delivery.md create mode 100644 src/cli/update-restart-candidate.ts create mode 100644 src/cli/update-restart-child.ts create mode 100644 src/cli/update-restart-home.ts create mode 100644 src/cli/update-restart-transport.ts create mode 100644 src/cli/update-restart.ts create mode 100644 tests/cli/cli-update-restart-child.test.ts create mode 100644 tests/cli/cli-update-restart-home.test.ts create mode 100644 tests/cli/cli-update-restart-transport.test.ts create mode 100644 tests/cli/cli-update-restart.test.ts 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 e6b11cd11b5..2123a8155f0 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -101,6 +101,16 @@ 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. 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 +132,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 29885b87eaf..aa94a71d0ba 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", "usage-antigravity-55.test.ts": "usage", "provider-antigravity-wire-snapshot.test.ts": "providers", "antigravity-discovered-families.test.ts": "adapters/google", diff --git a/src/cli/index.ts b/src/cli/index.ts index f7dcd16a8b0..c0e7c2bb1e3 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 { restartFromCurrentInstallation } from "./update-restart"; import { findGuiDist } from "../server/gui-static"; import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness"; @@ -200,6 +203,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. @@ -371,6 +375,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 @@ -466,6 +471,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}`); @@ -550,7 +556,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); } @@ -578,14 +594,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), @@ -939,6 +955,13 @@ 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 update = await restartFromCurrentInstallation(result.error.candidate(), deadlineAt, detachedStartEnvironment()); + if (update.ok) console.log(`✅ Proxy updated to ${update.live.version} (PID ${update.live.pid}).`); + else console.error(`❌ Update restart could not be confirmed (${update.code}); inspect status before retrying.`); + 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..5b45504f8d8 --- /dev/null +++ b/src/cli/update-restart-child.ts @@ -0,0 +1,85 @@ +import { existsSync } from "node:fs"; +import { readClientConnectionState } from "../client/state"; +import { getRuntimePortPath, readRuntimePort } from "../config/process-state"; +import { readConfigDiagnostics } from "../config/diagnostics"; +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 { 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 => { + if (readClientConnectionState().kind !== "disconnected") throw new Error("update_restart_client_refused"); + 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"); + } + const diagnostics = readConfigDiagnostics(); + if (diagnostics.error || (diagnostics.config.hostname ?? "") !== expected.hostname) throw new Error("update_restart_hostname_changed"); + }))(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..e112cbd5ea6 --- /dev/null +++ b/src/cli/update-restart-home.ts @@ -0,0 +1,29 @@ +import { realpathSync, statSync } from "node:fs"; +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"); +} 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..785c3afe64d --- /dev/null +++ b/src/cli/update-restart.ts @@ -0,0 +1,162 @@ +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 { 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 { 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; + 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; + 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"); + withinDeadline(); + }; + revalidate(); + phase = "stop"; + await io.stop(candidate, deadlineAt, revalidate); + withinDeadline(); + phase = "settle"; + if (!await io.stopped(candidate, deadlineAt)) throw new Error("stop_unconfirmed"); + 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: `update_restart_${phase}_failed` }; } + finally { lease?.release(); } +} + +function standalone(target: UpdateRestartCandidate["target"]): boolean { + 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, + 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 77476c0edc0..3438412a3d5 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -123,7 +123,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart 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. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | | `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` | The pure Bun-head parser retains command argv and derives transient help paths before shim preflight or command dispatch. Bare `help` is recognized only at root or immediately after the root command; nested paths use `--help`/`-h`, and exact `--` ends head help scanning. Later operand values such as `alias set demo help` stay commands. `runCli` exits help and rejects unknown roots before shim/stateful preflight, while preserving `parseCliHead` and its kinds. Registry names/aliases/hidden entries and the unregistered `internal` runner remain valid dispatch targets; hidden/internal names stay outside discovery and suggestions. `printUsage` renders the same compact index for bare `ocx`, `help`, `--help` and `-h`; static navigation groups reference registry names/summaries and add no configuration reads, width probes or color dependence. `printFullUsage` preserves the full reference behind `ocx help --all` and `ocx --help --all`. This contract concerns the Bun head; the Node launcher's updater interception has its own recognition rules. | | `src/cli/help-catalog.ts`, `src/cli/help-models-context.ts`, `src/cli/help-recovery.ts` | Pure help resolution uses `src/cli/registry.ts` and `src/cli/capabilities.ts`, preserving exact alias usage/details before canonical family navigation. Entry results carry transient canonical identity and declared descendants; family help lists partial child coverage and links the curated models-context topic separately. Hidden entries remain explicitly callable but absent from discovery. Declared capabilities render summaries, known flags/details and parent pointers without inventing operand grammar; declared-prefix groups mark incomplete coverage. Unknown roots and unavailable explicit `ocx help ` detail use concise stderr-only diagnostics/navigation, empty stdout and exit 1, without a full banner. Missing detail never establishes unsupported runtime grammar. Successful appended flag-help parent fallback runs before recovery and preserves its write sink; unresolved explicit help uses stderr regardless of sink. The resolver does not validate runtime command execution. The curated models-context topic shares `MODELS_CONTEXT_USAGE` with `src/cli/models-runtime.ts`, preserving its status/value/provider/all grammar and read-only status example. The catalog projects pure transient recovery candidates from visible registry names/aliases, immediate declared children and curated topics, deduplicated by canonical destination. The pure recovery formatter bounds input/depth/work, matches conservative edit distances including adjacent transpositions and sorts by distance/name; at most three metadata-derived destinations are suggested, never executed or extended with trailing argv. Distant/control/oversized input gets no guess. Root diagnostics echo only a bounded command-shaped first token accepted by `src/lib/redact.ts`; nested diagnostics never echo arbitrary operands. `src/cli/dispatch.ts` reuses the formatter for direct unknown-root dispatch. Capability JSON and provider-specific error semantics remain unchanged. Provider syntax/examples live in registry details; `src/cli/provider.ts` uses the shared renderer for bare/help success (stdout, exit 0) and unknown-action diagnostics/help (stderr, exit 1, no stdout). The transient optional `write` sink defaults to `console.log`, accepts `console.error` for that error path and propagates through parent fallback. Bare provider retains ordinary preflight; only head-help forms bypass it. | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | @@ -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. `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 shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. `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. Discovery resolves fnm's temporary `fnm_multishells` directory to the durable installation directory (`src/codex/shim-path-resolution.ts` takes the realpath of the directory only and keeps the command basename, so npm's command link is never followed into the package file) and refuses to shim when it cannot; the scan's injected path flavour governs join and separator alike. Connect readiness inspects only the first 16 KiB of regular launcher files through a descriptor, with nonblocking POSIX opens and a post-open file-type check. It follows npm/fnm symlinks, skips special files, and retains unreadable regular candidates as non-shims so a later launcher is not falsely reported active. `src/cli/codex-shim-readiness.ts` reports a healthy tracked shim as inactive when a completed scan finds no PATH command, and reports activation unverified when the scan throws; installation health alone never proves PATH activation. These diagnostics do not change the connect exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers FIFO replacement, symlinks and bounded reads. | | `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..a5ab82c5a21 --- /dev/null +++ b/tests/cli/cli-update-restart-home.test.ts @@ -0,0 +1,49 @@ +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 { 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, "{}\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) }; + expect(() => admitUpdateRestartChild(["start", "--port", "10100"], { + env: { [UPDATE_RESTART_CHILD_ENV]: JSON.stringify(marker) }, version: () => "2.77.0", + acquire: () => { throw new Error("lease busy"); }, + })).toThrow(); + expect({ bytes: readFileSync(configPath, "utf8"), mode: statSync(configPath).mode, files: readdirSync(h.config) }).toEqual(before); +}); 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..fba06c5a413 --- /dev/null +++ b/tests/cli/cli-update-restart-transport.test.ts @@ -0,0 +1,330 @@ +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"; + +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"]; + const saved = names.map(name => process.env[name]); + try { + for (const name of names) process.env[name] = name.toLowerCase() === "no_proxy" ? "" : `http://127.0.0.1:${proxy.options.target.port}`; + await stopAttestedUpdateTarget(f.options); + expect(f.requests).toHaveLength(2); + expect(f.connections()).toBe(1); + expect(proxy.connections()).toBe(0); + expect(proxy.wire()).toBe(""); + } finally { + names.forEach((name, i) => { if (saved[i] === undefined) delete process.env[name]; else process.env[name] = saved[i]; }); + 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..592b2c2c54c --- /dev/null +++ b/tests/cli/cli-update-restart.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { 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, + 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); + } + }); +}); 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 6d7377fa4f3..0d829b22909 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", "usage-antigravity-55.test.ts": "usage", "provider-antigravity-wire-snapshot.test.ts": "providers", "antigravity-discovered-families.test.ts": "adapters/google", "provider-antigravity-effort-families.test.ts": "providers", From 7463baea1231f0547d79dcd85a63f7a299afcbb7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 11:17:39 +0900 Subject: [PATCH 2/5] test(cli): exercise busy lease with matching host configuration --- tests/cli/cli-update-restart-home.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/cli/cli-update-restart-home.test.ts b/tests/cli/cli-update-restart-home.test.ts index a5ab82c5a21..9bb4cb79833 100644 --- a/tests/cli/cli-update-restart-home.test.ts +++ b/tests/cli/cli-update-restart-home.test.ts @@ -38,12 +38,14 @@ test("unreadable ownership state fails closed", () => { test("busy child lease leaves config bytes, permissions and files unchanged", () => { const h = home(); const configPath = join(h.config, "config.json"); - writeFileSync(configPath, "{}\n"); chmodSync(configPath, 0o644); + 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: () => { throw new Error("lease busy"); }, - })).toThrow(); + 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); }); From 917a03f59b5581de9baf92e7222397a1b5621e46 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 11:20:10 +0900 Subject: [PATCH 3/5] fix(cli): revalidate update configuration before stop and child admission --- src/cli/update-restart-child.ts | 8 ++----- src/cli/update-restart-home.ts | 9 ++++++++ src/cli/update-restart.ts | 3 ++- tests/cli/cli-update-restart-home.test.ts | 26 ++++++++++++++++++++++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/cli/update-restart-child.ts b/src/cli/update-restart-child.ts index 5b45504f8d8..817364ea038 100644 --- a/src/cli/update-restart-child.ts +++ b/src/cli/update-restart-child.ts @@ -1,12 +1,10 @@ import { existsSync } from "node:fs"; -import { readClientConnectionState } from "../client/state"; import { getRuntimePortPath, readRuntimePort } from "../config/process-state"; -import { readConfigDiagnostics } from "../config/diagnostics"; 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 { assertUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home"; +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 } @@ -57,14 +55,12 @@ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo 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 => { - if (readClientConnectionState().kind !== "disconnected") throw new Error("update_restart_client_refused"); + 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"); } - const diagnostics = readConfigDiagnostics(); - if (diagnostics.error || (diagnostics.config.hostname ?? "") !== expected.hostname) throw new Error("update_restart_hostname_changed"); }))(marker); }; check(); diff --git a/src/cli/update-restart-home.ts b/src/cli/update-restart-home.ts index e112cbd5ea6..bec52b47ef5 100644 --- a/src/cli/update-restart-home.ts +++ b/src/cli/update-restart-home.ts @@ -1,4 +1,6 @@ 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"; @@ -27,3 +29,10 @@ 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.ts b/src/cli/update-restart.ts index 785c3afe64d..12d076fea18 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -13,7 +13,7 @@ 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 { assertUpdateRestartHome, readUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home"; +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"; @@ -96,6 +96,7 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli } 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; diff --git a/tests/cli/cli-update-restart-home.test.ts b/tests/cli/cli-update-restart-home.test.ts index 9bb4cb79833..e82324b659b 100644 --- a/tests/cli/cli-update-restart-home.test.ts +++ b/tests/cli/cli-update-restart-home.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync, import { tmpdir } from "node:os"; import { join } from "node:path"; import { admitUpdateRestartChild, UPDATE_RESTART_CHILD_ENV } from "../../src/cli/update-restart-child"; -import { readUpdateRestartHome, assertUpdateRestartHome } from "../../src/cli/update-restart-home"; +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 }; @@ -49,3 +49,27 @@ test("busy child lease leaves config bytes, permissions and files unchanged", () 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); + } +}); From cfbbbeccc1ef1a2b2aa877d89dead1873df96659 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 00:25:01 +0900 Subject: [PATCH 4/5] fix(cli): wait for a complete runtime around the update restart handoff The update restart stops the old proxy and then spawns process.execPath, which an in-place npm install leaves as the bun placeholder until its postinstall. Refuse before any stop byte while that runtime is incomplete (tracked in the transaction because the stop transport sanitizes beforeStop errors), and after a confirmed stop wait for it within the deadline, then recheck deadline and physical home synchronously before the one launch. Same gate as the package-tree restart in #6569. Carry #6548's user-facing explanations: announce the handoff and map each terminal code to sanitized text that claims only what that phase proved. Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> --- .../content/docs/reference/cli/lifecycle.md | 5 +- src/cli/index.ts | 8 +-- src/cli/update-restart.ts | 50 ++++++++++++++++++- structure/runtime.md | 2 +- tests/cli/cli-update-restart.test.ts | 50 ++++++++++++++++++- 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 2123a8155f0..1ac46338ee3 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -107,7 +107,10 @@ a detached proxy whose parent is PID 1, no installed or active service, and a kn 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. Windows, foreground, desktop-supervised, service, +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. diff --git a/src/cli/index.ts b/src/cli/index.ts index c0e7c2bb1e3..f8901eabf63 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,7 +10,7 @@ import { currentServingCommand, deferServiceChildToNewerRuntime, markDelegatedSe import { packageVersion } from "../lib/package-version"; import { admitUpdateRestartChild } from "./update-restart-child"; import { UpdateRestartRequired } from "./update-restart-candidate"; -import { restartFromCurrentInstallation } from "./update-restart"; +import { describeUpdateRestartFailure, restartFromCurrentInstallation } from "./update-restart"; import { findGuiDist } from "../server/gui-static"; import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness"; @@ -956,9 +956,11 @@ async function handleProxyRestart( })), }); if (!result.ok && result.phase === "request" && result.error instanceof UpdateRestartRequired) { - const update = await restartFromCurrentInstallation(result.error.candidate(), deadlineAt, detachedStartEnvironment()); + 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(`❌ Update restart could not be confirmed (${update.code}); inspect status before retrying.`); + else console.error(`❌ ${describeUpdateRestartFailure(update.code)} (${update.code})`); process.exitCode = update.ok ? 0 : 1; return update.ok; } diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts index 12d076fea18..c8a300597b6 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -3,6 +3,7 @@ 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"; @@ -26,6 +27,8 @@ export interface UpdateRestartIo { 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; @@ -45,6 +48,8 @@ function sameRuntime(candidate: UpdateRestartCandidate, current: RuntimePortStat 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") { @@ -59,6 +64,10 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli 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(); @@ -67,6 +76,18 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli 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"; @@ -91,10 +112,36 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli await io.wait(Math.min(100, deadlineAt - io.now())); } throw new Error("deadline"); - } catch { return { ok: false, code: `update_restart_${phase}_failed` }; } + } 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; @@ -118,6 +165,7 @@ export function restartFromCurrentInstallation(candidate: UpdateRestartCandidate 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"); diff --git a/structure/runtime.md b/structure/runtime.md index 3438412a3d5..6c3e7f26ad9 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -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. 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. `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/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 shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. `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. Discovery resolves fnm's temporary `fnm_multishells` directory to the durable installation directory (`src/codex/shim-path-resolution.ts` takes the realpath of the directory only and keeps the command basename, so npm's command link is never followed into the package file) and refuses to shim when it cannot; the scan's injected path flavour governs join and separator alike. Connect readiness inspects only the first 16 KiB of regular launcher files through a descriptor, with nonblocking POSIX opens and a post-open file-type check. It follows npm/fnm symlinks, skips special files, and retains unreadable regular candidates as non-shims so a later launcher is not falsely reported active. `src/cli/codex-shim-readiness.ts` reports a healthy tracked shim as inactive when a completed scan finds no PATH command, and reports activation unverified when the scan throws; installation health alone never proves PATH activation. These diagnostics do not change the connect exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers FIFO replacement, symlinks and bounded reads. | | `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.test.ts b/tests/cli/cli-update-restart.test.ts index 592b2c2c54c..0b94ec51662 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { runUpdateRestart, type UpdateRestartIo } from "../../src/cli/update-restart"; +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"; @@ -17,7 +17,7 @@ function setup() { const io: UpdateRestartIo = { now: () => now, acquire: () => { calls.push("acquire"); return { release: () => { calls.push("release"); } }; }, home: () => home, checkHome: () => {}, runtime: () => candidate.runtime, - standalone: () => true, + 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 }; }, @@ -101,4 +101,50 @@ describe("CLI update restart transaction", () => { 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"); + }); }); From 00c850446ec999fd92c1c02dd0ee203c8869fa13 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 02:41:31 +0900 Subject: [PATCH 5/5] test(cli): keep the proxied environment of the transport case in a child Bun cannot unset an exported variable: delete process.env.X leaves it for later spawns and for the next file in a --parallel worker. The HTTP-proxy interception case set the proxy variables in the test process, so on macOS CI (BUN_TEST_PARALLEL=1) the next file in the batch spawned ocx status through a closed proxy and the live hub read reported unavailable. Run the stop in a child whose own environment carries the proxy instead; the fixtures and assertions stay in the test process. --- tests/cli/cli-update-restart-transport.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/cli/cli-update-restart-transport.test.ts b/tests/cli/cli-update-restart-transport.test.ts index fba06c5a413..98c8828a6bc 100644 --- a/tests/cli/cli-update-restart-transport.test.ts +++ b/tests/cli/cli-update-restart-transport.test.ts @@ -9,6 +9,7 @@ import { 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"; @@ -189,16 +190,25 @@ describe("attested update stop transport over real loopback", () => { 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"]; - const saved = names.map(name => process.env[name]); try { - for (const name of names) process.env[name] = name.toLowerCase() === "no_proxy" ? "" : `http://127.0.0.1:${proxy.options.target.port}`; - await stopAttestedUpdateTarget(f.options); + // 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 { - names.forEach((name, i) => { if (saved[i] === undefined) delete process.env[name]; else process.env[name] = saved[i]; }); await f.close(); await proxy.close(); }