From 3b2017df9756689ac81aea0d052c9a8f64674341 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 16:33:50 +0900 Subject: [PATCH 01/54] fix(link): ocx restart recycles a client runtime whose connection was removed, under any owner After ocx disconnect (or a newer connect) a desktop- or service-owned client runtime refused ocx restart with restart_supervised and kept answering role client, so a Home-driven leave over SSH could not finish. A changed connection has nothing to keep: accept the restart and hand off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). ocx disconnect now restarts a still-live client runtime itself and exits 1 naming ocx restart when that does not complete. --- scripts/test-layout/layout.json | 2 +- src/cli/disconnect-runtime.ts | 34 +++++++++++++ src/cli/dispatch.ts | 9 +++- src/client/runtime-restart.ts | 16 ++++++ src/client/runtime.ts | 2 + structure/runtime.md | 2 +- tests/cli/disconnect-runtime.test.ts | 51 ++++++++++++++++++++ tests/clients/client-runtime-restart.test.ts | 24 +++++++++ tests/fixtures/test-layout-expected.json | 2 +- 9 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 src/cli/disconnect-runtime.ts create mode 100644 tests/cli/disconnect-runtime.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index de0c4fa181a..9642bce4793 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1987,7 +1987,7 @@ "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "injection-link-websocket.test.ts": "codex-integration", - "cli-link.test.ts": "cli", "cli-link-enrollment.test.ts": "cli", "link-apply-restart.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server", + "cli-link.test.ts": "cli", "cli-link-enrollment.test.ts": "cli", "link-apply-restart.test.ts": "cli", "disconnect-runtime.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server", "link-management-routes.test.ts": "server", "link-enrollment.test.ts": "server", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts new file mode 100644 index 00000000000..e6bc4013b85 --- /dev/null +++ b/src/cli/disconnect-runtime.ts @@ -0,0 +1,34 @@ +/** + * `ocx disconnect` ends the connection in the durable state; the runtime that served it keeps + * running until something restarts it. Left alone it still answered as a client, so the machine + * was neither a working Child nor standalone, and a Home could not link it again. Disconnect + * therefore restarts a live client runtime itself (the same restart as `ocx restart`, which a + * disconnected client runtime hands to whichever owner holds it), and fails with the remaining + * step when that restart does not complete. + */ +export interface DisconnectRuntimeDeps { + findLive: () => Promise<{ pid: number | null; role?: string } | null>; + /** `ocx restart`; resolves true once a replacement is verified. */ + restart: () => Promise; + json: boolean; + info?: (line: string) => void; + error?: (line: string) => void; +} + +export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps): Promise { + const info = deps.info ?? (line => console.log(line)); + const error = deps.error ?? (line => console.error(line)); + const live = await deps.findLive(); + if (live?.role !== "client") return 0; + // A --json caller reads one document from stdout; restart progress goes to stderr. + const originalLog = console.log; + if (deps.json) console.log = (...args: unknown[]) => console.error(...args); + let ok = false; + try { ok = await deps.restart(); } finally { console.log = originalLog; } + if (ok) { + (deps.json ? error : info)("The client runtime was restarted; this machine now runs standalone OpenCodex."); + return 0; + } + error(`Disconnected, but the client runtime${live.pid ? ` (pid ${live.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`); + return 1; +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6cbe047f2a1..e992dac9186 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -423,7 +423,14 @@ const commandRunners: Record = { }, disconnect: async deps => { const { handleDisconnectCommand } = await import("./connect"); - return await handleDisconnectCommand(deps.args.slice(1)); + const code = await handleDisconnectCommand(deps.args.slice(1)); + if (code !== 0) return code; + const { restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); + return await restartRuntimeAfterDisconnect({ + findLive: () => deps.findLiveProxy(), + restart: () => deps.handleProxyRestart(deps.handleRestartStartWhenStopped), + json: deps.args.includes("--json"), + }); }, catalog: async deps => { const { handleCatalogCommand } = await import("./catalog"); diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index 899fb165112..e7848854d40 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -8,6 +8,11 @@ * - Under the desktop app or a viable service supervisor the restart is refused (409 * `restart_supervised`) and the runtime keeps serving. The supervisor owns this process and its * port; handing off to it means exiting before anything proves the replacement can start. + * - Unless the connection it serves is no longer the durable one (`ocx disconnect`, or a newer + * connect). Then there is nothing to keep, and under every owner the restart hands off to the + * standalone recycle, which exits the way that owner relaunches (desktop 75, service 1) or spawns + * its own replacement. Without this a Home-driven leave over SSH could not finish under the + * desktop app or a service: `ocx restart` refused and the stale client runtime kept serving. * - Otherwise the listener drains, a replacement `ocx start` is spawned and must answer within the * budget. Success exits this process. A replacement that exited, or never answered and was then * stopped, leaves this process to bind the port again, start a fresh tunnel supervisor and @@ -38,6 +43,10 @@ export interface ClientRestartIo { stopping?: () => boolean; /** Which supervisor owns this process, or null when it starts its own replacement. */ supervisor(): Promise; + /** The durable connection is no longer the one this runtime serves. */ + connectionChanged?: () => boolean; + /** Hand off to what the durable state now calls for; exits the process for its owner to relaunch. */ + recycle?: () => Promise; /** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */ stop(drainMs: number): Promise; /** Start the replacement; resolves once it answers, rejects when none survives. */ @@ -97,6 +106,13 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar hasHandoff: () => flight !== null, async request() { if (io.stopping?.()) return { kind: "stopping" }; + if (io.recycle && io.connectionChanged?.()) { + if (flight) return { kind: "accepted", alreadyRestarting: true }; + const recycle = io.recycle; + // Let the 202 reach the CLI before the listener closes. + flight = sleep(200).then(recycle).finally(() => { flight = null; }); + return { kind: "accepted", alreadyRestarting: false }; + } const supervisor = await io.supervisor(); if (io.stopping?.()) return { kind: "stopping" }; if (supervisor) return { kind: "supervised", supervisor }; diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 75e3462f147..dd5c44512c2 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -433,6 +433,8 @@ export async function startClientRuntime( const restartController: ClientRestartController = createClientRestartController({ stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, + connectionChanged: () => clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle", + recycle: () => recycleStandalone(state.value.tokenFingerprint), stop: stopServing, spawnReplacement: async deadlineAt => { const { replacementStartEnvironment } = await import("../server/management/system-restart"); diff --git a/structure/runtime.md b/structure/runtime.md index 55a1d0f1ec7..a7ee4934c8c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -180,7 +180,7 @@ until shutdown. Standalone shutdown restores native Codex; connected hub clients uninstall still restore. Native client signal cleanup follows the [served-connection and supervisor boundaries](remote-link.md); client update stops use full parent restoration because client start cannot discharge standalone handover receipts. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. -`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state once per `/readyz`, after readiness probing, and for authenticated machine/link status only on those routes. If they differ, `/readyz` and those status routes answer 503 with `client_state_changed`; the dashboard shows that reason with its recovery guidance. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. Explicit `ocx disconnect --orphaned-token --yes` in `src/client/orphan-token-recovery.ts` preserves unowned token files only after the start lease, lifecycle lock, liveness proof and config-lock revalidation; Hub, admission-token use and installed or unverified services refuse. Windows closes source handles before rename while private hard links pin identity; a refusal removes a pin only while another name still holds the token, and an extra hard link left by an interrupted run is refused with a message naming the `.identity` pin to remove from the `orphaned-token-*` folder. Missing ownership is never reconstructed. `ocx config import` refuses to replace a connected client record under the config lock. `tests/clients/client-link-connect.test.ts` covers these refusals and local liveness. +`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept"; a client whose connection was removed is never refused and recycles to standalone. See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state once per `/readyz`, after readiness probing, and for authenticated machine/link status only on those routes. If they differ, `/readyz` and those status routes answer 503 with `client_state_changed`; the dashboard shows that reason with its recovery guidance. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. Explicit `ocx disconnect --orphaned-token --yes` in `src/client/orphan-token-recovery.ts` preserves unowned token files only after the start lease, lifecycle lock, liveness proof and config-lock revalidation; Hub, admission-token use and installed or unverified services refuse. Windows closes source handles before rename while private hard links pin identity; a refusal removes a pin only while another name still holds the token, and an extra hard link left by an interrupted run is refused with a message naming the `.identity` pin to remove from the `orphaned-token-*` folder. Missing ownership is never reconstructed. `ocx config import` refuses to replace a connected client record under the config lock. `tests/clients/client-link-connect.test.ts` covers these refusals and local liveness. The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. `src/cli/start-owner-fence.ts` holds the ownership mutation lease through recovery for ordinary and supervised starts. After an early negative probe, `start` and `ensure` recheck with `START_OWNERSHIP_LIVENESS` under that lease through `observeProxyLiveness` in `src/server/proxy-liveness.ts`, probing both available loopback families concurrently for every loopback or wildcard spelling classified after the same startup normalization (including `localhost.` and case variants) within the existing per-family retry ceilings and shared deadline. Numeric loopback classification covers the IPv4 127/8 range, equivalent IPv6 loopback spellings and IPv4-mapped loopback literals; it probes the configured address itself plus the other family, retaining the original bind scope and the responding probe address separately. This recovery classification does not change listener admission or bind policy. `probeEndpointLiveness` and `probePortOwner` retain their existing host selection: other specific loopback literals are checked at the requested address without adopting the opposite family. Only definitive absence permits PID deletion and journal reconciliation: no alive or indeterminate PID from either the PID file or runtime record unless fresh readable command-line evidence confirms it is foreign, no answering runtime owner, and only unanimous aggregate connection refusals or clean foreign identities at probed endpoints. Port zero names no existing listener. Timeouts, resets, unreadable/non-200 responses, PID mismatches and exhausted deadlines remain unknown; startup refuses with a fixed message while preserving PID, journal and shared config. One recovery-only exception covers a specific non-loopback literal IP address (a Tailscale or LAN IP, never a hostname, loopback or wildcard), where a connect to this host's own unbound address can time out instead of being refused: when its probe ends "unknown", `canBindAddress` tries an exclusive listen on that raw socket address (IPv6 unbracketed) and port, closes it at once, and only a successful bind turns that endpoint "dead". The proof shares the discovery deadline: it is skipped once the budget is spent, its timeout is capped by the remaining budget, and a proof finishing past the deadline is discarded. Hostnames remain unknown after an inconclusive probe because name resolution may select different addresses for the probe and bind. Any bind error or timeout leaves the endpoint unknown, so startup still refuses; early (non-observer) discovery never binds. `tests/server/proxy-liveness-nonloopback-bind-proof.test.ts` covers hostnames, bound and unbindable IPs, IPv6 and deadline cases. An orphan runtime PID is checked separately from listener ownership: only a definite missing process or a fresh readable foreign command line permits recovery, while an indeterminate process check, unreadable/empty command line, or OpenCodex invocation remains unknown. The runtime PID is never supplied to destructive callers merely because it appears in the record; a confirmed foreign PID is never signalled. A discovered live owner preserves both records and its runtime/config bind scope in `LiveProxy.hostname`; omitted or blank hostnames become explicit `127.0.0.1` in both early and recovery discovery, so a later config change cannot replace a default loopback bind. Explicit wildcard scope remains intact, and the concrete responding address is separate `probeHostname` metadata. Integration policy uses the bind scope, so loopback discovery of a wildcard listener does not authorize tokenless Grok registration. Grok start and ensure/restart synchronization also recheck current desired-state admission after catalog discovery, immediately before the synchronous writer in `src/grok/sync.ts`; start additionally rejects work after shutdown begins or cleanup runs. This closes the same-process await gap without turning the desired-state and Grok files into a cross-process transaction. Ensure carries `probeHostname` separately through `src/cli/ensure-desired-integrations.ts` and `src/grok/sync.ts` to `src/grok/inject.ts` for the written destination; an explicit secondary loopback listener keeps its own address and port; after lease release, start applies `decideStartWithLiveOwner` and ensure follows its existing live-owner path. Cross-home sibling marking still precedes journal recovery. The bind/publication fence remains separate, and supervised recovery checks durable service ownership before liveness or mutation. `tests/cli/cli-start-journal-order.test.ts` covers an early miss; `tests/cli/cli-start-owner-absence.test.ts` covers consecutive misses and dead-owner recovery; `tests/server/proxy-liveness-recovery.test.ts` binds classification and existing budgets; `tests/server/proxy-liveness-bind-scope.test.ts` follows a missed early probe through desired-state reconciliation and the real Grok writer for runtime, orphan and configured-port discovery; `tests/server/proxy-liveness-owner-evidence.test.ts` and `tests/cli/cli-start-owner-evidence.test.ts` cover aggregate children, both listener families, shared deadlines and foreign-PID recovery. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts new file mode 100644 index 00000000000..896ad8cac7f --- /dev/null +++ b/tests/cli/disconnect-runtime.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { restartRuntimeAfterDisconnect } from "../../src/cli/disconnect-runtime"; + +function run(live: { pid: number | null; role?: string } | null, restartOk: boolean, json = false) { + const out: string[] = [], err: string[] = []; + let restarts = 0; + const done = restartRuntimeAfterDisconnect({ + findLive: async () => live, + restart: async () => { restarts += 1; return restartOk; }, + json, + info: line => out.push(line), + error: line => err.push(line), + }); + return done.then(code => ({ code, out, err, restarts })); +} + +describe("ocx disconnect restarts the client runtime it left behind", () => { + test("a live client runtime is restarted and the machine reported standalone", async () => { + const result = await run({ pid: 4321, role: "client" }, true); + expect(result).toMatchObject({ code: 0, restarts: 1, err: [] }); + expect(result.out.join("\n")).toContain("now runs standalone"); + }); + + test("a restart that does not complete fails and names the remaining command", async () => { + const result = await run({ pid: 4321, role: "client" }, false); + expect(result.code).toBe(1); + expect(result.err.join("\n")).toContain("client runtime (pid 4321) still serves the old connection. Run on this machine: ocx restart"); + }); + + test("a standalone runtime or none is left alone", async () => { + for (const live of [{ pid: 4321 }, { pid: 4321, role: "standalone" }, null]) { + expect(await run(live, true)).toMatchObject({ code: 0, restarts: 0, out: [], err: [] }); + } + }); + + test("--json keeps stdout to the disconnect document", async () => { + const logged: unknown[][] = []; + const original = console.log; + console.log = (...args: unknown[]) => { logged.push(args); }; + try { + const result = await restartRuntimeAfterDisconnect({ + findLive: async () => ({ pid: 1, role: "client" }), + restart: async () => { console.log("restart progress"); return true; }, + json: true, + error: () => {}, + }); + expect(result).toBe(0); + } finally { console.log = original; } + expect(logged).toEqual([]); + }); +}); diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 06cd7feb460..67e99fc1f41 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -253,6 +253,30 @@ describe("client restart controller", () => { expect(calls).toEqual([]); }); + // After `ocx disconnect` (or a newer connect) the served connection is gone, so there is no + // old runtime worth keeping: the restart hands off to the recycle, which every owner relaunches. + for (const supervisor of ["desktop", "service", null] as const) { + test(`a runtime whose connection changed recycles under ${supervisor ?? "no"} supervisor`, async () => { + const { io, calls } = fakeIo({ + supervisor: async () => supervisor, + connectionChanged: () => true, + recycle: async () => { calls.push("recycle"); }, + }); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + expect(controller.hasHandoff()).toBe(true); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: true }); + await controller.settled(); + expect(calls).toEqual(["recycle"]); + }); + } + + test("an unchanged connection under a supervisor is still refused", async () => { + const { io, calls } = fakeIo({ supervisor: async () => "service", connectionChanged: () => false, recycle: async () => { calls.push("recycle"); } }); + expect(await createClientRestartController(io).request()).toEqual({ kind: "supervised", supervisor: "service" }); + expect(calls).toEqual([]); + }); + test("a second request while one is in flight joins it", async () => { let finish!: () => void; let spawns = 0; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d03860e91ff..8e9314c92d6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1625,7 +1625,7 @@ "codex-set-prompt.test.ts": "gui", "codex-prompt-writes.test.ts": "codex-integration", "codex-prompt-scalar-scope.test.ts": "codex-integration", "advisor-settings.test.ts": "advisor", "advisor-context.test.ts": "advisor", "advisor-state.test.ts": "advisor", "advisor-guard.test.ts": "advisor", "advisor-consult.test.ts": "advisor", "advisor-plan.test.ts": "advisor", "advisor-responses-wiring.test.ts": "advisor", "advisor-routes.test.ts": "server", "advisor-internal-authority.test.ts": "advisor", "advisor-core-boundary.test.ts": "advisor", "advisor-continuation-limit.test.ts": "advisor", "advisor-authority-transport.test.ts": "advisor", "advisor-evidence.test.ts": "advisor", "advisor-budget.test.ts": "advisor", "advisor-adaptive.test.ts": "advisor", "advisor-scope-transport.test.ts": "advisor" ,"link-codex-settings-digest.test.ts": "clients", "link-apply-record.test.ts": "clients", "client-runtime-restart.test.ts": "clients", "link-parity-reporter.test.ts": "clients", - "link-apply-restart.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server", + "link-apply-restart.test.ts": "cli", "disconnect-runtime.test.ts": "cli", "link-child-parity.test.ts": "server", "restart-replacement-not-ready.test.ts": "server", "hermes-profiles.test.ts": "clients", "hermes-profile-routes.test.ts": "clients" ,"cli-sync-expect-link.test.ts": "cli" ,"local-credentials.test.ts": "cli", "local-credential-generation.test.ts": "config", "link-connection-generation.test.ts": "clients" From 3bca971a718fdb2ae4b49ac2c90803625d4922f1 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 16:33:50 +0900 Subject: [PATCH 02/54] fix(link): name the failed condition and its fix in the Child restart preflight refusal child_restart_unsupported now carries a hint for each cause (still linked, Hub, stale client runtime, no restart capability, port mismatch, nothing answering) with the Child command that fixes it, and the CLI appends the error code. --- src/cli/link-enrollment.ts | 4 ++- src/server/management/link-child-preflight.ts | 31 +++++++++++++++++++ src/server/management/link-enrollment.ts | 9 +++--- tests/cli/cli-link-enrollment.test.ts | 13 ++++++++ tests/server/link-enrollment.test.ts | 17 ++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/cli/link-enrollment.ts b/src/cli/link-enrollment.ts index f459c68ba47..f7af5a9b6a9 100644 --- a/src/cli/link-enrollment.ts +++ b/src/cli/link-enrollment.ts @@ -212,7 +212,9 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] const hint = record(error.body.error) && typeof error.body.error.hint === "string" ? error.body.error.hint : ""; const message = phase ? "Remote Link failed during " + phase + (hint ? ": " + hint : "") : error.message; const recovery = ephemeralFlow ? RECOVERY_EPHEMERAL : RECOVERY_FULL; - if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + recovery : "")); + // The machine-readable code rides along so a script can branch without parsing prose. + const codeSuffix = phase && typeof code === "string" ? " (code " + code + ")" : ""; + if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + recovery : "") + codeSuffix); } throw error; } finally { diff --git a/src/server/management/link-child-preflight.ts b/src/server/management/link-child-preflight.ts index d3332f03dee..8c9d9a2f37e 100644 --- a/src/server/management/link-child-preflight.ts +++ b/src/server/management/link-child-preflight.ts @@ -30,6 +30,37 @@ export function childConfigFailureHint(result: { code: number; stderr: string }) return "ocx config show --json on the Child did not print a configuration with a valid port"; } + +/** + * Why a full Child cannot be enrolled as it stands, and the Child command that fixes it. Each of + * these used to share one phase name ("Child restart preflight"), which left the Home operator to + * reproduce the probe over SSH. Kept under the 160 code point hint cap. + */ +export function childRoleRefusalHint(runtimeRole: unknown): string { + return runtimeRole === "client" + ? "The Child is still linked to a Home (config runtimeRole client). On the Child run: ocx disconnect, then ocx restart." + : `The Child is a Hub (config runtimeRole ${runtimeRole === "hub" ? "hub" : "unknown"}). On the Child run: ocx hub disable, then retry.`; +} + +export function childHealthRefusalHint(health: unknown, port: number): string { + const body = health && typeof health === "object" && !Array.isArray(health) ? health as Record : null; + if (!body || body.service !== "opencodex") { + return `No OpenCodex answered /healthz on the Child's port ${port}. On the Child run: ocx start (or ocx service start).`; + } + if (body.port !== port) { + const seen = typeof body.port === "number" ? String(body.port) : "another port"; + return `The Child's runtime answers as port ${seen} but its config port is ${port}. On the Child run: ocx restart.`; + } + if (body.role === "client") { + return `The Child still serves a client runtime (/healthz role client on port ${port}). On the Child run: ocx restart.`; + } + if (body.role !== undefined && body.role !== "standalone") { + return `The Child serves a Hub runtime (/healthz role hub on port ${port}). On the Child run: ocx hub disable, then ocx restart.`; + } + return `The Child's runtime advertises no restart capability v1 on port ${port}. Update OpenCodex on the Child, then run: ocx restart.`; +} + + /** `ocx config validate --json` prints one small object; this bounds a misbehaving Child. */ export const CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES = 16 * 1024; diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index 5afa7eabb96..f45970187e4 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -14,7 +14,7 @@ import { withConfigMutationLockSync } from "../../config/mutation-lock"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; import { fail, exactBody, port, isRecord, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, putKnownHost, issueKey, compensateNewLink, type LinkRouteState } from "./link-route-state"; -import { CHILD_CONFIG_TOO_LARGE_HINT, CHILD_CONFIG_TOO_LARGE_MESSAGE, CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES, childConfigFailureReason } from "./link-child-preflight"; +import { CHILD_CONFIG_TOO_LARGE_HINT, CHILD_CONFIG_TOO_LARGE_MESSAGE, CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES, childConfigFailureReason, childHealthRefusalHint, childRoleRefusalHint } from "./link-child-preflight"; const PROBE_TTL_MS = 5 * 60_000; const MIN_REMOTE_OCX = [2, 66, 0] as const; @@ -293,7 +293,7 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom const hint = await childConfigFailureReason(configured, () => run(ocx(["config", "validate", "--json"]), budget, CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES)); return await failed(fail("child_preflight_failed", "The Child effective configuration could not be read.", 502, hint)); } - if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return await failed(fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409)); + if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return await failed(fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409, childRoleRefusalHint(childConfig.runtimeRole))); const childPort = childConfig.port; if (remotePlatform === "posix" && (await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return await failed(fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409)); const before = await run(remoteChildHttpArgv(childPort, "healthz", LINK_RELAY_VERIFY_MS, remotePlatform)); @@ -302,8 +302,9 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom if (before.code === 255) return await failed(fail("child_preflight_failed", "The Child loopback port could not be inspected.", 502, sshFailureHint(before.stderr))); if (before.code === 0) return await failed(fail("child_port_busy", "The Child's target port is already in use; no Child files were modified.", 409)); } else { - const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null; - if (!isChildHealth(beforeHealth, "standalone", childPort)) return await failed(fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409)); + let beforeHealth: unknown = null; + try { if (before.code === 0) beforeHealth = parseChildHttp(before.stdout); } catch { /* reported as nothing answering */ } + if (!isChildHealth(beforeHealth, "standalone", childPort)) return await failed(fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409, childHealthRefusalHint(beforeHealth, childPort))); } state.confirmedHosts?.delete(alias); phase = "home_setup"; diff --git a/tests/cli/cli-link-enrollment.test.ts b/tests/cli/cli-link-enrollment.test.ts index 13dd81b9832..4a34e0078e7 100644 --- a/tests/cli/cli-link-enrollment.test.ts +++ b/tests/cli/cli-link-enrollment.test.ts @@ -180,6 +180,19 @@ describe("CLI Remote Link enrollment", () => { } finally { h.restore(); } }); + test("a Child restart preflight refusal names the condition, the fix and the code", async () => { + let step = 0; + const h = harness(); + try { + const hint = "The Child still serves a client runtime (/healthz role client on port 10100). On the Child run: ocx restart."; + h.deps.fetchImpl = async () => ++step < 3 ? Response.json(step === 1 ? probe : confirm) : Response.json({ error: { code: "child_restart_unsupported", message: "x", hint } }, { status: 409 }); + expect(await runLinkCommand(["add", "child-a", "--expect-fingerprint", fingerprint], h.deps)).not.toBe(0); + const message = h.err.join("\n"); + expect(message).toContain("Remote Link failed during Child restart preflight: " + hint); + expect(message).toContain("(code child_restart_unsupported)"); + } finally { h.restore(); } + }); + test("only the bounded, redacted hint survives from an error body (#8357)", async () => { let step = 0; const h = harness(); diff --git a/tests/server/link-enrollment.test.ts b/tests/server/link-enrollment.test.ts index c7e1365d016..3209102d7f7 100644 --- a/tests/server/link-enrollment.test.ts +++ b/tests/server/link-enrollment.test.ts @@ -303,6 +303,23 @@ describe("Home enrollment of a standalone Child", () => { expect(h.state.confirmedHosts?.has("child")).toBe(true); }); + // The refusal names the failed condition and the command that fixes it; a phase name alone + // left a Home operator guessing whether the Child was still linked, stale, old or misbound. + for (const [name, step, output, hint] of [ + ["config still linked", "config", result('{"port":10100,"runtimeRole":"client"}'), "still linked to a Home (config runtimeRole client). On the Child run: ocx disconnect, then ocx restart"], + ["config Hub", "config", result('{"port":10100,"runtimeRole":"hub"}'), "is a Hub (config runtimeRole hub). On the Child run: ocx hub disable"], + ["stale client runtime", "before", health("client"), "still serves a client runtime (/healthz role client on port 10100). On the Child run: ocx restart"], + ["Hub runtime", "before", health("hub"), "serves a Hub runtime (/healthz role hub on port 10100). On the Child run: ocx hub disable, then ocx restart"], + ["no restart capability", "before", health("standalone", null), "advertises no restart capability v1 on port 10100. Update OpenCodex on the Child, then run: ocx restart"], + ["port mismatch", "before", http({ service: "opencodex", pid: 4321, port: 10101, role: "standalone", restartCapability: "v1" }), "answers as port 10101 but its config port is 10100. On the Child run: ocx restart"], + ["nothing answering", "before", result("", 7), "No OpenCodex answered /healthz on the Child's port 10100. On the Child run: ocx start (or ocx service start)"], + ] as const) test(`${name} refusal names the condition and the fix`, async () => { + const h = harness(); h.confirmed(); h.overrides[step] = () => output; + const payload = await denied(await h.call("apply", { alias: "child" }), "child_restart_unsupported", 409); + expect(payload.error.hint).toContain(hint); + expect(Array.from(payload.error.hint as string).length).toBeLessThanOrEqual(160); + }); + test("measured 3400ms SSH latency allows 28600ms commands and separate restart/relay budgets", async () => { const h = harness(); h.overrides.probe = call => { From 69867afd3a5936f273462756cc2cc1f6b925b650 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 16:33:50 +0900 Subject: [PATCH 03/54] fix(link): a client injection takes over the Codex journal so a supervised relaunch keeps routing ocx connect --link re-injected routing over a standalone proxy's injection but left the journal owned by that process. After the supervisor relaunch (desktop or service), startup recovery saw a dead process owner and restored native Codex and the old catalog under the new link. --- src/codex/journal.ts | 21 ++++++++++-- tests/codex-integration/codex-journal.test.ts | 34 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/codex/journal.ts b/src/codex/journal.ts index fb8311a551e..dab13667b96 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -192,7 +192,7 @@ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWri // Ownership is decided HERE, from the bytes about to be journaled — never taken // on the caller's word. A caller that says "native" about injected content would // otherwise make opencodex's own routing the user's permanent "original". - if (hasInjectedCodexRouting(config)) return; + if (hasInjectedCodexRouting(config)) { adoptJournalForClient(options.owner, hooks); return; } // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than // the check above (it may describe bytes read a moment earlier), so an // unclassified call creates a first snapshot but never overwrites one. @@ -201,7 +201,7 @@ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWri throw new CodexHomeOwnerRefusal("owner-unknown"); } const previous = inspection.kind === "read" ? inspection.journal as unknown as Journal : null; - if (previous && options.currentStateIsNative !== true) return; + if (previous && options.currentStateIsNative !== true) { adoptJournalForClient(options.owner, hooks); return; } const profile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; @@ -219,6 +219,23 @@ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWri atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks); } +/** + * A committing client injection takes over a journal another owner wrote, keeping its original + * snapshot. Routing is the client's from here on; left with a standalone process as owner, the + * first start after that process exited (a supervisor relaunch after `ocx connect --link` and + * `ocx restart`) read it as a crashed session and restored native Codex under the new link. + */ +function adoptJournalForClient(owner: WriteJournalOptions["owner"], hooks: AtomicWriteHooks): void { + if (owner?.kind !== "client") return; + const inspection = readCodexHomeJournal(JOURNAL_PATH); + if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); + if (inspection.kind !== "read") return; + const previous = inspection.journal as unknown as Journal; + if (previous.owner?.kind === "client" && previous.owner.apiKeyId === owner.apiKeyId) return; + const adopted: Journal = { ...previous, owner: { kind: "client", apiKeyId: owner.apiKeyId } }; + atomicWriteFile(JOURNAL_PATH, JSON.stringify(adopted), undefined, hooks); +} + export interface InjectedJournalOwnership { injectedOpenaiBaseUrl: string | null; injectedRealtimeWsBaseUrl: string | null; diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 5cd189eb3d5..5392fed23d1 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -387,6 +387,40 @@ describe("codex-journal", () => { expect(existsSync(journalPath)).toBe(false); }); + // A Remote Link Child linked while a standalone proxy had routing injected: `ocx connect --link` + // re-injects for the client, but the journal stayed the standalone process's. Once that process + // exited into its supervisor (desktop exit 75, service exit 1), the supervisor's fresh start saw a + // dead process owner and restored native Codex and the old catalog under the new link. + test("a client injection over a standalone injection takes the journal, so a supervised restart keeps it", () => { + const original = '# original config\nmodel_provider = "openai"\n'; + writeFileSync(join(testDir, "config.toml"), original, "utf8"); + const linked = runScript(testDir, ` + const { injectCodexConfig } = require("./src/codex/inject"); + (async () => { + const config = { port: 10100, providers: {}, defaultProvider: "openai" }; + const standalone = await injectCodexConfig(10100, config, { catalogPath: null }); + const client = await injectCodexConfig(10100, config, { catalogPath: null, journalOwner: { kind: "client", apiKeyId: "link-key" } }); + console.log(JSON.stringify({ standalone: standalone.success, client: client.success })); + })(); + `); + expect(linked.status).toBe(0); + expect(JSON.parse(linked.stdout)).toEqual({ standalone: true, client: true }); + const journalPath = join(testDir, "opencodex-journal.json"); + const journal = JSON.parse(readFileSync(journalPath, "utf8")); + expect(journal.owner).toEqual({ kind: "client", apiKeyId: "link-key" }); + expect(Buffer.from(journal.originalConfig, "base64").toString("utf8")).toBe(original); + const routed = readFileSync(join(testDir, "config.toml"), "utf8"); + + // The supervisor's start: the injecting process is gone, the link is the committed client. + const restarted = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ restored: reconcileJournal({ activeClientApiKeyId: "link-key" }) })); + `); + expect(restarted.status).toBe(0); + expect(JSON.parse(restarted.stdout).restored).toBe(false); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(routed); + }); + test("removeJournal refuses unknown journal evidence", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "{}", "utf8"); From d79b051f079d95b67f0eb9df57c2104c7bc2a4ee Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 16:33:50 +0900 Subject: [PATCH 04/54] fix(cli): point refusal and status hints at commands that work beside a live or desktop-owned proxy --- src/cli/desktop-runtime-guidance.ts | 2 +- src/cli/restart-failure.ts | 2 +- src/cli/status.ts | 4 +++- tests/cli/cli-restart-health.test.ts | 2 +- tests/cli/link-apply-restart.test.ts | 2 +- tests/service/autostart-health.test.ts | 7 ++++--- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/cli/desktop-runtime-guidance.ts b/src/cli/desktop-runtime-guidance.ts index 241c6d1ad64..ece73a7b47e 100644 --- a/src/cli/desktop-runtime-guidance.ts +++ b/src/cli/desktop-runtime-guidance.ts @@ -19,7 +19,7 @@ export function duplicateRuntimeMessage( if (pid != null && latch.observe(evidence)) { const supervisor = evidence.kind === "desktop" ? `, ${evidence.app}` : ""; return `OpenCodex Desktop supervises the running proxy (pid ${pid}${supervisor}, port ${port}). ` - + "Use the running proxy, or quit OpenCodex before starting it from this CLI."; + + "Use the running proxy (restart it with 'ocx restart', re-point Codex with 'ocx sync'), or quit OpenCodex before starting it from this CLI."; } return `⚠️ Proxy already running (PID ${pid ?? "unknown"}, port ${port}). Use 'ocx stop' first.`; } diff --git a/src/cli/restart-failure.ts b/src/cli/restart-failure.ts index 5352cdf19c1..a086a753199 100644 --- a/src/cli/restart-failure.ts +++ b/src/cli/restart-failure.ts @@ -36,7 +36,7 @@ export function reportRestartFailure(result: Extract { test("duplicate start guidance names the verified Desktop target", () => { const probes: Array = []; expect(duplicateRuntimeMessage(4242, 19999, deps => { probes.push(deps?.targetPid); return DESKTOP; })) - .toBe(`OpenCodex Desktop supervises the running proxy (pid 4242, ${DESKTOP.app}, port 19999). Use the running proxy, or quit OpenCodex before starting it from this CLI.`); + .toBe(`OpenCodex Desktop supervises the running proxy (pid 4242, ${DESKTOP.app}, port 19999). Use the running proxy (restart it with 'ocx restart', re-point Codex with 'ocx sync'), or quit OpenCodex before starting it from this CLI.`); expect(probes).toEqual([4242]); for (const evidence of [...NON_DESKTOP, { ...DESKTOP, runtimePid: 9999 }]) { expect(duplicateRuntimeMessage(4242, 19999, () => evidence)) diff --git a/tests/cli/link-apply-restart.test.ts b/tests/cli/link-apply-restart.test.ts index 4a4f867c626..fb384be7781 100644 --- a/tests/cli/link-apply-restart.test.ts +++ b/tests/cli/link-apply-restart.test.ts @@ -198,7 +198,7 @@ describe("ocx restart against a client runtime", () => { } expect(lines).toEqual([ "Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.", - "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it: restart it from the app.", + "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it while its connection is current: use Quit in the OpenCodex tray menu, then reopen it. After `ocx disconnect`, `ocx restart` hands it to the app.", ]); }); }); diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts index d7a1ea51212..60aa28da140 100644 --- a/tests/service/autostart-health.test.ts +++ b/tests/service/autostart-health.test.ts @@ -452,8 +452,9 @@ describe("routing visibility (#2411)", () => { // Routing that is already ours needs no re-point. expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "opencodex-local", connectedClient: link })).toEqual([]); expect(unusedProxyWarningLines({ proxyUp: false, routingKind: "opencodex-local", connectedClient: link })).toEqual([]); - // Standalone wording is unchanged. - expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "native" }).join("\n")).toContain("Re-point with: ocx start"); + // Standalone: 'ocx start' refuses beside a live proxy (and under the desktop app), so the + // re-point is the command that writes routing to the running one. + expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "native" }).join("\n")).toContain("Re-point with: ocx sync"); }); test("the dead-proxy restore advice names the way back only on a connected client", () => { @@ -473,7 +474,7 @@ describe("routing visibility (#2411)", () => { expect(statusUnusedProxyLines(view(true, "link", "native", true)).join("\n")).toContain("not the Home. Re-point with: ocx sync --restart-codex"); expect(statusUnusedProxyLines(view(true, undefined, "native", false)).join("\n")).toContain("not the hub"); expect(statusUnusedProxyLines(view(true, "link", "native", false)).join("\n")).toContain("'ocx service start'"); - expect(statusUnusedProxyLines(view(false, undefined, "native", true)).join("\n")).toContain("Re-point with: ocx start"); + expect(statusUnusedProxyLines(view(false, undefined, "native", true)).join("\n")).toContain("Re-point with: ocx sync"); expect(statusDeadProxyLines(view(true, "link", "opencodex-local", false)).join("\n")).toContain("ocx sync --restart-codex"); expect(statusDeadProxyLines(view(false, undefined, "opencodex-local", false)).join("\n")).not.toContain("ocx sync"); const routedDown = statusDeadProxyLines(view(true, "link", "opencodex-local", false)).join("\n"); From 73464c7c6b351d69321d629b2a9d5d4274edb935 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 16:33:50 +0900 Subject: [PATCH 05/54] fix(cli): explain client_catalog_ownership_changed on disconnect and name --keep-catalog --- src/cli/connect.ts | 8 +++- tests/clients/client-link-teardown.test.ts | 54 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 695f3f752cc..74cbab8deab 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -599,7 +599,13 @@ export async function handleDisconnectCommand( knownHostsFile: deps.linkTeardownDeps?.knownHostsFile ?? linkKnownHostsPath(), timeoutMs: deps.linkTeardownDeps?.timeoutMs, }); - const result = await disconnectClient({ keepCatalog }, deps); + const result = await disconnectClient({ keepCatalog }, deps).catch((error: unknown) => { + // The bare code left a Home-driven leave with nothing to act on; the remedy already exists. + if (error instanceof Error && error.message === "client_catalog_ownership_changed") { + throw new Error("client_catalog_ownership_changed: the Codex model catalog (opencodex-catalog.json in the Codex home) matches neither the catalog this connection wrote nor the one recorded before it connected, so something changed it after connecting and disconnect will not overwrite it. To disconnect and leave that file as it is, run: ocx disconnect --keep-catalog"); + } + throw error; + }); const payload = { ...result, homeRevoke: teardown.homeRevoke, diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index 540bc7cf348..42e9309b197 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -6,6 +6,7 @@ import { connectClient } from "../../src/client/connect"; import { teardownClientLink } from "../../src/client/link-teardown"; import { clientLinkStatePath, writeClientLinkState } from "../../src/client/link-state"; import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; +import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; import { createTempHome } from "../helpers/temp-home"; const linkId = "lnk_0123456789abcdef"; @@ -151,3 +152,56 @@ test("an unreadable sidecar skips the revoke but still reports the manual revoke expect(standalone).toEqual({ linkId: null, homeRevoke: "not_applicable", tunnel: { tunnel: "absent" } }); expect(calls).toBe(0); }); + +test("a catalog changed after connect names what was found and --keep-catalog", async () => { + const home = createTempHome("ocx-client-link-catalog-"); + try { + const config = getDefaultConfig(); + config.port = 10100; + saveConfig(config); + mkdirSync(home.codexHome, { recursive: true }); + writeFileSync(home.path(".codex", "config.toml"), "model_provider = \"openai\"\n"); + await connectClient({ + serverUrl: "http://127.0.0.1:34567", + managementUrl: "http://127.0.0.1:34567", + managementTransport: "direct", + transport: "link", + link: { tunnelPort: 34567, linkId }, + credential: { kind: "link", apiKeyId: "key-1", key }, + selectedClients: ["codex"], + noSync: true, + }, { + fetchImpl: async input => String(input).endsWith("/readyz") + ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) + : Response.json({ models: [] }), + catalogCompatibility: { supportedEfforts: () => new Set() }, + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + }); + // Something else rewrote the catalog after connect: neither ours nor the recorded prior one. + writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify({ models: [{ slug: "rewritten-elsewhere" }] })); + const logs = spyOn(console, "log").mockImplementation(() => {}); + const errors = spyOn(console, "error").mockImplementation(() => {}); + let exit = -1; + let output = ""; + try { + exit = await handleDisconnectCommand([], { + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + linkTeardownDeps: { + reapOrphanTunnel: async () => ({ tunnel: "absent" }), + runner: { run: async () => ({ code: 1, stdout: "", stderr: "" }) }, + knownHostsFile, + }, + }); + output = errors.mock.calls.flat().join("\n"); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + expect(exit).not.toBe(0); + expect(output).toContain("client_catalog_ownership_changed"); + expect(output).toContain("matches neither the catalog this connection wrote nor the one recorded before it connected"); + expect(output).toContain("ocx disconnect --keep-catalog"); + } finally { + home.remove(); + } +}); From 9c20cdecba503ec32835fc0652c3944b8bd8696a Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 16:33:50 +0900 Subject: [PATCH 06/54] docs(link): Child leave and re-link behavior under the desktop app or a service --- docs-site/src/content/docs/guides/remote-link.md | 6 +++++- structure/remote-link.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 79a367d50ca..5115609da29 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -109,6 +109,8 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` +On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. + ## Troubleshooting When a step fails, the dashboard shows the reason and, when SSH reported one, a short sanitized hint from its last non-empty error line under the message. Remote-shell errors can appear there even when the remote shell emits non-UTF-8 text; OpenCodex removes terminal controls, link keys and URL queries and limits the hint's length. @@ -252,6 +254,8 @@ A Windows x64 Child needs OpenSSH Server, Windows PowerShell and either standalo Run `ocx link cleanup` on Home to inspect recorded Child platforms without changing them. Current Home releases disconnect Windows Children through PowerShell. If a remote disconnect fails, resolve the reported shell or installation error and retry; **Remove here only** remains an explicit recovery action that can leave the Child needing a local disconnect. -Restart waits for running turns and may take several minutes. Success requires the restarted Child to serve the client role and relay a fresh complete catalog to this Home. SSH setup has a bounded allowance derived from measured latency; relay verification starts after restart with its own 15-second window. Errors name the failing phase. If an error reports residual Child configuration, run `ocx disconnect` and `ocx restart` on that Child before retrying. Home cleanup alone does not restore Child configuration. A CLI timeout reports an uncertain outcome; inspect Home `ocx link status` and Child `ocx connect status` first. +Restart waits for running turns and may take several minutes. Success requires the restarted Child to serve the client role and relay a fresh complete catalog to this Home. SSH setup has a bounded allowance derived from measured latency; relay verification starts after restart with its own 15-second window. Errors name the failing phase and end with the error code. If an error reports residual Child configuration, run `ocx disconnect` and `ocx restart` on that Child before retrying. Home cleanup alone does not restore Child configuration. A CLI timeout reports an uncertain outcome; inspect Home `ocx link status` and Child `ocx connect status` first. + +A `Child restart preflight` failure (`child_restart_unsupported`) names what the Child is and the Child command that fixes it. Typical causes are a Child still linked to a Home (run `ocx disconnect`, then `ocx restart`), a Child still serving a client runtime (`ocx restart`), a Hub (`ocx hub disable`), a runtime without restart support (update OpenCodex), and a runtime answering on a port other than its configured one (`ocx restart`). On a Home-initiated Child, `ocx link status` reports role `child`, link identity and tunnel port from its saved connection even though its Home-side link store is empty. Locally unobserved tunnel state is `idle`. `ocx status` calls the peer a Remote Link Home. When Home runs in Hub mode, status reads its supported projection through the local Child relay. A standalone Home reports that it does not publish Hub state. Child-initiated tunnel identity comes from its valid sidecar; unreadable evidence does not claim a direction. diff --git a/structure/remote-link.md b/structure/remote-link.md index bfd7574e84e..9e0d738eb3b 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live and exits non-zero naming `ocx restart` when it does not complete. A committing client injection also takes over a Codex journal another owner wrote (`src/codex/journal.ts`), so a supervisor relaunch after link enrollment does not read the standalone owner as a crashed session and restore native Codex. Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From 95b4a51e8d3d2cd1aa1307acb6ad9f40783973f6 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 16:51:50 +0900 Subject: [PATCH 07/54] fix(cli): name the desktop-remote folder that blocks disconnect and how to clear it A desktop-remote folder holding no state or receipt (seen with a renamed receipt) refused disconnect with only desktop_lifecycle_unsafe. The inspector now returns that reason and path, and disconnect names the folder and says to move it aside. --- src/claude/desktop-remote-store.ts | 5 +- src/client/connect.ts | 5 +- tests/clients/client-link-teardown.test.ts | 57 +++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/claude/desktop-remote-store.ts b/src/claude/desktop-remote-store.ts index 3e18307c980..e59c4db44d9 100644 --- a/src/claude/desktop-remote-store.ts +++ b/src/claude/desktop-remote-store.ts @@ -52,6 +52,9 @@ function recordedGenerations(io: StoreIO, owner: DesktopRemoteOwner): string[] { export function inspectRemoteDesktopStore(owner: DesktopRemoteOwner): { kind: "absent" | "active" | "pending" | "restored" | "legacy_current_connection" | "conflict" | "unsafe"; + /** Set for the one refusal a person can clear: the store folder exists with nothing in it OpenCodex reads. */ + reason?: "root_without_state"; + path?: string; } { try { parseOwner(owner); @@ -71,7 +74,7 @@ export function inspectRemoteDesktopStore(owner: DesktopRemoteOwner): { if (projectionHash(file?.value ?? null) !== bundle.state.lastProjectionHash) return { kind: "conflict" }; return { kind: bundle.state.phase === "restored" ? "restored" : "active" }; } - if (existsSync(bundle.paths.root) && !receipt(io)) throw new DesktopStoreError("unsafe"); + if (existsSync(bundle.paths.root) && !receipt(io)) return { kind: "unsafe", reason: "root_without_state", path: bundle.paths.root }; const legacy = locateLegacy(io, owner, serviceGenerations()); if (legacy) currentConnection(owner); return { kind: legacy ? "legacy_current_connection" : "absent" }; diff --git a/src/client/connect.ts b/src/client/connect.ts index af6bf711eaf..31fd038a592 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -1118,7 +1118,10 @@ export async function disconnectClient( } if (!receipt) { const desktop = inspectRemoteDesktopStore(desktopOwner(connection)); - if (desktop.kind === "unsafe" || desktop.kind === "conflict") throw new Error("desktop_lifecycle_unsafe"); + if (desktop.reason === "root_without_state") { + throw new Error(`desktop_lifecycle_unsafe: ${desktop.path} exists but holds no Claude Desktop remote state or disconnect receipt (a leftover or renamed file), so disconnect cannot tell what it would restore. Move that folder aside, then run ocx disconnect again.`); + } + if (desktop.kind === "unsafe" || desktop.kind === "conflict") throw new Error(`desktop_lifecycle_unsafe: the Claude Desktop remote profile store could not be verified (${desktop.kind}); nothing was changed.`); preflightDisconnectCatalog(connection, keepCatalog); receipt = { version: 1, owner: desktopOwner(connection), tokenFingerprint: connection.tokenFingerprint, keepCatalog, phase: "prepared" }; writeDesktopDisconnectReceipt(held, previous, receipt); diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index 42e9309b197..3c9a49ec014 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -1,5 +1,5 @@ import { expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { handleDisconnectCommand } from "../../src/cli/connect"; import { connectClient } from "../../src/client/connect"; @@ -205,3 +205,58 @@ test("a catalog changed after connect names what was found and --keep-catalog", home.remove(); } }); + +test("a desktop-remote folder with no state or receipt is named, with the way to clear it", async () => { + const home = createTempHome("ocx-client-link-desktop-store-"); + try { + const config = getDefaultConfig(); + config.port = 10100; + saveConfig(config); + mkdirSync(home.codexHome, { recursive: true }); + writeFileSync(home.path(".codex", "config.toml"), "model_provider = \"openai\"\n"); + await connectClient({ + serverUrl: "http://127.0.0.1:34567", + managementUrl: "http://127.0.0.1:34567", + managementTransport: "direct", + transport: "link", + link: { tunnelPort: 34567, linkId }, + credential: { kind: "link", apiKeyId: "key-1", key }, + selectedClients: ["codex"], + noSync: true, + }, { + fetchImpl: async input => String(input).endsWith("/readyz") + ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) + : Response.json({ models: [] }), + catalogCompatibility: { supportedEfforts: () => new Set() }, + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + }); + // Seen on a Child: someone renamed the receipt, leaving the folder with nothing OpenCodex reads. + mkdirSync(home.path("desktop-remote"), { recursive: true }); + writeFileSync(home.path("desktop-remote", "disconnect.json.stale-20261108"), "{}"); + const logs = spyOn(console, "log").mockImplementation(() => {}); + const errors = spyOn(console, "error").mockImplementation(() => {}); + let exit = -1; + let output = ""; + try { + exit = await handleDisconnectCommand([], { + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + linkTeardownDeps: { + reapOrphanTunnel: async () => ({ tunnel: "absent" }), + runner: { run: async () => ({ code: 1, stdout: "", stderr: "" }) }, + knownHostsFile, + }, + }); + output = errors.mock.calls.flat().join("\n"); + } finally { + logs.mockRestore(); + errors.mockRestore(); + } + expect(exit).not.toBe(0); + expect(output).toContain("desktop_lifecycle_unsafe"); + expect(output).toContain(realpathSync.native(home.path("desktop-remote"))); + expect(output).toContain("holds no Claude Desktop remote state or disconnect receipt"); + expect(output).toContain("Move that folder aside, then run ocx disconnect again"); + } finally { + home.remove(); + } +}); From 0b90d4229618f0c1c672b6934fff546477db52c3 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:24:42 +0900 Subject: [PATCH 08/54] fix(link): recycle a supervised client only on a proven end of its connection Review: an invalid or mismatched client state also read as "changed", so a supervised runtime could exit on an unreadable config. Only a disconnected state or a different committed connection now counts, and the controller rechecks right before it recycles. --- src/client/runtime-restart.ts | 17 ++++++++++++--- src/client/runtime.ts | 4 ++-- tests/clients/client-runtime-restart.test.ts | 22 +++++++++++++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index e7848854d40..5b48db6cc56 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -108,9 +108,10 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar if (io.stopping?.()) return { kind: "stopping" }; if (io.recycle && io.connectionChanged?.()) { if (flight) return { kind: "accepted", alreadyRestarting: true }; - const recycle = io.recycle; - // Let the 202 reach the CLI before the listener closes. - flight = sleep(200).then(recycle).finally(() => { flight = null; }); + const recycle = io.recycle, changed = io.connectionChanged; + // Let the 202 reach the CLI before the listener closes, then confirm the connection is + // still ended: a connect that committed meanwhile keeps this runtime (the CLI reports kept). + flight = sleep(200).then(async () => { if (changed()) await recycle(); }).finally(() => { flight = null; }); return { kind: "accepted", alreadyRestarting: false }; } const supervisor = await io.supervisor(); @@ -149,3 +150,13 @@ export function clientRestartRollbackAction( ? "resume" : "recycle"; } + +/** + * Whether the served connection has provably ended: the durable state is disconnected, or names a + * different connection. Unreadable or mismatched state is not proof, so a supervised restart keeps + * refusing on it instead of exiting a runtime whose link may still work. + */ +export function clientConnectionEnded(served: OcxClientConnectionConfig, current: ClientConnectionState): boolean { + if (current.kind === "disconnected") return true; + return current.kind === "connected" && clientRestartRollbackAction(served, current) === "recycle"; +} diff --git a/src/client/runtime.ts b/src/client/runtime.ts index dd5c44512c2..b11332c715f 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -30,7 +30,7 @@ import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } f import { startMachineListener, type MachineListenerDeps } from "./machine-listener"; import { isLinkConnection, readClientConnectionState } from "./state"; import { withClientLifecycleSync } from "./lifecycle-lock"; -import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; +import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; @@ -433,7 +433,7 @@ export async function startClientRuntime( const restartController: ClientRestartController = createClientRestartController({ stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, - connectionChanged: () => clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle", + connectionChanged: () => clientConnectionEnded(state.value, readClientConnectionState()), recycle: () => recycleStandalone(state.value.tokenFingerprint), stop: stopServing, spawnReplacement: async deadlineAt => { diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 67e99fc1f41..29f960f18db 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; -import { clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; +import { clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; import { createLocalAttestationChallenge, createLocalAttestationSecret, @@ -277,6 +277,16 @@ describe("client restart controller", () => { expect(calls).toEqual([]); }); + test("a connection that is current again by the time the recycle would run keeps this runtime", async () => { + const verdicts = [true, false]; + const { io, calls } = fakeIo({ supervisor: async () => "desktop", connectionChanged: () => verdicts.shift() ?? false, recycle: async () => { calls.push("recycle"); } }); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + await controller.settled(); + expect(calls).toEqual([]); + expect(controller.hasHandoff()).toBe(false); + }); + test("a second request while one is in flight joins it", async () => { let finish!: () => void; let spawns = 0; @@ -293,6 +303,16 @@ describe("client restart controller", () => { describe("rollback after a failed replacement", () => { const link = { ...connection, transport: "link" as const, link: { linkId: `lnk_${"b".repeat(16)}`, tunnelPort: 23457 } }; + // Only a proven end of the served connection may recycle a supervised runtime; an unreadable or + // mismatched config is not one, and recycling on it could take down a link that still works. + test("a supervised restart treats only a disconnect or a different connection as ended", () => { + expect(clientConnectionEnded(link, { kind: "disconnected" } as never)).toBe(true); + expect(clientConnectionEnded(link, { kind: "connected", value: { ...link, connectedAt: "2026-10-21T00:00:00.000Z" } })).toBe(true); + expect(clientConnectionEnded(link, { kind: "connected", value: { ...link, link: { ...link.link, linkId: `lnk_${"c".repeat(16)}` } } })).toBe(true); + expect(clientConnectionEnded(link, { kind: "connected", value: { ...link, catalogSyncedAt: "2026-10-21T00:00:00.000Z" } })).toBe(false); + expect(clientConnectionEnded(link, { kind: "invalid", reason: "config.json is missing or unreadable" } as never)).toBe(false); + expect(clientConnectionEnded(link, { kind: "mismatched", reason: "x" } as never)).toBe(false); + }); test("serves the same connection again only while it is still the durable one", () => { expect(clientRestartRollbackAction(link, { kind: "connected", value: { ...link, catalogSyncedAt: "2026-10-21T00:00:00.000Z" } })).toBe("resume"); expect(clientRestartRollbackAction(link, { kind: "disconnected" } as never)).toBe("recycle"); From 666cddfa709e8a93353f14e77d5503b44c1617e9 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:24:42 +0900 Subject: [PATCH 09/54] fix(link): hand the Codex journal to a client only after its connection commits Review: taking the journal during injection let a runtime starting mid-connect restore native Codex as uncommitted client routing. The journal now stays its live writer's until commitClientConnection, then connect (and each connected sync, as the retry) adopts it under the Codex config lock. --- src/client/connect.ts | 9 ++++ src/codex/journal.ts | 46 +++++++++++-------- structure/remote-link.md | 2 +- tests/clients/client-link-teardown.test.ts | 41 ++++++++++++++++- tests/codex-integration/codex-journal.test.ts | 23 +++++++--- 5 files changed, 92 insertions(+), 29 deletions(-) diff --git a/src/client/connect.ts b/src/client/connect.ts index 31fd038a592..9ccc153fc25 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -28,6 +28,7 @@ import { type CodexRoutingTarget, } from "../codex/inject"; import { + adoptJournalForClient, journalOwner, restoreJournalState, } from "../codex/journal"; @@ -783,6 +784,12 @@ async function connectClientUnlocked( commitClientConnection(connection); committed = true; }), deps.lifecycleLockDeps); + // Only now does the routing belong to this connection: take over a journal another owner wrote + // (src/codex/journal.ts adoptJournalForClient). A failure keeps the connection; sync retries. + if (injectionCommitted) { + try { adoptJournalForClient(apiKeyId); } + catch { console.warn("Warning: the Codex journal could not be handed to this connection; run 'ocx sync' so a supervised restart keeps Codex routing."); } + } // Recorded only when the injection that applied it ran (not with --no-sync). The connection is // already committed, so a failed record write must not reach the rollback below: it is a // warning, and `ocx status` reports no recorded apply until `ocx sync` writes one. @@ -977,6 +984,8 @@ async function syncConnectedClientUnlocked( }); if (!result.success || result.status === "skipped") throw new Error(result.message); injected = true; + // A committed connection re-injected: own the journal too (retries a takeover connect missed). + try { adoptJournalForClient(next.apiKeyId); } catch { /* the journal keeps its owner; the next sync retries */ } if (homeSettings && next.link) { const linkId = next.link.linkId; const settings = homeSettings; diff --git a/src/codex/journal.ts b/src/codex/journal.ts index dab13667b96..3b9ac84b857 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -192,7 +192,7 @@ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWri // Ownership is decided HERE, from the bytes about to be journaled — never taken // on the caller's word. A caller that says "native" about injected content would // otherwise make opencodex's own routing the user's permanent "original". - if (hasInjectedCodexRouting(config)) { adoptJournalForClient(options.owner, hooks); return; } + if (hasInjectedCodexRouting(config)) return; // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than // the check above (it may describe bytes read a moment earlier), so an // unclassified call creates a first snapshot but never overwrites one. @@ -201,7 +201,7 @@ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWri throw new CodexHomeOwnerRefusal("owner-unknown"); } const previous = inspection.kind === "read" ? inspection.journal as unknown as Journal : null; - if (previous && options.currentStateIsNative !== true) { adoptJournalForClient(options.owner, hooks); return; } + if (previous && options.currentStateIsNative !== true) return; const profile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; @@ -219,23 +219,6 @@ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWri atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks); } -/** - * A committing client injection takes over a journal another owner wrote, keeping its original - * snapshot. Routing is the client's from here on; left with a standalone process as owner, the - * first start after that process exited (a supervisor relaunch after `ocx connect --link` and - * `ocx restart`) read it as a crashed session and restored native Codex under the new link. - */ -function adoptJournalForClient(owner: WriteJournalOptions["owner"], hooks: AtomicWriteHooks): void { - if (owner?.kind !== "client") return; - const inspection = readCodexHomeJournal(JOURNAL_PATH); - if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); - if (inspection.kind !== "read") return; - const previous = inspection.journal as unknown as Journal; - if (previous.owner?.kind === "client" && previous.owner.apiKeyId === owner.apiKeyId) return; - const adopted: Journal = { ...previous, owner: { kind: "client", apiKeyId: owner.apiKeyId } }; - atomicWriteFile(JOURNAL_PATH, JSON.stringify(adopted), undefined, hooks); -} - export interface InjectedJournalOwnership { injectedOpenaiBaseUrl: string | null; injectedRealtimeWsBaseUrl: string | null; @@ -511,6 +494,31 @@ export interface ReconcileJournalOptions { adoptUpdateHandover?: boolean; } +/** + * Called by `connectClient` right after its connection commits, when its injection ran: the + * client takes over a journal another owner wrote (a standalone proxy's injection it re-injected + * over), keeping the original snapshot. Before the commit the journal stays its writer's, so a + * runtime starting mid-connect sees a live process owner and leaves it alone. Without the takeover + * the first start after that process exited (a supervisor relaunch after `ocx connect --link` and + * `ocx restart`) read it as a crashed session and restored native Codex under the new link. + * Returns whether it changed the owner. + */ +export function adoptJournalForClient(apiKeyId: string): boolean { + if (codexHomeIsAbsent(CODEX_HOME)) return false; + const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, handle => { + assertCodexHomeOwner(CODEX_HOME); + beginCodexWriteSection(handle); + const journal = readJournal(); + if (!journal) return false; + if (journal.owner?.kind === "client" && journal.owner.apiKeyId === apiKeyId) return false; + const adopted = JSON.stringify({ ...journal, owner: { kind: "client", apiKeyId } }); + publishCodexArtifact(JOURNAL_PATH, handle, (path, hooks) => atomicWriteFile(path, adopted, undefined, hooks)); + return true; + }); + if (!locked.ok) throw new Error(locked.error === "unsafe" ? configWriteLockFailureMessage(locked) : "the Codex config lock is busy"); + return locked.value; +} + export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean { if (codexHomeIsAbsent(CODEX_HOME)) return false; const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, handle => { diff --git a/structure/remote-link.md b/structure/remote-link.md index 9e0d738eb3b..dbc590ff2b2 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live and exits non-zero naming `ocx restart` when it does not complete. A committing client injection also takes over a Codex journal another owner wrote (`src/codex/journal.ts`), so a supervisor relaunch after link enrollment does not read the standalone owner as a crashed session and restore native Codex. Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live and exits non-zero naming `ocx restart` when it does not complete. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks right before recycling. `ocx disconnect` treats an inconclusive liveness probe as a failure with guidance, never as absence. Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index 3c9a49ec014..0886d83fe20 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -1,12 +1,14 @@ import { expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { JOURNAL_PATH } from "../../src/codex/journal"; +import { injectCodexConfig } from "../../src/codex/inject"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { handleDisconnectCommand } from "../../src/cli/connect"; import { connectClient } from "../../src/client/connect"; import { teardownClientLink } from "../../src/client/link-teardown"; import { clientLinkStatePath, writeClientLinkState } from "../../src/client/link-state"; import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; -import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; import { createTempHome } from "../helpers/temp-home"; const linkId = "lnk_0123456789abcdef"; @@ -260,3 +262,38 @@ test("a desktop-remote folder with no state or receipt is named, with the way to home.remove(); } }); + +test("connect --link over a standalone injection hands the Codex journal to the committed client", async () => { + const home = createTempHome("ocx-client-link-journal-"); + try { + const config = getDefaultConfig(); + config.port = 10100; + saveConfig(config); + // The Codex paths are fixed at import from the preload's sandboxed CODEX_HOME. + writeFileSync(CODEX_CONFIG_PATH, "model_provider = \"openai\"\n"); + // The standalone proxy's own injection, journaled to this (live) process. + const standalone = await injectCodexConfig(10100, { ...config, syncResumeHistory: false }, { catalogPath: null }); + expect(standalone.success).toBe(true); + const journalPath = JOURNAL_PATH; + expect(JSON.parse(readFileSync(journalPath, "utf8")).owner.kind).toBe("process"); + await connectClient({ + serverUrl: "http://127.0.0.1:34567", + managementUrl: "http://127.0.0.1:34567", + managementTransport: "direct", + transport: "link", + link: { tunnelPort: 34567, linkId }, + credential: { kind: "link", apiKeyId: "key-1", key }, + selectedClients: ["codex"], + }, { + fetchImpl: async input => String(input).endsWith("/readyz") + ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) + : Response.json({ models: [] }), + catalogCompatibility: { supportedEfforts: () => new Set() }, + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + }); + expect(JSON.parse(readFileSync(journalPath, "utf8")).owner).toEqual({ kind: "client", apiKeyId: "key-1" }); + } finally { + for (const created of [JOURNAL_PATH, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH]) rmSync(created, { force: true }); + home.remove(); + } +}); diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 5392fed23d1..24512799a98 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -391,22 +391,31 @@ describe("codex-journal", () => { // re-injects for the client, but the journal stayed the standalone process's. Once that process // exited into its supervisor (desktop exit 75, service exit 1), the supervisor's fresh start saw a // dead process owner and restored native Codex and the old catalog under the new link. - test("a client injection over a standalone injection takes the journal, so a supervised restart keeps it", () => { + // A Remote Link Child linked while a standalone proxy had routing injected: `ocx connect --link` + // re-injects for the client. The journal must stay the live standalone's until the connection + // commits (a runtime starting mid-connect then skips it), and become the client's right after, or + // the supervisor's relaunch reads a dead process owner and restores native Codex under the link. + test("a client injection leaves the journal to its owner until connect commits and adopts it", () => { const original = '# original config\nmodel_provider = "openai"\n'; writeFileSync(join(testDir, "config.toml"), original, "utf8"); const linked = runScript(testDir, ` const { injectCodexConfig } = require("./src/codex/inject"); + const { adoptJournalForClient } = require("./src/codex/journal"); + const fs = require("fs"), path = require("path"); + const owner = () => JSON.parse(fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json"), "utf8")).owner; (async () => { const config = { port: 10100, providers: {}, defaultProvider: "openai" }; - const standalone = await injectCodexConfig(10100, config, { catalogPath: null }); - const client = await injectCodexConfig(10100, config, { catalogPath: null, journalOwner: { kind: "client", apiKeyId: "link-key" } }); - console.log(JSON.stringify({ standalone: standalone.success, client: client.success })); + await injectCodexConfig(10100, config, { catalogPath: null }); + await injectCodexConfig(10100, config, { catalogPath: null, journalOwner: { kind: "client", apiKeyId: "link-key" } }); + const beforeCommit = owner(); + const adopted = adoptJournalForClient("link-key"); + const again = adoptJournalForClient("link-key"); + console.log(JSON.stringify({ beforeCommit: beforeCommit.kind, adopted, again, pid: process.pid })); })(); `); expect(linked.status).toBe(0); - expect(JSON.parse(linked.stdout)).toEqual({ standalone: true, client: true }); - const journalPath = join(testDir, "opencodex-journal.json"); - const journal = JSON.parse(readFileSync(journalPath, "utf8")); + expect(JSON.parse(linked.stdout)).toMatchObject({ beforeCommit: "process", adopted: true, again: false }); + const journal = JSON.parse(readFileSync(join(testDir, "opencodex-journal.json"), "utf8")); expect(journal.owner).toEqual({ kind: "client", apiKeyId: "link-key" }); expect(Buffer.from(journal.originalConfig, "base64").toString("utf8")).toBe(original); const routed = readFileSync(join(testDir, "config.toml"), "utf8"); From 75be4bd0e4d7b54a725473b2b471f0648c441683 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:24:42 +0900 Subject: [PATCH 10/54] fix(cli): an inconclusive liveness probe after disconnect fails with guidance Review: findLiveProxy returns null both for no runtime and for a probe that could not decide, so disconnect could report success beside a still-serving client runtime. It now reads observeProxyLiveness and exits 1 on unknown; --orphaned-token skips the restart step. --- src/cli/disconnect-runtime.ts | 17 +++++++++++---- src/cli/dispatch.ts | 6 ++++-- tests/cli/disconnect-runtime.test.ts | 31 ++++++++++++++++++++-------- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index e6bc4013b85..97139c508e3 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -4,10 +4,14 @@ * was neither a working Child nor standalone, and a Home could not link it again. Disconnect * therefore restarts a live client runtime itself (the same restart as `ocx restart`, which a * disconnected client runtime hands to whichever owner holds it), and fails with the remaining - * step when that restart does not complete. + * step when that restart does not complete or when it cannot tell whether one is still running. */ export interface DisconnectRuntimeDeps { - findLive: () => Promise<{ pid: number | null; role?: string } | null>; + /** Liveness that separates a confirmed absence from a probe that could not decide. */ + observe: () => Promise< + | { kind: "live"; live: { pid: number | null; role?: string } } + | { kind: "absent" | "unknown"; live: null } + >; /** `ocx restart`; resolves true once a replacement is verified. */ restart: () => Promise; json: boolean; @@ -18,8 +22,13 @@ export interface DisconnectRuntimeDeps { export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps): Promise { const info = deps.info ?? (line => console.log(line)); const error = deps.error ?? (line => console.error(line)); - const live = await deps.findLive(); - if (live?.role !== "client") return 0; + const observed = await deps.observe(); + if (observed.kind === "unknown") { + error("Disconnected, but this machine could not confirm whether the client runtime stopped: a recorded OpenCodex process did not answer. Check with ocx status; if it still serves as a client, run on this machine: ocx restart"); + return 1; + } + if (observed.kind !== "live" || observed.live.role !== "client") return 0; + const live = observed.live; // A --json caller reads one document from stdout; restart progress goes to stderr. const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index e992dac9186..ec5e0a55073 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -424,10 +424,12 @@ const commandRunners: Record = { disconnect: async deps => { const { handleDisconnectCommand } = await import("./connect"); const code = await handleDisconnectCommand(deps.args.slice(1)); - if (code !== 0) return code; + // Token recovery refuses any live client runtime and leaves nothing to restart. + if (code !== 0 || deps.args.includes("--orphaned-token")) return code; const { restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); + const { observeProxyLiveness } = await import("../server/proxy-liveness"); return await restartRuntimeAfterDisconnect({ - findLive: () => deps.findLiveProxy(), + observe: () => observeProxyLiveness({ attempts: 2 }), restart: () => deps.handleProxyRestart(deps.handleRestartStartWhenStopped), json: deps.args.includes("--json"), }); diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 896ad8cac7f..9f097756934 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { restartRuntimeAfterDisconnect } from "../../src/cli/disconnect-runtime"; +import { restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps } from "../../src/cli/disconnect-runtime"; -function run(live: { pid: number | null; role?: string } | null, restartOk: boolean, json = false) { +type Observation = Awaited>; +const live = (role?: string): Observation => ({ kind: "live", live: { pid: 4321, ...(role ? { role } : {}) } }); + +function run(observation: Observation, restartOk: boolean, json = false) { const out: string[] = [], err: string[] = []; let restarts = 0; const done = restartRuntimeAfterDisconnect({ - findLive: async () => live, + observe: async () => observation, restart: async () => { restarts += 1; return restartOk; }, json, info: line => out.push(line), @@ -16,30 +19,40 @@ function run(live: { pid: number | null; role?: string } | null, restartOk: bool describe("ocx disconnect restarts the client runtime it left behind", () => { test("a live client runtime is restarted and the machine reported standalone", async () => { - const result = await run({ pid: 4321, role: "client" }, true); + const result = await run(live("client"), true); expect(result).toMatchObject({ code: 0, restarts: 1, err: [] }); expect(result.out.join("\n")).toContain("now runs standalone"); }); test("a restart that does not complete fails and names the remaining command", async () => { - const result = await run({ pid: 4321, role: "client" }, false); + const result = await run(live("client"), false); expect(result.code).toBe(1); expect(result.err.join("\n")).toContain("client runtime (pid 4321) still serves the old connection. Run on this machine: ocx restart"); }); - test("a standalone runtime or none is left alone", async () => { - for (const live of [{ pid: 4321 }, { pid: 4321, role: "standalone" }, null]) { - expect(await run(live, true)).toMatchObject({ code: 0, restarts: 0, out: [], err: [] }); + test("a standalone runtime, or confirmed absence, is left alone", async () => { + for (const observation of [live(), live("standalone"), { kind: "absent", live: null } as const]) { + expect(await run(observation, true)).toMatchObject({ code: 0, restarts: 0, out: [], err: [] }); } }); + // findLiveProxy answers null for "nothing there" and for "could not tell" alike; a disconnect that + // cannot rule out a still-serving client runtime must not report success to a caller over SSH. + test("an inconclusive liveness probe fails with what to check", async () => { + const result = await run({ kind: "unknown", live: null }, true); + expect(result).toMatchObject({ code: 1, restarts: 0 }); + expect(result.err.join("\n")).toContain("could not confirm whether the client runtime stopped"); + expect(result.err.join("\n")).toContain("ocx status"); + expect(result.err.join("\n")).toContain("ocx restart"); + }); + test("--json keeps stdout to the disconnect document", async () => { const logged: unknown[][] = []; const original = console.log; console.log = (...args: unknown[]) => { logged.push(args); }; try { const result = await restartRuntimeAfterDisconnect({ - findLive: async () => ({ pid: 1, role: "client" }), + observe: async () => live("client"), restart: async () => { console.log("restart progress"); return true; }, json: true, error: () => {}, From 53890827168881ae5c8a0ed058662f5bc5d848d1 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:32:53 +0900 Subject: [PATCH 11/54] fix(link): bind the journal handover to the connection guard and report a failed one Review round 2: adoption now never takes another client's journal and re-runs the caller's connection guard under the Codex config lock (connect: same owner, link and token; sync: the injection's own guard), so a stale sync cannot claim a later connection's journal. A sync whose handover failed exits 1 and says to sync again. --- src/cli/sync-command.ts | 4 +++ src/client/connect.ts | 22 ++++++++++++-- src/codex/journal.ts | 10 +++++-- tests/cli/cli-sync-expect-link.test.ts | 24 +++++++++++++++ tests/codex-integration/codex-journal.test.ts | 30 +++++++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/cli/sync-command.ts b/src/cli/sync-command.ts index 3c35ff4b4ce..ed417191b0d 100644 --- a/src/cli/sync-command.ts +++ b/src/cli/sync-command.ts @@ -90,6 +90,10 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { console.error("The Home's Codex settings were applied, but the apply record could not be written; the client runtime was not restarted. Run 'ocx sync' again."); process.exitCode = 1; } + if (result.journalHandoverFailed) { + console.error("Codex routing was applied, but the Codex journal could not be handed to this connection (the Codex config was busy or changed); a supervised restart could restore native routing. Run 'ocx sync' again."); + process.exitCode = 1; + } // OCX-6: a link Child's runtime must have started after the apply it serves under. if (link && result.appliedRecord) { const { applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart"); diff --git a/src/client/connect.ts b/src/client/connect.ts index 9ccc153fc25..1156a166734 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -787,7 +787,18 @@ async function connectClientUnlocked( // Only now does the routing belong to this connection: take over a journal another owner wrote // (src/codex/journal.ts adoptJournalForClient). A failure keeps the connection; sync retries. if (injectionCommitted) { - try { adoptJournalForClient(apiKeyId); } + try { + adoptJournalForClient(apiKeyId, () => { + // Under the Codex lock: still this committed connection (same owner and link) and its + // token, or write nothing. The stored record is normalized, so identity is compared. + assertNoClientDisconnectPending(); + const current = readClientConnectionState(); + if (current.kind !== "connected" || !sameClientConnectionOwner(current.value, connection) + || current.value.link?.linkId !== connection.link?.linkId) throw new Error("client_connection_changed"); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== connection.tokenFingerprint) throw new Error("client_token_changed"); + }); + } catch { console.warn("Warning: the Codex journal could not be handed to this connection; run 'ocx sync' so a supervised restart keeps Codex routing."); } } // Recorded only when the injection that applied it ran (not with --no-sync). The connection is @@ -855,6 +866,8 @@ export interface ConnectedSyncResult { appliedRecord?: HomeCodexSettingsRecord; /** The Home's settings were injected but their record could not be written (OCX-6). */ applyRecordFailed?: true; + /** Routing was injected but the Codex journal stayed with its previous owner; sync again. */ + journalHandoverFailed?: true; /** * The connection this sync ran under, read inside its lock and compare-and-swapped on every * write. Callers decide link-vs-hub wording and restart ownership from this, never from a read @@ -964,6 +977,7 @@ async function syncConnectedClientUnlocked( let homeSettings: HomeCodexSettingsSource | undefined; let appliedRecord: HomeCodexSettingsRecord | undefined; let applyRecordFailed = false; + let journalHandoverFailed = false; if (next.selectedClients.includes("codex")) { let config = loadConfig(); // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog. @@ -984,8 +998,9 @@ async function syncConnectedClientUnlocked( }); if (!result.success || result.status === "skipped") throw new Error(result.message); injected = true; - // A committed connection re-injected: own the journal too (retries a takeover connect missed). - try { adoptJournalForClient(next.apiKeyId); } catch { /* the journal keeps its owner; the next sync retries */ } + // A committed connection re-injected: own the journal too (retries a takeover connect missed), + // under the same connection guard as the injection, so a stale sync claims nothing. + try { adoptJournalForClient(next.apiKeyId, beforeClientWrite); } catch { journalHandoverFailed = true; } if (homeSettings && next.link) { const linkId = next.link.linkId; const settings = homeSettings; @@ -1014,6 +1029,7 @@ async function syncConnectedClientUnlocked( ...(homeSettings ? { homeSettings } : {}), ...(appliedRecord ? { appliedRecord } : {}), ...(applyRecordFailed ? { applyRecordFailed } : {}), + ...(journalHandoverFailed ? { journalHandoverFailed: true as const } : {}), connection: next, }; } diff --git a/src/codex/journal.ts b/src/codex/journal.ts index 3b9ac84b857..ca040934eb2 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -501,16 +501,20 @@ export interface ReconcileJournalOptions { * runtime starting mid-connect sees a live process owner and leaves it alone. Without the takeover * the first start after that process exited (a supervisor relaunch after `ocx connect --link` and * `ocx restart`) read it as a crashed session and restored native Codex under the new link. - * Returns whether it changed the owner. + * Only a process-written journal is taken: another client's journal belongs to a later connection + * (a stale sync must never claim it), and `beforeWrite` re-runs the caller's connection guard under + * the lock, so a connection that changed since its injection writes nothing. Returns whether it + * changed the owner. */ -export function adoptJournalForClient(apiKeyId: string): boolean { +export function adoptJournalForClient(apiKeyId: string, beforeWrite: () => void = () => {}): boolean { if (codexHomeIsAbsent(CODEX_HOME)) return false; const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, handle => { assertCodexHomeOwner(CODEX_HOME); beginCodexWriteSection(handle); const journal = readJournal(); if (!journal) return false; - if (journal.owner?.kind === "client" && journal.owner.apiKeyId === apiKeyId) return false; + if (journal.owner?.kind === "client") return false; + beforeWrite(); const adopted = JSON.stringify({ ...journal, owner: { kind: "client", apiKeyId } }); publishCodexArtifact(JOURNAL_PATH, handle, (path, hooks) => atomicWriteFile(path, adopted, undefined, hooks)); return true; diff --git a/tests/cli/cli-sync-expect-link.test.ts b/tests/cli/cli-sync-expect-link.test.ts index 26edbc95497..4925b7a676e 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -227,6 +227,30 @@ describe("ocx sync --expect-link / --expect-hub-key", () => { restart.mockRestore(); connected.mockRestore(); shim.mockRestore(); log.mockRestore(); error.mockRestore(); } }); + // Review round 2: a sync whose Codex journal handover failed must not report a clean success, + // because a later supervised restart would restore native routing again. + test("a sync whose journal handover failed exits 1 and says to sync again", async () => { + writeClient({ transport: "hub" }); + const current = readClientConnectionState(); + if (current.kind !== "connected") throw new Error("fixture must be connected"); + const connected = spyOn(connectApi, "syncConnectedClient").mockResolvedValue({ catalogWritten: false, cacheSynced: false, injected: true, stale: false, connection: current.value, journalHandoverFailed: true }); + const shim = spyOn(shimAutoRestore, "runCodexShimAutoRestoreAsync").mockResolvedValue(undefined); + const errors: string[] = []; + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation((...parts) => { errors.push(parts.map(String).join(" ")); }); + try { + process.exitCode = 0; + const args = ["sync"]; + const code = await dispatchCommand({ kind: "command", command: "sync", args }, { args } as unknown as CliDispatchDeps); + expect(code).toBe(1); + expect(errors.join("\n")).toContain("Codex journal could not be handed to this connection"); + expect(errors.join("\n")).toContain("Run 'ocx sync' again"); + } finally { + process.exitCode = 0; + connected.mockRestore(); shim.mockRestore(); log.mockRestore(); error.mockRestore(); + } + }); + async function runSync(args: string[]) { let discoveries = 0; const errors: string[] = []; diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 24512799a98..602d9cb68d7 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -430,6 +430,36 @@ describe("codex-journal", () => { expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(routed); }); + // Review round 2: a stale sync must never hand a later connection's journal to its own key, so + // adoption never takes another client's journal and re-runs the caller's connection guard + // under the Codex config lock. + test("adoption never takes another client's journal and honors the caller's guard", () => { + writeFileSync(join(testDir, "config.toml"), '# original config\nmodel_provider = "openai"\n', "utf8"); + const r = runScript(testDir, ` + const { injectCodexConfig } = require("./src/codex/inject"); + const { adoptJournalForClient } = require("./src/codex/journal"); + const fs = require("fs"), path = require("path"); + const owner = () => JSON.parse(fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json"), "utf8")).owner; + (async () => { + const config = { port: 10100, providers: {}, defaultProvider: "openai" }; + await injectCodexConfig(10100, config, { catalogPath: null, journalOwner: { kind: "client", apiKeyId: "newer-key" } }); + const foreign = adoptJournalForClient("stale-key"); + const afterForeign = owner(); + let refused = null; + await injectCodexConfig(10100, config, { catalogPath: null }); + fs.writeFileSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json"), JSON.stringify({ ...JSON.parse(fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json"), "utf8")), owner: { kind: "process", pid: process.pid } })); + try { adoptJournalForClient("link-key", () => { throw new Error("client_connection_changed"); }); } catch (error) { refused = error.message; } + console.log(JSON.stringify({ foreign, afterForeign, refused, afterGuard: owner() })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.foreign).toBe(false); + expect(out.afterForeign).toEqual({ kind: "client", apiKeyId: "newer-key" }); + expect(out.refused).toBe("client_connection_changed"); + expect(out.afterGuard.kind).toBe("process"); + }); + test("removeJournal refuses unknown journal evidence", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "{}", "utf8"); From 9a8d110639b250dda279ba336fc77b7199a540a6 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:38:46 +0900 Subject: [PATCH 12/54] fix(link): hold the mutation lock through the journal handover and report every failed one Review round 3: the connection guard and the journal write now run under the config mutation lock inside the Codex config lock, so no connection or runtime change lands between them. adoptJournalForClient returns adopted / owned / missing / foreign; sync treats missing and foreign as a failed handover, and the machine sync API answers 409 success:false for it. --- src/cli/sync-command.ts | 2 +- src/client/connect.ts | 6 +++- src/client/machine-api.ts | 4 +++ src/codex/journal.ts | 31 +++++++++------- tests/cli/cli-sync-expect-link.test.ts | 17 +++++++++ tests/codex-integration/codex-journal.test.ts | 35 +++++++++++++++++-- 6 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/cli/sync-command.ts b/src/cli/sync-command.ts index ed417191b0d..357dbc40587 100644 --- a/src/cli/sync-command.ts +++ b/src/cli/sync-command.ts @@ -91,7 +91,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { process.exitCode = 1; } if (result.journalHandoverFailed) { - console.error("Codex routing was applied, but the Codex journal could not be handed to this connection (the Codex config was busy or changed); a supervised restart could restore native routing. Run 'ocx sync' again."); + console.error("Codex routing was applied, but the Codex journal could not be handed to this connection (the Codex config was busy or changed, or the journal is missing or belongs to an earlier connection); a supervised restart could restore native routing. Run 'ocx sync' again; if this repeats, run ocx disconnect and connect again."); process.exitCode = 1; } // OCX-6: a link Child's runtime must have started after the apply it serves under. diff --git a/src/client/connect.ts b/src/client/connect.ts index 1156a166734..13bfce99539 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -1000,7 +1000,11 @@ async function syncConnectedClientUnlocked( injected = true; // A committed connection re-injected: own the journal too (retries a takeover connect missed), // under the same connection guard as the injection, so a stale sync claims nothing. - try { adoptJournalForClient(next.apiKeyId, beforeClientWrite); } catch { journalHandoverFailed = true; } + try { + const adoption = adoptJournalForClient(next.apiKeyId, beforeClientWrite); + // No journal or another client's means the routing is not this connection's to keep. + if (adoption === "missing" || adoption === "foreign") journalHandoverFailed = true; + } catch { journalHandoverFailed = true; } if (homeSettings && next.link) { const linkId = next.link.linkId; const settings = homeSettings; diff --git a/src/client/machine-api.ts b/src/client/machine-api.ts index e573050dbb4..40966557b6c 100644 --- a/src/client/machine-api.ts +++ b/src/client/machine-api.ts @@ -115,6 +115,10 @@ export async function handleMachineApi( input.restartCodex === undefined ? {} : { restartCodex: input.restartCodex }, ); deps.setHubReachability?.("online"); + // A sync whose journal handover failed is not complete: a supervised restart could undo it. + if (result.journalHandoverFailed) { + return Response.json({ success: false, ...result, error: "Codex routing was applied, but the Codex journal could not be handed to this connection; sync again, and if this repeats, disconnect and connect again." }, { status: 409 }); + } return Response.json({ success: true, ...result }); } catch (error) { const message = error instanceof Error ? error.message : "client sync failed"; diff --git a/src/codex/journal.ts b/src/codex/journal.ts index ca040934eb2..8927fa5db8d 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import type { AtomicWriteHooks } from "../config/atomic-write"; import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { atomicWriteFile } from "../config"; +import { atomicWriteFile, withConfigMutationLockSync } from "../config"; import { assertCodexHomeOwner, codexHomeIsAbsent, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner"; import { hasInjectedCodexRouting } from "./injected-marker"; import { configWriteLockFailureMessage, withConfigWriteLockHeld } from "./config-write-lock"; @@ -503,21 +503,28 @@ export interface ReconcileJournalOptions { * `ocx restart`) read it as a crashed session and restored native Codex under the new link. * Only a process-written journal is taken: another client's journal belongs to a later connection * (a stale sync must never claim it), and `beforeWrite` re-runs the caller's connection guard under - * the lock, so a connection that changed since its injection writes nothing. Returns whether it - * changed the owner. + * the Codex config lock and the config mutation lock together with the write, so a connection that + * changed since its injection writes nothing and none can change before the write lands. + * Outcomes: `adopted`, `owned` (already this client's), `missing` (no journal), `foreign` (another + * client's journal, left alone). Only the first two mean the routing will survive a relaunch. */ -export function adoptJournalForClient(apiKeyId: string, beforeWrite: () => void = () => {}): boolean { - if (codexHomeIsAbsent(CODEX_HOME)) return false; - const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, handle => { +export type JournalAdoption = "adopted" | "owned" | "missing" | "foreign"; + +export function adoptJournalForClient(apiKeyId: string, beforeWrite: () => void = () => {}): JournalAdoption { + if (codexHomeIsAbsent(CODEX_HOME)) return "missing"; + const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, (handle): JournalAdoption => { assertCodexHomeOwner(CODEX_HOME); beginCodexWriteSection(handle); const journal = readJournal(); - if (!journal) return false; - if (journal.owner?.kind === "client") return false; - beforeWrite(); - const adopted = JSON.stringify({ ...journal, owner: { kind: "client", apiKeyId } }); - publishCodexArtifact(JOURNAL_PATH, handle, (path, hooks) => atomicWriteFile(path, adopted, undefined, hooks)); - return true; + if (!journal) return "missing"; + if (journal.owner?.kind === "client") return journal.owner.apiKeyId === apiKeyId ? "owned" : "foreign"; + // Codex lock, then the mutation lock (the injection's order): guard and write as one step. + return withConfigMutationLockSync(() => { + beforeWrite(); + const adopted = JSON.stringify({ ...journal, owner: { kind: "client", apiKeyId } }); + publishCodexArtifact(JOURNAL_PATH, handle, (path, hooks) => atomicWriteFile(path, adopted, undefined, hooks)); + return "adopted" as const; + }); }); if (!locked.ok) throw new Error(locked.error === "unsafe" ? configWriteLockFailureMessage(locked) : "the Codex config lock is busy"); return locked.value; diff --git a/tests/cli/cli-sync-expect-link.test.ts b/tests/cli/cli-sync-expect-link.test.ts index 4925b7a676e..5aaa5767e32 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -526,3 +526,20 @@ test("the machine sync route does not return the saved connection", async () => ); expect(await response?.json()).toEqual({ success: true, catalogWritten: true, cacheSynced: true, injected: false, stale: false }); }); + +// Review round 3: the machine API must not call a sync complete when its journal handover failed. +test("the machine sync route reports a failed journal handover as not complete", async () => { + const connection = clientBlock({ transport: "hub" }) as unknown as OcxClientConnectionConfig; + const response = await handleMachineApi( + new Request("http://127.0.0.1/api/machine/sync", { method: "POST", body: "{}", headers: { "content-type": "application/json" } }), + new URL("http://127.0.0.1/api/machine/sync"), + connection, + { + sync: async () => ({ catalogWritten: true, cacheSynced: true, injected: true, stale: false, connection, journalHandoverFailed: true }), + disconnect: async () => { throw new Error("unused"); }, + scheduleStandaloneRecycle: () => {}, + }, + ); + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ success: false, journalHandoverFailed: true }); +}); diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 602d9cb68d7..4b264a5b626 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -414,7 +414,7 @@ describe("codex-journal", () => { })(); `); expect(linked.status).toBe(0); - expect(JSON.parse(linked.stdout)).toMatchObject({ beforeCommit: "process", adopted: true, again: false }); + expect(JSON.parse(linked.stdout)).toMatchObject({ beforeCommit: "process", adopted: "adopted", again: "owned" }); const journal = JSON.parse(readFileSync(join(testDir, "opencodex-journal.json"), "utf8")); expect(journal.owner).toEqual({ kind: "client", apiKeyId: "link-key" }); expect(Buffer.from(journal.originalConfig, "base64").toString("utf8")).toBe(original); @@ -454,12 +454,43 @@ describe("codex-journal", () => { `); expect(r.status).toBe(0); const out = JSON.parse(r.stdout); - expect(out.foreign).toBe(false); + expect(out.foreign).toBe("foreign"); expect(out.afterForeign).toEqual({ kind: "client", apiKeyId: "newer-key" }); expect(out.refused).toBe("client_connection_changed"); expect(out.afterGuard.kind).toBe("process"); }); + // Review round 3: the connection guard and the journal write share the config mutation lock + // (taken inside the Codex config lock), so no connection or runtime change can land between them; + // and a missing journal is reported as such, never as a completed handover. + test("adoption holds the config mutation lock through its guard and reports a missing journal", () => { + writeFileSync(join(testDir, "config.toml"), '# original config\nmodel_provider = "openai"\n', "utf8"); + const ocxHome = join(testDir, "ocx-home"); + require("node:fs").mkdirSync(ocxHome, { recursive: true }); + const r = spawnSync(process.execPath, ["--eval", ` + const { injectCodexConfig } = require("./src/codex/inject"); + const { adoptJournalForClient } = require("./src/codex/journal"); + const { prepareConfigMutationDatabasePathForWrite } = require("./src/config/mutation-lock"); + const { Database } = require("bun:sqlite"); + const fs = require("fs"), path = require("path"); + (async () => { + const missing = adoptJournalForClient("link-key"); + await injectCodexConfig(10100, { port: 10100, providers: {}, defaultProvider: "openai" }, { catalogPath: null }); + let competingWriter = "not-run"; + const mutationDb = prepareConfigMutationDatabasePathForWrite(); + const adopted = adoptJournalForClient("link-key", () => { + let db; + try { db = new Database(mutationDb); db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); competingWriter = "admitted"; db.exec("ROLLBACK"); } + catch (error) { competingWriter = /locked|busy/i.test(String(error)) ? "refused" : String(error); } + finally { db?.close(); } + }); + console.log(JSON.stringify({ missing, adopted, competingWriter })); + })(); + `], { cwd: repoRoot, env: { ...process.env, CODEX_HOME: testDir, OPENCODEX_HOME: ocxHome }, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000 }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout.trim())).toEqual({ missing: "missing", adopted: "adopted", competingWriter: "refused" }); + }); + test("removeJournal refuses unknown journal evidence", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "{}", "utf8"); From f349b2871bfb7a1af328fff366d572017846e204 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:42:56 +0900 Subject: [PATCH 13/54] fix(link): only another client's journal leaves routing at risk; connect says so Review round 4: external Codex providers leave no standard journal, so a missing journal is no longer a failed handover (sync no longer exits 1 for them on every run). Connect now warns when the journal belongs to an earlier connection, naming 'ocx sync --restart-codex' after the next restart, and the sync and machine-API messages give the same recovery. --- src/cli/sync-command.ts | 2 +- src/client/connect.ts | 8 +++- src/client/machine-api.ts | 2 +- src/codex/journal.ts | 11 +++++- tests/clients/client-link-teardown.test.ts | 38 +++++++++++++++++++ tests/codex-integration/codex-journal.test.ts | 19 ++++++++++ 6 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/cli/sync-command.ts b/src/cli/sync-command.ts index 357dbc40587..49d3b3b1617 100644 --- a/src/cli/sync-command.ts +++ b/src/cli/sync-command.ts @@ -91,7 +91,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { process.exitCode = 1; } if (result.journalHandoverFailed) { - console.error("Codex routing was applied, but the Codex journal could not be handed to this connection (the Codex config was busy or changed, or the journal is missing or belongs to an earlier connection); a supervised restart could restore native routing. Run 'ocx sync' again; if this repeats, run ocx disconnect and connect again."); + console.error("Codex routing was applied, but the Codex journal could not be handed to this connection (the Codex config was busy or changed, or the journal belongs to an earlier connection); the next OpenCodex restart could restore native routing. Run 'ocx sync' again; if it repeats, run 'ocx sync --restart-codex' after that restart."); process.exitCode = 1; } // OCX-6: a link Child's runtime must have started after the apply it serves under. diff --git a/src/client/connect.ts b/src/client/connect.ts index 13bfce99539..f4de1a05589 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -29,6 +29,7 @@ import { } from "../codex/inject"; import { adoptJournalForClient, + journalHandoverIncomplete, journalOwner, restoreJournalState, } from "../codex/journal"; @@ -788,7 +789,7 @@ async function connectClientUnlocked( // (src/codex/journal.ts adoptJournalForClient). A failure keeps the connection; sync retries. if (injectionCommitted) { try { - adoptJournalForClient(apiKeyId, () => { + const adoption = adoptJournalForClient(apiKeyId, () => { // Under the Codex lock: still this committed connection (same owner and link) and its // token, or write nothing. The stored record is normalized, so identity is compared. assertNoClientDisconnectPending(); @@ -798,6 +799,7 @@ async function connectClientUnlocked( const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== connection.tokenFingerprint) throw new Error("client_token_changed"); }); + if (journalHandoverIncomplete(adoption)) console.warn(FOREIGN_JOURNAL_WARNING); } catch { console.warn("Warning: the Codex journal could not be handed to this connection; run 'ocx sync' so a supervised restart keeps Codex routing."); } } @@ -1003,7 +1005,7 @@ async function syncConnectedClientUnlocked( try { const adoption = adoptJournalForClient(next.apiKeyId, beforeClientWrite); // No journal or another client's means the routing is not this connection's to keep. - if (adoption === "missing" || adoption === "foreign") journalHandoverFailed = true; + if (journalHandoverIncomplete(adoption)) journalHandoverFailed = true; } catch { journalHandoverFailed = true; } if (homeSettings && next.link) { const linkId = next.link.linkId; @@ -1075,6 +1077,8 @@ function disconnectAtLeast(receipt: DesktopDisconnectReceipt, phase: DesktopDisc return DISCONNECT_PHASES.indexOf(receipt.phase) >= DISCONNECT_PHASES.indexOf(phase); } +const FOREIGN_JOURNAL_WARNING = "Warning: the Codex journal belongs to an earlier connection, so the next OpenCodex restart restores native Codex routing. After that restart, run 'ocx sync --restart-codex' to point Codex at this connection again."; + function catalogIsRecordedPrior(connection: OcxClientConnectionConfig, snapshot: CatalogSnapshot): boolean { return snapshot.kind === "file" && !!connection.priorCatalog && snapshot.fingerprint === sha256(Buffer.from(connection.priorCatalog, "base64").toString("utf8")); diff --git a/src/client/machine-api.ts b/src/client/machine-api.ts index 40966557b6c..5020b3bf985 100644 --- a/src/client/machine-api.ts +++ b/src/client/machine-api.ts @@ -117,7 +117,7 @@ export async function handleMachineApi( deps.setHubReachability?.("online"); // A sync whose journal handover failed is not complete: a supervised restart could undo it. if (result.journalHandoverFailed) { - return Response.json({ success: false, ...result, error: "Codex routing was applied, but the Codex journal could not be handed to this connection; sync again, and if this repeats, disconnect and connect again." }, { status: 409 }); + return Response.json({ success: false, ...result, error: "Codex routing was applied, but the Codex journal could not be handed to this connection; sync again, and if it repeats, sync once more after the next OpenCodex restart." }, { status: 409 }); } return Response.json({ success: true, ...result }); } catch (error) { diff --git a/src/codex/journal.ts b/src/codex/journal.ts index 8927fa5db8d..355aa5ffeb4 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -506,10 +506,19 @@ export interface ReconcileJournalOptions { * the Codex config lock and the config mutation lock together with the write, so a connection that * changed since its injection writes nothing and none can change before the write lands. * Outcomes: `adopted`, `owned` (already this client's), `missing` (no journal), `foreign` (another - * client's journal, left alone). Only the first two mean the routing will survive a relaunch. + * client's journal, left alone; see {@link journalHandoverIncomplete}). */ export type JournalAdoption = "adopted" | "owned" | "missing" | "foreign"; +/** + * Whether routing is still at risk after a handover. No journal (external providers leave none) + * gives startup recovery nothing to restore; only another client's journal is restored over this + * connection on the next start. + */ +export function journalHandoverIncomplete(adoption: JournalAdoption): boolean { + return adoption === "foreign"; +} + export function adoptJournalForClient(apiKeyId: string, beforeWrite: () => void = () => {}): JournalAdoption { if (codexHomeIsAbsent(CODEX_HOME)) return "missing"; const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, (handle): JournalAdoption => { diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index 0886d83fe20..dfdf6ee47cd 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -297,3 +297,41 @@ test("connect --link over a standalone injection hands the Codex journal to the home.remove(); } }); + +// Review round 4: a connect over a journal an earlier connection owns must say so, not report a +// clean handover; the routing is restored on the next restart and a sync after it re-applies it. +test("connect over an earlier connection's Codex journal warns with the recovery step", async () => { + const home = createTempHome("ocx-client-link-foreign-journal-"); + const warnings: string[] = []; + const warn = spyOn(console, "warn").mockImplementation((...parts) => { warnings.push(parts.map(String).join(" ")); }); + try { + const config = getDefaultConfig(); + config.port = 10100; + saveConfig(config); + writeFileSync(CODEX_CONFIG_PATH, "model_provider = \"openai\"\n"); + const earlier = await injectCodexConfig(10100, { ...config, syncResumeHistory: false }, { catalogPath: null, journalOwner: { kind: "client", apiKeyId: "earlier-key" } }); + expect(earlier.success).toBe(true); + await connectClient({ + serverUrl: "http://127.0.0.1:34567", + managementUrl: "http://127.0.0.1:34567", + managementTransport: "direct", + transport: "link", + link: { tunnelPort: 34567, linkId }, + credential: { kind: "link", apiKeyId: "key-1", key }, + selectedClients: ["codex"], + }, { + fetchImpl: async input => String(input).endsWith("/readyz") + ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) + : Response.json({ models: [] }), + catalogCompatibility: { supportedEfforts: () => new Set() }, + lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + }); + expect(JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).owner).toEqual({ kind: "client", apiKeyId: "earlier-key" }); + expect(warnings.join("\n")).toContain("belongs to an earlier connection"); + expect(warnings.join("\n")).toContain("ocx sync --restart-codex"); + } finally { + warn.mockRestore(); + for (const created of [JOURNAL_PATH, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH]) rmSync(created, { force: true }); + home.remove(); + } +}); diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 4b264a5b626..1f980aa4b96 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -491,6 +491,25 @@ describe("codex-journal", () => { expect(JSON.parse(r.stdout.trim())).toEqual({ missing: "missing", adopted: "adopted", competingWriter: "refused" }); }); + // Review round 4: external Codex providers (approved or not) leave no standard journal, and no + // journal means startup recovery has nothing to restore. Only another client's journal leaves the + // routing at risk, so only that counts as an incomplete handover. + test("an external provider leaves no journal to hand over, and only a foreign journal is incomplete", () => { + writeFileSync(join(testDir, "config.toml"), 'model_provider = "acme"\n\n[model_providers.acme]\nname = "Acme"\nbase_url = "https://acme.example.test/v1"\n', "utf8"); + const r = runScript(testDir, ` + const { injectCodexConfig } = require("./src/codex/inject"); + const { adoptJournalForClient, journalHandoverIncomplete } = require("./src/codex/journal"); + (async () => { + const injected = await injectCodexConfig(10100, { port: 10100, providers: {}, defaultProvider: "openai" }, { catalogPath: null, journalOwner: { kind: "client", apiKeyId: "link-key" } }); + const adoption = adoptJournalForClient("link-key"); + const verdicts = ["adopted", "owned", "missing", "foreign"].map(journalHandoverIncomplete); + console.log(JSON.stringify({ success: injected.success, adoption, verdicts })); + })(); + `); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ success: true, adoption: "missing", verdicts: [false, false, false, true] }); + }); + test("removeJournal refuses unknown journal evidence", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "{}", "utf8"); From ea243901b6bf34654eb7ba082fcc43416a260449 Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:46:46 +0900 Subject: [PATCH 14/54] fix(link): an unreadable journal fails the handover instead of reading as missing Review round 5: adoption read the journal through readJournal(), which maps both a confirmed absence and an unreadable journal to null. It now inspects the journal directly: only a confirmed absence is "missing"; unknown throws, which sync and connect report as a failed handover. --- src/codex/journal.ts | 7 +++-- tests/codex-integration/codex-journal.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/codex/journal.ts b/src/codex/journal.ts index 355aa5ffeb4..70fd3dcb17e 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -524,8 +524,11 @@ export function adoptJournalForClient(apiKeyId: string, beforeWrite: () => void const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, (handle): JournalAdoption => { assertCodexHomeOwner(CODEX_HOME); beginCodexWriteSection(handle); - const journal = readJournal(); - if (!journal) return "missing"; + // Only a confirmed absence is safe; an unreadable journal may still hold a restorable owner. + const inspection = readCodexHomeJournal(JOURNAL_PATH); + if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); + if (inspection.kind === "missing") return "missing"; + const journal = inspection.journal as unknown as Journal; if (journal.owner?.kind === "client") return journal.owner.apiKeyId === apiKeyId ? "owned" : "foreign"; // Codex lock, then the mutation lock (the injection's order): guard and write as one step. return withConfigMutationLockSync(() => { diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 1f980aa4b96..96ae029916c 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -510,6 +510,34 @@ describe("codex-journal", () => { expect(JSON.parse(r.stdout)).toEqual({ success: true, adoption: "missing", verdicts: [false, false, false, true] }); }); + // Review round 5: only a confirmed absence is safe. A journal that passes the ownership check but + // cannot be read on the handover's own read must fail the handover, never read as "missing". + test("an unreadable journal on the handover read fails instead of reading as missing", () => { + writeFileSync(join(testDir, "config.toml"), '# original config\nmodel_provider = "openai"\n', "utf8"); + const r = runScript(testDir, ` + const { spyOn } = require("bun:test"); + const { injectCodexConfig } = require("./src/codex/inject"); + const owner = require("./src/codex/codex-home-owner"); + const { adoptJournalForClient } = require("./src/codex/journal"); + (async () => { + await injectCodexConfig(10100, { port: 10100, providers: {}, defaultProvider: "openai" }, { catalogPath: null }); + const real = owner.readCodexHomeJournal; + let calls = 0; + spyOn(owner, "readCodexHomeJournal").mockImplementation(path => (++calls === 2 ? { kind: "unknown" } : real(path))); + let outcome; + try { outcome = adoptJournalForClient("link-key"); } catch (error) { outcome = "threw"; } + const fs = require("fs"), path = require("path"); + const kind = JSON.parse(fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json"), "utf8")).owner.kind; + console.log(JSON.stringify({ outcome, calls, kind })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.calls).toBeGreaterThanOrEqual(2); + expect(out.outcome).toBe("threw"); + expect(out.kind).toBe("process"); + }); + test("removeJournal refuses unknown journal evidence", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "{}", "utf8"); From c2d1188803c1cf4a20aa5547c6908c076eddf41e Mon Sep 17 00:00:00 2001 From: cpa Date: Wed, 11 Nov 2026 17:49:00 +0900 Subject: [PATCH 15/54] fix(cli): skip the post-apply runtime restart after a failed journal handover Final review: ocx sync still restarted a link Child's runtime after reporting a failed handover; with an earlier connection's journal that restart restores the snapshot over the routing just applied. The restart is now skipped and the message says so. --- src/cli/sync-command.ts | 5 +++-- tests/cli/cli-sync-expect-link.test.ts | 30 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/cli/sync-command.ts b/src/cli/sync-command.ts index 49d3b3b1617..db7d86493c4 100644 --- a/src/cli/sync-command.ts +++ b/src/cli/sync-command.ts @@ -91,11 +91,12 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { process.exitCode = 1; } if (result.journalHandoverFailed) { - console.error("Codex routing was applied, but the Codex journal could not be handed to this connection (the Codex config was busy or changed, or the journal belongs to an earlier connection); the next OpenCodex restart could restore native routing. Run 'ocx sync' again; if it repeats, run 'ocx sync --restart-codex' after that restart."); + console.error("Codex routing was applied, but the Codex journal could not be handed to this connection (the Codex config was busy or changed, or the journal belongs to an earlier connection), so the client runtime was not restarted: the next OpenCodex restart could restore native routing. Run 'ocx sync' again; if it repeats, run 'ocx sync --restart-codex' after that restart."); process.exitCode = 1; } // OCX-6: a link Child's runtime must have started after the apply it serves under. - if (link && result.appliedRecord) { + // Not after a failed handover: that restart is the one that would undo this routing. + if (link && result.appliedRecord && !result.journalHandoverFailed) { const { applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart"); const { sameClientConnectionOwner } = await import("../client/state"); const applied = result.connection; diff --git a/tests/cli/cli-sync-expect-link.test.ts b/tests/cli/cli-sync-expect-link.test.ts index 5aaa5767e32..0fa879e0c33 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -251,6 +251,36 @@ describe("ocx sync --expect-link / --expect-hub-key", () => { } }); + // Final review: a failed handover must not be followed by the automatic post-apply restart, + // which on a Child with an earlier connection's journal would undo the routing just applied. + test("a failed journal handover skips the post-apply runtime restart", async () => { + writeClient({ transport: "link" }); + const current = readClientConnectionState(); + if (current.kind !== "connected") throw new Error("fixture must be connected"); + const appliedAt = "2026-10-21T01:00:00.000Z"; + const record = { linkId: LINK_ID, fetchedAt: appliedAt, appliedAt, document: { + version: 1 as const, home: { name: "fixture", version: "2.97.0" }, + settings: { fastMode: true, multiAgentMode: "v1" as const, webSearchSidecarEnabled: false, subagentDefaults: null }, + } }; + const restart = spyOn(applyRestart, "restartRuntimeAfterApply").mockResolvedValue({ kind: "restarted" }); + const connected = spyOn(connectApi, "syncConnectedClient").mockResolvedValue({ catalogWritten: false, cacheSynced: false, injected: true, stale: false, connection: current.value, appliedRecord: record, journalHandoverFailed: true }); + const shim = spyOn(shimAutoRestore, "runCodexShimAutoRestoreAsync").mockResolvedValue(undefined); + const errors: string[] = []; + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation((...parts) => { errors.push(parts.map(String).join(" ")); }); + try { + process.exitCode = 0; + const args = ["sync"]; + const code = await dispatchCommand({ kind: "command", command: "sync", args }, { args } as unknown as CliDispatchDeps); + expect(code).toBe(1); + expect(restart).not.toHaveBeenCalled(); + expect(errors.join("\n")).toContain("the client runtime was not restarted"); + } finally { + process.exitCode = 0; + restart.mockRestore(); connected.mockRestore(); shim.mockRestore(); log.mockRestore(); error.mockRestore(); + } + }); + async function runSync(args: string[]) { let discoveries = 0; const errors: string[] = []; From 9aae91318b6f7ad40ea8ae51e31978a51fc4f84d Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:24:23 +0900 Subject: [PATCH 16/54] test(link): run Codex-path journal scenarios in a child with an owned CODEX_HOME Final review P1: three teardown tests wrote and removed config.toml, the journal and the catalog through paths fixed when src/codex/paths loads. Run from outside the repository (no preload), those raw writes would land in the real ~/.codex. They now run in tests/fixtures/client-link-journal- runner.ts, whose CODEX_HOME and OPENCODEX_HOME point into the test's own temp home before any import; the parent touches no Codex path and removes only that owned home. --- tests/clients/client-link-teardown.test.ts | 154 +++++-------------- tests/fixtures/client-link-journal-runner.ts | 75 +++++++++ 2 files changed, 113 insertions(+), 116 deletions(-) create mode 100644 tests/fixtures/client-link-journal-runner.ts diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index dfdf6ee47cd..1ac5cd3478a 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -1,15 +1,13 @@ import { expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { JOURNAL_PATH } from "../../src/codex/journal"; -import { injectCodexConfig } from "../../src/codex/inject"; +import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { handleDisconnectCommand } from "../../src/cli/connect"; import { connectClient } from "../../src/client/connect"; import { teardownClientLink } from "../../src/client/link-teardown"; import { clientLinkStatePath, writeClientLinkState } from "../../src/client/link-state"; import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; -import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; import { createTempHome } from "../helpers/temp-home"; +import { fixturePath, repoPath } from "../helpers/repo-root"; const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; @@ -155,58 +153,6 @@ test("an unreadable sidecar skips the revoke but still reports the manual revoke expect(calls).toBe(0); }); -test("a catalog changed after connect names what was found and --keep-catalog", async () => { - const home = createTempHome("ocx-client-link-catalog-"); - try { - const config = getDefaultConfig(); - config.port = 10100; - saveConfig(config); - mkdirSync(home.codexHome, { recursive: true }); - writeFileSync(home.path(".codex", "config.toml"), "model_provider = \"openai\"\n"); - await connectClient({ - serverUrl: "http://127.0.0.1:34567", - managementUrl: "http://127.0.0.1:34567", - managementTransport: "direct", - transport: "link", - link: { tunnelPort: 34567, linkId }, - credential: { kind: "link", apiKeyId: "key-1", key }, - selectedClients: ["codex"], - noSync: true, - }, { - fetchImpl: async input => String(input).endsWith("/readyz") - ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) - : Response.json({ models: [] }), - catalogCompatibility: { supportedEfforts: () => new Set() }, - lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, - }); - // Something else rewrote the catalog after connect: neither ours nor the recorded prior one. - writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify({ models: [{ slug: "rewritten-elsewhere" }] })); - const logs = spyOn(console, "log").mockImplementation(() => {}); - const errors = spyOn(console, "error").mockImplementation(() => {}); - let exit = -1; - let output = ""; - try { - exit = await handleDisconnectCommand([], { - lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, - linkTeardownDeps: { - reapOrphanTunnel: async () => ({ tunnel: "absent" }), - runner: { run: async () => ({ code: 1, stdout: "", stderr: "" }) }, - knownHostsFile, - }, - }); - output = errors.mock.calls.flat().join("\n"); - } finally { - logs.mockRestore(); - errors.mockRestore(); - } - expect(exit).not.toBe(0); - expect(output).toContain("client_catalog_ownership_changed"); - expect(output).toContain("matches neither the catalog this connection wrote nor the one recorded before it connected"); - expect(output).toContain("ocx disconnect --keep-catalog"); - } finally { - home.remove(); - } -}); test("a desktop-remote folder with no state or receipt is named, with the way to clear it", async () => { const home = createTempHome("ocx-client-link-desktop-store-"); @@ -263,75 +209,51 @@ test("a desktop-remote folder with no state or receipt is named, with the way to } }); -test("connect --link over a standalone injection hands the Codex journal to the committed client", async () => { +/** + * Scenarios that write the Codex files directly. Those paths are fixed when src/codex/paths loads, + * so they run in tests/fixtures/client-link-journal-runner.ts, a child whose CODEX_HOME and + * OPENCODEX_HOME point into this test's own temp home before anything is imported; this process + * never touches a Codex path, and only that owned home is removed. + */ +async function runJournalScenario(scenario: "catalog-changed" | "standalone-handover" | "foreign-journal") { const home = createTempHome("ocx-client-link-journal-"); try { - const config = getDefaultConfig(); - config.port = 10100; - saveConfig(config); - // The Codex paths are fixed at import from the preload's sandboxed CODEX_HOME. - writeFileSync(CODEX_CONFIG_PATH, "model_provider = \"openai\"\n"); - // The standalone proxy's own injection, journaled to this (live) process. - const standalone = await injectCodexConfig(10100, { ...config, syncResumeHistory: false }, { catalogPath: null }); - expect(standalone.success).toBe(true); - const journalPath = JOURNAL_PATH; - expect(JSON.parse(readFileSync(journalPath, "utf8")).owner.kind).toBe("process"); - await connectClient({ - serverUrl: "http://127.0.0.1:34567", - managementUrl: "http://127.0.0.1:34567", - managementTransport: "direct", - transport: "link", - link: { tunnelPort: 34567, linkId }, - credential: { kind: "link", apiKeyId: "key-1", key }, - selectedClients: ["codex"], - }, { - fetchImpl: async input => String(input).endsWith("/readyz") - ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) - : Response.json({ models: [] }), - catalogCompatibility: { supportedEfforts: () => new Set() }, - lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, + const codex = home.path("codex"); + mkdirSync(codex, { recursive: true }); + const child = Bun.spawn([process.execPath, fixturePath("client-link-journal-runner.ts")], { + cwd: repoPath(), stdin: new Blob([scenario]), stdout: "pipe", stderr: "pipe", + env: { ...process.env, HOME: home.root, USERPROFILE: home.root, OPENCODEX_HOME: home.path("ocx"), CODEX_HOME: codex, OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, }); - expect(JSON.parse(readFileSync(journalPath, "utf8")).owner).toEqual({ kind: "client", apiKeyId: "key-1" }); + const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(exit, stderr).toBe(0); + return JSON.parse(stdout.trim().split("\n").at(-1)!) as { exit?: number; injected?: boolean; before?: unknown; after?: unknown; warnings: string[]; errors: string[] }; } finally { - for (const created of [JOURNAL_PATH, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH]) rmSync(created, { force: true }); home.remove(); } +} + +test("a catalog changed after connect names what was found and --keep-catalog", async () => { + const result = await runJournalScenario("catalog-changed"); + expect(result.exit).not.toBe(0); + const output = result.errors.join("\n"); + expect(output).toContain("client_catalog_ownership_changed"); + expect(output).toContain("matches neither the catalog this connection wrote nor the one recorded before it connected"); + expect(output).toContain("ocx disconnect --keep-catalog"); +}); + +test("connect --link over a standalone injection hands the Codex journal to the committed client", async () => { + const result = await runJournalScenario("standalone-handover"); + expect(result.injected).toBe(true); + expect((result.before as { kind: string }).kind).toBe("process"); + expect(result.after).toEqual({ kind: "client", apiKeyId: "key-1" }); }); // Review round 4: a connect over a journal an earlier connection owns must say so, not report a // clean handover; the routing is restored on the next restart and a sync after it re-applies it. test("connect over an earlier connection's Codex journal warns with the recovery step", async () => { - const home = createTempHome("ocx-client-link-foreign-journal-"); - const warnings: string[] = []; - const warn = spyOn(console, "warn").mockImplementation((...parts) => { warnings.push(parts.map(String).join(" ")); }); - try { - const config = getDefaultConfig(); - config.port = 10100; - saveConfig(config); - writeFileSync(CODEX_CONFIG_PATH, "model_provider = \"openai\"\n"); - const earlier = await injectCodexConfig(10100, { ...config, syncResumeHistory: false }, { catalogPath: null, journalOwner: { kind: "client", apiKeyId: "earlier-key" } }); - expect(earlier.success).toBe(true); - await connectClient({ - serverUrl: "http://127.0.0.1:34567", - managementUrl: "http://127.0.0.1:34567", - managementTransport: "direct", - transport: "link", - link: { tunnelPort: 34567, linkId }, - credential: { kind: "link", apiKeyId: "key-1", key }, - selectedClients: ["codex"], - }, { - fetchImpl: async input => String(input).endsWith("/readyz") - ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) - : Response.json({ models: [] }), - catalogCompatibility: { supportedEfforts: () => new Set() }, - lifecycleLockDeps: { lockPath: home.path("lifecycle.sqlite") }, - }); - expect(JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).owner).toEqual({ kind: "client", apiKeyId: "earlier-key" }); - expect(warnings.join("\n")).toContain("belongs to an earlier connection"); - expect(warnings.join("\n")).toContain("ocx sync --restart-codex"); - } finally { - warn.mockRestore(); - for (const created of [JOURNAL_PATH, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH]) rmSync(created, { force: true }); - home.remove(); - } + const result = await runJournalScenario("foreign-journal"); + expect(result.after).toEqual({ kind: "client", apiKeyId: "earlier-key" }); + expect(result.warnings.join("\n")).toContain("belongs to an earlier connection"); + expect(result.warnings.join("\n")).toContain("ocx sync --restart-codex"); }); + diff --git a/tests/fixtures/client-link-journal-runner.ts b/tests/fixtures/client-link-journal-runner.ts new file mode 100644 index 00000000000..762c35cd9a9 --- /dev/null +++ b/tests/fixtures/client-link-journal-runner.ts @@ -0,0 +1,75 @@ +/** + * Remote Link Codex-journal scenarios for tests/clients/client-link-teardown.test.ts. + * + * The Codex paths (config.toml, the journal, the catalog) are fixed when src/codex/paths loads, so + * a scenario that writes them directly runs here, in a child whose CODEX_HOME and OPENCODEX_HOME the + * parent test set to its own temp directory before anything was imported. Prints one JSON result. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const scenario = readFileSync(0, "utf8").trim() as "catalog-changed" | "standalone-handover" | "foreign-journal"; +const home = process.env.OPENCODEX_HOME!; +const { getDefaultConfig, saveConfig } = await import("../../src/config"); +const { connectClient } = await import("../../src/client/connect"); +const { injectCodexConfig } = await import("../../src/codex/inject"); +const { handleDisconnectCommand } = await import("../../src/cli/connect"); +const { CODEX_CONFIG_PATH, DEFAULT_CATALOG_PATH } = await import("../../src/codex/paths"); +const { JOURNAL_PATH } = await import("../../src/codex/journal"); + +const linkId = "lnk_0123456789abcdef"; +const key = `ocx_data_${"a".repeat(40)}`; +const lifecycleLockDeps = { lockPath: join(home, "lifecycle.sqlite") }; +const config = getDefaultConfig(); +config.port = 10100; +saveConfig(config); +writeFileSync(CODEX_CONFIG_PATH, "model_provider = \"openai\"\n"); +const owner = () => JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).owner as unknown; +const connect = (noSync: boolean) => connectClient({ + serverUrl: "http://127.0.0.1:34567", + managementUrl: "http://127.0.0.1:34567", + managementTransport: "direct", + transport: "link", + link: { tunnelPort: 34567, linkId }, + credential: { kind: "link", apiKeyId: "key-1", key }, + selectedClients: ["codex"], + ...(noSync ? { noSync: true } : {}), +}, { + fetchImpl: (async (input: RequestInfo | URL) => String(input).endsWith("/readyz") + ? Response.json({ status: "ready", protocol: 1, minimumClientProtocol: 1, managementUrl: "http://127.0.0.1:34567" }) + : Response.json({ models: [] })) as typeof fetch, + catalogCompatibility: { supportedEfforts: () => new Set() }, + lifecycleLockDeps, +}); +const warnings: string[] = [], errors: string[] = []; +console.warn = (...parts: unknown[]) => { warnings.push(parts.map(String).join(" ")); }; +const log = console.log; +console.log = () => {}; +const result: Record = {}; + +if (scenario === "catalog-changed") { + await connect(true); + // Something else rewrote the catalog after connect: neither ours nor the recorded prior one. + writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify({ models: [{ slug: "rewritten-elsewhere" }] })); + console.error = (...parts: unknown[]) => { errors.push(parts.map(String).join(" ")); }; + result.exit = await handleDisconnectCommand([], { + lifecycleLockDeps, + linkTeardownDeps: { + reapOrphanTunnel: async () => ({ tunnel: "absent" as const }), + runner: { run: async () => ({ code: 1, stdout: "", stderr: "" }) }, + knownHostsFile: join(home, "known_hosts"), + }, + }); +} else { + // The standalone proxy's own injection (journaled to this live process), or an earlier client's. + const earlier = await injectCodexConfig(10100, { ...config, syncResumeHistory: false }, { + catalogPath: null, + ...(scenario === "foreign-journal" ? { journalOwner: { kind: "client" as const, apiKeyId: "earlier-key" } } : {}), + }); + result.injected = earlier.success; + result.before = owner(); + await connect(false); + result.after = owner(); +} +console.log = log; +console.log(JSON.stringify({ ...result, warnings, errors })); From b0bbec1575ead3ee54b6690dbdcdb08db1dc40e6 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:24:23 +0900 Subject: [PATCH 17/54] fix(link): a restart-driven recycle exits for a service only when it is viable Final review: the recycle path called recycleStandalone, which exited 1 whenever OCX_SERVICE=1, so a process that outlived its service registration went down with nothing to relaunch it. The controller now passes its supervisor verdict (isServiceViable included) into the recycle; without a viable service it starts a verified replacement, and the replacement never inherits the marker. --- src/client/runtime-restart.ts | 9 ++++++--- src/client/runtime.ts | 16 ++++++++++++---- tests/clients/client-runtime-restart.test.ts | 13 +++++++++++++ tests/clients/client-runtime.test.ts | 17 +++++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index 5b48db6cc56..60bc11cf17e 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -45,8 +45,11 @@ export interface ClientRestartIo { supervisor(): Promise; /** The durable connection is no longer the one this runtime serves. */ connectionChanged?: () => boolean; - /** Hand off to what the durable state now calls for; exits the process for its owner to relaunch. */ - recycle?: () => Promise; + /** + * Hand off to what the durable state now calls for; exits the process for its owner to relaunch. + * Receives the same supervisor verdict as the refusal path (a service only when it is viable). + */ + recycle?: (supervisor: ClientRestartSupervisor | null) => Promise; /** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */ stop(drainMs: number): Promise; /** Start the replacement; resolves once it answers, rejects when none survives. */ @@ -111,7 +114,7 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar const recycle = io.recycle, changed = io.connectionChanged; // Let the 202 reach the CLI before the listener closes, then confirm the connection is // still ended: a connect that committed meanwhile keeps this runtime (the CLI reports kept). - flight = sleep(200).then(async () => { if (changed()) await recycle(); }).finally(() => { flight = null; }); + flight = sleep(200).then(async () => { if (changed()) await recycle(await io.supervisor()); }).finally(() => { flight = null; }); return { kind: "accepted", alreadyRestarting: false }; } const supervisor = await io.supervisor(); diff --git a/src/client/runtime.ts b/src/client/runtime.ts index b11332c715f..482de6cc189 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -83,6 +83,8 @@ export interface StandaloneRecycleIo { exitProcess?: (code: number) => void; configuredPort?: () => number | undefined; isDesktopSupervised?: () => boolean; + /** A service that will really relaunch this process; defaults to the OCX_SERVICE marker. */ + serviceSupervised?: () => boolean; } function cleanup(): void { @@ -110,8 +112,10 @@ export function standaloneRecycleEnv( disconnectedTokenFingerprint: string, ): NodeJS.ProcessEnv { const childEnv = { ...env }; - // A detached replacement is not the desktop app's child. + // A detached replacement is not the desktop app's child, nor a service's: a process that outlived + // its service registration must not hand the stale marker on. delete childEnv[DESKTOP_SUPERVISED_ENV]; + delete childEnv.OCX_SERVICE; const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim(); if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) { // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can @@ -164,7 +168,8 @@ export async function recycleStandalone( const port = activePort ?? (io.configuredPort ?? configuredTcpPort)(); const exit = io.exitProcess ?? ((code: number) => { process.exit(code); }); // Capture the one-use sibling handoff before this process removes its runtime record. - const replacementEnv = port && process.env.OCX_SERVICE !== "1" + const serviceSupervised = (io.serviceSupervised ?? (() => process.env.OCX_SERVICE === "1"))(); + const replacementEnv = port && !serviceSupervised ? withSiblingMarker(standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), issueSiblingHandoff) : null; try { @@ -201,7 +206,7 @@ export async function recycleStandalone( // // launchd's KeepAlive is failure-only too (`SuccessfulExit` false), so exit 1 is what // relaunches it as well; an exit 0 would leave the job stopped. - if (process.env.OCX_SERVICE === "1") { + if (serviceSupervised) { exit(1); return; } @@ -434,7 +439,10 @@ export async function startClientRuntime( stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, connectionChanged: () => clientConnectionEnded(state.value, readClientConnectionState()), - recycle: () => recycleStandalone(state.value.tokenFingerprint), + recycle: supervisor => recycleStandalone(state.value.tokenFingerprint, { + isDesktopSupervised: () => supervisor === "desktop", + serviceSupervised: () => supervisor === "service", + }), stop: stopServing, spawnReplacement: async deadlineAt => { const { replacementStartEnvironment } = await import("../server/management/system-restart"); diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 29f960f18db..e1afee96268 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -271,6 +271,19 @@ describe("client restart controller", () => { }); } + // Final review: the recycle hands off to the supervisor only when supervision is viable, so it + // receives the same verdict (isServiceViable included) the refusal path uses. + test("the recycle receives the supervisor verdict", async () => { + for (const supervisor of ["desktop", "service", null] as const) { + const seen: unknown[] = []; + const { io } = fakeIo({ supervisor: async () => supervisor, connectionChanged: () => true, recycle: async verdict => { seen.push(verdict); } }); + const controller = createClientRestartController(io); + await controller.request(); + await controller.settled(); + expect(seen).toEqual([supervisor]); + } + }); + test("an unchanged connection under a supervisor is still refused", async () => { const { io, calls } = fakeIo({ supervisor: async () => "service", connectionChanged: () => false, recycle: async () => { calls.push("recycle"); } }); expect(await createClientRestartController(io).request()).toEqual({ kind: "supervised", supervisor: "service" }); diff --git a/tests/clients/client-runtime.test.ts b/tests/clients/client-runtime.test.ts index 45da00f7d8a..8df41335eed 100644 --- a/tests/clients/client-runtime.test.ts +++ b/tests/clients/client-runtime.test.ts @@ -161,6 +161,23 @@ describe("standalone recycle", () => { } }); + // Final review: a process launched with OCX_SERVICE=1 can outlive its service registration. A + // restart-driven recycle told the service is not viable must start a verified replacement (without + // the stale service marker) instead of exiting 1 into nothing. + test("a recycle told the service is not viable starts a replacement despite OCX_SERVICE=1", async () => { + await withServiceMarker("1", async () => { + const calls: string[] = []; + await recycleStandalone(serviceApiTokenFingerprint("disconnected-hub-token"), { + configuredPort: () => 10456, + isDesktopSupervised: () => false, + serviceSupervised: () => false, + spawnReplacement: async request => { calls.push(`spawn:${request.env?.OCX_SERVICE ?? "unset"}`); }, + exitProcess: code => { calls.push(`exit:${code}`); }, + }); + expect(calls).toEqual(["spawn:unset", "exit:0"]); + }); + }); + test("a supervised client still exits 1 without spawning", async () => { await withServiceMarker("1", async () => { const calls: string[] = []; From 41e714cc9699c278eeffb81a4ecdb41a68828b14 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:24:23 +0900 Subject: [PATCH 18/54] fix(cli): disconnect restarts only a runtime its own disconnect left behind Final review: a connect landing between disconnect and its restart could have its new runtime restarted and the machine reported standalone. Disconnect now rechecks that the durable state is still disconnected right before the restart, and reports standalone only when the runtime seen after the restart is not a client. --- src/cli/disconnect-runtime.ts | 13 +++++++++++++ src/cli/dispatch.ts | 2 ++ tests/cli/disconnect-runtime.test.ts | 26 ++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index 97139c508e3..ddf3d04e23c 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -14,6 +14,8 @@ export interface DisconnectRuntimeDeps { >; /** `ocx restart`; resolves true once a replacement is verified. */ restart: () => Promise; + /** The durable state is still disconnected (no connect landed after this disconnect). */ + stillDisconnected: () => boolean; json: boolean; info?: (line: string) => void; error?: (line: string) => void; @@ -29,12 +31,23 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) } if (observed.kind !== "live" || observed.live.role !== "client") return 0; const live = observed.live; + // A connect that landed after this disconnect owns that runtime now; restarting it is not ours. + if (!deps.stillDisconnected()) { + (deps.json ? error : info)("The running client runtime was left alone: a new connection was made after this disconnect."); + return 0; + } // A --json caller reads one document from stdout; restart progress goes to stderr. const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; try { ok = await deps.restart(); } finally { console.log = originalLog; } if (ok) { + // Report standalone only for what is actually serving now. + const after = await deps.observe(); + if (after.kind === "live" && after.live.role === "client") { + error("The restart completed, but the runtime still answers as a client. Check with ocx status and ocx connect status on this machine."); + return 1; + } (deps.json ? error : info)("The client runtime was restarted; this machine now runs standalone OpenCodex."); return 0; } diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index ec5e0a55073..82fb41aca73 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -428,9 +428,11 @@ const commandRunners: Record = { if (code !== 0 || deps.args.includes("--orphaned-token")) return code; const { restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); const { observeProxyLiveness } = await import("../server/proxy-liveness"); + const { readClientConnectionState } = await import("../client/state"); return await restartRuntimeAfterDisconnect({ observe: () => observeProxyLiveness({ attempts: 2 }), restart: () => deps.handleProxyRestart(deps.handleRestartStartWhenStopped), + stillDisconnected: () => readClientConnectionState().kind === "disconnected", json: deps.args.includes("--json"), }); }, diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 9f097756934..ebd60f512e1 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -4,12 +4,13 @@ import { restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps } from "../.. type Observation = Awaited>; const live = (role?: string): Observation => ({ kind: "live", live: { pid: 4321, ...(role ? { role } : {}) } }); -function run(observation: Observation, restartOk: boolean, json = false) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { stillDisconnected?: () => boolean; after?: Observation } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; const done = restartRuntimeAfterDisconnect({ - observe: async () => observation, + observe: async () => (restarts > 0 ? extra.after ?? live("standalone") : observation), restart: async () => { restarts += 1; return restartOk; }, + stillDisconnected: extra.stillDisconnected ?? (() => true), json, info: line => out.push(line), error: line => err.push(line), @@ -46,14 +47,31 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(result.err.join("\n")).toContain("ocx restart"); }); + // Final review: a connect that lands between disconnect and its restart owns the runtime now; + // disconnect must leave it alone and never call the machine standalone. + test("a connection made after the disconnect leaves its runtime alone", async () => { + const result = await run(live("client"), true, false, { stillDisconnected: () => false }); + expect(result).toMatchObject({ code: 0, restarts: 0, err: [] }); + expect(result.out.join("\n")).toContain("a new connection was made after this disconnect"); + }); + + test("standalone is reported only when the runtime after the restart is not a client", async () => { + const result = await run(live("client"), true, false, { after: live("client") }); + expect(result.code).toBe(1); + expect(result.out).toEqual([]); + expect(result.err.join("\n")).toContain("still answers as a client"); + }); + test("--json keeps stdout to the disconnect document", async () => { const logged: unknown[][] = []; const original = console.log; console.log = (...args: unknown[]) => { logged.push(args); }; try { + let restarted = false; const result = await restartRuntimeAfterDisconnect({ - observe: async () => live("client"), - restart: async () => { console.log("restart progress"); return true; }, + observe: async () => live(restarted ? "standalone" : "client"), + restart: async () => { console.log("restart progress"); restarted = true; return true; }, + stillDisconnected: () => true, json: true, error: () => {}, }); From fe77ac07861b2b2278a49a7bdc234008e0ced3f3 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:37:05 +0900 Subject: [PATCH 19/54] test(link): sandbox the Codex catalog path in cli-sync-expect-link before it loads Coordinator review round 2 P1: the file's shared setup writes and removes DEFAULT_CATALOG_PATH with raw fs calls; without the repository preload that path resolved into the real Codex home. A first-import helper now points CODEX_HOME at an owned temp dir before src/codex/paths loads (and removes only that dir), and the file fails closed at load if the path is still real. --- tests/cli/cli-sync-expect-link.test.ts | 6 ++++++ tests/helpers/codex-home-sandbox.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tests/helpers/codex-home-sandbox.ts diff --git a/tests/cli/cli-sync-expect-link.test.ts b/tests/cli/cli-sync-expect-link.test.ts index 0fa879e0c33..596b1cca6c3 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -5,6 +5,9 @@ * providers. Every case is in-process and deterministic: config fixtures are written directly, the * Home catalog is a stubbed fetch, and both client locks live in the per-test directory. */ +// First: CODEX_HOME must be a temp directory before src/codex/paths resolves DEFAULT_CATALOG_PATH, +// which the shared setup below writes with raw fs calls (tests/helpers/codex-home-sandbox.ts). +import "../helpers/codex-home-sandbox"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -21,6 +24,7 @@ import * as shimAutoRestore from "../../src/cli/codex-shim-autorestore"; import { runCli } from "../../src/cli/root"; import * as codexSync from "../../src/codex/sync"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { assertNotRealCodexHomeUnderTest } from "../../src/lib/test-home-guard"; import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; import type { OcxClientConnectionConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -36,6 +40,8 @@ const TUNNEL = 23461; const SENTINEL_CATALOG = JSON.stringify({ models: [{ slug: "sentinel-local-model" }] }); const HOME_CATALOG = JSON.stringify({ models: [] }); const realSyncConnectedClient = connectApi.syncConnectedClient; +// Fail closed at load if the catalog path still resolves into the real Codex home. +assertNotRealCodexHomeUnderTest(dirname(DEFAULT_CATALOG_PATH)); let root = ""; let previousHome: string | undefined; diff --git a/tests/helpers/codex-home-sandbox.ts b/tests/helpers/codex-home-sandbox.ts new file mode 100644 index 00000000000..90c4db6437c --- /dev/null +++ b/tests/helpers/codex-home-sandbox.ts @@ -0,0 +1,19 @@ +/** + * Import FIRST in a test file that writes Codex paths directly (raw fs calls the write guard cannot + * see). Module evaluation follows import order, so this runs before src/codex/paths fixes its paths + * from CODEX_HOME. + * + * Under this repository's preload the sandbox already holds (OCX_TEST_HOME_GUARD=1) and this does + * nothing. A run started outside the repository loads no preload; there it points CODEX_HOME at a + * temp directory this module creates and owns, and removes only that directory when the process + * exits. Imports nothing from src/ on purpose. + */ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +if (process.env.OCX_TEST_HOME_GUARD !== "1") { + const owned = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-test-codex-home-"))); + process.env.CODEX_HOME = owned; + process.on("exit", () => { rmSync(owned, { recursive: true, force: true }); }); +} From cbe8dd253eae25e477f0c45b1acf49a516541c72 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:37:16 +0900 Subject: [PATCH 20/54] fix(cli): bind the disconnect restart to the runtime disconnect observed Coordinator review round 2: the check and the restart were separate discoveries, so a connect committing in between could have its new runtime restarted. The restart is now sent only to the observed pid (bindRestartToPid; the client listener admits it only for that pid), and a vanished runtime gets no generic start. Also (coordinator review round 2): disconnect claims standalone only for a live non-client runtime after the restart; an absent or inconclusive observation exits 1 naming ocx status / ocx restart. --- src/cli/disconnect-runtime.ts | 49 +++++++++++++++------ src/cli/dispatch.ts | 8 ++-- src/cli/index.ts | 6 ++- tests/cli/disconnect-runtime.test.ts | 66 ++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index ddf3d04e23c..1faff2b623a 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -2,18 +2,20 @@ * `ocx disconnect` ends the connection in the durable state; the runtime that served it keeps * running until something restarts it. Left alone it still answered as a client, so the machine * was neither a working Child nor standalone, and a Home could not link it again. Disconnect - * therefore restarts a live client runtime itself (the same restart as `ocx restart`, which a - * disconnected client runtime hands to whichever owner holds it), and fails with the remaining - * step when that restart does not complete or when it cannot tell whether one is still running. + * therefore restarts that runtime itself (the same restart as `ocx restart`, which a disconnected + * client runtime hands to whichever owner holds it), bound to the runtime it observed, and fails + * with the remaining step unless a live standalone runtime answers afterwards. */ +import type { ProxyRestartIo } from "./tray-proxy"; + export interface DisconnectRuntimeDeps { /** Liveness that separates a confirmed absence from a probe that could not decide. */ observe: () => Promise< | { kind: "live"; live: { pid: number | null; role?: string } } | { kind: "absent" | "unknown"; live: null } >; - /** `ocx restart`; resolves true once a replacement is verified. */ - restart: () => Promise; + /** `ocx restart` bound to this pid (see {@link bindRestartToPid}); resolves true when it completed. */ + restart: (expectedPid: number | null) => Promise; /** The durable state is still disconnected (no connect landed after this disconnect). */ stillDisconnected: () => boolean; json: boolean; @@ -21,6 +23,23 @@ export interface DisconnectRuntimeDeps { error?: (line: string) => void; } +/** + * Bind a restart to the runtime disconnect observed. The restart discovers its target again; a + * connect that committed in between can have started a new client runtime there, and restarting + * that one would interrupt a working connection. So the request is sent only to the same pid (the + * client listener admits it only for that pid too, via the expected-pid header), and a runtime that + * is gone gets no generic start: the caller then reports what it observes. + */ +export function bindRestartToPid(io: ProxyRestartIo, expectedPid: number | null): ProxyRestartIo { + return { + ...io, + startWhenStopped: () => ({ status: "skipped" }), + requestInPlaceRestart: previous => (expectedPid === null || previous.pid !== expectedPid + ? { accepted: false, uncertain: false, error: new Error("restart_target_changed") } + : io.requestInPlaceRestart(previous)), + }; +} + export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps): Promise { const info = deps.info ?? (line => console.log(line)); const error = deps.error ?? (line => console.error(line)); @@ -40,17 +59,19 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; - try { ok = await deps.restart(); } finally { console.log = originalLog; } - if (ok) { - // Report standalone only for what is actually serving now. - const after = await deps.observe(); - if (after.kind === "live" && after.live.role === "client") { - error("The restart completed, but the runtime still answers as a client. Check with ocx status and ocx connect status on this machine."); - return 1; - } + try { ok = await deps.restart(live.pid); } finally { console.log = originalLog; } + // Standalone is claimed only for what actually serves now: a live runtime that is not a client. + const after = await deps.observe(); + if (after.kind === "live" && after.live.role !== "client") { (deps.json ? error : info)("The client runtime was restarted; this machine now runs standalone OpenCodex."); return 0; } - error(`Disconnected, but the client runtime${live.pid ? ` (pid ${live.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`); + if (after.kind === "live") { + error(ok + ? "The restart completed, but the runtime still answers as a client. Check with ocx status and ocx connect status on this machine." + : `Disconnected, but the client runtime${live.pid ? ` (pid ${live.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`); + return 1; + } + error("Disconnected, but this machine could not confirm a standalone runtime afterwards (none answered, or the probe could not decide). Check with ocx status; start one with ocx restart (or the desktop app or ocx service start, whichever owns it)."); return 1; } diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 82fb41aca73..30e180596ab 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,4 +1,4 @@ -import type { ProxyRestartStartOutcome } from "./tray-proxy"; +import type { ProxyRestartIo, ProxyRestartStartOutcome } from "./tray-proxy"; import type { RestartSupervisor } from "./restart-failure"; /** * Registry-driven command dispatch (Phase 3 of the CLI deepening). @@ -60,7 +60,7 @@ export interface CliDispatchDeps { handleTrayProxyStart: (existingIsSuccess?: boolean) => Promise; handleTrayProxyRestart: () => Promise; handleRestartStartWhenStopped: (recoveringLiveRestart?: boolean) => Promise; - handleProxyRestart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void) => Promise; + handleProxyRestart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, bindIo?: (io: ProxyRestartIo) => ProxyRestartIo) => Promise; handleUninstall: () => Promise; handleStatus: () => Promise; handleRecoverHistory: () => Promise; @@ -426,12 +426,12 @@ const commandRunners: Record = { const code = await handleDisconnectCommand(deps.args.slice(1)); // Token recovery refuses any live client runtime and leaves nothing to restart. if (code !== 0 || deps.args.includes("--orphaned-token")) return code; - const { restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); + const { bindRestartToPid, restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); const { observeProxyLiveness } = await import("../server/proxy-liveness"); const { readClientConnectionState } = await import("../client/state"); return await restartRuntimeAfterDisconnect({ observe: () => observeProxyLiveness({ attempts: 2 }), - restart: () => deps.handleProxyRestart(deps.handleRestartStartWhenStopped), + restart: expectedPid => deps.handleProxyRestart(deps.handleRestartStartWhenStopped, undefined, io => bindRestartToPid(io, expectedPid)), stillDisconnected: () => readClientConnectionState().kind === "disconnected", json: deps.args.includes("--json"), }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 10e429277bc..d5a54d798fb 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -89,6 +89,7 @@ import { restartStartOutcome, waitForProxyReplacement, runTrayProxyStart, + type ProxyRestartIo, type ProxyRestartLive, type ProxyRestartStartOutcome, } from "./tray-proxy"; @@ -958,9 +959,11 @@ const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIM /** Reserve confirmation time within the shared restart deadline. */ async function handleProxyRestart( startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, + bindIo: (io: ProxyRestartIo) => ProxyRestartIo = io => io, ): Promise { const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS; - const result = await runDesktopAwareProxyRestart({ + // A caller may bind the restart to one target (ocx disconnect: src/cli/disconnect-runtime.ts). + const restartIo = bindIo({ findLive: () => discoverStableProxyForRestart({ findLive: () => findLiveProxy({ deadlineAt, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= deadlineAt, @@ -978,6 +981,7 @@ async function handleProxyRestart( findLive: () => findLiveProxy({ deadlineAt: end, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= end, })), }); + const result = await runDesktopAwareProxyRestart({ ...restartIo }); if (!result.ok && result.phase === "request" && result.error instanceof UpdateRestartRequired) { const candidate = result.error.candidate(); console.log(`🔄 Running proxy ${candidate.target.version} is older than this CLI (${candidate.cliVersion}); restarting it from the current installation...`); diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index ebd60f512e1..d91fe21210d 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps } from "../../src/cli/disconnect-runtime"; +import { bindRestartToPid, restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps } from "../../src/cli/disconnect-runtime"; +import { runProxyRestart, type ProxyRestartIo } from "../../src/cli/tray-proxy"; type Observation = Awaited>; const live = (role?: string): Observation => ({ kind: "live", live: { pid: 4321, ...(role ? { role } : {}) } }); @@ -7,15 +8,16 @@ const live = (role?: string): Observation => ({ kind: "live", live: { pid: 4321, function run(observation: Observation, restartOk: boolean, json = false, extra: { stillDisconnected?: () => boolean; after?: Observation } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; + const pids: Array = []; const done = restartRuntimeAfterDisconnect({ observe: async () => (restarts > 0 ? extra.after ?? live("standalone") : observation), - restart: async () => { restarts += 1; return restartOk; }, + restart: async pid => { restarts += 1; pids.push(pid); return restartOk; }, stillDisconnected: extra.stillDisconnected ?? (() => true), json, info: line => out.push(line), error: line => err.push(line), }); - return done.then(code => ({ code, out, err, restarts })); + return done.then(code => ({ code, out, err, restarts, pids })); } describe("ocx disconnect restarts the client runtime it left behind", () => { @@ -26,7 +28,7 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { }); test("a restart that does not complete fails and names the remaining command", async () => { - const result = await run(live("client"), false); + const result = await run(live("client"), false, false, { after: live("client") }); expect(result.code).toBe(1); expect(result.err.join("\n")).toContain("client runtime (pid 4321) still serves the old connection. Run on this machine: ocx restart"); }); @@ -62,6 +64,23 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(result.err.join("\n")).toContain("still answers as a client"); }); + // Coordinator review round 2: the restart is bound to the runtime disconnect observed. + test("the restart is bound to the observed runtime's pid", async () => { + expect((await run(live("client"), true)).pids).toEqual([4321]); + }); + + // ...and standalone is claimed only for a live, non-client runtime after it. + test.each([ + ["absent", { kind: "absent", live: null } as const], + ["inconclusive", { kind: "unknown", live: null } as const], + ])("an %s observation after the restart fails with what to run", async (_name, after) => { + const result = await run(live("client"), true, false, { after }); + expect(result.code).toBe(1); + expect(result.out).toEqual([]); + expect(result.err.join("\n")).toContain("could not confirm a standalone runtime"); + expect(result.err.join("\n")).toContain("ocx status"); + }); + test("--json keeps stdout to the disconnect document", async () => { const logged: unknown[][] = []; const original = console.log; @@ -80,3 +99,42 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(logged).toEqual([]); }); }); + +// Coordinator review round 2: a connect that commits after disconnect's check can start a new +// client runtime before the restart discovers its target. The hook at that gap is a findLive that +// answers a different pid: the request must never be sent, and nothing may be started generically. +describe("a disconnect restart bound to the observed runtime", () => { + function io(found: { status: "live"; pid: number } | { status: "absent" }) { + const calls: string[] = []; + const base: ProxyRestartIo = { + findLive: async () => found.status === "live" + ? { status: "live", live: { pid: found.pid, port: 10100, source: "runtime" } } + : { status: "absent" }, + startWhenStopped: async () => { calls.push("generic-start"); return { status: "started" }; }, + requestInPlaceRestart: async previous => { calls.push(`request:${previous.pid}`); return { accepted: true }; }, + waitForReplacement: async () => ({ pid: 9999, port: 10100, source: "runtime" }), + waitBetweenAttempts: async () => {}, + }; + return { calls, base }; + } + + test("a different runtime at the restart's own discovery is refused before any request", async () => { + const { calls, base } = io({ status: "live", pid: 5555 }); + const result = await runProxyRestart(bindRestartToPid(base, 4321)); + expect(calls).toEqual([]); + expect(result).toMatchObject({ ok: false, phase: "request" }); + expect(String((result as { error?: unknown }).error)).toContain("restart_target_changed"); + }); + + test("the observed runtime is restarted", async () => { + const { calls, base } = io({ status: "live", pid: 4321 }); + expect((await runProxyRestart(bindRestartToPid(base, 4321))).ok).toBe(true); + expect(calls).toEqual(["request:4321"]); + }); + + test("a runtime that vanished gets no generic start", async () => { + const { calls, base } = io({ status: "absent" }); + await runProxyRestart(bindRestartToPid(base, 4321)); + expect(calls).toEqual([]); + }); +}); From b47df93def1ffb1dd27f19011ca8ba04b3821b7d Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:37:16 +0900 Subject: [PATCH 21/54] fix(link): recheck the connection after the supervisor lookup, right before the recycle Coordinator review round 2: the recheck preceded the asynchronous supervisor lookup. A deterministic test holding that lookup open while the connection becomes current showed the gap; the recheck now runs after it. --- src/client/runtime-restart.ts | 6 +++++- tests/clients/client-runtime-restart.test.ts | 22 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index 60bc11cf17e..6aeaab119aa 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -114,7 +114,11 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar const recycle = io.recycle, changed = io.connectionChanged; // Let the 202 reach the CLI before the listener closes, then confirm the connection is // still ended: a connect that committed meanwhile keeps this runtime (the CLI reports kept). - flight = sleep(200).then(async () => { if (changed()) await recycle(await io.supervisor()); }).finally(() => { flight = null; }); + // The recheck is the last step before the recycle, after the (asynchronous) supervisor lookup. + flight = sleep(200).then(async () => { + const supervisor = await io.supervisor(); + if (changed()) await recycle(supervisor); + }).finally(() => { flight = null; }); return { kind: "accepted", alreadyRestarting: false }; } const supervisor = await io.supervisor(); diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index e1afee96268..a917ddd74c7 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -273,6 +273,28 @@ describe("client restart controller", () => { // Final review: the recycle hands off to the supervisor only when supervision is viable, so it // receives the same verdict (isServiceViable included) the refusal path uses. + // Coordinator review round 2: the connection recheck must be the last step before the recycle, + // after the asynchronous supervisor lookup. The hook holds that lookup open while the connection + // becomes current again; the recycle must not run. + test("a connection current again during the supervisor lookup keeps this runtime", async () => { + let ended = true; + let releaseLookup!: (value: "desktop") => void; + let lookups = 0; + const recycled: unknown[] = []; + const { io } = fakeIo({ + connectionChanged: () => ended, + supervisor: () => { lookups += 1; return new Promise(resolve => { releaseLookup = resolve; }); }, + recycle: async verdict => { recycled.push(verdict); }, + }); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + while (lookups === 0) await Promise.resolve(); + ended = false; + releaseLookup("desktop"); + await controller.settled(); + expect(recycled).toEqual([]); + }); + test("the recycle receives the supervisor verdict", async () => { for (const supervisor of ["desktop", "service", null] as const) { const seen: unknown[] = []; From 3d26a59a19efd6ac5b2fed9a6dade7356b58e764 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:53:51 +0900 Subject: [PATCH 22/54] test(link): prove every test Codex home is owned by path, not by an inherited flag Coordinator review round 3 P1: the sandbox helper skipped itself when OCX_TEST_HOME_GUARD=1, which can be inherited without the preload, so a custom real CODEX_HOME got the sentinel. The helper now always owns a temp CODEX_HOME when src/codex/paths is not loaded yet, and test files refuse to load unless the resolved home is that dir or the .codex of a test root whose owner marker names this process. Applied to cli-sync-expect-link and client-link-teardown; the journal runner proves its paths inside the parent's temp home; codex-journal children and client-runtime recycle tests pin OPENCODEX_HOME to owned temp dirs. New tests cover an inherited flag and a custom CODEX_HOME without the preload. --- scripts/test-layout/layout.json | 2 +- tests/ci-workflows/codex-home-sandbox.test.ts | 80 +++++++++++++++++++ tests/cli/cli-sync-expect-link.test.ts | 7 +- tests/clients/client-link-teardown.test.ts | 6 ++ tests/clients/client-runtime.test.ts | 17 +++- tests/codex-integration/codex-journal.test.ts | 6 +- tests/fixtures/client-link-journal-runner.ts | 10 +++ tests/fixtures/test-layout-expected.json | 2 +- tests/helpers/codex-home-sandbox.ts | 64 ++++++++++++--- 9 files changed, 173 insertions(+), 21 deletions(-) create mode 100644 tests/ci-workflows/codex-home-sandbox.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9642bce4793..7a052a8fe67 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1820,7 +1820,7 @@ "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "codex-home-sandbox.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", "test-run-lock-generation.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", diff --git a/tests/ci-workflows/codex-home-sandbox.test.ts b/tests/ci-workflows/codex-home-sandbox.test.ts new file mode 100644 index 00000000000..0c089e12a37 --- /dev/null +++ b/tests/ci-workflows/codex-home-sandbox.test.ts @@ -0,0 +1,80 @@ +/** + * tests/helpers/codex-home-sandbox.ts must resolve a test file's Codex paths into a temp directory + * this process owns, whatever the environment says, and refuse otherwise. Each no-preload case runs + * a child from a cwd with no bunfig.toml (so tests/preload.ts never loads), with CODEX_HOME pointing + * at a temp directory posing as a user's custom real Codex home; nothing here reaches a real home. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { assertCodexHomeOwned, createdCodexHomeForTests } from "../helpers/codex-home-sandbox"; +import { CODEX_HOME } from "../../src/codex/paths"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { repoPath } from "../helpers/repo-root"; + +const handles: TempHome[] = []; +afterEach(() => { for (const handle of handles.splice(0)) handle.remove(); }); + +function runUnpreloaded(script: string, env: Record) { + const handle = createTempHome("ocx-codex-sandbox-child-"); + handles.push(handle); + const customHome = handle.path("custom-codex-home"); + mkdirSync(customHome, { recursive: true }); + writeFileSync(join(customHome, "opencodex-catalog.json"), "{\"sentinel\":true}"); + const result = Bun.spawnSync([process.execPath, "--eval", script], { + cwd: handle.root, + env: { PATH: process.env.PATH ?? "", HOME: handle.root, CODEX_HOME: customHome, ...env }, + stdout: "pipe", stderr: "pipe", + }); + return { customHome, status: result.exitCode, stdout: result.stdout.toString().trim(), stderr: result.stderr.toString() }; +} + +const helperUrl = pathToFileURL(repoPath("tests", "helpers", "codex-home-sandbox.ts")).href; +const pathsUrl = pathToFileURL(repoPath("src", "codex", "paths.ts")).href; +const helperThenPaths = ` + const helper = await import(${JSON.stringify(helperUrl)}); + const paths = await import(${JSON.stringify(pathsUrl)}); + let owned = "yes"; + try { helper.assertCodexHomeOwned(paths.CODEX_HOME); } catch { owned = "no"; } + console.log(JSON.stringify({ catalog: paths.DEFAULT_CATALOG_PATH, created: helper.createdCodexHomeForTests(), owned })); +`; + +describe("codex-home-sandbox without the preload", () => { + test.each([ + ["an inherited guard flag", { OCX_TEST_HOME_GUARD: "1" }], + ["a custom CODEX_HOME alone", {}], + ] as const)("%s still resolves into a directory this process created", (_name, env) => { + const run = runUnpreloaded(helperThenPaths, env); + expect(run.status, run.stderr).toBe(0); + const out = JSON.parse(run.stdout) as { catalog: string; created: string | null; owned: string }; + expect(out.created).not.toBeNull(); + expect(out.catalog).toBe(join(out.created!, "opencodex-catalog.json")); + expect(out.catalog.startsWith(run.customHome)).toBe(false); + expect(out.owned).toBe("yes"); + // The custom home is untouched. + expect(readdirSync(run.customHome)).toEqual(["opencodex-catalog.json"]); + }); + + test("paths loaded before the helper are refused", () => { + const run = runUnpreloaded(` + const paths = await import(${JSON.stringify(pathsUrl)}); + const helper = await import(${JSON.stringify(helperUrl)}); + let refused = false; + try { helper.assertCodexHomeOwned(paths.CODEX_HOME); } catch { refused = true; } + console.log(JSON.stringify({ refused, home: paths.CODEX_HOME })); + `, { OCX_TEST_HOME_GUARD: "1" }); + expect(run.status, run.stderr).toBe(0); + const out = JSON.parse(run.stdout) as { refused: boolean; home: string }; + expect(out.refused).toBe(true); + expect(out.home).toBe(run.customHome); + }); +}); + +describe("codex-home-sandbox under the preload", () => { + test("the preload's own test root is proven by its owner marker", () => { + // The preload loaded src/codex/paths first, so the helper created nothing here. + expect(createdCodexHomeForTests()).toBeNull(); + expect(() => assertCodexHomeOwned(CODEX_HOME)).not.toThrow(); + }); +}); diff --git a/tests/cli/cli-sync-expect-link.test.ts b/tests/cli/cli-sync-expect-link.test.ts index 596b1cca6c3..d5d5213e7b4 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -7,7 +7,7 @@ */ // First: CODEX_HOME must be a temp directory before src/codex/paths resolves DEFAULT_CATALOG_PATH, // which the shared setup below writes with raw fs calls (tests/helpers/codex-home-sandbox.ts). -import "../helpers/codex-home-sandbox"; +import { assertCodexHomeOwned } from "../helpers/codex-home-sandbox"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -24,7 +24,6 @@ import * as shimAutoRestore from "../../src/cli/codex-shim-autorestore"; import { runCli } from "../../src/cli/root"; import * as codexSync from "../../src/codex/sync"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; -import { assertNotRealCodexHomeUnderTest } from "../../src/lib/test-home-guard"; import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; import type { OcxClientConnectionConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -40,8 +39,8 @@ const TUNNEL = 23461; const SENTINEL_CATALOG = JSON.stringify({ models: [{ slug: "sentinel-local-model" }] }); const HOME_CATALOG = JSON.stringify({ models: [] }); const realSyncConnectedClient = connectApi.syncConnectedClient; -// Fail closed at load if the catalog path still resolves into the real Codex home. -assertNotRealCodexHomeUnderTest(dirname(DEFAULT_CATALOG_PATH)); +// Refuse to load unless the catalog resolves into a Codex home this process owns (proven by path). +assertCodexHomeOwned(dirname(DEFAULT_CATALOG_PATH)); let root = ""; let previousHome: string | undefined; diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index 1ac5cd3478a..9841ad0b7f9 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -1,4 +1,7 @@ +// First: in-process connectClient writes the catalog through src/codex/paths, fixed at import. +import { assertCodexHomeOwned } from "../helpers/codex-home-sandbox"; import { expect, spyOn, test } from "bun:test"; +import { CODEX_HOME } from "../../src/codex/paths"; import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { handleDisconnectCommand } from "../../src/cli/connect"; @@ -9,6 +12,8 @@ import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; import { createTempHome } from "../helpers/temp-home"; import { fixturePath, repoPath } from "../helpers/repo-root"; +assertCodexHomeOwned(CODEX_HOME); + const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; const knownHostsFile = "/tmp/opencodex-link-known-hosts"; @@ -220,6 +225,7 @@ async function runJournalScenario(scenario: "catalog-changed" | "standalone-hand try { const codex = home.path("codex"); mkdirSync(codex, { recursive: true }); + mkdirSync(home.path("ocx"), { recursive: true }); const child = Bun.spawn([process.execPath, fixturePath("client-link-journal-runner.ts")], { cwd: repoPath(), stdin: new Blob([scenario]), stdout: "pipe", stderr: "pipe", env: { ...process.env, HOME: home.root, USERPROFILE: home.root, OPENCODEX_HOME: home.path("ocx"), CODEX_HOME: codex, OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, diff --git a/tests/clients/client-runtime.test.ts b/tests/clients/client-runtime.test.ts index 8df41335eed..f28d951f618 100644 --- a/tests/clients/client-runtime.test.ts +++ b/tests/clients/client-runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -82,6 +82,21 @@ describe("standalone recycle environment", () => { }); describe("standalone recycle", () => { + // recycleStandalone removes this process's pid and runtime records and flushes link diagnostics + // in the OpenCodex home; every case runs against a temp home this process created. + let recycleHome = ""; + let previousRecycleHome: string | undefined; + beforeEach(() => { + previousRecycleHome = process.env.OPENCODEX_HOME; + recycleHome = mkdtempSync(join(tmpdir(), "ocx-recycle-home-")); + process.env.OPENCODEX_HOME = recycleHome; + }); + afterEach(() => { + if (previousRecycleHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousRecycleHome; + removeTreeWithRetry(recycleHome); + }); + async function withServiceMarker(value: string | undefined, run: () => Promise): Promise { const previous = process.env.OCX_SERVICE; if (value === undefined) delete process.env.OCX_SERVICE; diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 96ae029916c..56ebd5c0fbe 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, lstatSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, lstatSync, symlinkSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -17,9 +17,11 @@ const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta setDefaultTimeout(SPAWN_BUDGET_MS); function runScript(codexHome: string, script: string): { stdout: string; stderr: string; status: number } { + mkdirSync(join(codexHome, "ocx-home"), { recursive: true }); const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, - env: { ...process.env, CODEX_HOME: codexHome }, + // OPENCODEX_HOME too: adoption and injection take the config mutation lock in that home. + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: join(codexHome, "ocx-home") }, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, }); diff --git a/tests/fixtures/client-link-journal-runner.ts b/tests/fixtures/client-link-journal-runner.ts index 762c35cd9a9..065588172ed 100644 --- a/tests/fixtures/client-link-journal-runner.ts +++ b/tests/fixtures/client-link-journal-runner.ts @@ -17,6 +17,16 @@ const { handleDisconnectCommand } = await import("../../src/cli/connect"); const { CODEX_CONFIG_PATH, DEFAULT_CATALOG_PATH } = await import("../../src/codex/paths"); const { JOURNAL_PATH } = await import("../../src/codex/journal"); +// Prove by path that every Codex and OpenCodex path this child writes is inside the temp home the +// parent created and passed as HOME; refuse before the first write otherwise. +const { realpathSync } = await import("node:fs"); +const { dirname: parentOf, relative, isAbsolute } = await import("node:path"); +const ownedRoot = realpathSync.native(process.env.HOME!); +const inside = (path: string) => { const rel = relative(ownedRoot, realpathSync.native(path)); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); }; +for (const path of [parentOf(CODEX_CONFIG_PATH), parentOf(DEFAULT_CATALOG_PATH), parentOf(JOURNAL_PATH), home]) { + if (!inside(path)) throw new Error(`refusing to run: ${path} is not inside the owned test home ${ownedRoot}`); +} + const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; const lifecycleLockDeps = { lockPath: join(home, "lifecycle.sqlite") }; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8e9314c92d6..3a2d77290f3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1453,7 +1453,7 @@ "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "codex-home-sandbox.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", "test-run-lock-generation.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", diff --git a/tests/helpers/codex-home-sandbox.ts b/tests/helpers/codex-home-sandbox.ts index 90c4db6437c..9f05e8af4ef 100644 --- a/tests/helpers/codex-home-sandbox.ts +++ b/tests/helpers/codex-home-sandbox.ts @@ -1,19 +1,59 @@ /** - * Import FIRST in a test file that writes Codex paths directly (raw fs calls the write guard cannot - * see). Module evaluation follows import order, so this runs before src/codex/paths fixes its paths - * from CODEX_HOME. + * Import FIRST in a test file whose code writes Codex paths (raw fs calls, or source that writes + * the import-time paths of src/codex/paths). Module evaluation follows import order. * - * Under this repository's preload the sandbox already holds (OCX_TEST_HOME_GUARD=1) and this does - * nothing. A run started outside the repository loads no preload; there it points CODEX_HOME at a - * temp directory this module creates and owns, and removes only that directory when the process - * exits. Imports nothing from src/ on purpose. + * Unconditional, and decided by paths, never by an inherited flag: + * - If src/codex/paths has not been evaluated yet (no preload ran), CODEX_HOME is pointed at a temp + * directory this module creates in this process; only that directory is removed at exit. + * - If it has (this repository's preload loads it), its CODEX_HOME was fixed by the preload. + * Either way the test file then calls {@link assertCodexHomeOwned} with the Codex home its paths + * actually resolved to, and refuses to load unless that directory is the one created here, or the + * `.codex` of a test root whose owner marker (scripts/test-temp.ts) names this very process. + * Imports nothing from src/ on purpose. */ -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; -if (process.env.OCX_TEST_HOME_GUARD !== "1") { - const owned = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-test-codex-home-"))); - process.env.CODEX_HOME = owned; +const OWNER_FILE = ".opencodex-test-owner.json"; + +function pathsModuleLoaded(): boolean { + return Object.keys(require.cache).some(key => key.replaceAll("\\", "/").endsWith("/src/codex/paths.ts")); +} + +let created: string | null = null; +if (!pathsModuleLoaded()) { + created = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-test-codex-home-"))); + process.env.CODEX_HOME = created; + const owned = created; process.on("exit", () => { rmSync(owned, { recursive: true, force: true }); }); } + +/** Whether `dir` is the `.codex` of a test root whose owner marker names this process. */ +function ownedTestRootCodexHome(dir: string): boolean { + if (basename(dir) !== ".codex") return false; + const root = dirname(dir); + const marker = join(root, OWNER_FILE); + try { + if (!existsSync(marker) || !lstatSync(marker).isFile() || lstatSync(marker).isSymbolicLink()) return false; + const owner = JSON.parse(readFileSync(marker, "utf8")) as { kind?: unknown; root?: unknown; pid?: unknown }; + return owner.kind === "opencodex-test-root" && owner.pid === process.pid + && typeof owner.root === "string" && realpathSync.native(owner.root) === root; + } catch { + return false; + } +} + +/** Throw unless `codexHome` is a Codex home this process owns (see the module comment). */ +export function assertCodexHomeOwned(codexHome: string): void { + let resolved: string; + try { resolved = realpathSync.native(codexHome); } catch { resolved = codexHome; } + if (created !== null && resolved === created) return; + if (ownedTestRootCodexHome(resolved)) return; + throw new Error(`refusing to run: the Codex home ${resolved} is not a temp directory this test process owns. Import tests/helpers/codex-home-sandbox.ts before anything that loads src/codex/paths.`); +} + +/** The directory this module created, or null when the preload had already fixed the paths. */ +export function createdCodexHomeForTests(): string | null { + return created; +} From 10561cd0bba57c6990109fca5283d8b1dac86d64 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:53:52 +0900 Subject: [PATCH 23/54] fix(cli): bind the disconnect restart to the runtime generation; exit 0 only when disconnected Coordinator review round 3: a pid can be reused, so disconnect captures the observed runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from that record; another process at the same pid cannot prove it. Any client state other than disconnected, read after the observation and again before exit 0, now exits 1 with its own message, on every branch, so a Home-driven leave stops before its own ocx restart. --- src/cli/disconnect-runtime.ts | 90 ++++++++++++----- src/cli/dispatch.ts | 21 +++- src/cli/index.ts | 4 +- tests/cli/disconnect-runtime.test.ts | 144 +++++++++++++++++---------- 4 files changed, 177 insertions(+), 82 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index 1faff2b623a..ffcb153b340 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -3,43 +3,80 @@ * running until something restarts it. Left alone it still answered as a client, so the machine * was neither a working Child nor standalone, and a Home could not link it again. Disconnect * therefore restarts that runtime itself (the same restart as `ocx restart`, which a disconnected - * client runtime hands to whichever owner holds it), bound to the runtime it observed, and fails - * with the remaining step unless a live standalone runtime answers afterwards. + * client runtime hands to whichever owner holds it), bound to the runtime generation it observed, + * and exits 0 only when no client runtime remained or a live standalone runtime answers afterwards. + * Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. */ -import type { ProxyRestartIo } from "./tray-proxy"; +import type { ProxyRestartIo, ProxyRestartLive, ProxyRestartRequestOutcome } from "./tray-proxy"; + +/** + * One runtime process: its runtime record. The attestation secret is created per process + * (src/lib/local-management-attestation.ts createLocalAttestationSecret), so a new process at a + * reused pid has a different one. + */ +export interface RuntimeGeneration { + pid: number; + port: number; + hostname?: string; + attestationSecret: string; +} + +export type DisconnectClientState = + | { kind: "disconnected" } + | { kind: "connected" } + | { kind: "invalid" | "mismatched"; reason: string }; export interface DisconnectRuntimeDeps { /** Liveness that separates a confirmed absence from a probe that could not decide. */ observe: () => Promise< - | { kind: "live"; live: { pid: number | null; role?: string } } + | { kind: "live"; live: { pid: number | null; port: number; role?: string } } | { kind: "absent" | "unknown"; live: null } >; - /** `ocx restart` bound to this pid (see {@link bindRestartToPid}); resolves true when it completed. */ - restart: (expectedPid: number | null) => Promise; - /** The durable state is still disconnected (no connect landed after this disconnect). */ - stillDisconnected: () => boolean; + /** The runtime record of the observed runtime, only when it names that pid and port. */ + capture: (live: { pid: number | null; port: number }) => RuntimeGeneration | null; + /** The durable client state, read right before the restart. */ + connectionState: () => DisconnectClientState; + /** `ocx restart` bound to this generation (see {@link bindRestartToRuntime}); true when it completed. */ + restart: (target: RuntimeGeneration) => Promise; json: boolean; info?: (line: string) => void; error?: (line: string) => void; } /** - * Bind a restart to the runtime disconnect observed. The restart discovers its target again; a - * connect that committed in between can have started a new client runtime there, and restarting - * that one would interrupt a working connection. So the request is sent only to the same pid (the - * client listener admits it only for that pid too, via the expected-pid header), and a runtime that - * is gone gets no generic start: the caller then reports what it observes. + * Bind a restart to one runtime generation. The restart discovers its target again, and a connect + * that committed in between can have started a new client runtime there, even at a reused pid. + * The request goes out only for the captured pid and port, and the runtime record it attests and + * derives the restart capability from is the captured one: a different process cannot prove that + * secret, so it is refused before the restart request is sent, and its listener would reject the + * capability anyway. A runtime that is gone gets no generic start. */ -export function bindRestartToPid(io: ProxyRestartIo, expectedPid: number | null): ProxyRestartIo { +export function bindRestartToRuntime( + io: ProxyRestartIo, + target: RuntimeGeneration, + request: (previous: ProxyRestartLive, readRuntime: (pid: number) => RuntimeGeneration | null) => ProxyRestartRequestOutcome | Promise, +): ProxyRestartIo { return { ...io, startWhenStopped: () => ({ status: "skipped" }), - requestInPlaceRestart: previous => (expectedPid === null || previous.pid !== expectedPid + requestInPlaceRestart: previous => (previous.pid !== target.pid || previous.port !== target.port ? { accepted: false, uncertain: false, error: new Error("restart_target_changed") } - : io.requestInPlaceRestart(previous)), + : request(previous, pid => (pid === target.pid ? target : null))), }; } +/** The message for a state that is not disconnected, or null when it is. */ +function notDisconnected(state: DisconnectClientState): string | null { + if (state.kind === "disconnected") return null; + if (state.kind === "connected") return "Disconnect finished, but a new connection was made on this machine after this disconnect; its client runtime was left alone. If this machine should leave its Home, run ocx disconnect again."; + return `Disconnect finished, but the client state is ${state.kind} (${state.reason}), so the client runtime was not restarted. Inspect it with ocx connect status, fix the client state, then run ocx disconnect again.`; +} + +/** + * Exit 0 only when the durable state is disconnected (read after the observation and again before + * exit 0) and no client runtime is left: none answered, a non-client runtime answered, or the + * restart left a live non-client runtime. Everything else exits 1 with what was seen and what to run. + */ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps): Promise { const info = deps.info ?? (line => console.log(line)); const error = deps.error ?? (line => console.error(line)); @@ -48,28 +85,33 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) error("Disconnected, but this machine could not confirm whether the client runtime stopped: a recorded OpenCodex process did not answer. Check with ocx status; if it still serves as a client, run on this machine: ocx restart"); return 1; } - if (observed.kind !== "live" || observed.live.role !== "client") return 0; - const live = observed.live; - // A connect that landed after this disconnect owns that runtime now; restarting it is not ours. - if (!deps.stillDisconnected()) { - (deps.json ? error : info)("The running client runtime was left alone: a new connection was made after this disconnect."); - return 0; + const client = observed.kind === "live" && observed.live.role === "client" ? observed.live : null; + // Captured before the state is read: a runtime a later connect starts is a later generation. + const target = client ? deps.capture(client) : null; + if (client && !target) { + error(`Disconnected, but this machine could not identify the running client runtime${client.pid ? ` (pid ${client.pid})` : ""}: its runtime record is missing or names another process. It was not restarted. Check with ocx status, then run on this machine: ocx restart`); + return 1; } + const before = notDisconnected(deps.connectionState()); + if (before) { error(before); return 1; } + if (!client || !target) return 0; // A --json caller reads one document from stdout; restart progress goes to stderr. const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; - try { ok = await deps.restart(live.pid); } finally { console.log = originalLog; } + try { ok = await deps.restart(target); } finally { console.log = originalLog; } // Standalone is claimed only for what actually serves now: a live runtime that is not a client. const after = await deps.observe(); if (after.kind === "live" && after.live.role !== "client") { + const late = notDisconnected(deps.connectionState()); + if (late) { error(late); return 1; } (deps.json ? error : info)("The client runtime was restarted; this machine now runs standalone OpenCodex."); return 0; } if (after.kind === "live") { error(ok ? "The restart completed, but the runtime still answers as a client. Check with ocx status and ocx connect status on this machine." - : `Disconnected, but the client runtime${live.pid ? ` (pid ${live.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`); + : `Disconnected, but the client runtime${client.pid ? ` (pid ${client.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`); return 1; } error("Disconnected, but this machine could not confirm a standalone runtime afterwards (none answered, or the probe could not decide). Check with ocx status; start one with ocx restart (or the desktop app or ocx service start, whichever owns it)."); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 30e180596ab..81036ac2ac9 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -60,7 +60,7 @@ export interface CliDispatchDeps { handleTrayProxyStart: (existingIsSuccess?: boolean) => Promise; handleTrayProxyRestart: () => Promise; handleRestartStartWhenStopped: (recoveringLiveRestart?: boolean) => Promise; - handleProxyRestart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, bindIo?: (io: ProxyRestartIo) => ProxyRestartIo) => Promise; + handleProxyRestart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, bindIo?: (io: ProxyRestartIo, deadlineAt: number) => ProxyRestartIo) => Promise; handleUninstall: () => Promise; handleStatus: () => Promise; handleRecoverHistory: () => Promise; @@ -426,13 +426,26 @@ const commandRunners: Record = { const code = await handleDisconnectCommand(deps.args.slice(1)); // Token recovery refuses any live client runtime and leaves nothing to restart. if (code !== 0 || deps.args.includes("--orphaned-token")) return code; - const { bindRestartToPid, restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); + const { bindRestartToRuntime, restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); const { observeProxyLiveness } = await import("../server/proxy-liveness"); const { readClientConnectionState } = await import("../client/state"); + const { readRuntimePort } = await import("../config/process-state"); + const { requestBoundSystemRestart } = await import("./system-restart-client"); return await restartRuntimeAfterDisconnect({ observe: () => observeProxyLiveness({ attempts: 2 }), - restart: expectedPid => deps.handleProxyRestart(deps.handleRestartStartWhenStopped, undefined, io => bindRestartToPid(io, expectedPid)), - stillDisconnected: () => readClientConnectionState().kind === "disconnected", + capture: live => { + if (live.pid === null) return null; + const record = readRuntimePort(live.pid); + return record?.attestationSecret && record.pid === live.pid && record.port === live.port + ? { ...record, attestationSecret: record.attestationSecret } : null; + }, + connectionState: () => { + const state = readClientConnectionState(); + return state.kind === "invalid" || state.kind === "mismatched" ? { kind: state.kind, reason: state.reason } : { kind: state.kind }; + }, + restart: target => deps.handleProxyRestart(deps.handleRestartStartWhenStopped, undefined, (io, deadlineAt) => + bindRestartToRuntime(io, target, (previous, readRuntime) => + requestBoundSystemRestart(previous as Parameters[0], deadlineAt, { readRuntime: readRuntime as typeof readRuntimePort }))), json: deps.args.includes("--json"), }); }, diff --git a/src/cli/index.ts b/src/cli/index.ts index d5a54d798fb..8aa175d87d1 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -959,7 +959,7 @@ const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIM /** Reserve confirmation time within the shared restart deadline. */ async function handleProxyRestart( startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, - bindIo: (io: ProxyRestartIo) => ProxyRestartIo = io => io, + bindIo: (io: ProxyRestartIo, deadlineAt: number) => ProxyRestartIo = io => io, ): Promise { const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS; // A caller may bind the restart to one target (ocx disconnect: src/cli/disconnect-runtime.ts). @@ -980,7 +980,7 @@ async function handleProxyRestart( recheckAfterFailedStart: () => recheckRestartFailedStart(end => discoverStableProxyForRestart({ findLive: () => findLiveProxy({ deadlineAt: end, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= end, })), - }); + }, deadlineAt); const result = await runDesktopAwareProxyRestart({ ...restartIo }); if (!result.ok && result.phase === "request" && result.error instanceof UpdateRestartRequired) { const candidate = result.error.candidate(); diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index d91fe21210d..d7987169df5 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -1,23 +1,29 @@ import { describe, expect, test } from "bun:test"; -import { bindRestartToPid, restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps } from "../../src/cli/disconnect-runtime"; +import { bindRestartToRuntime, restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps, type RuntimeGeneration } from "../../src/cli/disconnect-runtime"; +import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; import { runProxyRestart, type ProxyRestartIo } from "../../src/cli/tray-proxy"; +import { createLocalAttestationProof, createLocalAttestationSecret, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; +import { SYSTEM_RESTART_PATH } from "../../src/lib/system-restart-contract"; type Observation = Awaited>; -const live = (role?: string): Observation => ({ kind: "live", live: { pid: 4321, ...(role ? { role } : {}) } }); +type State = ReturnType; +const live = (role?: string, pid = 4321): Observation => ({ kind: "live", live: { pid, port: 10100, ...(role ? { role } : {}) } }); +const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret() }; -function run(observation: Observation, restartOk: boolean, json = false, extra: { stillDisconnected?: () => boolean; after?: Observation } = {}) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; - const pids: Array = []; + const bound: RuntimeGeneration[] = []; const done = restartRuntimeAfterDisconnect({ - observe: async () => (restarts > 0 ? extra.after ?? live("standalone") : observation), - restart: async pid => { restarts += 1; pids.push(pid); return restartOk; }, - stillDisconnected: extra.stillDisconnected ?? (() => true), + observe: async () => (restarts > 0 ? extra.after ?? live("standalone", 9999) : observation), + capture: () => (extra.capture === undefined ? generation : extra.capture), + connectionState: () => extra.states?.shift() ?? extra.state ?? { kind: "disconnected" }, + restart: async target => { restarts += 1; bound.push(target); return restartOk; }, json, info: line => out.push(line), error: line => err.push(line), }); - return done.then(code => ({ code, out, err, restarts, pids })); + return done.then(code => ({ code, out, err, restarts, bound })); } describe("ocx disconnect restarts the client runtime it left behind", () => { @@ -39,37 +45,50 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { } }); - // findLiveProxy answers null for "nothing there" and for "could not tell" alike; a disconnect that - // cannot rule out a still-serving client runtime must not report success to a caller over SSH. test("an inconclusive liveness probe fails with what to check", async () => { const result = await run({ kind: "unknown", live: null }, true); expect(result).toMatchObject({ code: 1, restarts: 0 }); expect(result.err.join("\n")).toContain("could not confirm whether the client runtime stopped"); - expect(result.err.join("\n")).toContain("ocx status"); - expect(result.err.join("\n")).toContain("ocx restart"); }); - // Final review: a connect that lands between disconnect and its restart owns the runtime now; - // disconnect must leave it alone and never call the machine standalone. - test("a connection made after the disconnect leaves its runtime alone", async () => { - const result = await run(live("client"), true, false, { stillDisconnected: () => false }); - expect(result).toMatchObject({ code: 0, restarts: 0, err: [] }); - expect(result.out.join("\n")).toContain("a new connection was made after this disconnect"); + test("the restart is bound to the generation captured from the observed runtime", async () => { + expect((await run(live("client"), true)).bound).toEqual([generation]); }); - test("standalone is reported only when the runtime after the restart is not a client", async () => { - const result = await run(live("client"), true, false, { after: live("client") }); - expect(result.code).toBe(1); - expect(result.out).toEqual([]); - expect(result.err.join("\n")).toContain("still answers as a client"); + test("a client runtime whose generation cannot be captured is not restarted", async () => { + const result = await run(live("client"), true, false, { capture: null }); + expect(result).toMatchObject({ code: 1, restarts: 0 }); + expect(result.err.join("\n")).toContain("could not identify the running client runtime"); }); - // Coordinator review round 2: the restart is bound to the runtime disconnect observed. - test("the restart is bound to the observed runtime's pid", async () => { - expect((await run(live("client"), true)).pids).toEqual([4321]); + // Coordinator review round 3: any state but disconnected stops the leave before the Home's own + // \`ocx restart\`, which would interrupt a connection that just landed or act on unreadable state. + test.each([ + ["connected", { kind: "connected" } as State, "a new connection was made on this machine after this disconnect"], + ["invalid", { kind: "invalid", reason: "config.json is missing or unreadable" } as State, "client state is invalid (config.json is missing or unreadable)"], + ["mismatched", { kind: "mismatched", reason: "runtimeRole=client is present without config.json.client" } as State, "client state is mismatched (runtimeRole=client is present without config.json.client)"], + ])("a %s state after disconnect exits 1 and restarts nothing", async (_name, state, message) => { + const result = await run(live("client"), true, false, { state }); + expect(result).toMatchObject({ code: 1, restarts: 0, out: [] }); + expect(result.err.join("\n")).toContain(message); + }); + + // The rule holds on every branch: exit 0 needs a disconnected state, read again before exit 0. + test.each([ + ["no runtime", { kind: "absent", live: null } as Observation], + ["a standalone runtime", live("standalone")], + ])("%s with a connected state exits 1", async (_name, observation) => { + const result = await run(observation, true, false, { state: { kind: "connected" } }); + expect(result).toMatchObject({ code: 1, restarts: 0, out: [] }); + expect(result.err.join("\n")).toContain("a new connection was made on this machine after this disconnect"); + }); + + test("a connection that lands during the restart turns the standalone result into exit 1", async () => { + const result = await run(live("client"), true, false, { states: [{ kind: "disconnected" }, { kind: "connected" }] }); + expect(result).toMatchObject({ code: 1, restarts: 1, out: [] }); + expect(result.err.join("\n")).toContain("a new connection was made on this machine after this disconnect"); }); - // ...and standalone is claimed only for a live, non-client runtime after it. test.each([ ["absent", { kind: "absent", live: null } as const], ["inconclusive", { kind: "unknown", live: null } as const], @@ -78,7 +97,6 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(result.code).toBe(1); expect(result.out).toEqual([]); expect(result.err.join("\n")).toContain("could not confirm a standalone runtime"); - expect(result.err.join("\n")).toContain("ocx status"); }); test("--json keeps stdout to the disconnect document", async () => { @@ -88,9 +106,10 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { try { let restarted = false; const result = await restartRuntimeAfterDisconnect({ - observe: async () => live(restarted ? "standalone" : "client"), + observe: async () => live(restarted ? "standalone" : "client", restarted ? 9999 : 4321), + capture: () => generation, + connectionState: () => ({ kind: "disconnected" }), restart: async () => { console.log("restart progress"); restarted = true; return true; }, - stillDisconnected: () => true, json: true, error: () => {}, }); @@ -100,41 +119,62 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { }); }); -// Coordinator review round 2: a connect that commits after disconnect's check can start a new -// client runtime before the restart discovers its target. The hook at that gap is a findLive that -// answers a different pid: the request must never be sent, and nothing may be started generically. -describe("a disconnect restart bound to the observed runtime", () => { - function io(found: { status: "live"; pid: number } | { status: "absent" }) { +// Coordinator review rounds 2-3: the restart must reach only the runtime generation disconnect +// observed. A pid can be reused, so the binding is the captured runtime record (pid, port and the +// per-process attestation secret), and admission is driven through the real requestBoundSystemRestart. +describe("a disconnect restart bound to the captured runtime generation", () => { + function world(current: { pid: number; secret: string } | null) { + const posts: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = String(input); + if (url.endsWith(SYSTEM_RESTART_PATH)) { posts.push(url); return Response.json({ success: true }, { status: 202 }); } + if (!current) throw new Error("connection refused"); + const challenge = new Headers(init.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER) ?? ""; + const proof = createLocalAttestationProof(current.secret, challenge, current.pid, 10100) ?? ""; + return Response.json({ service: "opencodex", pid: current.pid, port: 10100, role: "client", restartCapability: "v1" }, { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: proof } }); + }) as typeof fetch; const calls: string[] = []; - const base: ProxyRestartIo = { - findLive: async () => found.status === "live" - ? { status: "live", live: { pid: found.pid, port: 10100, source: "runtime" } } + const io: ProxyRestartIo = { + findLive: async () => current + ? { status: "live", live: { pid: current.pid, port: 10100, hostname: "127.0.0.1", source: "runtime" } } : { status: "absent" }, startWhenStopped: async () => { calls.push("generic-start"); return { status: "started" }; }, - requestInPlaceRestart: async previous => { calls.push(`request:${previous.pid}`); return { accepted: true }; }, + requestInPlaceRestart: async () => { calls.push("unbound-request"); return { accepted: true }; }, waitForReplacement: async () => ({ pid: 9999, port: 10100, source: "runtime" }), waitBetweenAttempts: async () => {}, }; - return { calls, base }; + const request = (previous: Parameters[0], readRuntime: (pid: number) => RuntimeGeneration | null) => + requestBoundSystemRestart({ ...previous, version: "0.0.0" } as never, Date.now() + 600_000, { fetchImpl, readRuntime: readRuntime as never, readUpdateHome: () => null, cliVersion: "0.0.0", + findLive: (async () => current ? { pid: current.pid, port: 10100, hostname: "127.0.0.1", source: "runtime" } : null) as never }); + return { posts, calls, io, request }; } - test("a different runtime at the restart's own discovery is refused before any request", async () => { - const { calls, base } = io({ status: "live", pid: 5555 }); - const result = await runProxyRestart(bindRestartToPid(base, 4321)); - expect(calls).toEqual([]); + test("same pid, different generation: refused before any restart request", async () => { + const w = world({ pid: 4321, secret: createLocalAttestationSecret() }); + const result = await runProxyRestart(bindRestartToRuntime(w.io, generation, w.request)); + expect(w.posts).toEqual([]); + expect(w.calls).toEqual([]); + expect(result.ok).toBe(false); + }); + + test("a different pid at the restart's own discovery is refused", async () => { + const w = world({ pid: 5555, secret: generation.attestationSecret }); + const result = await runProxyRestart(bindRestartToRuntime(w.io, generation, w.request)); + expect(w.posts).toEqual([]); expect(result).toMatchObject({ ok: false, phase: "request" }); - expect(String((result as { error?: unknown }).error)).toContain("restart_target_changed"); }); - test("the observed runtime is restarted", async () => { - const { calls, base } = io({ status: "live", pid: 4321 }); - expect((await runProxyRestart(bindRestartToPid(base, 4321))).ok).toBe(true); - expect(calls).toEqual(["request:4321"]); + test("the captured generation is restarted", async () => { + const w = world({ pid: 4321, secret: generation.attestationSecret }); + const result = await runProxyRestart(bindRestartToRuntime(w.io, generation, w.request)); + expect(w.posts.length, JSON.stringify(result, (_k, v) => v instanceof Error ? v.message : v)).toBe(1); + expect(w.calls).toEqual([]); }); test("a runtime that vanished gets no generic start", async () => { - const { calls, base } = io({ status: "absent" }); - await runProxyRestart(bindRestartToPid(base, 4321)); - expect(calls).toEqual([]); + const w = world(null); + await runProxyRestart(bindRestartToRuntime(w.io, generation, w.request)); + expect(w.calls).toEqual([]); + expect(w.posts).toEqual([]); }); }); From da3da8fefd138eb8f8f49b27054bcf01bde9c344 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:53:52 +0900 Subject: [PATCH 24/54] test(link): await the supervisor lookup's entry instead of polling microtasks --- tests/clients/client-runtime-restart.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index a917ddd74c7..18f79d65bdc 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -279,16 +279,17 @@ describe("client restart controller", () => { test("a connection current again during the supervisor lookup keeps this runtime", async () => { let ended = true; let releaseLookup!: (value: "desktop") => void; - let lookups = 0; + let lookupEntered!: () => void; + const entered = new Promise(resolve => { lookupEntered = resolve; }); const recycled: unknown[] = []; const { io } = fakeIo({ connectionChanged: () => ended, - supervisor: () => { lookups += 1; return new Promise(resolve => { releaseLookup = resolve; }); }, + supervisor: () => { lookupEntered(); return new Promise(resolve => { releaseLookup = resolve; }); }, recycle: async verdict => { recycled.push(verdict); }, }); const controller = createClientRestartController(io); expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); - while (lookups === 0) await Promise.resolve(); + await entered; ended = false; releaseLookup("desktop"); await controller.settled(); From 31d29be52cc5dd41eb0b8370c67f18dfcc0b4e68 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:54:14 +0900 Subject: [PATCH 25/54] docs(link): structure note for the generation-bound disconnect restart and its exit rule --- structure/remote-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/remote-link.md b/structure/remote-link.md index dbc590ff2b2..13a53fe5ba7 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live and exits non-zero naming `ocx restart` when it does not complete. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks right before recycling. `ocx disconnect` treats an inconclusive liveness probe as a failure with guidance, never as absence. Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It exits 0 only when the client state is disconnected (read after the observation and again before exit 0) and no client runtime remains or a live non-client runtime answers after the restart; every other outcome exits 1 with what was seen and what to run, so a Home-driven leave stops before its own `ocx restart`. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From f30dc5d42b1caa7b58a1c1801608e74c5df4aac9 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:56:24 +0900 Subject: [PATCH 26/54] fix(cli): keep src/cli/index.ts under the file-size ratchet CI test 2/5 on b47df93: the restart binding grew src/cli/index.ts to 2003 lines, past the 2000-line threshold. The binding now adds no lines there (1999, as on dev); its description moved to the handleProxyRestart type in dispatch.ts. Behavior unchanged. --- src/cli/dispatch.ts | 1 + src/cli/index.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 81036ac2ac9..b417f88a144 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -60,6 +60,7 @@ export interface CliDispatchDeps { handleTrayProxyStart: (existingIsSuccess?: boolean) => Promise; handleTrayProxyRestart: () => Promise; handleRestartStartWhenStopped: (recoveringLiveRestart?: boolean) => Promise; + /** `bindIo` lets a caller bind the restart to one target (ocx disconnect: src/cli/disconnect-runtime.ts). */ handleProxyRestart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, bindIo?: (io: ProxyRestartIo, deadlineAt: number) => ProxyRestartIo) => Promise; handleUninstall: () => Promise; handleStatus: () => Promise; diff --git a/src/cli/index.ts b/src/cli/index.ts index 8aa175d87d1..4abf5c14a61 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -89,8 +89,7 @@ import { restartStartOutcome, waitForProxyReplacement, runTrayProxyStart, - type ProxyRestartIo, - type ProxyRestartLive, + type ProxyRestartIo, type ProxyRestartLive, type ProxyRestartStartOutcome, } from "./tray-proxy"; import { duplicateRuntimeMessage, runDesktopAwareProxyRestart } from "./desktop-runtime-guidance"; @@ -958,12 +957,10 @@ async function handleTrayProxyStart(existingIsSuccess = true): Promise const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + 15_000; /** Reserve confirmation time within the shared restart deadline. */ async function handleProxyRestart( - startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, - bindIo: (io: ProxyRestartIo, deadlineAt: number) => ProxyRestartIo = io => io, + startWhenStopped: (recoveringLiveRestart: boolean) => Promise, onSupervised?: (supervisor: RestartSupervisor) => void, bindIo: (io: ProxyRestartIo, deadlineAt: number) => ProxyRestartIo = io => io, ): Promise { const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS; - // A caller may bind the restart to one target (ocx disconnect: src/cli/disconnect-runtime.ts). - const restartIo = bindIo({ + const result = await runDesktopAwareProxyRestart({ ...bindIo({ findLive: () => discoverStableProxyForRestart({ findLive: () => findLiveProxy({ deadlineAt, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= deadlineAt, @@ -980,8 +977,7 @@ async function handleProxyRestart( recheckAfterFailedStart: () => recheckRestartFailedStart(end => discoverStableProxyForRestart({ findLive: () => findLiveProxy({ deadlineAt: end, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= end, })), - }, deadlineAt); - const result = await runDesktopAwareProxyRestart({ ...restartIo }); + }, deadlineAt) }); if (!result.ok && result.phase === "request" && result.error instanceof UpdateRestartRequired) { const candidate = result.error.candidate(); console.log(`🔄 Running proxy ${candidate.target.version} is older than this CLI (${candidate.cliVersion}); restarting it from the current installation...`); From 247c7697a24ae1502f4f9c4f4bac6607c2bc9c81 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:02:32 +0900 Subject: [PATCH 27/54] test(link): the journal runner writes only into a home its parent handed it a token for Coordinator review round 4 P1: the runner proved only that its paths were under HOME. The parent now writes a one-time token into the temp home it created and passes it in OCX_FIXTURE_TOKEN; the runner exits 2 before any import or write unless they match. A new test runs it against a temp folder standing in for a real home, without and with a forged token, and asserts it untouched. --- tests/clients/client-link-teardown.test.ts | 40 +++++++++++++++++++- tests/fixtures/client-link-journal-runner.ts | 16 +++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/tests/clients/client-link-teardown.test.ts b/tests/clients/client-link-teardown.test.ts index 9841ad0b7f9..11d6370bb3e 100644 --- a/tests/clients/client-link-teardown.test.ts +++ b/tests/clients/client-link-teardown.test.ts @@ -2,7 +2,8 @@ import { assertCodexHomeOwned } from "../helpers/codex-home-sandbox"; import { expect, spyOn, test } from "bun:test"; import { CODEX_HOME } from "../../src/codex/paths"; -import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { handleDisconnectCommand } from "../../src/cli/connect"; import { connectClient } from "../../src/client/connect"; @@ -11,6 +12,9 @@ import { clientLinkStatePath, writeClientLinkState } from "../../src/client/link import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; import { createTempHome } from "../helpers/temp-home"; import { fixturePath, repoPath } from "../helpers/repo-root"; +import { randomUUID } from "node:crypto"; + +const FIXTURE_TOKEN_FILE = ".ocx-fixture-token"; assertCodexHomeOwned(CODEX_HOME); @@ -226,9 +230,12 @@ async function runJournalScenario(scenario: "catalog-changed" | "standalone-hand const codex = home.path("codex"); mkdirSync(codex, { recursive: true }); mkdirSync(home.path("ocx"), { recursive: true }); + // The runner writes only into a home that carries the one-time token this process hands it. + const token = randomUUID(); + writeFileSync(home.path(FIXTURE_TOKEN_FILE), token); const child = Bun.spawn([process.execPath, fixturePath("client-link-journal-runner.ts")], { cwd: repoPath(), stdin: new Blob([scenario]), stdout: "pipe", stderr: "pipe", - env: { ...process.env, HOME: home.root, USERPROFILE: home.root, OPENCODEX_HOME: home.path("ocx"), CODEX_HOME: codex, OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, + env: { ...process.env, HOME: home.root, USERPROFILE: home.root, OPENCODEX_HOME: home.path("ocx"), CODEX_HOME: codex, OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0", OCX_FIXTURE_TOKEN: token }, }); const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); expect(exit, stderr).toBe(0); @@ -263,3 +270,32 @@ test("connect over an earlier connection's Codex journal warns with the recovery expect(result.warnings.join("\n")).toContain("ocx sync --restart-codex"); }); + +// Coordinator review round 4: run by hand, with HOME pointing at a real home, the runner must +// refuse before any write. A temp folder stands in for that home and is asserted untouched. +test("the journal runner refuses a home its parent did not create, and writes nothing", async () => { + const home = createTempHome("ocx-client-link-runner-direct-"); + try { + const realLike = home.path("real-home"); + mkdirSync(join(realLike, ".codex"), { recursive: true }); + mkdirSync(join(realLike, ".opencodex"), { recursive: true }); + writeFileSync(join(realLike, ".codex", "config.toml"), "model_provider = \"real\"\n"); + writeFileSync(join(realLike, ".opencodex", "config.json"), "{\"real\":true}"); + for (const token of [undefined, "forged-token"]) { + const child = Bun.spawn([process.execPath, fixturePath("client-link-journal-runner.ts")], { + cwd: repoPath(), stdin: new Blob(["standalone-handover"]), stdout: "pipe", stderr: "pipe", + env: { PATH: process.env.PATH ?? "", HOME: realLike, OPENCODEX_HOME: join(realLike, ".opencodex"), CODEX_HOME: join(realLike, ".codex"), ...(token ? { OCX_FIXTURE_TOKEN: token } : {}) }, + }); + const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(exit).not.toBe(0); + expect(stdout).toBe(""); + expect(stderr.trim().split("\n")).toEqual(["client-link-journal-runner.ts is a test fixture; run it through tests/clients/client-link-teardown.test.ts."]); + } + expect(readdirSync(join(realLike, ".codex"))).toEqual(["config.toml"]); + expect(readFileSync(join(realLike, ".codex", "config.toml"), "utf8")).toBe("model_provider = \"real\"\n"); + expect(readdirSync(join(realLike, ".opencodex"))).toEqual(["config.json"]); + expect(readFileSync(join(realLike, ".opencodex", "config.json"), "utf8")).toBe("{\"real\":true}"); + } finally { + home.remove(); + } +}); diff --git a/tests/fixtures/client-link-journal-runner.ts b/tests/fixtures/client-link-journal-runner.ts index 065588172ed..0db18510113 100644 --- a/tests/fixtures/client-link-journal-runner.ts +++ b/tests/fixtures/client-link-journal-runner.ts @@ -5,9 +5,23 @@ * a scenario that writes them directly runs here, in a child whose CODEX_HOME and OPENCODEX_HOME the * parent test set to its own temp directory before anything was imported. Prints one JSON result. */ -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +// First, before any source module loads or anything is written: the parent test creates the temp +// home, writes a one-time token into it and hands the same token over. A direct run (a developer's +// real HOME, no token, or a token the home does not carry) stops here. +{ + const token = process.env.OCX_FIXTURE_TOKEN; + const marker = process.env.HOME ? join(process.env.HOME, ".ocx-fixture-token") : ""; + let carried: string | null = null; + try { carried = marker && existsSync(marker) ? readFileSync(marker, "utf8") : null; } catch { carried = null; } + if (!token || carried !== token) { + console.error("client-link-journal-runner.ts is a test fixture; run it through tests/clients/client-link-teardown.test.ts."); + process.exit(2); + } +} + const scenario = readFileSync(0, "utf8").trim() as "catalog-changed" | "standalone-handover" | "foreign-journal"; const home = process.env.OPENCODEX_HOME!; const { getDefaultConfig, saveConfig } = await import("../../src/config"); From e5ae67c3bd636cd4340075b953279d9351e08ea9 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:02:32 +0900 Subject: [PATCH 28/54] fix(cli): after a refused restart, say who answers before advising a restart Coordinator review round 4 P2: when a client still answered after the bound restart, disconnect said the old runtime served the old connection and advised ocx restart, which could interrupt a newer connection. It now re-reads the state and recaptures who answers: a new connection in use, an unreadable state or a different runtime are reported for inspection, and a plain restart is advised only while the captured runtime still answers. The absent/unknown branch reads the state too. --- src/cli/disconnect-runtime.ts | 16 ++++++++++++ tests/cli/disconnect-runtime.test.ts | 38 ++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index ffcb153b340..556be1018a3 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -72,6 +72,11 @@ function notDisconnected(state: DisconnectClientState): string | null { return `Disconnect finished, but the client state is ${state.kind} (${state.reason}), so the client runtime was not restarted. Inspect it with ocx connect status, fix the client state, then run ocx disconnect again.`; } +/** After a restart: a connection that appeared meanwhile is in use; never advise restarting it. */ +function afterRestartState(state: Exclude): string { + return state.kind === "connected" ? "Disconnect finished, but a new connection is in use on this machine, so its client runtime was not restarted. Inspect it with ocx status and ocx connect status; do not restart it unless this machine should leave its Home (then run ocx disconnect again)." : notDisconnected(state)!; +} + /** * Exit 0 only when the durable state is disconnected (read after the observation and again before * exit 0) and no client runtime is left: none answered, a non-client runtime answered, or the @@ -109,11 +114,22 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) return 0; } if (after.kind === "live") { + // A client still answers. Say what is true before advising anything: a restart is recommended + // only while the captured runtime is the one answering and the state is still disconnected. + const state = deps.connectionState(); + if (state.kind !== "disconnected") { error(afterRestartState(state)); return 1; } + const answering = deps.capture(after.live); + if (!answering || answering.attestationSecret !== target.attestationSecret) { + error(`Disconnected, but a different client runtime${after.live.pid ? ` (pid ${after.live.pid})` : ""} answers now, so it was not restarted. Inspect it with ocx status and ocx connect status before restarting anything.`); + return 1; + } error(ok ? "The restart completed, but the runtime still answers as a client. Check with ocx status and ocx connect status on this machine." : `Disconnected, but the client runtime${client.pid ? ` (pid ${client.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`); return 1; } + const state = deps.connectionState(); + if (state.kind !== "disconnected") { error(afterRestartState(state)); return 1; } error("Disconnected, but this machine could not confirm a standalone runtime afterwards (none answered, or the probe could not decide). Check with ocx status; start one with ocx restart (or the desktop app or ocx service start, whichever owns it)."); return 1; } diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index d7987169df5..e044aa3428b 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -10,13 +10,13 @@ type State = ReturnType; const live = (role?: string, pid = 4321): Observation => ({ kind: "live", live: { pid, port: 10100, ...(role ? { role } : {}) } }); const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret() }; -function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null } = {}) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; const bound: RuntimeGeneration[] = []; const done = restartRuntimeAfterDisconnect({ observe: async () => (restarts > 0 ? extra.after ?? live("standalone", 9999) : observation), - capture: () => (extra.capture === undefined ? generation : extra.capture), + capture: () => (restarts > 0 && extra.captureAfter !== undefined ? extra.captureAfter : extra.capture === undefined ? generation : extra.capture), connectionState: () => extra.states?.shift() ?? extra.state ?? { kind: "disconnected" }, restart: async target => { restarts += 1; bound.push(target); return restartOk; }, json, @@ -89,6 +89,40 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(result.err.join("\n")).toContain("a new connection was made on this machine after this disconnect"); }); + // Coordinator review round 4: when a client still answers after a refused or failed restart, + // say what is true; recommend a plain ocx restart only when the captured runtime still answers. + const newer: RuntimeGeneration = { pid: 5555, port: 10100, attestationSecret: createLocalAttestationSecret() }; + test("a new connection in use after a refused restart is reported, with no restart advice", async () => { + const result = await run(live("client"), false, false, { after: live("client", 5555), captureAfter: newer, states: [{ kind: "disconnected" }, { kind: "connected" }] }); + expect(result.code).toBe(1); + const text = result.err.join("\n"); + expect(text).toContain("a new connection is in use on this machine"); + expect(text).toContain("ocx status"); + expect(text).not.toContain("Run on this machine: ocx restart"); + }); + + test("a different client runtime with the state still disconnected is reported for inspection", async () => { + const result = await run(live("client"), false, false, { after: live("client", 5555), captureAfter: newer }); + expect(result.code).toBe(1); + const text = result.err.join("\n"); + expect(text).toContain("a different client runtime (pid 5555) answers now"); + expect(text).not.toContain("Run on this machine: ocx restart"); + }); + + test("an unreadable state after a refused restart says what to check", async () => { + const result = await run(live("client"), false, false, { after: live("client", 5555), captureAfter: newer, states: [{ kind: "disconnected" }, { kind: "invalid", reason: "config.json is missing or unreadable" }] }); + expect(result.code).toBe(1); + expect(result.err.join("\n")).toContain("client state is invalid (config.json is missing or unreadable)"); + expect(result.err.join("\n")).not.toContain("Run on this machine: ocx restart"); + }); + + test("nothing answering after the restart while a new connection exists reports that connection", async () => { + const result = await run(live("client"), true, false, { after: { kind: "absent", live: null }, states: [{ kind: "disconnected" }, { kind: "connected" }] }); + expect(result.code).toBe(1); + expect(result.err.join("\n")).toContain("a new connection is in use on this machine"); + expect(result.err.join("\n")).not.toContain("could not confirm a standalone runtime"); + }); + test.each([ ["absent", { kind: "absent", live: null } as const], ["inconclusive", { kind: "unknown", live: null } as const], From 9b2c687f72d7d0035471d23ebea305b5ff0be794 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:02:32 +0900 Subject: [PATCH 29/54] test(link): assert the codex-home-sandbox branch in effect instead of assuming the preload --- tests/ci-workflows/codex-home-sandbox.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/ci-workflows/codex-home-sandbox.test.ts b/tests/ci-workflows/codex-home-sandbox.test.ts index 0c089e12a37..e567f124627 100644 --- a/tests/ci-workflows/codex-home-sandbox.test.ts +++ b/tests/ci-workflows/codex-home-sandbox.test.ts @@ -5,8 +5,8 @@ * at a temp directory posing as a user's custom real Codex home; nothing here reaches a real home. */ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { assertCodexHomeOwned, createdCodexHomeForTests } from "../helpers/codex-home-sandbox"; import { CODEX_HOME } from "../../src/codex/paths"; @@ -71,10 +71,17 @@ describe("codex-home-sandbox without the preload", () => { }); }); -describe("codex-home-sandbox under the preload", () => { - test("the preload's own test root is proven by its owner marker", () => { - // The preload loaded src/codex/paths first, so the helper created nothing here. - expect(createdCodexHomeForTests()).toBeNull(); +describe("codex-home-sandbox in this process", () => { + // Valid with or without the preload: whichever branch is in effect is asserted, never skipped. + test("this file's own Codex home is proven owned on the branch in effect", () => { + const created = createdCodexHomeForTests(); + if (created !== null) { + // No preload: the helper ran before src/codex/paths and owns the home it resolved to. + expect(realpathSync.native(CODEX_HOME)).toBe(created); + } else { + // The preload loaded src/codex/paths first; its test root's owner marker names this process. + expect(existsSync(join(dirname(realpathSync.native(CODEX_HOME)), ".opencodex-test-owner.json"))).toBe(true); + } expect(() => assertCodexHomeOwned(CODEX_HOME)).not.toThrow(); }); }); From 7c04b6120945362833cbd2967e43a76f9321d83f Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:02:32 +0900 Subject: [PATCH 30/54] docs(link): describe the disconnect state reads per branch --- structure/remote-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/remote-link.md b/structure/remote-link.md index 13a53fe5ba7..e73c15dddc4 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It exits 0 only when the client state is disconnected (read after the observation and again before exit 0) and no client runtime remains or a live non-client runtime answers after the restart; every other outcome exits 1 with what was seen and what to run, so a Home-driven leave stops before its own `ocx restart`. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and when a client still answers it advises a restart only if the captured runtime is still the one answering, otherwise it reports the new connection, the unreadable state or the different runtime. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From e9ae2a5e96106f3a9b1e7006747c74e47abd85e2 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:07:28 +0900 Subject: [PATCH 31/54] fix(cli): one recovery-advice function for every exit-1 outcome of the disconnect restart Coordinator review round 5: the inconclusive-liveness and uncapturable-record exits returned before reading the client state and advised ocx restart, which could interrupt a connection that had just landed. Every exit-1 outcome now takes its advice from recoveryAdvice(state read now, who answers): a plain restart only for the captured runtime under a disconnected state (or a start when nothing answers); a connected or unreadable state gets ocx status inspection only. A sweep test checks every exit-1 branch under each non-disconnected state. --- src/cli/disconnect-runtime.ts | 85 ++++++++++++++++------------ structure/remote-link.md | 2 +- tests/cli/disconnect-runtime.test.ts | 51 ++++++++++++++--- 3 files changed, 95 insertions(+), 43 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index 556be1018a3..cc0c129c173 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -65,71 +65,86 @@ export function bindRestartToRuntime( }; } -/** The message for a state that is not disconnected, or null when it is. */ -function notDisconnected(state: DisconnectClientState): string | null { - if (state.kind === "disconnected") return null; - if (state.kind === "connected") return "Disconnect finished, but a new connection was made on this machine after this disconnect; its client runtime was left alone. If this machine should leave its Home, run ocx disconnect again."; - return `Disconnect finished, but the client state is ${state.kind} (${state.reason}), so the client runtime was not restarted. Inspect it with ocx connect status, fix the client state, then run ocx disconnect again.`; -} +/** What is known about the runtime answering when the advice is given. */ +export type AnsweringRuntime = + | { kind: "captured"; pid: number | null } + | { kind: "different"; pid: number | null } + | { kind: "unknown" } + | { kind: "none" }; -/** After a restart: a connection that appeared meanwhile is in use; never advise restarting it. */ -function afterRestartState(state: Exclude): string { - return state.kind === "connected" ? "Disconnect finished, but a new connection is in use on this machine, so its client runtime was not restarted. Inspect it with ocx status and ocx connect status; do not restart it unless this machine should leave its Home (then run ocx disconnect again)." : notDisconnected(state)!; +/** + * The only source of recovery advice for every exit-1 outcome of this step. A plain + * `ocx restart` is advised only while the state read now is disconnected and either the captured + * runtime is still the one answering, or nothing answers at all (a start, nothing to interrupt). + * Any other state gets what was seen and `ocx status` inspection, never a restart. + */ +export function recoveryAdvice(state: DisconnectClientState, answering: AnsweringRuntime): string { + if (state.kind === "connected") { + return "A new connection is in use on this machine; inspect it with ocx status and ocx connect status and do not restart it. If this machine should leave its Home, run ocx disconnect again."; + } + if (state.kind !== "disconnected") { + return `The client state is ${state.kind} (${state.reason}); nothing was restarted. Inspect it with ocx connect status and ocx status, fix the client state, then run ocx disconnect again.`; + } + switch (answering.kind) { + case "captured": return `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`; + case "different": return `A different client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} answers now, so it was not restarted; inspect it with ocx status and ocx connect status before restarting anything.`; + case "unknown": return "Which runtime answers could not be determined; inspect with ocx status and ocx connect status, and restart a client runtime only while ocx connect status reports disconnected."; + case "none": return "No runtime answers; start one with ocx restart (or the desktop app or ocx service start, whichever owns it)."; + } } /** - * Exit 0 only when the durable state is disconnected (read after the observation and again before - * exit 0) and no client runtime is left: none answered, a non-client runtime answered, or the - * restart left a live non-client runtime. Everything else exits 1 with what was seen and what to run. + * Exit 0 only when no client runtime remains and the state is disconnected: read once after the + * observation when no runtime or a non-client runtime answered, and again after a restart. Every + * exit-1 outcome is a headline plus {@link recoveryAdvice} computed from the state read at that point. */ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps): Promise { const info = deps.info ?? (line => console.log(line)); const error = deps.error ?? (line => console.error(line)); + const fail = (headline: string, answering: AnsweringRuntime): number => { + error(`${headline} ${recoveryAdvice(deps.connectionState(), answering)}`); + return 1; + }; const observed = await deps.observe(); if (observed.kind === "unknown") { - error("Disconnected, but this machine could not confirm whether the client runtime stopped: a recorded OpenCodex process did not answer. Check with ocx status; if it still serves as a client, run on this machine: ocx restart"); - return 1; + return fail("Disconnected, but this machine could not confirm whether the client runtime stopped: a recorded OpenCodex process did not answer.", { kind: "unknown" }); } const client = observed.kind === "live" && observed.live.role === "client" ? observed.live : null; // Captured before the state is read: a runtime a later connect starts is a later generation. const target = client ? deps.capture(client) : null; if (client && !target) { - error(`Disconnected, but this machine could not identify the running client runtime${client.pid ? ` (pid ${client.pid})` : ""}: its runtime record is missing or names another process. It was not restarted. Check with ocx status, then run on this machine: ocx restart`); + return fail(`Disconnected, but this machine could not identify the running client runtime${client.pid ? ` (pid ${client.pid})` : ""}: its runtime record is missing or names another process, so it was not restarted.`, { kind: "unknown" }); + } + const before = deps.connectionState(); + if (before.kind !== "disconnected") { + error(`Disconnect finished, but the client runtime was not restarted. ${recoveryAdvice(before, { kind: "unknown" })}`); return 1; } - const before = notDisconnected(deps.connectionState()); - if (before) { error(before); return 1; } if (!client || !target) return 0; // A --json caller reads one document from stdout; restart progress goes to stderr. const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; try { ok = await deps.restart(target); } finally { console.log = originalLog; } - // Standalone is claimed only for what actually serves now: a live runtime that is not a client. const after = await deps.observe(); if (after.kind === "live" && after.live.role !== "client") { - const late = notDisconnected(deps.connectionState()); - if (late) { error(late); return 1; } + const late = deps.connectionState(); + if (late.kind !== "disconnected") { + error(`The client runtime was restarted, but the client state changed meanwhile. ${recoveryAdvice(late, { kind: "unknown" })}`); + return 1; + } (deps.json ? error : info)("The client runtime was restarted; this machine now runs standalone OpenCodex."); return 0; } if (after.kind === "live") { - // A client still answers. Say what is true before advising anything: a restart is recommended - // only while the captured runtime is the one answering and the state is still disconnected. - const state = deps.connectionState(); - if (state.kind !== "disconnected") { error(afterRestartState(state)); return 1; } const answering = deps.capture(after.live); - if (!answering || answering.attestationSecret !== target.attestationSecret) { - error(`Disconnected, but a different client runtime${after.live.pid ? ` (pid ${after.live.pid})` : ""} answers now, so it was not restarted. Inspect it with ocx status and ocx connect status before restarting anything.`); - return 1; + const who: AnsweringRuntime = !answering ? { kind: "unknown" } + : answering.attestationSecret === target.attestationSecret ? { kind: "captured", pid: after.live.pid } + : { kind: "different", pid: after.live.pid }; + if (ok && who.kind === "captured") { + return fail("The restart completed, but the runtime still answers as a client.", { kind: "unknown" }); } - error(ok - ? "The restart completed, but the runtime still answers as a client. Check with ocx status and ocx connect status on this machine." - : `Disconnected, but the client runtime${client.pid ? ` (pid ${client.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`); - return 1; + return fail(ok ? "The restart completed, but a client runtime answers." : "Disconnected, but the restart did not complete.", who); } - const state = deps.connectionState(); - if (state.kind !== "disconnected") { error(afterRestartState(state)); return 1; } - error("Disconnected, but this machine could not confirm a standalone runtime afterwards (none answered, or the probe could not decide). Check with ocx status; start one with ocx restart (or the desktop app or ocx service start, whichever owns it)."); - return 1; + return fail("Disconnected, but this machine could not confirm a standalone runtime afterwards.", after.kind === "absent" ? { kind: "none" } : { kind: "unknown" }); } diff --git a/structure/remote-link.md b/structure/remote-link.md index e73c15dddc4..4a4e7e9da17 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and when a client still answers it advises a restart only if the captured runtime is still the one answering, otherwise it reports the new connection, the unreadable state or the different runtime. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index e044aa3428b..7d6dd8e80f8 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -64,7 +64,7 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { // Coordinator review round 3: any state but disconnected stops the leave before the Home's own // \`ocx restart\`, which would interrupt a connection that just landed or act on unreadable state. test.each([ - ["connected", { kind: "connected" } as State, "a new connection was made on this machine after this disconnect"], + ["connected", { kind: "connected" } as State, "A new connection is in use on this machine"], ["invalid", { kind: "invalid", reason: "config.json is missing or unreadable" } as State, "client state is invalid (config.json is missing or unreadable)"], ["mismatched", { kind: "mismatched", reason: "runtimeRole=client is present without config.json.client" } as State, "client state is mismatched (runtimeRole=client is present without config.json.client)"], ])("a %s state after disconnect exits 1 and restarts nothing", async (_name, state, message) => { @@ -80,13 +80,13 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { ])("%s with a connected state exits 1", async (_name, observation) => { const result = await run(observation, true, false, { state: { kind: "connected" } }); expect(result).toMatchObject({ code: 1, restarts: 0, out: [] }); - expect(result.err.join("\n")).toContain("a new connection was made on this machine after this disconnect"); + expect(result.err.join("\n")).toContain("A new connection is in use on this machine"); }); test("a connection that lands during the restart turns the standalone result into exit 1", async () => { const result = await run(live("client"), true, false, { states: [{ kind: "disconnected" }, { kind: "connected" }] }); expect(result).toMatchObject({ code: 1, restarts: 1, out: [] }); - expect(result.err.join("\n")).toContain("a new connection was made on this machine after this disconnect"); + expect(result.err.join("\n")).toContain("A new connection is in use on this machine"); }); // Coordinator review round 4: when a client still answers after a refused or failed restart, @@ -96,7 +96,7 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { const result = await run(live("client"), false, false, { after: live("client", 5555), captureAfter: newer, states: [{ kind: "disconnected" }, { kind: "connected" }] }); expect(result.code).toBe(1); const text = result.err.join("\n"); - expect(text).toContain("a new connection is in use on this machine"); + expect(text).toContain("A new connection is in use on this machine"); expect(text).toContain("ocx status"); expect(text).not.toContain("Run on this machine: ocx restart"); }); @@ -105,7 +105,7 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { const result = await run(live("client"), false, false, { after: live("client", 5555), captureAfter: newer }); expect(result.code).toBe(1); const text = result.err.join("\n"); - expect(text).toContain("a different client runtime (pid 5555) answers now"); + expect(text).toContain("A different client runtime (pid 5555) answers now"); expect(text).not.toContain("Run on this machine: ocx restart"); }); @@ -119,8 +119,8 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { test("nothing answering after the restart while a new connection exists reports that connection", async () => { const result = await run(live("client"), true, false, { after: { kind: "absent", live: null }, states: [{ kind: "disconnected" }, { kind: "connected" }] }); expect(result.code).toBe(1); - expect(result.err.join("\n")).toContain("a new connection is in use on this machine"); - expect(result.err.join("\n")).not.toContain("could not confirm a standalone runtime"); + expect(result.err.join("\n")).toContain("A new connection is in use on this machine"); + expect(result.err.join("\n")).not.toMatch(/ocx restart/); }); test.each([ @@ -133,6 +133,43 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(result.err.join("\n")).toContain("could not confirm a standalone runtime"); }); + // Coordinator review round 5: the early exits (inconclusive liveness, uncapturable record) read the + // state too, and a connection that landed gets inspection advice, never a restart. + test.each([ + ["inconclusive liveness", { kind: "unknown", live: null } as Observation, undefined], + ["an uncapturable client runtime", live("client"), null], + ] as const)("%s with a connection that landed advises inspection, not a restart", async (_name, observation, capture) => { + const result = await run(observation, true, false, { state: { kind: "connected" }, ...(capture === null ? { capture: null } : {}) }); + expect(result).toMatchObject({ code: 1, restarts: 0 }); + const text = result.err.join("\n"); + expect(text).toContain("A new connection is in use on this machine"); + expect(text).toContain("ocx status"); + expect(text).not.toMatch(/ocx restart/); + }); + + // One rule for every exit-1 branch: a plain restart is advised only while the state is + // disconnected and the captured runtime still answers (or nothing answers at all). + test("no exit-1 branch advises a restart while a connection is in use or the state is unreadable", async () => { + const states: State[] = [{ kind: "connected" }, { kind: "invalid", reason: "unreadable" }, { kind: "mismatched", reason: "mixed" }]; + const newer: RuntimeGeneration = { pid: 5555, port: 10100, attestationSecret: createLocalAttestationSecret() }; + const branches: Array> = []; + for (const state of states) { + branches.push([{ kind: "unknown", live: null }, true, false, { state }]); + branches.push([live("client"), true, false, { state, capture: null }]); + branches.push([live("client"), true, false, { state }]); + branches.push([{ kind: "absent", live: null }, true, false, { state }]); + branches.push([live("standalone"), true, false, { state }]); + for (const after of [live("client"), live("client", 5555), { kind: "absent", live: null } as Observation, { kind: "unknown", live: null } as Observation, live("standalone", 9999)]) { + for (const ok of [true, false]) branches.push([live("client"), ok, false, { states: [{ kind: "disconnected" }, state], after, captureAfter: newer }]); + } + } + for (const args of branches) { + const result = await run(...args); + expect(result.code).toBe(1); + expect(result.err.join("\n")).not.toMatch(/ocx restart/); + } + }); + test("--json keeps stdout to the disconnect document", async () => { const logged: unknown[][] = []; const original = console.log; From 7fd45d3e26508d03c430bdd92255aadfd5ccdde2 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:24:13 +0900 Subject: [PATCH 32/54] fix(link): a missing config.json is not evidence that the served connection ended Final review P2: readClientConnectionState() reads a missing config.json as the default configuration, i.e. disconnected, so the new restart admission could recycle a runtime still serving from memory. readClientConnectionStateWithEvidence() maps that case to invalid; the runtime's restart admission and ocx disconnect's state reads use it. --- src/client/state.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/client/state.ts b/src/client/state.ts index deaaffe1c2f..60b6d2ab6f4 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -1,4 +1,4 @@ -import { lstatSync, readFileSync, unlinkSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir, @@ -82,6 +82,18 @@ function rawTopLevelConfig(): Record | null { } } +/** + * The client state with evidence required for "disconnected": a saved config.json that records + * no client. A missing config.json reads as the default configuration (disconnected) above, which + * proves nothing about a connection a running process still serves, so here it is invalid. + * Used wherever "disconnected" authorizes a restart, a recycle or a successful exit. + */ +export function readClientConnectionStateWithEvidence(): ClientConnectionState { + const state = readClientConnectionState(); + if (state.kind === "disconnected" && !existsSync(getConfigPath())) return { kind: "invalid", reason: "config.json is missing" }; + return state; +} + export function readClientConnectionState(): ClientConnectionState { const raw = rawTopLevelConfig(); const diagnostics = readConfigDiagnostics({ passive: true }); From bb8e3116a5b38f117cf868548b98a554060a3811 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:24:13 +0900 Subject: [PATCH 33/54] fix(link): keep a client runtime its service manager owns but cannot be proven to relaunch Final review P2: a systemd unit started by hand while disabled is not viable, which mapped to no supervisor, so the recycle detached a replacement inside the unit. classifyClientRestartSupervisor separates ownership (OCX_SERVICE or OCX_SERVICE_MANAGED) from relaunch (the service diagnostic): owned but not viable is service-unproven, refused at admission and named in the CLI message; only an unowned process detaches, and standaloneRecycleEnv clears every supervisor marker. ocx disconnect advises the service manager when that is what refused. --- src/cli/disconnect-runtime.ts | 20 ++++-- src/cli/dispatch.ts | 14 ++-- src/cli/restart-failure.ts | 5 +- src/cli/system-restart-client.ts | 5 +- src/client/runtime-restart.ts | 38 +++++++++-- src/client/runtime.ts | 10 +-- tests/cli/disconnect-runtime.test.ts | 24 ++++++- tests/cli/link-apply-restart.test.ts | 6 +- tests/clients/client-runtime-restart.test.ts | 70 +++++++++++++++++++- tests/clients/client-runtime.test.ts | 23 +++++++ 10 files changed, 183 insertions(+), 32 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index cc0c129c173..1f67e293182 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -36,8 +36,11 @@ export interface DisconnectRuntimeDeps { capture: (live: { pid: number | null; port: number }) => RuntimeGeneration | null; /** The durable client state, read right before the restart. */ connectionState: () => DisconnectClientState; - /** `ocx restart` bound to this generation (see {@link bindRestartToRuntime}); true when it completed. */ - restart: (target: RuntimeGeneration) => Promise; + /** + * `ocx restart` bound to this generation (see {@link bindRestartToRuntime}). `refusedBy` names a + * supervisor that refused it: an owning service manager whose relaunch could not be proven. + */ + restart: (target: RuntimeGeneration) => Promise<{ ok: boolean; refusedBy?: "service-unproven" }>; json: boolean; info?: (line: string) => void; error?: (line: string) => void; @@ -67,7 +70,7 @@ export function bindRestartToRuntime( /** What is known about the runtime answering when the advice is given. */ export type AnsweringRuntime = - | { kind: "captured"; pid: number | null } + | { kind: "captured"; pid: number | null; viaServiceManager?: boolean } | { kind: "different"; pid: number | null } | { kind: "unknown" } | { kind: "none" }; @@ -83,10 +86,12 @@ export function recoveryAdvice(state: DisconnectClientState, answering: Answerin return "A new connection is in use on this machine; inspect it with ocx status and ocx connect status and do not restart it. If this machine should leave its Home, run ocx disconnect again."; } if (state.kind !== "disconnected") { - return `The client state is ${state.kind} (${state.reason}); nothing was restarted. Inspect it with ocx connect status and ocx status, fix the client state, then run ocx disconnect again.`; + return `The client state is ${state.kind} (${state.reason}). Inspect it with ocx connect status and ocx status, fix the client state, then run ocx disconnect again.`; } switch (answering.kind) { - case "captured": return `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`; + case "captured": return answering.viaServiceManager + ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its service manager did not allow a restart from here. Restart it through the manager: ocx service restart (check ocx service status).` + : `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`; case "different": return `A different client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} answers now, so it was not restarted; inspect it with ocx status and ocx connect status before restarting anything.`; case "unknown": return "Which runtime answers could not be determined; inspect with ocx status and ocx connect status, and restart a client runtime only while ocx connect status reports disconnected."; case "none": return "No runtime answers; start one with ocx restart (or the desktop app or ocx service start, whichever owns it)."; @@ -125,7 +130,8 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; - try { ok = await deps.restart(target); } finally { console.log = originalLog; } + let refusedBy: "service-unproven" | undefined; + try { ({ ok, refusedBy } = await deps.restart(target)); } finally { console.log = originalLog; } const after = await deps.observe(); if (after.kind === "live" && after.live.role !== "client") { const late = deps.connectionState(); @@ -139,7 +145,7 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) if (after.kind === "live") { const answering = deps.capture(after.live); const who: AnsweringRuntime = !answering ? { kind: "unknown" } - : answering.attestationSecret === target.attestationSecret ? { kind: "captured", pid: after.live.pid } + : answering.attestationSecret === target.attestationSecret ? { kind: "captured", pid: after.live.pid, ...(refusedBy ? { viaServiceManager: true } : {}) } : { kind: "different", pid: after.live.pid }; if (ok && who.kind === "captured") { return fail("The restart completed, but the runtime still answers as a client.", { kind: "unknown" }); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b417f88a144..b9150d3b7e0 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -429,7 +429,7 @@ const commandRunners: Record = { if (code !== 0 || deps.args.includes("--orphaned-token")) return code; const { bindRestartToRuntime, restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); const { observeProxyLiveness } = await import("../server/proxy-liveness"); - const { readClientConnectionState } = await import("../client/state"); + const { readClientConnectionStateWithEvidence } = await import("../client/state"); const { readRuntimePort } = await import("../config/process-state"); const { requestBoundSystemRestart } = await import("./system-restart-client"); return await restartRuntimeAfterDisconnect({ @@ -441,12 +441,16 @@ const commandRunners: Record = { ? { ...record, attestationSecret: record.attestationSecret } : null; }, connectionState: () => { - const state = readClientConnectionState(); + const state = readClientConnectionStateWithEvidence(); return state.kind === "invalid" || state.kind === "mismatched" ? { kind: state.kind, reason: state.reason } : { kind: state.kind }; }, - restart: target => deps.handleProxyRestart(deps.handleRestartStartWhenStopped, undefined, (io, deadlineAt) => - bindRestartToRuntime(io, target, (previous, readRuntime) => - requestBoundSystemRestart(previous as Parameters[0], deadlineAt, { readRuntime: readRuntime as typeof readRuntimePort }))), + restart: async target => { + let refusedBy: "service-unproven" | undefined; + const ok = await deps.handleProxyRestart(deps.handleRestartStartWhenStopped, supervisor => { if (supervisor === "service-unproven") refusedBy = supervisor; }, (io, deadlineAt) => + bindRestartToRuntime(io, target, (previous, readRuntime) => + requestBoundSystemRestart(previous as Parameters[0], deadlineAt, { readRuntime: readRuntime as typeof readRuntimePort }))); + return { ok, ...(refusedBy ? { refusedBy } : {}) }; + }, json: deps.args.includes("--json"), }); }, diff --git a/src/cli/restart-failure.ts b/src/cli/restart-failure.ts index a086a753199..89fb5526077 100644 --- a/src/cli/restart-failure.ts +++ b/src/cli/restart-failure.ts @@ -1,12 +1,13 @@ import type { ProxyRestartResult } from "./tray-proxy"; -export type RestartSupervisor = "desktop" | "service"; +export type RestartSupervisor = "desktop" | "service" | "service-unproven"; /** Only the bound client's explicit refusal establishes a supervisor for sync. */ export function supervisedRestartRefusal(result: ProxyRestartResult): RestartSupervisor | null { if (result.ok || result.phase !== "request" || !(result.error instanceof Error)) return null; if (result.error.message === "restart_supervised_desktop") return "desktop"; if (result.error.message === "restart_supervised_service") return "service"; + if (result.error.message === "restart_supervised_service-unproven") return "service-unproven"; return null; } @@ -35,6 +36,8 @@ export function reportRestartFailure(result: Extract { +async function supervisedRefusal(response: Response): Promise<"service" | "desktop" | "service-unproven" | null> { try { const read = await readBoundedResponseBytes(response, { maxBytes: 1024, signal: AbortSignal.timeout(SYSTEM_RESTART_REQUEST_TIMEOUT_MS) }); if (read.oversized) return null; const body = JSON.parse(new TextDecoder().decode(read.bytes)) as { error?: { code?: unknown; supervisor?: unknown } } | null; if (body?.error?.code !== RESTART_SUPERVISED_ERROR) return null; - return body.error.supervisor === "service" || body.error.supervisor === "desktop" ? body.error.supervisor : null; + const supervisor = body.error.supervisor; + return supervisor === "service" || supervisor === "desktop" || supervisor === "service-unproven" ? supervisor : null; } catch { return null; } diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index 6aeaab119aa..d79f81cd964 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -31,7 +31,12 @@ import { sameClientConnectionOwner, type readClientConnectionState } from "./sta type ClientConnectionState = ReturnType; -export type ClientRestartSupervisor = "service" | "desktop"; +/** + * `service-unproven`: a service manager launched and owns this process (OCX_SERVICE or + * OCX_SERVICE_MANAGED), but its relaunch cannot be proven (for example a systemd unit started by + * hand while disabled). Neither exiting for it nor detaching from it is safe, so the restart is refused. + */ +export type ClientRestartSupervisor = "service" | "desktop" | "service-unproven"; export type ClientRestartAdmission = | { kind: "accepted"; alreadyRestarting: boolean } @@ -110,6 +115,11 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar async request() { if (io.stopping?.()) return { kind: "stopping" }; if (io.recycle && io.connectionChanged?.()) { + if (flight) return { kind: "accepted", alreadyRestarting: true }; + // An owner that cannot be proven to relaunch this process keeps it, connection ended or not. + const owner = await io.supervisor(); + if (io.stopping?.()) return { kind: "stopping" }; + if (owner === "service-unproven") return { kind: "supervised", supervisor: owner }; if (flight) return { kind: "accepted", alreadyRestarting: true }; const recycle = io.recycle, changed = io.connectionChanged; // Let the 202 reach the CLI before the listener closes, then confirm the connection is @@ -117,6 +127,7 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar // The recheck is the last step before the recycle, after the (asynchronous) supervisor lookup. flight = sleep(200).then(async () => { const supervisor = await io.supervisor(); + if (supervisor === "service-unproven") { warn("[client] restart: the service manager's relaunch could not be proven; this runtime keeps serving"); return; } if (changed()) await recycle(supervisor); }).finally(() => { flight = null; }); return { kind: "accepted", alreadyRestarting: false }; @@ -136,11 +147,24 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar /** Production supervision check: the desktop app, then a service that will really relaunch us. */ export async function clientRestartSupervisor(): Promise { - if (isDesktopSupervised()) return "desktop"; - if (process.env.OCX_SERVICE !== "1") return null; - // Same viability test as the standalone restart: stale service assets would not relaunch. - const { isServiceViable } = await import("../service/orchestration"); - return isServiceViable() ? "service" : null; + const { diagnoseService } = await import("../service/diagnostics"); + return classifyClientRestartSupervisor({ desktop: isDesktopSupervised(), env: process.env, diagnose: diagnoseService }); +} + +/** + * Ownership and relaunch are separate facts. A manager owns this process when it launched it + * (OCX_SERVICE or OCX_SERVICE_MANAGED); only an owned process whose service diagnostic is viable + * may exit for the manager to relaunch it. Owned but not viable is `service-unproven` and keeps + * the runtime; only a process no manager owns may start a detached replacement. + */ +export function classifyClientRestartSupervisor(input: { + desktop: boolean; + env: NodeJS.ProcessEnv; + diagnose: () => { viable: boolean }; +}): ClientRestartSupervisor | null { + if (input.desktop) return "desktop"; + if (input.env.OCX_SERVICE !== "1" && input.env.OCX_SERVICE_MANAGED !== "1") return null; + return input.diagnose().viable ? "service" : "service-unproven"; } /** @@ -164,6 +188,8 @@ export function clientRestartRollbackAction( * refusing on it instead of exiting a runtime whose link may still work. */ export function clientConnectionEnded(served: OcxClientConnectionConfig, current: ClientConnectionState): boolean { + // Callers pass readClientConnectionStateWithEvidence(): "disconnected" then means a saved config + // with no client, never a missing file. if (current.kind === "disconnected") return true; return current.kind === "connected" && clientRestartRollbackAction(served, current) === "recycle"; } diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 482de6cc189..bee9d5a5ed3 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -28,7 +28,7 @@ import { clientInitiatedLinkReadiness, createHomeInitiatedLinkReadiness } from " import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tunnel"; import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state"; import { startMachineListener, type MachineListenerDeps } from "./machine-listener"; -import { isLinkConnection, readClientConnectionState } from "./state"; +import { isLinkConnection, readClientConnectionState, readClientConnectionStateWithEvidence } from "./state"; import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; @@ -112,10 +112,12 @@ export function standaloneRecycleEnv( disconnectedTokenFingerprint: string, ): NodeJS.ProcessEnv { const childEnv = { ...env }; - // A detached replacement is not the desktop app's child, nor a service's: a process that outlived - // its service registration must not hand the stale marker on. + // A detached replacement is nobody's child: not the desktop app's, not a service's, not a Windows + // wrapper's. It must carry no supervisor marker it would otherwise misread as its own. delete childEnv[DESKTOP_SUPERVISED_ENV]; delete childEnv.OCX_SERVICE; + delete childEnv.OCX_SERVICE_MANAGED; + delete childEnv.OCX_WINDOWS_WRAPPER_PROTOCOL; const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim(); if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) { // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can @@ -438,7 +440,7 @@ export async function startClientRuntime( const restartController: ClientRestartController = createClientRestartController({ stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, - connectionChanged: () => clientConnectionEnded(state.value, readClientConnectionState()), + connectionChanged: () => clientConnectionEnded(state.value, readClientConnectionStateWithEvidence()), recycle: supervisor => recycleStandalone(state.value.tokenFingerprint, { isDesktopSupervised: () => supervisor === "desktop", serviceSupervised: () => supervisor === "service", diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 7d6dd8e80f8..461298fb659 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -10,7 +10,7 @@ type State = ReturnType; const live = (role?: string, pid = 4321): Observation => ({ kind: "live", live: { pid, port: 10100, ...(role ? { role } : {}) } }); const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret() }; -function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null } = {}) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; const bound: RuntimeGeneration[] = []; @@ -18,7 +18,7 @@ function run(observation: Observation, restartOk: boolean, json = false, extra: observe: async () => (restarts > 0 ? extra.after ?? live("standalone", 9999) : observation), capture: () => (restarts > 0 && extra.captureAfter !== undefined ? extra.captureAfter : extra.capture === undefined ? generation : extra.capture), connectionState: () => extra.states?.shift() ?? extra.state ?? { kind: "disconnected" }, - restart: async target => { restarts += 1; bound.push(target); return restartOk; }, + restart: async target => { restarts += 1; bound.push(target); return { ok: restartOk, ...(extra.refusedBy ? { refusedBy: extra.refusedBy } : {}) }; }, json, info: line => out.push(line), error: line => err.push(line), @@ -170,6 +170,24 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { } }); + // Final review: a restart its service manager refused (relaunch unproven) is redone through that + // manager; a plain ocx restart would only be refused again. + test("a restart refused by an unproven service manager advises the manager", async () => { + const result = await run(live("client"), false, false, { after: live("client"), refusedBy: "service-unproven" }); + expect(result.code).toBe(1); + const text = result.err.join("\n"); + expect(text).toContain("ocx service restart"); + expect(text).not.toContain("Run on this machine: ocx restart"); + }); + + // P3: after a completed restart, an unreadable state must not also claim nothing was restarted. + test("a completed restart followed by an unreadable state does not say nothing was restarted", async () => { + const result = await run(live("client"), true, false, { states: [{ kind: "disconnected" }, { kind: "invalid", reason: "unreadable" }] }); + expect(result.code).toBe(1); + expect(result.err.join("\n")).toContain("The client runtime was restarted"); + expect(result.err.join("\n")).not.toContain("nothing was restarted"); + }); + test("--json keeps stdout to the disconnect document", async () => { const logged: unknown[][] = []; const original = console.log; @@ -180,7 +198,7 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { observe: async () => live(restarted ? "standalone" : "client", restarted ? 9999 : 4321), capture: () => generation, connectionState: () => ({ kind: "disconnected" }), - restart: async () => { console.log("restart progress"); restarted = true; return true; }, + restart: async () => { console.log("restart progress"); restarted = true; return { ok: true }; }, json: true, error: () => {}, }); diff --git a/tests/cli/link-apply-restart.test.ts b/tests/cli/link-apply-restart.test.ts index fb384be7781..55f847a5d8e 100644 --- a/tests/cli/link-apply-restart.test.ts +++ b/tests/cli/link-apply-restart.test.ts @@ -151,7 +151,7 @@ function clientHealthDeps(postResponse: Response) { describe("ocx restart against a client runtime", () => { test("only explicit request-phase supervision refusals qualify for deferral", () => { const notices: string[] = []; - for (const supervisor of ["desktop", "service"] as const) { + for (const supervisor of ["desktop", "service", "service-unproven"] as const) { const refused = { ok: false as const, phase: "request" as const, error: new Error(`restart_supervised_${supervisor}`) }; expect(supervisedRestartRefusal(refused)).toBe(supervisor); expect(deferSupervisedRestart(refused, value => notices.push(value))).toBe(true); @@ -160,7 +160,7 @@ describe("ocx restart against a client runtime", () => { const uncertain = { ok: false as const, phase: "request" as const, error: new Error("restart_request_outcome_unknown") }; expect(supervisedRestartRefusal(uncertain)).toBeNull(); expect(deferSupervisedRestart(uncertain, value => notices.push(value))).toBe(false); - expect(notices).toEqual(["desktop", "service"]); + expect(notices).toEqual(["desktop", "service", "service-unproven"]); expect(supervisedRestartRefusal({ ok: false, phase: "request", error: new Error("restart_supervised_other") })).toBeNull(); expect(supervisedRestartRefusal({ ok: true, mode: "skipped" })).toBeNull(); }); @@ -193,12 +193,14 @@ describe("ocx restart against a client runtime", () => { try { reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_service") }); reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_desktop") }); + reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_service-unproven") }); } finally { console.error = original; } expect(lines).toEqual([ "Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.", "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it while its connection is current: use Quit in the OpenCodex tray menu, then reopen it. After `ocx disconnect`, `ocx restart` hands it to the app.", + "Restart not performed; the running client runtime was kept. A service manager started it (systemd opencodex-proxy.service, launchd com.opencodex.proxy or the Windows OpenCodex service), but it is not enabled or its relaunch could not be verified: restart it through the manager with 'ocx service restart', or check 'ocx service status'.", ]); }); }); diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 18f79d65bdc..7678e3cf904 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -11,7 +11,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; -import { clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; +import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; +import { readClientConnectionStateWithEvidence } from "../../src/client/state"; +import { deriveLaunchdServiceDiagnostic, deriveSystemdServiceDiagnostic, deriveWindowsServiceDiagnostic } from "../../src/service/diagnostics"; import { createLocalAttestationChallenge, createLocalAttestationSecret, @@ -280,11 +282,12 @@ describe("client restart controller", () => { let ended = true; let releaseLookup!: (value: "desktop") => void; let lookupEntered!: () => void; + let lookups = 0; const entered = new Promise(resolve => { lookupEntered = resolve; }); const recycled: unknown[] = []; const { io } = fakeIo({ connectionChanged: () => ended, - supervisor: () => { lookupEntered(); return new Promise(resolve => { releaseLookup = resolve; }); }, + supervisor: () => { lookups += 1; if (lookups === 1) return Promise.resolve("desktop" as const); lookupEntered(); return new Promise(resolve => { releaseLookup = resolve; }); }, recycle: async verdict => { recycled.push(verdict); }, }); const controller = createClientRestartController(io); @@ -296,6 +299,17 @@ describe("client restart controller", () => { expect(recycled).toEqual([]); }); + // Final review: a manager that owns this process but cannot be proven to relaunch it must keep + // the runtime; refused at admission, nothing recycled or detached. + test("an owned but unproven service refuses even an ended connection, and recycles nothing", async () => { + const recycled: unknown[] = []; + const { io } = fakeIo({ supervisor: async () => "service-unproven", connectionChanged: () => true, recycle: async verdict => { recycled.push(verdict); } }); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "supervised", supervisor: "service-unproven" }); + await controller.settled(); + expect(recycled).toEqual([]); + }); + test("the recycle receives the supervisor verdict", async () => { for (const supervisor of ["desktop", "service", null] as const) { const seen: unknown[] = []; @@ -337,6 +351,58 @@ describe("client restart controller", () => { }); }); +describe("who supervises a client restart (real service diagnostics)", () => { + const owned = { OCX_SERVICE: "1" } as NodeJS.ProcessEnv; + const systemd = (enabled: boolean, running: boolean) => deriveSystemdServiceDiagnostic({ installed: true, enabled, running, stale: false, diagnostics: "test" }); + test.each([ + ["systemd running but disabled (started by hand)", systemd(false, true), "service-unproven"], + ["systemd enabled and running", systemd(true, true), "service"], + ["launchd job installed but not loaded", deriveLaunchdServiceDiagnostic({ installed: true, stale: false, load: { state: "not-loaded" } as never, diagnostics: "test" }), "service-unproven"], + ["launchd job loaded from the current plist", deriveLaunchdServiceDiagnostic({ installed: true, stale: false, load: { state: "loaded-current" }, diagnostics: "test" }), "service"], + ["Windows native service stopped", deriveWindowsServiceDiagnostic({ schedulerXml: "", schedulerExpectedUserId: null, schedulerAssetsPresent: false, nativeStatus: "stopped", recordedBackend: "native", staleBakedPaths: false, nativeRepairAssetsOnly: false, diagnostics: "test" }), "service-unproven"], + ] as const)("%s", (_name, diagnostic, expected) => { + expect(classifyClientRestartSupervisor({ desktop: false, env: owned, diagnose: () => diagnostic })).toBe(expected); + }); + + test("OCX_SERVICE_MANAGED alone marks the process as manager-owned", () => { + expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE_MANAGED: "1" }, diagnose: () => systemd(false, true) })).toBe("service-unproven"); + }); + + test("no manager marker means no supervisor, whatever the service diagnostic says", () => { + expect(classifyClientRestartSupervisor({ desktop: false, env: {}, diagnose: () => systemd(true, true) })).toBeNull(); + }); + + test("the desktop app wins", () => { + expect(classifyClientRestartSupervisor({ desktop: true, env: owned, diagnose: () => systemd(false, true) })).toBe("desktop"); + }); +}); + +// Final review: a missing config.json reads as the default configuration, which is not evidence that +// the served connection ended. The real reader is used here, with no injected state. +describe("connection-ended evidence from the real state reader", () => { + const link = { ...connection, transport: "link" as const, link: { linkId: `lnk_${"b".repeat(16)}`, tunnelPort: 23457 } }; + test("a missing config.json is not proof the connection ended", () => { + const empty = mkdtempSync(join(tmpdir(), "ocx-client-restart-missing-config-")); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = empty; + try { + const state = readClientConnectionStateWithEvidence(); + expect(state).toEqual({ kind: "invalid", reason: "config.json is missing" }); + expect(clientConnectionEnded(link, state)).toBe(false); + } finally { + process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(empty); + } + }); + + test("a saved config with no client is proof", () => { + // beforeEach wrote a standalone config.json into this test's home. + const state = readClientConnectionStateWithEvidence(); + expect(state).toEqual({ kind: "disconnected" }); + expect(clientConnectionEnded(link, state)).toBe(true); + }); +}); + describe("rollback after a failed replacement", () => { const link = { ...connection, transport: "link" as const, link: { linkId: `lnk_${"b".repeat(16)}`, tunnelPort: 23457 } }; // Only a proven end of the served connection may recycle a supervised runtime; an unreadable or diff --git a/tests/clients/client-runtime.test.ts b/tests/clients/client-runtime.test.ts index f28d951f618..44126a5104e 100644 --- a/tests/clients/client-runtime.test.ts +++ b/tests/clients/client-runtime.test.ts @@ -193,6 +193,29 @@ describe("standalone recycle", () => { }); }); + // Final review: an authorized detached replacement carries no supervisor marker at all. + test("a detached replacement's environment carries no supervisor marker", async () => { + await withServiceMarker("1", async () => { + const markers = ["OCX_SERVICE", "OCX_SERVICE_MANAGED", "OCX_DESKTOP_SUPERVISED", "OCX_WINDOWS_WRAPPER_PROTOCOL"]; + const previous = Object.fromEntries(markers.map(name => [name, process.env[name]])); + for (const name of markers) process.env[name] = "1"; + let env: NodeJS.ProcessEnv | undefined; + try { + await recycleStandalone(serviceApiTokenFingerprint("disconnected-hub-token"), { + configuredPort: () => 10456, + isDesktopSupervised: () => false, + serviceSupervised: () => false, + spawnReplacement: async request => { env = request.env; }, + exitProcess: () => {}, + }); + } finally { + for (const name of markers) { if (previous[name] === undefined) delete process.env[name]; else process.env[name] = previous[name]; } + } + expect(env).toBeDefined(); + for (const name of markers) expect(env?.[name]).toBeUndefined(); + }); + }); + test("a supervised client still exits 1 without spawning", async () => { await withServiceMarker("1", async () => { const calls: string[] = []; From cff29080086ff33eaf865925ec6560657720699c Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:24:13 +0900 Subject: [PATCH 34/54] docs(link): structure note for restart evidence and service ownership --- structure/remote-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/remote-link.md b/structure/remote-link.md index 4a4e7e9da17..826de43ffc7 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted under every owner and hands off to the standalone recycle (desktop exit 75, service exit 1, otherwise a spawned replacement). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`): a saved config.json with no client or with another connection; a missing config.json is invalid, never disconnected. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): a process a service manager launched (OCX_SERVICE or OCX_SERVICE_MANAGED) whose service diagnostic is not viable, such as a systemd unit started by hand while disabled, is `service-unproven` and keeps serving, refused at admission even when its connection ended; only a process no manager owns detaches. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From d8b94c3a9c5470a8ffb06e1bd9cc562fbf509f84 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:36:07 +0900 Subject: [PATCH 35/54] fix(link): decide disconnected-state evidence from one valid snapshot of config.json Final review round 2: the evidence reader still returned disconnected for a saved file that fails validation, and paired raw data and diagnostics from two reads, so a connect committed between them could leave old bytes deciding. It now decides from one readConfigFileSnapshot read: disconnected only for a valid saved file with no client; missing, unreadable, invalid or fallback is invalid with its reason. Both readers share one classification. --- src/client/state.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/client/state.ts b/src/client/state.ts index 60b6d2ab6f4..132475860f3 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs"; +import { lstatSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir, @@ -11,6 +11,7 @@ import { withConfigMutationLockSync, } from "../config"; import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; +import { readConfigFileSnapshot } from "../config/diagnostics"; import type { OcxClientConnectionConfig } from "../types"; import { inspectRemoteDesktopStore, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store"; import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "./lifecycle-lock"; @@ -83,15 +84,24 @@ function rawTopLevelConfig(): Record | null { } /** - * The client state with evidence required for "disconnected": a saved config.json that records - * no client. A missing config.json reads as the default configuration (disconnected) above, which - * proves nothing about a connection a running process still serves, so here it is invalid. - * Used wherever "disconnected" authorizes a restart, a recycle or a successful exit. + * The client state with evidence required for "disconnected", decided from ONE read of the saved + * file (readConfigFileSnapshot): the bytes and their validation belong together, so a connect that + * commits meanwhile cannot pair old bytes with a validation of new ones. "disconnected" means a + * saved config.json that validates and records no client; a missing, unreadable or invalid file is + * invalid with its reason. Used wherever "disconnected" authorizes a restart, a recycle or exit 0. */ export function readClientConnectionStateWithEvidence(): ClientConnectionState { - const state = readClientConnectionState(); - if (state.kind === "disconnected" && !existsSync(getConfigPath())) return { kind: "invalid", reason: "config.json is missing" }; - return state; + const snapshot = readConfigFileSnapshot({ passive: true }); + if (snapshot.raw === undefined) { + return { kind: "invalid", reason: snapshot.diagnostics.source === "default" ? "config.json is missing" : "config.json is unreadable" }; + } + if (snapshot.diagnostics.source !== "file") { + return { kind: "invalid", reason: `config.json failed validation (${snapshot.diagnostics.error ?? "invalid"})` }; + } + let parsed: unknown; + try { parsed = JSON.parse(snapshot.raw.replace(/^\uFEFF/, "")); } catch { return { kind: "invalid", reason: "config.json is unreadable" }; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "invalid", reason: "config.json is not an object" }; + return classifyClientConnection(parsed as Record, snapshot.diagnostics); } export function readClientConnectionState(): ClientConnectionState { @@ -102,6 +112,14 @@ export function readClientConnectionState(): ClientConnectionState { ? { kind: "disconnected" } : { kind: "invalid", reason: "config.json is missing or unreadable" }; } + return classifyClientConnection(raw, diagnostics); +} + +/** The client verdict for one saved top-level object and the diagnostics of the same bytes. */ +function classifyClientConnection( + raw: Record, + diagnostics: Pick, "config" | "warnings">, +): ClientConnectionState { const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; const role = raw.runtimeRole; if (role !== undefined && role !== "standalone" && role !== "hub" && role !== "client") { From 1469958feec98fee8cce5a9793d0bef0daa11ee2 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:36:07 +0900 Subject: [PATCH 36/54] fix(link): restart ownership needs manager evidence and a loaded job, not a bare flag Final review round 2: OCX_SERVICE=1 alone is also what ocx claude and ocx opencode give their own proxies, so their runtimes were refused as service-owned (a regression); and launchd's unknown load state counted as proof of relaunch. Manager-launched evidence is now isSupervisedServiceChild; relaunch needs the diagnostic to be viable and running. A bare OCX_SERVICE=1 is a companion when no service is installed and the new owner-unproven refusal when one is, with advice naming both owners. claudeProxyStartEnv is extracted (behavior unchanged) so the companion's real environment is tested. --- src/cli/claude.ts | 11 ++- src/cli/disconnect-runtime.ts | 12 +-- src/cli/dispatch.ts | 4 +- src/cli/restart-failure.ts | 5 +- src/cli/system-restart-client.ts | 4 +- src/client/runtime-restart.ts | 31 +++++-- tests/cli/cli-dispatch.test.ts | 5 +- tests/cli/disconnect-runtime.test.ts | 11 ++- tests/cli/link-apply-restart.test.ts | 6 +- tests/clients/client-runtime-restart.test.ts | 91 +++++++++++++++++++- 10 files changed, 158 insertions(+), 22 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 56de313bc04..ed7d1686f06 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -38,6 +38,15 @@ import { inspectDesktopFirstParty } from "../claude/desktop-first-party"; import { isClaudeInterceptProxyUrl, type ClaudeInterceptSettingsState } from "../claude/intercept/settings"; import { withoutSiblingMarker } from "../codex/sibling-start"; +/** + * The environment of the detached `ocx start` that `ocx claude` launches: an ordinary owner. It + * carries OCX_SERVICE=1 but no service manager's marker, which is how a restart tells it apart from + * a manager-launched process (src/client/runtime-restart.ts classifyClientRestartSupervisor). + */ +export function claudeProxyStartEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return withProcessRuntimeProvenance(withoutSiblingMarker({ ...base, OCX_SERVICE: "1" })); +} + export interface ClaudeLaunchEnv { [key: string]: string | undefined; } @@ -532,7 +541,7 @@ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Pr stdio: "ignore", windowsHide: true, // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. - env: withProcessRuntimeProvenance(withoutSiblingMarker({ ...process.env, OCX_SERVICE: "1" })), + env: claudeProxyStartEnv(), }); child.unref(); const deadline = Date.now() + 8_000; diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index 1f67e293182..f91792397bc 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -40,7 +40,7 @@ export interface DisconnectRuntimeDeps { * `ocx restart` bound to this generation (see {@link bindRestartToRuntime}). `refusedBy` names a * supervisor that refused it: an owning service manager whose relaunch could not be proven. */ - restart: (target: RuntimeGeneration) => Promise<{ ok: boolean; refusedBy?: "service-unproven" }>; + restart: (target: RuntimeGeneration) => Promise<{ ok: boolean; refusedBy?: "service-unproven" | "owner-unproven" }>; json: boolean; info?: (line: string) => void; error?: (line: string) => void; @@ -70,7 +70,7 @@ export function bindRestartToRuntime( /** What is known about the runtime answering when the advice is given. */ export type AnsweringRuntime = - | { kind: "captured"; pid: number | null; viaServiceManager?: boolean } + | { kind: "captured"; pid: number | null; refusedBy?: "service-unproven" | "owner-unproven" } | { kind: "different"; pid: number | null } | { kind: "unknown" } | { kind: "none" }; @@ -89,7 +89,9 @@ export function recoveryAdvice(state: DisconnectClientState, answering: Answerin return `The client state is ${state.kind} (${state.reason}). Inspect it with ocx connect status and ocx status, fix the client state, then run ocx disconnect again.`; } switch (answering.kind) { - case "captured": return answering.viaServiceManager + case "captured": return answering.refusedBy === "owner-unproven" + ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its owner could not be proven: it is either a service registered before the service manager\'s marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run ocx service repair and then ocx service restart; if it is a companion, end that ocx claude or ocx opencode session and start it again.` + : answering.refusedBy ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its service manager did not allow a restart from here. Restart it through the manager: ocx service restart (check ocx service status).` : `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`; case "different": return `A different client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} answers now, so it was not restarted; inspect it with ocx status and ocx connect status before restarting anything.`; @@ -130,7 +132,7 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; - let refusedBy: "service-unproven" | undefined; + let refusedBy: "service-unproven" | "owner-unproven" | undefined; try { ({ ok, refusedBy } = await deps.restart(target)); } finally { console.log = originalLog; } const after = await deps.observe(); if (after.kind === "live" && after.live.role !== "client") { @@ -145,7 +147,7 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) if (after.kind === "live") { const answering = deps.capture(after.live); const who: AnsweringRuntime = !answering ? { kind: "unknown" } - : answering.attestationSecret === target.attestationSecret ? { kind: "captured", pid: after.live.pid, ...(refusedBy ? { viaServiceManager: true } : {}) } + : answering.attestationSecret === target.attestationSecret ? { kind: "captured", pid: after.live.pid, ...(refusedBy ? { refusedBy } : {}) } : { kind: "different", pid: after.live.pid }; if (ok && who.kind === "captured") { return fail("The restart completed, but the runtime still answers as a client.", { kind: "unknown" }); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b9150d3b7e0..69c1dd63602 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -445,8 +445,8 @@ const commandRunners: Record = { return state.kind === "invalid" || state.kind === "mismatched" ? { kind: state.kind, reason: state.reason } : { kind: state.kind }; }, restart: async target => { - let refusedBy: "service-unproven" | undefined; - const ok = await deps.handleProxyRestart(deps.handleRestartStartWhenStopped, supervisor => { if (supervisor === "service-unproven") refusedBy = supervisor; }, (io, deadlineAt) => + let refusedBy: "service-unproven" | "owner-unproven" | undefined; + const ok = await deps.handleProxyRestart(deps.handleRestartStartWhenStopped, supervisor => { if (supervisor === "service-unproven" || supervisor === "owner-unproven") refusedBy = supervisor; }, (io, deadlineAt) => bindRestartToRuntime(io, target, (previous, readRuntime) => requestBoundSystemRestart(previous as Parameters[0], deadlineAt, { readRuntime: readRuntime as typeof readRuntimePort }))); return { ok, ...(refusedBy ? { refusedBy } : {}) }; diff --git a/src/cli/restart-failure.ts b/src/cli/restart-failure.ts index 89fb5526077..6113ba5a01e 100644 --- a/src/cli/restart-failure.ts +++ b/src/cli/restart-failure.ts @@ -1,6 +1,6 @@ import type { ProxyRestartResult } from "./tray-proxy"; -export type RestartSupervisor = "desktop" | "service" | "service-unproven"; +export type RestartSupervisor = "desktop" | "service" | "service-unproven" | "owner-unproven"; /** Only the bound client's explicit refusal establishes a supervisor for sync. */ export function supervisedRestartRefusal(result: ProxyRestartResult): RestartSupervisor | null { @@ -8,6 +8,7 @@ export function supervisedRestartRefusal(result: ProxyRestartResult): RestartSup if (result.error.message === "restart_supervised_desktop") return "desktop"; if (result.error.message === "restart_supervised_service") return "service"; if (result.error.message === "restart_supervised_service-unproven") return "service-unproven"; + if (result.error.message === "restart_supervised_owner-unproven") return "owner-unproven"; return null; } @@ -38,6 +39,8 @@ export function reportRestartFailure(result: Extract { +async function supervisedRefusal(response: Response): Promise<"service" | "desktop" | "service-unproven" | "owner-unproven" | null> { try { const read = await readBoundedResponseBytes(response, { maxBytes: 1024, signal: AbortSignal.timeout(SYSTEM_RESTART_REQUEST_TIMEOUT_MS) }); if (read.oversized) return null; const body = JSON.parse(new TextDecoder().decode(read.bytes)) as { error?: { code?: unknown; supervisor?: unknown } } | null; if (body?.error?.code !== RESTART_SUPERVISED_ERROR) return null; const supervisor = body.error.supervisor; - return supervisor === "service" || supervisor === "desktop" || supervisor === "service-unproven" ? supervisor : null; + return supervisor === "service" || supervisor === "desktop" || supervisor === "service-unproven" || supervisor === "owner-unproven" ? supervisor : null; } catch { return null; } diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index d79f81cd964..a322b4ea8ba 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -27,6 +27,7 @@ import { isDesktopSupervised, } from "../lib/system-restart-contract"; import type { OcxClientConnectionConfig } from "../types"; +import { isSupervisedServiceChild } from "../service/service-child-ownership"; import { sameClientConnectionOwner, type readClientConnectionState } from "./state"; type ClientConnectionState = ReturnType; @@ -36,7 +37,12 @@ type ClientConnectionState = ReturnType; * OCX_SERVICE_MANAGED), but its relaunch cannot be proven (for example a systemd unit started by * hand while disabled). Neither exiting for it nor detaching from it is safe, so the restart is refused. */ -export type ClientRestartSupervisor = "service" | "desktop" | "service-unproven"; +export type ClientRestartSupervisor = "service" | "desktop" | "service-unproven" | "owner-unproven"; + +/** Verdicts that keep the runtime: an owner exists, but exiting for it or detaching is not proven safe. */ +export function restartRefusedByOwner(supervisor: ClientRestartSupervisor | null): supervisor is "service-unproven" | "owner-unproven" { + return supervisor === "service-unproven" || supervisor === "owner-unproven"; +} export type ClientRestartAdmission = | { kind: "accepted"; alreadyRestarting: boolean } @@ -119,7 +125,7 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar // An owner that cannot be proven to relaunch this process keeps it, connection ended or not. const owner = await io.supervisor(); if (io.stopping?.()) return { kind: "stopping" }; - if (owner === "service-unproven") return { kind: "supervised", supervisor: owner }; + if (restartRefusedByOwner(owner)) return { kind: "supervised", supervisor: owner }; if (flight) return { kind: "accepted", alreadyRestarting: true }; const recycle = io.recycle, changed = io.connectionChanged; // Let the 202 reach the CLI before the listener closes, then confirm the connection is @@ -127,7 +133,7 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar // The recheck is the last step before the recycle, after the (asynchronous) supervisor lookup. flight = sleep(200).then(async () => { const supervisor = await io.supervisor(); - if (supervisor === "service-unproven") { warn("[client] restart: the service manager's relaunch could not be proven; this runtime keeps serving"); return; } + if (restartRefusedByOwner(supervisor)) { warn("[client] restart: this runtime's owner could not be proven to relaunch it; it keeps serving"); return; } if (changed()) await recycle(supervisor); }).finally(() => { flight = null; }); return { kind: "accepted", alreadyRestarting: false }; @@ -160,11 +166,24 @@ export async function clientRestartSupervisor(): Promise { viable: boolean }; + diagnose: () => { installed: boolean; viable: boolean; running: boolean }; + /** Manager-launched evidence; defaults to src/service/service-child-ownership.ts. */ + managed?: (env: NodeJS.ProcessEnv) => boolean; }): ClientRestartSupervisor | null { if (input.desktop) return "desktop"; - if (input.env.OCX_SERVICE !== "1" && input.env.OCX_SERVICE_MANAGED !== "1") return null; - return input.diagnose().viable ? "service" : "service-unproven"; + // The managed marker (launchd, systemd, WinSW), the Windows wrapper's protocol marker, or a + // legacy Windows wrapper parent. A bare OCX_SERVICE=1 is also what ocx claude and ocx opencode + // give their own proxies, so it alone proves no manager. + if (!(input.managed ?? isSupervisedServiceChild)(input.env)) { + if (input.env.OCX_SERVICE !== "1") return null; + // Bare marker: a companion when no service is installed; with one installed it may also be a + // service registered before the managed marker existed, which nothing here can tell apart. + return input.diagnose().installed ? "owner-unproven" : null; + } + // Relaunch needs positive evidence: viable AND running (launchd's viable also covers an + // unknown load state for its other callers; running there means loaded). + const diagnostic = input.diagnose(); + return diagnostic.viable && diagnostic.running ? "service" : "service-unproven"; } /** diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 3a36a246f2d..64d0adb12b0 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -742,7 +742,10 @@ describe("a sibling start leaves shared client routing to the live owner", () => // updater's restart. A stray marker would mark them before any probe. const opencodeStartEnv = "env: withProcessRuntimeProvenance(opencodeProxyStartEnv(withoutSiblingMarker(process.env)) as NodeJS.ProcessEnv),"; for (const [path, env] of [ - ["src/cli/claude.ts", 'env: withProcessRuntimeProvenance(withoutSiblingMarker({ ...process.env, OCX_SERVICE: "1" })),'], + // ocx claude builds its proxy's environment in claudeProxyStartEnv (also read by the restart's + // owner classification) and spawns with it. + ["src/cli/claude.ts", 'return withProcessRuntimeProvenance(withoutSiblingMarker({ ...base, OCX_SERVICE: "1" }));'], + ["src/cli/claude.ts", "env: claudeProxyStartEnv(),"], ["src/cli/opencode.ts", opencodeStartEnv], ["src/cli/minimax.ts", opencodeStartEnv], ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"], diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 461298fb659..8a4db5cf160 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -10,7 +10,7 @@ type State = ReturnType; const live = (role?: string, pid = 4321): Observation => ({ kind: "live", live: { pid, port: 10100, ...(role ? { role } : {}) } }); const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret() }; -function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" } = {}) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" | "owner-unproven" } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; const bound: RuntimeGeneration[] = []; @@ -180,6 +180,15 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(text).not.toContain("Run on this machine: ocx restart"); }); + test("a restart refused because its owner is unproven names both possible owners", async () => { + const result = await run(live("client"), false, false, { after: live("client"), refusedBy: "owner-unproven" }); + expect(result.code).toBe(1); + const text = result.err.join("\n"); + expect(text).toContain("ocx service repair"); + expect(text).toContain("ocx claude or ocx opencode"); + expect(text).not.toContain("Run on this machine: ocx restart"); + }); + // P3: after a completed restart, an unreadable state must not also claim nothing was restarted. test("a completed restart followed by an unreadable state does not say nothing was restarted", async () => { const result = await run(live("client"), true, false, { states: [{ kind: "disconnected" }, { kind: "invalid", reason: "unreadable" }] }); diff --git a/tests/cli/link-apply-restart.test.ts b/tests/cli/link-apply-restart.test.ts index 55f847a5d8e..8c1b211933e 100644 --- a/tests/cli/link-apply-restart.test.ts +++ b/tests/cli/link-apply-restart.test.ts @@ -151,7 +151,7 @@ function clientHealthDeps(postResponse: Response) { describe("ocx restart against a client runtime", () => { test("only explicit request-phase supervision refusals qualify for deferral", () => { const notices: string[] = []; - for (const supervisor of ["desktop", "service", "service-unproven"] as const) { + for (const supervisor of ["desktop", "service", "service-unproven", "owner-unproven"] as const) { const refused = { ok: false as const, phase: "request" as const, error: new Error(`restart_supervised_${supervisor}`) }; expect(supervisedRestartRefusal(refused)).toBe(supervisor); expect(deferSupervisedRestart(refused, value => notices.push(value))).toBe(true); @@ -160,7 +160,7 @@ describe("ocx restart against a client runtime", () => { const uncertain = { ok: false as const, phase: "request" as const, error: new Error("restart_request_outcome_unknown") }; expect(supervisedRestartRefusal(uncertain)).toBeNull(); expect(deferSupervisedRestart(uncertain, value => notices.push(value))).toBe(false); - expect(notices).toEqual(["desktop", "service", "service-unproven"]); + expect(notices).toEqual(["desktop", "service", "service-unproven", "owner-unproven"]); expect(supervisedRestartRefusal({ ok: false, phase: "request", error: new Error("restart_supervised_other") })).toBeNull(); expect(supervisedRestartRefusal({ ok: true, mode: "skipped" })).toBeNull(); }); @@ -194,6 +194,7 @@ describe("ocx restart against a client runtime", () => { reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_service") }); reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_desktop") }); reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_service-unproven") }); + reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_owner-unproven") }); } finally { console.error = original; } @@ -201,6 +202,7 @@ describe("ocx restart against a client runtime", () => { "Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.", "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it while its connection is current: use Quit in the OpenCodex tray menu, then reopen it. After `ocx disconnect`, `ocx restart` hands it to the app.", "Restart not performed; the running client runtime was kept. A service manager started it (systemd opencodex-proxy.service, launchd com.opencodex.proxy or the Windows OpenCodex service), but it is not enabled or its relaunch could not be verified: restart it through the manager with 'ocx service restart', or check 'ocx service status'.", + "Restart not performed; the running client runtime was kept. It was started with OCX_SERVICE=1 but without the service manager's marker while a service is installed, so it is either a service registered before that marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run 'ocx service repair' and then 'ocx service restart'; if it is a companion, end that ocx claude or ocx opencode session and start it again.", ]); }); }); diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 7678e3cf904..264e242ac45 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -13,6 +13,10 @@ import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; import { readClientConnectionStateWithEvidence } from "../../src/client/state"; +import * as configModule from "../../src/config"; +import { claudeProxyStartEnv } from "../../src/cli/claude"; +import { opencodeProxyStartEnv } from "../../src/cli/opencode"; +import { spyOn } from "bun:test"; import { deriveLaunchdServiceDiagnostic, deriveSystemdServiceDiagnostic, deriveWindowsServiceDiagnostic } from "../../src/service/diagnostics"; import { createLocalAttestationChallenge, @@ -301,6 +305,31 @@ describe("client restart controller", () => { // Final review: a manager that owns this process but cannot be proven to relaunch it must keep // the runtime; refused at admission, nothing recycled or detached. + // The restart decision table (PR body): every supervisor verdict, with the connection ended + // (saved, valid, disconnected or another connection) or not (served connection current, or + // the saved state is invalid, missing or unreadable). + test.each([ + ["desktop", true, "accepted", ["recycle:desktop"]], + ["desktop", false, "supervised", []], + ["service", true, "accepted", ["recycle:service"]], + ["service", false, "supervised", []], + ["service-unproven", true, "supervised", []], + ["service-unproven", false, "supervised", []], + ["owner-unproven", true, "supervised", []], + ["owner-unproven", false, "supervised", []], + [null, true, "accepted", ["recycle:none"]], + [null, false, "accepted", ["stop:20000", "spawn:71000", "exit:0"]], + ] as const)("decision table: supervisor %s, connection ended %s", async (supervisor, ended, kind, expectedCalls) => { + const calls: string[] = []; + const { io, calls: ioCalls } = fakeIo({ supervisor: async () => supervisor, connectionChanged: () => ended, recycle: async verdict => { calls.push(`recycle:${verdict ?? "none"}`); } }); + const controller = createClientRestartController(io); + const admission = await controller.request(); + expect(admission.kind).toBe(kind); + if (kind === "supervised") expect(admission).toEqual({ kind: "supervised", supervisor }); + await controller.settled(); + expect([...calls, ...ioCalls]).toEqual([...expectedCalls]); + }); + test("an owned but unproven service refuses even an ended connection, and recycles nothing", async () => { const recycled: unknown[] = []; const { io } = fakeIo({ supervisor: async () => "service-unproven", connectionChanged: () => true, recycle: async verdict => { recycled.push(verdict); } }); @@ -352,7 +381,7 @@ describe("client restart controller", () => { }); describe("who supervises a client restart (real service diagnostics)", () => { - const owned = { OCX_SERVICE: "1" } as NodeJS.ProcessEnv; + const owned = { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" } as NodeJS.ProcessEnv; const systemd = (enabled: boolean, running: boolean) => deriveSystemdServiceDiagnostic({ installed: true, enabled, running, stale: false, diagnostics: "test" }); test.each([ ["systemd running but disabled (started by hand)", systemd(false, true), "service-unproven"], @@ -364,6 +393,37 @@ describe("who supervises a client restart (real service diagnostics)", () => { expect(classifyClientRestartSupervisor({ desktop: false, env: owned, diagnose: () => diagnostic })).toBe(expected); }); + // Final review round 2: launchd's unknown load state stays viable for the diagnostic's other + // callers, but a restart needs positive evidence that the job is loaded. + test("launchd job whose load state is unknown is not proof of relaunch", () => { + const unknown = deriveLaunchdServiceDiagnostic({ installed: true, stale: false, load: { state: "unknown", detail: "launchctl could not be asked" }, diagnostics: "test" }); + expect(unknown.viable).toBe(true); + expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE_MANAGED: "1" }, diagnose: () => unknown })).toBe("service-unproven"); + }); + + // Final review round 2: ocx claude and ocx opencode start their own proxies with OCX_SERVICE=1; + // through their real launch environments, with no service installed, nothing supervises them. + const notInstalled = deriveSystemdServiceDiagnostic({ installed: false, enabled: false, running: false, stale: false, diagnostics: "test" }); + const installedDisabled = systemd(false, false); + test.each([ + ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], + ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], + ] as const)("a %s companion with no service installed is not supervised", (_name, env) => { + expect(env().OCX_SERVICE).toBe("1"); + expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => notInstalled })).toBeNull(); + }); + + test.each([ + ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], + ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], + ] as const)("a bare OCX_SERVICE=1 (%s environment) while a service is installed is owner-unproven", (_name, env) => { + expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => installedDisabled })).toBe("owner-unproven"); + }); + + test("the Windows wrapper's marker counts as manager-launched", () => { + expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE: "1", OCX_WINDOWS_WRAPPER_PROTOCOL: "1" }, diagnose: () => systemd(true, true) })).toBe("service"); + }); + test("OCX_SERVICE_MANAGED alone marks the process as manager-owned", () => { expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE_MANAGED: "1" }, diagnose: () => systemd(false, true) })).toBe("service-unproven"); }); @@ -395,6 +455,35 @@ describe("connection-ended evidence from the real state reader", () => { } }); + // Final review round 2: a saved file that fails validation is not evidence either. + test("a saved config that fails validation is not proof", () => { + writeFileSync(join(root, "config.json"), JSON.stringify({ port: "bad" })); + const state = readClientConnectionStateWithEvidence(); + expect(state.kind).toBe("invalid"); + expect(clientConnectionEnded(link, state)).toBe(false); + }); + + // ...and the verdict comes from one snapshot: a connect committed after the file was read cannot + // pair the old bytes with a validation of the new ones. The hook commits at the second read. + test("a connection committed between reads never yields disconnected from a stale read", () => { + writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai" })); + const realDiagnostics = configModule.readConfigDiagnostics; + let committed = false; + const hook = spyOn(configModule, "readConfigDiagnostics").mockImplementation((options?: Parameters[0]) => { + if (!committed) { + committed = true; + writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai", runtimeRole: "client", client: link })); + } + return realDiagnostics(options); + }); + try { + const state = readClientConnectionStateWithEvidence(); + expect(committed && state.kind === "disconnected").toBe(false); + } finally { + hook.mockRestore(); + } + }); + test("a saved config with no client is proof", () => { // beforeEach wrote a standalone config.json into this test's home. const state = readClientConnectionStateWithEvidence(); From 946a73403f4ab8a60992b1ca363c923c5f0f339e Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:36:07 +0900 Subject: [PATCH 37/54] docs(link): structure note for single-snapshot evidence and manager ownership --- structure/remote-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/remote-link.md b/structure/remote-link.md index 826de43ffc7..740f81e562d 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`): a saved config.json with no client or with another connection; a missing config.json is invalid, never disconnected. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): a process a service manager launched (OCX_SERVICE or OCX_SERVICE_MANAGED) whose service diagnostic is not viable, such as a systemd unit started by hand while disabled, is `service-unproven` and keeps serving, refused at admission even when its connection ended; only a process no manager owns detaches. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. A bare OCX_SERVICE=1 is an ocx claude or ocx opencode companion when no service is installed (it restarts normally), and `owner-unproven` when one is. Both unproven verdicts keep the runtime and are refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From f90dbe8192b2060edd9731923e7e9df6cb89053c Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:43:34 +0900 Subject: [PATCH 38/54] fix(claude): keep the provenance stamp at the ocx claude spawn site --- src/cli/claude.ts | 7 ++++--- tests/cli/cli-dispatch.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index ed7d1686f06..33d99cb3268 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -39,12 +39,13 @@ import { isClaudeInterceptProxyUrl, type ClaudeInterceptSettingsState } from ".. import { withoutSiblingMarker } from "../codex/sibling-start"; /** - * The environment of the detached `ocx start` that `ocx claude` launches: an ordinary owner. It + * The environment of the detached `ocx start` that `ocx claude` launches (the spawn adds the runtime + * provenance stamp): an ordinary owner. It * carries OCX_SERVICE=1 but no service manager's marker, which is how a restart tells it apart from * a manager-launched process (src/client/runtime-restart.ts classifyClientRestartSupervisor). */ export function claudeProxyStartEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return withProcessRuntimeProvenance(withoutSiblingMarker({ ...base, OCX_SERVICE: "1" })); + return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" }); } export interface ClaudeLaunchEnv { @@ -541,7 +542,7 @@ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Pr stdio: "ignore", windowsHide: true, // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. - env: claudeProxyStartEnv(), + env: withProcessRuntimeProvenance(claudeProxyStartEnv()), }); child.unref(); const deadline = Date.now() + 8_000; diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 64d0adb12b0..c3ba06e6b09 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -744,8 +744,8 @@ describe("a sibling start leaves shared client routing to the live owner", () => for (const [path, env] of [ // ocx claude builds its proxy's environment in claudeProxyStartEnv (also read by the restart's // owner classification) and spawns with it. - ["src/cli/claude.ts", 'return withProcessRuntimeProvenance(withoutSiblingMarker({ ...base, OCX_SERVICE: "1" }));'], - ["src/cli/claude.ts", "env: claudeProxyStartEnv(),"], + ["src/cli/claude.ts", 'return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" });'], + ["src/cli/claude.ts", "env: withProcessRuntimeProvenance(claudeProxyStartEnv()),"], ["src/cli/opencode.ts", opencodeStartEnv], ["src/cli/minimax.ts", opencodeStartEnv], ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"], From 707b92bb80c15f52e7daa79bd952a95c103c9860 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:59:25 +0900 Subject: [PATCH 39/54] fix(link): decide client restart ownership from positive evidence only A bare OCX_SERVICE=1 was read as a companion whenever no unit file existed, but every service registered before v2.49 launches with only that flag, and systemd keeps a running unit in its cgroup after its file is removed. ocx claude, ocx codex and ocx opencode now mark their own proxies (OCX_COMPANION_PROXY); a bare flag without that marker or a manager marker is owner-unproven whatever the unit file says. The detached replacement clears the companion marker too. The single-read test now counts config.json reads at the file system. --- src/cli/claude.ts | 9 ++- src/cli/codex-launcher.ts | 5 +- src/cli/opencode.ts | 4 +- src/client/runtime-restart.ts | 24 +++--- src/client/runtime.ts | 4 +- src/service/companion-proxy.ts | 9 +++ tests/cli/cli-dispatch.test.ts | 2 +- tests/clients/client-runtime-restart.test.ts | 78 +++++++++++++------- tests/clients/client-runtime.test.ts | 2 +- 9 files changed, 90 insertions(+), 47 deletions(-) create mode 100644 src/service/companion-proxy.ts diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 33d99cb3268..9274587073c 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -37,15 +37,16 @@ import { desktopProfileWireAlias, legacyDesktopProfileWireAlias } from "../claud import { inspectDesktopFirstParty } from "../claude/desktop-first-party"; import { isClaudeInterceptProxyUrl, type ClaudeInterceptSettingsState } from "../claude/intercept/settings"; import { withoutSiblingMarker } from "../codex/sibling-start"; +import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; /** * The environment of the detached `ocx start` that `ocx claude` launches (the spawn adds the runtime - * provenance stamp): an ordinary owner. It - * carries OCX_SERVICE=1 but no service manager's marker, which is how a restart tells it apart from - * a manager-launched process (src/client/runtime-restart.ts classifyClientRestartSupervisor). + * provenance stamp): an ordinary owner. It carries OCX_SERVICE=1 and the companion marker, which a + * restart reads as positive evidence that no service manager owns it + * (src/client/runtime-restart.ts classifyClientRestartSupervisor). */ export function claudeProxyStartEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" }); + return withoutSiblingMarker({ ...base, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "claude" }); } export interface ClaudeLaunchEnv { diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 7b328602308..d19632fde51 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -38,6 +38,7 @@ import { attestCodexRuntime, codexCredentialDestinationIsTrusted, resolveCodexLa import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; import { codexTokenWithheldDiagnostic, type CodexTokenRefusal, type CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; +import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; export interface CodexLaunchEnv { [key: string]: string | undefined; @@ -109,13 +110,13 @@ export function codexLaunchPlan(routingKind: CodexRoutingKind): CodexLaunchPlan } } -/** Env the detached `ocx start` child inherits (service-owned admission ladder). */ +/** Env the detached `ocx start` child inherits (service-owned admission ladder); the companion marker tells a restart no manager owns it. */ export function codexProxyStartEnv(base: CodexLaunchEnv = process.env): CodexLaunchEnv { const stripped = withoutSiblingMarker(base) as CodexLaunchEnv; const withTokenFile = stripped.OPENCODEX_API_AUTH_TOKEN?.trim() ? stripped : { ...stripped, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; - return { ...withTokenFile, OCX_SERVICE: "1" } as CodexLaunchEnv; + return { ...withTokenFile, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "codex" } as CodexLaunchEnv; } export type CodexProxyEnsureDeps = { diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index ab66e8dd253..b4abb4871b2 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -47,6 +47,7 @@ import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets import { localManagementOrigin } from "../lib/local-destinations"; import { directLocalHttpFetch } from "../server/direct-local-http"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -570,12 +571,13 @@ function serviceTokenLookupEnv(env: OpencodeLaunchEnv): OpencodeLaunchEnv { * Child env for a detached `ocx start` from `ocx opencode`. When the admission token is * not already in the environment, pass through an existing `OCX_API_TOKEN_FILE` or the * default hardened service token path so `handleStart` can load it before bind. + * The companion marker tells a restart that no service manager owns this proxy. */ export function opencodeProxyStartEnv(base: OpencodeLaunchEnv = process.env): OpencodeLaunchEnv { const withTokenFile = base.OPENCODEX_API_AUTH_TOKEN?.trim() ? base : serviceTokenLookupEnv(base); - return { ...withTokenFile, OCX_SERVICE: "1" }; + return { ...withTokenFile, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "opencode" }; } /** diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index a322b4ea8ba..cc53d4d1290 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -28,9 +28,11 @@ import { } from "../lib/system-restart-contract"; import type { OcxClientConnectionConfig } from "../types"; import { isSupervisedServiceChild } from "../service/service-child-ownership"; +import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; import { sameClientConnectionOwner, type readClientConnectionState } from "./state"; type ClientConnectionState = ReturnType; +const COMPANION_PROXY_LAUNCHERS = new Set(["claude", "codex", "opencode"]); /** * `service-unproven`: a service manager launched and owns this process (OCX_SERVICE or @@ -158,27 +160,29 @@ export async function clientRestartSupervisor(): Promise { installed: boolean; viable: boolean; running: boolean }; + diagnose: () => { viable: boolean; running: boolean }; /** Manager-launched evidence; defaults to src/service/service-child-ownership.ts. */ managed?: (env: NodeJS.ProcessEnv) => boolean; }): ClientRestartSupervisor | null { if (input.desktop) return "desktop"; // The managed marker (launchd, systemd, WinSW), the Windows wrapper's protocol marker, or a - // legacy Windows wrapper parent. A bare OCX_SERVICE=1 is also what ocx claude and ocx opencode - // give their own proxies, so it alone proves no manager. + // legacy Windows wrapper parent. if (!(input.managed ?? isSupervisedServiceChild)(input.env)) { if (input.env.OCX_SERVICE !== "1") return null; - // Bare marker: a companion when no service is installed; with one installed it may also be a - // service registered before the managed marker existed, which nothing here can tell apart. - return input.diagnose().installed ? "owner-unproven" : null; + // Never inferred from a missing unit file: systemd keeps a running unit's process in its cgroup + // after the file is removed. + return COMPANION_PROXY_LAUNCHERS.has(input.env[COMPANION_PROXY_ENV] ?? "") ? null : "owner-unproven"; } // Relaunch needs positive evidence: viable AND running (launchd's viable also covers an // unknown load state for its other callers; running there means loaded). diff --git a/src/client/runtime.ts b/src/client/runtime.ts index bee9d5a5ed3..fdbee3b3217 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -33,6 +33,7 @@ import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; +import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; let activeServer: Server | null = null; @@ -113,11 +114,12 @@ export function standaloneRecycleEnv( ): NodeJS.ProcessEnv { const childEnv = { ...env }; // A detached replacement is nobody's child: not the desktop app's, not a service's, not a Windows - // wrapper's. It must carry no supervisor marker it would otherwise misread as its own. + // wrapper's, not a companion launcher's. It must carry no owner marker it would misread as its own. delete childEnv[DESKTOP_SUPERVISED_ENV]; delete childEnv.OCX_SERVICE; delete childEnv.OCX_SERVICE_MANAGED; delete childEnv.OCX_WINDOWS_WRAPPER_PROTOCOL; + delete childEnv[COMPANION_PROXY_ENV]; const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim(); if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) { // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can diff --git a/src/service/companion-proxy.ts b/src/service/companion-proxy.ts new file mode 100644 index 00000000000..4650a03d943 --- /dev/null +++ b/src/service/companion-proxy.ts @@ -0,0 +1,9 @@ +/** + * The marker `ocx claude` and `ocx opencode` (and `ocx minimax`, which reuses the OpenCode launch) + * set on the detached `ocx start` they launch, naming the launcher. Their proxies also carry + * OCX_SERVICE=1, which is all a service registered before v2.49 gives its process, so a restart + * reads this marker as the positive evidence that no service manager owns the runtime + * (src/client/runtime-restart.ts classifyClientRestartSupervisor). A manager's own marker + * (OCX_SERVICE_MANAGED, the Windows wrapper marker) always wins over it. + */ +export const COMPANION_PROXY_ENV = "OCX_COMPANION_PROXY"; diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index c3ba06e6b09..d5db9d1187c 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -744,7 +744,7 @@ describe("a sibling start leaves shared client routing to the live owner", () => for (const [path, env] of [ // ocx claude builds its proxy's environment in claudeProxyStartEnv (also read by the restart's // owner classification) and spawns with it. - ["src/cli/claude.ts", 'return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" });'], + ["src/cli/claude.ts", 'return withoutSiblingMarker({ ...base, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "claude" });'], ["src/cli/claude.ts", "env: withProcessRuntimeProvenance(claudeProxyStartEnv()),"], ["src/cli/opencode.ts", opencodeStartEnv], ["src/cli/minimax.ts", opencodeStartEnv], diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 264e242ac45..353bb88bed9 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -6,16 +6,18 @@ * replacement, and a replacement that does not come up leaves the old runtime serving. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; -import { readClientConnectionStateWithEvidence } from "../../src/client/state"; -import * as configModule from "../../src/config"; +import { readClientConnectionState, readClientConnectionStateWithEvidence } from "../../src/client/state"; +import { COMPANION_PROXY_ENV } from "../../src/service/companion-proxy"; import { claudeProxyStartEnv } from "../../src/cli/claude"; import { opencodeProxyStartEnv } from "../../src/cli/opencode"; +import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; import { spyOn } from "bun:test"; import { deriveLaunchdServiceDiagnostic, deriveSystemdServiceDiagnostic, deriveWindowsServiceDiagnostic } from "../../src/service/diagnostics"; import { @@ -366,6 +368,21 @@ describe("client restart controller", () => { expect(controller.hasHandoff()).toBe(false); }); + test("a supervisor verdict that changes between the admission and the recycle lookups is honoured", async () => { + // Admitted with no supervisor, then unproven at the second lookup: kept, no recycle. + const turned = [null, "owner-unproven"] as const; + let i = 0; + const late = fakeIo({ supervisor: async () => turned[Math.min(i++, 1)] ?? null, connectionChanged: () => true, recycle: async () => { late.calls.push("recycle"); } }); + const controller = createClientRestartController(late.io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + await controller.settled(); + expect(late.calls.filter(call => call === "recycle")).toEqual([]); + // Unproven at admission: refused before anything is accepted. + const early = fakeIo({ supervisor: async () => "service-unproven", connectionChanged: () => true, recycle: async () => { early.calls.push("recycle"); } }); + expect(await createClientRestartController(early.io).request()).toEqual({ kind: "supervised", supervisor: "service-unproven" }); + expect(early.calls).toEqual([]); + }); + test("a second request while one is in flight joins it", async () => { let finish!: () => void; let spawns = 0; @@ -401,23 +418,32 @@ describe("who supervises a client restart (real service diagnostics)", () => { expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE_MANAGED: "1" }, diagnose: () => unknown })).toBe("service-unproven"); }); - // Final review round 2: ocx claude and ocx opencode start their own proxies with OCX_SERVICE=1; - // through their real launch environments, with no service installed, nothing supervises them. + // Final review round 3: ownership comes from positive evidence only. ocx claude and ocx opencode + // mark their own proxies (COMPANION_PROXY_ENV); a bare OCX_SERVICE=1 without that marker is also + // what every service registered before v2.49 launches, whether or not its unit file still exists. const notInstalled = deriveSystemdServiceDiagnostic({ installed: false, enabled: false, running: false, stale: false, diagnostics: "test" }); const installedDisabled = systemd(false, false); test.each([ ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], + ["ocx codex", () => codexProxyStartEnv({ PATH: "/usr/bin" }) as NodeJS.ProcessEnv], ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], - ] as const)("a %s companion with no service installed is not supervised", (_name, env) => { + ] as const)("a %s companion is not supervised, whatever service is installed", (_name, env) => { expect(env().OCX_SERVICE).toBe("1"); - expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => notInstalled })).toBeNull(); + for (const diagnostic of [notInstalled, installedDisabled, systemd(true, true)]) { + expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => diagnostic })).toBeNull(); + } }); - test.each([ - ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], - ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], - ] as const)("a bare OCX_SERVICE=1 (%s environment) while a service is installed is owner-unproven", (_name, env) => { - expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => installedDisabled })).toBe("owner-unproven"); + test("a bare OCX_SERVICE=1 is owner-unproven, also when no unit file exists (a pre-2.49 unit whose file is gone)", () => { + for (const diagnostic of [notInstalled, installedDisabled]) { + expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE: "1", PATH: "/usr/bin" }, diagnose: () => diagnostic })).toBe("owner-unproven"); + } + }); + + test("a companion marker never outranks a service manager's marker", () => { + const env = { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1", [COMPANION_PROXY_ENV]: "claude" }; + expect(classifyClientRestartSupervisor({ desktop: false, env, diagnose: () => systemd(true, true) })).toBe("service"); + expect(classifyClientRestartSupervisor({ desktop: false, env, diagnose: () => notInstalled })).toBe("service-unproven"); }); test("the Windows wrapper's marker counts as manager-launched", () => { @@ -463,24 +489,22 @@ describe("connection-ended evidence from the real state reader", () => { expect(clientConnectionEnded(link, state)).toBe(false); }); - // ...and the verdict comes from one snapshot: a connect committed after the file was read cannot - // pair the old bytes with a validation of the new ones. The hook commits at the second read. - test("a connection committed between reads never yields disconnected from a stale read", () => { - writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai" })); - const realDiagnostics = configModule.readConfigDiagnostics; - let committed = false; - const hook = spyOn(configModule, "readConfigDiagnostics").mockImplementation((options?: Parameters[0]) => { - if (!committed) { - committed = true; - writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai", runtimeRole: "client", client: link })); - } - return realDiagnostics(options); - }); + // ...and the verdict comes from one read of config.json, so a connect that commits meanwhile cannot + // pair old bytes with a validation of new ones. Counted at the file system: the older two-read + // reader shows the count detects a second read. + test("the verdict comes from exactly one read of config.json", () => { + const path = join(root, "config.json"); + const reads = spyOn(fs, "readFileSync"); + const count = (read: () => unknown) => { + reads.mockClear(); + read(); + return reads.mock.calls.filter(([target]) => String(target) === path).length; + }; try { - const state = readClientConnectionStateWithEvidence(); - expect(committed && state.kind === "disconnected").toBe(false); + expect(count(readClientConnectionState)).toBe(2); + expect(count(readClientConnectionStateWithEvidence)).toBe(1); } finally { - hook.mockRestore(); + reads.mockRestore(); } }); diff --git a/tests/clients/client-runtime.test.ts b/tests/clients/client-runtime.test.ts index 44126a5104e..7786ea8ecfb 100644 --- a/tests/clients/client-runtime.test.ts +++ b/tests/clients/client-runtime.test.ts @@ -196,7 +196,7 @@ describe("standalone recycle", () => { // Final review: an authorized detached replacement carries no supervisor marker at all. test("a detached replacement's environment carries no supervisor marker", async () => { await withServiceMarker("1", async () => { - const markers = ["OCX_SERVICE", "OCX_SERVICE_MANAGED", "OCX_DESKTOP_SUPERVISED", "OCX_WINDOWS_WRAPPER_PROTOCOL"]; + const markers = ["OCX_SERVICE", "OCX_SERVICE_MANAGED", "OCX_DESKTOP_SUPERVISED", "OCX_WINDOWS_WRAPPER_PROTOCOL", "OCX_COMPANION_PROXY"]; const previous = Object.fromEntries(markers.map(name => [name, process.env[name]])); for (const name of markers) process.env[name] = "1"; let env: NodeJS.ProcessEnv | undefined; From c4b83b6da8ee66892d9d580cf2299ef4e9381cab Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:59:25 +0900 Subject: [PATCH 40/54] fix(link): give an owner-unproven client runtime advice and steps that end it Ending an ocx claude or ocx opencode session does not end its detached proxy. ocx restart and ocx sync now name ocx stop then ocx start; sync reports owner-unproven as its own outcome and exits 1 instead of a background-service deferral. ocx disconnect stops that exact runtime itself: it terminates the captured generation only after it attests the captured secret, and starts OpenCodex only once the exit was observed, the state is still disconnected and nothing else answers. --- src/cli/disconnect-runtime.ts | 64 +++++++++++++++-- src/cli/dispatch.ts | 11 ++- src/cli/link-apply-restart.ts | 15 +++- src/cli/restart-failure.ts | 10 ++- src/cli/sync-command.ts | 4 +- tests/cli/disconnect-runtime.test.ts | 104 ++++++++++++++++++++++++--- tests/cli/link-apply-restart.test.ts | 26 ++++++- 7 files changed, 211 insertions(+), 23 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index f91792397bc..dc445633a37 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -8,6 +8,10 @@ * Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. */ import type { ProxyRestartIo, ProxyRestartLive, ProxyRestartRequestOutcome } from "./tray-proxy"; +import { OWNER_UNPROVEN_RECOVERY } from "./restart-failure"; +import { createLocalAttestationChallenge, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof } from "../lib/local-management-attestation"; +import { directLocalHttpFetch } from "../server/direct-local-http"; +import { probeHostname } from "../server/proxy-liveness"; /** * One runtime process: its runtime record. The attestation secret is created per process @@ -41,11 +45,48 @@ export interface DisconnectRuntimeDeps { * supervisor that refused it: an owning service manager whose relaunch could not be proven. */ restart: (target: RuntimeGeneration) => Promise<{ ok: boolean; refusedBy?: "service-unproven" | "owner-unproven" }>; + /** + * Stop exactly the captured runtime when its owner could not be proven ({@link stopCapturedRuntime}): + * "exited" only once its exit was observed, "changed" when another process answers for it. + */ + stopCaptured: (target: RuntimeGeneration) => Promise<"exited" | "changed" | "kept">; + /** Start OpenCodex the way `ocx restart` does when nothing runs (the saved state decides the role). */ + start: () => Promise; json: boolean; info?: (line: string) => void; error?: (line: string) => void; } +/** + * Stop one runtime generation: it must prove the captured attestation secret right before it is + * terminated (a process at a reused pid cannot), escalation re-checks that the runtime record still + * names that secret, and "exited" needs the process to be gone afterwards. + */ +export async function stopCapturedRuntime(target: RuntimeGeneration, io: { + attest: (target: RuntimeGeneration) => Promise; + terminate: (pid: number, stillTarget: () => boolean) => void; + alive: (pid: number) => boolean; + stillTarget: () => boolean; +}): Promise<"exited" | "changed" | "kept"> { + if (!(await io.attest(target))) return "changed"; + try { io.terminate(target.pid, io.stillTarget); } catch { /* decided by the exit check below */ } + return io.alive(target.pid) ? "kept" : "exited"; +} + +/** Whether the listener at the captured port proves the captured secret for the captured pid now. */ +export async function attestRuntimeGeneration(target: RuntimeGeneration, fetchImpl: typeof directLocalHttpFetch = directLocalHttpFetch): Promise { + try { + const challenge = createLocalAttestationChallenge(); + const response = await fetchImpl(`http://${probeHostname(target.hostname)}:${target.port}/healthz`, { + method: "GET", headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, redirect: "error", signal: AbortSignal.timeout(2_000), + }, { timeoutMs: 2_000 }); + void response.body?.cancel().catch(() => {}); + return verifyLocalAttestationProof(target.attestationSecret, challenge, target.pid, target.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER)); + } catch { + return false; + } +} + /** * Bind a restart to one runtime generation. The restart discovers its target again, and a connect * that committed in between can have started a new client runtime there, even at a reused pid. @@ -90,7 +131,7 @@ export function recoveryAdvice(state: DisconnectClientState, answering: Answerin } switch (answering.kind) { case "captured": return answering.refusedBy === "owner-unproven" - ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its owner could not be proven: it is either a service registered before the service manager\'s marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run ocx service repair and then ocx service restart; if it is a companion, end that ocx claude or ocx opencode session and start it again.` + ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection and did not exit when this machine stopped it. ${OWNER_UNPROVEN_RECOVERY}` : answering.refusedBy ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its service manager did not allow a restart from here. Restart it through the manager: ocx service restart (check ocx service status).` : `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`; @@ -129,11 +170,26 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) } if (!client || !target) return 0; // A --json caller reads one document from stdout; restart progress goes to stderr. - const originalLog = console.log; - if (deps.json) console.log = (...args: unknown[]) => console.error(...args); + const toStderr = async (step: () => Promise): Promise => { + const originalLog = console.log; + if (deps.json) console.log = (...args: unknown[]) => console.error(...args); + try { return await step(); } finally { console.log = originalLog; } + }; let ok = false; let refusedBy: "service-unproven" | "owner-unproven" | undefined; - try { ({ ok, refusedBy } = await deps.restart(target)); } finally { console.log = originalLog; } + ({ ok, refusedBy } = await toStderr(() => deps.restart(target))); + if (!ok && refusedBy === "owner-unproven" && (await deps.stopCaptured(target)) === "exited") { + // No owner could be proven to relaunch it, and none may be assumed absent: this exact + // generation was stopped, and OpenCodex starts only once that exit was observed, the state is + // still disconnected, and nothing (such as a manager's relaunch) answers instead. + refusedBy = undefined; + const stopped = deps.connectionState(); + if (stopped.kind !== "disconnected") { + error(`The client runtime (pid ${target.pid}) was stopped, but the client state changed meanwhile, so OpenCodex was not started. ${recoveryAdvice(stopped, { kind: "unknown" })}`); + return 1; + } + if ((await deps.observe()).kind === "absent") await toStderr(deps.start); + } const after = await deps.observe(); if (after.kind === "live" && after.live.role !== "client") { const late = deps.connectionState(); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 69c1dd63602..c1c3b39e4d6 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -427,7 +427,7 @@ const commandRunners: Record = { const code = await handleDisconnectCommand(deps.args.slice(1)); // Token recovery refuses any live client runtime and leaves nothing to restart. if (code !== 0 || deps.args.includes("--orphaned-token")) return code; - const { bindRestartToRuntime, restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); + const { attestRuntimeGeneration, bindRestartToRuntime, restartRuntimeAfterDisconnect, stopCapturedRuntime } = await import("./disconnect-runtime"); const { observeProxyLiveness } = await import("../server/proxy-liveness"); const { readClientConnectionStateWithEvidence } = await import("../client/state"); const { readRuntimePort } = await import("../config/process-state"); @@ -451,6 +451,15 @@ const commandRunners: Record = { requestBoundSystemRestart(previous as Parameters[0], deadlineAt, { readRuntime: readRuntime as typeof readRuntimePort }))); return { ok, ...(refusedBy ? { refusedBy } : {}) }; }, + stopCaptured: async target => { + const { isProcessAlive, killProxy } = await import("../lib/process-control"); + return await stopCapturedRuntime(target, { + attest: attestRuntimeGeneration, terminate: killProxy, alive: isProcessAlive, + stillTarget: () => readRuntimePort(target.pid)?.attestationSecret === target.attestationSecret, + }); + }, + // The captured runtime was running and its exit was observed: recover it like a vanished restart. + start: () => deps.handleRestartStartWhenStopped(true), json: deps.args.includes("--json"), }); }, diff --git a/src/cli/link-apply-restart.ts b/src/cli/link-apply-restart.ts index bd0e21f52f2..af9fae80265 100644 --- a/src/cli/link-apply-restart.ts +++ b/src/cli/link-apply-restart.ts @@ -11,14 +11,16 @@ import type { HomeCodexSettingsRecord } from "../client/link-home"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { ProxyRestartStartOutcome } from "./tray-proxy"; -import type { RestartSupervisor } from "./restart-failure"; +import { OWNER_UNPROVEN_RECOVERY, type RestartSupervisor } from "./restart-failure"; export type ApplyRestartOutcome = | { kind: "current" } | { kind: "not-running" } | { kind: "connection-changed" } | { kind: "restarted" } - | { kind: "deferred-supervisor"; supervisor: RestartSupervisor } + | { kind: "deferred-supervisor"; supervisor: Exclude } + /** Kept by a runtime whose owner cannot be proven: no owner will restart it, so the apply is not in effect. */ + | { kind: "owner-unproven" } | { kind: "failed" }; export interface ApplyRestartDeps { @@ -60,10 +62,16 @@ export async function restartRuntimeAfterApply(record: HomeCodexSettingsRecord, const ok = await deps.restart(async recovering => recovering ? deps.recoveryStart(true) : { status: "skipped" }, value => { supervisor = value; }); - if (!ok && supervisor) return { kind: "deferred-supervisor", supervisor }; + const refusedBy = supervisor as RestartSupervisor | undefined; + if (!ok && refusedBy) return refusedBy === "owner-unproven" ? { kind: "owner-unproven" } : { kind: "deferred-supervisor", supervisor: refusedBy }; return ok ? { kind: "restarted" } : { kind: "failed" }; } +/** `ocx sync` exits 1 for these: the applied settings are not in effect and no owner will fix that. */ +export function applyRestartFailed(outcome: ApplyRestartOutcome): boolean { + return outcome.kind === "failed" || outcome.kind === "connection-changed" || outcome.kind === "owner-unproven"; +} + /** One line for each outcome, printed by `ocx sync`. */ export function applyRestartMessage(outcome: ApplyRestartOutcome): string { switch (outcome.kind) { @@ -74,6 +82,7 @@ export function applyRestartMessage(outcome: ApplyRestartOutcome): string { case "deferred-supervisor": return outcome.supervisor === "desktop" ? "Client runtime restart deferred; the settings take effect when OpenCodex Desktop restarts it. Use Quit in the OpenCodex tray menu, then reopen OpenCodex." : "Client runtime restart deferred; the settings take effect when the background service restarts it. Run `ocx service restart`."; + case "owner-unproven": return `The Home's settings were applied, but the client runtime was not restarted and keeps serving with the previous ones; they take effect when it starts again. ${OWNER_UNPROVEN_RECOVERY}`; case "failed": return "The client runtime restart did not complete; the previous runtime keeps serving. If a background service or the desktop app owns it, restart it there; otherwise retry with 'ocx restart' or 'ocx sync'."; } } diff --git a/src/cli/restart-failure.ts b/src/cli/restart-failure.ts index 6113ba5a01e..62c34366091 100644 --- a/src/cli/restart-failure.ts +++ b/src/cli/restart-failure.ts @@ -19,6 +19,14 @@ export function deferSupervisedRestart(result: ProxyRestartResult, notify: (supe return true; } +/** + * What is left to do for a client runtime whose owner cannot be proven (a bare OCX_SERVICE=1: + * a service registered before v2.49 or a proxy an older ocx claude or ocx opencode started). + * Ending a companion's session does not end its detached proxy; `ocx stop` does, through the + * runtime the pid file and runtime record name. `ocx disconnect` does this step itself. + */ +export const OWNER_UNPROVEN_RECOVERY = "Its owner cannot be proven: it carries OCX_SERVICE=1 without a service manager's marker or the marker ocx claude and ocx opencode now set, so it is either a service installed before v2.49 or a proxy an older ocx claude or ocx opencode started. Stop it with 'ocx stop', check that 'ocx status' reports no proxy running, then start OpenCodex with 'ocx start' (or 'ocx service start' if a service should own it)."; + export const INCOMPLETE_INSTALL_RECOVERY = "If the installer already exited or failed, run `ocx status`, stop any running proxy through its owner (`ocx stop`, or its service or desktop app), then reinstall with the same package manager and start it again (see https://opencodex.me/troubleshooting/update-failed/)."; export function reportRestartFailure(result: Extract): void { @@ -40,7 +48,7 @@ export function reportRestartFailure(result: Extract { // OCX-6: a link Child's runtime must have started after the apply it serves under. // Not after a failed handover: that restart is the one that would undo this routing. if (link && result.appliedRecord && !result.journalHandoverFailed) { - const { applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart"); + const { applyRestartFailed, applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart"); const { sameClientConnectionOwner } = await import("../client/state"); const applied = result.connection; const outcome = await restartRuntimeAfterApply(result.appliedRecord, { @@ -110,7 +110,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { }, }); const message = applyRestartMessage(outcome); - if (outcome.kind === "failed" || outcome.kind === "connection-changed") { + if (applyRestartFailed(outcome)) { console.error(message); process.exitCode = 1; } else { diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 8a4db5cf160..96c8282af1d 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bindRestartToRuntime, restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps, type RuntimeGeneration } from "../../src/cli/disconnect-runtime"; +import { attestRuntimeGeneration, bindRestartToRuntime, restartRuntimeAfterDisconnect, stopCapturedRuntime, type DisconnectRuntimeDeps, type RuntimeGeneration } from "../../src/cli/disconnect-runtime"; +import { OWNER_UNPROVEN_RECOVERY } from "../../src/cli/restart-failure"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; import { runProxyRestart, type ProxyRestartIo } from "../../src/cli/tray-proxy"; import { createLocalAttestationProof, createLocalAttestationSecret, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; @@ -10,20 +11,27 @@ type State = ReturnType; const live = (role?: string, pid = 4321): Observation => ({ kind: "live", live: { pid, port: 10100, ...(role ? { role } : {}) } }); const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret() }; -function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" | "owner-unproven" } = {}) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" | "owner-unproven"; observations?: Observation[]; stopped?: "exited" | "changed" | "kept" } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; const bound: RuntimeGeneration[] = []; + const events: string[] = []; const done = restartRuntimeAfterDisconnect({ - observe: async () => (restarts > 0 ? extra.after ?? live("standalone", 9999) : observation), + observe: async () => { + const seen = extra.observations ? extra.observations.shift()! : restarts > 0 ? extra.after ?? live("standalone", 9999) : observation; + events.push(`observe:${seen.kind}${seen.live?.role ? `:${seen.live.role}` : ""}`); + return seen; + }, capture: () => (restarts > 0 && extra.captureAfter !== undefined ? extra.captureAfter : extra.capture === undefined ? generation : extra.capture), connectionState: () => extra.states?.shift() ?? extra.state ?? { kind: "disconnected" }, - restart: async target => { restarts += 1; bound.push(target); return { ok: restartOk, ...(extra.refusedBy ? { refusedBy: extra.refusedBy } : {}) }; }, + restart: async target => { restarts += 1; bound.push(target); events.push("restart"); return { ok: restartOk, ...(extra.refusedBy ? { refusedBy: extra.refusedBy } : {}) }; }, + stopCaptured: async target => { events.push(`stop:${target.pid}`); return extra.stopped ?? "kept"; }, + start: async () => { events.push("start"); }, json, info: line => out.push(line), error: line => err.push(line), }); - return done.then(code => ({ code, out, err, restarts, bound })); + return done.then(code => ({ code, out, err, restarts, bound, events })); } describe("ocx disconnect restarts the client runtime it left behind", () => { @@ -180,15 +188,47 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(text).not.toContain("Run on this machine: ocx restart"); }); - test("a restart refused because its owner is unproven names both possible owners", async () => { - const result = await run(live("client"), false, false, { after: live("client"), refusedBy: "owner-unproven" }); + // Final review round 3: a runtime whose owner cannot be proven is stopped by its captured + // generation; OpenCodex is started only after that exit was observed. + test("an owner-unproven runtime is stopped, its exit observed, and only then OpenCodex started", async () => { + const result = await run(live("client"), false, false, { refusedBy: "owner-unproven", stopped: "exited", observations: [live("client"), { kind: "absent", live: null }, live("standalone", 9999)] }); + expect(result.events).toEqual(["observe:live:client", "restart", "stop:4321", "observe:absent", "start", "observe:live:standalone"]); + expect(result.code).toBe(0); + expect(result.out.join("\n")).toContain("now runs standalone"); + }); + + test("a manager that relaunched the stopped runtime as standalone needs no start", async () => { + const result = await run(live("client"), false, false, { refusedBy: "owner-unproven", stopped: "exited", observations: [live("client"), live("standalone", 9999), live("standalone", 9999)] }); + expect(result.events).not.toContain("start"); + expect(result.code).toBe(0); + }); + + test("an owner-unproven runtime that did not exit is kept, and the advice is ocx stop", async () => { + const result = await run(live("client"), false, false, { after: live("client"), refusedBy: "owner-unproven", stopped: "kept" }); expect(result.code).toBe(1); + expect(result.events).not.toContain("start"); const text = result.err.join("\n"); - expect(text).toContain("ocx service repair"); - expect(text).toContain("ocx claude or ocx opencode"); + expect(text).toContain(OWNER_UNPROVEN_RECOVERY); + expect(OWNER_UNPROVEN_RECOVERY).toContain("ocx stop"); + expect(text).not.toContain("session"); expect(text).not.toContain("Run on this machine: ocx restart"); }); + test("another process answering for the captured runtime is not stopped and nothing is started", async () => { + const other = { ...generation, attestationSecret: createLocalAttestationSecret() }; + const result = await run(live("client"), false, false, { after: live("client"), captureAfter: other, refusedBy: "owner-unproven", stopped: "changed" }); + expect(result.code).toBe(1); + expect(result.events).not.toContain("start"); + expect(result.err.join("\n")).toContain("A different client runtime"); + }); + + test("a connection that lands while the runtime stops gets no start", async () => { + const result = await run(live("client"), false, false, { refusedBy: "owner-unproven", stopped: "exited", states: [{ kind: "disconnected" }, { kind: "connected" }, { kind: "connected" }], observations: [live("client"), { kind: "absent", live: null }] }); + expect(result.code).toBe(1); + expect(result.events).not.toContain("start"); + expect(result.err.join("\n")).toContain("A new connection is in use"); + }); + // P3: after a completed restart, an unreadable state must not also claim nothing was restarted. test("a completed restart followed by an unreadable state does not say nothing was restarted", async () => { const result = await run(live("client"), true, false, { states: [{ kind: "disconnected" }, { kind: "invalid", reason: "unreadable" }] }); @@ -276,3 +316,49 @@ describe("a disconnect restart bound to the captured runtime generation", () => expect(w.posts).toEqual([]); }); }); + +describe("stopping exactly the captured runtime", () => { + const io = (attested: boolean, exits: boolean, throws = false) => { + const calls: string[] = []; + return { + calls, + value: { + attest: async (target: RuntimeGeneration) => { calls.push(`attest:${target.attestationSecret === generation.attestationSecret}`); return attested; }, + terminate: (pid: number, stillTarget: () => boolean) => { calls.push(`terminate:${pid}:${stillTarget()}`); if (throws) throw new Error("refused"); }, + alive: (pid: number) => { calls.push(`alive:${pid}`); return !exits; }, + stillTarget: () => true, + }, + }; + }; + test("an attested runtime is terminated and reported exited only once its exit is observed", async () => { + const ok = io(true, true); + expect(await stopCapturedRuntime(generation, ok.value)).toBe("exited"); + expect(ok.calls).toEqual(["attest:true", "terminate:4321:true", "alive:4321"]); + const stuck = io(true, false); + expect(await stopCapturedRuntime(generation, stuck.value)).toBe("kept"); + }); + test("a runtime that cannot prove the captured secret is never terminated", async () => { + const other = io(false, true); + expect(await stopCapturedRuntime(generation, other.value)).toBe("changed"); + expect(other.calls).toEqual(["attest:true"]); + }); + test("a refused termination of a live process is kept", async () => { + const refused = io(true, false, true); + expect(await stopCapturedRuntime(generation, refused.value)).toBe("kept"); + }); + + test("attestation needs the captured secret, pid and port, answered by the listener", async () => { + const secret = createLocalAttestationSecret(); + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: request => new Response("{}", { + headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof(secret, request.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) ?? "", 4321, server.port!) ?? "" }, + }) }); + try { + const target = { pid: 4321, port: server.port!, hostname: "127.0.0.1", attestationSecret: secret }; + expect(await attestRuntimeGeneration(target)).toBe(true); + expect(await attestRuntimeGeneration({ ...target, attestationSecret: createLocalAttestationSecret() })).toBe(false); + expect(await attestRuntimeGeneration({ ...target, pid: 4322 })).toBe(false); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/cli/link-apply-restart.test.ts b/tests/cli/link-apply-restart.test.ts index 8c1b211933e..62407f90807 100644 --- a/tests/cli/link-apply-restart.test.ts +++ b/tests/cli/link-apply-restart.test.ts @@ -11,9 +11,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyRestartMessage, restartRuntimeAfterApply, type ApplyRestartDeps } from "../../src/cli/link-apply-restart"; +import { applyRestartFailed, applyRestartMessage, restartRuntimeAfterApply, type ApplyRestartDeps } from "../../src/cli/link-apply-restart"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; -import { deferSupervisedRestart, reportRestartFailure, supervisedRestartRefusal } from "../../src/cli/restart-failure"; +import { deferSupervisedRestart, OWNER_UNPROVEN_RECOVERY, reportRestartFailure, supervisedRestartRefusal } from "../../src/cli/restart-failure"; import { collectAppliedCodexSettingsStatus } from "../../src/cli/status-codex-settings"; import { recordHomeCodexSettings, type HomeCodexSettingsRecord } from "../../src/client/link-home"; import type { HomeCodexSettingsDocument } from "../../src/link/codex-settings"; @@ -119,6 +119,26 @@ describe("ocx sync restarts the runtime after an apply", () => { expect(calls).toEqual([]); }); } + + // Final review round 3: an owner that cannot be proven is not a background service. Sync reports + // it as its own outcome, exits 1 (the apply is not in effect while the old runtime serves), and + // names the command that stops that runtime instead of 'ocx service restart'. + test("an owner-unproven refusal is its own failed outcome with its own advice", async () => { + const { value, calls } = deps(0, { restart: async (_start, onSupervised) => { onSupervised("owner-unproven"); return false; } }); + const outcome = await restartRuntimeAfterApply(record, value); + expect(outcome).toEqual({ kind: "owner-unproven" }); + expect(applyRestartFailed(outcome)).toBe(true); + const message = applyRestartMessage(outcome); + expect(message).toContain(OWNER_UNPROVEN_RECOVERY); + expect(message).not.toContain("background service"); + expect(calls).toEqual([]); + for (const supervisor of ["desktop", "service", "service-unproven"] as const) { + expect(applyRestartFailed({ kind: "deferred-supervisor", supervisor })).toBe(false); + } + expect(applyRestartFailed({ kind: "failed" })).toBe(true); + expect(applyRestartFailed({ kind: "connection-changed" })).toBe(true); + expect(applyRestartFailed({ kind: "restarted" })).toBe(false); + }); }); function clientHealthDeps(postResponse: Response) { @@ -202,7 +222,7 @@ describe("ocx restart against a client runtime", () => { "Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.", "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it while its connection is current: use Quit in the OpenCodex tray menu, then reopen it. After `ocx disconnect`, `ocx restart` hands it to the app.", "Restart not performed; the running client runtime was kept. A service manager started it (systemd opencodex-proxy.service, launchd com.opencodex.proxy or the Windows OpenCodex service), but it is not enabled or its relaunch could not be verified: restart it through the manager with 'ocx service restart', or check 'ocx service status'.", - "Restart not performed; the running client runtime was kept. It was started with OCX_SERVICE=1 but without the service manager's marker while a service is installed, so it is either a service registered before that marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run 'ocx service repair' and then 'ocx service restart'; if it is a companion, end that ocx claude or ocx opencode session and start it again.", + `Restart not performed; the running client runtime was kept. ${OWNER_UNPROVEN_RECOVERY}`, ]); }); }); From 340b067d193129701b8e068234c423ed008d5896 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:59:25 +0900 Subject: [PATCH 41/54] docs(link): owner evidence, companion marker and owner-unproven recovery --- docs-site/src/content/docs/guides/remote-link.md | 4 ++-- structure/ops/service-and-sidecars.md | 2 +- structure/remote-link.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 5115609da29..79c3272f06f 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -90,7 +90,7 @@ A Child runs on the Home's providers, accounts and settings, so the Home is wher - **Dashboard.** The Child's dashboard shows the Home's state read-only and says "Managed by Home over Remote Link". Dashboard and Providers read the Home through the link, and the usage strip in the header shows the Home's quota. The model list is not shown on a Child, because building it runs provider discovery on the Home; Codex on the Child already offers the Home's models. For the same reason, the delegation and sidecar model pickers on a Child list the native models and the Home's current choices, not the Home's full routed list. Saving a change there is refused with a message that names the Home; make the change in the Home's dashboard. The Child's browser never receives a Home credential. - **Codex settings.** `ocx sync` on the Child (and connecting it) applies the Codex settings OpenCodex manages on the Home: Fast mode, the multi-agent mode, the web search sidecar switch, and the native sub-agent defaults when the Home syncs them. `ocx sync` and `ocx status` list the values that came from the Home. The default reasoning effort and the effort levels shown in the Codex app's model picker are Codex app settings on each computer; OpenCodex does not manage them on any computer. - When OpenCodex Desktop or a background service supervises the Child runtime, sync succeeds after applying settings and reports that the runtime restart is deferred. The settings take effect when that supervisor restarts the runtime: use **Quit** in the OpenCodex tray menu, then reopen OpenCodex, or run `ocx service restart` for a service. + When OpenCodex Desktop or a background service supervises the Child runtime, sync succeeds after applying settings and reports that the runtime restart is deferred. The settings take effect when that supervisor restarts the runtime: use **Quit** in the OpenCodex tray menu, then reopen OpenCodex, or run `ocx service restart` for a service. A runtime whose owner cannot be proven (see above) is not deferred: sync exits 1, keeps the runtime, and says to run `ocx stop` and then `ocx start`. - **CLI.** Commands that only read, such as `ocx account list`, `ocx provider quota` and `ocx usage`, read the Home. They show what the Home has already observed: a read from a Child never makes the Home probe a provider, refresh a token or start a process, so quota and account details are as fresh as the Home's own activity keeps them. Reads that only exist to run provider discovery or a forced refresh, such as `ocx models live`, `ocx models preset show` and `ocx account list --quota`, are refused like changes. Commands that change configuration are refused with one message that names the Home and the link and says no request was sent. `ocx status` describes the Home and the link. `ocx provider list` and `ocx config get` still show this computer's own configuration, labelled as local and inactive while the computer is linked. `ocx config set`, `ocx config unset` and `ocx config import` still write locally, each with the same note, and `ocx provider add`, `remove` and `set-default` are refused because those providers are not used while linked. An import that would replace or remove an active client connection is refused. Disconnect that client first, or import a file that preserves its connection record exactly. @@ -109,7 +109,7 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` -On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. +On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. A runtime that carries only `OCX_SERVICE=1` (a service installed before 2.49, or a proxy an older `ocx claude`, `ocx codex` or `ocx opencode` started) has no owner that can be proven: `ocx restart` keeps it and says to run `ocx stop` and then `ocx start`, while `ocx disconnect` stops that exact runtime itself and starts OpenCodex once it has exited. ## Troubleshooting diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index ddd5903cfab..37e5b93dbc7 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -236,7 +236,7 @@ deliberate stop. Existing launchd jobs retain their old keepalive definition until `ocx service repair` rewrites and reloads the changed plist. New WinSW XML stamps `OCX_SERVICE_MANAGED=1`; parent command-line inference applies only to legacy registrations whose XML lacks that marker. Bare `OCX_SERVICE=1` -is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too; Codex's client-token loading separately requires a managed loopback destination. A +is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too (they add `OCX_COMPANION_PROXY`, which a client restart reads as evidence that no manager owns the runtime); Codex's client-token loading separately requires a managed loopback destination. A marker-less Windows registration is still recognised by the parent's command line naming this install's wrapper script, launcher or WinSW host as a complete token in any position, and an unreadable parent command line is no evidence and proceeds. POSIX keeps the diff --git a/structure/remote-link.md b/structure/remote-link.md index 740f81e562d..fd79027b612 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. A bare OCX_SERVICE=1 is an ocx claude or ocx opencode companion when no service is installed (it restarts normally), and `owner-unproven` when one is. Both unproven verdicts keep the runtime and are refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. No manager is also decided from evidence, never from a missing unit file (systemd keeps a running unit's process in its cgroup after the file is removed): no OCX_SERVICE at all, or the companion marker `OCX_COMPANION_PROXY` (`src/service/companion-proxy.ts`) that ocx claude, ocx codex and ocx opencode set on their own proxies. A bare OCX_SERVICE=1 with neither is what every service registered before v2.49 launches, and what an older companion carries: `owner-unproven`. Both unproven verdicts keep the runtime and are refused at admission even when the connection ended; only a process no manager owns detaches, carrying no owner marker. `ocx restart` and `ocx sync` (its own `owner-unproven` outcome, exit 1) advise `ocx stop` then `ocx start` for it (`OWNER_UNPROVEN_RECOVERY`); ending a companion's session would not end its detached proxy. `ocx disconnect` stops it itself: `stopCapturedRuntime` terminates the captured generation only after it attests the captured secret, and OpenCodex is started only once that exit was observed, the state is still disconnected and nothing (a manager's relaunch) answers. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From c6673cb7eb4e163dab9e13d1fd68c9984cc3dd15 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:13:15 +0900 Subject: [PATCH 42/54] Revert "docs(link): owner evidence, companion marker and owner-unproven recovery" This reverts commit 340b067d193129701b8e068234c423ed008d5896. --- docs-site/src/content/docs/guides/remote-link.md | 4 ++-- structure/ops/service-and-sidecars.md | 2 +- structure/remote-link.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 79c3272f06f..5115609da29 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -90,7 +90,7 @@ A Child runs on the Home's providers, accounts and settings, so the Home is wher - **Dashboard.** The Child's dashboard shows the Home's state read-only and says "Managed by Home over Remote Link". Dashboard and Providers read the Home through the link, and the usage strip in the header shows the Home's quota. The model list is not shown on a Child, because building it runs provider discovery on the Home; Codex on the Child already offers the Home's models. For the same reason, the delegation and sidecar model pickers on a Child list the native models and the Home's current choices, not the Home's full routed list. Saving a change there is refused with a message that names the Home; make the change in the Home's dashboard. The Child's browser never receives a Home credential. - **Codex settings.** `ocx sync` on the Child (and connecting it) applies the Codex settings OpenCodex manages on the Home: Fast mode, the multi-agent mode, the web search sidecar switch, and the native sub-agent defaults when the Home syncs them. `ocx sync` and `ocx status` list the values that came from the Home. The default reasoning effort and the effort levels shown in the Codex app's model picker are Codex app settings on each computer; OpenCodex does not manage them on any computer. - When OpenCodex Desktop or a background service supervises the Child runtime, sync succeeds after applying settings and reports that the runtime restart is deferred. The settings take effect when that supervisor restarts the runtime: use **Quit** in the OpenCodex tray menu, then reopen OpenCodex, or run `ocx service restart` for a service. A runtime whose owner cannot be proven (see above) is not deferred: sync exits 1, keeps the runtime, and says to run `ocx stop` and then `ocx start`. + When OpenCodex Desktop or a background service supervises the Child runtime, sync succeeds after applying settings and reports that the runtime restart is deferred. The settings take effect when that supervisor restarts the runtime: use **Quit** in the OpenCodex tray menu, then reopen OpenCodex, or run `ocx service restart` for a service. - **CLI.** Commands that only read, such as `ocx account list`, `ocx provider quota` and `ocx usage`, read the Home. They show what the Home has already observed: a read from a Child never makes the Home probe a provider, refresh a token or start a process, so quota and account details are as fresh as the Home's own activity keeps them. Reads that only exist to run provider discovery or a forced refresh, such as `ocx models live`, `ocx models preset show` and `ocx account list --quota`, are refused like changes. Commands that change configuration are refused with one message that names the Home and the link and says no request was sent. `ocx status` describes the Home and the link. `ocx provider list` and `ocx config get` still show this computer's own configuration, labelled as local and inactive while the computer is linked. `ocx config set`, `ocx config unset` and `ocx config import` still write locally, each with the same note, and `ocx provider add`, `remove` and `set-default` are refused because those providers are not used while linked. An import that would replace or remove an active client connection is refused. Disconnect that client first, or import a file that preserves its connection record exactly. @@ -109,7 +109,7 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` -On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. A runtime that carries only `OCX_SERVICE=1` (a service installed before 2.49, or a proxy an older `ocx claude`, `ocx codex` or `ocx opencode` started) has no owner that can be proven: `ocx restart` keeps it and says to run `ocx stop` and then `ocx start`, while `ocx disconnect` stops that exact runtime itself and starts OpenCodex once it has exited. +On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. ## Troubleshooting diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 37e5b93dbc7..ddd5903cfab 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -236,7 +236,7 @@ deliberate stop. Existing launchd jobs retain their old keepalive definition until `ocx service repair` rewrites and reloads the changed plist. New WinSW XML stamps `OCX_SERVICE_MANAGED=1`; parent command-line inference applies only to legacy registrations whose XML lacks that marker. Bare `OCX_SERVICE=1` -is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too (they add `OCX_COMPANION_PROXY`, which a client restart reads as evidence that no manager owns the runtime); Codex's client-token loading separately requires a managed loopback destination. A +is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too; Codex's client-token loading separately requires a managed loopback destination. A marker-less Windows registration is still recognised by the parent's command line naming this install's wrapper script, launcher or WinSW host as a complete token in any position, and an unreadable parent command line is no evidence and proceeds. POSIX keeps the diff --git a/structure/remote-link.md b/structure/remote-link.md index fd79027b612..740f81e562d 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. No manager is also decided from evidence, never from a missing unit file (systemd keeps a running unit's process in its cgroup after the file is removed): no OCX_SERVICE at all, or the companion marker `OCX_COMPANION_PROXY` (`src/service/companion-proxy.ts`) that ocx claude, ocx codex and ocx opencode set on their own proxies. A bare OCX_SERVICE=1 with neither is what every service registered before v2.49 launches, and what an older companion carries: `owner-unproven`. Both unproven verdicts keep the runtime and are refused at admission even when the connection ended; only a process no manager owns detaches, carrying no owner marker. `ocx restart` and `ocx sync` (its own `owner-unproven` outcome, exit 1) advise `ocx stop` then `ocx start` for it (`OWNER_UNPROVEN_RECOVERY`); ending a companion's session would not end its detached proxy. `ocx disconnect` stops it itself: `stopCapturedRuntime` terminates the captured generation only after it attests the captured secret, and OpenCodex is started only once that exit was observed, the state is still disconnected and nothing (a manager's relaunch) answers. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. A bare OCX_SERVICE=1 is an ocx claude or ocx opencode companion when no service is installed (it restarts normally), and `owner-unproven` when one is. Both unproven verdicts keep the runtime and are refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From b7207107028de17e960049529b817c812c33b873 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:13:15 +0900 Subject: [PATCH 43/54] Revert "fix(link): give an owner-unproven client runtime advice and steps that end it" This reverts commit c4b83b6da8ee66892d9d580cf2299ef4e9381cab. --- src/cli/disconnect-runtime.ts | 64 ++--------------- src/cli/dispatch.ts | 11 +-- src/cli/link-apply-restart.ts | 15 +--- src/cli/restart-failure.ts | 10 +-- src/cli/sync-command.ts | 4 +- tests/cli/disconnect-runtime.test.ts | 104 +++------------------------ tests/cli/link-apply-restart.test.ts | 26 +------ 7 files changed, 23 insertions(+), 211 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index dc445633a37..f91792397bc 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -8,10 +8,6 @@ * Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. */ import type { ProxyRestartIo, ProxyRestartLive, ProxyRestartRequestOutcome } from "./tray-proxy"; -import { OWNER_UNPROVEN_RECOVERY } from "./restart-failure"; -import { createLocalAttestationChallenge, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof } from "../lib/local-management-attestation"; -import { directLocalHttpFetch } from "../server/direct-local-http"; -import { probeHostname } from "../server/proxy-liveness"; /** * One runtime process: its runtime record. The attestation secret is created per process @@ -45,48 +41,11 @@ export interface DisconnectRuntimeDeps { * supervisor that refused it: an owning service manager whose relaunch could not be proven. */ restart: (target: RuntimeGeneration) => Promise<{ ok: boolean; refusedBy?: "service-unproven" | "owner-unproven" }>; - /** - * Stop exactly the captured runtime when its owner could not be proven ({@link stopCapturedRuntime}): - * "exited" only once its exit was observed, "changed" when another process answers for it. - */ - stopCaptured: (target: RuntimeGeneration) => Promise<"exited" | "changed" | "kept">; - /** Start OpenCodex the way `ocx restart` does when nothing runs (the saved state decides the role). */ - start: () => Promise; json: boolean; info?: (line: string) => void; error?: (line: string) => void; } -/** - * Stop one runtime generation: it must prove the captured attestation secret right before it is - * terminated (a process at a reused pid cannot), escalation re-checks that the runtime record still - * names that secret, and "exited" needs the process to be gone afterwards. - */ -export async function stopCapturedRuntime(target: RuntimeGeneration, io: { - attest: (target: RuntimeGeneration) => Promise; - terminate: (pid: number, stillTarget: () => boolean) => void; - alive: (pid: number) => boolean; - stillTarget: () => boolean; -}): Promise<"exited" | "changed" | "kept"> { - if (!(await io.attest(target))) return "changed"; - try { io.terminate(target.pid, io.stillTarget); } catch { /* decided by the exit check below */ } - return io.alive(target.pid) ? "kept" : "exited"; -} - -/** Whether the listener at the captured port proves the captured secret for the captured pid now. */ -export async function attestRuntimeGeneration(target: RuntimeGeneration, fetchImpl: typeof directLocalHttpFetch = directLocalHttpFetch): Promise { - try { - const challenge = createLocalAttestationChallenge(); - const response = await fetchImpl(`http://${probeHostname(target.hostname)}:${target.port}/healthz`, { - method: "GET", headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, redirect: "error", signal: AbortSignal.timeout(2_000), - }, { timeoutMs: 2_000 }); - void response.body?.cancel().catch(() => {}); - return verifyLocalAttestationProof(target.attestationSecret, challenge, target.pid, target.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER)); - } catch { - return false; - } -} - /** * Bind a restart to one runtime generation. The restart discovers its target again, and a connect * that committed in between can have started a new client runtime there, even at a reused pid. @@ -131,7 +90,7 @@ export function recoveryAdvice(state: DisconnectClientState, answering: Answerin } switch (answering.kind) { case "captured": return answering.refusedBy === "owner-unproven" - ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection and did not exit when this machine stopped it. ${OWNER_UNPROVEN_RECOVERY}` + ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its owner could not be proven: it is either a service registered before the service manager\'s marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run ocx service repair and then ocx service restart; if it is a companion, end that ocx claude or ocx opencode session and start it again.` : answering.refusedBy ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its service manager did not allow a restart from here. Restart it through the manager: ocx service restart (check ocx service status).` : `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`; @@ -170,26 +129,11 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) } if (!client || !target) return 0; // A --json caller reads one document from stdout; restart progress goes to stderr. - const toStderr = async (step: () => Promise): Promise => { - const originalLog = console.log; - if (deps.json) console.log = (...args: unknown[]) => console.error(...args); - try { return await step(); } finally { console.log = originalLog; } - }; + const originalLog = console.log; + if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; let refusedBy: "service-unproven" | "owner-unproven" | undefined; - ({ ok, refusedBy } = await toStderr(() => deps.restart(target))); - if (!ok && refusedBy === "owner-unproven" && (await deps.stopCaptured(target)) === "exited") { - // No owner could be proven to relaunch it, and none may be assumed absent: this exact - // generation was stopped, and OpenCodex starts only once that exit was observed, the state is - // still disconnected, and nothing (such as a manager's relaunch) answers instead. - refusedBy = undefined; - const stopped = deps.connectionState(); - if (stopped.kind !== "disconnected") { - error(`The client runtime (pid ${target.pid}) was stopped, but the client state changed meanwhile, so OpenCodex was not started. ${recoveryAdvice(stopped, { kind: "unknown" })}`); - return 1; - } - if ((await deps.observe()).kind === "absent") await toStderr(deps.start); - } + try { ({ ok, refusedBy } = await deps.restart(target)); } finally { console.log = originalLog; } const after = await deps.observe(); if (after.kind === "live" && after.live.role !== "client") { const late = deps.connectionState(); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index c1c3b39e4d6..69c1dd63602 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -427,7 +427,7 @@ const commandRunners: Record = { const code = await handleDisconnectCommand(deps.args.slice(1)); // Token recovery refuses any live client runtime and leaves nothing to restart. if (code !== 0 || deps.args.includes("--orphaned-token")) return code; - const { attestRuntimeGeneration, bindRestartToRuntime, restartRuntimeAfterDisconnect, stopCapturedRuntime } = await import("./disconnect-runtime"); + const { bindRestartToRuntime, restartRuntimeAfterDisconnect } = await import("./disconnect-runtime"); const { observeProxyLiveness } = await import("../server/proxy-liveness"); const { readClientConnectionStateWithEvidence } = await import("../client/state"); const { readRuntimePort } = await import("../config/process-state"); @@ -451,15 +451,6 @@ const commandRunners: Record = { requestBoundSystemRestart(previous as Parameters[0], deadlineAt, { readRuntime: readRuntime as typeof readRuntimePort }))); return { ok, ...(refusedBy ? { refusedBy } : {}) }; }, - stopCaptured: async target => { - const { isProcessAlive, killProxy } = await import("../lib/process-control"); - return await stopCapturedRuntime(target, { - attest: attestRuntimeGeneration, terminate: killProxy, alive: isProcessAlive, - stillTarget: () => readRuntimePort(target.pid)?.attestationSecret === target.attestationSecret, - }); - }, - // The captured runtime was running and its exit was observed: recover it like a vanished restart. - start: () => deps.handleRestartStartWhenStopped(true), json: deps.args.includes("--json"), }); }, diff --git a/src/cli/link-apply-restart.ts b/src/cli/link-apply-restart.ts index af9fae80265..bd0e21f52f2 100644 --- a/src/cli/link-apply-restart.ts +++ b/src/cli/link-apply-restart.ts @@ -11,16 +11,14 @@ import type { HomeCodexSettingsRecord } from "../client/link-home"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { ProxyRestartStartOutcome } from "./tray-proxy"; -import { OWNER_UNPROVEN_RECOVERY, type RestartSupervisor } from "./restart-failure"; +import type { RestartSupervisor } from "./restart-failure"; export type ApplyRestartOutcome = | { kind: "current" } | { kind: "not-running" } | { kind: "connection-changed" } | { kind: "restarted" } - | { kind: "deferred-supervisor"; supervisor: Exclude } - /** Kept by a runtime whose owner cannot be proven: no owner will restart it, so the apply is not in effect. */ - | { kind: "owner-unproven" } + | { kind: "deferred-supervisor"; supervisor: RestartSupervisor } | { kind: "failed" }; export interface ApplyRestartDeps { @@ -62,16 +60,10 @@ export async function restartRuntimeAfterApply(record: HomeCodexSettingsRecord, const ok = await deps.restart(async recovering => recovering ? deps.recoveryStart(true) : { status: "skipped" }, value => { supervisor = value; }); - const refusedBy = supervisor as RestartSupervisor | undefined; - if (!ok && refusedBy) return refusedBy === "owner-unproven" ? { kind: "owner-unproven" } : { kind: "deferred-supervisor", supervisor: refusedBy }; + if (!ok && supervisor) return { kind: "deferred-supervisor", supervisor }; return ok ? { kind: "restarted" } : { kind: "failed" }; } -/** `ocx sync` exits 1 for these: the applied settings are not in effect and no owner will fix that. */ -export function applyRestartFailed(outcome: ApplyRestartOutcome): boolean { - return outcome.kind === "failed" || outcome.kind === "connection-changed" || outcome.kind === "owner-unproven"; -} - /** One line for each outcome, printed by `ocx sync`. */ export function applyRestartMessage(outcome: ApplyRestartOutcome): string { switch (outcome.kind) { @@ -82,7 +74,6 @@ export function applyRestartMessage(outcome: ApplyRestartOutcome): string { case "deferred-supervisor": return outcome.supervisor === "desktop" ? "Client runtime restart deferred; the settings take effect when OpenCodex Desktop restarts it. Use Quit in the OpenCodex tray menu, then reopen OpenCodex." : "Client runtime restart deferred; the settings take effect when the background service restarts it. Run `ocx service restart`."; - case "owner-unproven": return `The Home's settings were applied, but the client runtime was not restarted and keeps serving with the previous ones; they take effect when it starts again. ${OWNER_UNPROVEN_RECOVERY}`; case "failed": return "The client runtime restart did not complete; the previous runtime keeps serving. If a background service or the desktop app owns it, restart it there; otherwise retry with 'ocx restart' or 'ocx sync'."; } } diff --git a/src/cli/restart-failure.ts b/src/cli/restart-failure.ts index 62c34366091..6113ba5a01e 100644 --- a/src/cli/restart-failure.ts +++ b/src/cli/restart-failure.ts @@ -19,14 +19,6 @@ export function deferSupervisedRestart(result: ProxyRestartResult, notify: (supe return true; } -/** - * What is left to do for a client runtime whose owner cannot be proven (a bare OCX_SERVICE=1: - * a service registered before v2.49 or a proxy an older ocx claude or ocx opencode started). - * Ending a companion's session does not end its detached proxy; `ocx stop` does, through the - * runtime the pid file and runtime record name. `ocx disconnect` does this step itself. - */ -export const OWNER_UNPROVEN_RECOVERY = "Its owner cannot be proven: it carries OCX_SERVICE=1 without a service manager's marker or the marker ocx claude and ocx opencode now set, so it is either a service installed before v2.49 or a proxy an older ocx claude or ocx opencode started. Stop it with 'ocx stop', check that 'ocx status' reports no proxy running, then start OpenCodex with 'ocx start' (or 'ocx service start' if a service should own it)."; - export const INCOMPLETE_INSTALL_RECOVERY = "If the installer already exited or failed, run `ocx status`, stop any running proxy through its owner (`ocx stop`, or its service or desktop app), then reinstall with the same package manager and start it again (see https://opencodex.me/troubleshooting/update-failed/)."; export function reportRestartFailure(result: Extract): void { @@ -48,7 +40,7 @@ export function reportRestartFailure(result: Extract { // OCX-6: a link Child's runtime must have started after the apply it serves under. // Not after a failed handover: that restart is the one that would undo this routing. if (link && result.appliedRecord && !result.journalHandoverFailed) { - const { applyRestartFailed, applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart"); + const { applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart"); const { sameClientConnectionOwner } = await import("../client/state"); const applied = result.connection; const outcome = await restartRuntimeAfterApply(result.appliedRecord, { @@ -110,7 +110,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { }, }); const message = applyRestartMessage(outcome); - if (applyRestartFailed(outcome)) { + if (outcome.kind === "failed" || outcome.kind === "connection-changed") { console.error(message); process.exitCode = 1; } else { diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 96c8282af1d..8a4db5cf160 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { attestRuntimeGeneration, bindRestartToRuntime, restartRuntimeAfterDisconnect, stopCapturedRuntime, type DisconnectRuntimeDeps, type RuntimeGeneration } from "../../src/cli/disconnect-runtime"; -import { OWNER_UNPROVEN_RECOVERY } from "../../src/cli/restart-failure"; +import { bindRestartToRuntime, restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps, type RuntimeGeneration } from "../../src/cli/disconnect-runtime"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; import { runProxyRestart, type ProxyRestartIo } from "../../src/cli/tray-proxy"; import { createLocalAttestationProof, createLocalAttestationSecret, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; @@ -11,27 +10,20 @@ type State = ReturnType; const live = (role?: string, pid = 4321): Observation => ({ kind: "live", live: { pid, port: 10100, ...(role ? { role } : {}) } }); const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret() }; -function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" | "owner-unproven"; observations?: Observation[]; stopped?: "exited" | "changed" | "kept" } = {}) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" | "owner-unproven" } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; const bound: RuntimeGeneration[] = []; - const events: string[] = []; const done = restartRuntimeAfterDisconnect({ - observe: async () => { - const seen = extra.observations ? extra.observations.shift()! : restarts > 0 ? extra.after ?? live("standalone", 9999) : observation; - events.push(`observe:${seen.kind}${seen.live?.role ? `:${seen.live.role}` : ""}`); - return seen; - }, + observe: async () => (restarts > 0 ? extra.after ?? live("standalone", 9999) : observation), capture: () => (restarts > 0 && extra.captureAfter !== undefined ? extra.captureAfter : extra.capture === undefined ? generation : extra.capture), connectionState: () => extra.states?.shift() ?? extra.state ?? { kind: "disconnected" }, - restart: async target => { restarts += 1; bound.push(target); events.push("restart"); return { ok: restartOk, ...(extra.refusedBy ? { refusedBy: extra.refusedBy } : {}) }; }, - stopCaptured: async target => { events.push(`stop:${target.pid}`); return extra.stopped ?? "kept"; }, - start: async () => { events.push("start"); }, + restart: async target => { restarts += 1; bound.push(target); return { ok: restartOk, ...(extra.refusedBy ? { refusedBy: extra.refusedBy } : {}) }; }, json, info: line => out.push(line), error: line => err.push(line), }); - return done.then(code => ({ code, out, err, restarts, bound, events })); + return done.then(code => ({ code, out, err, restarts, bound })); } describe("ocx disconnect restarts the client runtime it left behind", () => { @@ -188,47 +180,15 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(text).not.toContain("Run on this machine: ocx restart"); }); - // Final review round 3: a runtime whose owner cannot be proven is stopped by its captured - // generation; OpenCodex is started only after that exit was observed. - test("an owner-unproven runtime is stopped, its exit observed, and only then OpenCodex started", async () => { - const result = await run(live("client"), false, false, { refusedBy: "owner-unproven", stopped: "exited", observations: [live("client"), { kind: "absent", live: null }, live("standalone", 9999)] }); - expect(result.events).toEqual(["observe:live:client", "restart", "stop:4321", "observe:absent", "start", "observe:live:standalone"]); - expect(result.code).toBe(0); - expect(result.out.join("\n")).toContain("now runs standalone"); - }); - - test("a manager that relaunched the stopped runtime as standalone needs no start", async () => { - const result = await run(live("client"), false, false, { refusedBy: "owner-unproven", stopped: "exited", observations: [live("client"), live("standalone", 9999), live("standalone", 9999)] }); - expect(result.events).not.toContain("start"); - expect(result.code).toBe(0); - }); - - test("an owner-unproven runtime that did not exit is kept, and the advice is ocx stop", async () => { - const result = await run(live("client"), false, false, { after: live("client"), refusedBy: "owner-unproven", stopped: "kept" }); + test("a restart refused because its owner is unproven names both possible owners", async () => { + const result = await run(live("client"), false, false, { after: live("client"), refusedBy: "owner-unproven" }); expect(result.code).toBe(1); - expect(result.events).not.toContain("start"); const text = result.err.join("\n"); - expect(text).toContain(OWNER_UNPROVEN_RECOVERY); - expect(OWNER_UNPROVEN_RECOVERY).toContain("ocx stop"); - expect(text).not.toContain("session"); + expect(text).toContain("ocx service repair"); + expect(text).toContain("ocx claude or ocx opencode"); expect(text).not.toContain("Run on this machine: ocx restart"); }); - test("another process answering for the captured runtime is not stopped and nothing is started", async () => { - const other = { ...generation, attestationSecret: createLocalAttestationSecret() }; - const result = await run(live("client"), false, false, { after: live("client"), captureAfter: other, refusedBy: "owner-unproven", stopped: "changed" }); - expect(result.code).toBe(1); - expect(result.events).not.toContain("start"); - expect(result.err.join("\n")).toContain("A different client runtime"); - }); - - test("a connection that lands while the runtime stops gets no start", async () => { - const result = await run(live("client"), false, false, { refusedBy: "owner-unproven", stopped: "exited", states: [{ kind: "disconnected" }, { kind: "connected" }, { kind: "connected" }], observations: [live("client"), { kind: "absent", live: null }] }); - expect(result.code).toBe(1); - expect(result.events).not.toContain("start"); - expect(result.err.join("\n")).toContain("A new connection is in use"); - }); - // P3: after a completed restart, an unreadable state must not also claim nothing was restarted. test("a completed restart followed by an unreadable state does not say nothing was restarted", async () => { const result = await run(live("client"), true, false, { states: [{ kind: "disconnected" }, { kind: "invalid", reason: "unreadable" }] }); @@ -316,49 +276,3 @@ describe("a disconnect restart bound to the captured runtime generation", () => expect(w.posts).toEqual([]); }); }); - -describe("stopping exactly the captured runtime", () => { - const io = (attested: boolean, exits: boolean, throws = false) => { - const calls: string[] = []; - return { - calls, - value: { - attest: async (target: RuntimeGeneration) => { calls.push(`attest:${target.attestationSecret === generation.attestationSecret}`); return attested; }, - terminate: (pid: number, stillTarget: () => boolean) => { calls.push(`terminate:${pid}:${stillTarget()}`); if (throws) throw new Error("refused"); }, - alive: (pid: number) => { calls.push(`alive:${pid}`); return !exits; }, - stillTarget: () => true, - }, - }; - }; - test("an attested runtime is terminated and reported exited only once its exit is observed", async () => { - const ok = io(true, true); - expect(await stopCapturedRuntime(generation, ok.value)).toBe("exited"); - expect(ok.calls).toEqual(["attest:true", "terminate:4321:true", "alive:4321"]); - const stuck = io(true, false); - expect(await stopCapturedRuntime(generation, stuck.value)).toBe("kept"); - }); - test("a runtime that cannot prove the captured secret is never terminated", async () => { - const other = io(false, true); - expect(await stopCapturedRuntime(generation, other.value)).toBe("changed"); - expect(other.calls).toEqual(["attest:true"]); - }); - test("a refused termination of a live process is kept", async () => { - const refused = io(true, false, true); - expect(await stopCapturedRuntime(generation, refused.value)).toBe("kept"); - }); - - test("attestation needs the captured secret, pid and port, answered by the listener", async () => { - const secret = createLocalAttestationSecret(); - const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: request => new Response("{}", { - headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof(secret, request.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) ?? "", 4321, server.port!) ?? "" }, - }) }); - try { - const target = { pid: 4321, port: server.port!, hostname: "127.0.0.1", attestationSecret: secret }; - expect(await attestRuntimeGeneration(target)).toBe(true); - expect(await attestRuntimeGeneration({ ...target, attestationSecret: createLocalAttestationSecret() })).toBe(false); - expect(await attestRuntimeGeneration({ ...target, pid: 4322 })).toBe(false); - } finally { - await server.stop(true); - } - }); -}); diff --git a/tests/cli/link-apply-restart.test.ts b/tests/cli/link-apply-restart.test.ts index 62407f90807..8c1b211933e 100644 --- a/tests/cli/link-apply-restart.test.ts +++ b/tests/cli/link-apply-restart.test.ts @@ -11,9 +11,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyRestartFailed, applyRestartMessage, restartRuntimeAfterApply, type ApplyRestartDeps } from "../../src/cli/link-apply-restart"; +import { applyRestartMessage, restartRuntimeAfterApply, type ApplyRestartDeps } from "../../src/cli/link-apply-restart"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; -import { deferSupervisedRestart, OWNER_UNPROVEN_RECOVERY, reportRestartFailure, supervisedRestartRefusal } from "../../src/cli/restart-failure"; +import { deferSupervisedRestart, reportRestartFailure, supervisedRestartRefusal } from "../../src/cli/restart-failure"; import { collectAppliedCodexSettingsStatus } from "../../src/cli/status-codex-settings"; import { recordHomeCodexSettings, type HomeCodexSettingsRecord } from "../../src/client/link-home"; import type { HomeCodexSettingsDocument } from "../../src/link/codex-settings"; @@ -119,26 +119,6 @@ describe("ocx sync restarts the runtime after an apply", () => { expect(calls).toEqual([]); }); } - - // Final review round 3: an owner that cannot be proven is not a background service. Sync reports - // it as its own outcome, exits 1 (the apply is not in effect while the old runtime serves), and - // names the command that stops that runtime instead of 'ocx service restart'. - test("an owner-unproven refusal is its own failed outcome with its own advice", async () => { - const { value, calls } = deps(0, { restart: async (_start, onSupervised) => { onSupervised("owner-unproven"); return false; } }); - const outcome = await restartRuntimeAfterApply(record, value); - expect(outcome).toEqual({ kind: "owner-unproven" }); - expect(applyRestartFailed(outcome)).toBe(true); - const message = applyRestartMessage(outcome); - expect(message).toContain(OWNER_UNPROVEN_RECOVERY); - expect(message).not.toContain("background service"); - expect(calls).toEqual([]); - for (const supervisor of ["desktop", "service", "service-unproven"] as const) { - expect(applyRestartFailed({ kind: "deferred-supervisor", supervisor })).toBe(false); - } - expect(applyRestartFailed({ kind: "failed" })).toBe(true); - expect(applyRestartFailed({ kind: "connection-changed" })).toBe(true); - expect(applyRestartFailed({ kind: "restarted" })).toBe(false); - }); }); function clientHealthDeps(postResponse: Response) { @@ -222,7 +202,7 @@ describe("ocx restart against a client runtime", () => { "Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.", "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it while its connection is current: use Quit in the OpenCodex tray menu, then reopen it. After `ocx disconnect`, `ocx restart` hands it to the app.", "Restart not performed; the running client runtime was kept. A service manager started it (systemd opencodex-proxy.service, launchd com.opencodex.proxy or the Windows OpenCodex service), but it is not enabled or its relaunch could not be verified: restart it through the manager with 'ocx service restart', or check 'ocx service status'.", - `Restart not performed; the running client runtime was kept. ${OWNER_UNPROVEN_RECOVERY}`, + "Restart not performed; the running client runtime was kept. It was started with OCX_SERVICE=1 but without the service manager's marker while a service is installed, so it is either a service registered before that marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run 'ocx service repair' and then 'ocx service restart'; if it is a companion, end that ocx claude or ocx opencode session and start it again.", ]); }); }); From fc57a28596e6798861e2d56156ab1cd5fc1903db Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:13:15 +0900 Subject: [PATCH 44/54] Revert "fix(link): decide client restart ownership from positive evidence only" This reverts commit 707b92bb80c15f52e7daa79bd952a95c103c9860. --- src/cli/claude.ts | 9 +-- src/cli/codex-launcher.ts | 5 +- src/cli/opencode.ts | 4 +- src/client/runtime-restart.ts | 24 +++--- src/client/runtime.ts | 4 +- src/service/companion-proxy.ts | 9 --- tests/cli/cli-dispatch.test.ts | 2 +- tests/clients/client-runtime-restart.test.ts | 78 +++++++------------- tests/clients/client-runtime.test.ts | 2 +- 9 files changed, 47 insertions(+), 90 deletions(-) delete mode 100644 src/service/companion-proxy.ts diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 9274587073c..33d99cb3268 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -37,16 +37,15 @@ import { desktopProfileWireAlias, legacyDesktopProfileWireAlias } from "../claud import { inspectDesktopFirstParty } from "../claude/desktop-first-party"; import { isClaudeInterceptProxyUrl, type ClaudeInterceptSettingsState } from "../claude/intercept/settings"; import { withoutSiblingMarker } from "../codex/sibling-start"; -import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; /** * The environment of the detached `ocx start` that `ocx claude` launches (the spawn adds the runtime - * provenance stamp): an ordinary owner. It carries OCX_SERVICE=1 and the companion marker, which a - * restart reads as positive evidence that no service manager owns it - * (src/client/runtime-restart.ts classifyClientRestartSupervisor). + * provenance stamp): an ordinary owner. It + * carries OCX_SERVICE=1 but no service manager's marker, which is how a restart tells it apart from + * a manager-launched process (src/client/runtime-restart.ts classifyClientRestartSupervisor). */ export function claudeProxyStartEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return withoutSiblingMarker({ ...base, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "claude" }); + return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" }); } export interface ClaudeLaunchEnv { diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index d19632fde51..7b328602308 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -38,7 +38,6 @@ import { attestCodexRuntime, codexCredentialDestinationIsTrusted, resolveCodexLa import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; import { codexTokenWithheldDiagnostic, type CodexTokenRefusal, type CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; -import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; export interface CodexLaunchEnv { [key: string]: string | undefined; @@ -110,13 +109,13 @@ export function codexLaunchPlan(routingKind: CodexRoutingKind): CodexLaunchPlan } } -/** Env the detached `ocx start` child inherits (service-owned admission ladder); the companion marker tells a restart no manager owns it. */ +/** Env the detached `ocx start` child inherits (service-owned admission ladder). */ export function codexProxyStartEnv(base: CodexLaunchEnv = process.env): CodexLaunchEnv { const stripped = withoutSiblingMarker(base) as CodexLaunchEnv; const withTokenFile = stripped.OPENCODEX_API_AUTH_TOKEN?.trim() ? stripped : { ...stripped, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; - return { ...withTokenFile, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "codex" } as CodexLaunchEnv; + return { ...withTokenFile, OCX_SERVICE: "1" } as CodexLaunchEnv; } export type CodexProxyEnsureDeps = { diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index b4abb4871b2..ab66e8dd253 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -47,7 +47,6 @@ import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets import { localManagementOrigin } from "../lib/local-destinations"; import { directLocalHttpFetch } from "../server/direct-local-http"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; -import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -571,13 +570,12 @@ function serviceTokenLookupEnv(env: OpencodeLaunchEnv): OpencodeLaunchEnv { * Child env for a detached `ocx start` from `ocx opencode`. When the admission token is * not already in the environment, pass through an existing `OCX_API_TOKEN_FILE` or the * default hardened service token path so `handleStart` can load it before bind. - * The companion marker tells a restart that no service manager owns this proxy. */ export function opencodeProxyStartEnv(base: OpencodeLaunchEnv = process.env): OpencodeLaunchEnv { const withTokenFile = base.OPENCODEX_API_AUTH_TOKEN?.trim() ? base : serviceTokenLookupEnv(base); - return { ...withTokenFile, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "opencode" }; + return { ...withTokenFile, OCX_SERVICE: "1" }; } /** diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index cc53d4d1290..a322b4ea8ba 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -28,11 +28,9 @@ import { } from "../lib/system-restart-contract"; import type { OcxClientConnectionConfig } from "../types"; import { isSupervisedServiceChild } from "../service/service-child-ownership"; -import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; import { sameClientConnectionOwner, type readClientConnectionState } from "./state"; type ClientConnectionState = ReturnType; -const COMPANION_PROXY_LAUNCHERS = new Set(["claude", "codex", "opencode"]); /** * `service-unproven`: a service manager launched and owns this process (OCX_SERVICE or @@ -160,29 +158,27 @@ export async function clientRestartSupervisor(): Promise { viable: boolean; running: boolean }; + diagnose: () => { installed: boolean; viable: boolean; running: boolean }; /** Manager-launched evidence; defaults to src/service/service-child-ownership.ts. */ managed?: (env: NodeJS.ProcessEnv) => boolean; }): ClientRestartSupervisor | null { if (input.desktop) return "desktop"; // The managed marker (launchd, systemd, WinSW), the Windows wrapper's protocol marker, or a - // legacy Windows wrapper parent. + // legacy Windows wrapper parent. A bare OCX_SERVICE=1 is also what ocx claude and ocx opencode + // give their own proxies, so it alone proves no manager. if (!(input.managed ?? isSupervisedServiceChild)(input.env)) { if (input.env.OCX_SERVICE !== "1") return null; - // Never inferred from a missing unit file: systemd keeps a running unit's process in its cgroup - // after the file is removed. - return COMPANION_PROXY_LAUNCHERS.has(input.env[COMPANION_PROXY_ENV] ?? "") ? null : "owner-unproven"; + // Bare marker: a companion when no service is installed; with one installed it may also be a + // service registered before the managed marker existed, which nothing here can tell apart. + return input.diagnose().installed ? "owner-unproven" : null; } // Relaunch needs positive evidence: viable AND running (launchd's viable also covers an // unknown load state for its other callers; running there means loaded). diff --git a/src/client/runtime.ts b/src/client/runtime.ts index fdbee3b3217..bee9d5a5ed3 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -33,7 +33,6 @@ import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; -import { COMPANION_PROXY_ENV } from "../service/companion-proxy"; import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; let activeServer: Server | null = null; @@ -114,12 +113,11 @@ export function standaloneRecycleEnv( ): NodeJS.ProcessEnv { const childEnv = { ...env }; // A detached replacement is nobody's child: not the desktop app's, not a service's, not a Windows - // wrapper's, not a companion launcher's. It must carry no owner marker it would misread as its own. + // wrapper's. It must carry no supervisor marker it would otherwise misread as its own. delete childEnv[DESKTOP_SUPERVISED_ENV]; delete childEnv.OCX_SERVICE; delete childEnv.OCX_SERVICE_MANAGED; delete childEnv.OCX_WINDOWS_WRAPPER_PROTOCOL; - delete childEnv[COMPANION_PROXY_ENV]; const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim(); if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) { // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can diff --git a/src/service/companion-proxy.ts b/src/service/companion-proxy.ts deleted file mode 100644 index 4650a03d943..00000000000 --- a/src/service/companion-proxy.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * The marker `ocx claude` and `ocx opencode` (and `ocx minimax`, which reuses the OpenCode launch) - * set on the detached `ocx start` they launch, naming the launcher. Their proxies also carry - * OCX_SERVICE=1, which is all a service registered before v2.49 gives its process, so a restart - * reads this marker as the positive evidence that no service manager owns the runtime - * (src/client/runtime-restart.ts classifyClientRestartSupervisor). A manager's own marker - * (OCX_SERVICE_MANAGED, the Windows wrapper marker) always wins over it. - */ -export const COMPANION_PROXY_ENV = "OCX_COMPANION_PROXY"; diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index d5db9d1187c..c3ba06e6b09 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -744,7 +744,7 @@ describe("a sibling start leaves shared client routing to the live owner", () => for (const [path, env] of [ // ocx claude builds its proxy's environment in claudeProxyStartEnv (also read by the restart's // owner classification) and spawns with it. - ["src/cli/claude.ts", 'return withoutSiblingMarker({ ...base, OCX_SERVICE: "1", [COMPANION_PROXY_ENV]: "claude" });'], + ["src/cli/claude.ts", 'return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" });'], ["src/cli/claude.ts", "env: withProcessRuntimeProvenance(claudeProxyStartEnv()),"], ["src/cli/opencode.ts", opencodeStartEnv], ["src/cli/minimax.ts", opencodeStartEnv], diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 353bb88bed9..264e242ac45 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -6,18 +6,16 @@ * replacement, and a replacement that does not come up leaves the old runtime serving. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import * as fs from "node:fs"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; -import { readClientConnectionState, readClientConnectionStateWithEvidence } from "../../src/client/state"; -import { COMPANION_PROXY_ENV } from "../../src/service/companion-proxy"; +import { readClientConnectionStateWithEvidence } from "../../src/client/state"; +import * as configModule from "../../src/config"; import { claudeProxyStartEnv } from "../../src/cli/claude"; import { opencodeProxyStartEnv } from "../../src/cli/opencode"; -import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; import { spyOn } from "bun:test"; import { deriveLaunchdServiceDiagnostic, deriveSystemdServiceDiagnostic, deriveWindowsServiceDiagnostic } from "../../src/service/diagnostics"; import { @@ -368,21 +366,6 @@ describe("client restart controller", () => { expect(controller.hasHandoff()).toBe(false); }); - test("a supervisor verdict that changes between the admission and the recycle lookups is honoured", async () => { - // Admitted with no supervisor, then unproven at the second lookup: kept, no recycle. - const turned = [null, "owner-unproven"] as const; - let i = 0; - const late = fakeIo({ supervisor: async () => turned[Math.min(i++, 1)] ?? null, connectionChanged: () => true, recycle: async () => { late.calls.push("recycle"); } }); - const controller = createClientRestartController(late.io); - expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); - await controller.settled(); - expect(late.calls.filter(call => call === "recycle")).toEqual([]); - // Unproven at admission: refused before anything is accepted. - const early = fakeIo({ supervisor: async () => "service-unproven", connectionChanged: () => true, recycle: async () => { early.calls.push("recycle"); } }); - expect(await createClientRestartController(early.io).request()).toEqual({ kind: "supervised", supervisor: "service-unproven" }); - expect(early.calls).toEqual([]); - }); - test("a second request while one is in flight joins it", async () => { let finish!: () => void; let spawns = 0; @@ -418,32 +401,23 @@ describe("who supervises a client restart (real service diagnostics)", () => { expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE_MANAGED: "1" }, diagnose: () => unknown })).toBe("service-unproven"); }); - // Final review round 3: ownership comes from positive evidence only. ocx claude and ocx opencode - // mark their own proxies (COMPANION_PROXY_ENV); a bare OCX_SERVICE=1 without that marker is also - // what every service registered before v2.49 launches, whether or not its unit file still exists. + // Final review round 2: ocx claude and ocx opencode start their own proxies with OCX_SERVICE=1; + // through their real launch environments, with no service installed, nothing supervises them. const notInstalled = deriveSystemdServiceDiagnostic({ installed: false, enabled: false, running: false, stale: false, diagnostics: "test" }); const installedDisabled = systemd(false, false); test.each([ ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], - ["ocx codex", () => codexProxyStartEnv({ PATH: "/usr/bin" }) as NodeJS.ProcessEnv], ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], - ] as const)("a %s companion is not supervised, whatever service is installed", (_name, env) => { + ] as const)("a %s companion with no service installed is not supervised", (_name, env) => { expect(env().OCX_SERVICE).toBe("1"); - for (const diagnostic of [notInstalled, installedDisabled, systemd(true, true)]) { - expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => diagnostic })).toBeNull(); - } + expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => notInstalled })).toBeNull(); }); - test("a bare OCX_SERVICE=1 is owner-unproven, also when no unit file exists (a pre-2.49 unit whose file is gone)", () => { - for (const diagnostic of [notInstalled, installedDisabled]) { - expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE: "1", PATH: "/usr/bin" }, diagnose: () => diagnostic })).toBe("owner-unproven"); - } - }); - - test("a companion marker never outranks a service manager's marker", () => { - const env = { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1", [COMPANION_PROXY_ENV]: "claude" }; - expect(classifyClientRestartSupervisor({ desktop: false, env, diagnose: () => systemd(true, true) })).toBe("service"); - expect(classifyClientRestartSupervisor({ desktop: false, env, diagnose: () => notInstalled })).toBe("service-unproven"); + test.each([ + ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], + ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], + ] as const)("a bare OCX_SERVICE=1 (%s environment) while a service is installed is owner-unproven", (_name, env) => { + expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => installedDisabled })).toBe("owner-unproven"); }); test("the Windows wrapper's marker counts as manager-launched", () => { @@ -489,22 +463,24 @@ describe("connection-ended evidence from the real state reader", () => { expect(clientConnectionEnded(link, state)).toBe(false); }); - // ...and the verdict comes from one read of config.json, so a connect that commits meanwhile cannot - // pair old bytes with a validation of new ones. Counted at the file system: the older two-read - // reader shows the count detects a second read. - test("the verdict comes from exactly one read of config.json", () => { - const path = join(root, "config.json"); - const reads = spyOn(fs, "readFileSync"); - const count = (read: () => unknown) => { - reads.mockClear(); - read(); - return reads.mock.calls.filter(([target]) => String(target) === path).length; - }; + // ...and the verdict comes from one snapshot: a connect committed after the file was read cannot + // pair the old bytes with a validation of the new ones. The hook commits at the second read. + test("a connection committed between reads never yields disconnected from a stale read", () => { + writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai" })); + const realDiagnostics = configModule.readConfigDiagnostics; + let committed = false; + const hook = spyOn(configModule, "readConfigDiagnostics").mockImplementation((options?: Parameters[0]) => { + if (!committed) { + committed = true; + writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai", runtimeRole: "client", client: link })); + } + return realDiagnostics(options); + }); try { - expect(count(readClientConnectionState)).toBe(2); - expect(count(readClientConnectionStateWithEvidence)).toBe(1); + const state = readClientConnectionStateWithEvidence(); + expect(committed && state.kind === "disconnected").toBe(false); } finally { - reads.mockRestore(); + hook.mockRestore(); } }); diff --git a/tests/clients/client-runtime.test.ts b/tests/clients/client-runtime.test.ts index 7786ea8ecfb..44126a5104e 100644 --- a/tests/clients/client-runtime.test.ts +++ b/tests/clients/client-runtime.test.ts @@ -196,7 +196,7 @@ describe("standalone recycle", () => { // Final review: an authorized detached replacement carries no supervisor marker at all. test("a detached replacement's environment carries no supervisor marker", async () => { await withServiceMarker("1", async () => { - const markers = ["OCX_SERVICE", "OCX_SERVICE_MANAGED", "OCX_DESKTOP_SUPERVISED", "OCX_WINDOWS_WRAPPER_PROTOCOL", "OCX_COMPANION_PROXY"]; + const markers = ["OCX_SERVICE", "OCX_SERVICE_MANAGED", "OCX_DESKTOP_SUPERVISED", "OCX_WINDOWS_WRAPPER_PROTOCOL"]; const previous = Object.fromEntries(markers.map(name => [name, process.env[name]])); for (const name of markers) process.env[name] = "1"; let env: NodeJS.ProcessEnv | undefined; From c413808398abde9038d6ed88a4dbe1b242108334 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:20:03 +0900 Subject: [PATCH 45/54] fix(link): a bare OCX_SERVICE=1 runtime keeps origin/dev restart, disconnect and sync behaviour Scope split after final review round 4. The owner-unproven verdict is gone. A runtime whose only supervisor evidence is a bare OCX_SERVICE=1 (a legacy service child or a companion proxy) gets origin/dev decision again (isServiceViable ? service : none, no ended-connection branch); the ended-connection recycle now needs positive ownership evidence (desktop, a manager marker, or no OCX_SERVICE). Such a runtime marks its record restartOwnerUnproven, and ocx disconnect leaves it alone with exit 0, as origin/dev did. Tests pin each command against the merge-base code. --- src/cli/disconnect-runtime.ts | 16 ++- src/cli/dispatch.ts | 4 +- src/cli/restart-failure.ts | 5 +- src/cli/system-restart-client.ts | 4 +- src/client/runtime-restart.ts | 53 +++++--- src/client/runtime.ts | 13 +- src/config/process-state.ts | 7 + tests/cli/disconnect-runtime.test.ts | 37 +++++- tests/cli/link-apply-restart.test.ts | 42 +++++- tests/clients/client-runtime-restart.test.ts | 128 +++++++++++++------ 10 files changed, 224 insertions(+), 85 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index f91792397bc..7d83fd5bfa4 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -19,6 +19,8 @@ export interface RuntimeGeneration { port: number; hostname?: string; attestationSecret: string; + /** The runtime published no positive evidence of its owner (a bare OCX_SERVICE=1). */ + restartOwnerUnproven?: true; } export type DisconnectClientState = @@ -40,7 +42,7 @@ export interface DisconnectRuntimeDeps { * `ocx restart` bound to this generation (see {@link bindRestartToRuntime}). `refusedBy` names a * supervisor that refused it: an owning service manager whose relaunch could not be proven. */ - restart: (target: RuntimeGeneration) => Promise<{ ok: boolean; refusedBy?: "service-unproven" | "owner-unproven" }>; + restart: (target: RuntimeGeneration) => Promise<{ ok: boolean; refusedBy?: "service-unproven" }>; json: boolean; info?: (line: string) => void; error?: (line: string) => void; @@ -70,7 +72,7 @@ export function bindRestartToRuntime( /** What is known about the runtime answering when the advice is given. */ export type AnsweringRuntime = - | { kind: "captured"; pid: number | null; refusedBy?: "service-unproven" | "owner-unproven" } + | { kind: "captured"; pid: number | null; refusedBy?: "service-unproven" } | { kind: "different"; pid: number | null } | { kind: "unknown" } | { kind: "none" }; @@ -89,9 +91,7 @@ export function recoveryAdvice(state: DisconnectClientState, answering: Answerin return `The client state is ${state.kind} (${state.reason}). Inspect it with ocx connect status and ocx status, fix the client state, then run ocx disconnect again.`; } switch (answering.kind) { - case "captured": return answering.refusedBy === "owner-unproven" - ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its owner could not be proven: it is either a service registered before the service manager\'s marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run ocx service repair and then ocx service restart; if it is a companion, end that ocx claude or ocx opencode session and start it again.` - : answering.refusedBy + case "captured": return answering.refusedBy ? `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection, and its service manager did not allow a restart from here. Restart it through the manager: ocx service restart (check ocx service status).` : `The client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} still serves the old connection. Run on this machine: ocx restart`; case "different": return `A different client runtime${answering.pid ? ` (pid ${answering.pid})` : ""} answers now, so it was not restarted; inspect it with ocx status and ocx connect status before restarting anything.`; @@ -122,6 +122,10 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) if (client && !target) { return fail(`Disconnected, but this machine could not identify the running client runtime${client.pid ? ` (pid ${client.pid})` : ""}: its runtime record is missing or names another process, so it was not restarted.`, { kind: "unknown" }); } + // A runtime with no positive evidence of its owner (a bare OCX_SERVICE=1: a legacy service child + // or a companion proxy) is left exactly as origin/dev's `ocx disconnect` left it: not restarted, + // and the disconnect's own exit code (0) stands. + if (target?.restartOwnerUnproven) return 0; const before = deps.connectionState(); if (before.kind !== "disconnected") { error(`Disconnect finished, but the client runtime was not restarted. ${recoveryAdvice(before, { kind: "unknown" })}`); @@ -132,7 +136,7 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) const originalLog = console.log; if (deps.json) console.log = (...args: unknown[]) => console.error(...args); let ok = false; - let refusedBy: "service-unproven" | "owner-unproven" | undefined; + let refusedBy: "service-unproven" | undefined; try { ({ ok, refusedBy } = await deps.restart(target)); } finally { console.log = originalLog; } const after = await deps.observe(); if (after.kind === "live" && after.live.role !== "client") { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 69c1dd63602..b9150d3b7e0 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -445,8 +445,8 @@ const commandRunners: Record = { return state.kind === "invalid" || state.kind === "mismatched" ? { kind: state.kind, reason: state.reason } : { kind: state.kind }; }, restart: async target => { - let refusedBy: "service-unproven" | "owner-unproven" | undefined; - const ok = await deps.handleProxyRestart(deps.handleRestartStartWhenStopped, supervisor => { if (supervisor === "service-unproven" || supervisor === "owner-unproven") refusedBy = supervisor; }, (io, deadlineAt) => + let refusedBy: "service-unproven" | undefined; + const ok = await deps.handleProxyRestart(deps.handleRestartStartWhenStopped, supervisor => { if (supervisor === "service-unproven") refusedBy = supervisor; }, (io, deadlineAt) => bindRestartToRuntime(io, target, (previous, readRuntime) => requestBoundSystemRestart(previous as Parameters[0], deadlineAt, { readRuntime: readRuntime as typeof readRuntimePort }))); return { ok, ...(refusedBy ? { refusedBy } : {}) }; diff --git a/src/cli/restart-failure.ts b/src/cli/restart-failure.ts index 6113ba5a01e..89fb5526077 100644 --- a/src/cli/restart-failure.ts +++ b/src/cli/restart-failure.ts @@ -1,6 +1,6 @@ import type { ProxyRestartResult } from "./tray-proxy"; -export type RestartSupervisor = "desktop" | "service" | "service-unproven" | "owner-unproven"; +export type RestartSupervisor = "desktop" | "service" | "service-unproven"; /** Only the bound client's explicit refusal establishes a supervisor for sync. */ export function supervisedRestartRefusal(result: ProxyRestartResult): RestartSupervisor | null { @@ -8,7 +8,6 @@ export function supervisedRestartRefusal(result: ProxyRestartResult): RestartSup if (result.error.message === "restart_supervised_desktop") return "desktop"; if (result.error.message === "restart_supervised_service") return "service"; if (result.error.message === "restart_supervised_service-unproven") return "service-unproven"; - if (result.error.message === "restart_supervised_owner-unproven") return "owner-unproven"; return null; } @@ -39,8 +38,6 @@ export function reportRestartFailure(result: Extract { +async function supervisedRefusal(response: Response): Promise<"service" | "desktop" | "service-unproven" | null> { try { const read = await readBoundedResponseBytes(response, { maxBytes: 1024, signal: AbortSignal.timeout(SYSTEM_RESTART_REQUEST_TIMEOUT_MS) }); if (read.oversized) return null; const body = JSON.parse(new TextDecoder().decode(read.bytes)) as { error?: { code?: unknown; supervisor?: unknown } } | null; if (body?.error?.code !== RESTART_SUPERVISED_ERROR) return null; const supervisor = body.error.supervisor; - return supervisor === "service" || supervisor === "desktop" || supervisor === "service-unproven" || supervisor === "owner-unproven" ? supervisor : null; + return supervisor === "service" || supervisor === "desktop" || supervisor === "service-unproven" ? supervisor : null; } catch { return null; } diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index a322b4ea8ba..67d7c9db483 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -33,15 +33,15 @@ import { sameClientConnectionOwner, type readClientConnectionState } from "./sta type ClientConnectionState = ReturnType; /** - * `service-unproven`: a service manager launched and owns this process (OCX_SERVICE or - * OCX_SERVICE_MANAGED), but its relaunch cannot be proven (for example a systemd unit started by - * hand while disabled). Neither exiting for it nor detaching from it is safe, so the restart is refused. + * `service-unproven`: a service manager's marker shows it launched and owns this process, but its + * relaunch cannot be proven (for example a systemd unit started by hand while disabled). Neither + * exiting for it nor detaching from it is safe, so the restart is refused. */ -export type ClientRestartSupervisor = "service" | "desktop" | "service-unproven" | "owner-unproven"; +export type ClientRestartSupervisor = "service" | "desktop" | "service-unproven"; /** Verdicts that keep the runtime: an owner exists, but exiting for it or detaching is not proven safe. */ -export function restartRefusedByOwner(supervisor: ClientRestartSupervisor | null): supervisor is "service-unproven" | "owner-unproven" { - return supervisor === "service-unproven" || supervisor === "owner-unproven"; +export function restartRefusedByOwner(supervisor: ClientRestartSupervisor | null): supervisor is "service-unproven" { + return supervisor === "service-unproven"; } export type ClientRestartAdmission = @@ -56,6 +56,11 @@ export interface ClientRestartIo { supervisor(): Promise; /** The durable connection is no longer the one this runtime serves. */ connectionChanged?: () => boolean; + /** + * Positive evidence of who owns this process ({@link restartOwnerProven}). Without it the + * ended-connection recycle is never reached and the restart is origin/dev's. + */ + ownerProven?: () => boolean; /** * Hand off to what the durable state now calls for; exits the process for its owner to relaunch. * Receives the same supervisor verdict as the refusal path (a service only when it is viable). @@ -120,7 +125,9 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar hasHandoff: () => flight !== null, async request() { if (io.stopping?.()) return { kind: "stopping" }; - if (io.recycle && io.connectionChanged?.()) { + // Only positive ownership evidence opens the ended-connection recycle; without it (a bare + // OCX_SERVICE=1) the request takes origin/dev's path below. + if (io.recycle && (io.ownerProven?.() ?? true) && io.connectionChanged?.()) { if (flight) return { kind: "accepted", alreadyRestarting: true }; // An owner that cannot be proven to relaunch this process keeps it, connection ended or not. const owner = await io.supervisor(); @@ -154,31 +161,41 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar /** Production supervision check: the desktop app, then a service that will really relaunch us. */ export async function clientRestartSupervisor(): Promise { const { diagnoseService } = await import("../service/diagnostics"); - return classifyClientRestartSupervisor({ desktop: isDesktopSupervised(), env: process.env, diagnose: diagnoseService }); + const { isServiceViable } = await import("../service/orchestration"); + return classifyClientRestartSupervisor({ desktop: isDesktopSupervised(), env: process.env, diagnose: diagnoseService, legacyServiceViable: isServiceViable }); +} + +/** + * Positive evidence of who owns this process: the desktop app, a service manager's marker + * (isSupervisedServiceChild), or no OCX_SERVICE at all (every service OpenCodex registers sets it). + * A bare OCX_SERVICE=1 is none of these: a service registered before the manager marker existed + * and the proxies of ocx claude, ocx codex and ocx opencode all carry it. + */ +export function restartOwnerProven(input: { desktop: boolean; env: NodeJS.ProcessEnv; managed?: (env: NodeJS.ProcessEnv) => boolean }): boolean { + return input.desktop || input.env.OCX_SERVICE !== "1" || (input.managed ?? isSupervisedServiceChild)(input.env); } /** - * Ownership and relaunch are separate facts. A manager owns this process when it launched it - * (OCX_SERVICE or OCX_SERVICE_MANAGED); only an owned process whose service diagnostic is viable - * may exit for the manager to relaunch it. Owned but not viable is `service-unproven` and keeps - * the runtime; only a process no manager owns may start a detached replacement. + * Ownership and relaunch are separate facts. With a manager's marker, only a service diagnostic + * that is viable and running lets this process exit for the manager to relaunch it; otherwise it is + * `service-unproven` and keeps the runtime. A bare OCX_SERVICE=1 keeps origin/dev's decision + * unchanged (b1148220574917c8 src/client/runtime-restart.ts:114-119): a viable service, else none. */ export function classifyClientRestartSupervisor(input: { desktop: boolean; env: NodeJS.ProcessEnv; - diagnose: () => { installed: boolean; viable: boolean; running: boolean }; + diagnose: () => { viable: boolean; running: boolean }; + /** origin/dev's check for a bare OCX_SERVICE=1: src/service/orchestration.ts isServiceViable. */ + legacyServiceViable: () => boolean; /** Manager-launched evidence; defaults to src/service/service-child-ownership.ts. */ managed?: (env: NodeJS.ProcessEnv) => boolean; }): ClientRestartSupervisor | null { if (input.desktop) return "desktop"; // The managed marker (launchd, systemd, WinSW), the Windows wrapper's protocol marker, or a - // legacy Windows wrapper parent. A bare OCX_SERVICE=1 is also what ocx claude and ocx opencode - // give their own proxies, so it alone proves no manager. + // legacy Windows wrapper parent. if (!(input.managed ?? isSupervisedServiceChild)(input.env)) { if (input.env.OCX_SERVICE !== "1") return null; - // Bare marker: a companion when no service is installed; with one installed it may also be a - // service registered before the managed marker existed, which nothing here can tell apart. - return input.diagnose().installed ? "owner-unproven" : null; + return input.legacyServiceViable() ? "service" : null; } // Relaunch needs positive evidence: viable AND running (launchd's viable also covers an // unknown load state for its other callers; running there means loaded). diff --git a/src/client/runtime.ts b/src/client/runtime.ts index bee9d5a5ed3..29bc8ba68a8 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -30,7 +30,7 @@ import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } f import { startMachineListener, type MachineListenerDeps } from "./machine-listener"; import { isLinkConnection, readClientConnectionState, readClientConnectionStateWithEvidence } from "./state"; import { withClientLifecycleSync } from "./lifecycle-lock"; -import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; +import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, restartOwnerProven, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; @@ -101,10 +101,18 @@ export function clientRuntimeRecord( pid: number, port: number, attestationSecret: string = createLocalAttestationSecret(), + ownerEvidence: boolean = ownerProven(), ): RuntimePortState { let command: string[] | undefined; try { command = currentServingCommand(); } catch { /* no reliable relaunch identity; update admission fails closed */ } - return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}) }; + return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}), ...(ownerEvidence ? {} : { restartOwnerUnproven: true as const }) }; +} + +/** Evaluated once: the launch environment and parent of this process do not change. */ +let ownerProvenMemo: boolean | undefined; +function ownerProven(): boolean { + ownerProvenMemo ??= restartOwnerProven({ desktop: isDesktopSupervised(), env: process.env }); + return ownerProvenMemo; } export function standaloneRecycleEnv( @@ -441,6 +449,7 @@ export async function startClientRuntime( stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, connectionChanged: () => clientConnectionEnded(state.value, readClientConnectionStateWithEvidence()), + ownerProven, recycle: supervisor => recycleStandalone(state.value.tokenFingerprint, { isDesktopSupervised: () => supervisor === "desktop", serviceSupervised: () => supervisor === "service", diff --git a/src/config/process-state.ts b/src/config/process-state.ts index 5b71fd4f430..9b5c3fd4c44 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -41,6 +41,12 @@ export type RuntimePortState = { attestationSecret?: string; /** A Link runtime is draining; new and in-flight sync commits must stand down. */ clientStopping?: true; + /** + * A client runtime with no positive evidence of its owner (a bare OCX_SERVICE=1: + * src/client/runtime-restart.ts restartOwnerProven). `ocx disconnect` leaves such a runtime + * alone, as origin/dev did. Absent for every other runtime. + */ + restartOwnerUnproven?: true; /** * The live proxy's port when this runtime is a sibling instance started beside it * (`src/codex/sibling-start.ts`). `ocx stop` reads it to leave shared client routing alone. @@ -64,6 +70,7 @@ export function isValidRuntimePortState(value: unknown): value is RuntimePortSta && hostnameOk && attestationOk && (state.clientStopping === undefined || state.clientStopping === true) + && (state.restartOwnerUnproven === undefined || state.restartOwnerUnproven === true) && siblingOk; } diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 8a4db5cf160..c9d839939b0 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; import { bindRestartToRuntime, restartRuntimeAfterDisconnect, type DisconnectRuntimeDeps, type RuntimeGeneration } from "../../src/cli/disconnect-runtime"; +import { claudeProxyStartEnv } from "../../src/cli/claude"; +import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; +import { opencodeProxyStartEnv } from "../../src/cli/opencode"; +import { restartOwnerProven } from "../../src/client/runtime-restart"; +import { clientRuntimeRecord } from "../../src/client/runtime"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; import { runProxyRestart, type ProxyRestartIo } from "../../src/cli/tray-proxy"; import { createLocalAttestationProof, createLocalAttestationSecret, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; @@ -180,13 +185,31 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(text).not.toContain("Run on this machine: ocx restart"); }); - test("a restart refused because its owner is unproven names both possible owners", async () => { - const result = await run(live("client"), false, false, { after: live("client"), refusedBy: "owner-unproven" }); - expect(result.code).toBe(1); - const text = result.err.join("\n"); - expect(text).toContain("ocx service repair"); - expect(text).toContain("ocx claude or ocx opencode"); - expect(text).not.toContain("Run on this machine: ocx restart"); + // Final review round 4 (scope split): origin/dev's `ocx disconnect` restarted nothing + // (b1148220574917c8 src/cli/dispatch.ts disconnect: handleDisconnectCommand's code only). A + // runtime whose only supervisor evidence is a bare OCX_SERVICE=1 is still left that way: its + // record, built by the production record builder and ownership check, says so, and the step + // returns the disconnect's own 0 without restarting it or reading anything further. + test.each([ + ["a legacy service child", () => ({ OCX_SERVICE: "1", PATH: "/usr/bin" }) as NodeJS.ProcessEnv], + ["ocx claude's proxy", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], + ["ocx codex's proxy", () => codexProxyStartEnv({ PATH: "/usr/bin" }) as NodeJS.ProcessEnv], + ["ocx opencode's proxy", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], + ] as const)("%s is left as origin/dev's disconnect left it: exit 0, nothing restarted", async (_name, env) => { + const record = clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerProven({ desktop: false, env: env() })); + expect(record.restartOwnerUnproven).toBe(true); + for (const state of [{ kind: "disconnected" }, { kind: "connected" }, { kind: "invalid", reason: "x" }] as State[]) { + const result = await run(live("client"), true, false, { capture: { ...generation, ...record, attestationSecret: generation.attestationSecret }, state }); + expect(result).toMatchObject({ code: 0, restarts: 0, out: [], err: [] }); + } + }); + + test("a runtime with positive owner evidence publishes no such mark and is restarted", async () => { + for (const env of [{}, { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" }] as NodeJS.ProcessEnv[]) { + const record = clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerProven({ desktop: false, env })); + expect(record.restartOwnerUnproven).toBeUndefined(); + } + expect(clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerProven({ desktop: true, env: { OCX_SERVICE: "1" } })).restartOwnerUnproven).toBeUndefined(); }); // P3: after a completed restart, an unreadable state must not also claim nothing was restarted. diff --git a/tests/cli/link-apply-restart.test.ts b/tests/cli/link-apply-restart.test.ts index 8c1b211933e..c47ec965f93 100644 --- a/tests/cli/link-apply-restart.test.ts +++ b/tests/cli/link-apply-restart.test.ts @@ -12,6 +12,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyRestartMessage, restartRuntimeAfterApply, type ApplyRestartDeps } from "../../src/cli/link-apply-restart"; +import { classifyClientRestartSupervisor, createClientRestartController, restartOwnerProven } from "../../src/client/runtime-restart"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; import { deferSupervisedRestart, reportRestartFailure, supervisedRestartRefusal } from "../../src/cli/restart-failure"; import { collectAppliedCodexSettingsStatus } from "../../src/cli/status-codex-settings"; @@ -151,7 +152,7 @@ function clientHealthDeps(postResponse: Response) { describe("ocx restart against a client runtime", () => { test("only explicit request-phase supervision refusals qualify for deferral", () => { const notices: string[] = []; - for (const supervisor of ["desktop", "service", "service-unproven", "owner-unproven"] as const) { + for (const supervisor of ["desktop", "service", "service-unproven"] as const) { const refused = { ok: false as const, phase: "request" as const, error: new Error(`restart_supervised_${supervisor}`) }; expect(supervisedRestartRefusal(refused)).toBe(supervisor); expect(deferSupervisedRestart(refused, value => notices.push(value))).toBe(true); @@ -160,7 +161,7 @@ describe("ocx restart against a client runtime", () => { const uncertain = { ok: false as const, phase: "request" as const, error: new Error("restart_request_outcome_unknown") }; expect(supervisedRestartRefusal(uncertain)).toBeNull(); expect(deferSupervisedRestart(uncertain, value => notices.push(value))).toBe(false); - expect(notices).toEqual(["desktop", "service", "service-unproven", "owner-unproven"]); + expect(notices).toEqual(["desktop", "service", "service-unproven"]); expect(supervisedRestartRefusal({ ok: false, phase: "request", error: new Error("restart_supervised_other") })).toBeNull(); expect(supervisedRestartRefusal({ ok: true, mode: "skipped" })).toBeNull(); }); @@ -194,7 +195,6 @@ describe("ocx restart against a client runtime", () => { reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_service") }); reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_desktop") }); reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_service-unproven") }); - reportRestartFailure({ ok: false, phase: "request", error: new Error("restart_supervised_owner-unproven") }); } finally { console.error = original; } @@ -202,11 +202,45 @@ describe("ocx restart against a client runtime", () => { "Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.", "Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it while its connection is current: use Quit in the OpenCodex tray menu, then reopen it. After `ocx disconnect`, `ocx restart` hands it to the app.", "Restart not performed; the running client runtime was kept. A service manager started it (systemd opencodex-proxy.service, launchd com.opencodex.proxy or the Windows OpenCodex service), but it is not enabled or its relaunch could not be verified: restart it through the manager with 'ocx service restart', or check 'ocx service status'.", - "Restart not performed; the running client runtime was kept. It was started with OCX_SERVICE=1 but without the service manager's marker while a service is installed, so it is either a service registered before that marker existed or a proxy started by ocx claude or ocx opencode. If it is the service, run 'ocx service repair' and then 'ocx service restart'; if it is a companion, end that ocx claude or ocx opencode session and start it again.", ]); }); }); +// Final review round 4 (scope split): `ocx sync` on a Child whose runtime carries only a bare +// OCX_SERVICE=1 ends exactly as on origin/dev. Expected values from the merge base +// b1148220574917c8: src/client/runtime-restart.ts:114-119 and :98-106 (the runtime's admission), +// src/cli/link-apply-restart.ts:63-64 and :74-76 (the outcome and its message), and +// src/cli/sync-command.ts (exit 1 only for failed or connection-changed). +describe("ocx sync with a bare OCX_SERVICE=1 runtime keeps origin/dev's outcome", () => { + for (const viable of [true, false]) { + test(`service viable ${viable}`, async () => { + const env = { OCX_SERVICE: "1", PATH: "/usr/bin" } as NodeJS.ProcessEnv; + const supervisor = classifyClientRestartSupervisor({ desktop: false, env, diagnose: () => ({ viable: true, running: true }), legacyServiceViable: () => viable }); + const controller = createClientRestartController({ + supervisor: async () => supervisor, + ownerProven: () => restartOwnerProven({ desktop: false, env }), + // A connected sync: the served connection is current. + connectionChanged: () => false, + recycle: async () => { throw new Error("the recycle is not reachable"); }, + stop: async () => {}, spawnReplacement: async () => {}, resume: async () => {}, exitProcess: () => {}, sleep: async () => {}, + }); + const { value } = deps(0, { + restart: async (_start, onSupervised) => { + const admission = await controller.request(); + await controller.settled(); + if (admission.kind === "supervised") { onSupervised(admission.supervisor); return false; } + return admission.kind === "accepted"; + }, + }); + const outcome = await restartRuntimeAfterApply(record, value); + expect(outcome).toEqual(viable ? { kind: "deferred-supervisor", supervisor: "service" } : { kind: "restarted" }); + expect(applyRestartMessage(outcome)).toBe(viable + ? "Client runtime restart deferred; the settings take effect when the background service restarts it. Run `ocx service restart`." + : "Client runtime restarted after applying the Home's settings."); + }); + } +}); + describe("ocx status --json OCX-6 fields (shape snapshot)", () => { const homeConfig = { fastMode: true, multiAgentMode: "v1", webSearchSidecar: { enabled: false } } as unknown as OcxConfig; const childConfig = { diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 264e242ac45..9730a0d2ac5 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -6,16 +6,17 @@ * replacement, and a replacement that does not come up leaves the old runtime serving. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; -import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; -import { readClientConnectionStateWithEvidence } from "../../src/client/state"; -import * as configModule from "../../src/config"; +import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, restartOwnerProven, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; +import { readClientConnectionState, readClientConnectionStateWithEvidence } from "../../src/client/state"; import { claudeProxyStartEnv } from "../../src/cli/claude"; import { opencodeProxyStartEnv } from "../../src/cli/opencode"; +import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; import { spyOn } from "bun:test"; import { deriveLaunchdServiceDiagnostic, deriveSystemdServiceDiagnostic, deriveWindowsServiceDiagnostic } from "../../src/service/diagnostics"; import { @@ -315,8 +316,6 @@ describe("client restart controller", () => { ["service", false, "supervised", []], ["service-unproven", true, "supervised", []], ["service-unproven", false, "supervised", []], - ["owner-unproven", true, "supervised", []], - ["owner-unproven", false, "supervised", []], [null, true, "accepted", ["recycle:none"]], [null, false, "accepted", ["stop:20000", "spawn:71000", "exit:0"]], ] as const)("decision table: supervisor %s, connection ended %s", async (supervisor, ended, kind, expectedCalls) => { @@ -378,6 +377,20 @@ describe("client restart controller", () => { await controller.settled(); expect(spawns).toBe(1); }); + + test("a supervisor verdict that changes between the admission and the recycle lookups is honoured", async () => { + // Admitted with no supervisor, then unproven at the second lookup: kept, no recycle. + const turned = [null, "service-unproven"] as const; + let i = 0; + const late = fakeIo({ supervisor: async () => turned[Math.min(i++, 1)] ?? null, connectionChanged: () => true, recycle: async () => { late.calls.push("recycle"); } }); + const controller = createClientRestartController(late.io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + await controller.settled(); + expect(late.calls.filter(call => call === "recycle")).toEqual([]); + const early = fakeIo({ supervisor: async () => "service-unproven", connectionChanged: () => true, recycle: async () => { early.calls.push("recycle"); } }); + expect(await createClientRestartController(early.io).request()).toEqual({ kind: "supervised", supervisor: "service-unproven" }); + expect(early.calls).toEqual([]); + }); }); describe("who supervises a client restart (real service diagnostics)", () => { @@ -401,25 +414,6 @@ describe("who supervises a client restart (real service diagnostics)", () => { expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE_MANAGED: "1" }, diagnose: () => unknown })).toBe("service-unproven"); }); - // Final review round 2: ocx claude and ocx opencode start their own proxies with OCX_SERVICE=1; - // through their real launch environments, with no service installed, nothing supervises them. - const notInstalled = deriveSystemdServiceDiagnostic({ installed: false, enabled: false, running: false, stale: false, diagnostics: "test" }); - const installedDisabled = systemd(false, false); - test.each([ - ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], - ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], - ] as const)("a %s companion with no service installed is not supervised", (_name, env) => { - expect(env().OCX_SERVICE).toBe("1"); - expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => notInstalled })).toBeNull(); - }); - - test.each([ - ["ocx claude", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], - ["ocx opencode", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], - ] as const)("a bare OCX_SERVICE=1 (%s environment) while a service is installed is owner-unproven", (_name, env) => { - expect(classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => installedDisabled })).toBe("owner-unproven"); - }); - test("the Windows wrapper's marker counts as manager-launched", () => { expect(classifyClientRestartSupervisor({ desktop: false, env: { OCX_SERVICE: "1", OCX_WINDOWS_WRAPPER_PROTOCOL: "1" }, diagnose: () => systemd(true, true) })).toBe("service"); }); @@ -437,6 +431,62 @@ describe("who supervises a client restart (real service diagnostics)", () => { }); }); +// Final review round 4 (scope split): a runtime whose only supervisor evidence is a bare +// OCX_SERVICE=1 (a legacy service child, or the proxy of ocx claude, ocx codex or ocx opencode) +// keeps origin/dev's `ocx restart` decision exactly. The oracle is written from the merge base +// b1148220574917c8: src/client/runtime-restart.ts:114-119 (desktop, else no OCX_SERVICE -> none, +// else isServiceViable() ? "service" : none) and :98-106 (a supervisor refuses, none restarts with +// a replacement; no ended-connection branch). +describe("a bare OCX_SERVICE=1 keeps origin/dev's ocx restart decision", () => { + const devSupervisor = (env: NodeJS.ProcessEnv, viable: boolean) => (env.OCX_SERVICE !== "1" ? null : viable ? "service" : null); + const bareEnvs = [ + ["a legacy service child", () => ({ OCX_SERVICE: "1", PATH: "/usr/bin" }) as NodeJS.ProcessEnv], + ["ocx claude's proxy", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], + ["ocx codex's proxy", () => codexProxyStartEnv({ PATH: "/usr/bin" }) as NodeJS.ProcessEnv], + ["ocx opencode's proxy", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], + ] as const; + const anyDiagnostic = [ + deriveSystemdServiceDiagnostic({ installed: false, enabled: false, running: false, stale: false, diagnostics: "test" }), + deriveSystemdServiceDiagnostic({ installed: true, enabled: false, running: false, stale: false, diagnostics: "test" }), + deriveSystemdServiceDiagnostic({ installed: true, enabled: true, running: true, stale: false, diagnostics: "test" }), + ]; + for (const [name, env] of bareEnvs) { + for (const viable of [true, false]) { + test(`${name}, service viable ${viable}, connection ended or not: the same admission as origin/dev`, async () => { + expect(env().OCX_SERVICE).toBe("1"); + expect(restartOwnerProven({ desktop: false, env: env() })).toBe(false); + for (const diagnostic of anyDiagnostic) { + const supervisor = classifyClientRestartSupervisor({ desktop: false, env: env(), diagnose: () => diagnostic, legacyServiceViable: () => viable }); + expect(supervisor).toBe(devSupervisor(env(), viable)); + for (const ended of [true, false]) { + const recycled: unknown[] = []; + const { io, calls } = fakeIo({ + supervisor: async () => supervisor, + ownerProven: () => restartOwnerProven({ desktop: false, env: env() }), + connectionChanged: () => ended, + recycle: async verdict => { recycled.push(verdict); }, + }); + const controller = createClientRestartController(io); + const admission = await controller.request(); + await controller.settled(); + // origin/dev: a supervisor refuses; none runs the replacement restart. Never the recycle. + expect(admission).toEqual(viable ? { kind: "supervised", supervisor: "service" } : { kind: "accepted", alreadyRestarting: false }); + expect(calls).toEqual(viable ? [] : ["stop:20000", "spawn:71000", "exit:0"]); + expect(recycled).toEqual([]); + } + } + }); + } + } + + test("positive evidence opens the ended-connection recycle: desktop, a manager's marker, or no OCX_SERVICE", () => { + expect(restartOwnerProven({ desktop: true, env: { OCX_SERVICE: "1" } })).toBe(true); + expect(restartOwnerProven({ desktop: false, env: { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" } })).toBe(true); + expect(restartOwnerProven({ desktop: false, env: { OCX_SERVICE: "1", OCX_WINDOWS_WRAPPER_PROTOCOL: "1" } })).toBe(true); + expect(restartOwnerProven({ desktop: false, env: {} })).toBe(true); + }); +}); + // Final review: a missing config.json reads as the default configuration, which is not evidence that // the served connection ended. The real reader is used here, with no injected state. describe("connection-ended evidence from the real state reader", () => { @@ -463,24 +513,22 @@ describe("connection-ended evidence from the real state reader", () => { expect(clientConnectionEnded(link, state)).toBe(false); }); - // ...and the verdict comes from one snapshot: a connect committed after the file was read cannot - // pair the old bytes with a validation of the new ones. The hook commits at the second read. - test("a connection committed between reads never yields disconnected from a stale read", () => { - writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai" })); - const realDiagnostics = configModule.readConfigDiagnostics; - let committed = false; - const hook = spyOn(configModule, "readConfigDiagnostics").mockImplementation((options?: Parameters[0]) => { - if (!committed) { - committed = true; - writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai", runtimeRole: "client", client: link })); - } - return realDiagnostics(options); - }); + // ...and the verdict comes from one read of config.json, so a connect that commits meanwhile cannot + // pair old bytes with a validation of new ones. Counted at the file system: the older two-read + // reader shows the count detects a second read. + test("the verdict comes from exactly one read of config.json", () => { + const path = join(root, "config.json"); + const reads = spyOn(fs, "readFileSync"); + const count = (read: () => unknown) => { + reads.mockClear(); + read(); + return reads.mock.calls.filter(([target]) => String(target) === path).length; + }; try { - const state = readClientConnectionStateWithEvidence(); - expect(committed && state.kind === "disconnected").toBe(false); + expect(count(readClientConnectionState)).toBe(2); + expect(count(readClientConnectionStateWithEvidence)).toBe(1); } finally { - hook.mockRestore(); + reads.mockRestore(); } }); From 82c011effc9345b46b04966913498755b8ff0941 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:20:03 +0900 Subject: [PATCH 46/54] docs(link): bare OCX_SERVICE=1 runtimes keep their previous restart and disconnect behaviour --- docs-site/src/content/docs/guides/remote-link.md | 2 +- structure/remote-link.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 5115609da29..cdf7ad1a0c4 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -109,7 +109,7 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` -On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. +On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. A runtime that carries only `OCX_SERVICE=1` (a service installed before 2.49, or a proxy started by `ocx claude`, `ocx codex` or `ocx opencode`) is not changed by this: `ocx disconnect` does not restart it, and `ocx restart` refuses while its service is viable, as before. ## Troubleshooting diff --git a/structure/remote-link.md b/structure/remote-link.md index 740f81e562d..49fe5bd2230 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. A bare OCX_SERVICE=1 is an ocx claude or ocx opencode companion when no service is installed (it restarts normally), and `owner-unproven` when one is. Both unproven verdicts keep the runtime and are refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. `service-unproven` keeps the runtime and is refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. The ended-connection recycle needs positive ownership evidence (`restartOwnerProven`: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: `ocx restart` refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), the runtime record carries `restartOwnerUnproven` and `ocx disconnect` leaves that runtime alone with exit 0. Owner evidence for those runtimes is a separate design (follow-up issue). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From bbfa89eef97e59d4e73f954ab47412159e8fd232 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:39:51 +0900 Subject: [PATCH 47/54] fix(link): disconnect restarts only a runtime whose record states an owner it handles Final review round 5. A client runtime now records the owner it classified from positive evidence (restartOwner: desktop, service-manager or none). ocx disconnect restarts only a record stating one of those; a bare OCX_SERVICE=1 runtime, a runtime started before this field existed, or an unknown value gets origin/dev disconnect: no restart request, exit 0. Replaces the restartOwnerUnproven mark, which left old runtimes exposed to the new restart. --- src/cli/disconnect-runtime.ts | 18 ++++++--- src/client/runtime-restart.ts | 13 ++++++- src/client/runtime.ts | 15 ++++---- src/config/process-state.ts | 11 +++--- tests/cli/disconnect-runtime.test.ts | 56 ++++++++++++++++++++-------- 5 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/cli/disconnect-runtime.ts b/src/cli/disconnect-runtime.ts index 7d83fd5bfa4..1fa46905f95 100644 --- a/src/cli/disconnect-runtime.ts +++ b/src/cli/disconnect-runtime.ts @@ -8,6 +8,11 @@ * Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. */ import type { ProxyRestartIo, ProxyRestartLive, ProxyRestartRequestOutcome } from "./tray-proxy"; +import { RESTART_OWNERS, type RestartOwner } from "../client/runtime-restart"; + +function isHandledRestartOwner(value: string | undefined): value is RestartOwner { + return value !== undefined && (RESTART_OWNERS as readonly string[]).includes(value); +} /** * One runtime process: its runtime record. The attestation secret is created per process @@ -19,8 +24,8 @@ export interface RuntimeGeneration { port: number; hostname?: string; attestationSecret: string; - /** The runtime published no positive evidence of its owner (a bare OCX_SERVICE=1). */ - restartOwnerUnproven?: true; + /** The owner the runtime classified from positive evidence; absent for a bare flag or an older runtime. */ + restartOwner?: string; } export type DisconnectClientState = @@ -122,10 +127,11 @@ export async function restartRuntimeAfterDisconnect(deps: DisconnectRuntimeDeps) if (client && !target) { return fail(`Disconnected, but this machine could not identify the running client runtime${client.pid ? ` (pid ${client.pid})` : ""}: its runtime record is missing or names another process, so it was not restarted.`, { kind: "unknown" }); } - // A runtime with no positive evidence of its owner (a bare OCX_SERVICE=1: a legacy service child - // or a companion proxy) is left exactly as origin/dev's `ocx disconnect` left it: not restarted, - // and the disconnect's own exit code (0) stands. - if (target?.restartOwnerUnproven) return 0; + // Only a record that states an owner this step handles (written by a runtime with this change: + // the desktop app, a service manager with a current marker, or no supervisor) is restarted. Any + // other record, a bare OCX_SERVICE=1 runtime or one started before this change, is left exactly + // as origin/dev's `ocx disconnect` left it: no restart request, the disconnect's own exit 0. + if (target && !isHandledRestartOwner(target.restartOwner)) return 0; const before = deps.connectionState(); if (before.kind !== "disconnected") { error(`Disconnect finished, but the client runtime was not restarted. ${recoveryAdvice(before, { kind: "unknown" })}`); diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index 67d7c9db483..1ce83c94724 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -172,7 +172,18 @@ export async function clientRestartSupervisor(): Promise boolean }): boolean { - return input.desktop || input.env.OCX_SERVICE !== "1" || (input.managed ?? isSupervisedServiceChild)(input.env); + return restartOwnerEvidence(input) !== null; +} + +/** The owners this change handles, as a client runtime states them in its record (`restartOwner`). */ +export type RestartOwner = "desktop" | "service-manager" | "none"; +export const RESTART_OWNERS: readonly RestartOwner[] = ["desktop", "service-manager", "none"]; + +/** The classified owner from positive evidence, or null for a bare OCX_SERVICE=1 ({@link restartOwnerProven}). */ +export function restartOwnerEvidence(input: { desktop: boolean; env: NodeJS.ProcessEnv; managed?: (env: NodeJS.ProcessEnv) => boolean }): RestartOwner | null { + if (input.desktop) return "desktop"; + if ((input.managed ?? isSupervisedServiceChild)(input.env)) return "service-manager"; + return input.env.OCX_SERVICE !== "1" ? "none" : null; } /** diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 29bc8ba68a8..70636cffc26 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -30,7 +30,7 @@ import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } f import { startMachineListener, type MachineListenerDeps } from "./machine-listener"; import { isLinkConnection, readClientConnectionState, readClientConnectionStateWithEvidence } from "./state"; import { withClientLifecycleSync } from "./lifecycle-lock"; -import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, restartOwnerProven, type ClientRestartController } from "./runtime-restart"; +import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, restartOwnerEvidence, type ClientRestartController, type RestartOwner } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; @@ -101,19 +101,20 @@ export function clientRuntimeRecord( pid: number, port: number, attestationSecret: string = createLocalAttestationSecret(), - ownerEvidence: boolean = ownerProven(), + owner: RestartOwner | null = classifiedOwner(), ): RuntimePortState { let command: string[] | undefined; try { command = currentServingCommand(); } catch { /* no reliable relaunch identity; update admission fails closed */ } - return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}), ...(ownerEvidence ? {} : { restartOwnerUnproven: true as const }) }; + return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}), ...(owner ? { restartOwner: owner } : {}) }; } /** Evaluated once: the launch environment and parent of this process do not change. */ -let ownerProvenMemo: boolean | undefined; -function ownerProven(): boolean { - ownerProvenMemo ??= restartOwnerProven({ desktop: isDesktopSupervised(), env: process.env }); - return ownerProvenMemo; +let ownerMemo: { value: RestartOwner | null } | undefined; +function classifiedOwner(): RestartOwner | null { + ownerMemo ??= { value: restartOwnerEvidence({ desktop: isDesktopSupervised(), env: process.env }) }; + return ownerMemo.value; } +const ownerProven = (): boolean => classifiedOwner() !== null; export function standaloneRecycleEnv( env: NodeJS.ProcessEnv, diff --git a/src/config/process-state.ts b/src/config/process-state.ts index 18feceae895..61c21704f86 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -43,11 +43,12 @@ export type RuntimePortState = { /** A Link runtime is draining; new and in-flight sync commits must stand down. */ clientStopping?: true; /** - * A client runtime with no positive evidence of its owner (a bare OCX_SERVICE=1: - * src/client/runtime-restart.ts restartOwnerProven). `ocx disconnect` leaves such a runtime - * alone, as origin/dev did. Absent for every other runtime. + * The owner a client runtime classified from positive evidence (src/client/runtime-restart.ts + * restartOwnerEvidence: desktop, service-manager or none). Absent for a bare OCX_SERVICE=1 and for + * runtimes older than this field; `ocx disconnect` restarts only a runtime whose record states a + * value it recognises. Any string is accepted here so a later value never invalidates the record. */ - restartOwnerUnproven?: true; + restartOwner?: string; /** * The live proxy's port when this runtime is a sibling instance started beside it * (`src/codex/sibling-start.ts`). `ocx stop` reads it to leave shared client routing alone. @@ -71,7 +72,7 @@ export function isValidRuntimePortState(value: unknown): value is RuntimePortSta && hostnameOk && attestationOk && (state.clientStopping === undefined || state.clientStopping === true) - && (state.restartOwnerUnproven === undefined || state.restartOwnerUnproven === true) + && (state.restartOwner === undefined || (typeof state.restartOwner === "string" && state.restartOwner.length <= 64)) && siblingOk; } diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index c9d839939b0..8771a67a149 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -3,7 +3,7 @@ import { bindRestartToRuntime, restartRuntimeAfterDisconnect, type DisconnectRun import { claudeProxyStartEnv } from "../../src/cli/claude"; import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; import { opencodeProxyStartEnv } from "../../src/cli/opencode"; -import { restartOwnerProven } from "../../src/client/runtime-restart"; +import { restartOwnerEvidence } from "../../src/client/runtime-restart"; import { clientRuntimeRecord } from "../../src/client/runtime"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; import { runProxyRestart, type ProxyRestartIo } from "../../src/cli/tray-proxy"; @@ -13,9 +13,10 @@ import { SYSTEM_RESTART_PATH } from "../../src/lib/system-restart-contract"; type Observation = Awaited>; type State = ReturnType; const live = (role?: string, pid = 4321): Observation => ({ kind: "live", live: { pid, port: 10100, ...(role ? { role } : {}) } }); -const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret() }; +// A record written by a runtime with this change and a proven owner (here: no supervisor). +const generation: RuntimeGeneration = { pid: 4321, port: 10100, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret(), restartOwner: "none" }; -function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" | "owner-unproven" } = {}) { +function run(observation: Observation, restartOk: boolean, json = false, extra: { state?: State; states?: State[]; after?: Observation; capture?: RuntimeGeneration | null; captureAfter?: RuntimeGeneration | null; refusedBy?: "service-unproven" } = {}) { const out: string[] = [], err: string[] = []; let restarts = 0; const bound: RuntimeGeneration[] = []; @@ -185,31 +186,54 @@ describe("ocx disconnect restarts the client runtime it left behind", () => { expect(text).not.toContain("Run on this machine: ocx restart"); }); - // Final review round 4 (scope split): origin/dev's `ocx disconnect` restarted nothing - // (b1148220574917c8 src/cli/dispatch.ts disconnect: handleDisconnectCommand's code only). A - // runtime whose only supervisor evidence is a bare OCX_SERVICE=1 is still left that way: its - // record, built by the production record builder and ownership check, says so, and the step - // returns the disconnect's own 0 without restarting it or reading anything further. + // Final review rounds 4 and 5: origin/dev's `ocx disconnect` restarted nothing (b1148220574917c8 + // src/cli/dispatch.ts disconnect: handleDisconnectCommand's code only). The restart step acts only + // on a record that states a proven owner (restartOwner, written by a runtime with this change). + // A bare OCX_SERVICE=1 runtime states none; built here by the production record builder and + // ownership check, it gets the disconnect's own 0 with nothing restarted or read further. test.each([ ["a legacy service child", () => ({ OCX_SERVICE: "1", PATH: "/usr/bin" }) as NodeJS.ProcessEnv], ["ocx claude's proxy", () => claudeProxyStartEnv({ PATH: "/usr/bin" })], ["ocx codex's proxy", () => codexProxyStartEnv({ PATH: "/usr/bin" }) as NodeJS.ProcessEnv], ["ocx opencode's proxy", () => opencodeProxyStartEnv({ PATH: "/usr/bin" })], ] as const)("%s is left as origin/dev's disconnect left it: exit 0, nothing restarted", async (_name, env) => { - const record = clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerProven({ desktop: false, env: env() })); - expect(record.restartOwnerUnproven).toBe(true); + const record = clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerEvidence({ desktop: false, env: env() })); + expect(record.restartOwner).toBeUndefined(); for (const state of [{ kind: "disconnected" }, { kind: "connected" }, { kind: "invalid", reason: "x" }] as State[]) { - const result = await run(live("client"), true, false, { capture: { ...generation, ...record, attestationSecret: generation.attestationSecret }, state }); + const result = await run(live("client"), true, false, { capture: { ...record, attestationSecret: generation.attestationSecret }, state }); expect(result).toMatchObject({ code: 0, restarts: 0, out: [], err: [] }); } }); - test("a runtime with positive owner evidence publishes no such mark and is restarted", async () => { - for (const env of [{}, { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" }] as NodeJS.ProcessEnv[]) { - const record = clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerProven({ desktop: false, env })); - expect(record.restartOwnerUnproven).toBeUndefined(); + // A runtime started before this change writes no restartOwner. Whatever its old handler would + // answer (refused because the versions are known to differ, or accepted by an old handler that + // would start its own replacement), no restart request is sent: origin/dev's disconnect, exit 0. + test.each([ + ["versions known to differ (the request would be refused)", false], + ["an old handler that would accept the request", true], + ] as const)("a record without restartOwner, %s, gets origin/dev's disconnect", async (_name, wouldAccept) => { + const { restartOwner: _dropped, ...oldRecord } = generation; + for (const capture of [oldRecord, { ...oldRecord, restartOwner: "some-later-owner" }]) { + for (const state of [{ kind: "disconnected" }, { kind: "connected" }] as State[]) { + const result = await run(live("client"), wouldAccept, false, { capture, state }); + expect(result).toMatchObject({ code: 0, restarts: 0, out: [], err: [] }); + } + } + }); + + test("a record that states a proven owner is restarted, bound to that record", async () => { + for (const [env, desktop, owner] of [ + [{}, false, "none"], + [{ OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" }, false, "service-manager"], + [{ OCX_SERVICE: "1" }, true, "desktop"], + ] as const) { + const record = clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerEvidence({ desktop, env })); + expect(record.restartOwner).toBe(owner); + const capture = { ...record, attestationSecret: generation.attestationSecret }; + const result = await run(live("client"), true, false, { capture }); + expect(result).toMatchObject({ code: 0, restarts: 1 }); + expect(result.bound).toEqual([capture]); } - expect(clientRuntimeRecord(4321, 10100, generation.attestationSecret, restartOwnerProven({ desktop: true, env: { OCX_SERVICE: "1" } })).restartOwnerUnproven).toBeUndefined(); }); // P3: after a completed restart, an unreadable state must not also claim nothing was restarted. From 07efff4942f59e0d29ce4d187c118805f2598118 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:39:51 +0900 Subject: [PATCH 48/54] docs(link): disconnect restart applies to runtimes started on this version; service-unproven still refuses --- docs-site/src/content/docs/guides/remote-link.md | 2 +- structure/remote-link.md | 2 +- structure/runtime.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index cdf7ad1a0c4..fcf60a733c3 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -109,7 +109,7 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` -On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` whoever owns it: the OpenCodex desktop app or a background service starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. A runtime that carries only `OCX_SERVICE=1` (a service installed before 2.49, or a proxy started by `ocx claude`, `ocx codex` or `ocx opencode`) is not changed by this: `ocx disconnect` does not restart it, and `ocx restart` refuses while its service is viable, as before. +On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` when its owner can relaunch it: the OpenCodex desktop app, or a background service that is enabled and running, starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. A service that cannot be shown to relaunch it (for example a unit started by hand while disabled) keeps the runtime and the command says to run `ocx service restart`. A runtime that carries only `OCX_SERVICE=1` (a service installed before 2.49, or a proxy started by `ocx claude`, `ocx codex` or `ocx opencode`) is not changed by this: `ocx disconnect` does not restart it, and `ocx restart` refuses while its service is viable, as before. The same holds for a client runtime started by an OpenCodex version before this change: `ocx disconnect` restarts a runtime only after it has been restarted once on the new version (an update's own restart, the desktop app restarting it, `ocx service restart`, or `ocx restart`). ## Troubleshooting diff --git a/structure/remote-link.md b/structure/remote-link.md index b401d8ebd12..696f16b77bf 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. `service-unproven` keeps the runtime and is refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. The ended-connection recycle needs positive ownership evidence (`restartOwnerProven`: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: `ocx restart` refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), the runtime record carries `restartOwnerUnproven` and `ocx disconnect` leaves that runtime alone with exit 0. Owner evidence for those runtimes is a separate design (follow-up issue). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. `service-unproven` keeps the runtime and is refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. The ended-connection recycle needs positive ownership evidence (`restartOwnerProven`: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: `ocx restart` refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), its runtime record states no owner, and `ocx disconnect` restarts only a runtime whose record states one it handles (`restartOwner`: desktop, service-manager or none). A bare-flag runtime, one started before this field existed, or an unknown value gets origin/dev's disconnect: no restart request, exit 0. An inconclusive liveness probe or an unreadable record still exits 1 before any request, the one deliberate difference from origin/dev (no runtime is stopped, and the Home's leave stops before its own `ocx restart`). Owner evidence for those runtimes is a separate design (follow-up issue). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. diff --git a/structure/runtime.md b/structure/runtime.md index 38dde8cafa6..0ea619871d2 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -180,7 +180,7 @@ until shutdown. Standalone shutdown restores native Codex; connected hub clients uninstall still restore. Native client signal cleanup follows the [served-connection and supervisor boundaries](remote-link.md); client update stops use full parent restoration because client start cannot discharge standalone handover receipts. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. -`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept"; a client whose connection was removed is never refused and recycles to standalone. See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state once per `/readyz`, after readiness probing, and for authenticated machine/link status only on those routes. If they differ, `/readyz` and those status routes answer 503 with `client_state_changed`; the dashboard shows that reason with its recovery guidance. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. Explicit `ocx disconnect --orphaned-token --yes` in `src/client/orphan-token-recovery.ts` preserves unowned token files only after the start lease, lifecycle lock, liveness proof and config-lock revalidation; Hub, admission-token use and installed or unverified services refuse. Windows closes source handles before rename while private hard links pin identity; a refusal removes a pin only while another name still holds the token, and an extra hard link left by an interrupted run is refused with a message naming the `.identity` pin to remove from the `orphaned-token-*` folder. Missing ownership is never reconstructed. `ocx config import` refuses to replace a connected client record under the config lock. `tests/clients/client-link-connect.test.ts` covers these refusals and local liveness. +`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept"; a client whose connection was removed recycles to standalone when its owner is proven (`restartOwnerProven`), except that a manager whose relaunch is not proven (`service-unproven`) still refuses; a bare OCX_SERVICE=1 keeps origin/dev's decision. See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state once per `/readyz`, after readiness probing, and for authenticated machine/link status only on those routes. If they differ, `/readyz` and those status routes answer 503 with `client_state_changed`; the dashboard shows that reason with its recovery guidance. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. Explicit `ocx disconnect --orphaned-token --yes` in `src/client/orphan-token-recovery.ts` preserves unowned token files only after the start lease, lifecycle lock, liveness proof and config-lock revalidation; Hub, admission-token use and installed or unverified services refuse. Windows closes source handles before rename while private hard links pin identity; a refusal removes a pin only while another name still holds the token, and an extra hard link left by an interrupted run is refused with a message naming the `.identity` pin to remove from the `orphaned-token-*` folder. Missing ownership is never reconstructed. `ocx config import` refuses to replace a connected client record under the config lock. `tests/clients/client-link-connect.test.ts` covers these refusals and local liveness. The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. `src/cli/start-owner-fence.ts` holds the ownership mutation lease through recovery for ordinary and supervised starts. After an early negative probe, `start` and `ensure` recheck with `START_OWNERSHIP_LIVENESS` under that lease through `observeProxyLiveness` in `src/server/proxy-liveness.ts`, probing both available loopback families concurrently for every loopback or wildcard spelling classified after the same startup normalization (including `localhost.` and case variants) within the existing per-family retry ceilings and shared deadline. Numeric loopback classification covers the IPv4 127/8 range, equivalent IPv6 loopback spellings and IPv4-mapped loopback literals; it probes the configured address itself plus the other family, retaining the original bind scope and the responding probe address separately. This recovery classification does not change listener admission or bind policy. `probeEndpointLiveness` and `probePortOwner` retain their existing host selection: other specific loopback literals are checked at the requested address without adopting the opposite family. Only definitive absence permits PID deletion and journal reconciliation: no alive or indeterminate PID from either the PID file or runtime record unless fresh readable command-line evidence confirms it is foreign, no answering runtime owner, and only unanimous aggregate connection refusals or clean foreign identities at probed endpoints. Port zero names no existing listener. Timeouts, resets, unreadable/non-200 responses, PID mismatches and exhausted deadlines remain unknown; startup refuses with a fixed message while preserving PID, journal and shared config. One recovery-only exception covers a specific non-loopback literal IP address (a Tailscale or LAN IP, never a hostname, loopback or wildcard), where a connect to this host's own unbound address can time out instead of being refused: when its probe ends "unknown", `canBindAddress` tries an exclusive listen on that raw socket address (IPv6 unbracketed) and port, closes it at once, and only a successful bind turns that endpoint "dead". The proof shares the discovery deadline: it is skipped once the budget is spent, its timeout is capped by the remaining budget, and a proof finishing past the deadline is discarded. Hostnames remain unknown after an inconclusive probe because name resolution may select different addresses for the probe and bind. Any bind error or timeout leaves the endpoint unknown, so startup still refuses; early (non-observer) discovery never binds. `tests/server/proxy-liveness-nonloopback-bind-proof.test.ts` covers hostnames, bound and unbindable IPs, IPv6 and deadline cases. An orphan runtime PID is checked separately from listener ownership: only a definite missing process or a fresh readable foreign command line permits recovery, while an indeterminate process check, unreadable/empty command line, or OpenCodex invocation remains unknown. The runtime PID is never supplied to destructive callers merely because it appears in the record; a confirmed foreign PID is never signalled. A discovered live owner preserves both records and its runtime/config bind scope in `LiveProxy.hostname`; omitted or blank hostnames become explicit `127.0.0.1` in both early and recovery discovery, so a later config change cannot replace a default loopback bind. Explicit wildcard scope remains intact, and the concrete responding address is separate `probeHostname` metadata. Integration policy uses the bind scope, so loopback discovery of a wildcard listener does not authorize tokenless Grok registration. Grok start and ensure/restart synchronization also recheck current desired-state admission after catalog discovery, immediately before the synchronous writer in `src/grok/sync.ts`; start additionally rejects work after shutdown begins or cleanup runs. This closes the same-process await gap without turning the desired-state and Grok files into a cross-process transaction. Ensure carries `probeHostname` separately through `src/cli/ensure-desired-integrations.ts` and `src/grok/sync.ts` to `src/grok/inject.ts` for the written destination; an explicit secondary loopback listener keeps its own address and port; after lease release, start applies `decideStartWithLiveOwner` and ensure follows its existing live-owner path. Cross-home sibling marking still precedes journal recovery. The bind/publication fence remains separate, and supervised recovery checks durable service ownership before liveness or mutation. `tests/cli/cli-start-journal-order.test.ts` covers an early miss; `tests/cli/cli-start-owner-absence.test.ts` covers consecutive misses and dead-owner recovery; `tests/server/proxy-liveness-recovery.test.ts` binds classification and existing budgets; `tests/server/proxy-liveness-bind-scope.test.ts` follows a missed early probe through desired-state reconciliation and the real Grok writer for runtime, orphan and configured-port discovery; `tests/server/proxy-liveness-owner-evidence.test.ts` and `tests/cli/cli-start-owner-evidence.test.ts` cover aggregate children, both listener families, shared deadlines and foreign-PID recovery. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks From 1c6369df11b07efa9ae08d2eb70378aebe4ba28f Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:49:27 +0900 Subject: [PATCH 49/54] fix(link): client restart ownership reads only current manager markers, never the parent command line Final review round 6. restartOwnerEvidence and the restart classification used isSupervisedServiceChild, which also accepts a markerless legacy Windows registration from its parent command line. Both now use isMarkedSupervisedServiceChild (OCX_SERVICE_MANAGED or the Windows wrapper marker), so such a runtime records no owner and keeps origin/dev restart, disconnect and sync behaviour. --- src/client/runtime-restart.ts | 15 +++--- src/service/service-child-ownership.ts | 8 ++- tests/clients/client-runtime-restart.test.ts | 57 +++++++++++++++++++- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index 1ce83c94724..0688fc03c04 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -27,7 +27,7 @@ import { isDesktopSupervised, } from "../lib/system-restart-contract"; import type { OcxClientConnectionConfig } from "../types"; -import { isSupervisedServiceChild } from "../service/service-child-ownership"; +import { isMarkedSupervisedServiceChild } from "../service/service-child-ownership"; import { sameClientConnectionOwner, type readClientConnectionState } from "./state"; type ClientConnectionState = ReturnType; @@ -167,7 +167,9 @@ export async function clientRestartSupervisor(): Promise boolean }): RestartOwner | null { if (input.desktop) return "desktop"; - if ((input.managed ?? isSupervisedServiceChild)(input.env)) return "service-manager"; + if ((input.managed ?? isMarkedSupervisedServiceChild)(input.env)) return "service-manager"; return input.env.OCX_SERVICE !== "1" ? "none" : null; } @@ -198,13 +200,12 @@ export function classifyClientRestartSupervisor(input: { diagnose: () => { viable: boolean; running: boolean }; /** origin/dev's check for a bare OCX_SERVICE=1: src/service/orchestration.ts isServiceViable. */ legacyServiceViable: () => boolean; - /** Manager-launched evidence; defaults to src/service/service-child-ownership.ts. */ + /** Manager-launched evidence; defaults to the current markers only (isMarkedSupervisedServiceChild). */ managed?: (env: NodeJS.ProcessEnv) => boolean; }): ClientRestartSupervisor | null { if (input.desktop) return "desktop"; - // The managed marker (launchd, systemd, WinSW), the Windows wrapper's protocol marker, or a - // legacy Windows wrapper parent. - if (!(input.managed ?? isSupervisedServiceChild)(input.env)) { + // The managed marker (launchd, systemd, WinSW) or the Windows wrapper's protocol marker. + if (!(input.managed ?? isMarkedSupervisedServiceChild)(input.env)) { if (input.env.OCX_SERVICE !== "1") return null; return input.legacyServiceViable() ? "service" : null; } diff --git a/src/service/service-child-ownership.ts b/src/service/service-child-ownership.ts index 0b319dbc3d0..c5cba4184c5 100644 --- a/src/service/service-child-ownership.ts +++ b/src/service/service-child-ownership.ts @@ -31,8 +31,12 @@ export type ServiceChildOwnershipDeps = { serviceHostPaths?: () => readonly string[]; }; -/** The env markers a real supervisor writes that an `ocx claude`/`ocx opencode` child does not. */ -function isMarkedSupervisedServiceChild(env: NodeJS.ProcessEnv): boolean { +/** + * The env markers a current supervisor writes that an `ocx claude`/`ocx opencode` child does not. + * Exported for the client restart (src/client/runtime-restart.ts), which takes only these markers as + * manager evidence; the parent-command-line inference below stays with the start-time ownership check. + */ +export function isMarkedSupervisedServiceChild(env: NodeJS.ProcessEnv): boolean { return env[SERVICE_MANAGED_ENV] === "1" || env[WINDOWS_WRAPPER_PROTOCOL_ENV] === "1"; } diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 9730a0d2ac5..56b27350a68 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -12,13 +12,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { startMachineListener } from "../../src/client/machine-listener"; -import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, restartOwnerProven, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; +import { classifyClientRestartSupervisor, clientConnectionEnded, clientRestartRollbackAction, createClientRestartController, restartOwnerEvidence, restartOwnerProven, type ClientRestartAdmission, type ClientRestartIo } from "../../src/client/runtime-restart"; import { readClientConnectionState, readClientConnectionStateWithEvidence } from "../../src/client/state"; import { claudeProxyStartEnv } from "../../src/cli/claude"; import { opencodeProxyStartEnv } from "../../src/cli/opencode"; import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; import { spyOn } from "bun:test"; import { deriveLaunchdServiceDiagnostic, deriveSystemdServiceDiagnostic, deriveWindowsServiceDiagnostic } from "../../src/service/diagnostics"; +import * as serviceChildOwnership from "../../src/service/service-child-ownership"; +import { clientRuntimeRecord } from "../../src/client/runtime"; +import { restartRuntimeAfterDisconnect } from "../../src/cli/disconnect-runtime"; +import { restartRuntimeAfterApply } from "../../src/cli/link-apply-restart"; import { createLocalAttestationChallenge, createLocalAttestationSecret, @@ -485,6 +489,57 @@ describe("a bare OCX_SERVICE=1 keeps origin/dev's ocx restart decision", () => { expect(restartOwnerProven({ desktop: false, env: { OCX_SERVICE: "1", OCX_WINDOWS_WRAPPER_PROTOCOL: "1" } })).toBe(true); expect(restartOwnerProven({ desktop: false, env: {} })).toBe(true); }); + + // Final review round 6: a markerless legacy Windows registration (only OCX_SERVICE=1; its parent is + // this install's Task Scheduler wrapper) is recognised by the real parent-command-line derivation, + // but that is inference, not a current manager marker. It stays on origin/dev's path for restart, + // disconnect and sync. The production default is routed through the real Windows derivation with + // faked process data, so the branch is reached on any host. + test("a markerless legacy Windows service child stays on origin/dev's path: restart, disconnect, sync", async () => { + const wrapper = "C:\\Users\\u\\.opencodex\\service\\opencodex-service.cmd"; + const windowsParent = { platform: "win32" as const, parentPid: () => 4242, processCommandLine: () => `cmd.exe /d /c "${wrapper}"`, serviceHostPaths: () => [wrapper] }; + const env = { OCX_SERVICE: "1", PATH: "C:\\Windows" } as NodeJS.ProcessEnv; + const real = serviceChildOwnership.isSupervisedServiceChild; + expect(real(env, windowsParent)).toBe(true); + const spy = spyOn(serviceChildOwnership, "isSupervisedServiceChild").mockImplementation(e => real(e, windowsParent)); + try { + // Recorded with no owner, and classified as origin/dev classifies a bare flag (task disabled: not viable). + const owner = restartOwnerEvidence({ desktop: false, env }); + expect(owner).toBeNull(); + const supervisor = classifyClientRestartSupervisor({ desktop: false, env, diagnose: () => ({ viable: false, running: true }), legacyServiceViable: () => false }); + expect(supervisor).toBeNull(); + // ocx restart: origin/dev's replacement restart, never the recycle. + const { io, calls } = fakeIo({ supervisor: async () => supervisor, ownerProven: () => restartOwnerProven({ desktop: false, env }), connectionChanged: () => true, recycle: async () => { calls.push("recycle"); } }); + const controller = createClientRestartController(io); + expect(await controller.request()).toEqual({ kind: "accepted", alreadyRestarting: false }); + await controller.settled(); + expect(calls).toEqual(["stop:20000", "spawn:71000", "exit:0"]); + // ocx disconnect: the record states no owner, so no restart request and exit 0. + const record = clientRuntimeRecord(4321, 10100, createLocalAttestationSecret(), owner); + expect(record.restartOwner).toBeUndefined(); + let restarts = 0; + const code = await restartRuntimeAfterDisconnect({ + observe: async () => ({ kind: "live", live: { pid: 4321, port: 10100, role: "client" } }), + capture: () => ({ ...record, attestationSecret: record.attestationSecret! }), + connectionState: () => ({ kind: "disconnected" }), + restart: async () => { restarts += 1; return { ok: true }; }, + json: false, info: () => {}, error: () => {}, + }); + expect({ code, restarts }).toEqual({ code: 0, restarts: 0 }); + // ocx sync: restarted as on origin/dev, not reported as a service deferral. + const syncController = createClientRestartController({ ...fakeIo({ supervisor: async () => supervisor, ownerProven: () => restartOwnerProven({ desktop: false, env }), connectionChanged: () => false }).io }); + const outcome = await restartRuntimeAfterApply({ linkId: "lnk_x", fetchedAt: "2026-11-11T00:00:00.000Z", appliedAt: "2026-11-11T00:00:00.000Z", document: {} } as never, { + findLive: async () => ({ pid: 4321, port: 10100, source: "runtime", role: "client" }) as never, + readStartedAt: async () => 0, + restart: async (_start, onSupervised) => { const admission = await syncController.request(); await syncController.settled(); if (admission.kind === "supervised") { onSupervised(admission.supervisor); return false; } return admission.kind === "accepted"; }, + recoveryStart: async () => ({ status: "skipped" }), + connectionUnchanged: () => true, + }); + expect(outcome).toEqual({ kind: "restarted" }); + } finally { + spy.mockRestore(); + } + }); }); // Final review: a missing config.json reads as the default configuration, which is not evidence that From e41ce8a820240b5cbffcac685335db0c42f2d166 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:49:27 +0900 Subject: [PATCH 50/54] docs(link): disconnect advice per observed state; manager evidence is markers only --- docs-site/src/content/docs/guides/remote-link.md | 2 +- structure/remote-link.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index fcf60a733c3..8b10196d9ad 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -109,7 +109,7 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` -On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints the remaining step: `ocx restart` on the Child. A client runtime whose connection was removed accepts `ocx restart` when its owner can relaunch it: the OpenCodex desktop app, or a background service that is enabled and running, starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. A service that cannot be shown to relaunch it (for example a unit started by hand while disabled) keeps the runtime and the command says to run `ocx service restart`. A runtime that carries only `OCX_SERVICE=1` (a service installed before 2.49, or a proxy started by `ocx claude`, `ocx codex` or `ocx opencode`) is not changed by this: `ocx disconnect` does not restart it, and `ocx restart` refuses while its service is viable, as before. The same holds for a client runtime started by an OpenCodex version before this change: `ocx disconnect` restarts a runtime only after it has been restarted once on the new version (an update's own restart, the desktop app restarting it, `ocx service restart`, or `ocx restart`). +On a full OpenCodex Child, `ocx disconnect` also restarts a client runtime that is still running, so the Child is serving standalone OpenCodex when the command returns. If that restart does not complete, the command exits non-zero and prints advice for the state it observed: `ocx restart` on the Child when the same runtime still serves the old connection; `ocx service restart` when a service that cannot be shown to relaunch the runtime refused the restart; and inspection with `ocx status` and `ocx connect status` when a new connection is in use, the client state cannot be read, or a different or unknown runtime answers. A client runtime whose connection was removed accepts `ocx restart` when its owner can relaunch it: the OpenCodex desktop app, or a background service that is enabled and running, starts the standalone replacement, so the same command works over SSH with nobody at the Child's screen. A service that cannot be shown to relaunch it (for example a unit started by hand while disabled) keeps the runtime and the command says to run `ocx service restart`. A runtime that carries only `OCX_SERVICE=1` (a service installed before 2.49, or a proxy started by `ocx claude`, `ocx codex` or `ocx opencode`) is not changed by this: `ocx disconnect` does not restart it, and `ocx restart` refuses while its service is viable, as before. The same holds for a client runtime started by an OpenCodex version before this change: `ocx disconnect` restarts a runtime only after it has been restarted once on the new version (an update's own restart, the desktop app restarting it, `ocx service restart`, or `ocx restart`). ## Troubleshooting diff --git a/structure/remote-link.md b/structure/remote-link.md index 696f16b77bf..fe7d3448f79 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is `isSupervisedServiceChild` (the managed marker, the Windows wrapper marker or a legacy Windows service-host parent); relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. `service-unproven` keeps the runtime and is refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. The ended-connection recycle needs positive ownership evidence (`restartOwnerProven`: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: `ocx restart` refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), its runtime record states no owner, and `ocx disconnect` restarts only a runtime whose record states one it handles (`restartOwner`: desktop, service-manager or none). A bare-flag runtime, one started before this field existed, or an unknown value gets origin/dev's disconnect: no restart request, exit 0. An inconclusive liveness probe or an unreadable record still exits 1 before any request, the one deliberate difference from origin/dev (no runtime is stopped, and the Home's leave stops before its own `ocx restart`). Owner evidence for those runtimes is a separate design (follow-up issue). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is the current markers in the environment only (`isMarkedSupervisedServiceChild`: the managed marker or the Windows wrapper marker); a legacy Windows registration recognised only from its parent's command line counts as a bare OCX_SERVICE=1; relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. `service-unproven` keeps the runtime and is refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. The ended-connection recycle needs positive ownership evidence (`restartOwnerProven`: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: `ocx restart` refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), its runtime record states no owner, and `ocx disconnect` restarts only a runtime whose record states one it handles (`restartOwner`: desktop, service-manager or none). A bare-flag runtime, one started before this field existed, or an unknown value gets origin/dev's disconnect: no restart request, exit 0. An inconclusive liveness probe or an unreadable record still exits 1 before any request, the one deliberate difference from origin/dev (no runtime is stopped, and the Home's leave stops before its own `ocx restart`). Owner evidence for those runtimes is a separate design (follow-up issue). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From 521ad93ed3a72357a42ca83e7d672de5f8900cbf Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:03:49 +0900 Subject: [PATCH 51/54] fix(link): no supervisor marker reaches a process its supervisor did not launch Final review round 7. A shell a manager-owned runtime hands to a tool inherits OCX_SERVICE_MANAGED or the Windows wrapper marker, so a companion started from it recorded a service-manager owner that does not own it. One shared list (src/lib/supervisor-markers.ts) now clears every supervisor marker from the three companion launch environments, the standalone recycle, the detached ensure/tray start, the CLI detached start, the restart replacement, the CLI and launcher post-update restarts, the update worker pinned start, and the Cursor background shell and desktop executor. --- bin/ocx.mjs | 2 + src/adapters/cursor/native-exec-desktop.ts | 3 +- src/adapters/cursor/native-exec-shell.ts | 3 +- src/cli/claude.ts | 3 +- src/cli/codex-launcher.ts | 4 +- src/cli/index.ts | 10 ++- src/cli/opencode.ts | 4 +- src/client/runtime.ts | 5 +- src/lib/supervisor-markers.ts | 18 +++++ src/server/management/system-restart.ts | 6 +- src/update/index.ts | 3 +- src/update/job-pinned-start.ts | 3 +- tests/cli/cli-dispatch.test.ts | 12 ++-- tests/clients/client-runtime-restart.test.ts | 75 +++++++++++++++++++- 14 files changed, 124 insertions(+), 27 deletions(-) create mode 100644 src/lib/supervisor-markers.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index dbff457b940..13e5de45e07 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -576,6 +576,8 @@ async function runPackageManagerSelfUpdate(manager, child) { } const env = mutationChildEnvironment(); delete env.OCX_SERVICE; + // Not launched by a supervisor: none of its markers (src/lib/supervisor-markers.ts) may ride along. + for (const name of ["OCX_DESKTOP_SUPERVISED", "OCX_SERVICE_MANAGED", "OCX_WINDOWS_WRAPPER_PROTOCOL"]) delete env[name]; // The restarted proxy is an ordinary owner; only a sibling's own replacement carries this. delete env.OCX_SIBLING_OF_PORT; delete env.OCX_SIBLING_HANDOFF_NONCE; diff --git a/src/adapters/cursor/native-exec-desktop.ts b/src/adapters/cursor/native-exec-desktop.ts index 3a7b207ee89..d72608455a5 100644 --- a/src/adapters/cursor/native-exec-desktop.ts +++ b/src/adapters/cursor/native-exec-desktop.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { withoutSupervisorMarkers } from "../../lib/supervisor-markers"; import { shellInvocation } from "../../lib/win-exec"; import { create } from "@bufbuild/protobuf"; import { @@ -120,7 +121,7 @@ function runExternalJson(command: string, payload: unknown, config: DesktopExecu const inv = shellInvocation(command); const child = spawn(inv.file, inv.args, { cwd: config.cwd, - env: config.env ? { ...process.env, ...config.env } : process.env, + env: withoutSupervisorMarkers(config.env ? { ...process.env, ...config.env } : process.env), stdio: ["pipe", "pipe", "pipe"], ...inv.options, }); diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 6dd9f64c897..9a1faf81a86 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { withoutSupervisorMarkers } from "../../lib/supervisor-markers"; import { resolve } from "node:path"; import { foregroundShellUnavailableMessage, runForegroundShell, type CursorForegroundShellOwner } from "./native-foreground-shell"; import { create } from "@bufbuild/protobuf"; @@ -430,7 +431,7 @@ export function backgroundShellSpawnExec(execMsg: ExecServerMessage, sessionId: if (!admissionLease) return backgroundShellSpawnError(execMsg, args.command, cwd, "background shell limit reached"); let child: ChildProcessWithoutNullStreams; try { - child = backgroundShellRuntime.spawn(args.command, { cwd, shell: true }); + child = backgroundShellRuntime.spawn(args.command, { cwd, shell: true, env: withoutSupervisorMarkers(process.env) }); } catch (err) { admissionLease.release(); return backgroundShellSpawnError(execMsg, args.command, cwd, errorText(err)); diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 33d99cb3268..3c26a55e4fc 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -37,6 +37,7 @@ import { desktopProfileWireAlias, legacyDesktopProfileWireAlias } from "../claud import { inspectDesktopFirstParty } from "../claude/desktop-first-party"; import { isClaudeInterceptProxyUrl, type ClaudeInterceptSettingsState } from "../claude/intercept/settings"; import { withoutSiblingMarker } from "../codex/sibling-start"; +import { withoutSupervisorMarkers } from "../lib/supervisor-markers"; /** * The environment of the detached `ocx start` that `ocx claude` launches (the spawn adds the runtime @@ -45,7 +46,7 @@ import { withoutSiblingMarker } from "../codex/sibling-start"; * a manager-launched process (src/client/runtime-restart.ts classifyClientRestartSupervisor). */ export function claudeProxyStartEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" }); + return withoutSupervisorMarkers(withoutSiblingMarker({ ...base, OCX_SERVICE: "1" })); } export interface ClaudeLaunchEnv { diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 7b328602308..617e30edf84 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -38,6 +38,7 @@ import { attestCodexRuntime, codexCredentialDestinationIsTrusted, resolveCodexLa import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; import { codexTokenWithheldDiagnostic, type CodexTokenRefusal, type CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; +import { withoutSupervisorMarkers } from "../lib/supervisor-markers"; export interface CodexLaunchEnv { [key: string]: string | undefined; @@ -115,7 +116,8 @@ export function codexProxyStartEnv(base: CodexLaunchEnv = process.env): CodexLau const withTokenFile = stripped.OPENCODEX_API_AUTH_TOKEN?.trim() ? stripped : { ...stripped, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; - return { ...withTokenFile, OCX_SERVICE: "1" } as CodexLaunchEnv; + // A shell a supervised runtime handed to a tool carries its supervisor's markers; this proxy is not its. + return withoutSupervisorMarkers({ ...withTokenFile, OCX_SERVICE: "1" }) as CodexLaunchEnv; } export type CodexProxyEnsureDeps = { diff --git a/src/cli/index.ts b/src/cli/index.ts index 4abf5c14a61..870000f41dd 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun -import { serviceStayOutExitCode, WINDOWS_WRAPPER_PROTOCOL_ENV } from "../service/windows-wrapper-exit"; +import { serviceStayOutExitCode } from "../service/windows-wrapper-exit"; import { isSupervisedServiceChild, serviceChildOwnershipDecisionForClassifiedChild } from "../service/service-child-ownership"; -import { SERVICE_MANAGED_ENV } from "../service/state"; +import { withoutSupervisorMarkers } from "../lib/supervisor-markers"; import { raiseWindowsProxyPriority } from "../service/windows-process-priority"; import { spawn } from "node:child_process"; import { homedir } from "node:os"; @@ -806,15 +806,13 @@ async function handleStart(options: { block?: boolean } = {}) { } function detachedStartEnvironment(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env); + const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env)); // Only a real service wrapper may claim supervision. A detached ensure/tray child is an // ordinary owner, never a sibling: while live it maintains routing, and on exit restores it. // The supervisor markers ride along whenever this runs inside a service child's // environment, so they must leave with OCX_SERVICE — otherwise the child would // answer the ownership gate as a managed job it is not. delete env.OCX_SERVICE; - delete env[SERVICE_MANAGED_ENV]; - delete env[WINDOWS_WRAPPER_PROTOCOL_ENV]; return withProcessRuntimeProvenance(env); } @@ -1979,7 +1977,7 @@ process.exit(await dispatchCommand(head, { detached: true, stdio: "ignore", windowsHide: true, - env: withProcessRuntimeProvenance(withoutSiblingMarker(process.env)), + env: withProcessRuntimeProvenance(withoutSupervisorMarkers(withoutSiblingMarker(process.env))), }); child.unref(); }, diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index ab66e8dd253..39989946744 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -54,6 +54,7 @@ import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { withoutSiblingMarker } from "../codex/sibling-start"; import { parseJsonc } from "../lib/jsonc"; +import { withoutSupervisorMarkers } from "../lib/supervisor-markers"; export { parseJsonc }; /** @@ -575,7 +576,8 @@ export function opencodeProxyStartEnv(base: OpencodeLaunchEnv = process.env): Op const withTokenFile = base.OPENCODEX_API_AUTH_TOKEN?.trim() ? base : serviceTokenLookupEnv(base); - return { ...withTokenFile, OCX_SERVICE: "1" }; + // A shell a supervised runtime handed to a tool carries its supervisor's markers; this proxy is not its. + return withoutSupervisorMarkers({ ...withTokenFile, OCX_SERVICE: "1" }); } /** diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 70636cffc26..4df1fc535c8 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -33,6 +33,7 @@ import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientConnectionEnded, clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, restartOwnerEvidence, type ClientRestartController, type RestartOwner } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; +import { SUPERVISOR_MARKER_ENVS } from "../lib/supervisor-markers"; import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; let activeServer: Server | null = null; @@ -123,10 +124,8 @@ export function standaloneRecycleEnv( const childEnv = { ...env }; // A detached replacement is nobody's child: not the desktop app's, not a service's, not a Windows // wrapper's. It must carry no supervisor marker it would otherwise misread as its own. - delete childEnv[DESKTOP_SUPERVISED_ENV]; + for (const name of SUPERVISOR_MARKER_ENVS) delete childEnv[name]; delete childEnv.OCX_SERVICE; - delete childEnv.OCX_SERVICE_MANAGED; - delete childEnv.OCX_WINDOWS_WRAPPER_PROTOCOL; const admissionToken = childEnv.OPENCODEX_API_AUTH_TOKEN?.trim(); if (admissionToken && serviceApiTokenFingerprint(admissionToken) !== disconnectedTokenFingerprint) { // A surviving env token shadows OCX_API_TOKEN_FILE entirely, so nothing below can diff --git a/src/lib/supervisor-markers.ts b/src/lib/supervisor-markers.ts new file mode 100644 index 00000000000..00d074ebd61 --- /dev/null +++ b/src/lib/supervisor-markers.ts @@ -0,0 +1,18 @@ +/** + * The environment markers a supervisor writes for the one process it launches: the desktop app + * (OCX_DESKTOP_SUPERVISED), a service manager (OCX_SERVICE_MANAGED: launchd, systemd, WinSW) and the + * Windows Task Scheduler wrapper (OCX_WINDOWS_WRAPPER_PROTOCOL). A marker proves only that the + * process was started with it, so it must never reach a process that supervisor did not launch: a + * detached proxy, a replacement, or a shell handed to a tool. Every such launch removes them with + * {@link withoutSupervisorMarkers}. The values match DESKTOP_SUPERVISED_ENV + * (src/lib/system-restart-contract.ts), SERVICE_MANAGED_ENV (src/service/state.ts) and + * WINDOWS_WRAPPER_PROTOCOL_ENV (src/service/windows-wrapper-exit.ts); a test pins that. + */ +export const SUPERVISOR_MARKER_ENVS = ["OCX_DESKTOP_SUPERVISED", "OCX_SERVICE_MANAGED", "OCX_WINDOWS_WRAPPER_PROTOCOL"] as const; + +/** A copy of `env` without any supervisor marker; `env` itself is not changed. */ +export function withoutSupervisorMarkers>(env: T): T { + const copy = { ...env }; + for (const name of SUPERVISOR_MARKER_ENVS) delete copy[name]; + return copy; +} diff --git a/src/server/management/system-restart.ts b/src/server/management/system-restart.ts index 35e3cefd6e5..d05a0861529 100644 --- a/src/server/management/system-restart.ts +++ b/src/server/management/system-restart.ts @@ -54,6 +54,7 @@ import { withSiblingMarker } from "../../codex/sibling-start"; import { issueSiblingHandoff } from "../../codex/sibling-handoff"; import { readRuntimePort } from "../../config/process-state"; import { spendLedgerRestartEnvironment } from "../../lib/spend-ledger-owner"; +import { SUPERVISOR_MARKER_ENVS } from "../../lib/supervisor-markers"; import { DESKTOP_RESTART_EXIT_CODE, DESKTOP_SUPERVISED_ENV, @@ -275,8 +276,9 @@ export function replacementStartEnvironment( // A sibling's replacement stays a sibling even if the owner is down while it probes. const sourceEnv: NodeJS.ProcessEnv = withSiblingMarker(process.env, issueSiblingHandoff); delete sourceEnv.OCX_SERVICE; - // A detached replacement is not the desktop app's child; it must never exit to an app that is not waiting on it. - delete sourceEnv[DESKTOP_SUPERVISED_ENV]; + // A detached replacement is not the desktop app's child, nor a service manager's or the Windows + // wrapper's; it must never exit to a supervisor that is not waiting on it. + for (const name of SUPERVISOR_MARKER_ENVS) delete sourceEnv[name]; return spendLedgerRestartEnvironment( sourceEnv, waitForHealthBeforeParentExit ? undefined : parentPid, diff --git a/src/update/index.ts b/src/update/index.ts index ad49810be9b..28a0d618553 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -42,6 +42,7 @@ import { import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { withoutSiblingMarker } from "../codex/sibling-start"; +import { withoutSupervisorMarkers } from "../lib/supervisor-markers"; import { packageVersion } from "../lib/package-version"; import { isStandaloneModuleUrl } from "../lib/standalone"; import { selfLaunchArgv } from "../lib/self-launch-argv"; @@ -691,7 +692,7 @@ async function runUpdateWithSession(child: Extract void, formatError: (error: unknown) => string): ChildProcess { // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. - const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env); + const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env)); delete env.OCX_SERVICE; report(`$ ${cmd.display}`); let stdio: "ignore" | [ "ignore", number, number ] = "ignore"; diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index c3ba06e6b09..333b4ce75b1 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -733,23 +733,21 @@ describe("a sibling start leaves shared client routing to the live owner", () => expect(recovery.indexOf("markCrossHomeSibling()")).toBeGreaterThan(-1); expect(recovery.indexOf("markCrossHomeSibling()")).toBeLessThan(recovery.indexOf("reconcileStartupJournal()")); const detached = slice("function detachedStartEnvironment(", "async function handleEnsure("); - expect(detached).toContain("const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"); + expect(detached).toContain("const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env));"); expect(detached).toContain("delete env.OCX_SERVICE;"); - expect(detached).toContain("delete env[SERVICE_MANAGED_ENV];"); - expect(detached).toContain("delete env[WINDOWS_WRAPPER_PROTOCOL_ENV];"); - expect(cliSource).toContain("env: withProcessRuntimeProvenance(withoutSiblingMarker(process.env)),"); + expect(cliSource).toContain("env: withProcessRuntimeProvenance(withoutSupervisorMarkers(withoutSiblingMarker(process.env))),"); // Every other detached `ocx start` is an ordinary owner too: the client auto-starts and the // updater's restart. A stray marker would mark them before any probe. const opencodeStartEnv = "env: withProcessRuntimeProvenance(opencodeProxyStartEnv(withoutSiblingMarker(process.env)) as NodeJS.ProcessEnv),"; for (const [path, env] of [ // ocx claude builds its proxy's environment in claudeProxyStartEnv (also read by the restart's // owner classification) and spawns with it. - ["src/cli/claude.ts", 'return withoutSiblingMarker({ ...base, OCX_SERVICE: "1" });'], + ["src/cli/claude.ts", 'return withoutSupervisorMarkers(withoutSiblingMarker({ ...base, OCX_SERVICE: "1" }));'], ["src/cli/claude.ts", "env: withProcessRuntimeProvenance(claudeProxyStartEnv()),"], ["src/cli/opencode.ts", opencodeStartEnv], ["src/cli/minimax.ts", opencodeStartEnv], - ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"], - ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSiblingMarker(process.env));"], + ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env));"], + ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSupervisorMarkers(withoutSiblingMarker(process.env)));"], ] as const) { expect(readFileSync(repoPath(path), "utf8"), path).toContain(env); } diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 56b27350a68..5e68f314ea8 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -7,7 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; @@ -20,7 +20,13 @@ import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; import { spyOn } from "bun:test"; import { deriveLaunchdServiceDiagnostic, deriveSystemdServiceDiagnostic, deriveWindowsServiceDiagnostic } from "../../src/service/diagnostics"; import * as serviceChildOwnership from "../../src/service/service-child-ownership"; -import { clientRuntimeRecord } from "../../src/client/runtime"; +import { clientRuntimeRecord, standaloneRecycleEnv } from "../../src/client/runtime"; +import { replacementStartEnvironment } from "../../src/server/management/system-restart"; +import { SUPERVISOR_MARKER_ENVS } from "../../src/lib/supervisor-markers"; +import { DESKTOP_SUPERVISED_ENV } from "../../src/lib/system-restart-contract"; +import { SERVICE_MANAGED_ENV } from "../../src/service/state"; +import { WINDOWS_WRAPPER_PROTOCOL_ENV } from "../../src/service/windows-wrapper-exit"; +import { repoPath } from "../helpers/repo-root"; import { restartRuntimeAfterDisconnect } from "../../src/cli/disconnect-runtime"; import { restartRuntimeAfterApply } from "../../src/cli/link-apply-restart"; import { @@ -614,3 +620,68 @@ describe("rollback after a failed replacement", () => { expect(clientRestartRollbackAction(link, { kind: "connected", value: { ...link, link: { ...link.link, linkId: `lnk_${"c".repeat(16)}` } } })).toBe("recycle"); }); }); + +// Final review round 7: a marker proves only that a process was started with it. Shells a +// manager-owned runtime hands to tools inherit its environment, so a companion started from one +// would record a manager that does not own it. Every detached launch strips the markers. +describe("supervisor markers never reach a process their supervisor did not launch", () => { + test("one list, matching the constants that define each marker", () => { + expect([...SUPERVISOR_MARKER_ENVS].sort()).toEqual([DESKTOP_SUPERVISED_ENV, SERVICE_MANAGED_ENV, WINDOWS_WRAPPER_PROTOCOL_ENV].sort()); + }); + + const companions = [ + ["ocx claude", (base: NodeJS.ProcessEnv) => claudeProxyStartEnv(base)], + ["ocx codex", (base: NodeJS.ProcessEnv) => codexProxyStartEnv(base) as NodeJS.ProcessEnv], + ["ocx opencode", (base: NodeJS.ProcessEnv) => opencodeProxyStartEnv(base) as NodeJS.ProcessEnv], + ] as const; + for (const [name, launch] of companions) { + for (const marker of SUPERVISOR_MARKER_ENVS) { + test(name + " started from a shell carrying " + marker + ": no marker, no owner, disconnect leaves it", async () => { + const env = launch({ PATH: "/usr/bin", OCX_SERVICE: "1", [marker]: "1" }); + for (const other of SUPERVISOR_MARKER_ENVS) expect(env[other]).toBeUndefined(); + const owner = restartOwnerEvidence({ desktop: false, env }); + expect(owner).toBeNull(); + const record = clientRuntimeRecord(4321, 10100, createLocalAttestationSecret(), owner); + expect(record.restartOwner).toBeUndefined(); + let restarts = 0; + const code = await restartRuntimeAfterDisconnect({ + observe: async () => ({ kind: "live", live: { pid: 4321, port: 10100, role: "client" } }), + capture: () => ({ ...record, attestationSecret: record.attestationSecret! }), + connectionState: () => ({ kind: "disconnected" }), + restart: async () => { restarts += 1; return { ok: true }; }, + json: false, info: () => {}, error: () => {}, + }); + expect({ code, restarts }).toEqual({ code: 0, restarts: 0 }); + }); + } + } + + test("the standalone recycle and the restart replacement strip every marker", () => { + const saved = Object.fromEntries(SUPERVISOR_MARKER_ENVS.map(name => [name, process.env[name]])); + for (const name of SUPERVISOR_MARKER_ENVS) process.env[name] = "1"; + try { + const recycle = standaloneRecycleEnv(process.env, "fp"); + const replacement = replacementStartEnvironment(true); + for (const name of SUPERVISOR_MARKER_ENVS) { + expect(recycle[name]).toBeUndefined(); + expect(replacement[name]).toBeUndefined(); + } + } finally { + for (const name of SUPERVISOR_MARKER_ENVS) { if (saved[name] === undefined) delete process.env[name]; else process.env[name] = saved[name]; } + } + }); + + // The other launch sites build their environment inline; each must pass it through the helper. + test.each([ + ["src/cli/index.ts", "function detachedStartEnvironment(): NodeJS.ProcessEnv {\n const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env));"], + ["src/cli/index.ts", "env: withProcessRuntimeProvenance(withoutSupervisorMarkers(withoutSiblingMarker(process.env))),"], + ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSupervisorMarkers(withoutSiblingMarker(process.env)));"], + ["src/adapters/cursor/native-exec-shell.ts", "backgroundShellRuntime.spawn(args.command, { cwd, shell: true, env: withoutSupervisorMarkers(process.env) });"], + ["src/adapters/cursor/native-exec-desktop.ts", "env: withoutSupervisorMarkers(config.env ? { ...process.env, ...config.env } : process.env),"], + ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env));"], + ["bin/ocx.mjs", `for (const name of ${JSON.stringify(SUPERVISOR_MARKER_ENVS).replaceAll(",", ", ")}) delete env[name];`], + ] as const)("%s strips the markers where it launches", (path, line) => { + expect(readFileSync(repoPath(path), "utf8")).toContain(line); + }); +}); + From 1a44872e2bbd94fcc84421b49b9c2eb01384816a Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:05:21 +0900 Subject: [PATCH 52/54] docs(link): bare-flag legacy service can detach on the dev path; markers never reach unlaunched processes --- structure/remote-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/remote-link.md b/structure/remote-link.md index fe7d3448f79..956a2d9f321 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen `ocx status --json` reports `appliedCodexSettingsDigest` on every device (a Child's record, or a Home's own projection, which is what it serves), and on a Child `lastApplyAt` and `runtimeStartedAt` as epoch ms (`src/cli/status-codex-settings.ts`). The client runtime's `/healthz` reports `startedAt`. After `ocx sync` records an apply, `src/cli/link-apply-restart.ts` restarts the client runtime through `handleProxyRestart`, the `ocx restart` path, only when the live client runtime started before `appliedAt` or reports no start; its start callback starts nothing unless an accepted restart left no runtime, which takes the ordinary recovery start. An explicit Desktop or service supervised refusal defers that restart without failing the applied sync; the notice directs Desktop users to Quit from the tray menu and reopen OpenCodex, or service users to `ocx service restart`. The next sync retries until the runtime starts after the apply. Other restart failures remain failures, and any earlier sync error is retained. The Child's JSON shape stays unchanged so existing Home fan-out parsers accept the successful result. -The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is the current markers in the environment only (`isMarkedSupervisedServiceChild`: the managed marker or the Windows wrapper marker); a legacy Windows registration recognised only from its parent's command line counts as a bare OCX_SERVICE=1; relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. `service-unproven` keeps the runtime and is refused at admission even when the connection ended; only a process no manager owns detaches, carrying no supervisor marker. The ended-connection recycle needs positive ownership evidence (`restartOwnerProven`: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: `ocx restart` refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), its runtime record states no owner, and `ocx disconnect` restarts only a runtime whose record states one it handles (`restartOwner`: desktop, service-manager or none). A bare-flag runtime, one started before this field existed, or an unknown value gets origin/dev's disconnect: no restart request, exit 0. An inconclusive liveness probe or an unreadable record still exits 1 before any request, the one deliberate difference from origin/dev (no runtime is stopped, and the Home's leave stops before its own `ocx restart`). Owner evidence for those runtimes is a separate design (follow-up issue). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. +The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". That refusal applies only while the durable connection is still the one the runtime serves: after `ocx disconnect` or a newer connect there is nothing to keep, so the restart is accepted and hands off to the standalone recycle (desktop exit 75, a viable service exit 1, otherwise a spawned replacement carrying no supervisor marker). "Ended" needs evidence (`readClientConnectionStateWithEvidence`, one snapshot of config.json): a saved file that validates and records no client, or another connection; missing, unreadable or invalid is invalid. Ownership and relaunch are separate (`classifyClientRestartSupervisor`): manager-launched evidence is the current markers in the environment only (`isMarkedSupervisedServiceChild`: the managed marker or the Windows wrapper marker); a legacy Windows registration recognised only from its parent's command line counts as a bare OCX_SERVICE=1; relaunch needs the service diagnostic to be viable and running. Manager-launched without that is `service-unproven`. `service-unproven` keeps the runtime and is refused at admission even when the connection ended; on this PR's recycle path only a process whose owner is proven to be none detaches, carrying no supervisor marker. A bare OCX_SERVICE=1 is outside that path: with a viable service it is refused, and otherwise it takes origin/dev's replacement restart, which spawns a detached replacement even while a legacy manager still owns the process (#8545). A marker proves only that the process was started with it, so no supervisor marker may reach a process that supervisor did not launch: `src/lib/supervisor-markers.ts` holds the one list, and every detached runtime launch (companions, recycle, replacement, detached and post-update starts) and every shell a runtime hands to a tool clears it. The ended-connection recycle needs positive ownership evidence (`restartOwnerProven`: the desktop app, a manager's marker, or no OCX_SERVICE at all). A bare OCX_SERVICE=1 (a service registered before v2.49, or the proxy of ocx claude, ocx codex or ocx opencode) keeps origin/dev's behaviour unchanged: `ocx restart` refuses when the service is viable and otherwise restarts with a replacement (rollback exits 1 without spawning), its runtime record states no owner, and `ocx disconnect` restarts only a runtime whose record states one it handles (`restartOwner`: desktop, service-manager or none). A bare-flag runtime, one started before this field existed, or an unknown value gets origin/dev's disconnect: no restart request, exit 0. An inconclusive liveness probe or an unreadable record still exits 1 before any request, the one deliberate difference from origin/dev (no runtime is stopped, and the Home's leave stops before its own `ocx restart`). Owner evidence for those runtimes is a separate design (follow-up issue). `ocx disconnect` (`src/cli/disconnect-runtime.ts`) runs that restart itself when a client runtime is still live, bound to the runtime generation it observed: it captures that runtime's record (pid, port, per-process attestation secret) and the bound restart attests and derives its capability from the captured record, so another process at a reused pid is refused. It reads the client state once after its observation; when no runtime or a non-client runtime answered, that read decides alone (exit 0 only if disconnected). When it restarted a client runtime, it reads the state again after the restart: exit 0 needs that read to be disconnected and a live non-client runtime answering, and every exit-1 outcome takes its advice from one function (`recoveryAdvice`) given the state read at that point and whether the captured runtime, a different one or nothing answers: a plain restart is advised only for the captured runtime under a disconnected state (or a start when nothing answers), and a connected or unreadable state gets `ocx status` inspection only. Every other outcome exits 1, so a Home-driven leave stops before its own `ocx restart`. No read fences a process that runs after the command has exited. Right after its connection commits, `connectClient` (and each connected `ocx sync` injection) takes over a Codex journal another owner wrote (`adoptJournalForClient` in `src/codex/journal.ts`), keeping the original snapshot; before the commit the journal stays its live writer's, so a runtime starting mid-connect leaves it alone, and after it a supervisor relaunch does not read the standalone owner as a crashed session and restore native Codex. Only a disconnected state or a different committed connection counts as ended for a supervised restart; unreadable or mismatched state keeps the refusal, and the controller rechecks after its supervisor lookup, immediately before recycling, handing the recycle the same supervisor verdict (a service only when viable). Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. From 2c7ec70b0b2f909f47a2cb45cf5a496bb3ff165c Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:15:25 +0900 Subject: [PATCH 53/54] fix(update): clear supervisor markers inline in the update restart bodies The update restart and pinned-start bodies run under their tests dependency injection, so they delete the shared marker list inline (as bin/ocx.mjs does) instead of calling the helper; the pin test checks the list stays the same. --- src/update/index.ts | 5 +++-- src/update/job-pinned-start.ts | 5 +++-- tests/cli/cli-dispatch.test.ts | 4 ++-- tests/clients/client-runtime-restart.test.ts | 5 +++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/update/index.ts b/src/update/index.ts index 28a0d618553..41fa4274d4c 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -42,7 +42,6 @@ import { import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { withoutSiblingMarker } from "../codex/sibling-start"; -import { withoutSupervisorMarkers } from "../lib/supervisor-markers"; import { packageVersion } from "../lib/package-version"; import { isStandaloneModuleUrl } from "../lib/standalone"; import { selfLaunchArgv } from "../lib/self-launch-argv"; @@ -692,8 +691,10 @@ async function runUpdateWithSession(child: Extract void, formatError: (error: unknown) => string): ChildProcess { // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. - const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env)); + const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env); delete env.OCX_SERVICE; + // Not launched by a supervisor: none of its markers (src/lib/supervisor-markers.ts) may ride along. + for (const name of ["OCX_DESKTOP_SUPERVISED", "OCX_SERVICE_MANAGED", "OCX_WINDOWS_WRAPPER_PROTOCOL"]) delete env[name]; report(`$ ${cmd.display}`); let stdio: "ignore" | [ "ignore", number, number ] = "ignore"; let logFd: number | undefined; diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 333b4ce75b1..2b2c3a90427 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -746,8 +746,8 @@ describe("a sibling start leaves shared client routing to the live owner", () => ["src/cli/claude.ts", "env: withProcessRuntimeProvenance(claudeProxyStartEnv()),"], ["src/cli/opencode.ts", opencodeStartEnv], ["src/cli/minimax.ts", opencodeStartEnv], - ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env));"], - ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSupervisorMarkers(withoutSiblingMarker(process.env)));"], + ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"], + ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSiblingMarker(process.env));"], ] as const) { expect(readFileSync(repoPath(path), "utf8"), path).toContain(env); } diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 5e68f314ea8..f1d3f1ea2ae 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -675,10 +675,11 @@ describe("supervisor markers never reach a process their supervisor did not laun test.each([ ["src/cli/index.ts", "function detachedStartEnvironment(): NodeJS.ProcessEnv {\n const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env));"], ["src/cli/index.ts", "env: withProcessRuntimeProvenance(withoutSupervisorMarkers(withoutSiblingMarker(process.env))),"], - ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSupervisorMarkers(withoutSiblingMarker(process.env)));"], + // The update bodies run under their tests' dependency injection, so they delete the list inline. + ["src/update/index.ts", `for (const name of ${JSON.stringify(SUPERVISOR_MARKER_ENVS).replaceAll(",", ", ")}) delete env[name];`], ["src/adapters/cursor/native-exec-shell.ts", "backgroundShellRuntime.spawn(args.command, { cwd, shell: true, env: withoutSupervisorMarkers(process.env) });"], ["src/adapters/cursor/native-exec-desktop.ts", "env: withoutSupervisorMarkers(config.env ? { ...process.env, ...config.env } : process.env),"], - ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSupervisorMarkers(withoutSiblingMarker(process.env));"], + ["src/update/job-pinned-start.ts", `for (const name of ${JSON.stringify(SUPERVISOR_MARKER_ENVS).replaceAll(",", ", ")}) delete env[name];`], ["bin/ocx.mjs", `for (const name of ${JSON.stringify(SUPERVISOR_MARKER_ENVS).replaceAll(",", ", ")}) delete env[name];`], ] as const)("%s strips the markers where it launches", (path, line) => { expect(readFileSync(repoPath(path), "utf8")).toContain(line); From ec7d1f5cdd12e54aa012d4be07ccb3e426174f26 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:49:43 +0900 Subject: [PATCH 54/54] test(link): a stale same-PID runtime record never reaches the disconnect restart Covers the meeting of #8562 (a record left by an earlier lifetime of this PID) with restartOwner: the disconnect step decides from the liveness observation, so such a record is never captured and no restart is requested. --- tests/cli/disconnect-runtime.test.ts | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/cli/disconnect-runtime.test.ts b/tests/cli/disconnect-runtime.test.ts index 8771a67a149..fa8b93131fc 100644 --- a/tests/cli/disconnect-runtime.test.ts +++ b/tests/cli/disconnect-runtime.test.ts @@ -5,6 +5,8 @@ import { codexProxyStartEnv } from "../../src/cli/codex-launcher"; import { opencodeProxyStartEnv } from "../../src/cli/opencode"; import { restartOwnerEvidence } from "../../src/client/runtime-restart"; import { clientRuntimeRecord } from "../../src/client/runtime"; +import { runtimeOwnerScope } from "../../src/config/process-state"; +import { observeProxyLiveness } from "../../src/server/proxy-liveness"; import { requestBoundSystemRestart } from "../../src/cli/system-restart-client"; import { runProxyRestart, type ProxyRestartIo } from "../../src/cli/tray-proxy"; import { createLocalAttestationProof, createLocalAttestationSecret, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; @@ -323,3 +325,43 @@ describe("a disconnect restart bound to the captured runtime generation", () => expect(w.posts).toEqual([]); }); }); + +// The merge with dev #8562 (#8552): observeProxyLiveness skips a runtime record that names this +// process's own PID from an earlier lifetime. Such a stale record can still carry a restartOwner. +// The disconnect step decides from the liveness observation first, so a stale record never reaches +// the capture or a restart. Everything is injected: no network, no real port, no real home. +describe("a stale runtime record from an earlier lifetime of this PID never drives the disconnect restart", () => { + for (const published of [false, true]) { + test("this process " + (published ? "has" : "has not") + " published its own records", async () => { + const ownScope = runtimeOwnerScope(); + const stale = { pid: process.pid, port: 54321, hostname: "127.0.0.1", attestationSecret: createLocalAttestationSecret(), restartOwner: "service-manager", ownerScope: ownScope }; + const refused = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + const observe = () => observeProxyLiveness({ + attempts: 1, + fetchFn: (async () => { throw refused; }) as unknown as typeof fetch, + readPidFn: () => null, + readRuntimeFn: () => stale, + configFn: () => ({ port: 54321, hostname: "127.0.0.1" }), + bindProofFn: async () => true, + readCommandLineFn: () => "bun ocx start", + ownProcessFn: () => ({ pid: process.pid, publishedOwnership: published, scope: ownScope }), + sleepFn: async () => {}, + }); + const seen = await observe(); + expect(seen.kind).toBe(published ? "unknown" : "absent"); + let captures = 0, restarts = 0; + const code = await restartRuntimeAfterDisconnect({ + observe, + capture: () => { captures += 1; return stale; }, + connectionState: () => ({ kind: "disconnected" }), + restart: async () => { restarts += 1; return { ok: true }; }, + json: false, info: () => {}, error: () => {}, + }); + expect({ captures, restarts }).toEqual({ captures: 0, restarts: 0 }); + // An earlier lifetime proven stale reads as nothing running (origin/dev's exit 0); one this + // process cannot rule out stays inconclusive and fails closed (exit 1), still without a request. + expect(code).toBe(published ? 1 : 0); + }); + } +}); +