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 a123bf34797..f014dd0ecd6 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -32,6 +32,8 @@ ocx start --socks5-off ### `ocx stop` +Un Link Child non supervisé restaure Codex natif et Grok avant de libérer son listener lors d’un signal, en revérifiant la connexion à chaque écriture. Service/Desktop, sibling, transfert de redémarrage et reçu stop du parent préservent les intégrations. L’arrêt refuse le redémarrage avec `409 runtime_stopping`. Une terminaison forcée peut éviter le signal ; la restauration par `ocx stop` reste nécessaire. + Arrête le proxy actif à partir de son PID, supprime le fichier de PID et rétablit le fonctionnement natif de Codex. Si un service d’arrière-plan géré est installé, `ocx stop` l’arrête d’abord afin qu’il ne puisse pas relancer le proxy. Le bouton **Stop** du tableau de bord Web exécute la même opération (`POST /api/stop`) sur tous les backends, sauf le Planificateur de tâches Windows : le wrapper peut y relancer le proxy après la fin de la tâche, donc le tableau de bord refuse avec `respawnable_service`, ne modifie rien et vous demande d'exécuter `ocx stop`. Sur un client hub connecté, `ocx stop`, y compris via l’API, conserve le routage Codex, le catalogue et la configuration Grok appartenant à la connexion ; `ocx start` les dessert de nouveau. L’arrêt explicite d’un Remote Link Child rétablit au contraire, si la restauration partagée réussit, le routage natif de Codex, le catalogue et les métadonnées de reprise sauvegardées, et supprime la configuration Grok gérée. La connexion Home enregistrée reste conservée ; `ocx disconnect` la supprime. 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 73020dc2fc0..06103b47bc8 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +監督プロセスのない Link Child は終了シグナル時、各書き込みで接続を再確認し、リスナーを閉じる前に Codex と管理対象 Grok を復元します。サービス・Desktop、sibling、再起動の引き継ぎ、親 stop 記録がある場合は設定を保持します。終了中の再起動は `409 runtime_stopping` です。強制終了はシグナル処理を省略する場合があり、親 `ocx stop` の復元も必要です。 + 実行中のプロキシを (PID によって) 停止し、PID ファイルを削除します。スタンドアロンの所有者ではネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。 接続済みの hub クライアントでは、`ocx stop`(成功した API 停止を含む)は接続が所有する Codex のルーティング、モデルカタログ、Grok 設定を保持します。`ocx start` はルーティングを再注入せず、その設定で再び処理します。Codex を自身のアカウントへ戻すには `ocx disconnect`、既にネイティブルーティングへ戻した後の再適用には `ocx sync --restart-codex` を使います。 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 90512b785e1..40ecde6889f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -44,6 +44,8 @@ ocx start --socks5-off ### `ocx stop` +감독 프로세스 없이 실행되는 Link Child는 종료 신호 처리 시 각 쓰기 직전에 연결을 재확인하고 리스너를 닫기 전에 Codex 기본 설정과 관리 Grok 설정을 복원합니다. 서비스·Desktop 감독, sibling, 재시작·재활용 인계, 부모 stop 영수증이 있으면 설정을 보존합니다. 종료 중 재시작 요청은 `409 runtime_stopping`입니다. 강제 종료는 신호 처리를 건너뛸 수 있어 부모 `ocx stop`의 복원도 필요합니다. + 실행 중인 프록시를 PID 기준으로 중지하고 PID 파일을 삭제합니다. 독립 실행 소유자라면 기본 Codex를 복원합니다. 관리형 백그라운드 서비스가 설치되어 있으면 `ocx stop`이 먼저 그 서비스를 중지하므로 프록시가 다시 올라올 수 없습니다. 웹 대시보드의 **Stop** 버튼도 같은 동작(`POST /api/stop`)을 하지만, Windows 작업 스케줄러는 예외입니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 대시보드는 `respawnable_service`로 거절하고 아무것도 바꾸지 않은 채 `ocx stop` 실행을 안내합니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 9dc04b30b64..b557c2dfcb5 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -82,6 +82,8 @@ 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. 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. On a connected hub client, `ocx stop` leaves Codex routing, its 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 2ecf0d288cc..390aae87e90 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -50,6 +50,8 @@ ocx start --socks5-off ### `ocx stop` +Link Child без супервизора при сигнале восстанавливает нативный Codex и управляемый Grok до закрытия listener, проверяя подключение перед каждой записью. Сервис/Desktop, sibling, передача перезапуска и квитанция stop родителя сохраняют настройки. Во время завершения перезапуск возвращает `409 runtime_stopping`. Принудительное завершение может обойти сигнал; восстановление родителем `ocx stop` остаётся необходимым. + Остановить работающий прокси (по PID) и удалить PID-file; для автономного владельца также восстановить native Codex. Если установлена managed background service, `ocx stop` сначала останавливает и её, чтобы она не перезапустила прокси обратно. Кнопка **Stop** в веб-дашборде выполняет то же действие (`POST /api/stop`) на всех бэкендах, кроме планировщика заданий Windows: там обёртка может перезапустить прокси после завершения задачи, поэтому дашборд отказывает с `respawnable_service`, ничего не меняет и просит выполнить `ocx stop`. 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 4a95d62b8a2..8c7e61c2750 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -50,6 +50,8 @@ ocx start --socks5-off ### `ocx stop` +Gözetimsiz Link Child kapanma sinyalinde her yazmadan önce bağlantıyı doğrular ve dinleyiciyi bırakmadan önce yerel Codex ile yönetilen Grok ayarlarını geri yükler. Servis/Desktop, sibling, yeniden başlatma devri ve üst stop makbuzu ayarları korur. Kapanma sırasında yeniden başlatma `409 runtime_stopping` döndürür. Zorla sonlandırma sinyali atlayabilir; üst `ocx stop` geri yüklemesi yine gereklidir. + Çalışan proxy'yi (PID'ye göre) durdurun, PID dosyasını kaldırın ve yerel Codex'i geri yükleyin. Yönetilen bir arka plan servisi kuruluysa `ocx stop` proxy'yi yeniden oluşturamaması için önce onu da durdurur. Web kontrol panelinin **Durdur** düğmesi aynı eylemi (`POST /api/stop`) Windows Görev Zamanlayıcı dışındaki tüm arka uçlarda çalıştırır: orada görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir, bu yüzden panel `respawnable_service` ile reddeder, hiçbir şeyi değiştirmez ve `ocx stop` çalıştırmanızı ister. 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 d547e59f933..5fafbccc46f 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 @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +无人监督的 Link Child 处理退出信号时,会在每次写入前核对连接,并在关闭监听器前恢复 Codex 原生配置及受管理的 Grok。服务/Desktop、sibling、重启交接和父进程 stop 收据会保留配置。退出中的重启请求返回 `409 runtime_stopping`。强制终止可能跳过信号处理,仍需父进程 `ocx stop` 的恢复路径。 + 停止正在运行的代理(按 PID),移除 PID 文件;独立运行的所有者还会恢复原生 Codex。如果安装了受管后台服务,`ocx stop` 还会先停止该服务,这样它就无法重新拉起代理。Web 仪表盘的 **Stop** 按钮在多数后端执行同样的操作(`POST /api/stop`),但 Windows 任务计划程序除外:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口,因此仪表盘会以 `respawnable_service` 拒绝、不做任何更改,并提示改用 `ocx stop`。 对于已连接的 hub 客户端,`ocx stop`(包括成功的 API 停止)会保留连接所拥有的 Codex 路由、模型目录和 Grok 配置。`ocx start` 使用这些保留的配置恢复服务,不会重新注入路由。要让 Codex 回到自己的账户,请运行 `ocx disconnect`;若已恢复为原生路由,要重新应用连接路由,请运行 `ocx sync --restart-codex`。 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 c76ec26e196..66ba8a3ea3b 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 @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +無人監督的 Link Child 處理結束訊號時,會在每次寫入前核對連線,並在關閉接聽器前還原 Codex 原生設定及受管理的 Grok。服務/Desktop、sibling、重新啟動交接和父程序 stop 收據會保留設定。結束中的重新啟動要求回傳 `409 runtime_stopping`。強制終止可能略過訊號處理,仍需父程序 `ocx stop` 的還原路徑。 + 停止執行中的代理(依 PID)、移除 PID 檔案,並還原原生 Codex。若已安裝受管背景服務,`ocx stop` 也會先停止它,使其無法重新生成代理。網頁儀表板的 **Stop** 按鈕在多數後端執行相同動作(`POST /api/stop`),但 Windows 工作排程器除外:工作結束後包裝程序仍可能重新啟動 Proxy,因此儀表板會以 `respawnable_service` 拒絕、不做任何變更,並請你改用 `ocx stop`。 對已連線的 hub 用戶端,`ocx stop`(包括成功的 API 停止)會保留連線擁有的 Codex 路由、目錄和 Grok 設定,`ocx start` 會再次提供服務。明確停止 Remote Link Child 時,若共用設定還原成功,則會還原原生 Codex 路由、目錄與已備份的續接歷程中繼資料,並移除受管理的 Grok 設定。已儲存的 Home 連線會保留,可用 `ocx disconnect` 移除。 diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 01a3abb4020..c08082731bb 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -151,6 +151,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/src/cli/index.ts b/src/cli/index.ts index e2f98925666..bcddd09f7ff 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1139,7 +1139,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning)); let teardownNonce: string | undefined; // #7423: an update that restarts the proxy asks this stop to leave Codex routing for the replacement. - const handoverRequested = updateTeardownHandoverRequested(process.env, () => shouldSyncCodexOnStart(loadConfig())) && !stoppingSibling && !connectedClientSkip; + // Client start never discharges a standalone update-handover receipt or reapplies routing. + const handoverRequested = updateTeardownHandoverRequested(process.env, () => shouldSyncCodexOnStart(loadConfig())) + && !stoppingSibling && readClientConnectionState().kind !== "connected"; let deferralHonoredByProxy = false; const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => { if (teardownNonce || stoppingSibling) return; 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/machine-listener.ts b/src/client/machine-listener.ts index 0f6ca2681b4..a697089b975 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -324,6 +324,9 @@ async function handleBoundRestart( ); if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore }); const admission = await restart.request(); + if (admission.kind === "stopping") { + return Response.json({ success: false, error: { code: "runtime_stopping" } }, { status: 409, headers: noStore }); + } if (admission.kind === "supervised") { return Response.json({ success: false, diff --git a/src/client/restore-ownership.ts b/src/client/restore-ownership.ts index e1d190cb9f0..4ca5d24741d 100644 --- a/src/client/restore-ownership.ts +++ b/src/client/restore-ownership.ts @@ -1,5 +1,6 @@ import { withConfigMutationLockSync } from "../config"; import { assertNoClientDisconnectPending, pendingClientConnectMayOwnToken, readClientConnectionState } from "./state"; +import type { OcxClientConnectionConfig } from "../types"; export type RestoreBoundaryOwnership = "restore" | "hub-owned" | "unproven"; export type RestoreOwnershipRefusal = Exclude; @@ -21,8 +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() { +export function stopRestoreWriteGuard(expectedConnection?: OcxClientConnectionConfig) { + const expected = expectedConnection && restoreConnectionSnapshot(structuredClone(expectedConnection)); let boundary: RestoreBoundaryOwnership = "restore"; return { boundary: () => boundary, @@ -32,6 +41,15 @@ export function stopRestoreWriteGuard() { return withConfigMutationLockSync(() => { entered = true; if (boundary === "restore") boundary = inspectStopRestoreOwnership(); + if (boundary === "restore" && expected) { + try { + if (expected.pendingOperation) throw new Error("client_rotation_pending"); + 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); return write(); }); diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index f5ad4417908..899fb165112 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -30,9 +30,12 @@ export type ClientRestartSupervisor = "service" | "desktop"; export type ClientRestartAdmission = | { kind: "accepted"; alreadyRestarting: boolean } + | { kind: "stopping" } | { kind: "supervised"; supervisor: ClientRestartSupervisor }; export interface ClientRestartIo { + /** A signal shutdown has claimed this runtime; it cannot admit a replacement. */ + stopping?: () => boolean; /** Which supervisor owns this process, or null when it starts its own replacement. */ supervisor(): Promise; /** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */ @@ -41,7 +44,7 @@ export interface ClientRestartIo { spawnReplacement(deadlineAt: number): Promise; /** Serve again on this process: listener, fresh tunnel supervisor, records. */ resume(): Promise; - exitProcess(code: number): void; + exitProcess(code: number): void | Promise; now?: () => number; sleep?: (ms: number) => Promise; warn?: (message: string) => void; @@ -49,7 +52,9 @@ export interface ClientRestartIo { export interface ClientRestartController { request(): Promise; - /** Settles when the restart in flight (if any) has finished or rolled back. Tests only. */ + /** Only an accepted flight owns routing; a supervisor check does not. */ + hasHandoff(): boolean; + /** Join the accepted flight before signal teardown; may reject on a failed exit callback. */ settled(): Promise; } @@ -81,16 +86,19 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar await io.resume(); } catch (resumeError) { warn(`[client] restart: this runtime could not serve again (${failureCode(resumeError)}); run 'ocx start'`); - io.exitProcess(1); + await io.exitProcess(1); } return; } - io.exitProcess(0); + await io.exitProcess(0); }; return { + hasHandoff: () => flight !== null, async request() { + if (io.stopping?.()) return { kind: "stopping" }; const supervisor = await io.supervisor(); + if (io.stopping?.()) return { kind: "stopping" }; if (supervisor) return { kind: "supervised", supervisor }; if (flight) return { kind: "accepted", alreadyRestarting: true }; flight = run(now()).finally(() => { flight = null; }); diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 7677170f3ae..75e3462f147 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -32,12 +32,17 @@ 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"; +import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; let activeServer: Server | null = null; let activePort: number | null = null; let activeSupervisor: ClientLinkSupervisor | null = null; let activeReporter: LinkParityReporter | null = null; let recycleScheduled = false; +let recycleTimer: ReturnType | undefined; +let recycleFlight: Promise | null = null; +let clientStopping = false; /** * When this runtime's process started (OCX-6, P5): what `/healthz` reports as `startedAt` and the @@ -97,7 +102,7 @@ export function clientRuntimeRecord( ): RuntimePortState { let command: string[] | undefined; try { command = currentServingCommand(); } catch { /* no reliable relaunch identity; update admission fails closed */ } - return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField() }; + return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}) }; } export function standaloneRecycleEnv( @@ -126,12 +131,15 @@ export function standaloneRecycleEnv( } export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): void { - if (recycleScheduled) return; + if (recycleScheduled || clientStopping) return; recycleScheduled = true; - const timer = setTimeout(() => { - void recycleStandalone(disconnectedTokenFingerprint); + recycleTimer = setTimeout(() => { + recycleTimer = undefined; + if (clientStopping) return; + recycleFlight = recycleStandalone(disconnectedTokenFingerprint).finally(() => { recycleFlight = null; }); + void recycleFlight.catch(error => { if (!clientStopping) throw error; }); }, 50); - if (typeof timer === "object" && "unref" in timer) timer.unref(); + if (typeof recycleTimer === "object" && "unref" in recycleTimer) recycleTimer.unref(); } function configuredTcpPort(): number | undefined { @@ -421,7 +429,9 @@ export async function startClientRuntime( try { await server.stop(true); } catch { /* already closed */ } }; + let shuttingDown = false; const restartController: ClientRestartController = createClientRestartController({ + stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, stop: stopServing, spawnReplacement: async deadlineAt => { @@ -445,8 +455,8 @@ export async function startClientRuntime( } }, exitProcess: async code => { - await flushLinkDiagnostics(); - process.exit(code); + try { await flushLinkDiagnostics(); } + finally { process.exit(code); } }, }); @@ -455,11 +465,24 @@ export async function startClientRuntime( await serve(); options.afterPublish?.(); - let shuttingDown = false; const shutdown = (signal?: NodeJS.Signals) => { if (shuttingDown) return; 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 = !linkMode || await markClientRuntimeStoppingWithRetry({ pid: process.pid, port: servedPort, attestationSecret }); credentialObservation?.stop(); credentialObservation = null; activeReporter?.stop(); @@ -470,15 +493,18 @@ 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, { 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. - process.exit(handledSignalExitCode(signal)); + 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 new file mode 100644 index 00000000000..f3a5377e9ee --- /dev/null +++ b/src/client/stop-routing.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { restoreNativeCodex } from "../codex/inject"; +import { hasInjectedCodexRouting } from "../codex/injected-marker"; +import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; +import { journalOwner, JOURNAL_PATH } from "../codex/journal"; +import { CODEX_CONFIG_PATH } from "../codex/paths"; +import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; +import type { OcxClientConnectionConfig } from "../types"; +import { pendingTeardownOutstanding } from "../config/pending-teardown"; +import { stripGrokConfig } from "../grok/inject"; +import { isDesktopSupervised } from "../lib/system-restart-contract"; +import { serviceEnvironmentOwnedHere } from "../service/guards"; +import { stopRestoreWriteGuard } from "./restore-ownership"; +import { isLinkConnection } 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}. No further writes; inspect remaining recovery evidence.`, +}); + +/** Unsupervised signal cleanup; explicit parent stop and restart retain their own restoration. */ +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." }; + } + 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 !== 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"); + const owner = journalOwner({ readOnly: true }); + if (existsSync(JOURNAL_PATH) && (owner?.kind !== "client" || owner.apiKeyId !== expected.apiKeyId)) { + throw new Error("the Codex journal belongs to another owner or is unverified"); + } + return write(); + }); + try { + withWriteGuard(() => { + const owner = journalOwner({ readOnly: true }); + if (owner === null) { + const config = readBoundedCodexConfig(CODEX_CONFIG_PATH); + if (existsSync(JOURNAL_PATH) || (config !== null && hasInjectedCodexRouting(config))) throw new Error("the client journal is missing or unverified"); + } + }); + const result = restoreNativeCodex({ withWriteGuard }); + if (result.artifacts.config.state === "failed" || result.historyPreflightRefusal) { + return refusal("native Codex restoration was not authorized or could not complete"); + } + const grok = serviceEnvironmentOwnedHere() ? withWriteGuard(() => stripGrokConfig()) : { ok: true, changed: false }; + if (!result.success || !grok.ok) return refusal("native Codex or managed Grok restoration did not complete"); + return { success: true, restored: result.artifacts.config.changed || result.artifacts.catalog.changed || grok.changed, + message: `Link Child native Codex and managed Grok restored; connection credentials preserved. ${resumeGuidance}` }; + } catch (error) { + return refusal(error instanceof Error ? error.message : "restoration failed"); + } +} diff --git a/src/client/sync-runtime.ts b/src/client/sync-runtime.ts new file mode 100644 index 00000000000..883b013b6e9 --- /dev/null +++ b/src/client/sync-runtime.ts @@ -0,0 +1,65 @@ +/** Cross-process admission for Link sync while its local runtime shuts down. */ +import { lstatSync } from "node:fs"; +import { ConfigMutationLockError, 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 }); + }); +} + +/** Yield only for proved SQLite contention; storage/identity failures remain immediate refusals. */ +export async function markClientRuntimeStoppingWithRetry( + 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/src/config/process-state.ts b/src/config/process-state.ts index b7fa2349112..5b71fd4f430 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -39,6 +39,8 @@ export type RuntimePortState = { command?: 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. @@ -61,6 +63,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 c0a02ec2c21..75dbd235f5a 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -1,5 +1,11 @@ # Remote Link +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: 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`. + `src/link/ssh-argv.ts` and `src/link/remote-windows.ts` build pinned Child sync commands that select and version-check the same executable before executing `sync --expect-link` without a restart flag. Fan-out platform discovery in `src/link/remote-platform.ts` accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's `src/cli/sync-command.ts` fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. `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 fb63adc54be..9e0106fd619 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. | @@ -177,7 +177,7 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- `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 `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-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 new file mode 100644 index 00000000000..0a68e736791 --- /dev/null +++ b/tests/clients/client-link-stop.test.ts @@ -0,0 +1,332 @@ +import { expect, test } from "bun:test"; +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"; +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 { + if (options.osSignal) { + const deadline = Date.now() + 10_000; + while (!existsSync(join(home, "ready")) && child.exitCode === null && Date.now() < deadline) await Bun.sleep(10); + if (!existsSync(join(home, "ready"))) throw new Error("fixture did not publish its signal-ready listener"); + child.kill("SIGTERM"); + } + 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 if (String(options.preserve).startsWith("restart-rollback")) { + expect(exit, stdout).toBe(0); + expect(stderr.trim()).toBe("[client] restart: the replacement did not start (not_ready); this runtime keeps serving"); + } 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", "journalHomeBound", "catalogAfter", "grokAfter", "protectedUnchanged"]) { + expect(atExit[field], `no late restoration after listener release: ${field}`).toEqual(report[field]); + } + return report; + } 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); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); + }, 20_000); +} + +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.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); + +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 full restore is idempotent and retains connection credentials", 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); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}); + +for (const mutation of ["connection", "selection", "journal", "process-journal", "unverified", "rotation", "hub"]) { + 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); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }); +} + +for (const mutation of ["config", "profile"]) { + test(`Link full restore preserves user edits in ${mutation} while removing owned routing`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result.success).toBe(true); + if (mutation === "config") expect(report.after).toContain('approval_policy = "on-request"'); + else expect(report.profileAfter).toBe(report.profileBefore); + expect(report.after).not.toContain("openai_base_url"); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); + }); +} + +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 preserves the merged parent deferral contract", async () => { + const report = await runStop({ mode: "api" }); + expect(report.result).toMatchObject({ success: true, sharedTeardown: "deferred" }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}); + +test("parent Link teardown retains the merged full restoration contract", 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); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}); + +for (const preserve of ["service", "desktop", "receipt", "sibling"]) { + test(`native Link signal preserves ${preserve} restoration ownership`, async () => { + const report = await runStop({ mode: "runtime", preserve }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }, 20_000); +} + +test("a pending restart admission cannot leave routing behind on signal shutdown", async () => { + const report = await runStop({ mode: "runtime", preserve: "restart" }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test.each(["restart-rollback", "restart-rollback-recycle"])("signal shutdown joins %s before restoring", async preserve => { + const report = await runStop({ mode: "runtime", preserve }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("a scheduled recycle cannot skip restoration or spawn after signal shutdown", async () => { + const report = await runStop({ mode: "runtime", preserve: "recycle-pending" }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +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: 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" }); +}); + +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 }); + 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" }, { 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-")); + // 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)], { + 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("SIGKILL"); }, 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"); + 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'); + 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(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); + 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); + // 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); + } + }, 40_000); +} diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 4f256fe85fd..06cd7feb460 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -139,6 +139,13 @@ describe("client listener: bound restart", () => { expect(body.restartCapability).toBeUndefined(); expect((await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST" })).status).toBe(404); }); + + test("a signal-stopping runtime refuses bound restart without a success receipt", async () => { + const { server, secret, port } = listen(async () => ({ kind: "stopping" })); + const response = await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST", headers: restartHeaders(secret, port) }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ success: false, error: { code: "runtime_stopping" } }); + }); }); function fakeIo(overrides: Partial = {}) { @@ -158,6 +165,50 @@ function fakeIo(overrides: Partial = {}) { } describe("client restart controller", () => { + test("signal shutdown refuses new and pending restart admissions without spawning", async () => { + let stopping = false; + let admit!: (value: null) => void; + const { io, calls } = fakeIo({ stopping: () => stopping, supervisor: () => new Promise(resolve => { admit = resolve; }) }); + const controller = createClientRestartController(io); + const pending = controller.request(); + expect(controller.hasHandoff()).toBe(false); + stopping = true; + expect(await controller.request()).toEqual({ kind: "stopping" }); + admit(null); + expect(await pending).toEqual({ kind: "stopping" }); + expect(controller.hasHandoff()).toBe(false); + expect(calls).toEqual([]); + }); + + test("pending admission never owns routing and refusal or failure leaves no handoff", async () => { + let settle!: (value: "service" | null) => void; + const { io } = fakeIo({ supervisor: () => new Promise(resolve => { settle = resolve; }) }); + const controller = createClientRestartController(io); + expect(controller.hasHandoff()).toBe(false); + const pending = controller.request(); + expect(controller.hasHandoff()).toBe(false); + settle("service"); + expect(await pending).toEqual({ kind: "supervised", supervisor: "service" }); + expect(controller.hasHandoff()).toBe(false); + const failed = createClientRestartController(fakeIo({ supervisor: async () => { throw new Error("unavailable"); } }).io); + await expect(failed.request()).rejects.toThrow("unavailable"); + expect(failed.hasHandoff()).toBe(false); + }); + + test("accepted handoff remains owned until its asynchronous exit settles", async () => { + let entered!: () => void; + let release!: () => void; + const atExit = new Promise(resolve => { entered = resolve; }); + const exitGate = new Promise(resolve => { release = resolve; }); + const controller = createClientRestartController(fakeIo({ exitProcess: async () => { entered(); await exitGate; } }).io); + await controller.request(); + await atExit; + expect(controller.hasHandoff()).toBe(true); + release(); + await controller.settled(); + expect(controller.hasHandoff()).toBe(false); + }); + test("a replacement that answers ends this process", async () => { const { io, calls } = fakeIo(); const controller = createClientRestartController(io); diff --git a/tests/fixtures/client-link-stop-runner.ts b/tests/fixtures/client-link-stop-runner.ts new file mode 100644 index 00000000000..316a8c88c8b --- /dev/null +++ b/tests/fixtures/client-link-stop-runner.ts @@ -0,0 +1,228 @@ +/** 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"; +import { ensureCatalogBackup } from "../../src/codex/catalog/parsing"; +import { takeDesktopSupervisedMarker } from "../../src/lib/system-restart-contract"; +import { claimPendingTeardown } from "../../src/config/pending-teardown"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { + mode: "runtime" | "helper" | "api" | "parent" | "guard" | "retry"; + transport?: "link" | "hub"; + signal?: NodeJS.Signals; + osSignal?: boolean; + syncBeforeStop?: boolean; + 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"; +}; +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, +}); +const nativeCatalog = { models: [{ slug: "fixture-native", visibility: "list" }] }; +writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify(nativeCatalog)); +ensureCatalogBackup(DEFAULT_CATALOG_PATH, nativeCatalog); +writeFileSync(DEFAULT_CATALOG_PATH, '{"models":[{"slug":"fixture/home-model"}]}'); +writeFileSync(join(home, "service-api-token"), token); +const grokHome = join(home, "grok"); +process.env.GROK_HOME = grokHome; +mkdirSync(grokHome, { recursive: true }); +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") { + delete config.client!.transport; delete config.client!.link; + config.client!.serverUrl = config.client!.managementUrl = "https://hub.example.test"; + saveConfig(config); + } + if (options.mutation === "rotation") { + config.client!.pendingOperation = { kind: "rotate", rotationId: "fixture-rotation", newKeyIssuedAt: "2026-10-02T00:00:00.000Z", oldKeyBackupPath: join(home, "token.prev") }; + 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" && options.mode !== "guard") mutate(); +const protectedPaths = [join(home, "config.json"), join(home, "service-api-token")]; +let protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); +const catalogBefore = readFileSync(DEFAULT_CATALOG_PATH, "utf8"); +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, syncEvidence, after: configContents(), + profileAfter: existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf8") : null, + 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"), +}); +const report = (result: unknown) => writeFileSync(join(home, "result.json"), JSON.stringify(snapshot(result))); +process.on("exit", () => writeFileSync(join(home, "exit.json"), JSON.stringify(snapshot(null)))); +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 === "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[] = []; + guard.run(() => writes.push("before-change")); + mutate(); + // A caller mutating its own snapshot cannot move an already-created guard's authority. + Object.assign(expected, config.client); + let refused = false; + try { guard.run(() => writes.push("after-change")); } catch { refused = true; } + 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, + spawnReplacementStart: async () => { throw Object.assign(new Error("fixture replacement refused"), { code: "not_ready" }); }, + })); + } + const { scheduleStandaloneRecycle, startClientRuntime } = await import("../../src/client/runtime"); + const { startMachineListener } = await import("../../src/client/machine-listener"); + let requestRestart: (() => Promise) | undefined; + await startClientRuntime({ block: false }, { restartSupervisor: () => options.preserve?.startsWith("restart-rollback") ? Promise.resolve(null) : new Promise(() => {}), startListener: (port, deps) => { + requestRestart = deps?.restart?.request; + 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; + } }); + 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" }; + } + 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") { + if (!requestRestart) throw new Error("fixture missing real restart admission"); + void requestRestart(); + } + if (options.preserve?.startsWith("restart-rollback")) { + if (!requestRestart) throw new Error("fixture missing real restart admission"); + await requestRestart(); + } + 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.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" + ? 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(() => {}) + // 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 new file mode 100644 index 00000000000..b2a94d21830 --- /dev/null +++ b/tests/fixtures/client-link-sync-runner.ts @@ -0,0 +1,49 @@ +/** 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" | "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, + 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)); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3854b21fc39..d3bc2e20c74 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1553,7 +1553,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/update/update-teardown-handover.test.ts b/tests/update/update-teardown-handover.test.ts index 688fc588724..a0216e46ac1 100644 --- a/tests/update/update-teardown-handover.test.ts +++ b/tests/update/update-teardown-handover.test.ts @@ -229,6 +229,22 @@ describe("ocx stop under an update handover", () => { expect(receiptNames()).toEqual([]); }); + test("a manager-stopped Link Child cannot leave a standalone update-handover receipt", () => { + const serverUrl = "http://127.0.0.1:10200"; + writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "client", providers: {}, defaultProvider: "openai", client: { + serverUrl, managementUrl: serverUrl, managementTransport: "direct", transport: "link", + link: { tunnelPort: 10200, linkId: "lnk_0123456789abcdef" }, selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", tokenFingerprint: "a".repeat(64), + protocolVersion: 1, connectedAt: "2026-10-01T00:00:00.000Z", + } })); + const run = runStop(true, { managerCascades: true, deadPidReadsNull: true }); + expect(run.exitCode, run.stderr).toBe(0); + expect(run.calls).toMatchObject({ native: 1, grok: 1, service: 1 }); + expect(run.receiptExists).toBe(false); + expect(receiptNames()).toEqual([]); + expect(JSON.parse(run.stdout)).toMatchObject({ sharedTeardown: "restored", exitCode: 0 }); + }); + test("without a runtime record the stop restores instead of handing over a guessed receipt", () => { const run = runStop(true, { noRuntimeRecord: true, nativeSignal: true }); expect(run.exitCode, run.stderr).toBe(0); @@ -376,6 +392,11 @@ describe("dashboard update job", () => { } }), resolvePnpmActiveLauncherFn: () => "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs", integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + serviceHomeAuthorityFn: () => ({ kind: "absent" }), + launcherSupportsUpdateHomeFn: () => true, + // This fixture checks the update child's environment, not a real Windows tray process. + trayHandoffFn: () => ({ installed: false, running: false, stopBeforeReplacement: false, + refreshAfterReplacement: false, restoreOnFailure: false, installArgs: ["tray", "install", "--no-start"] }), // Only the environment handed to the update child matters here; failing the command // ends the job before any restart work, which other suites already cover. runCommandFn: (_job, _bin, _args, _timeout, env) => { updateEnv = env; return { status: 1, signal: null }; },