From 0cbf98dc9f9d695b9d950d7d3731c8c24f893925 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 23:30:14 +0000 Subject: [PATCH 01/13] fix(link): restore local routing when child stops --- src/cli/stop-restore.ts | 15 +-- src/server/stop-teardown.ts | 4 +- tests/service/stop-deferred-teardown.test.ts | 111 +++++++++++-------- 3 files changed, 74 insertions(+), 56 deletions(-) diff --git a/src/cli/stop-restore.ts b/src/cli/stop-restore.ts index 3003df0300a..e4c302946b1 100644 --- a/src/cli/stop-restore.ts +++ b/src/cli/stop-restore.ts @@ -15,7 +15,8 @@ import type { StopOutcome } from "./stop-report"; /** Re-read ownership when an ordinary runtime actually exits, including after API drain. */ export function preserveConnectedClientRoutingOnExit(teardown: StartExitTeardown): StartExitTeardown { - return readClientConnectionState().kind === "connected" + const connection = readClientConnectionState(); + return connection.kind === "connected" && connection.value.transport !== "link" ? { ...teardown, restoreNativeCodex: false, stripGrokConfig: false } : teardown; } @@ -102,17 +103,17 @@ export async function preflightConnectedClientStop(approved?: ConnectedStopTarge * The line `ocx stop` prints in place of the shared teardown on a connected client, or null * when this home is not one (#7563). * - * A hub client or Remote Link Child gets its Codex routing and catalog from `ocx connect` / - * `ocx sync`, and its client runtime never writes them on start. The standalone teardown + * A hub client gets its Codex routing and catalog from `ocx connect` / `ocx sync`, and its + * client runtime never writes them on start. The standalone teardown * restored native Codex, rewrote the catalog and stripped the Grok block, and nothing on the * client put any of it back: `ocx start` brought the runtime up with Codex still native and * Grok unconfigured. Leaving them in place makes stop/start a round trip; `ocx disconnect` - * remains the teardown that owns them, with its own ownership checks. + * remains the teardown that owns them, with its own ownership checks. A Remote Link Child + * is deliberately excluded: its unauthenticated loopback route is unsafe after the listener exits. */ export function connectedClientStopSkipLine(state: ClientConnectionState): string | null { - if (state.kind !== "connected") return null; - const role = state.value.transport === "link" ? "Remote Link Child" : "hub client"; - return `↩️ Codex routing, its catalog and the Grok config stay as they are: this ${role}'s connection owns them, and 'ocx start' serves them again. To hand Codex back to its own account, run 'ocx disconnect'.`; + if (state.kind !== "connected" || state.value.transport === "link") return null; + return `↩️ Codex routing, its catalog and the Grok config stay as they are: this hub client's connection owns them, and 'ocx start' serves them again. To hand Codex back to its own account, run 'ocx disconnect'.`; } /** diff --git a/src/server/stop-teardown.ts b/src/server/stop-teardown.ts index d31e2b5dd19..298700d8c21 100644 --- a/src/server/stop-teardown.ts +++ b/src/server/stop-teardown.ts @@ -87,7 +87,9 @@ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Pr // A connection owns its integrations across stop/start, even for a successful API stop. // This is persisted authority, not a caller's request to skip an obligation it owns. const connection = (await import("../client/state")).readClientConnectionState(); - if (connection.kind === "connected") { + // A Link Child's route is an unauthenticated loopback listener. Once this process exits, + // preserving that route would let another local process claim the released port. + if (connection.kind === "connected" && connection.value.transport !== "link") { return { success: true, message: "Proxy stopping; connection-owned Codex routing, catalog and Grok config preserved. Run `ocx disconnect` to release them.", diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index 1a8eb9727f1..0cb52e7d8f6 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -93,10 +93,10 @@ async function runParentStop(options: { describe("parent CLI shared teardown completion", () => { test("consecutive connected stops preserve configuration and the stopped managed client remains update-eligible", async () => { - const first = await runParentStop({ receipt: true, clientTransport: "link", serverTeardown: true, json: true, + const first = await runParentStop({ receipt: true, clientTransport: "hub", serverTeardown: true, json: true, response: null, restore: restoreResult(true) }); const before = readFileSync(join(home, "config.json"), "utf8"); - const second = await runParentStop({ receipt: true, clientTransport: "link", alreadyStopped: true, json: true, + const second = await runParentStop({ receipt: true, clientTransport: "hub", alreadyStopped: true, json: true, response: null, restore: restoreResult(true) }); expect(first.exitCode).toBe(0); expect(second.exitCode, second.stderr).toBe(0); @@ -170,7 +170,7 @@ describe("parent CLI shared teardown completion", () => { }); for (const clientServer of ["legacy", "unknown", "wrong-pid", "malformed", "oversized", "unauthorized", "redirect", "body-timeout"] as const) { test(`connected stop refuses ${clientServer} preservation before service or POST`, async () => { - const outcome = await runParentStop({ receipt: true, clientTransport: "link", clientServer, json: true, + const outcome = await runParentStop({ receipt: true, clientTransport: "hub", clientServer, json: true, response: { success: true, sharedTeardown: "performed" }, restore: restoreResult(true) }); expect(outcome.calls).toMatchObject({ service: 0, killed: 0, native: 0, grok: 0, cleared: 0 }); expect(outcome.nonce).toBeUndefined(); @@ -189,10 +189,10 @@ describe("parent CLI shared teardown completion", () => { expect(outcome.exitCode).toBe(1); }); } - test("actual startup exit cleanup preserves freshly connected routing after a signal or API exit", async () => { + test("actual Link Child exit cleanup restores routing before releasing its loopback port", async () => { const outcome = await runParentStop({ receipt: true, clientTransport: "link", exitCleanup: true, response: null, restore: restoreResult(true) }); - expect(outcome.calls).toMatchObject({ native: 0, grok: 0, service: 0, cleared: 0, killed: 0 }); + expect(outcome.calls).toMatchObject({ native: 1, grok: 1, service: 0, cleared: 0, killed: 0 }); expect(outcome.exitCode).toBe(0); }); test("ordinary startup exit cleanup retains native and Grok restoration", async () => { @@ -200,8 +200,8 @@ describe("parent CLI shared teardown completion", () => { expect(outcome.calls).toMatchObject({ native: 1, grok: 1, service: 0 }); expect(outcome.exitCode).toBe(0); }); - test("PID-identified native client keeps the 404 to signal path", async () => { - const outcome = await runParentStop({ receipt: true, clientTransport: "link", nativeSignal: true, json: true, + test("PID-identified hub client keeps the 404 to signal path", async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "hub", nativeSignal: true, json: true, status: 404, response: { error: "not found" }, restore: restoreResult(true) }); expect(outcome.calls).toMatchObject({ service: 1, killed: 1, native: 0, grok: 0 }); expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); @@ -214,29 +214,34 @@ describe("parent CLI shared teardown completion", () => { expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); expect(outcome.exitCode).toBe(0); }); - // #7563: nothing on a connected client writes its Codex routing, catalog or Grok block back - // on start, so a stop that tore them down left them torn down. - for (const clientTransport of ["link", "hub"] as const) { - test(`a successful ${clientTransport} API stop preserves integrations and reports skipped JSON`, async () => { - const outcome = await runParentStop({ receipt: true, clientTransport, serverTeardown: true, json: true, - response: null, restore: restoreResult(true) }); - expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); - expect(outcome.nonce).toBeUndefined(); - expect(outcome.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); - expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); - expect(outcome.exitCode, outcome.stderr).toBe(0); - }); + // #7563: hub clients route to an authenticated remote origin and preserve that routing on stop. + test("a successful hub API stop preserves integrations and reports skipped JSON", async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "hub", serverTeardown: true, json: true, + response: null, restore: restoreResult(true) }); + expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + expect(outcome.nonce).toBeUndefined(); + expect(outcome.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); + expect(outcome.exitCode, outcome.stderr).toBe(0); + }); - test(`a ${clientTransport} native client stop leaves the connection's integrations and claims no receipt`, async () => { - const outcome = await runParentStop({ receipt: true, clientTransport, - response: { error: "not found" }, status: 404, restore: restoreResult(true) }); - expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); - expect(outcome.nonce).toBeUndefined(); - expect(outcome.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); - expect(outcome.stdout).toContain("this " + (clientTransport === "link" ? "Remote Link Child" : "hub client") + "'s connection owns them"); - expect(outcome.exitCode, outcome.stderr).toBe(0); - }); - } + test("a hub native client stop leaves the connection's integrations and claims no receipt", async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "hub", + response: { error: "not found" }, status: 404, restore: restoreResult(true) }); + expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + expect(outcome.nonce).toBeUndefined(); + expect(outcome.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); + expect(outcome.stdout).toContain("this hub client's connection owns them"); + expect(outcome.exitCode, outcome.stderr).toBe(0); + }); + + test("a successful Link Child stop restores integrations before releasing its loopback port", async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "link", serverTeardown: true, json: true, + response: null, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ native: 1, grok: 1, cleared: 1 }); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "restored" }); + expect(outcome.exitCode, outcome.stderr).toBe(0); + }); test("receipt failure and unconfirmed child teardown cause real parent restoration without a kill", async () => { const outcome = await runParentStop({ receipt: false, @@ -439,26 +444,36 @@ describe("performStopTeardown", () => { expect(await response!.json()).toEqual({ schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: process.pid }); expect(readFileSync(join(home, "config.json"), "utf8")).toBe(before); }); - for (const transport of ["hub", "link"] as const) { - test(`persisted ${transport} ownership precedes receipt deferral and leaves its receipt intact`, async () => { - connectClient(transport); - const { claimPendingTeardown, readPendingTeardown } = await import("../../src/config/pending-teardown"); - const receipt = claimPendingTeardown(ENDPOINT, "exact"); - let restored = 0; - let stripped = 0; - for (const query of ["", `?deferSharedTeardown=1&teardownNonce=${receipt.nonce}`]) { - const body = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop${query}`), { - restoreNativeCodex: async () => { restored++; return restoreResult(true); }, - stripGrok: () => { stripped++; return { ok: true, changed: true, message: "stripped" }; }, - }); - expect(body).toMatchObject({ success: true, sharedTeardown: "not-owned" }); - expect(body.message).toContain("ocx disconnect"); - } - expect(restored).toBe(0); - expect(stripped).toBe(0); - expect(readPendingTeardown(receipt.nonce).state).toBe("valid"); + test("persisted hub ownership precedes receipt deferral and leaves its receipt intact", async () => { + connectClient("hub"); + const { claimPendingTeardown, readPendingTeardown } = await import("../../src/config/pending-teardown"); + const receipt = claimPendingTeardown(ENDPOINT, "exact"); + let restored = 0; + let stripped = 0; + for (const query of ["", `?deferSharedTeardown=1&teardownNonce=${receipt.nonce}`]) { + const body = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop${query}`), { + restoreNativeCodex: async () => { restored++; return restoreResult(true); }, + stripGrok: () => { stripped++; return { ok: true, changed: true, message: "stripped" }; }, + }); + expect(body).toMatchObject({ success: true, sharedTeardown: "not-owned" }); + expect(body.message).toContain("ocx disconnect"); + } + expect(restored).toBe(0); + expect(stripped).toBe(0); + expect(readPendingTeardown(receipt.nonce).state).toBe("valid"); + }); + + test("persisted Link ownership does not preserve an unauthenticated loopback route", async () => { + connectClient("link"); + let restored = 0; + let stripped = 0; + const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop"), { + restoreNativeCodex: async () => { restored++; return restoreResult(true); }, + stripGrok: () => { stripped++; return { ok: true, changed: true, message: "stripped" }; }, }); - } + expect(body).toMatchObject({ success: true, sharedTeardown: "performed" }); + expect([restored, stripped]).toEqual([1, 1]); + }); test("a hub server without a client connection still restores its own integrations", async () => { writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "hub" })); From 466da71867820c4f08fc49a9a52fe895e2e6c97b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 12:37:10 +0000 Subject: [PATCH 02/13] fix(client): restore owned Link routing before listener release --- .../src/content/docs/fr/guides/remote-link.md | 2 + .../src/content/docs/guides/remote-link.md | 4 +- .../src/content/docs/ja/guides/remote-link.md | 2 + .../docs/ja/reference/cli/lifecycle.md | 2 +- .../src/content/docs/ko/guides/remote-link.md | 2 + .../docs/ko/reference/cli/lifecycle.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 14 +- .../content/docs/reference/management-api.md | 2 +- .../src/content/docs/ru/guides/remote-link.md | 2 + .../docs/ru/reference/cli/lifecycle.md | 2 +- .../src/content/docs/tr/guides/remote-link.md | 2 + .../content/docs/zh-cn/guides/remote-link.md | 2 + .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../content/docs/zh-tw/guides/remote-link.md | 2 + scripts/test-layout/layout-clients.json | 1 + skills/ocx/references/01_surface_lifecycle.md | 6 +- src/cli/capabilities-observe-system.ts | 4 +- src/cli/index.ts | 19 +-- src/cli/registry.ts | 2 +- src/cli/stop-restore.ts | 28 +++- src/client/runtime.ts | 5 +- src/client/stop-routing.ts | 58 ++++++++ src/server/lifecycle.ts | 5 + src/server/stop-teardown.ts | 9 ++ structure/gui-and-management-api.md | 4 +- structure/remote-link.md | 2 + structure/runtime.md | 8 +- tests/clients/client-link-stop.test.ts | 125 ++++++++++++++++++ tests/fixtures/client-link-stop-runner.ts | 100 ++++++++++++++ tests/fixtures/parent-stop-runner.ts | 17 ++- tests/fixtures/test-layout-expected.json | 2 +- tests/providers/xai/grok-lifecycle.test.ts | 8 +- tests/service/shutdown-drain.test.ts | 38 ++++++ tests/service/stop-deferred-teardown.test.ts | 45 +++++-- 34 files changed, 471 insertions(+), 57 deletions(-) create mode 100644 src/client/stop-routing.ts create mode 100644 tests/clients/client-link-stop.test.ts create mode 100644 tests/fixtures/client-link-stop-runner.ts diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 82d715db467..24337e50eeb 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -47,6 +47,8 @@ Sur l’ordinateur qui doit utiliser les fournisseurs de Home : La connexion redémarre OpenCodex sur cet ordinateur. Les tours Codex déjà en cours se terminent d’abord, et les nouvelles requêtes peuvent échouer pendant une minute au plus pendant le redémarrage. Le tableau de bord se recharge ensuite de lui-même et affiche la liaison Child. Codex continue d’utiliser `http://127.0.0.1:/v1` sur cet ordinateur, sans jeton ni variable d’environnement à définir : l’OpenCodex local relaie chaque requête vers Home, qui y répond avec ses propres fournisseurs et comptes. +Un arrêt normal du Child avec `ocx stop` restaure le routage Codex depuis le journal appartenant à cette connexion avant de fermer le listener. La connexion, la clé, le catalogue et les autres intégrations restent en place. Pour reprendre le routage vers Home, lancez `ocx start` ou `ocx service start`, puis `ocx sync --restart-codex` : le démarrage seul ne réapplique pas le routage. Le transfert normal de `ocx restart` conserve le routage. Un conflit de propriété ou de journal est signalé comme un échec et conserve les éléments de récupération ; un plantage ou un arrêt forcé ne peut pas exécuter cette restauration ordonnée. + Si **Child** demande de jumeler d’abord cet ordinateur, ouvrez son tableau de bord HTTP à l’adresse IP de bouclage configurée, par exemple `http://127.0.0.1:`. Le formulaire de jumelage local apparaît uniquement lorsque le jumelage manque et que le tableau de bord et l’API utilisent la même origine de bouclage. Copiez la commande `ocx gui pair --origin "http://127.0.0.1:"` du formulaire, exécutez-la dans un terminal sur cet ordinateur, puis collez le code à usage unique dans le formulaire. Utilisez exactement l’origine affichée ; une clé API de fournisseur ou un jeton d’administration n’est pas un code de jumelage. L’absence de jumelage et un port différent du port configuré sont deux causes distinctes. Le rôle **Child** exige également qu’OpenCodex fonctionne en mode autonome sur son port configuré, car Child redémarre exactement sur ce port. Si le tableau de bord indique qu’OpenCodex ne tourne pas sur son port configuré, redémarrez-le d’abord sur ce port. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 7120ca099f0..0c4b0e69b05 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -49,7 +49,9 @@ Connecting restarts OpenCodex on this computer. Codex turns that are already run The Child waits for its configured port while the old process releases it. If a CLI-managed restart still fails, run `ocx start` on the Child and check `~/.opencodex/restart-handoff.log`. In the desktop app, the app starts and supervises the replacement automatically. -`ocx stop` on a Child stops its proxy and leaves Codex routing, the catalog and the Grok config in place, so `ocx start` (or `ocx service start`) brings the Child back unchanged. If `ocx status` on the Child reports that Codex routing is native, run `ocx sync --restart-codex`; `ocx start` does not rewrite a Child's Codex routing. +An orderly Child stop restores the Codex routing recorded in that connection's journal before releasing the local listener. The connection, key, catalog and other integrations remain in place. To resume Home routing after `ocx stop`, run `ocx start` (or `ocx service start`), then `ocx sync --restart-codex`. Starting the Child alone does not reapply its Codex routing. The bound `ocx restart` handoff preserves routing and does not need this extra sync. + +If stop reports an ownership or journal conflict, it preserves the conflicting files and recovery evidence instead of overwriting them. Resolve that reported conflict before reapplying the connection. An abrupt crash or forced termination cannot run orderly restoration. If **Child** says to pair this machine first, open this computer's configured literal-loopback HTTP dashboard, for example `http://127.0.0.1:`. The local pairing form appears only when pairing is missing and the dashboard and API use the same loopback origin. Copy the form's `ocx gui pair --origin "http://127.0.0.1:"` command, run it in a terminal on this computer, and paste the one-use code into the form. Use the exact origin shown in the form; a provider API key or admin token is not a pairing code. Missing pairing is separate from a configured-port mismatch. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 905cfb3db49..11d8356c732 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -47,6 +47,8 @@ Home のプロバイダーを使うコンピューターで次の操作を行い 接続すると、このコンピューターの OpenCodex が再起動します。すでに実行中の Codex ターンは先に完了し、再起動中の最大 1 分間は新しいリクエストが失敗することがあります。その後ダッシュボードは自動的に再読み込みされ、Child のリンクを表示します。Codex はこのコンピューターの `http://127.0.0.1:/v1` を使い続け、トークンや環境変数の設定は不要です。ローカルの OpenCodex が各リクエストを Home に中継し、Home が自身のプロバイダーとアカウントで応答します。 +Child を `ocx stop` で正常に停止すると、リスナーを閉じる前に、その接続が所有するジャーナルから Codex のルーティングを復元します。接続情報、キー、カタログ、その他の連携設定は保持されます。Home のルーティングを再開するには `ocx start` または `ocx service start` の後に `ocx sync --restart-codex` を実行してください。起動だけではルーティングは再適用されません。`ocx restart` の通常の引き継ぎ経路ではルーティングを保持します。所有権やジャーナルの競合は失敗として報告し、復旧の証拠を保持します。クラッシュや強制終了では正常停止時の復元を実行できません。 + **Child** にこのコンピューターを先にペアリングするよう表示された場合は、このコンピューターに設定された HTTP ループバック IP アドレスのダッシュボード(例: `http://127.0.0.1:`)を開いてください。ローカルのペアリングフォームは、ペアリングが必要で、ダッシュボードと API が同じループバックオリジンを使う場合にのみ表示されます。フォームの `ocx gui pair --origin "http://127.0.0.1:"` コマンドをコピーしてこのコンピューターのターミナルで実行し、使い捨てコードをフォームに貼り付けてください。フォームに表示された正確なオリジンを使ってください。プロバイダーの API キーや管理者トークンはペアリングコードではありません。ペアリングの不足と設定されたポートの不一致は別の原因です。 **Child** の役割には、OpenCodex がスタンドアロンモードで設定されたポートで動作していることも必要です。Child はまさにそのポートで再起動するためです。設定されたポートで動作していないとダッシュボードに表示された場合は、先にそのポートで OpenCodex を再起動してください。 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 7e2e5c6d7ba..25b224049a4 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -30,7 +30,7 @@ ocx start --socks5-off 実行中のプロキシを (PID によって) 停止し、PID ファイルを削除します。スタンドアロンの所有者ではネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。 -接続済みの hub クライアントまたは Remote Link Child では、`ocx stop`(成功した API 停止を含む)は接続が所有する Codex のルーティング、モデルカタログ、Grok 設定を保持します。`ocx start` はルーティングを再注入せず、その設定で再び処理します。Codex を自身のアカウントへ戻すには `ocx disconnect`、既にネイティブルーティングへ戻した後の再適用には `ocx sync --restart-codex` を使います。 +接続済み hub クライアントでは `ocx stop` は接続所有の Codex ルーティング、カタログ、Grok 設定を保持します。接続を解除するには `ocx disconnect` を実行してください。 Child を `ocx stop` で正常に停止すると、リスナーを閉じる前に、その接続が所有するジャーナルから Codex のルーティングを復元します。接続情報、キー、カタログ、その他の連携設定は保持されます。Home のルーティングを再開するには `ocx start` または `ocx service start` の後に `ocx sync --restart-codex` を実行してください。起動だけではルーティングは再適用されません。`ocx restart` の通常の引き継ぎ経路ではルーティングを保持します。所有権やジャーナルの競合は失敗として報告し、復旧の証拠を保持します。クラッシュや強制終了では正常停止時の復元を実行できません。 互換性を確認できない古い通常プロキシは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。 diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index 5ad0f0ea302..e0391896ade 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -47,6 +47,8 @@ Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다. 연결하면 이 컴퓨터의 OpenCodex가 다시 시작됩니다. 이미 실행 중인 Codex 작업은 먼저 끝나고, 다시 시작되는 동안 최대 1분 정도 새 요청이 실패할 수 있습니다. 그 뒤 대시보드가 스스로 새로 고쳐지고 Child 링크를 보여 줍니다. Codex는 이 컴퓨터의 `http://127.0.0.1:/v1`을 그대로 사용하며 토큰이나 환경 변수를 설정할 필요가 없습니다. 로컬 OpenCodex가 각 요청을 Home으로 전달하고, Home은 자신의 프로바이더와 계정으로 응답합니다. +Child에서 `ocx stop`으로 정상 종료하면 리스너를 닫기 전에 해당 연결이 소유한 저널을 사용해 Codex 라우팅을 복원합니다. 연결 정보, 키, 카탈로그와 다른 연동 설정은 유지됩니다. Home 라우팅을 다시 사용하려면 `ocx start` 또는 `ocx service start`를 실행한 뒤 `ocx sync --restart-codex`를 실행하세요. 시작만으로는 라우팅이 다시 적용되지 않습니다. `ocx restart`의 정상 인계 경로는 라우팅을 유지합니다. 소유권이나 저널 충돌은 실패로 보고하고 복구 근거를 보존하며, 강제 종료나 충돌 시에는 정상 종료 복원이 실행되지 않습니다. + **Child**에 이 컴퓨터를 먼저 페어링하라는 안내가 나오면, 이 컴퓨터에 설정된 IP 주소 형식의 HTTP 루프백 대시보드(예: `http://127.0.0.1:`)를 여세요. 로컬 페어링 양식은 페어링이 필요하고 대시보드와 API가 같은 루프백 출처를 사용할 때만 표시됩니다. 양식의 `ocx gui pair --origin "http://127.0.0.1:"` 명령을 복사해 이 컴퓨터의 터미널에서 실행한 뒤, 일회용 코드를 양식에 붙여 넣으세요. 양식에 표시된 정확한 출처를 사용해야 하며, 프로바이더 API 키나 관리자 토큰은 페어링 코드가 아닙니다. 페어링 누락과 설정된 포트의 불일치는 서로 다른 사유입니다. **Child** 역할을 선택하려면 OpenCodex가 독립형 런타임으로 설정된 포트에서 실행 중이어야 합니다. Child는 정확히 그 포트에서 다시 시작하기 때문입니다. 대시보드가 설정된 포트에서 실행되고 있지 않다고 알리면, 먼저 그 포트에서 OpenCodex를 다시 시작하세요. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index d6a05fe430e..ed7a7cc067e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -54,7 +54,7 @@ ocx start --socks5-off 넘어가지 않습니다. 영수증에 근거한 지연 복원도 최종 복원과 영수증 정리를 부모가 담당하며, 부모의 공유 설정 복원이 실패하면 종료 실패로 남기고 미완료 영수증을 보존합니다. -연결된 hub 클라이언트나 Remote Link Child에서는 성공한 API 종료를 포함해 `ocx stop`이 연결 소유의 Codex 라우팅, 모델 카탈로그와 Grok 설정을 보존합니다. `ocx start`는 라우팅을 다시 주입하지 않고 보존한 설정으로 서비스를 재개합니다. Codex를 자체 계정으로 돌리려면 `ocx disconnect`를, 이미 기본 라우팅으로 복원했다면 다시 연결하려고 `ocx sync --restart-codex`를 실행하세요. +연결된 hub 클라이언트에서는 `ocx stop`이 연결 소유의 Codex 라우팅, 카탈로그와 Grok 설정을 보존합니다. 연결을 해제하려면 `ocx disconnect`를 실행하세요. Child에서 `ocx stop`으로 정상 종료하면 리스너를 닫기 전에 해당 연결이 소유한 저널을 사용해 Codex 라우팅을 복원합니다. 연결 정보, 키, 카탈로그와 다른 연동 설정은 유지됩니다. Home 라우팅을 다시 사용하려면 `ocx start` 또는 `ocx service start`를 실행한 뒤 `ocx sync --restart-codex`를 실행하세요. 시작만으로는 라우팅이 다시 적용되지 않습니다. `ocx restart`의 정상 인계 경로는 라우팅을 유지합니다. 소유권이나 저널 충돌은 실패로 보고하고 복구 근거를 보존하며, 강제 종료나 충돌 시에는 정상 종료 복원이 실행되지 않습니다. 보존 기능을 확인할 수 없는 오래된 일반 프록시는 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 4cf53c31851..4b919189e91 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -84,12 +84,16 @@ ocx start --socks5-off Stop the running proxy (by PID), remove the PID file, and restore native Codex for standalone owners. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. -On a connected client (a Remote Link Child or a hub client), `ocx stop` leaves Codex routing, its -model catalog and the Grok config as they are, including after a successful API stop: the connection -owns them, and `ocx start` serves them again without re-injecting routing. Use `ocx disconnect` to hand Codex back to its own account. If Codex on a connected -client already reads native routing, `ocx status` names `ocx sync --restart-codex` to point it back. +On a connected hub client, `ocx stop` preserves connection-owned Codex routing, its model catalog +and the Grok config, including after a successful API stop. On a Remote Link Child, orderly stop +restores only that connection's Codex journal before the listener closes; its connection, key, +catalog and other integrations remain in place. After a Child stop, run `ocx start` (or +`ocx service start`), then `ocx sync --restart-codex` to resume Home routing. Client start does not +re-inject routing. The bound `ocx restart` handoff preserves it. Use `ocx disconnect` to release the +connection. Conflicting ownership or edited/unverified journal files produce a restoration failure +and retain recovery evidence; abrupt crashes and forced termination cannot run orderly cleanup. A connected stop verifies the local runtime before changing its service: a client-role listener -with the matching PID is supported, while an ordinary proxy must explicitly advertise preservation +with the matching PID is supported, while an ordinary proxy must explicitly advertise connected-client ownership handling across API, signal and service shutdown. Older or unverified ordinary runtimes are refused before stop actions. Update/restart that runtime through its supervisor and retry; use `ocx disconnect` only when you intend to release the connection-owned integrations. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 6c810c21dac..d01fc205d79 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -771,7 +771,7 @@ the 503 without reporting model capacity; provider overload errors retain their | --- | --- | --- | | `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications; cumulative `spillCapacityRefusals` and `spillHeadroomEvictions` count continuations refused at the spill ceiling and continuations evicted to make room for a newer one. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — | | `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection; optional JSON `drainGraceMs` explicitly shortens the default 60s grace | Returns 202; repeated calls report the existing grace; 400 invalid body or grace | -| `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 `self_unload_service` when this proxy is running as the installed launchd/systemd service, because stopping the manager from inside it would end the process before native Codex is restored — run `ocx stop` instead (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | +| `POST /api/stop` | Stop the service and drain the proxy. Ordinary owners restore native Codex/Grok; hub clients and siblings preserve their integrations; Link Children restore only their connection-owned Codex journal before listener release, retaining the connection and catalog. A stopped Child needs `ocx sync --restart-codex` after start | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 `self_unload_service` when this proxy is running as the installed launchd/systemd service, because stopping the manager from inside it would end the process before native Codex is restored — run `ocx stop` instead (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | | `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — | | `POST /api/system/codex-restart` | Refresh the catalog, then restart Codex app-servers. A running Codex desktop app is given a restart attempt so the model picker can reload; the `desktopApp` response fields report a refusal, surviving targets, or a failed relaunch. A shell newer than the catalog is still reported as `desktopApp.pickerState: "unknown"`, because its start time cannot prove which `model_catalog_json` binding it loaded; an earlier sync may have rebound an already-existing catalog. A shell no newer than the catalog, or one whose binding this request changed, is `stale`. A closed desktop returns `desktopApp.reason: "no_targets"`; its `pickerState` may be `not_running`, or `stale` when this request changed the catalog binding. When the proxy itself is running inside the Codex app, the desktop restart is refused rather than handed off. | Returns 200 with `code: partially_stopped` when a target survives | diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index 229918c6f7c..69bd0a3f005 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -47,6 +47,8 @@ Host devbox При подключении OpenCodex на этом компьютере перезапустится. Уже идущие запросы Codex сначала завершатся, а новые запросы могут не проходить до минуты, пока идёт перезапуск. Затем панель перезагрузится сама и покажет связь Child. Codex продолжит использовать `http://127.0.0.1:/v1` на этом компьютере без токена и без переменных окружения: локальный OpenCodex передаёт каждый запрос на Home, а Home обслуживает его своими провайдерами и учётными записями. +При штатной остановке Child командой `ocx stop` маршрутизация Codex восстанавливается из журнала, принадлежащего этому соединению, до закрытия слушателя. Соединение, ключ, каталог и остальные интеграции сохраняются. Чтобы снова направлять запросы в Home, выполните `ocx start` или `ocx service start`, затем `ocx sync --restart-codex`: один запуск не применяет маршрутизацию повторно. Штатная передача управления через `ocx restart` сохраняет маршрутизацию. Конфликт владельца или журнала сообщает об ошибке и сохраняет данные для восстановления; аварийное завершение или принудительная остановка не выполняют штатное восстановление. + Если **Child** просит сначала выполнить сопряжение этого компьютера, откройте на нём HTTP-панель по настроенному IP-адресу обратной петли, например `http://127.0.0.1:`. Локальная форма сопряжения появляется только при отсутствии сопряжения, когда панель и API используют один и тот же origin обратной петли. Скопируйте из формы команду `ocx gui pair --origin "http://127.0.0.1:"`, выполните её в терминале на этом компьютере и вставьте одноразовый код в форму. Используйте точно тот origin, который показан в форме; API-ключ провайдера или токен администратора не является кодом сопряжения. Отсутствие сопряжения и несовпадение настроенного порта — разные причины. Роль **Child** также требует, чтобы OpenCodex работал в автономном режиме на настроенном порту, потому что Child перезапускается именно на нём. Если панель сообщает, что OpenCodex работает не на настроенном порту, сначала перезапустите его на этом порту. diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index b19777c2e89..f632502b0a9 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -54,7 +54,7 @@ ocx start --socks5-off установлена managed background service, `ocx stop` сначала останавливает и её, чтобы она не перезапустила прокси обратно. Кнопка **Stop** в веб-дашборде выполняет то же действие (`POST /api/stop`) на всех бэкендах, кроме планировщика заданий Windows: там обёртка может перезапустить прокси после завершения задачи, поэтому дашборд отказывает с `respawnable_service`, ничего не меняет и просит выполнить `ocx stop`. -На подключённом клиенте hub или Remote Link Child команда `ocx stop`, включая успешную остановку через API, сохраняет принадлежащие соединению маршрутизацию Codex, каталог моделей и конфигурацию Grok. `ocx start` снова обслуживает их, не внедряя маршрутизацию заново. Чтобы вернуть Codex к собственному аккаунту, выполните `ocx disconnect`; для повторного применения уже восстановленной нативной маршрутизации используйте `ocx sync --restart-codex`. +На подключённом клиенте hub команда `ocx stop` сохраняет маршрутизацию Codex, каталог и настройки Grok, принадлежащие соединению. Для отключения выполните `ocx disconnect`. При штатной остановке Child командой `ocx stop` маршрутизация Codex восстанавливается из журнала, принадлежащего этому соединению, до закрытия слушателя. Соединение, ключ, каталог и остальные интеграции сохраняются. Чтобы снова направлять запросы в Home, выполните `ocx start` или `ocx service start`, затем `ocx sync --restart-codex`: один запуск не применяет маршрутизацию повторно. Штатная передача управления через `ocx restart` сохраняет маршрутизацию. Конфликт владельца или журнала сообщает об ошибке и сохраняет данные для восстановления; аварийное завершение или принудительная остановка не выполняют штатное восстановление. Старая обычная версия прокси без подтверждённой поддержки сохранения отклоняется до остановки службы или процесса. Обновите и перезапустите её через управляющий супервизор, затем повторите команду. diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index ef4af2ee5ec..42a423331b1 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -47,6 +47,8 @@ Home'un sağlayıcılarını kullanacak bilgisayarda: Bağlanmak bu bilgisayardaki OpenCodex'i yeniden başlatır. Zaten çalışan Codex istekleri önce tamamlanır ve yeniden başlatma sırasında yeni istekler bir dakikaya kadar başarısız olabilir. Ardından kontrol paneli kendiliğinden yeniden yüklenir ve Child bağlantısını gösterir. Codex bu bilgisayarda `http://127.0.0.1:/v1` adresini kullanmaya devam eder ve belirteç veya ortam değişkeni ayarlamanız gerekmez: yerel OpenCodex her isteği Home'a aktarır, Home da kendi sağlayıcıları ve hesaplarıyla yanıt verir. +Child üzerinde `ocx stop` ile normal durdurma, dinleyiciyi kapatmadan önce yalnızca bu bağlantının sahip olduğu günlükten Codex yönlendirmesini geri yükler. Bağlantı, anahtar, katalog ve diğer entegrasyonlar korunur. Home yönlendirmesini sürdürmek için `ocx start` veya `ocx service start` çalıştırın, ardından `ocx sync --restart-codex` çalıştırın; yalnızca başlatmak yönlendirmeyi yeniden uygulamaz. `ocx restart` komutunun normal devir yolu yönlendirmeyi korur. Sahiplik veya günlük çakışmaları hata olarak bildirilir ve kurtarma kanıtları korunur; çökme veya zorla sonlandırma bu normal durdurma geri yüklemesini çalıştıramaz. + **Child** önce bu bilgisayarı eşleştirmenizi istiyorsa bu bilgisayarın yapılandırılmış HTTP geri döngü IP adresindeki kontrol panelini açın; örneğin `http://127.0.0.1:`. Yerel eşleştirme formu yalnızca eşleştirme eksik olduğunda ve kontrol paneli ile API aynı geri döngü kaynağını kullandığında görünür. Formdaki `ocx gui pair --origin "http://127.0.0.1:"` komutunu kopyalayıp bu bilgisayarın terminalinde çalıştırın, ardından tek kullanımlık kodu forma yapıştırın. Formda gösterilen kaynak adresini aynen kullanın; sağlayıcı API anahtarı veya yönetici belirteci eşleştirme kodu değildir. Eksik eşleştirme ile yapılandırılmış bağlantı noktası uyuşmazlığı farklı nedenlerdir. **Child** rolü ayrıca OpenCodex'in bağımsız çalışma modunda, yapılandırılmış bağlantı noktasında çalışmasını gerektirir, çünkü Child tam olarak o bağlantı noktasında yeniden başlar. Kontrol paneli OpenCodex'in yapılandırılmış bağlantı noktasında çalışmadığını söylerse önce onu o bağlantı noktasında yeniden başlatın. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 37c89eef023..0349238ed5b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -47,6 +47,8 @@ Host devbox 连接会重启这台电脑上的 OpenCodex。已在运行的 Codex 请求会先完成,重启期间新的请求可能在最多一分钟内失败。随后控制台会自动重新加载并显示子机链接。Codex 继续使用这台电脑上的 `http://127.0.0.1:/v1`,无需设置令牌或环境变量:本地 OpenCodex 会把每个请求转发给主机,由主机用它自己的提供商和账户提供服务。 +在 Child 上通过 `ocx stop` 正常停止时,会在关闭监听器之前,仅从该连接拥有的日志恢复 Codex 路由。连接信息、密钥、目录和其他集成配置保持不变。要恢复 Home 路由,请先运行 `ocx start` 或 `ocx service start`,再运行 `ocx sync --restart-codex`;仅启动不会重新应用路由。`ocx restart` 的正常交接路径会保留路由。所有权或日志冲突会报告失败并保留恢复依据;崩溃或强制终止无法执行正常停止时的恢复。 + 如果 **Child** 提示先配对这台电脑,请在这台电脑上打开配置的 HTTP 回环 IP 地址控制台,例如 `http://127.0.0.1:`。只有在缺少配对且控制台与 API 使用同一个回环源时,才会显示本地配对表单。复制表单中的 `ocx gui pair --origin "http://127.0.0.1:"` 命令,在这台电脑的终端中运行,再将一次性代码粘贴到表单中。必须使用表单显示的确切源;提供商 API 密钥或管理员令牌不是配对代码。缺少配对与配置端口不匹配是不同的原因。 选择 **Child** 角色还要求 OpenCodex 以独立运行模式在其配置的端口上运行,因为子机会在正好这个端口上重启。如果控制台提示 OpenCodex 未在其配置的端口上运行,请先在该端口上重启它。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 4ce49ff65f0..2ea670b53b3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -30,7 +30,7 @@ ocx start --socks5-off 停止正在运行的代理(按 PID),移除 PID 文件;独立运行的所有者还会恢复原生 Codex。如果安装了受管后台服务,`ocx stop` 还会先停止该服务,这样它就无法重新拉起代理。Web 仪表盘的 **Stop** 按钮在多数后端执行同样的操作(`POST /api/stop`),但 Windows 任务计划程序除外:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口,因此仪表盘会以 `respawnable_service` 拒绝、不做任何更改,并提示改用 `ocx stop`。 -对于已连接的 hub 客户端或 Remote Link Child,`ocx stop`(包括成功的 API 停止)会保留连接所拥有的 Codex 路由、模型目录和 Grok 配置。`ocx start` 使用这些保留的配置恢复服务,不会重新注入路由。要让 Codex 回到自己的账户,请运行 `ocx disconnect`;若已恢复为原生路由,要重新应用连接路由,请运行 `ocx sync --restart-codex`。 +已连接的 hub 客户端在 `ocx stop` 时保留连接拥有的 Codex 路由、目录和 Grok 配置。要解除连接,请运行 `ocx disconnect`。 在 Child 上通过 `ocx stop` 正常停止时,会在关闭监听器之前,仅从该连接拥有的日志恢复 Codex 路由。连接信息、密钥、目录和其他集成配置保持不变。要恢复 Home 路由,请先运行 `ocx start` 或 `ocx service start`,再运行 `ocx sync --restart-codex`;仅启动不会重新应用路由。`ocx restart` 的正常交接路径会保留路由。所有权或日志冲突会报告失败并保留恢复依据;崩溃或强制终止无法执行正常停止时的恢复。 若旧版普通代理无法确认支持保留配置,命令会在停止服务或进程前拒绝操作。请通过管理该运行时的应用或服务更新并重启它,然后重试。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index d91b65acb3a..8f4ff81f1c3 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -47,6 +47,8 @@ Host devbox 連線會重新啟動這台電腦上的 OpenCodex。已在執行的 Codex 請求會先完成,重新啟動期間新的請求可能在最多一分鐘內失敗。之後儀表板會自動重新載入並顯示 Child 連結。Codex 會繼續使用這台電腦上的 `http://127.0.0.1:/v1`,不需要設定權杖或環境變數:本機 OpenCodex 會把每個請求轉送給 Home,由 Home 以它自己的供應商與帳戶提供服務。 +在 Child 上透過 `ocx stop` 正常停止時,會在關閉接聽器之前,僅從該連線擁有的日誌還原 Codex 路由。連線資訊、金鑰、目錄與其他整合設定會保留。若要恢復 Home 路由,請先執行 `ocx start` 或 `ocx service start`,再執行 `ocx sync --restart-codex`;單獨啟動不會重新套用路由。`ocx restart` 的正常交接流程會保留路由。所有權或日誌衝突會回報失敗並保留復原依據;當機或強制終止無法執行正常停止時的還原。 + 如果 **Child** 提示先配對這台電腦,請在這台電腦上開啟設定的 HTTP 回環 IP 位址儀表板,例如 `http://127.0.0.1:`。只有在缺少配對且儀表板與 API 使用相同的回環來源時,才會顯示本機配對表單。複製表單中的 `ocx gui pair --origin "http://127.0.0.1:"` 命令,在這台電腦的終端機中執行,再將一次性代碼貼到表單中。必須使用表單顯示的確切來源;供應商 API 金鑰或管理員權杖不是配對代碼。缺少配對與設定的連接埠不符是不同的原因。 選擇 **Child** 角色還要求 OpenCodex 以獨立執行模式在其設定的連接埠上執行,因為 Child 會在正好這個連接埠上重新啟動。如果儀表板提示 OpenCodex 未在其設定的連接埠上執行,請先在該連接埠上重新啟動它。 diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 6c384913a52..3046b5762cd 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -124,6 +124,7 @@ "client-link-connect.test.ts": "clients", "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", + "client-link-stop.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", "client-link-status.test.ts": "clients", diff --git a/skills/ocx/references/01_surface_lifecycle.md b/skills/ocx/references/01_surface_lifecycle.md index 8e93c6a3fe2..0d2db3d242f 100644 --- a/skills/ocx/references/01_surface_lifecycle.md +++ b/skills/ocx/references/01_surface_lifecycle.md @@ -104,7 +104,7 @@ State-changing: yes. JSON mode: `none`. Usage: `ocx stop [--json]` -Stop the proxy; preserve connected-client integrations, restore native Codex config for standalone owners. +Stop the proxy; restore owned Link Child routing, preserve hub client integrations, and restore standalone Codex config. State-changing: yes. JSON mode: `envelope`. @@ -117,8 +117,8 @@ State-changing: yes. JSON mode: `envelope`. |---|---|---| | `--json` | boolean | Emit one structured stop summary; human output moves to stderr. | -- Connected clients verify runtime preservation support before stopping; unavailable or unsupported live targets refuse without stopping the service. Proven-stopped clients remain successful when the manager is inactive. -- Siblings and connected clients preserve shared integrations. Standalone owners restore native Codex configuration and remove the managed Grok block. +- Connected clients verify runtime ownership handling before stopping; unavailable or unsupported live targets refuse without stopping the service. Proven-stopped clients remain successful when the manager is inactive. +- Siblings and hub clients preserve shared integrations. A Link Child restores only its connection-owned Codex journal before listener release; its connection, catalog and other integrations remain. After starting the Child again, run ocx sync --restart-codex. Standalone owners restore native Codex configuration and remove the managed Grok block. - JSON uses ocx-stop/1 and retains the same exit codes: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred). ### `ocx service install` diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 9f413835d54..adc0ff1182f 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -5,12 +5,12 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ { command: ["stop"], usage: "ocx stop [--json]", - summary: "Stop the proxy; preserve connected-client integrations, restore native Codex config for standalone owners.", + summary: "Stop the proxy; restore owned Link Child routing, preserve hub client integrations, and restore standalone Codex config.", routes: [{ method: "GET", path: "/api/stop" }, { method: "POST", path: "/api/stop" }], flags: [{ name: "--json", value: "boolean", summary: "Emit one structured stop summary; human output moves to stderr." }], mutates: true, json: "envelope", - details: ["Connected clients verify runtime preservation support before stopping; unavailable or unsupported live targets refuse without stopping the service. Proven-stopped clients remain successful when the manager is inactive.", "Siblings and connected clients preserve shared integrations. Standalone owners restore native Codex configuration and remove the managed Grok block.", "JSON uses ocx-stop/1 and retains the same exit codes: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred)."], + details: ["Connected clients verify runtime ownership handling before stopping; unavailable or unsupported live targets refuse without stopping the service. Proven-stopped clients remain successful when the manager is inactive.", "Siblings and hub clients preserve shared integrations. A Link Child restores only its connection-owned Codex journal before listener release; its connection, catalog and other integrations remain. After starting the Child again, run ocx sync --restart-codex. Standalone owners restore native Codex configuration and remove the managed Grok block.", "JSON uses ocx-stop/1 and retains the same exit codes: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred)."], }, {"command": ["logs", "filter"], "summary": "Select a bounded log snapshot with dashboard filters.", "usage": "ocx logs filter [--surface ] [--model ] [--provider ] [--status ] [--time-window ] [--min-tok-per-sec ] [--max-tok-per-sec ] [--intercepted-only] [--protocol-mode ] [--conversation ] [--scan-limit <1-2000>] [--limit <1-2000>] [--json|--jsonl]", "routes": [{"method": "GET", "path": "/api/logs"}], "flags": [{"name": "--surface", "value": "string", "summary": "all | codex | claude | grok."}, {"name": "--model", "value": "string", "summary": "Case-insensitive exact identity across requested, resolved, served and attempted models."}, {"name": "--provider", "value": "string", "summary": "Case-insensitive provider or failover attempt identity."}, {"name": "--status", "value": "string", "summary": "all | success (200-299) | errors (400-599)."}, {"name": "--time-window", "value": "string", "summary": "all | 15m | 1h | 24h, relative to observation time."}, {"name": "--min-tok-per-sec", "value": "number", "summary": "Inclusive minimum observed token speed."}, {"name": "--max-tok-per-sec", "value": "number", "summary": "Exclusive maximum observed token speed."}, {"name": "--intercepted-only", "value": "boolean", "summary": "Rows carrying an observed rewrite marker."}, {"name": "--protocol-mode", "value": "string", "summary": "all | native | translated | legacy-bridge | blocked | none."}, {"name": "--conversation", "value": "string", "summary": "Conversation ID or stored hash."}, {"name": "--conversationId", "value": "string", "summary": "Alias of --conversation."}, {"name": "--scan-limit", "value": "number", "summary": "1-2000 raw rows inspected; default 2000."}, {"name": "--limit", "value": "number", "summary": "1-2000 matching rows returned; default 200."}, {"name": "--json", "value": "boolean", "summary": "Versioned view with filters, cursor and observed-window counts."}, {"name": "--jsonl", "value": "boolean", "summary": "Matching rows only; exclusive with --json."}], "mutates": false, "json": "envelope", "details": ["One snapshot: filters run locally over the raw server window before the output limit. No follow/events; existing logs behavior remains available.", "JSON schemaVersion1 reports scanLimit, loaded, matched, returned and limit for this observed window only. Cursor is not a resumable filter token.", "Empty matches succeed; malformed, refused, oversized or unavailable responses fail. SIGINT130/SIGTERM143 preserve cancellation."]}, {"command": ["observe", "logs", "filter"], "summary": "Select a bounded log snapshot with dashboard filters.", "usage": "ocx observe logs filter [--surface ] [--model ] [--provider ] [--status ] [--time-window ] [--min-tok-per-sec ] [--max-tok-per-sec ] [--intercepted-only] [--protocol-mode ] [--conversation ] [--scan-limit <1-2000>] [--limit <1-2000>] [--json|--jsonl]", "routes": [{"method": "GET", "path": "/api/logs"}], "flags": [{"name": "--surface", "value": "string", "summary": "all | codex | claude | grok."}, {"name": "--model", "value": "string", "summary": "Case-insensitive exact identity across requested, resolved, served and attempted models."}, {"name": "--provider", "value": "string", "summary": "Case-insensitive provider or failover attempt identity."}, {"name": "--status", "value": "string", "summary": "all | success (200-299) | errors (400-599)."}, {"name": "--time-window", "value": "string", "summary": "all | 15m | 1h | 24h, relative to observation time."}, {"name": "--min-tok-per-sec", "value": "number", "summary": "Inclusive minimum observed token speed."}, {"name": "--max-tok-per-sec", "value": "number", "summary": "Exclusive maximum observed token speed."}, {"name": "--intercepted-only", "value": "boolean", "summary": "Rows carrying an observed rewrite marker."}, {"name": "--protocol-mode", "value": "string", "summary": "all | native | translated | legacy-bridge | blocked | none."}, {"name": "--conversation", "value": "string", "summary": "Conversation ID or stored hash."}, {"name": "--conversationId", "value": "string", "summary": "Alias of --conversation."}, {"name": "--scan-limit", "value": "number", "summary": "1-2000 raw rows inspected; default 2000."}, {"name": "--limit", "value": "number", "summary": "1-2000 matching rows returned; default 200."}, {"name": "--json", "value": "boolean", "summary": "Versioned view with filters, cursor and observed-window counts."}, {"name": "--jsonl", "value": "boolean", "summary": "Matching rows only; exclusive with --json."}], "mutates": false, "json": "envelope", "details": ["One snapshot: filters run locally over the raw server window before the output limit. No follow/events; existing logs behavior remains available.", "JSON schemaVersion1 reports scanLimit, loaded, matched, returned and limit for this observed window only. Cursor is not a resumable filter token.", "Empty matches succeed; malformed, refused, oversized or unavailable responses fail. SIGINT130/SIGTERM143 preserve cancellation."]}, diff --git a/src/cli/index.ts b/src/cli/index.ts index 9b59169f38d..432db985187 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -29,7 +29,6 @@ try { } import { currentExternalCodexModelProvider, - restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader, } from "../codex/inject"; @@ -163,7 +162,7 @@ import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { startArgv } from "../lib/self-launch-argv"; import { initializeNodeLauncherContext } from "./launcher-context"; -import { assertConnectedClientStopSupported, connectedClientStopSkipLine, preflightConnectedClientStop, preserveConnectedClientRoutingOnExit, restoreSharedClientStateAfterStop } from "./stop-restore"; +import { assertConnectedClientStopSupported, connectedClientStopSkipLine, preflightConnectedClientStop, preserveConnectedClientRoutingOnExit, restoreCodexRoutingOnExit, restoreSharedClientStateAfterStop } from "./stop-restore"; import { startClientRuntimeUnderOwnershipLease } from "./client-start-fence"; import { recoverStartStateUnderOwnershipLease } from "./start-owner-fence"; import { handledSignalExitCode } from "../lib/handled-signal-exit"; @@ -664,7 +663,7 @@ async function handleStart(options: { block?: boolean } = {}) { if (teardown.restoreNativeCodex) { try { if (!codexHomeIsAbsent(getCodexHome())) { - const restored = restoreNativeCodex(); + const restored = restoreCodexRoutingOnExit(); if (!restored.success) { cleanupSucceeded = false; console.error(`⚠️ Native Codex restore failed during shutdown: ${restored.message}`); @@ -707,7 +706,7 @@ async function handleStart(options: { block?: boolean } = {}) { void (async () => { let shutdownSucceeded = false; try { - shutdownSucceeded = await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000); + shutdownSucceeded = await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000, () => { syncCleanup(); }); } finally { const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit") process.exit(restored && shutdownSucceeded ? handledSignalExitCode(signal) : 1); @@ -1087,9 +1086,11 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { // the record; a record left behind by a hard-killed sibling still says so. const siblingOfPort = readRuntimePort()?.siblingOfPort; const stoppingSibling = siblingOfPort !== undefined; - const connectedClientSkip = stoppingSibling ? null : connectedClientStopSkipLine(readClientConnectionState()); + const clientConnection = readClientConnectionState(); + const connectedClient = !stoppingSibling && clientConnection.kind === "connected"; + const connectedClientSkip = stoppingSibling ? null : connectedClientStopSkipLine(clientConnection); let connectedClientAlreadyStopped = false; - if (connectedClientSkip) { + if (connectedClient) { const preflight = await preflightConnectedClientStop(snapshot?.approval); if (preflight === "already-stopped") connectedClientAlreadyStopped = true; else if (preflight) return preflight; @@ -1149,10 +1150,10 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { // Resolve ONCE. Reading the runtime record twice let the receipt name the configured // guess while the request went to a runtime endpoint that appeared in between. const exact = discovered ?? endpointOf(readRuntimePort(pid)); - if (connectedClientSkip) await assertConnectedClientStopSupported(exact ? { ...exact, pid } : null); + if (connectedClient) await assertConnectedClientStopSupported(exact ? { ...exact, pid } : null); claimTeardown(exact ?? configuredEndpoint(), exact ? "exact" : "guessed"); const graceful = await stopProxy(pid, { - fetchFn: connectedClientSkip ? directLocalHttpFetch as typeof fetch : undefined, + fetchFn: connectedClient ? directLocalHttpFetch as typeof fetch : undefined, deferSharedTeardownNonce: teardownNonce, // Only an exact endpoint may direct the request; the configured fallback is a guess // good enough to record an obligation against, not to POST a stop to. @@ -1431,7 +1432,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { // not answering. That is the whole point of leaving the receipt behind. console.log("↩️ Finishing a shared teardown left unfinished by an earlier stop."); } - const restore = await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable); + const restore = await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable, clientConnection.kind === "connected" ? clientConnection.value : undefined); record.sharedTeardown = restore.historyDeferred ? "refused" : restore.other ? "failed" : "restored"; if (restore.other) stopFailed = true; else if (restore.historyDeferred) historyDeferredNonces = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 2544078424b..28937c97d99 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -50,7 +50,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "stop", usage: "ocx stop [--json]", - summary: "Stop the proxy; preserve connected-client integrations, restore native Codex config for standalone owners.", + summary: "Stop the proxy; restore owned Link Child routing, preserve hub client integrations, and restore standalone Codex config.", details: [ "--json keeps the stop path unchanged and prints one structured summary document on stdout; human output moves to stderr.", "Exit codes are identical with and without --json: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred).", diff --git a/src/cli/stop-restore.ts b/src/cli/stop-restore.ts index e4c302946b1..bdf030dd807 100644 --- a/src/cli/stop-restore.ts +++ b/src/cli/stop-restore.ts @@ -1,4 +1,4 @@ -import { restoreNativeCodexAsync } from "../codex/inject"; +import { restoreNativeCodex, restoreNativeCodexAsync } from "../codex/inject"; import type { RetainedCodexProviderTable } from "../codex/inject/restore"; import { stripGrokConfig } from "../grok/inject"; import { readClientConnectionState, type ClientConnectionState } from "../client/state"; @@ -12,15 +12,26 @@ import { findLiveProxy, probeEndpointLiveness, proveLiveProxyOwnedByHome } from import { observeGuardedManagerStopped } from "../service/guarded-manager-target"; import type { StartExitTeardown } from "./dispatch"; import type { StopOutcome } from "./stop-report"; +import { restoreLinkClientRouting } from "../client/stop-routing"; +import type { OcxClientConnectionConfig } from "../types"; /** Re-read ownership when an ordinary runtime actually exits, including after API drain. */ export function preserveConnectedClientRoutingOnExit(teardown: StartExitTeardown): StartExitTeardown { const connection = readClientConnectionState(); - return connection.kind === "connected" && connection.value.transport !== "link" - ? { ...teardown, restoreNativeCodex: false, stripGrokConfig: false } + return connection.kind === "connected" + ? { ...teardown, restoreNativeCodex: connection.value.transport === "link" && teardown.restoreNativeCodex, stripGrokConfig: false } : teardown; } +/** Signal cleanup must not apply standalone restoration to connection-owned integrations. */ +export function restoreCodexRoutingOnExit(): { success: boolean; message: string } { + const connection = readClientConnectionState(); + if (connection.kind === "connected") return connection.value.transport === "link" + ? restoreLinkClientRouting(connection.value) + : { success: true, message: "Hub connection-owned routing preserved." }; + return restoreNativeCodex(); +} + type ConnectedStopTarget = { pid: number; port: number; hostname?: string }; const CONNECTED_STOP_PROBE_MS = 2000; const unsupportedConnectedStop = () => new ProxyOwnershipRefusedError( @@ -51,7 +62,7 @@ export async function assertConnectedClientStopSupported(target: ConnectedStopTa } catch { return null; } }; const health = await probe("/healthz", false); - // The native machine listener has always preserved integrations on signal and 404s /api/stop. + // The native machine listener owns its transport-specific signal cleanup and 404s /api/stop. if (health?.service === "opencodex" && health.role === "client" && health.pid === target.pid) return; const capability = await probe("/api/stop", true); if (capability?.schema === "ocx-stop-capabilities/1" @@ -135,7 +146,16 @@ export function connectedClientStopSkipLine(state: ClientConnectionState): strin */ export async function restoreSharedClientStateAfterStop( reportRetainedCodexProviderTable: (retained: RetainedCodexProviderTable) => void, + expectedConnection?: OcxClientConnectionConfig, ): Promise<{ historyOnly: boolean; historyDeferred: boolean; other: boolean }> { + const connection = readClientConnectionState(); + const link = expectedConnection?.transport === "link" ? expectedConnection + : connection.kind === "connected" && connection.value.transport === "link" ? connection.value : null; + if (link) { + const result = restoreLinkClientRouting(link); + (result.success ? console.log : console.error)(`↩️ ${result.message}`); + return { historyOnly: false, historyDeferred: false, other: !result.success }; + } let historyOnly = false; let historyDeferred = false; let other = false; diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 631e35c19e7..1fb669cca0a 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -29,6 +29,7 @@ import { isLinkConnection, readClientConnectionState } from "./state"; import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; +import { restoreLinkClientRouting } from "./stop-routing"; let activeServer: Server | null = null; let activePort: number | null = null; @@ -455,6 +456,8 @@ export async function startClientRuntime( console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); } activeSupervisor = null; + const restored = linkMode ? restoreLinkClientRouting(state.value) : null; + if (restored && !restored.success) console.error(`[client] ${restored.message}`); try { activeServer?.stop(true); } catch (error) { @@ -463,7 +466,7 @@ export async function startClientRuntime( cleanup(); await flushLinkDiagnostics(); // launchd relaunches only unsuccessful exits; an external signal must still count. - process.exit(handledSignalExitCode(signal)); + process.exit(restored?.success === false ? 1 : handledSignalExitCode(signal)); } })(); }; diff --git a/src/client/stop-routing.ts b/src/client/stop-routing.ts new file mode 100644 index 00000000000..1b362be7762 --- /dev/null +++ b/src/client/stop-routing.ts @@ -0,0 +1,58 @@ +import { existsSync } from "node:fs"; +import { codexHomeIsAbsent } from "../codex/codex-home-owner"; +import { configWriteLockFailureMessage, withConfigWriteLock } from "../codex/config-write-lock"; +import { hasInjectedCodexRouting } from "../codex/injected-marker"; +import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; +import { journalOwner, JOURNAL_PATH, restoreJournalState } from "../codex/journal"; +import { CODEX_CONFIG_PATH, CODEX_HOME } from "../codex/paths"; +import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; +import type { OcxClientConnectionConfig } from "../types"; +import { withClientLifecycleSync } from "./lifecycle-lock"; +import { assertNoClientDisconnectPending, isLinkConnection, readClientConnectionState, sameClientConnectionOwner } from "./state"; + +export type LinkRoutingRestoreResult = { success: boolean; restored: boolean; message: string }; + +const resumeGuidance = "After starting the Child again, run `ocx sync --restart-codex` to restore its Home routing."; +const refusal = (reason: string): LinkRoutingRestoreResult => ({ + success: false, restored: false, + message: `Link Child Codex routing restore refused: ${reason}. Connection and recovery evidence preserved.`, +}); + +/** Restore only this connection's Codex journal; stop never disconnects or restores other clients. */ +export function restoreLinkClientRouting(expected: OcxClientConnectionConfig): LinkRoutingRestoreResult { + if (siblingOfLivePort() !== null) return { success: true, restored: false, message: siblingSkipMessage() }; + if (!isLinkConnection(expected) || !expected.selectedClients.includes("codex")) { + return { success: true, restored: false, message: "This connection owns no Link Child Codex routing to restore." }; + } + try { + if (codexHomeIsAbsent(CODEX_HOME)) return { success: true, restored: false, message: `Codex home is absent. ${resumeGuidance}` }; + // File lock first, then L. Journal replay uses the held file lock and never acquires N; + // this follows injection's file -> N -> L -> C order without taking N inside L. + const locked = withConfigWriteLock(CODEX_CONFIG_PATH, held => withClientLifecycleSync((): LinkRoutingRestoreResult => { + const current = readClientConnectionState(); + if (current.kind !== "connected" || !isLinkConnection(current.value) + || !sameClientConnectionOwner(current.value, expected) + || current.value.tokenFingerprint !== expected.tokenFingerprint + || JSON.stringify(current.value.link) !== JSON.stringify(expected.link) + || JSON.stringify(current.value.selectedClients) !== JSON.stringify(expected.selectedClients)) { + return refusal("the connected owner changed"); + } + assertNoClientDisconnectPending(); + if (current.value.pendingOperation) return refusal("key rotation is pending"); + const owner = journalOwner({ readOnly: true }); + if (owner === null) { + const config = readBoundedCodexConfig(CODEX_CONFIG_PATH); + if (existsSync(JOURNAL_PATH) || (config !== null && hasInjectedCodexRouting(config))) return refusal("the client journal is missing or unverified"); + return { success: true, restored: false, message: `No owned Codex routing remains. ${resumeGuidance}` }; + } + if (owner.kind !== "client" || owner.apiKeyId !== expected.apiKeyId) return refusal("the Codex journal belongs to another owner"); + const result = restoreJournalState({ heldConfigWriteLock: held }); + if (!result.complete) return refusal("the Codex config/profile pair could not be completely restored"); + return { success: true, restored: result.configRewritten || result.profileRewritten, + message: `Link Child Codex routing restored; connection, catalog and other integrations preserved. ${resumeGuidance}` }; + })); + return locked.ok ? locked.value : refusal(configWriteLockFailureMessage(locked)); + } catch (error) { + return refusal(error instanceof Error ? error.message : "restoration failed"); + } +} diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 57421fb29e5..ebdbb336138 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -520,6 +520,7 @@ export function trackStreamLifetime( export async function drainAndShutdown( server: ReturnType | undefined, timeoutMs: number, + beforeListenerStop?: () => void | Promise, ): Promise { const s = server ?? _serverRef; let shutdownSucceeded = true; @@ -605,6 +606,10 @@ export async function drainAndShutdown( setStorageCleanupPolicyJobLiveApply(null); } finally { try { + // Routing restoration belongs after admitted work drains but before another process + // can claim this listener's port. Failure still closes the listener and is reported. + try { await beforeListenerStop?.(); } + catch { shutdownSucceeded = false; console.warn("[opencodex] routing cleanup before listener shutdown failed"); } await stopServerListener(s); } finally { await releaseServerStartupLifecycle(s); diff --git a/src/server/stop-teardown.ts b/src/server/stop-teardown.ts index 298700d8c21..d2f83f1ffa8 100644 --- a/src/server/stop-teardown.ts +++ b/src/server/stop-teardown.ts @@ -3,6 +3,8 @@ import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; import { deferralMatchesReceipt } from "../config/pending-teardown"; import { isDesktopSupervised } from "../lib/system-restart-contract"; import type { ManagementPrincipal } from "./management-auth"; +import { restoreLinkClientRouting, type LinkRoutingRestoreResult } from "../client/stop-routing"; +import type { OcxClientConnectionConfig } from "../types"; /** * Shared-teardown decision and execution for `POST /api/stop` (#3008). @@ -25,6 +27,7 @@ export type StopTeardownIo = { ownsReceipt?: (nonce: string | null) => boolean; restoreNativeCodex?: () => Promise; stripGrok?: () => GrokStripResult; + restoreLinkRouting?: (connection: OcxClientConnectionConfig) => LinkRoutingRestoreResult; }; export type StopTeardownBody = { @@ -96,6 +99,12 @@ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Pr sharedTeardown: "not-owned", }; } + if (connection.kind === "connected") { + // The loopback route must be removed while this listener still owns its port, even + // when a parent holds a deferred-teardown receipt. Never use standalone restoration. + const result = (io.restoreLinkRouting ?? restoreLinkClientRouting)(connection.value); + return { success: result.success, message: result.message, sharedTeardown: "performed" }; + } const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt; if (deferralHonored(url, ownsReceipt)) { // Not "native Codex restored": nothing was restored here, and claiming otherwise diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 44729c1de4a..63aad3669ae 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -286,7 +286,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. Fast observations and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority): the provider editor classifies `responseTierAuthoritative` as an operator-owned boolean under the existing redaction and write boundaries, `src/usage/log.ts` retains it on final outcomes and attempts (old records keep their legacy meaning), and `src/usage/cost.ts` never prices a non-authoritative echo or confirmed label as response-confirmed. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | -| Stop | `GET /api/stop` reports a read-only PID-bound connected-client shutdown-preservation capability. `POST /api/stop` — stop any installed service and exit the proxy; ordinary owners restore native Codex/Grok. Siblings and validated connected hub/Link clients preserve shared integrations (`sharedTeardown: "not-owned"`; [runtime contract](runtime.md#lifecycle)). A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | +| Stop | `GET /api/stop` reports a read-only PID-bound connected-client shutdown-preservation capability. `POST /api/stop` — stop any installed service and exit the proxy; ordinary owners restore native Codex/Grok. Hub clients preserve connection-owned integrations (`sharedTeardown: "not-owned"`); Link Children restore only their owned Codex journal before listener release and preserve the connection, catalog and other integrations ([runtime contract](runtime.md#lifecycle)). A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`, and `GET/PUT /api/auto-review-settings`. The auto-review settings route validates the global reviewer selector, persists its enabled/model fields with rollback before publication, then converges the catalog and returns its disposition; the Models panel keeps the server echo and displays a pending-sync notice whenever no commit is reported. Sidecar PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. An effective `vision.enabled` change persists before one bounded catalog-only convergence updates the managed catalog and Codex model cache; the PUT response returns `catalogRefreshPending` for incomplete work. Identical effective values and unrelated partial writes do not converge, and no result claims client reload. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-6-luna` and `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. PUT refuses a qualified target that only the router's default-provider fallback accepts. Provider disable and delete report the target they leave behind as `dependentShadowIntercept` (`shadowInterceptProviderDependency` in `src/server/management/shadow-call-validation.ts`), and at request time an unresolvable target returns `409 intercept_target_unavailable` before any send (`src/server/responses/shadow-target-availability.ts`); it never falls back to the native source model or the default provider. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | @@ -447,7 +447,7 @@ The desktop shell needs only the dashboard, so it attaches to a client-role list The dashboard sidebar includes a stop button that calls `POST /api/stop`. The button shows a confirmation prompt, then fires the request and accepts the connection drop (the proxy exits). The -endpoint restores native Codex config for ordinary owners; connected clients and siblings preserve their integrations. It stops any installed service to prevent respawn and exits, as described in the [runtime contract](runtime.md#lifecycle). +endpoint restores native Codex config for ordinary owners; hub clients and siblings preserve their integrations, while Link Children restore only their connection-owned Codex journal before listener release. It stops any installed service to prevent respawn and exits, as described in the [runtime contract](runtime.md#lifecycle). While the desktop app supervises the proxy it refuses a dashboard session with 409 `desktop_supervised` before touching anything, because the app would start the proxy again ([desktop shell](desktop-shell.md#keeping-the-runtime-alive)); the button reports the refusal. diff --git a/structure/remote-link.md b/structure/remote-link.md index ca7404d3b16..0c20cd90349 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -159,4 +159,6 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen 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. 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`. +Orderly Child signal shutdown restores only its matching client-owned Codex journal through `src/client/stop-routing.ts` before closing the listener. The connection/key/catalog records and other integrations remain in place. API and parent stop use the same guarded helper; a changed owner or edited/unverified journal reports failure without a standalone restore fallback. After a stop followed by start, the operator runs `ocx sync --restart-codex` to reapply Home routing. Bound restart and recycle keep their existing handoff behavior. See the [lifecycle contract](runtime.md#lifecycle) and `tests/clients/client-link-stop.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 65a345ef8dd..0c02941b73e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -107,7 +107,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). An inherited explicit `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is scoped to a `bun-` leaf beneath the configured directory — appended on a version change, kept unchanged when the path already ends in that leaf — for the bundled or PATH runtime it starts (`src/lib/bun-transpiler-cache-scope.mjs`), because another Bun version sharing the directory evicts its entries. Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Stop preserves connected hub/Link client integrations; disconnect owns their teardown, and start serves them without re-injecting routing. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Stop preserves connected hub client integrations; orderly Link Child stop restores only its connection-owned Codex journal before listener release. Client start does not re-inject routing, so a stopped Child needs `ocx sync --restart-codex` after start; disconnect releases the connection. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | | `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` | CLI head and compact/full help; see [head and help navigation](cli-management.md#head-and-help-navigation). | | `src/cli/help-catalog.ts`, `src/cli/help-models-context.ts`, `src/cli/help-recovery.ts` | Pure help resolution, contextual recovery and output sinks; see [head and help navigation](cli-management.md#head-and-help-navigation). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | @@ -169,13 +169,13 @@ their own files. `src/grok/inject.ts` holds the shared canonical config-write lo Cross-home discovery in `src/cli/cross-home-owner.ts` checks concrete loopback address availability through `src/cli/loopback-family.ts` before probing each family. A synchronous ephemeral bind is immediately closed; only address/family-unavailable errors exclude that address. This prevents a disabled IPv6 loopback's off-host `::1` connect timeout from blocking startup. Other bind errors retain discovery, and a timeout on an available or unknown family still produces an indeterminate owner verdict. Live owners on either available family retain identity and attestation checks. -Startup catalog sync and native restore apply the [retired-native policy](catalog.md#shared-catalog). Foreground exit cleanup in `src/cli/index.ts` delegates restoration through the existing sibling/recycle teardown gates; approved external URLs follow [custom-provider consent](codex-provider-routing.md), while unapproved provider configuration and history remain unchanged. Guarded tests isolate the owner registry used by cross-home discovery beneath their selected home, including children without a registry override; production retains the shared locator. See [test state isolation](ops/test-sandbox-cleanup.md). +Startup catalog sync and native restore apply the [retired-native policy](catalog.md#shared-catalog). Foreground exit cleanup in `src/cli/index.ts` delegates restoration through the existing sibling/recycle teardown gates, after drain and before `src/server/lifecycle.ts` releases the listener; approved external URLs follow [custom-provider consent](codex-provider-routing.md), while unapproved provider configuration and history remain unchanged. Guarded tests isolate the owner registry used by cross-home discovery beneath their selected home, including children without a registry override; production retains the shared locator. See [test state isolation](ops/test-sandbox-cleanup.md). `src/server/background-lifecycle.ts` registers low-quota protection for each live server with the OS desktop sink resolver, flushes pending saves before releasing its owner, binds its own event ledger to management requests, and unregisters it on failed startup or stop; with no pending save, owner release remains synchronous. The protection writer loads config persistence only when a threshold schedules a save; see [configuration](config.md#codex-pool-low-quota-protection). Codex quota processing has shared and Reserve scopes; retired model evidence is suppressed as described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider-contract). `ocx start` refuses a duplicate PID, starts the proxy, writes `~/.opencodex/ocx.pid` and `runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves -until shutdown. Standalone shutdown restores native Codex; connected hub clients and Remote Link Children preserve connection-owned Codex routing, catalog and Grok config across `ocx stop`/`ocx start`. An ordinary runtime rechecks persisted connection ownership in its exit cleanup too, preserving Codex/Grok while retaining its existing environment cleanup. Client start does not re-inject routing: use `ocx sync --restart-codex` to reapply it, or `ocx disconnect` to release connection ownership. A sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets +until shutdown. Standalone shutdown restores native Codex; connected hub clients preserve connection-owned routing across `ocx stop`/`ocx start`. Orderly Remote Link Child stop calls `src/client/stop-routing.ts` before releasing the listener. It restores only a matching client-owned Codex journal, under the Codex file write lock followed by the client lifecycle lock; journal replay acquires no N lock. The current connection owner, key fingerprint, link and selected clients must still match, with no pending disconnect or key rotation. Changed ownership and incomplete or unverified recovery report failure and retain evidence. Connection/key/catalog records and unrelated Grok/Desktop state remain untouched. An ordinary runtime rechecks persisted connection ownership in its exit cleanup too and retains its existing environment cleanup. Client start does not re-inject routing: run `ocx sync --restart-codex` after starting a stopped Child; `ocx disconnect` releases connection ownership. Bound restart and recycle preserve their existing handoff behavior. Crashes and forced exits cannot run this orderly cleanup. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/service/stop-deferred-teardown.test.ts`, `tests/service/shutdown-drain.test.ts`. A sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. 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. @@ -335,7 +335,7 @@ field for every tool. Only bare or `default.`-prefixed `exec` and `apply_patch` one recognized alternate body field or remove one complete outer Markdown fence; ambiguous alternate fields and every other freeform grammar pass through unchanged. -The server exposes read-only authenticated `GET /api/stop` with `schema: "ocx-stop-capabilities/1"`, PID and connected-client preservation for API, signal and manager shutdown. `src/cli/stop-restore.ts` requires matching client-role health identity or this capability before any connected-client service stop, then rechecks the pinned target before POST/kill. Its probes and connected-stop POST use the proxy-independent TCP transport in `src/server/direct-local-http.ts`, a shared two-second deadline and a 4 KiB capability-document limit; unknown targets refuse without mutation. A stopped connected client skips manager mutation only after the manager is observed inactive and all known local endpoints are definitively dead; the ordinary receipt and environment cleanup still runs. `POST /api/stop`: `src/server/stop-teardown.ts` checks sibling then validated persisted connected-client ownership before receipt deferral or restoration. Both owners answer `sharedTeardown: "not-owned"` without restoring Codex/catalog or stripping Grok; an existing receipt remains outstanding. Other owners restore native Codex/Grok, or defer only with the matching receipt. The route stops any installed service +The server exposes read-only authenticated `GET /api/stop` with `schema: "ocx-stop-capabilities/1"`, PID and connected-client ownership handling for API, signal and manager shutdown. `src/cli/stop-restore.ts` requires matching client-role health identity or this capability before any connected-client service stop, then rechecks the pinned target before POST/kill. Its probes and connected-stop POST use the proxy-independent TCP transport in `src/server/direct-local-http.ts`, a shared two-second deadline and a 4 KiB capability-document limit; unknown targets refuse without mutation. A stopped connected client skips manager mutation only after the manager is observed inactive and all known local endpoints are definitively dead; the ordinary receipt and environment cleanup still runs. `POST /api/stop`: `src/server/stop-teardown.ts` checks sibling then validated persisted connected-client ownership before receipt deferral or restoration. Siblings and hub clients answer `sharedTeardown: "not-owned"` without restoring Codex/catalog or stripping Grok; an existing receipt remains outstanding. Link clients restore only their owned Codex journal before receipt deferral, while the listener still owns its port, and report the result as `sharedTeardown: "performed"`; a parent retry rechecks the original connection owner and is idempotent. Other owners restore native Codex/Grok, or defer only with the matching receipt. The route stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. > Decision record: [ADR-0004](decisions/ADR-0004-lifecycle.md) diff --git a/tests/clients/client-link-stop.test.ts b/tests/clients/client-link-stop.test.ts new file mode 100644 index 00000000000..55134d65350 --- /dev/null +++ b/tests/clients/client-link-stop.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fixturePath, repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +async function runStop(options: Record) { + const root = mkdtempSync(join(tmpdir(), "ocx-link-stop-")); + const home = join(root, "ocx"); + const child = Bun.spawn([process.execPath, fixturePath("client-link-stop-runner.ts")], { + cwd: repoPath(), stdin: new Blob([JSON.stringify(options)]), stdout: "pipe", stderr: "pipe", + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: home, CODEX_HOME: join(root, "codex"), OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, + }); + const timeout = setTimeout(() => child.kill(), 15_000); + try { + const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + if (options.expectedFailure || options.expectedRefusal) { + expect(exit, stdout).toBe(options.expectedFailure ? 1 : 0); + expect(stderr).toContain("Link Child Codex routing restore refused"); + } else expect({ exit, stderr }, stdout).toEqual({ exit: 0, stderr: "" }); + return JSON.parse(readFileSync(join(home, "result.json"), "utf8")); + } finally { + clearTimeout(timeout); + if (child.exitCode === null) { child.kill(); await child.exited; } + removeTreeWithRetry(root); + } +} + +for (const signal of process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]) { + test(`native Link ${signal} restores its owned Codex routing before listener release`, async () => { + const report = await runStop({ mode: "runtime", signal }); + expect(report.result).toEqual({ atListenerStop: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); + }, 20_000); +} + +test("native hub signal exit preserves the connection-owned route", async () => { + const report = await runStop({ mode: "runtime", transport: "hub" }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}, 20_000); + +test("native Link signal refuses a connection swapped after runtime publication", async () => { + const report = await runStop({ mode: "runtime", mutation: "connection", expectedFailure: true }); + expect(report.result).toEqual({ atListenerStop: true }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}, 20_000); + +test("owned Link routing restore is idempotent and retains the connection's remaining artifacts", async () => { + const report = await runStop({ mode: "helper", repeat: true }); + expect(report.result.first).toMatchObject({ success: true, restored: true }); + expect(report.result.repeated).toMatchObject({ success: true, restored: false }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); +}); + +for (const mutation of ["connection", "selection", "journal", "process-journal", "unverified"]) { + test(`Link restore refuses ${mutation} evidence without touching its files`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result).toMatchObject({ success: false, restored: false }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); + }); +} + +for (const mutation of ["config", "profile"]) { + test(`Link restore preserves an edited ${mutation} and reports the incomplete pair`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result.success).toBe(false); + if (mutation === "config") expect(report.after).toBe(report.before); + else expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); + }); +} + +for (const mutation of ["oversized", "nonregular"]) { + test(`missing Link journal cannot make an ${mutation} config a successful no-op`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result).toMatchObject({ success: false, restored: false }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.protectedUnchanged).toBe(true); + }); +} + +test("Link API teardown restores the owned journal before an accepted parent deferral", async () => { + const report = await runStop({ mode: "api" }); + expect(report.result).toMatchObject({ success: true, sharedTeardown: "performed" }); + expect(report.result.message).toContain("ocx sync --restart-codex"); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); +}); + +test("parent Link teardown restores only its connection-owned journal", async () => { + const report = await runStop({ mode: "parent" }); + expect(report.result).toEqual({ historyOnly: false, historyDeferred: false, other: false }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); +}); + +test("parent Link teardown refuses the connection that replaced its stopped target", async () => { + const report = await runStop({ mode: "parent", mutation: "connection", expectedRefusal: true }); + expect(report.result).toEqual({ historyOnly: false, historyDeferred: false, other: true }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}); diff --git a/tests/fixtures/client-link-stop-runner.ts b/tests/fixtures/client-link-stop-runner.ts new file mode 100644 index 00000000000..37c25583690 --- /dev/null +++ b/tests/fixtures/client-link-stop-runner.ts @@ -0,0 +1,100 @@ +/** Isolated disk and native-runtime coverage for a connected Link Child's stop. */ +import { mock } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getDefaultConfig, saveConfig } from "../../src/config"; +import { CODEX_CONFIG_PATH, CODEX_HOME, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { JOURNAL_PATH, markJournalInjectedState, writeJournal } from "../../src/codex/journal"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { + mode: "runtime" | "helper" | "api" | "parent"; + transport?: "link" | "hub"; + signal?: NodeJS.Signals; + repeat?: boolean; + mutation?: "connection" | "journal" | "process-journal" | "config" | "profile" | "unverified" | "selection" | "oversized" | "nonregular"; +}; +const home = process.env.OPENCODEX_HOME!; +mkdirSync(CODEX_HOME, { recursive: true }); +mkdirSync(home, { recursive: true }); +const lifecycle = await import("../../src/client/lifecycle-lock"); +const lock = lifecycle.withClientLifecycleSync; +mock.module("../../src/client/lifecycle-lock", () => ({ ...lifecycle, + withClientLifecycleSync: (work: Parameters[0]) => lock(work, { lockPath: join(home, "lifecycle.sqlite") }), +})); +const token = `ocx_data_${"c".repeat(40)}`; +const config = getDefaultConfig(); +const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response(null) }); +config.port = probe.port!; +await probe.stop(true); +config.runtimeRole = "client"; +config.client = { + serverUrl: options.transport === "hub" ? "https://hub.example.test" : "http://127.0.0.1:34567", + managementUrl: options.transport === "hub" ? "https://hub.example.test" : "http://127.0.0.1:34567", + managementTransport: "direct", transport: options.transport ?? "link", + ...(options.transport === "hub" ? {} : { link: { tunnelPort: 34567, linkId: "lnk_0123456789abcdef" } }), + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", + tokenFingerprint: serviceApiTokenFingerprint(token), protocolVersion: 1, connectedAt: "2026-10-01T00:00:00.000Z", +}; +saveConfig(config); +const expected = structuredClone(config.client); +const original = 'model = "native"\n'; +const injected = `# Auto-injected by opencodex (undo: ocx restore)\nopenai_base_url = "http://127.0.0.1:${config.port}/v1"\n${original}`; +writeFileSync(CODEX_CONFIG_PATH, original); +writeJournal({ currentStateIsNative: true, owner: { kind: "client", apiKeyId: expected.apiKeyId } }); +writeFileSync(CODEX_CONFIG_PATH, injected); +writeFileSync(CODEX_PROFILE_PATH, 'model = "managed"\n'); +markJournalInjectedState(injected, 'model = "managed"\n', { + injectedOpenaiBaseUrl: `http://127.0.0.1:${config.port}/v1`, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: DEFAULT_CATALOG_PATH, +}); +writeFileSync(DEFAULT_CATALOG_PATH, '{"models":[{"id":"home-model"}]}'); +writeFileSync(join(home, "service-api-token"), token); +writeFileSync(join(home, "grok-sentinel"), "unrelated Grok configuration"); +const mutate = () => { + if (options.mutation === "connection") { config.client!.connectedAt = "2026-10-02T00:00:00.000Z"; saveConfig(config); } + if (options.mutation === "selection") { config.client!.selectedClients = ["claude"]; saveConfig(config); } + if (options.mutation === "journal" || options.mutation === "process-journal" || options.mutation === "unverified") { + const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf8")); + if (options.mutation === "unverified") delete journal.injectedConfigHash; + else journal.owner = options.mutation === "journal" ? { kind: "client", apiKeyId: "another-client" } : { kind: "process", pid: process.pid }; + writeFileSync(JOURNAL_PATH, JSON.stringify(journal)); + } + if (options.mutation === "config") writeFileSync(CODEX_CONFIG_PATH, injected + 'approval_policy = "on-request"\n'); + if (options.mutation === "profile") writeFileSync(CODEX_PROFILE_PATH, 'model = "operator-edit"\n'); + if (options.mutation === "oversized" || options.mutation === "nonregular") { + unlinkSync(JOURNAL_PATH); + if (options.mutation === "oversized") writeFileSync(CODEX_CONFIG_PATH, " ".repeat(1024 * 1024 + 1)); + else { unlinkSync(CODEX_CONFIG_PATH); mkdirSync(CODEX_CONFIG_PATH); } + } +}; +if (options.mode !== "runtime") mutate(); +const protectedPaths = [join(home, "config.json"), DEFAULT_CATALOG_PATH, join(home, "service-api-token"), join(home, "grok-sentinel")]; +let protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); +const configContents = () => statSync(CODEX_CONFIG_PATH).isFile() ? readFileSync(CODEX_CONFIG_PATH, "utf8") : ""; +const before = configContents(); +const profileBefore = readFileSync(CODEX_PROFILE_PATH, "utf8"); +const report = (result: unknown) => writeFileSync(join(home, "result.json"), JSON.stringify({ + result, before, profileBefore, after: configContents(), + profileAfter: existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf8") : null, + journalRetained: existsSync(JOURNAL_PATH), protectedUnchanged: protectedPaths.every((path, index) => readFileSync(path, "utf8") === protectedBefore[index]), +})); +if (options.mode === "runtime") { + const { startClientRuntime } = await import("../../src/client/runtime"); + const { startMachineListener } = await import("../../src/client/machine-listener"); + await startClientRuntime({ block: false }, { startListener: (port, deps) => { + const server = startMachineListener(port, deps); + const stop = server.stop.bind(server); + server.stop = ((close: boolean) => { report({ atListenerStop: true }); return stop(close); }) as typeof server.stop; + return server; + } }); + mutate(); + protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); + process.emit(options.signal ?? "SIGTERM", options.signal ?? "SIGTERM"); +} else { + const result = options.mode === "api" + ? await (await import("../../src/server/stop-teardown")).performStopTeardown(new URL("http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=fixture"), { ownsReceipt: () => true }) + : options.mode === "parent" ? await (await import("../../src/cli/stop-restore")).restoreSharedClientStateAfterStop(() => {}, expected) + : (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected); + const repeated = options.repeat ? (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected) : undefined; + report(repeated ? { first: result, repeated } : result); +} diff --git a/tests/fixtures/parent-stop-runner.ts b/tests/fixtures/parent-stop-runner.ts index 93ef7fae037..26edee90482 100644 --- a/tests/fixtures/parent-stop-runner.ts +++ b/tests/fixtures/parent-stop-runner.ts @@ -49,6 +49,8 @@ const fakePid = 4242; const calls = { killed: 0, native: 0, grok: 0, cleared: 0, exited: 0, service: 0 }; const urls: string[] = []; const probes: string[] = []; +let directPosts = 0; +const linkRestores: string[] = []; const unexpectedIo: string[] = []; let alive = !options.alreadyStopped; let nonce: string | undefined; @@ -80,7 +82,7 @@ mock.module("node:child_process", () => ({ const receipts = await import("../../src/config/pending-teardown"); process.on("exit", () => { writeFileSync(join(home, "parent-stop-result.json"), JSON.stringify({ - calls, urls, probes, unexpectedIo, nonce, + calls, urls, probes, directPosts, linkRestores, unexpectedIo, nonce, receiptExists: nonce !== undefined && existsSync(receipts.pendingTeardownPathFor(nonce)), })); }); @@ -142,8 +144,18 @@ if (options.alreadyStopped) { const native = await import("../../src/codex/inject"); mock.module("../../src/codex/inject", () => ({ ...native, + restoreNativeCodex: () => { calls.native += 1; return options.restore; }, restoreNativeCodexAsync: async () => { calls.native += 1; return options.restore; }, })); +const linkRouting = await import("../../src/client/stop-routing"); +let linkRestored = false; +mock.module("../../src/client/stop-routing", () => ({ ...linkRouting, restoreLinkClientRouting: () => { + linkRestores.push(alive ? "before-stop" : "after-stop"); + if (linkRestored) return { success: true, restored: false, message: "Link routing already restored" }; + calls.native++; + linkRestored = options.restore.success; + return { success: options.restore.success, restored: options.restore.success, message: "Link routing restored" }; +} })); const grok = await import("../../src/grok/inject"); mock.module("../../src/grok/inject", () => ({ ...grok, @@ -192,6 +204,7 @@ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => const direct = await import("../../src/server/direct-local-http"); mock.module("../../src/server/direct-local-http", () => ({ ...direct, directLocalHttpFetch: async (input: string | URL | Request, init?: RequestInit) => { + if (init?.method === "POST") directPosts++; if (!alive && String(input).endsWith("/healthz")) throw Object.assign(new Error("fixture listener closed"), { code: "ECONNREFUSED" }); return globalThis.fetch(input, { ...init, method: init?.method ?? "GET", redirect: "error" }); }, @@ -214,7 +227,7 @@ if (options.exitCleanup) { preserveConnectedClientRoutingOnExit: (restore as Record).preserveConnectedClientRoutingOnExit, revertSystemEnv() {}, removePid() {}, removeRuntimePort() {}, serviceEnvironmentOwnedHere: () => true, getCodexHome: () => "fixture", codexHomeIsAbsent: () => false, - restoreNativeCodex: () => { calls.native++; return options.restore; }, + restoreCodexRoutingOnExit: restore.restoreCodexRoutingOnExit, stripGrokConfig: () => { calls.grok++; return { ok: true, changed: true, message: "stripped" }; }, }; const cleanup = new Function(...Object.keys(bindings), "let cleaned = false; let cleanupSucceeded = true;\n" + source.slice(from, to) + "\nreturn syncCleanup();"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 32e61e52725..3ff4dcae8e0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1375,7 +1375,7 @@ "command-code-tool-text-prose-split.test.ts": "providers", "cli-effort-slug.test.ts": "cli", "grok-47-build-fast-metadata.test.ts": "providers/xai", "grok-47-fast-model.test.ts": "providers/xai", "grok-47-fast-model-wire.test.ts": "providers/xai", "client-link-connect.test.ts": "clients", - "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", + "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", "client-link-stop.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", "client-link-status.test.ts": "clients", "client-link-teardown.test.ts": "clients", "client-link-tunnel.test.ts": "clients", "link-ports.test.ts": "clients", diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index 8f1dbcae0a9..8b53bbefcf8 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -112,11 +112,11 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).toContain("ownershipBlocked = true"); // Ownership is one reason to skip the restore; others are an inherited obligation whose // proxy could not be confirmed down (#3008), a sibling runtime, whose shared client - // routing belongs to the live proxy it ran beside, and a connected client, whose routing, + // routing belongs to the live proxy it ran beside, and a connected hub client, whose routing, // catalog and Grok config belong to its connection and are never written back on start (#7563). expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling || connectedClientSkip !== null;"); expect(stopFn).toContain("if (!restoreBlocked) {"); - expect(stopFn).toContain("await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable)"); + expect(stopFn).toContain("await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable,"); expect(restoreFn).toContain("restoreNativeCodexAsync()"); expect(restoreFn).not.toContain("revertSystemEnv()"); expect(restoreFn).toContain("stripGrokConfig()"); @@ -274,9 +274,9 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks"); expect(stopFn).toContain("if (!restoreBlocked) {"); // The restore is reached only through that gate — no other call site may bypass it. - const restoreCalls = stopFn.split("await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable)").length - 1; + const restoreCalls = stopFn.split("await restoreSharedClientStateAfterStop(").length - 1; expect(restoreCalls).toBe(1); - expect(stopFn.indexOf("const restoreBlocked")).toBeLessThan(stopFn.indexOf("await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable)")); + expect(stopFn.indexOf("const restoreBlocked")).toBeLessThan(stopFn.indexOf("await restoreSharedClientStateAfterStop(")); // An obligation that cannot be discharged fails the stop and is preserved. const gateBlock = stopFn.slice(stopFn.indexOf("const recoveredNonces"), stopFn.indexOf("const restoreBlocked")); expect(gateBlock).toContain("inheritedBlocks = true;"); diff --git a/tests/service/shutdown-drain.test.ts b/tests/service/shutdown-drain.test.ts index 329f761b797..ff41653ade8 100644 --- a/tests/service/shutdown-drain.test.ts +++ b/tests/service/shutdown-drain.test.ts @@ -81,6 +81,44 @@ function fakeServer(stopImpl?: (closeActiveConnections?: boolean) => void | Prom } describe("server listener shutdown", () => { + test("routing cleanup waits for admitted work and finishes while the real listener still owns its port", async () => { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("still bound") }); + const turn = tryAdmitTurn()!; + let entered = false; + let finishRestore!: () => void; + let signalRestore!: () => void; + const restoreStarted = new Promise(resolve => { signalRestore = resolve; }); + const restoreGate = new Promise(resolve => { finishRestore = resolve; }); + let completed = false; + const draining = drainAndShutdown(server, 2_000, async () => { + entered = true; + signalRestore(); + await restoreGate; + }).then(result => { completed = true; return result; }); + try { + await Bun.sleep(10); + expect(entered).toBe(false); + turn.release(); + await restoreStarted; + expect(await fetch(`http://127.0.0.1:${server.port}`, { signal: AbortSignal.timeout(1000) }).then(r => r.text())).toBe("still bound"); + expect(completed).toBe(false); + finishRestore(); + expect(await draining).toBe(true); + expect(completed).toBe(true); + } finally { + turn.release(); + finishRestore(); + await draining; + await server.stop(true); + } + }); + + test("a failed routing cleanup still closes the listener and reports failure", async () => { + const fake = fakeServer(); + expect(await drainAndShutdown(fake.server, 0, () => { throw new Error("fixture restore failure"); })).toBe(false); + expect(fake.stops()).toBe(1); + }); + test("single-flights stop(true) and keeps every waiter pending until close completes", async () => { let resolveStop!: () => void; const fake = fakeServer(() => new Promise(resolve => { resolveStop = resolve; })); diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index 0cb52e7d8f6..45a68e13fde 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -83,7 +83,7 @@ async function runParentStop(options: { expect(existsSync(reportPath), child.stderr).toBe(true); const report = JSON.parse(readFileSync(reportPath, "utf8")) as { calls: { killed: number; native: number; grok: number; cleared: number; exited: number; service: number }; - probes: string[]; + probes: string[]; directPosts: number; linkRestores: string[]; urls: string[]; nonce?: string; receiptExists: boolean; unexpectedIo: string[]; }; expect(report.unexpectedIo, child.stderr).toEqual([]); @@ -179,9 +179,9 @@ describe("parent CLI shared teardown completion", () => { expect(outcome.exitCode).toBe(1); }); } - for (const mode of ["tracked", "orphan", "guarded"] as const) { - test(`connected ${mode} stop refuses an old server before a cascading manager`, async () => { - const outcome = await runParentStop({ receipt: true, clientTransport: "hub", clientServer: "legacy", json: true, + for (const clientTransport of ["hub", "link"] as const) for (const mode of ["tracked", "orphan", "guarded"] as const) { + test(`connected ${clientTransport} ${mode} stop refuses an old server before a cascading manager`, async () => { + const outcome = await runParentStop({ receipt: true, clientTransport, clientServer: "legacy", json: true, orphan: mode === "orphan", guarded: mode === "guarded", managerCascades: true, response: { success: true, sharedTeardown: "performed" }, restore: restoreResult(true) }); expect(outcome.calls).toMatchObject({ service: 0, killed: 0, native: 0, grok: 0, cleared: 0 }); @@ -189,10 +189,11 @@ describe("parent CLI shared teardown completion", () => { expect(outcome.exitCode).toBe(1); }); } - test("actual Link Child exit cleanup restores routing before releasing its loopback port", async () => { + test("ordinary runtime exit cleanup restores only Link-owned Codex routing", async () => { const outcome = await runParentStop({ receipt: true, clientTransport: "link", exitCleanup: true, response: null, restore: restoreResult(true) }); - expect(outcome.calls).toMatchObject({ native: 1, grok: 1, service: 0, cleared: 0, killed: 0 }); + expect(outcome.calls).toMatchObject({ native: 1, grok: 0, service: 0, cleared: 0, killed: 0 }); + expect(outcome.linkRestores).toEqual(["before-stop"]); expect(outcome.exitCode).toBe(0); }); test("ordinary startup exit cleanup retains native and Grok restoration", async () => { @@ -219,6 +220,7 @@ describe("parent CLI shared teardown completion", () => { const outcome = await runParentStop({ receipt: true, clientTransport: "hub", serverTeardown: true, json: true, response: null, restore: restoreResult(true) }); expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + expect(outcome.directPosts).toBe(1); expect(outcome.nonce).toBeUndefined(); expect(outcome.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); @@ -235,10 +237,12 @@ describe("parent CLI shared teardown completion", () => { expect(outcome.exitCode, outcome.stderr).toBe(0); }); - test("a successful Link Child stop restores integrations before releasing its loopback port", async () => { + test("Link stop uses direct transport and completes owned restoration in the serving runtime", async () => { const outcome = await runParentStop({ receipt: true, clientTransport: "link", serverTeardown: true, json: true, response: null, restore: restoreResult(true) }); - expect(outcome.calls).toMatchObject({ native: 1, grok: 1, cleared: 1 }); + expect(outcome.calls).toMatchObject({ native: 1, grok: 0, cleared: 1 }); + expect(outcome.linkRestores).toEqual(["before-stop", "after-stop"]); + expect(outcome.directPosts).toBe(1); expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "restored" }); expect(outcome.exitCode, outcome.stderr).toBe(0); }); @@ -463,16 +467,31 @@ describe("performStopTeardown", () => { expect(readPendingTeardown(receipt.nonce).state).toBe("valid"); }); - test("persisted Link ownership does not preserve an unauthenticated loopback route", async () => { + test("persisted Link ownership restores only its journal before receipt deferral", async () => { connectClient("link"); let restored = 0; let stripped = 0; + let linked = 0; + for (const query of ["", "?deferSharedTeardown=1&teardownNonce=fixture"]) { + const body = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop${query}`), { + ownsReceipt: () => true, + restoreLinkRouting: connection => { expect(connection.apiKeyId).toBe("fixture-client"); linked++; return { success: true, restored: true, message: "owned journal restored" }; }, + restoreNativeCodex: async () => { restored++; return restoreResult(true); }, + stripGrok: () => { stripped++; return { ok: true, changed: true, message: "stripped" }; }, + }); + expect(body).toMatchObject({ success: true, sharedTeardown: "performed" }); + } + expect([linked, restored, stripped]).toEqual([2, 0, 0]); + }); + + test("Link teardown exposes ownership refusal without falling back to standalone restoration", async () => { + connectClient("link"); const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop"), { - restoreNativeCodex: async () => { restored++; return restoreResult(true); }, - stripGrok: () => { stripped++; return { ok: true, changed: true, message: "stripped" }; }, + restoreLinkRouting: () => ({ success: false, restored: false, message: "fixture owner changed" }), + restoreNativeCodex: async () => { throw new Error("unrelated native restoration"); }, + stripGrok: () => { throw new Error("unrelated Grok restoration"); }, }); - expect(body).toMatchObject({ success: true, sharedTeardown: "performed" }); - expect([restored, stripped]).toEqual([1, 1]); + expect(body).toEqual({ success: false, sharedTeardown: "performed", message: "fixture owner changed" }); }); test("a hub server without a client connection still restores its own integrations", async () => { From 560ff4bb73ce2c14ba9a680d4773b83aa26baa50 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 12:59:22 +0000 Subject: [PATCH 03/13] test(cli): align stop contracts with owned routing cleanup --- tests/cli/cli-dispatch.test.ts | 3 ++- tests/cli/cli-start-routing-heal-wiring.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 544ca90c7fc..29aea594d46 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -625,7 +625,8 @@ describe("a sibling start leaves shared client routing to the live owner", () => // #7563 adds a connected client to the same gate; the sibling term is unchanged. expect(stop).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling || connectedClientSkip !== null;"); expect(stop).toContain('if (nativeRestoreHandledByProxy && !stoppingSibling && !connectedClientSkip) record.sharedTeardown = "performed-by-proxy";'); - expect(stop).toContain("fetchFn: connectedClientSkip ? directLocalHttpFetch as typeof fetch : undefined,"); + // Preserve process-control's direct default and its observed-409 refusal handling. + expect(stop).not.toContain("fetchFn:"); // The system env was never the sibling's to set, so its stop does not roll it back either. expect(stop).toContain("if (!stoppingSibling) { try { revertSystemEnv(); } catch { /* best-effort */ } }"); expect(stop).not.toMatch(/\n {2}try \{ revertSystemEnv\(\); \}/); diff --git a/tests/cli/cli-start-routing-heal-wiring.test.ts b/tests/cli/cli-start-routing-heal-wiring.test.ts index 42a7b88f4b9..e992c39f57e 100644 --- a/tests/cli/cli-start-routing-heal-wiring.test.ts +++ b/tests/cli/cli-start-routing-heal-wiring.test.ts @@ -46,7 +46,7 @@ describe("handleStart routing-healer wiring", () => { const stopAt = cleanup.indexOf("try { routingHealer?.stop(); } catch { /* best-effort */ }"); expect(stopAt).toBeGreaterThan(-1); expect(stopAt).toBeLessThan(cleanup.indexOf("revertSystemEnv()")); - expect(stopAt).toBeLessThan(cleanup.indexOf("restoreNativeCodex()")); + expect(stopAt).toBeLessThan(cleanup.indexOf("restoreCodexRoutingOnExit()")); expect(stopAt).toBeLessThan(cleanup.indexOf("stripGrokConfig()")); expect(stopAt).toBeGreaterThan(cleanup.indexOf("cleaned = true;")); }); From 7458fe370227ba78db0917e21792a144100f2786 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 13:18:44 +0000 Subject: [PATCH 04/13] test(client): preserve failure exit in signal cleanup contract --- tests/service/service-handled-signal-exit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/service/service-handled-signal-exit.test.ts b/tests/service/service-handled-signal-exit.test.ts index d45d8493fef..f40c91febf5 100644 --- a/tests/service/service-handled-signal-exit.test.ts +++ b/tests/service/service-handled-signal-exit.test.ts @@ -25,7 +25,7 @@ describe("handled signal exit code", () => { const cli = readFileSync(repoPath("src/cli/index.ts"), "utf8"); expect(cli).toContain("process.exit(restored && shutdownSucceeded ? handledSignalExitCode(signal) : 1);"); const client = readFileSync(repoPath("src/client/runtime.ts"), "utf8"); - expect(client).toContain("process.exit(handledSignalExitCode(signal));"); + expect(client).toContain("process.exit(restored?.success === false ? 1 : handledSignalExitCode(signal));"); const launchd = readFileSync(repoPath("src/service/launchd.ts"), "utf8"); expect(launchd).toContain("KeepAliveSuccessfulExit"); }); From bb775e13a2bdf498b4d1c7e9616cc4aae21e94ea Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 27 Oct 2026 02:37:54 +0000 Subject: [PATCH 05/13] docs(link): document pending disconnect stop refusal Add an incomplete disconnect receipt (client_disconnect_pending) to the existing stop restoration refusal conditions in all eight lifecycle guides. Keep the recovery-evidence guidance and the runtime behavior unchanged. --- docs-site/src/content/docs/fr/reference/cli/lifecycle.md | 2 +- docs-site/src/content/docs/ja/reference/cli/lifecycle.md | 2 +- docs-site/src/content/docs/ko/reference/cli/lifecycle.md | 2 +- docs-site/src/content/docs/reference/cli/lifecycle.md | 7 ++++--- docs-site/src/content/docs/ru/reference/cli/lifecycle.md | 2 +- docs-site/src/content/docs/tr/reference/cli/lifecycle.md | 2 +- .../src/content/docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../src/content/docs/zh-tw/reference/cli/lifecycle.md | 2 +- 8 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 37163c0d33c..34bedc611fc 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -36,7 +36,7 @@ Arrête le proxy actif à partir de son PID, supprime le fichier de PID et réta Sur un client hub connecté, `ocx stop` conserve le routage Codex, le catalogue et la configuration Grok appartenant à la connexion. Sur un Remote Link Child, un arrêt normal restaure uniquement le journal Codex de cette connexion avant de fermer l’écouteur ; la connexion, la clé, le catalogue et les autres intégrations sont conservés. Pour rétablir le routage vers Home, exécutez `ocx start` ou `ocx service start`, puis `ocx sync --restart-codex` : le démarrage seul ne réapplique pas le routage. Le transfert normal de `ocx restart` le conserve. Utilisez `ocx disconnect` pour libérer la connexion. -Un conflit de propriétaire, une rotation de clé en attente, des fichiers de configuration ou de profil modifiés, ou un journal illisible ou non vérifié (y compris un journal trop volumineux ou qui n’est pas un fichier ordinaire) entraîne un échec de restauration et conserve les éléments de récupération. Examinez ces éléments et résolvez le conflit signalé avant de réappliquer la connexion. Un plantage ou un arrêt forcé ne peut pas exécuter ce nettoyage normal. +Un conflit de propriétaire, une rotation de clé en attente, un reçu de déconnexion incomplet (`client_disconnect_pending`), des fichiers de configuration ou de profil modifiés, ou un journal illisible ou non vérifié (y compris un journal trop volumineux ou qui n’est pas un fichier ordinaire) entraîne un échec de restauration et conserve les éléments de récupération. Examinez ces éléments et résolvez le conflit signalé avant de réappliquer la connexion. Un plantage ou un arrêt forcé ne peut pas exécuter ce nettoyage normal. ### `ocx restart` diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index dd3c51fd6bf..6856ea8de0d 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -30,7 +30,7 @@ ocx start --socks5-off 実行中のプロキシを (PID によって) 停止し、PID ファイルを削除します。スタンドアロンの所有者ではネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。 -接続済み hub クライアントでは `ocx stop` は接続所有の Codex ルーティング、カタログ、Grok 設定を保持します。接続を解除するには `ocx disconnect` を実行してください。 Child を `ocx stop` で正常に停止すると、リスナーを閉じる前に、その接続が所有するジャーナルから Codex のルーティングを復元します。接続情報、キー、カタログ、その他の連携設定は保持されます。Home のルーティングを再開するには `ocx start` または `ocx service start` の後に `ocx sync --restart-codex` を実行してください。起動だけではルーティングは再適用されません。`ocx restart` の通常の引き継ぎ経路ではルーティングを保持します。所有権の競合、保留中のキー更新、変更された設定/プロファイル、または読み取り不能・未検証のジャーナル状態(サイズ超過や通常ファイル以外のジャーナルを含む)は復元失敗として報告され、復旧の証拠は保持されます。接続を再適用する前に、保持された証拠を確認して報告された競合を解決してください。クラッシュや強制終了では正常停止時の復元を実行できません。 +接続済み hub クライアントでは `ocx stop` は接続所有の Codex ルーティング、カタログ、Grok 設定を保持します。接続を解除するには `ocx disconnect` を実行してください。 Child を `ocx stop` で正常に停止すると、リスナーを閉じる前に、その接続が所有するジャーナルから Codex のルーティングを復元します。接続情報、キー、カタログ、その他の連携設定は保持されます。Home のルーティングを再開するには `ocx start` または `ocx service start` の後に `ocx sync --restart-codex` を実行してください。起動だけではルーティングは再適用されません。`ocx restart` の通常の引き継ぎ経路ではルーティングを保持します。所有権の競合、保留中のキー更新、未完了の接続解除記録(`client_disconnect_pending`)、変更された設定/プロファイル、または読み取り不能・未検証のジャーナル状態(サイズ超過や通常ファイル以外のジャーナルを含む)は復元失敗として報告され、復旧の証拠は保持されます。接続を再適用する前に、保持された証拠を確認して報告された競合を解決してください。クラッシュや強制終了では正常停止時の復元を実行できません。 互換性を確認できない古い通常プロキシは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 91b3b7fdcce..f21186e0a94 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -54,7 +54,7 @@ ocx start --socks5-off 넘어가지 않습니다. 영수증에 근거한 지연 복원도 최종 복원과 영수증 정리를 부모가 담당하며, 부모의 공유 설정 복원이 실패하면 종료 실패로 남기고 미완료 영수증을 보존합니다. -연결된 hub 클라이언트에서는 `ocx stop`이 연결 소유의 Codex 라우팅, 카탈로그와 Grok 설정을 보존합니다. 연결을 해제하려면 `ocx disconnect`를 실행하세요. Child에서 `ocx stop`으로 정상 종료하면 리스너를 닫기 전에 해당 연결이 소유한 저널을 사용해 Codex 라우팅을 복원합니다. 연결 정보, 키, 카탈로그와 다른 연동 설정은 유지됩니다. Home 라우팅을 다시 사용하려면 `ocx start` 또는 `ocx service start`를 실행한 뒤 `ocx sync --restart-codex`를 실행하세요. 시작만으로는 라우팅이 다시 적용되지 않습니다. `ocx restart`의 정상 인계 경로는 라우팅을 유지합니다. 소유권 충돌, 진행 중인 키 교체, 수정된 설정/프로필 파일 또는 읽을 수 없거나 검증되지 않은 저널 상태(크기가 너무 크거나 일반 파일이 아닌 저널 포함)는 복원 실패로 보고하고 복구 근거를 보존합니다. 연결을 다시 적용하기 전에 보존된 복구 근거를 확인하여 보고된 충돌을 해결하세요. 프로세스 비정상 종료나 강제 종료 시에는 정상 종료 복원이 실행되지 않습니다. +연결된 hub 클라이언트에서는 `ocx stop`이 연결 소유의 Codex 라우팅, 카탈로그와 Grok 설정을 보존합니다. 연결을 해제하려면 `ocx disconnect`를 실행하세요. Child에서 `ocx stop`으로 정상 종료하면 리스너를 닫기 전에 해당 연결이 소유한 저널을 사용해 Codex 라우팅을 복원합니다. 연결 정보, 키, 카탈로그와 다른 연동 설정은 유지됩니다. Home 라우팅을 다시 사용하려면 `ocx start` 또는 `ocx service start`를 실행한 뒤 `ocx sync --restart-codex`를 실행하세요. 시작만으로는 라우팅이 다시 적용되지 않습니다. `ocx restart`의 정상 인계 경로는 라우팅을 유지합니다. 소유권 충돌, 진행 중인 키 교체, 미완료 연결 해제 영수증(`client_disconnect_pending`), 수정된 설정/프로필 파일 또는 읽을 수 없거나 검증되지 않은 저널 상태(크기가 너무 크거나 일반 파일이 아닌 저널 포함)는 복원 실패로 보고하고 복구 근거를 보존합니다. 연결을 다시 적용하기 전에 보존된 복구 근거를 확인하여 보고된 충돌을 해결하세요. 프로세스 비정상 종료나 강제 종료 시에는 정상 종료 복원이 실행되지 않습니다. 보존 기능을 확인할 수 없는 오래된 일반 프록시는 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index c10469ef691..79f1a117f63 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -90,9 +90,10 @@ restores only that connection's Codex journal before the listener closes; its co catalog and other integrations remain in place. After a Child stop, run `ocx start` (or `ocx service start`), then `ocx sync --restart-codex` to resume Home routing. Client start does not re-inject routing. The bound `ocx restart` handoff preserves it. Use `ocx disconnect` to release the -connection. Conflicting ownership, pending key rotation, edited configuration/profile files, or -unreadable or unverified journal state (including oversized or nonregular journal files) produce a -restoration failure and retain recovery evidence. Resolve the reported conflict using that preserved +connection. Conflicting ownership, pending key rotation, an incomplete disconnect receipt +(`client_disconnect_pending`), edited configuration/profile files, or unreadable or unverified +journal state (including oversized or nonregular journal files) produce a restoration failure and +retain recovery evidence. Resolve the reported conflict using that preserved evidence before reapplying the connection. Abrupt crashes and forced termination cannot run orderly cleanup. A connected stop verifies the local runtime before changing its service: a client-role listener with the matching PID is supported, while an ordinary proxy must explicitly advertise connected-client ownership handling diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index cfc236e1d2a..81bac191929 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -54,7 +54,7 @@ ocx start --socks5-off установлена managed background service, `ocx stop` сначала останавливает и её, чтобы она не перезапустила прокси обратно. Кнопка **Stop** в веб-дашборде выполняет то же действие (`POST /api/stop`) на всех бэкендах, кроме планировщика заданий Windows: там обёртка может перезапустить прокси после завершения задачи, поэтому дашборд отказывает с `respawnable_service`, ничего не меняет и просит выполнить `ocx stop`. -На подключённом клиенте hub команда `ocx stop` сохраняет маршрутизацию Codex, каталог и настройки Grok, принадлежащие соединению. Для отключения выполните `ocx disconnect`. При штатной остановке Child командой `ocx stop` маршрутизация Codex восстанавливается из журнала, принадлежащего этому соединению, до закрытия слушателя. Соединение, ключ, каталог и остальные интеграции сохраняются. Чтобы снова направлять запросы в Home, выполните `ocx start` или `ocx service start`, затем `ocx sync --restart-codex`: один запуск не применяет маршрутизацию повторно. Штатная передача управления через `ocx restart` сохраняет маршрутизацию. Конфликт владельца, незавершённая смена ключа, изменённые файлы конфигурации или профиля, а также недоступный для чтения или непроверенный журнал (включая слишком большой журнал или объект, не являющийся обычным файлом) приводят к ошибке восстановления с сохранением данных для восстановления. Перед повторным применением соединения изучите сохранённые данные и устраните указанный конфликт. Аварийное завершение или принудительная остановка не выполняют штатное восстановление. +На подключённом клиенте hub команда `ocx stop` сохраняет маршрутизацию Codex, каталог и настройки Grok, принадлежащие соединению. Для отключения выполните `ocx disconnect`. При штатной остановке Child командой `ocx stop` маршрутизация Codex восстанавливается из журнала, принадлежащего этому соединению, до закрытия слушателя. Соединение, ключ, каталог и остальные интеграции сохраняются. Чтобы снова направлять запросы в Home, выполните `ocx start` или `ocx service start`, затем `ocx sync --restart-codex`: один запуск не применяет маршрутизацию повторно. Штатная передача управления через `ocx restart` сохраняет маршрутизацию. Конфликт владельца, незавершённая смена ключа, незавершённая запись об отключении (`client_disconnect_pending`), изменённые файлы конфигурации или профиля, а также недоступный для чтения или непроверенный журнал (включая слишком большой журнал или объект, не являющийся обычным файлом) приводят к ошибке восстановления с сохранением данных для восстановления. Перед повторным применением соединения изучите сохранённые данные и устраните указанный конфликт. Аварийное завершение или принудительная остановка не выполняют штатное восстановление. Старая обычная версия прокси без подтверждённой поддержки сохранения отклоняется до остановки службы или процесса. Обновите и перезапустите её через управляющий супервизор, затем повторите команду. diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 823597f4319..7e212dc2b9e 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -56,7 +56,7 @@ yeniden oluşturamaması için önce onu da durdurur. Web kontrol panelinin **Du Bağlı bir hub istemcisinde `ocx stop`, bağlantının sahip olduğu Codex yönlendirmesini, kataloğu ve Grok yapılandırmasını korur. Remote Link Child normal biçimde durdurulduğunda, dinleyici kapanmadan önce yalnızca o bağlantının Codex günlüğü geri yüklenir; bağlantı, anahtar, katalog ve diğer entegrasyonlar korunur. Home yönlendirmesini yeniden kullanmak için `ocx start` veya `ocx service start`, ardından `ocx sync --restart-codex` çalıştırın; yalnızca başlatmak yönlendirmeyi yeniden uygulamaz. `ocx restart` komutunun normal devir işlemi yönlendirmeyi korur. Bağlantıyı bırakmak için `ocx disconnect` kullanın. -Sahiplik çakışması, bekleyen anahtar yenileme, değiştirilmiş yapılandırma/profil dosyaları veya okunamayan ya da doğrulanmamış günlük durumu (aşırı büyük veya normal dosya olmayan günlükler dahil) geri yükleme hatasına yol açar ve kurtarma kanıtlarını korur. Bağlantıyı yeniden uygulamadan önce bu kanıtları inceleyip bildirilen çakışmayı çözün. Çökme veya zorla sonlandırma normal kapatma temizliğini çalıştıramaz. +Sahiplik çakışması, bekleyen anahtar yenileme, tamamlanmamış bağlantı kesme kaydı (`client_disconnect_pending`), değiştirilmiş yapılandırma/profil dosyaları veya okunamayan ya da doğrulanmamış günlük durumu (aşırı büyük veya normal dosya olmayan günlükler dahil) geri yükleme hatasına yol açar ve kurtarma kanıtlarını korur. Bağlantıyı yeniden uygulamadan önce bu kanıtları inceleyip bildirilen çakışmayı çözün. Çökme veya zorla sonlandırma normal kapatma temizliğini çalıştıramaz. ### `ocx restart` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 9d0e7a7053b..cd56d8a5ddf 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -30,7 +30,7 @@ ocx start --socks5-off 停止正在运行的代理(按 PID),移除 PID 文件;独立运行的所有者还会恢复原生 Codex。如果安装了受管后台服务,`ocx stop` 还会先停止该服务,这样它就无法重新拉起代理。Web 仪表盘的 **Stop** 按钮在多数后端执行同样的操作(`POST /api/stop`),但 Windows 任务计划程序除外:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口,因此仪表盘会以 `respawnable_service` 拒绝、不做任何更改,并提示改用 `ocx stop`。 -已连接的 hub 客户端在 `ocx stop` 时保留连接拥有的 Codex 路由、目录和 Grok 配置。要解除连接,请运行 `ocx disconnect`。 在 Child 上通过 `ocx stop` 正常停止时,会在关闭监听器之前,仅从该连接拥有的日志恢复 Codex 路由。连接信息、密钥、目录和其他集成配置保持不变。要恢复 Home 路由,请先运行 `ocx start` 或 `ocx service start`,再运行 `ocx sync --restart-codex`;仅启动不会重新应用路由。`ocx restart` 的正常交接路径会保留路由。所有权冲突、待完成的密钥轮换、已修改的配置或配置档文件,以及无法读取或未经验证的恢复日志状态(包括过大或不是常规文件的日志)都会报告恢复失败并保留恢复依据。重新应用连接之前,请检查保留的恢复依据并解决报告的冲突。崩溃或强制终止无法执行正常停止时的恢复。 +已连接的 hub 客户端在 `ocx stop` 时保留连接拥有的 Codex 路由、目录和 Grok 配置。要解除连接,请运行 `ocx disconnect`。 在 Child 上通过 `ocx stop` 正常停止时,会在关闭监听器之前,仅从该连接拥有的日志恢复 Codex 路由。连接信息、密钥、目录和其他集成配置保持不变。要恢复 Home 路由,请先运行 `ocx start` 或 `ocx service start`,再运行 `ocx sync --restart-codex`;仅启动不会重新应用路由。`ocx restart` 的正常交接路径会保留路由。所有权冲突、待完成的密钥轮换、未完成的断开连接回执(`client_disconnect_pending`)、已修改的配置或配置档文件,以及无法读取或未经验证的恢复日志状态(包括过大或不是常规文件的日志)都会报告恢复失败并保留恢复依据。重新应用连接之前,请检查保留的恢复依据并解决报告的冲突。崩溃或强制终止无法执行正常停止时的恢复。 若旧版普通代理无法确认支持保留配置,命令会在停止服务或进程前拒绝操作。请通过管理该运行时的应用或服务更新并重启它,然后重试。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index ce3ecd8f67b..690ab0d02db 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -32,7 +32,7 @@ ocx start --socks5-off 已連線的 hub 用戶端在 `ocx stop` 時會保留連線擁有的 Codex 路由、目錄和 Grok 設定。Remote Link Child 正常停止時,會在關閉監聽器前,僅從該連線擁有的日誌還原 Codex 路由;連線資訊、金鑰、目錄和其他整合設定保持不變。要恢復 Home 路由,請先執行 `ocx start` 或 `ocx service start`,再執行 `ocx sync --restart-codex`;僅啟動不會重新套用路由。`ocx restart` 的正常交接會保留路由。使用 `ocx disconnect` 才會解除連線。 -擁有權衝突、待完成的金鑰輪替、已修改的設定或設定檔,以及無法讀取或未經驗證的還原日誌狀態(包括過大或不是一般檔案的日誌),都會回報還原失敗並保留復原依據。重新套用連線之前,請檢查保留的復原依據並解決回報的衝突。當機或強制終止無法執行正常停止時的清理。 +擁有權衝突、待完成的金鑰輪替、未完成的中斷連線收據(`client_disconnect_pending`)、已修改的設定或設定檔,以及無法讀取或未經驗證的還原日誌狀態(包括過大或不是一般檔案的日誌),都會回報還原失敗並保留復原依據。重新套用連線之前,請檢查保留的復原依據並解決回報的衝突。當機或強制終止無法執行正常停止時的清理。 ### `ocx restart` From 74d1b981c6d740ed0f980e0666d5d4234a50b4cb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 14:09:11 +0900 Subject: [PATCH 06/13] fix(link): preserve handled-signal exit wiring --- src/client/runtime.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 4bf3e91ac2d..2519c8d4985 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -491,7 +491,8 @@ export async function startClientRuntime( cleanup(); await flushLinkDiagnostics(); // launchd relaunches only unsuccessful exits; an external signal must still count. - process.exit(restored?.success === false ? 1 : handledSignalExitCode(signal)); + if (restored?.success === false) process.exit(1); + else process.exit(handledSignalExitCode(signal)); } })(); }; From 165b689770bbc80d8144b1bd2ed8cbe9335252ea Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 16:27:33 +0900 Subject: [PATCH 07/13] fix(link): allow catalog sync before signal restoration --- src/client/restore-ownership.ts | 16 ++++++++--- structure/remote-link.md | 2 +- structure/runtime.md | 4 +-- tests/clients/client-link-stop.test.ts | 33 ++++++++++++++++++----- tests/fixtures/client-link-stop-runner.ts | 32 +++++++++++++++++++--- 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/client/restore-ownership.ts b/src/client/restore-ownership.ts index a89a769fdcc..4ca5d24741d 100644 --- a/src/client/restore-ownership.ts +++ b/src/client/restore-ownership.ts @@ -1,5 +1,5 @@ import { withConfigMutationLockSync } from "../config"; -import { assertClientConnectionUnchanged, assertNoClientDisconnectPending, pendingClientConnectMayOwnToken, readClientConnectionState } from "./state"; +import { assertNoClientDisconnectPending, pendingClientConnectMayOwnToken, readClientConnectionState } from "./state"; import type { OcxClientConnectionConfig } from "../types"; export type RestoreBoundaryOwnership = "restore" | "hub-owned" | "unproven"; @@ -22,9 +22,16 @@ export function inspectStopRestoreOwnership(read = readClientConnectionState): R } catch { return "unproven"; } } +function restoreConnectionSnapshot(connection: OcxClientConnectionConfig) { + // Normal sync changes these observations, not restoration ownership. All other + // fields, including priorCatalog and future connection fields, remain guarded. + const { catalogFingerprint: _fingerprint, catalogSyncedAt: _syncedAt, ...snapshot } = connection; + return snapshot; +} + /** C is reentrant inside N/C; this never acquires L or N. No lease crosses an await. */ export function stopRestoreWriteGuard(expectedConnection?: OcxClientConnectionConfig) { - const expected = expectedConnection && structuredClone(expectedConnection); + const expected = expectedConnection && restoreConnectionSnapshot(structuredClone(expectedConnection)); let boundary: RestoreBoundaryOwnership = "restore"; return { boundary: () => boundary, @@ -37,7 +44,10 @@ export function stopRestoreWriteGuard(expectedConnection?: OcxClientConnectionCo if (boundary === "restore" && expected) { try { if (expected.pendingOperation) throw new Error("client_rotation_pending"); - assertClientConnectionUnchanged(expected); + const current = readClientConnectionState(); + if (current.kind !== "connected" || !Bun.deepEquals(restoreConnectionSnapshot(current.value), expected, true)) { + throw new Error("client_connection_changed"); + } } catch { boundary = "unproven"; } } if (boundary !== "restore") throw new StopRestoreOwnershipError(boundary); diff --git a/structure/remote-link.md b/structure/remote-link.md index 11d26b48101..8e90dd87265 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -1,6 +1,6 @@ # Remote Link -Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start and run `ocx sync --restart-codex`; start alone does not reapply routing. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. +Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start and run `ocx sync --restart-codex`; start alone does not reapply routing. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. `src/link/` owns the building blocks for linking OpenCodex machines over SSH. The pure modules start no process, open no socket and schedule no timer on import. diff --git a/structure/runtime.md b/structure/runtime.md index 3f80da8e068..24d72018a0c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -175,9 +175,9 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- `ocx start` refuses a duplicate PID, starts the proxy, writes `~/.opencodex/ocx.pid` and `runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves -until shutdown. Standalone shutdown restores native Codex; connected hub clients preserve connection-owned Codex routing, catalog and Grok config across `ocx stop`/`ocx start`. An ordinary runtime rechecks persisted connection ownership in its exit cleanup: a connected Remote Link Child remains subject to the existing native Codex/Grok restoration decisions. Graceful signal shutdown runs that synchronous cleanup after draining and before releasing the listener. Sibling, recycling and service-managed exit exclusions remain intact; receipt-backed API stop may explicitly defer shared restoration to the stopping client. Only an accepted receipt deferral in `src/server/stop-teardown.ts` latches that process's exit deferral; CLI exit cleanup keeps native Codex and Grok restoration with the parent, while still stopping background owners and removing runtime records. Client start does not re-inject routing: use `ocx sync --restart-codex` to reapply it, or `ocx disconnect` to release connection ownership. A sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets 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. +until shutdown. Standalone shutdown restores native Codex; connected hub clients preserve connection-owned Codex routing, catalog and Grok config across `ocx stop`/`ocx start`. An ordinary runtime rechecks persisted connection ownership in its exit cleanup: a connected Remote Link Child remains subject to the existing native Codex/Grok restoration decisions. Graceful signal shutdown runs that synchronous cleanup after draining and before releasing the listener. Sibling, recycling and service-managed exit exclusions remain intact; receipt-backed API stop may explicitly defer shared restoration to the stopping client. Only an accepted receipt deferral in `src/server/stop-teardown.ts` latches that process's exit deferral; CLI exit cleanup keeps native Codex and Grok restoration with the parent, while still stopping background owners and removing runtime records. Client start does not re-inject routing: use `ocx sync --restart-codex` to reapply it, or `ocx disconnect` to release connection ownership. A sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; scheduler failure diagnostics follow the [service command contract](ops/service-and-sidecars.md#background-service-command-selection). Explicit service stop and -uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. +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. diff --git a/tests/clients/client-link-stop.test.ts b/tests/clients/client-link-stop.test.ts index e02ceefe3a8..16504d06227 100644 --- a/tests/clients/client-link-stop.test.ts +++ b/tests/clients/client-link-stop.test.ts @@ -30,7 +30,7 @@ async function runStop(options: Record) { } else expect({ exit, stderr }, stdout).toEqual({ exit: 0, stderr: "" }); const report = JSON.parse(readFileSync(join(home, "result.json"), "utf8")); const atExit = JSON.parse(readFileSync(join(home, "exit.json"), "utf8")); - for (const field of ["after", "profileAfter", "journalRetained", "catalogAfter", "grokAfter", "protectedUnchanged"]) { + for (const field of ["after", "profileAfter", "journalRetained", "journalHomeBound", "catalogAfter", "grokAfter", "protectedUnchanged"]) { expect(atExit[field], `no late restoration after listener release: ${field}`).toEqual(report[field]); } return report; @@ -54,10 +54,26 @@ for (const signal of process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SI }, 20_000); } -test.skipIf(process.platform === "win32")("a real POSIX SIGTERM restores before listener release", async () => { - const report = await runStop({ mode: "runtime", osSignal: true }); +test.skipIf(process.platform === "win32")("a real POSIX SIGTERM restores after catalog sync before listener release", async () => { + const report = await runStop({ mode: "runtime", osSignal: true, syncBeforeStop: true }); + expect(report.syncEvidence).toEqual({ changedFields: ["catalogFingerprint", "catalogSyncedAt"], fullCasRefused: true, catalogWritten: true, injected: true, journalHomeBound: true }); expect(report.after).toBe('model = "native"\n'); - expect(report.journalRetained).toBe(false); + expect(report.profileAfter).toBeNull(); + expect(report.journalHomeBound).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("native Link signal restores after real catalog sync without weakening the full snapshot CAS", async () => { + const report = await runStop({ mode: "runtime", syncBeforeStop: true }); + expect(report.syncEvidence).toEqual({ changedFields: ["catalogFingerprint", "catalogSyncedAt"], fullCasRefused: true, catalogWritten: true, injected: true, journalHomeBound: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + // Reinjection preserves the first hash: field-level restore retains its recovery + // snapshot but releases the home binding, rather than deleting recovery evidence. + expect(report.journalRetained).toBe(true); + expect(report.journalHomeBound).toBe(false); + expect(report.protectedUnchanged).toBe(true); expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); expect(report.grokAfter).toBe('theme = "fixture"\n'); }, 20_000); @@ -182,13 +198,18 @@ test("a scheduled recycle cannot skip restoration or spawn after signal shutdown expect(report.grokAfter).toBe('theme = "fixture"\n'); }, 20_000); -for (const mutation of ["connection", "selection", "rotation"]) { +for (const mutation of ["connection", "selection", "rotation", "hub", "prior-catalog", "api-key", "token", "management", "link-id"]) { test(`served-connection guard rechecks ${mutation} at each write and freezes its snapshot`, async () => { const report = await runStop({ mode: "guard", mutation }); - expect(report.result).toEqual({ writes: ["before-change"], refused: true, boundary: "unproven" }); + expect(report.result).toEqual({ writes: ["before-change"], refused: true, boundary: mutation === "hub" ? "hub-owned" : "unproven" }); expect(report.after).toBe(report.before); expect(report.journalRetained).toBe(true); expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); expect(report.grokAfter).toBe(report.grokBefore); }); } + +test("served-connection guard permits catalog sync metadata at a later write", async () => { + const report = await runStop({ mode: "guard", mutation: "catalog" }); + expect(report.result).toEqual({ writes: ["before-change", "after-change"], refused: false, boundary: "restore" }); +}); diff --git a/tests/fixtures/client-link-stop-runner.ts b/tests/fixtures/client-link-stop-runner.ts index c2099b2189c..5aee34d0620 100644 --- a/tests/fixtures/client-link-stop-runner.ts +++ b/tests/fixtures/client-link-stop-runner.ts @@ -15,9 +15,10 @@ const options = JSON.parse(readFileSync(0, "utf8")) as { transport?: "link" | "hub"; signal?: NodeJS.Signals; osSignal?: boolean; + syncBeforeStop?: boolean; repeat?: boolean; preserve?: "service" | "desktop" | "receipt" | "sibling" | "restart" | "restart-rollback" | "recycle-pending" | "restart-rollback-recycle"; - mutation?: "connection" | "journal" | "process-journal" | "config" | "profile" | "unverified" | "selection" | "oversized" | "nonregular" | "rotation" | "hub"; + mutation?: "connection" | "journal" | "process-journal" | "config" | "profile" | "unverified" | "selection" | "oversized" | "nonregular" | "rotation" | "hub" | "catalog" | "prior-catalog" | "api-key" | "token" | "management" | "link-id"; }; const home = process.env.OPENCODEX_HOME!; mkdirSync(CODEX_HOME, { recursive: true }); @@ -64,6 +65,12 @@ const grokPath = join(grokHome, "config.toml"); const grokNative = 'theme = "fixture"\n'; writeFileSync(grokPath, grokNative + '\n# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:10100/v1"\n# <<< opencodex managed block <<<\n'); const mutate = () => { + if (options.mutation === "catalog") { config.client!.catalogFingerprint = "synced"; config.client!.catalogSyncedAt = "2026-10-31T00:00:00.000Z"; saveConfig(config); } + if (options.mutation === "prior-catalog") { config.client!.priorCatalog = ""; saveConfig(config); } + if (options.mutation === "api-key") { config.client!.apiKeyId = "other-client"; saveConfig(config); } + if (options.mutation === "token") { config.client!.tokenFingerprint = serviceApiTokenFingerprint("different-token"); saveConfig(config); } + if (options.mutation === "management") { config.client!.managementUrl = "https://other.example.test"; saveConfig(config); } + if (options.mutation === "link-id") { config.client!.link!.linkId = "lnk_fedcba9876543210"; saveConfig(config); } if (options.mutation === "connection") { config.client!.connectedAt = "2026-10-02T00:00:00.000Z"; saveConfig(config); } if (options.mutation === "selection") { config.client!.selectedClients = ["claude"]; saveConfig(config); } if (options.mutation === "hub") { @@ -97,10 +104,13 @@ const grokBefore = readFileSync(grokPath, "utf8"); const configContents = () => statSync(CODEX_CONFIG_PATH).isFile() ? readFileSync(CODEX_CONFIG_PATH, "utf8") : ""; const before = configContents(); const profileBefore = readFileSync(CODEX_PROFILE_PATH, "utf8"); +let syncEvidence: { changedFields: string[]; fullCasRefused: boolean; catalogWritten: boolean; injected: boolean; journalHomeBound: boolean } | undefined; const snapshot = (result: unknown) => ({ - result, before, profileBefore, after: configContents(), + result, before, profileBefore, syncEvidence, after: configContents(), profileAfter: existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf8") : null, - journalRetained: existsSync(JOURNAL_PATH), protectedUnchanged: protectedPaths.every((path, index) => readFileSync(path, "utf8") === protectedBefore[index]), + journalRetained: existsSync(JOURNAL_PATH), + journalHomeBound: existsSync(JOURNAL_PATH) && typeof JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).opencodexHome === "string", + protectedUnchanged: protectedPaths.every((path, index) => readFileSync(path, "utf8") === protectedBefore[index]), catalogBefore, catalogAfter: JSON.parse(readFileSync(DEFAULT_CATALOG_PATH, "utf8")), grokBefore, grokAfter: readFileSync(grokPath, "utf8"), }); @@ -139,6 +149,22 @@ if (options.mode === "guard") { server.stop = ((close: boolean) => { report({ atListenerStop: true }); return stop(close); }) as typeof server.stop; return server; } }); + if (options.syncBeforeStop) { + const { syncConnectedClient } = await import("../../src/client/connect"); + const { assertClientConnectionUnchanged } = await import("../../src/client/state"); + const synced = await syncConnectedClient({}, { + fetchImpl: (async () => Response.json({ models: [{ slug: "fixture/synced" }] })) as typeof fetch, + now: () => new Date("2026-10-31T00:00:00.000Z"), + catalogCompatibility: { supportedEfforts: () => new Set(["low", "medium", "high"]) }, + linkHome: { fetchImpl: (async () => Response.json({}, { status: 404 })) as typeof fetch }, + }); + const keys = new Set([...Object.keys(expected), ...Object.keys(synced.connection)]); + const changedFields = [...keys].filter(key => !Bun.deepEquals(Reflect.get(expected, key), Reflect.get(synced.connection, key))).sort(); + let fullCasRefused = false; + try { assertClientConnectionUnchanged(expected); } catch { fullCasRefused = true; } + syncEvidence = { changedFields, fullCasRefused, catalogWritten: synced.catalogWritten, injected: synced.injected, + journalHomeBound: typeof JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).opencodexHome === "string" }; + } mutate(); protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); if (options.preserve === "restart") { From e4c3df5508f4db07e976ee48be4df3fddebf93da Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 17:15:15 +0900 Subject: [PATCH 08/13] fix(link): fence sync commits during signal shutdown --- src/client/connect.ts | 45 +++++++--- src/client/link-home.ts | 5 +- src/client/runtime.ts | 17 +++- src/client/stop-routing.ts | 3 +- src/client/sync-runtime.ts | 48 +++++++++++ src/config/process-state.ts | 3 + structure/remote-link.md | 2 + structure/runtime.md | 2 +- tests/clients/client-link-connect.test.ts | 13 +++ tests/clients/client-link-stop.test.ts | 100 +++++++++++++++++++++- tests/fixtures/client-link-stop-runner.ts | 22 ++++- tests/fixtures/client-link-sync-runner.ts | 37 ++++++++ 12 files changed, 273 insertions(+), 24 deletions(-) create mode 100644 src/client/sync-runtime.ts create mode 100644 tests/fixtures/client-link-sync-runner.ts diff --git a/src/client/connect.ts b/src/client/connect.ts index 81f7f8ff516..00bb058891c 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -18,7 +18,8 @@ import { withClientLifecycle, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps, } from "./lifecycle-lock"; -import { invalidateCodexModelsCache } from "../codex/catalog/sync"; +import { invalidateCodexModelsCache, invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; +import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { injectCodexConfig, currentExternalCodexModelProvider, @@ -30,7 +31,7 @@ import { journalOwner, restoreJournalState, } from "../codex/journal"; -import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { DEFAULT_CATALOG_PATH, getCodexHome } from "../codex/paths"; import { readServiceApiTokenState, readTokenBackupState, @@ -95,6 +96,7 @@ import { import { hubStateCachePath } from "./hub-state"; import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state"; import { isLinkPort } from "../link/ports"; +import { clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime"; class RotationRecoveryRequiredError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -901,7 +903,15 @@ async function syncConnectedClientUnlocked( if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("client_sync_unavailable"); const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) throw new Error("client_token_changed"); - return { connection: state.value, token }; + let assertRuntimeCurrent = () => {}; + try { if (isLinkConnection(state.value)) assertRuntimeCurrent = clientSyncRuntimeGuard(); } + catch (error) { + if (options.expect && error instanceof ClientSyncRuntimeError && error.code === "client_runtime_stopping") { + throw new ClientSyncExpectationError(options.expect, "pending_operation"); + } + throw error; + } + return { connection: state.value, token, assertRuntimeCurrent }; }), deps.lifecycleLockDeps); let downloaded: Awaited> | undefined; let stale = false; @@ -918,6 +928,7 @@ async function syncConnectedClientUnlocked( // they were, which is the known-good state. if (downloaded) assertClientCatalogCompatible(downloaded.body, deps.catalogCompatibility); const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(initial.connection); const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== initial.token.fingerprint) throw new Error("client_token_changed"); @@ -933,10 +944,12 @@ async function syncConnectedClientUnlocked( }), deps.lifecycleLockDeps); // Read-only: injection invokes this while N/C are held. Acquiring L here would invert C→L. const beforeClientWrite = () => { + initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(next); const token = readServiceApiTokenState(); if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed"); }; + const withClientWrite = (write: () => T): T => withConfigMutationLockSync(() => { beforeClientWrite(); return write(); }); let injected = false; let homeSettings: HomeCodexSettingsSource | undefined; let appliedRecord: HomeCodexSettingsRecord | undefined; @@ -945,12 +958,15 @@ async function syncConnectedClientUnlocked( let config = loadConfig(); // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog. if (isLinkConnection(next)) { - homeSettings = await resolveHomeCodexSettings(next, initial.token.token, deps.linkHome); + homeSettings = await resolveHomeCodexSettings(next, initial.token.token, { ...deps.linkHome, withWriteGuard: withClientWrite }); if (homeSettings.source !== "local") config = overlayHomeCodexSettings(config, homeSettings.document.settings); // About to write different settings: until their record commits, report no applied digest // rather than the previous one (OCX-6). A failed injection then reads "unknown", never a // digest of values Codex no longer has or never got. - if (homeSettings.source === "home" && next.link) withdrawChangingHomeCodexSettingsRecord(next.link.linkId, homeSettings.document); + if (homeSettings.source === "home" && next.link) { + const { linkId } = next.link, { document } = homeSettings; + withClientWrite(() => withdrawChangingHomeCodexSettingsRecord(linkId, document)); + } } const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH, @@ -962,23 +978,24 @@ async function syncConnectedClientUnlocked( const linkId = next.link.linkId; const settings = homeSettings; try { - appliedRecord = withClientLifecycleSync(() => { - beforeClientWrite(); - return recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined; - }, deps.lifecycleLockDeps); + appliedRecord = withClientLifecycleSync(() => withClientWrite(() => + recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined), deps.lifecycleLockDeps); } catch (error) { // An ownership change is not a disk problem: report it as itself. - if (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed")) throw error; + if (error instanceof ClientSyncRuntimeError || (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed"))) throw error; // The injection committed, so the cache refresh below still runs; the caller reports this. - try { clearHomeCodexSettingsRecord(); } catch { /* status already reads no recorded apply or reports the write */ } + try { withClientWrite(clearHomeCodexSettingsRecord); } catch { /* status already reads no recorded apply or reports the write */ } applyRecordFailed = true; } } } const cacheSynced = withClientLifecycleSync(() => { - beforeClientWrite(); - // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held. - return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); + // Acquire K before the guarded C commit; never acquire K from inside C. + const owningHome = getCodexHome(); + const result = withCatalogWriteSerialization(owningHome, permit => withClientWrite(() => + invalidateCodexModelsCacheWithPermit(permit, owningHome, { allowWhenDesiredDisabled: true })), + { intent: "cache", writer: "cache-invalidate" }); + return result.kind === "completed" && result.value; }, deps.lifecycleLockDeps); return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, diff --git a/src/client/link-home.ts b/src/client/link-home.ts index a3cf4b49343..3452e602ee1 100644 --- a/src/client/link-home.ts +++ b/src/client/link-home.ts @@ -299,7 +299,7 @@ export type HomeCodexSettingsSource = export async function resolveHomeCodexSettings( connection: LinkHomeConnection, admissionKey: string, - deps: LinkHomeDeps & { now?: () => Date } = {}, + deps: LinkHomeDeps & { now?: () => Date; withWriteGuard?: (write: () => T) => T } = {}, ): Promise { const linkId = connection.link?.linkId; if (!linkId) return { source: "local", reason: "not a Remote Link Child" }; @@ -308,7 +308,8 @@ export async function resolveHomeCodexSettings( return { source: "home", document: fetched.document, fetchedAt: (deps.now ?? (() => new Date()))().toISOString() }; } if (fetched.kind === "unsupported") { - clearHomeCodexSettingsRecord(); + if (deps.withWriteGuard) deps.withWriteGuard(clearHomeCodexSettingsRecord); + else clearHomeCodexSettingsRecord(); return { source: "local", reason: "the Home does not publish Codex settings; upgrade the Home" }; } const cached = readHomeCodexSettingsRecord(linkId); diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 2519c8d4985..52d71624c4c 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -32,6 +32,7 @@ import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; +import { markClientRuntimeStopping } from "./sync-runtime"; let activeServer: Server | null = null; let activePort: number | null = null; @@ -369,7 +370,7 @@ export async function startClientRuntime( supervisor?.start(); credentialObservation ??= observeLocalCredentials(); writePid(process.pid); - writeRuntimePort(clientRuntimeRecord(process.pid, boundPort, attestationSecret)); + writeRuntimePort({ ...clientRuntimeRecord(process.pid, boundPort, attestationSecret), ...(clientStopping ? { clientStopping: true as const } : {}) }); if (linkMode && state.value.link && linkKey) { activeReporter = createLinkParityReporter({ connection: state.value, linkKey, runtimeStartedAt: CLIENT_RUNTIME_STARTED_AT }); activeReporter.start(); @@ -466,11 +467,19 @@ export async function startClientRuntime( shuttingDown = true; clientStopping = true; if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = undefined; } + const fenceSync = () => { + if (!linkMode) return true; + try { markClientRuntimeStopping({ pid: process.pid, port: servedPort, attestationSecret }); return true; } + catch { return false; } + }; + let syncFenced = fenceSync(); void (async () => { // An accepted restart owns its replacement and rollback. A successful handoff exits // inside that flight; a rollback finishes before this signal tears the runtime down. if (restartController.hasHandoff()) await restartController.settled().catch(() => {}); if (recycleFlight) await recycleFlight.catch(() => {}); + // A rollback may have republished its runtime record while shutdown joined it. + syncFenced = fenceSync(); credentialObservation?.stop(); credentialObservation = null; activeReporter?.stop(); @@ -481,15 +490,15 @@ export async function startClientRuntime( console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); } activeSupervisor = null; - const restored = linkMode ? restoreLinkClientRouting(state.value) : null; + const restored = linkMode ? restoreLinkClientRouting(state.value, { syncFenced }) : null; if (restored && !restored.success) console.error(`[client] ${restored.message}`); try { activeServer?.stop(true); } catch (error) { console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`); } finally { - cleanup(); - await flushLinkDiagnostics(); + // Keep stopping admission visible through the last await. Cleanup and exit are adjacent. + try { await flushLinkDiagnostics(); } finally { cleanup(); } // launchd relaunches only unsuccessful exits; an external signal must still count. if (restored?.success === false) process.exit(1); else process.exit(handledSignalExitCode(signal)); diff --git a/src/client/stop-routing.ts b/src/client/stop-routing.ts index 1f20f056748..28490d7d6f0 100644 --- a/src/client/stop-routing.ts +++ b/src/client/stop-routing.ts @@ -22,7 +22,7 @@ const refusal = (reason: string): LinkRoutingRestoreResult => ({ }); /** Unsupervised signal cleanup; explicit parent stop and restart retain their own restoration. */ -export function restoreLinkClientRouting(expected: OcxClientConnectionConfig): LinkRoutingRestoreResult { +export function restoreLinkClientRouting(expected: OcxClientConnectionConfig, options: { syncFenced?: boolean } = {}): LinkRoutingRestoreResult { if (siblingOfLivePort() !== null) return { success: true, restored: false, message: siblingSkipMessage() }; if (!isLinkConnection(expected)) { return { success: true, restored: false, message: "Hub connection-owned integrations preserved." }; @@ -30,6 +30,7 @@ export function restoreLinkClientRouting(expected: OcxClientConnectionConfig): L const preserve = () => process.env.OCX_SERVICE === "1" || isDesktopSupervised() || pendingTeardownOutstanding(); if (preserve()) return { success: true, restored: false, message: "Supervised restart or pending teardown owns the client integrations." }; + if (options.syncFenced === false) return refusal("the runtime sync fence could not be verified"); const guard = stopRestoreWriteGuard(expected); const withWriteGuard = (write: () => T): T => guard.run(() => { if (preserve()) throw new Error("restart or teardown ownership changed"); diff --git a/src/client/sync-runtime.ts b/src/client/sync-runtime.ts new file mode 100644 index 00000000000..991db129659 --- /dev/null +++ b/src/client/sync-runtime.ts @@ -0,0 +1,48 @@ +/** Cross-process admission for Link sync while its local runtime shuts down. */ +import { lstatSync } from "node:fs"; +import { withConfigMutationLockSync } from "../config"; +import { getRuntimePortPath, isValidRuntimePortState, writeRuntimePort, type RuntimePortState } from "../config/process-state"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; + +export class ClientSyncRuntimeError extends Error { + constructor(readonly code: "client_runtime_stopping" | "client_runtime_changed" | "client_runtime_unverified") { + super(`${code}: retry sync after the Child has started; ocx start/stop can reconcile a stale runtime record`); + } +} + +function readSyncRuntime(): RuntimePortState | null { + const path = getRuntimePortPath(); + try { lstatSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new ClientSyncRuntimeError("client_runtime_unverified"); + } + try { + const value: unknown = JSON.parse(readBoundedRegularFileSync(path, 16 * 1024).toString("utf8")); + if (!isValidRuntimePortState(value)) throw new Error("invalid runtime record"); + return value; + } catch { throw new ClientSyncRuntimeError("client_runtime_unverified"); } +} + +/** Capture and recheck under C. Proven no-runtime sync keeps its existing offline behavior. */ +export function clientSyncRuntimeGuard(): () => void { + const expected = readSyncRuntime(); + if (expected?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping"); + return () => { + const current = readSyncRuntime(); + if (current?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping"); + if (!Bun.deepEquals(current, expected, true)) throw new ClientSyncRuntimeError("client_runtime_changed"); + }; +} + +/** The same C transaction excludes every sync commit; never mark a replacement runtime. */ +export function markClientRuntimeStopping(expected: Required>): void { + withConfigMutationLockSync(() => { + const current = readSyncRuntime(); + if (!current || current.pid !== expected.pid || current.port !== expected.port + || current.attestationSecret !== expected.attestationSecret) { + throw new ClientSyncRuntimeError("client_runtime_changed"); + } + if (!current.clientStopping) writeRuntimePort({ ...current, clientStopping: true }); + }); +} diff --git a/src/config/process-state.ts b/src/config/process-state.ts index 4ac5191e57c..a236fb4a448 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -37,6 +37,8 @@ export type RuntimePortState = { hostname?: string; /** Per-process proof key; protected by the config directory and never served. */ attestationSecret?: string; + /** A Link runtime is draining; new and in-flight sync commits must stand down. */ + clientStopping?: 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. @@ -59,6 +61,7 @@ export function isValidRuntimePortState(value: unknown): value is RuntimePortSta && Number(state.port) <= 65535 && hostnameOk && attestationOk + && (state.clientStopping === undefined || state.clientStopping === true) && siblingOk; } diff --git a/structure/remote-link.md b/structure/remote-link.md index 8e90dd87265..2c7e70d84b0 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -1,5 +1,7 @@ # Remote Link +`src/client/sync-runtime.ts` fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as `clientStopping` under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation; after a normal stop, start the Child before syncing its routing. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. The separate-process catalog/settings-response holds in `tests/clients/client-link-stop.test.ts` cover late commits, fresh sync during shutdown and the completed-sync control. + Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start and run `ocx sync --restart-codex`; start alone does not reapply routing. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. `src/link/` owns the building blocks for linking OpenCodex machines over SSH. The pure modules start no process, open no socket and schedule no timer on import. diff --git a/structure/runtime.md b/structure/runtime.md index 24d72018a0c..e2ccb669861 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -117,7 +117,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config.ts`, `src/config/persisted-mutation.ts` | Persisted `~/.opencodex/config.json` surface: the facade keeps load/save/initialize entry points and re-exports the schema-valid mutation callback, types, and one-shot test seam; the leaf owns its bounded rebase under the shared lock and a read-only authority reader for admitted writers that starts no optional ACL flight. Schema lives in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`), defaults in `src/config/proxy-env.ts`, and replace-path persistence in `src/config/persist-unlocked.ts`. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. No-follow exclusive publication reuses its private descriptor writer for raw recovery bytes, checks bigint identity and publishes by a no-replace hard link; cleanup never scrubs an already published inode. Synchronous caller validation runs immediately before each rename attempt, including Windows retries. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. The existing-secret compliance probe in `src/lib/windows-secret-acl.ts` uses the same SID-based private-entry policy as recovery-copy reuse when the opt-in single-owner ACE fast check cannot establish compliance; recovery-copy reuse always calls the SID policy directly. Retained SYSTEM and Administrators ACEs do not trigger mutation or duplicate recovery publication. The asynchronous probe in `src/lib/windows-owner-acl.ts` yields during the bounded descriptor subprocess and shares the synchronous parser, without admitting a hardening success memo. | -| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | +| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. The optional `clientStopping: true` field fences Link sync through signal restoration and the final diagnostic flush; `src/client/sync-runtime.ts` compares the runtime generation before each sync commit. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | | `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | diff --git a/tests/clients/client-link-connect.test.ts b/tests/clients/client-link-connect.test.ts index 6b8130a9e33..25e59266a14 100644 --- a/tests/clients/client-link-connect.test.ts +++ b/tests/clients/client-link-connect.test.ts @@ -13,6 +13,8 @@ import { connectClient, routingTarget } from "../../src/client/connect"; import { readServiceApiTokenState } from "../../src/lib/service-secrets"; import { isLinkConnection, readClientConnectionState } from "../../src/client/state"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; @@ -89,12 +91,23 @@ describe("client link connection contracts", () => { const previousCodexHome = process.env.CODEX_HOME; process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = codexHome; + // These are enrollment/catalog contracts. ACL enforcement has its own native tests; + // real PowerShell/icacls work can consume the five-second semantic test budget. + let aclCalls = 0; + const aclSuccess = () => { aclCalls += 1; return { success: true, exitCode: 0, timedOut: false, stdout: "" }; }; + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + setIcaclsRunnerForTests(aclSuccess); + setAsyncIcaclsRunnerForTests(async () => aclSuccess()); try { const config = getDefaultConfig(); config.port = 10100; saveConfig(config); await run(home, codexHome); + if (process.platform === "win32") expect(aclCalls).toBeGreaterThan(0); } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setSyntheticWindowsPrincipalForTests(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; diff --git a/tests/clients/client-link-stop.test.ts b/tests/clients/client-link-stop.test.ts index 16504d06227..98c59ba4a7d 100644 --- a/tests/clients/client-link-stop.test.ts +++ b/tests/clients/client-link-stop.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fixturePath, repoPath } from "../helpers/repo-root"; @@ -213,3 +213,101 @@ test("served-connection guard permits catalog sync metadata at a later write", a const report = await runStop({ mode: "guard", mutation: "catalog" }); expect(report.result).toEqual({ writes: ["before-change", "after-change"], refused: false, boundary: "restore" }); }); + +for (const runtimeRecord of ["absent", "invalid", "foreign"]) { + test(`signal refuses restoration when its runtime record is ${runtimeRecord}`, async () => { + const report = await runStop({ mode: "runtime", runtimeRecord, expectedFailure: true }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + expect(report.protectedUnchanged).toBe(true); + }, 20_000); +} + +for (const preserve of ["service", "desktop", "receipt", "sibling"]) { + test(`${preserve} signal preservation does not require a restoration sync fence`, async () => { + const report = await runStop({ mode: "runtime", preserve, runtimeRecord: "absent" }); + expect(report.after).toBe(report.before); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }, 20_000); +} + +for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, { releaseFirst: false, hold: "home" }, { releaseFirst: true, hold: "catalog" }] as const) { + test(`a separate sync ${releaseFirst ? "finishes before" : "cannot commit after"} signal restoration (${hold})`, async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-link-sync-stop-")); + const home = join(root, "ocx"), codex = join(root, "codex"); + const children: Array<{ child: ReturnType; drained: Promise }> = []; + const spawn = (file: string, options: unknown) => { + const child = Bun.spawn([process.execPath, fixturePath(file)], { + cwd: repoPath(), stdin: new Blob([JSON.stringify(options)]), stdout: "pipe", stderr: "pipe", + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: home, CODEX_HOME: codex, OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, + }); + const drained = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); + children.push({ child, drained }); + return child; + }; + const waitFor = async (name: string) => { + const end = Date.now() + 15_000; + while (!existsSync(join(home, name)) && Date.now() < end) await Bun.sleep(10); + if (!existsSync(join(home, name))) throw new Error(`fixture did not publish ${name}`); + }; + const release = (name: string) => writeFileSync(join(home, name), "ready"); + const read = (name: string) => JSON.parse(readFileSync(join(home, name), "utf8")); + const durable = () => ({ + config: readFileSync(join(codex, "config.toml"), "utf8"), + catalog: JSON.parse(readFileSync(join(codex, "opencodex-catalog.json"), "utf8")), + profile: existsSync(join(codex, "opencodex.config.toml")), + connection: readFileSync(join(home, "config.json"), "utf8"), + token: readFileSync(join(home, "service-api-token"), "utf8"), + }); + const timer = setTimeout(() => { for (const { child } of children) if (child.exitCode === null) child.kill(); }, 35_000); + try { + const runtime = spawn("client-link-stop-runner.ts", { mode: "runtime", externalSignal: true, holdShutdown: true }); + await waitFor("ready"); + const held = spawn("client-link-sync-runner.ts", { hold, name: "held" }); + await waitFor("held-requested"); + if (releaseFirst) { + release("catalog-release"); + expect(await held.exited).toBe(0); + expect(read("held-sync-result.json")).toMatchObject({ ok: true, catalogWritten: true, injected: true }); + expect(durable().config).toContain("openai_base_url"); + expect(durable().catalog.models).toEqual([{ slug: "fixture/late-sync" }]); + } + release("signal-now"); + await waitFor("shutdown-drained"); + const restored = durable(); + expect(restored.config).toBe('model = "native"\n'); + expect(restored.profile).toBe(false); + expect(restored.catalog.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + if (releaseFirst) { + // No apply lock is held now: a fresh sync must be refused by shutdown admission. + const fresh = spawn("client-link-sync-runner.ts", { hold: false, name: "fresh" }); + expect(await fresh.exited).toBe(0); + expect(read("fresh-sync-result.json")).toMatchObject({ ok: false, fetched: false }); + expect(durable()).toEqual(restored); + } + release("shutdown-release"); + expect(await runtime.exited).toBe(0); + if (!releaseFirst) { + release("catalog-release"); + expect(await held.exited).toBe(0); + expect(read("held-sync-result.json")).toMatchObject({ ok: false, fetched: true }); + } + // Read after BOTH processes exit; the runtime's own exit snapshot cannot prove this. + expect(durable()).toEqual(restored); + if (releaseFirst) { + // A new explicit offline sync after complete shutdown remains supported. + const offline = spawn("client-link-sync-runner.ts", { hold: false, name: "fresh" }); + expect(await offline.exited).toBe(0); + expect(read("fresh-sync-result.json")).toMatchObject({ ok: true, fetched: true, injected: true }); + } + } finally { + clearTimeout(timer); + for (const { child } of children) { if (child.exitCode === null) child.kill(); await child.exited; } + await Promise.all(children.map(value => value.drained)); + removeTreeWithRetry(root); + } + }, 40_000); +} diff --git a/tests/fixtures/client-link-stop-runner.ts b/tests/fixtures/client-link-stop-runner.ts index 5aee34d0620..2e5566cf217 100644 --- a/tests/fixtures/client-link-stop-runner.ts +++ b/tests/fixtures/client-link-stop-runner.ts @@ -16,6 +16,9 @@ const options = JSON.parse(readFileSync(0, "utf8")) as { signal?: NodeJS.Signals; osSignal?: boolean; syncBeforeStop?: boolean; + externalSignal?: boolean; + holdShutdown?: boolean; + runtimeRecord?: "absent" | "invalid" | "foreign"; repeat?: boolean; preserve?: "service" | "desktop" | "receipt" | "sibling" | "restart" | "restart-rollback" | "recycle-pending" | "restart-rollback-recycle"; mutation?: "connection" | "journal" | "process-journal" | "config" | "profile" | "unverified" | "selection" | "oversized" | "nonregular" | "rotation" | "hub" | "catalog" | "prior-catalog" | "api-key" | "token" | "management" | "link-id"; @@ -133,6 +136,13 @@ if (options.mode === "guard") { protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); report({ writes, refused, boundary: guard.boundary() }); } else if (options.mode === "runtime") { + if (options.holdShutdown) { + const diagnostics = await import("../../src/link/diagnostics"); + mock.module("../../src/link/diagnostics", () => ({ ...diagnostics, flushLinkDiagnostics: async () => { + writeFileSync(join(home, "shutdown-drained"), "ready"); + while (!existsSync(join(home, "shutdown-release"))) await Bun.sleep(10); + } })); + } if (options.preserve?.startsWith("restart-rollback")) { const replacement = await import("../../src/server/restart-replacement"); mock.module("../../src/server/restart-replacement", () => ({ ...replacement, @@ -165,6 +175,12 @@ if (options.mode === "guard") { syncEvidence = { changedFields, fullCasRefused, catalogWritten: synced.catalogWritten, injected: synced.injected, journalHomeBound: typeof JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).opencodexHome === "string" }; } + if (options.runtimeRecord) { + const path = join(home, "runtime-port.json"); + if (options.runtimeRecord === "absent") unlinkSync(path); + else if (options.runtimeRecord === "invalid") writeFileSync(path, "{"); + else { const record = JSON.parse(readFileSync(path, "utf8")); record.pid += 100_000; writeFileSync(path, JSON.stringify(record)); } + } mutate(); protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); if (options.preserve === "restart") { @@ -177,7 +193,11 @@ if (options.mode === "guard") { } if (options.preserve === "recycle-pending" || options.preserve === "restart-rollback-recycle") scheduleStandaloneRecycle(expected.tokenFingerprint); // Exercises the registered production handler; it is not evidence of native OS signal delivery. - if (options.osSignal) writeFileSync(join(home, "ready"), "ready"); + if (options.externalSignal) { + writeFileSync(join(home, "ready"), "ready"); + while (!existsSync(join(home, "signal-now"))) await Bun.sleep(10); + process.emit(options.signal ?? "SIGTERM", options.signal ?? "SIGTERM"); + } else if (options.osSignal) writeFileSync(join(home, "ready"), "ready"); else process.emit(options.signal ?? "SIGTERM", options.signal ?? "SIGTERM"); } else { const result = options.mode === "api" diff --git a/tests/fixtures/client-link-sync-runner.ts b/tests/fixtures/client-link-sync-runner.ts new file mode 100644 index 00000000000..0e5519e2b4f --- /dev/null +++ b/tests/fixtures/client-link-sync-runner.ts @@ -0,0 +1,37 @@ +/** A separate CLI-like sync process; never initializes or overwrites the running fixture. */ +import { mock } from "bun:test"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { hold: "catalog" | "home" | false; name: "held" | "fresh" }; +const home = process.env.OPENCODEX_HOME!; +const lifecycle = await import("../../src/client/lifecycle-lock"); +const lock = lifecycle.withClientLifecycleSync; +mock.module("../../src/client/lifecycle-lock", () => ({ ...lifecycle, + withClientLifecycleSync: (work: Parameters[0]) => lock(work, { lockPath: join(home, "lifecycle.sqlite") }), +})); +const { syncConnectedClient } = await import("../../src/client/connect"); +let fetched = false; +let result: unknown; +const pause = async (phase: "catalog" | "home") => { + if (options.hold !== phase) return; + writeFileSync(join(home, `${options.name}-requested`), "ready"); + while (!existsSync(join(home, "catalog-release"))) await Bun.sleep(10); +}; +try { + const synced = await syncConnectedClient({}, { + fetchImpl: (async () => { + fetched = true; + // A deterministic transport seam: test ordering, not a production network deadline. + await pause("catalog"); + return Response.json({ models: [{ slug: "fixture/late-sync" }] }); + }) as typeof fetch, + now: () => new Date("2026-10-31T01:00:00.000Z"), + catalogCompatibility: { supportedEfforts: () => new Set(["low", "medium", "high"]) }, + linkHome: { fetchImpl: (async () => { await pause("home"); return Response.json({}, { status: 404 }); }) as typeof fetch }, + }); + result = { ok: true, fetched, catalogWritten: synced.catalogWritten, injected: synced.injected }; +} catch (error) { + result = { ok: false, fetched, error: error instanceof Error ? error.message : "unknown" }; +} +writeFileSync(join(home, `${options.name}-sync-result.json`), JSON.stringify(result)); From 604840ec40173f9b5adb8e37c9034f71df990ff1 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 17:22:37 +0900 Subject: [PATCH 09/13] fix(link): keep runtime publication through its record builder --- src/client/runtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 52d71624c4c..8ccc410c814 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -99,7 +99,7 @@ export function clientRuntimeRecord( port: number, attestationSecret: string = createLocalAttestationSecret(), ): RuntimePortState { - return { pid, port, hostname: "127.0.0.1", attestationSecret, ...siblingRuntimeField() }; + return { pid, port, hostname: "127.0.0.1", attestationSecret, ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}) }; } export function standaloneRecycleEnv( @@ -370,7 +370,7 @@ export async function startClientRuntime( supervisor?.start(); credentialObservation ??= observeLocalCredentials(); writePid(process.pid); - writeRuntimePort({ ...clientRuntimeRecord(process.pid, boundPort, attestationSecret), ...(clientStopping ? { clientStopping: true as const } : {}) }); + writeRuntimePort(clientRuntimeRecord(process.pid, boundPort, attestationSecret)); if (linkMode && state.value.link && linkKey) { activeReporter = createLinkParityReporter({ connection: state.value, linkKey, runtimeStartedAt: CLIENT_RUNTIME_STARTED_AT }); activeReporter.start(); From 521d14c56ad50e6a6479728a146a44b08cddc8a3 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 17:35:22 +0900 Subject: [PATCH 10/13] fix(link): retry transient stop-fence contention explicitly --- src/client/runtime.ts | 4 ++-- src/client/stop-routing.ts | 4 ++-- src/client/sync-runtime.ts | 19 +++++++++++++++- structure/remote-link.md | 2 ++ tests/clients/client-link-stop.test.ts | 20 +++++++++++++++-- tests/fixtures/client-link-stop-runner.ts | 27 +++++++++++++++++++---- tests/fixtures/client-link-sync-runner.ts | 14 +++++++++++- 7 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 8ccc410c814..0297dfbc160 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -32,7 +32,7 @@ import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; -import { markClientRuntimeStopping } from "./sync-runtime"; +import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; let activeServer: Server | null = null; let activePort: number | null = null; @@ -479,7 +479,7 @@ export async function startClientRuntime( if (restartController.hasHandoff()) await restartController.settled().catch(() => {}); if (recycleFlight) await recycleFlight.catch(() => {}); // A rollback may have republished its runtime record while shutdown joined it. - syncFenced = fenceSync(); + syncFenced = !linkMode || await markClientRuntimeStoppingWithRetry({ pid: process.pid, port: servedPort, attestationSecret }); credentialObservation?.stop(); credentialObservation = null; activeReporter?.stop(); diff --git a/src/client/stop-routing.ts b/src/client/stop-routing.ts index 28490d7d6f0..f3a5377e9ee 100644 --- a/src/client/stop-routing.ts +++ b/src/client/stop-routing.ts @@ -22,7 +22,7 @@ const refusal = (reason: string): LinkRoutingRestoreResult => ({ }); /** Unsupervised signal cleanup; explicit parent stop and restart retain their own restoration. */ -export function restoreLinkClientRouting(expected: OcxClientConnectionConfig, options: { syncFenced?: boolean } = {}): LinkRoutingRestoreResult { +export function restoreLinkClientRouting(expected: OcxClientConnectionConfig, options: { syncFenced: boolean }): LinkRoutingRestoreResult { if (siblingOfLivePort() !== null) return { success: true, restored: false, message: siblingSkipMessage() }; if (!isLinkConnection(expected)) { return { success: true, restored: false, message: "Hub connection-owned integrations preserved." }; @@ -30,7 +30,7 @@ export function restoreLinkClientRouting(expected: OcxClientConnectionConfig, op const preserve = () => process.env.OCX_SERVICE === "1" || isDesktopSupervised() || pendingTeardownOutstanding(); if (preserve()) return { success: true, restored: false, message: "Supervised restart or pending teardown owns the client integrations." }; - if (options.syncFenced === false) return refusal("the runtime sync fence could not be verified"); + if (options?.syncFenced !== true) return refusal("the runtime sync fence could not be verified"); const guard = stopRestoreWriteGuard(expected); const withWriteGuard = (write: () => T): T => guard.run(() => { if (preserve()) throw new Error("restart or teardown ownership changed"); diff --git a/src/client/sync-runtime.ts b/src/client/sync-runtime.ts index 991db129659..883b013b6e9 100644 --- a/src/client/sync-runtime.ts +++ b/src/client/sync-runtime.ts @@ -1,6 +1,6 @@ /** Cross-process admission for Link sync while its local runtime shuts down. */ import { lstatSync } from "node:fs"; -import { withConfigMutationLockSync } from "../config"; +import { ConfigMutationLockError, withConfigMutationLockSync } from "../config"; import { getRuntimePortPath, isValidRuntimePortState, writeRuntimePort, type RuntimePortState } from "../config/process-state"; import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; @@ -46,3 +46,20 @@ export function markClientRuntimeStopping(expected: Required>, + deps: { mark?: () => void; sleep?: (ms: number) => Promise } = {}, +): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { (deps.mark ?? (() => markClientRuntimeStopping(expected)))(); return true; } + catch (error) { + const cause = error instanceof ConfigMutationLockError ? error.cause : undefined; + const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined; + if ((code !== "SQLITE_BUSY" && code !== "SQLITE_LOCKED") || attempt === 19) return false; + await (deps.sleep ?? Bun.sleep)(50); + } + } + return false; +} diff --git a/structure/remote-link.md b/structure/remote-link.md index 2c7e70d84b0..0011bb6b0b1 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -1,5 +1,7 @@ # Remote Link +Signal fencing yields 50 ms between at most 20 attempts only when `ConfigMutationLockError` wraps `SQLITE_BUSY` or `SQLITE_LOCKED`; identity, storage and other failures refuse immediately. Restoration callers must explicitly supply their sync-fence verdict. `tests/clients/client-link-stop.test.ts` covers a real cross-process C transaction, retry exhaustion and non-contention errors. + `src/client/sync-runtime.ts` fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as `clientStopping` under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation; after a normal stop, start the Child before syncing its routing. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. The separate-process catalog/settings-response holds in `tests/clients/client-link-stop.test.ts` cover late commits, fresh sync during shutdown and the completed-sync control. Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start and run `ocx sync --restart-codex`; start alone does not reapply routing. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. diff --git a/tests/clients/client-link-stop.test.ts b/tests/clients/client-link-stop.test.ts index 98c59ba4a7d..ebf425783a3 100644 --- a/tests/clients/client-link-stop.test.ts +++ b/tests/clients/client-link-stop.test.ts @@ -214,6 +214,17 @@ test("served-connection guard permits catalog sync metadata at a later write", a expect(report.result).toEqual({ writes: ["before-change", "after-change"], refused: false, boundary: "restore" }); }); +for (const retryError of ["busy", "locked", "exhausted", "permission", "unwrapped", "identity"]) { + test(`signal sync fencing bounds and classifies ${retryError} retries`, async () => { + const report = await runStop({ mode: "retry", retryError }); + const succeeds = retryError === "busy" || retryError === "locked"; + expect(report.result).toEqual({ success: succeeds, attempts: succeeds ? 3 : retryError === "exhausted" ? 20 : 1, + waits: Array(succeeds ? 2 : retryError === "exhausted" ? 19 : 0).fill(50) }); + expect(report.after).toBe(report.before); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + }); +} + for (const runtimeRecord of ["absent", "invalid", "foreign"]) { test(`signal refuses restoration when its runtime record is ${runtimeRecord}`, async () => { const report = await runStop({ mode: "runtime", runtimeRecord, expectedFailure: true }); @@ -234,7 +245,7 @@ for (const preserve of ["service", "desktop", "receipt", "sibling"]) { }, 20_000); } -for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, { releaseFirst: false, hold: "home" }, { releaseFirst: true, hold: "catalog" }] as const) { +for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, { releaseFirst: false, hold: "home" }, { releaseFirst: true, hold: "catalog" }, { releaseFirst: false, hold: "lock" }] as const) { test(`a separate sync ${releaseFirst ? "finishes before" : "cannot commit after"} signal restoration (${hold})`, async () => { const root = mkdtempSync(join(tmpdir(), "ocx-link-sync-stop-")); const home = join(root, "ocx"), codex = join(root, "codex"); @@ -276,6 +287,11 @@ for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, expect(durable().catalog.models).toEqual([{ slug: "fixture/late-sync" }]); } release("signal-now"); + if (hold === "lock") { + // Both immediate attempts see a real competing C transaction. Yielding lets it finish. + await Bun.sleep(150); + release("catalog-release"); + } await waitFor("shutdown-drained"); const restored = durable(); expect(restored.config).toBe('model = "native"\n'); @@ -293,7 +309,7 @@ for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, if (!releaseFirst) { release("catalog-release"); expect(await held.exited).toBe(0); - expect(read("held-sync-result.json")).toMatchObject({ ok: false, fetched: true }); + expect(read("held-sync-result.json")).toMatchObject(hold === "lock" ? { ok: true, lockHeld: true } : { ok: false, fetched: true }); } // Read after BOTH processes exit; the runtime's own exit snapshot cannot prove this. expect(durable()).toEqual(restored); diff --git a/tests/fixtures/client-link-stop-runner.ts b/tests/fixtures/client-link-stop-runner.ts index 2e5566cf217..316a8c88c8b 100644 --- a/tests/fixtures/client-link-stop-runner.ts +++ b/tests/fixtures/client-link-stop-runner.ts @@ -11,7 +11,7 @@ import { takeDesktopSupervisedMarker } from "../../src/lib/system-restart-contra import { claimPendingTeardown } from "../../src/config/pending-teardown"; const options = JSON.parse(readFileSync(0, "utf8")) as { - mode: "runtime" | "helper" | "api" | "parent" | "guard"; + mode: "runtime" | "helper" | "api" | "parent" | "guard" | "retry"; transport?: "link" | "hub"; signal?: NodeJS.Signals; osSignal?: boolean; @@ -19,6 +19,7 @@ const options = JSON.parse(readFileSync(0, "utf8")) as { externalSignal?: boolean; holdShutdown?: boolean; runtimeRecord?: "absent" | "invalid" | "foreign"; + retryError?: "busy" | "locked" | "exhausted" | "permission" | "unwrapped" | "identity"; repeat?: boolean; preserve?: "service" | "desktop" | "receipt" | "sibling" | "restart" | "restart-rollback" | "recycle-pending" | "restart-rollback-recycle"; mutation?: "connection" | "journal" | "process-journal" | "config" | "profile" | "unverified" | "selection" | "oversized" | "nonregular" | "rotation" | "hub" | "catalog" | "prior-catalog" | "api-key" | "token" | "management" | "link-id"; @@ -123,7 +124,24 @@ if (options.preserve === "service") process.env.OCX_SERVICE = "1"; if (options.preserve === "desktop") takeDesktopSupervisedMarker({ OCX_DESKTOP_SUPERVISED: "1" }); if (options.preserve === "receipt") claimPendingTeardown({ hostname: "127.0.0.1", port: config.port }, "exact"); if (options.preserve === "sibling") (await import("../../src/codex/sibling-start")).markSiblingStart(10100); -if (options.mode === "guard") { +if (options.mode === "retry") { + const { ConfigMutationLockError } = await import("../../src/config"); + const { ClientSyncRuntimeError, markClientRuntimeStoppingWithRetry } = await import("../../src/client/sync-runtime"); + let attempts = 0; + const waits: number[] = []; + const success = await markClientRuntimeStoppingWithRetry({ pid: process.pid, port: config.port, attestationSecret: "unused-fixture-identity" }, { + mark: () => { + attempts += 1; + if ((options.retryError === "busy" || options.retryError === "locked") && attempts === 3) return; + if (options.retryError === "identity") throw new ClientSyncRuntimeError("client_runtime_changed"); + const cause = Object.assign(new Error("fixture acquisition failure"), { code: options.retryError === "permission" ? "EACCES" : options.retryError === "locked" ? "SQLITE_LOCKED" : "SQLITE_BUSY" }); + if (options.retryError === "unwrapped") throw cause; + throw new ConfigMutationLockError("fixture acquisition failure", { cause }); + }, + sleep: async ms => { waits.push(ms); }, + }); + report({ success, attempts, waits }); +} else if (options.mode === "guard") { const { stopRestoreWriteGuard } = await import("../../src/client/restore-ownership"); const guard = stopRestoreWriteGuard(expected); const writes: string[] = []; @@ -203,7 +221,8 @@ if (options.mode === "guard") { const result = options.mode === "api" ? await (await import("../../src/server/stop-teardown")).performStopTeardown(new URL("http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=fixture"), { ownsReceipt: () => true }) : options.mode === "parent" ? await (await import("../../src/cli/stop-restore")).restoreSharedClientStateAfterStop(() => {}) - : (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected); - const repeated = options.repeat ? (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected) : undefined; + // The direct helper fixture has no runtime, async sync or other writer. + : (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected, { syncFenced: true }); + const repeated = options.repeat ? (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected, { syncFenced: true }) : undefined; report(repeated ? { first: result, repeated } : result); } diff --git a/tests/fixtures/client-link-sync-runner.ts b/tests/fixtures/client-link-sync-runner.ts index 0e5519e2b4f..b2a94d21830 100644 --- a/tests/fixtures/client-link-sync-runner.ts +++ b/tests/fixtures/client-link-sync-runner.ts @@ -3,8 +3,20 @@ import { mock } from "bun:test"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const options = JSON.parse(readFileSync(0, "utf8")) as { hold: "catalog" | "home" | false; name: "held" | "fresh" }; +const options = JSON.parse(readFileSync(0, "utf8")) as { hold: "catalog" | "home" | "lock" | false; name: "held" | "fresh" }; const home = process.env.OPENCODEX_HOME!; +if (options.hold === "lock") { + const { Database } = await import("bun:sqlite"); + const database = new Database(join(home, "config-mutation.sqlite")); + try { + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + writeFileSync(join(home, "held-requested"), "ready"); + while (!existsSync(join(home, "catalog-release"))) await Bun.sleep(10); + database.exec("ROLLBACK"); + } finally { database.close(); } + writeFileSync(join(home, "held-sync-result.json"), JSON.stringify({ ok: true, lockHeld: true })); + process.exit(0); +} const lifecycle = await import("../../src/client/lifecycle-lock"); const lock = lifecycle.withClientLifecycleSync; mock.module("../../src/client/lifecycle-lock", () => ({ ...lifecycle, From cea1e29c0b5c7162974c73a6a865f37f2904c98f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 18:07:55 +0900 Subject: [PATCH 11/13] docs(link): define the configuration mutation lock on first use --- 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 0011bb6b0b1..8e283183197 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -1,6 +1,6 @@ # Remote Link -Signal fencing yields 50 ms between at most 20 attempts only when `ConfigMutationLockError` wraps `SQLITE_BUSY` or `SQLITE_LOCKED`; identity, storage and other failures refuse immediately. Restoration callers must explicitly supply their sync-fence verdict. `tests/clients/client-link-stop.test.ts` covers a real cross-process C transaction, retry exhaustion and non-contention errors. +Signal fencing uses the configuration mutation lock (`C`), yielding 50 ms between at most 20 attempts only when `ConfigMutationLockError` wraps `SQLITE_BUSY` or `SQLITE_LOCKED`; identity, storage and other failures refuse immediately. Restoration callers must explicitly supply their sync-fence verdict. `tests/clients/client-link-stop.test.ts` covers a real cross-process C transaction, retry exhaustion and non-contention errors. `src/client/sync-runtime.ts` fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as `clientStopping` under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation; after a normal stop, start the Child before syncing its routing. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. The separate-process catalog/settings-response holds in `tests/clients/client-link-stop.test.ts` cover late commits, fresh sync during shutdown and the completed-sync control. From df1f4ffd7917abdf80d2db1235405c09fbda24f4 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:09:14 +0900 Subject: [PATCH 12/13] docs(link): keep operator recovery steps in the CLI lifecycle guide The structure note now states only the runtime behavior; starting and resyncing a Link Child after a signal stop is documented with the other stop steps in the public lifecycle reference. --- docs-site/src/content/docs/reference/cli/lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 29a4c7866bf..7a5d0d405bc 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -82,7 +82,7 @@ ocx start --socks5-off ### `ocx stop [--json]` -An unsupervised Link Child handling a shutdown signal restores native Codex and managed Grok before releasing its listener, rechecking the served connection before each write. Service/Desktop supervision, sibling ownership, restart/recycle handoff and pending parent stop receipts preserve integrations. Shutdown rejects bound restart with `409 runtime_stopping`. Forced termination can bypass signal handlers; parent `ocx stop` restoration remains necessary. +An unsupervised Link Child handling a shutdown signal restores native Codex and managed Grok before releasing its listener, rechecking the served connection before each write. Service/Desktop supervision, sibling ownership, restart/recycle handoff and pending parent stop receipts preserve integrations. Shutdown rejects bound restart with `409 runtime_stopping`. Forced termination can bypass signal handlers; parent `ocx stop` restoration remains necessary. To route Codex through Home again after such a stop, start the Child (`ocx start`, or `ocx service start` for an installed service) and then run `ocx sync --restart-codex`. Starting alone does not reapply routing, and a sync run while the Child is stopped is an offline operation. Stop the running proxy (by PID), remove the PID file, and restore native Codex for standalone owners. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. From 3a0e00d51a3bd9554f798dbf87fa90809edfc21c Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:59:26 +0900 Subject: [PATCH 13/13] test(link): use the canonical Codex home in sync-stop fixtures Sync records the canonical catalog path while the fixture created its native backup under the /var alias on macOS, so restoration fell back to an empty catalog; clean up held children with SIGKILL so a failed assertion is reported instead of hanging. Also correct the offline-sync wording: an offline sync does reapply routing. --- docs-site/src/content/docs/reference/cli/lifecycle.md | 2 +- structure/remote-link.md | 2 +- tests/clients/client-link-stop.test.ts | 11 +++++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 7a5d0d405bc..b557c2dfcb5 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -82,7 +82,7 @@ ocx start --socks5-off ### `ocx stop [--json]` -An unsupervised Link Child handling a shutdown signal restores native Codex and managed Grok before releasing its listener, rechecking the served connection before each write. Service/Desktop supervision, sibling ownership, restart/recycle handoff and pending parent stop receipts preserve integrations. Shutdown rejects bound restart with `409 runtime_stopping`. Forced termination can bypass signal handlers; parent `ocx stop` restoration remains necessary. To route Codex through Home again after such a stop, start the Child (`ocx start`, or `ocx service start` for an installed service) and then run `ocx sync --restart-codex`. Starting alone does not reapply routing, and a sync run while the Child is stopped is an offline operation. +An unsupervised Link Child handling a shutdown signal restores native Codex and managed Grok before releasing its listener, rechecking the served connection before each write. Service/Desktop supervision, sibling ownership, restart/recycle handoff and pending parent stop receipts preserve integrations. Shutdown rejects bound restart with `409 runtime_stopping`. Forced termination can bypass signal handlers; parent `ocx stop` restoration remains necessary. To route Codex through Home again after such a stop, start the Child (`ocx start`, or `ocx service start` for an installed service) and then run `ocx sync --restart-codex`. Starting alone does not reapply routing. A sync run while the Child is stopped still reapplies routing, which points Codex at a listener that is not running, so start the Child first. Stop the running proxy (by PID), remove the PID file, and restore native Codex for standalone owners. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. diff --git a/structure/remote-link.md b/structure/remote-link.md index bc0247daa39..75dbd235f5a 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -2,7 +2,7 @@ Signal fencing uses the configuration mutation lock (`C`), yielding 50 ms between at most 20 attempts only when `ConfigMutationLockError` wraps `SQLITE_BUSY` or `SQLITE_LOCKED`; identity, storage and other failures refuse immediately. Restoration callers must explicitly supply their sync-fence verdict. `tests/clients/client-link-stop.test.ts` covers a real cross-process C transaction, retry exhaustion and non-contention errors. -`src/client/sync-runtime.ts` fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as `clientStopping` under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation and does not reapply routing to a stopped Child. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. The separate-process catalog/settings-response holds in `tests/clients/client-link-stop.test.ts` cover late commits, fresh sync during shutdown and the completed-sync control. +`src/client/sync-runtime.ts` fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as `clientStopping` under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation: it reapplies routing even though no Child listener is serving it. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. The separate-process catalog/settings-response holds in `tests/clients/client-link-stop.test.ts` cover late commits, fresh sync during shutdown and the completed-sync control. Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start alone does not reapply routing; the operator steps live in the public CLI lifecycle reference. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. diff --git a/tests/clients/client-link-stop.test.ts b/tests/clients/client-link-stop.test.ts index ebf425783a3..0a68e736791 100644 --- a/tests/clients/client-link-stop.test.ts +++ b/tests/clients/client-link-stop.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fixturePath, repoPath } from "../helpers/repo-root"; @@ -248,7 +248,9 @@ for (const preserve of ["service", "desktop", "receipt", "sibling"]) { for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, { releaseFirst: false, hold: "home" }, { releaseFirst: true, hold: "catalog" }, { releaseFirst: false, hold: "lock" }] as const) { test(`a separate sync ${releaseFirst ? "finishes before" : "cannot commit after"} signal restoration (${hold})`, async () => { const root = mkdtempSync(join(tmpdir(), "ocx-link-sync-stop-")); - const home = join(root, "ocx"), codex = join(root, "codex"); + // Sync records the canonical catalog path (/private/var on macOS) and backup lookup hashes the + // spelling, so the fixture must create its native backup under the same spelling. + const home = join(root, "ocx"), codex = join(realpathSync(root), "codex"); const children: Array<{ child: ReturnType; drained: Promise }> = []; const spawn = (file: string, options: unknown) => { const child = Bun.spawn([process.execPath, fixturePath(file)], { @@ -273,7 +275,7 @@ for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, connection: readFileSync(join(home, "config.json"), "utf8"), token: readFileSync(join(home, "service-api-token"), "utf8"), }); - const timer = setTimeout(() => { for (const { child } of children) if (child.exitCode === null) child.kill(); }, 35_000); + const timer = setTimeout(() => { for (const { child } of children) if (child.exitCode === null) child.kill("SIGKILL"); }, 35_000); try { const runtime = spawn("client-link-stop-runner.ts", { mode: "runtime", externalSignal: true, holdShutdown: true }); await waitFor("ready"); @@ -321,7 +323,8 @@ for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, } } finally { clearTimeout(timer); - for (const { child } of children) { if (child.exitCode === null) child.kill(); await child.exited; } + // A child parked on a held release ignores another SIGTERM; kill it so a failed assertion surfaces. + for (const { child } of children) { if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; } await Promise.all(children.map(value => value.drained)); removeTreeWithRetry(root); }