diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 6f534ae9f18..7120ca099f0 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -49,6 +49,8 @@ Connecting restarts OpenCodex on this computer. Codex turns that are already run The Child waits for its configured port while the old process releases it. If a CLI-managed restart still fails, run `ocx start` on the Child and check `~/.opencodex/restart-handoff.log`. In the desktop app, the app starts and supervises the replacement automatically. +`ocx stop` on a Child stops its proxy and leaves Codex routing, the catalog and the Grok config in place, so `ocx start` (or `ocx service start`) brings the Child back unchanged. If `ocx status` on the Child reports that Codex routing is native, run `ocx sync --restart-codex`; `ocx start` does not rewrite a Child's Codex routing. + If **Child** says to pair this machine first, open this computer's configured literal-loopback HTTP dashboard, for example `http://127.0.0.1:`. The local pairing form appears only when pairing is missing and the dashboard and API use the same loopback origin. Copy the form's `ocx gui pair --origin "http://127.0.0.1:"` command, run it in a terminal on this computer, and paste the one-use code into the form. Use the exact origin shown in the form; a provider API key or admin token is not a pairing code. Missing pairing is separate from a configured-port mismatch. On a Hub dashboard opened through a non-loopback address, the browser may have no loopback session or may have a shared session that cannot manage machine links. When Remote Link reports that session or permission problem, choose **Connect this dashboard to the hub** in the Remote Link page to open the one-time pairing form. Run the displayed command on the Hub and enter its code. The page validates the new session's Remote Link access before it closes the form. 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 9cf957db195..7e2e5c6d7ba 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -28,7 +28,13 @@ ocx start --socks5-off ### `ocx stop` -実行中のプロキシを (PID によって) 停止し、PID ファイルを削除して、ネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。 +実行中のプロキシを (PID によって) 停止し、PID ファイルを削除します。スタンドアロンの所有者ではネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。 + +接続済みの hub クライアントまたは Remote Link Child では、`ocx stop`(成功した API 停止を含む)は接続が所有する Codex のルーティング、モデルカタログ、Grok 設定を保持します。`ocx start` はルーティングを再注入せず、その設定で再び処理します。Codex を自身のアカウントへ戻すには `ocx disconnect`、既にネイティブルーティングへ戻した後の再適用には `ocx sync --restart-codex` を使います。 + +互換性を確認できない古い通常プロキシは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。 + +既に停止済みのクライアントでは、サービス管理側が非アクティブで、既知のローカル接続先にランタイムが存在しないことを確認してから、通常の未実行結果を返します。 ### `ocx restart` 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 2b062a19c67..d6a05fe430e 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -44,7 +44,7 @@ ocx start --socks5-off ### `ocx stop` -실행 중인 프록시를 PID 기준으로 중지하고, PID 파일을 삭제한 뒤 기본 Codex를 복원합니다. 관리형 +실행 중인 프록시를 PID 기준으로 중지하고 PID 파일을 삭제합니다. 독립 실행 소유자라면 기본 Codex를 복원합니다. 관리형 백그라운드 서비스가 설치되어 있으면 `ocx stop`이 먼저 그 서비스를 중지하므로 프록시가 다시 올라올 수 없습니다. 웹 대시보드의 **Stop** 버튼도 같은 동작(`POST /api/stop`)을 하지만, Windows 작업 스케줄러는 예외입니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 대시보드는 `respawnable_service`로 거절하고 아무것도 바꾸지 않은 채 `ocx stop` 실행을 안내합니다. @@ -54,6 +54,12 @@ ocx start --socks5-off 넘어가지 않습니다. 영수증에 근거한 지연 복원도 최종 복원과 영수증 정리를 부모가 담당하며, 부모의 공유 설정 복원이 실패하면 종료 실패로 남기고 미완료 영수증을 보존합니다. +연결된 hub 클라이언트나 Remote Link Child에서는 성공한 API 종료를 포함해 `ocx stop`이 연결 소유의 Codex 라우팅, 모델 카탈로그와 Grok 설정을 보존합니다. `ocx start`는 라우팅을 다시 주입하지 않고 보존한 설정으로 서비스를 재개합니다. Codex를 자체 계정으로 돌리려면 `ocx disconnect`를, 이미 기본 라우팅으로 복원했다면 다시 연결하려고 `ocx sync --restart-codex`를 실행하세요. + +보존 기능을 확인할 수 없는 오래된 일반 프록시는 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요. + +이미 중지된 클라이언트는 서비스 관리자가 비활성이고 알려진 로컬 주소에 런타임이 없음을 확인한 뒤 정상적인 미실행 결과를 반환합니다. + ### `ocx restart` 프록시가 실행 중이면 확인된 정확한 PID와 포트에 in-place 재시작을 요청하고, 정상 드레인을 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 3940a5c7a64..4cf53c31851 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -82,8 +82,19 @@ ocx start --socks5-off ### `ocx stop [--json]` -Stop the running proxy (by PID), remove the PID file, and restore native Codex. If a managed +Stop the running proxy (by PID), remove the PID file, and restore native Codex for standalone owners. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. +On a connected client (a Remote Link Child or a hub client), `ocx stop` leaves Codex routing, its +model catalog and the Grok config as they are, including after a successful API stop: the connection +owns them, and `ocx start` serves them again without re-injecting routing. Use `ocx disconnect` to hand Codex back to its own account. If Codex on a connected +client already reads native routing, `ocx status` names `ocx sync --restart-codex` to point it back. +A connected stop verifies the local runtime before changing its service: a client-role listener +with the matching PID is supported, while an ordinary proxy must explicitly advertise preservation +across API, signal and service shutdown. Older or unverified ordinary runtimes are refused before +stop actions. Update/restart that runtime through its supervisor and retry; use `ocx disconnect` +only when you intend to release the connection-owned integrations. +Repeated stops of an already-stopped client confirm service-manager inactivity and absence at the +known local endpoints before returning the normal not-running result. While the OpenCodex desktop app is running, `ocx stop` does not keep the proxy down: the app treats it as an unexpected exit and starts a proxy again, within seconds for one it started and after about a minute for one it had only attached to. Use the app's tray **Stop proxy** (for a proxy the app 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 08b8e46afa4..b19777c2e89 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -50,10 +50,16 @@ ocx start --socks5-off ### `ocx stop` -Остановить работающий прокси (по PID), удалить PID-file и восстановить native Codex. Если +Остановить работающий прокси (по PID) и удалить PID-file; для автономного владельца также восстановить native Codex. Если установлена managed background service, `ocx stop` сначала останавливает и её, чтобы она не перезапустила прокси обратно. Кнопка **Stop** в веб-дашборде выполняет то же действие (`POST /api/stop`) на всех бэкендах, кроме планировщика заданий Windows: там обёртка может перезапустить прокси после завершения задачи, поэтому дашборд отказывает с `respawnable_service`, ничего не меняет и просит выполнить `ocx stop`. +На подключённом клиенте hub или Remote Link Child команда `ocx stop`, включая успешную остановку через API, сохраняет принадлежащие соединению маршрутизацию Codex, каталог моделей и конфигурацию Grok. `ocx start` снова обслуживает их, не внедряя маршрутизацию заново. Чтобы вернуть Codex к собственному аккаунту, выполните `ocx disconnect`; для повторного применения уже восстановленной нативной маршрутизации используйте `ocx sync --restart-codex`. + +Старая обычная версия прокси без подтверждённой поддержки сохранения отклоняется до остановки службы или процесса. Обновите и перезапустите её через управляющий супервизор, затем повторите команду. + +Для уже остановленного клиента команда подтверждает неактивность службы и отсутствие рантайма на известных локальных адресах, затем возвращает обычный результат «не запущен». + ### `ocx restart` Если прокси уже работает, команда запрашивает перезапуск на месте у точно аттестованных PID и 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 92d9e89cb40..4ce49ff65f0 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,7 +28,13 @@ ocx start --socks5-off ### `ocx stop` -停止正在运行的代理(按 PID),移除 PID 文件,并恢复原生 Codex。如果安装了受管后台服务,`ocx stop` 还会先停止该服务,这样它就无法重新拉起代理。Web 仪表盘的 **Stop** 按钮在多数后端执行同样的操作(`POST /api/stop`),但 Windows 任务计划程序除外:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口,因此仪表盘会以 `respawnable_service` 拒绝、不做任何更改,并提示改用 `ocx stop`。 +停止正在运行的代理(按 PID),移除 PID 文件;独立运行的所有者还会恢复原生 Codex。如果安装了受管后台服务,`ocx stop` 还会先停止该服务,这样它就无法重新拉起代理。Web 仪表盘的 **Stop** 按钮在多数后端执行同样的操作(`POST /api/stop`),但 Windows 任务计划程序除外:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口,因此仪表盘会以 `respawnable_service` 拒绝、不做任何更改,并提示改用 `ocx stop`。 + +对于已连接的 hub 客户端或 Remote Link Child,`ocx stop`(包括成功的 API 停止)会保留连接所拥有的 Codex 路由、模型目录和 Grok 配置。`ocx start` 使用这些保留的配置恢复服务,不会重新注入路由。要让 Codex 回到自己的账户,请运行 `ocx disconnect`;若已恢复为原生路由,要重新应用连接路由,请运行 `ocx sync --restart-codex`。 + +若旧版普通代理无法确认支持保留配置,命令会在停止服务或进程前拒绝操作。请通过管理该运行时的应用或服务更新并重启它,然后重试。 + +对于已停止的客户端,命令会确认服务管理器未活动且已知本地端点没有运行时,然后返回正常的“未运行”结果。 ### `ocx restart` diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 29b7b2b7471..48362d5a839 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -25,7 +25,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | Chapter | Declared capabilities | |---|---| -| [lifecycle](01_surface_lifecycle.md) | 13 | +| [lifecycle](01_surface_lifecycle.md) | 14 | | [providers-models](01_surface_providers-models.md) | 49 | | [accounts](01_surface_accounts.md) | 42 | | [agents-routing](01_surface_agents-routing.md) | 67 | @@ -999,6 +999,10 @@ Original invocation order. These headings preserve links to the previous single- [Read-oriented task](01_surface_integrations.md#ocx-integration-native-cursor-local-installer) +### `ocx stop` + +[State-changing task](01_surface_lifecycle.md#ocx-stop) + ### `ocx logs filter` [Read-oriented task](01_surface_observe-system.md#ocx-logs-filter) @@ -1497,6 +1501,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 362 -- of those, state-changing: 221 +- declared capabilities: 363 +- of those, state-changing: 222 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_lifecycle.md b/skills/ocx/references/01_surface_lifecycle.md index a661dda4707..8e93c6a3fe2 100644 --- a/skills/ocx/references/01_surface_lifecycle.md +++ b/skills/ocx/references/01_surface_lifecycle.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 13. +Declared capabilities: 14. ### `ocx codex-provider` @@ -100,6 +100,27 @@ State-changing: yes. JSON mode: `none`. - The Aside refresh uses the live server; other catalog synchronization also performs local work. +### `ocx stop` + +Usage: `ocx stop [--json]` + +Stop the proxy; preserve connected-client integrations, restore native Codex config for standalone owners. + +State-changing: yes. JSON mode: `envelope`. + +| Method | Route | +|---|---| +| GET | `/api/stop` | +| POST | `/api/stop` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit one structured stop summary; human output moves to stderr. | + +- Connected clients verify runtime preservation support before stopping; unavailable or unsupported live targets refuse without stopping the service. Proven-stopped clients remain successful when the manager is inactive. +- Siblings and connected clients preserve shared integrations. Standalone owners restore native Codex configuration and remove the managed Grok block. +- JSON uses ocx-stop/1 and retains the same exit codes: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred). + ### `ocx service install` Usage: `ocx service install [--native|--scheduler]` diff --git a/src/cli/capabilities-observe-system.ts b/src/cli/capabilities-observe-system.ts index 7f9b627fc84..9f413835d54 100644 --- a/src/cli/capabilities-observe-system.ts +++ b/src/cli/capabilities-observe-system.ts @@ -2,6 +2,16 @@ import type { Capability } from "./capability-types"; /** Verified existing handlers only; local operations never claim management routes. */ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [ + { + command: ["stop"], + usage: "ocx stop [--json]", + summary: "Stop the proxy; preserve connected-client integrations, restore native Codex config for standalone owners.", + routes: [{ method: "GET", path: "/api/stop" }, { method: "POST", path: "/api/stop" }], + flags: [{ name: "--json", value: "boolean", summary: "Emit one structured stop summary; human output moves to stderr." }], + mutates: true, + json: "envelope", + details: ["Connected clients verify runtime preservation support before stopping; unavailable or unsupported live targets refuse without stopping the service. Proven-stopped clients remain successful when the manager is inactive.", "Siblings and connected clients preserve shared integrations. Standalone owners restore native Codex configuration and remove the managed Grok block.", "JSON uses ocx-stop/1 and retains the same exit codes: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred)."], + }, {"command": ["logs", "filter"], "summary": "Select a bounded log snapshot with dashboard filters.", "usage": "ocx logs filter [--surface ] [--model ] [--provider ] [--status ] [--time-window ] [--min-tok-per-sec ] [--max-tok-per-sec ] [--intercepted-only] [--protocol-mode ] [--conversation ] [--scan-limit <1-2000>] [--limit <1-2000>] [--json|--jsonl]", "routes": [{"method": "GET", "path": "/api/logs"}], "flags": [{"name": "--surface", "value": "string", "summary": "all | codex | claude | grok."}, {"name": "--model", "value": "string", "summary": "Case-insensitive exact identity across requested, resolved, served and attempted models."}, {"name": "--provider", "value": "string", "summary": "Case-insensitive provider or failover attempt identity."}, {"name": "--status", "value": "string", "summary": "all | success (200-299) | errors (400-599)."}, {"name": "--time-window", "value": "string", "summary": "all | 15m | 1h | 24h, relative to observation time."}, {"name": "--min-tok-per-sec", "value": "number", "summary": "Inclusive minimum observed token speed."}, {"name": "--max-tok-per-sec", "value": "number", "summary": "Exclusive maximum observed token speed."}, {"name": "--intercepted-only", "value": "boolean", "summary": "Rows carrying an observed rewrite marker."}, {"name": "--protocol-mode", "value": "string", "summary": "all | native | translated | legacy-bridge | blocked | none."}, {"name": "--conversation", "value": "string", "summary": "Conversation ID or stored hash."}, {"name": "--conversationId", "value": "string", "summary": "Alias of --conversation."}, {"name": "--scan-limit", "value": "number", "summary": "1-2000 raw rows inspected; default 2000."}, {"name": "--limit", "value": "number", "summary": "1-2000 matching rows returned; default 200."}, {"name": "--json", "value": "boolean", "summary": "Versioned view with filters, cursor and observed-window counts."}, {"name": "--jsonl", "value": "boolean", "summary": "Matching rows only; exclusive with --json."}], "mutates": false, "json": "envelope", "details": ["One snapshot: filters run locally over the raw server window before the output limit. No follow/events; existing logs behavior remains available.", "JSON schemaVersion1 reports scanLimit, loaded, matched, returned and limit for this observed window only. Cursor is not a resumable filter token.", "Empty matches succeed; malformed, refused, oversized or unavailable responses fail. SIGINT130/SIGTERM143 preserve cancellation."]}, {"command": ["observe", "logs", "filter"], "summary": "Select a bounded log snapshot with dashboard filters.", "usage": "ocx observe logs filter [--surface ] [--model ] [--provider ] [--status ] [--time-window ] [--min-tok-per-sec ] [--max-tok-per-sec ] [--intercepted-only] [--protocol-mode ] [--conversation ] [--scan-limit <1-2000>] [--limit <1-2000>] [--json|--jsonl]", "routes": [{"method": "GET", "path": "/api/logs"}], "flags": [{"name": "--surface", "value": "string", "summary": "all | codex | claude | grok."}, {"name": "--model", "value": "string", "summary": "Case-insensitive exact identity across requested, resolved, served and attempted models."}, {"name": "--provider", "value": "string", "summary": "Case-insensitive provider or failover attempt identity."}, {"name": "--status", "value": "string", "summary": "all | success (200-299) | errors (400-599)."}, {"name": "--time-window", "value": "string", "summary": "all | 15m | 1h | 24h, relative to observation time."}, {"name": "--min-tok-per-sec", "value": "number", "summary": "Inclusive minimum observed token speed."}, {"name": "--max-tok-per-sec", "value": "number", "summary": "Exclusive maximum observed token speed."}, {"name": "--intercepted-only", "value": "boolean", "summary": "Rows carrying an observed rewrite marker."}, {"name": "--protocol-mode", "value": "string", "summary": "all | native | translated | legacy-bridge | blocked | none."}, {"name": "--conversation", "value": "string", "summary": "Conversation ID or stored hash."}, {"name": "--conversationId", "value": "string", "summary": "Alias of --conversation."}, {"name": "--scan-limit", "value": "number", "summary": "1-2000 raw rows inspected; default 2000."}, {"name": "--limit", "value": "number", "summary": "1-2000 matching rows returned; default 200."}, {"name": "--json", "value": "boolean", "summary": "Versioned view with filters, cursor and observed-window counts."}, {"name": "--jsonl", "value": "boolean", "summary": "Matching rows only; exclusive with --json."}], "mutates": false, "json": "envelope", "details": ["One snapshot: filters run locally over the raw server window before the output limit. No follow/events; existing logs behavior remains available.", "JSON schemaVersion1 reports scanLimit, loaded, matched, returned and limit for this observed window only. Cursor is not a resumable filter token.", "Empty matches succeed; malformed, refused, oversized or unavailable responses fail. SIGINT130/SIGTERM143 preserve cancellation."]}, {"command": ["companion", "usage"], "summary": "Read today and 30-day totals using saved companion filters.", "usage": "ocx companion usage [--json]", "routes": [{"method": "GET", "path": "/api/companion/settings"}, {"method": "GET", "path": "/api/usage"}], "flags": [{"name": "--json", "value": "boolean", "summary": "Versioned filtered ranges, settings provenance and partial status."}], "mutates": false, "json": "envelope", "details": ["Reads saved settings then two usage ranges on the same runtime; this is not an atomic snapshot. No connected-client management relay or local fallback.", "Preserves null/default/corrupt settings provenance, missing measurements and incomplete reports. Filters follow saved model and hidden-provider preferences.", "Any unavailable range yields partial:true and exit1 while preserving the other result. Both available yields exit0 even when incomplete. SIGINT130/SIGTERM143 emit no late report."]}, diff --git a/src/cli/index.ts b/src/cli/index.ts index 4a83719a2cc..9b59169f38d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -77,7 +77,7 @@ import { pendingTeardownsAreExactly, quarantinePendingTeardown, } from "../config/pending-teardown"; -import { collectStatus, deadProxyRoutingAdviceLines, detectMissingCodexCatalogPath, hubStatusLines, missingCodexCatalogLines, remoteHubBannerLine, remoteHubStatusLines, runtimeSupervisorLine, unusedProxyWarningLines } from "./status"; +import { collectStatus, detectMissingCodexCatalogPath, hubStatusLines, missingCodexCatalogLines, remoteHubBannerLine, remoteHubStatusLines, runtimeSupervisorLine, statusDeadProxyLines, statusProxyUp, statusUnusedProxyLines } from "./status"; import { endpointsToProve, everyEndpointProvenDownAsync, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; import { parseStartOptions, StartArgsError } from "./start-args"; @@ -127,6 +127,7 @@ import { inspectGuardedManagerTarget, observeGuardedManagerStopped } from "../se import { summarizeStopRun, type StopOutcome, type StopRunRecord } from "./stop-report"; import { runCli } from "./root"; import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control"; +import { directLocalHttpFetch } from "../server/direct-local-http"; import { startupDataPlaneToken } from "../lib/service-secrets"; import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatePaths, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; @@ -162,7 +163,7 @@ import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { startArgv } from "../lib/self-launch-argv"; import { initializeNodeLauncherContext } from "./launcher-context"; -import { restoreSharedClientStateAfterStop } from "./stop-restore"; +import { assertConnectedClientStopSupported, connectedClientStopSkipLine, preflightConnectedClientStop, preserveConnectedClientRoutingOnExit, restoreSharedClientStateAfterStop } from "./stop-restore"; import { startClientRuntimeUnderOwnershipLease } from "./client-start-fence"; import { recoverStartStateUnderOwnershipLease } from "./start-owner-fence"; import { handledSignalExitCode } from "../lib/handled-signal-exit"; @@ -654,7 +655,7 @@ async function handleStart(options: { block?: boolean } = {}) { try { healthSentinel?.stop(); } catch { /* best-effort */ } // Dashboard drain-and-restart (#563) must not tear down injection: the replacement // process expects Codex/Grok/env fences to still be in place. A sibling owns none of them. - const teardown = decideStartExitTeardown({ sibling: siblingStart, recycling: isRecyclingForExit(), ocxService: process.env.OCX_SERVICE }); + const teardown = preserveConnectedClientRoutingOnExit(decideStartExitTeardown({ sibling: siblingStart, recycling: isRecyclingForExit(), ocxService: process.env.OCX_SERVICE })); if (teardown.revertSystemEnv) { try { revertSystemEnv(); } catch { /* best-effort */ } } @@ -995,8 +996,6 @@ async function handleRestartStartWhenStopped(recoveringLiveRestart = false): Pro return restartStartOutcome(false, child, error); } } - - async function handleStop(approval?: StopApproval) { const lease = acquireOwnershipMutationLease(serviceStatePaths()); try { @@ -1088,6 +1087,13 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { // the record; a record left behind by a hard-killed sibling still says so. const siblingOfPort = readRuntimePort()?.siblingOfPort; const stoppingSibling = siblingOfPort !== undefined; + const connectedClientSkip = stoppingSibling ? null : connectedClientStopSkipLine(readClientConnectionState()); + let connectedClientAlreadyStopped = false; + if (connectedClientSkip) { + const preflight = await preflightConnectedClientStop(snapshot?.approval); + if (preflight === "already-stopped") connectedClientAlreadyStopped = true; + else if (preflight) return preflight; + } // Structured twin of the human lines below, for `ocx stop --json`: one document the // desktop shell can read across the process boundary (D4). Every field is assigned // where the corresponding boolean already flips — the summarizer never re-decides. @@ -1126,6 +1132,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { let teardownNonce: string | undefined; const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => { if (teardownNonce || stoppingSibling) return; + if (connectedClientSkip) return; try { teardownNonce = claimPendingTeardown(endpoint, endpointSource).nonce; } catch (err) { @@ -1135,33 +1142,17 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { console.warn(`⚠️ Could not record the deferred-teardown receipt: ${err instanceof Error ? err.message : String(err)}`); } }; - /** - * One stop target, used for BOTH the receipt and the request. - * - * Deriving them separately meant the receipt could name a different endpoint than the - * one actually contacted, and a proxy with no runtime record got no receipt at all — - * silently reopening the parent-crash window on the path where the stop is a hard kill - * and no child teardown runs at all. - * - * So the caller supplies the endpoint it discovered after the orphan listener proves - * possession of this home's runtime record, even when the pid file is gone. - * - * When nothing resolves, the graceful request cannot be made at all — `stopProxy` goes - * straight to the kill ladder, no child teardown runs, and there is no receipt to leave - * behind. A warning does not make that durable, so the receipt is claimed FIRST against - * the endpoint this process would restore anyway. It is the configured listen address, - * which is the same address every recovery probe would ask about, and an obligation - * recorded against it is strictly better than none: at worst the probe cannot confirm - * A guessed endpoint is NOT evidence, so the receipt records which kind it holds: a - * guessed one fails closed into manual recovery rather than letting a later probe read - * "the configured port refuses" as proof that the right proxy is down. - */ + // Resolve one target for receipt and request. Only a discovered endpoint may direct POST; + // a configured guess records an obligation but is never evidence for orphan recovery. + // Claim before any kill fallback so a parent crash leaves a durable teardown obligation. const stopWithDeferral = async (pid: number, discovered?: { hostname: string; port: number } | null): Promise => { // Resolve ONCE. Reading the runtime record twice let the receipt name the configured // guess while the request went to a runtime endpoint that appeared in between. const exact = discovered ?? endpointOf(readRuntimePort(pid)); + if (connectedClientSkip) await assertConnectedClientStopSupported(exact ? { ...exact, pid } : null); claimTeardown(exact ?? configuredEndpoint(), exact ? "exact" : "guessed"); const graceful = await stopProxy(pid, { + fetchFn: connectedClientSkip ? directLocalHttpFetch as typeof fetch : undefined, deferSharedTeardownNonce: teardownNonce, // Only an exact endpoint may direct the request; the configured fallback is a guess // good enough to record an obligation against, not to POST a stop to. @@ -1191,7 +1182,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { })).service // A sibling never runs under a service manager: an installed one belongs to the live owner, // and its ownership check would fail this stop and skip the stale-record purge below. - : stoppingSibling ? "absent" : stopServiceIfInstalledDetailed(); + : stoppingSibling || connectedClientAlreadyStopped ? "absent" : stopServiceIfInstalledDetailed(); record.service = serviceStop; stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable"; schedulerCanRespawn = serviceStop === "stopped-respawnable"; @@ -1430,9 +1421,10 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { console.error(" The obligation is preserved; retry once the proxy is confirmed stopped."); } } - if (nativeRestoreHandledByProxy && !stoppingSibling) record.sharedTeardown = "performed-by-proxy"; + if (nativeRestoreHandledByProxy && !stoppingSibling && !connectedClientSkip) record.sharedTeardown = "performed-by-proxy"; if (stoppingSibling) console.log(`↩️ Client routing stays on the proxy at port ${siblingOfPort}; this sibling had no shared teardown to run.`); - const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling; + if (connectedClientSkip) console.log(connectedClientSkip); + const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling || connectedClientSkip !== null; if (!restoreBlocked) { if (recoveredNonces.length > 0) { // A previous deferred stop died before restoring, and the probe says its endpoint is @@ -1735,7 +1727,7 @@ async function handleStatus() { // `(local)` only while connected: on a standalone install every line is local, and tagging // them all would be noise that trains the reader to skip the tag. const local = status.json.remoteHub.connected ? " (local)" : ""; - if (status.json.proxy.pid || status.json.proxy.health.ok) { + if (statusProxyUp(status.json)) { console.log(`✅ Proxy: ${status.proxyLabel}${local}`); } else { console.log(`❌ Proxy: ${status.proxyLabel}${local}`); @@ -1752,14 +1744,10 @@ async function handleStatus() { if (status.json.versionSkew.warning) { console.log(` ⚠️ ${status.json.versionSkew.warning}`); } - for (const line of unusedProxyWarningLines({ - proxyUp: Boolean(status.json.proxy.pid || status.json.proxy.health.ok), - routingKind: status.json.startup.routingKind, - })) { + for (const line of statusUnusedProxyLines(status.json)) { console.log(` ${line}`); } - if (!(status.json.proxy.pid || status.json.proxy.health.ok)) { - console.log(" ↳ Not running — Codex/Claude requests will fail with connection errors."); + if (!statusProxyUp(status.json)) { // The service summary a few lines below already tells a registered-but-not-serving // user to repair. Printing "install the persistent service" unconditionally // contradicted it in the same report, and install re-registers: UAC on Windows and a @@ -1781,10 +1769,7 @@ async function handleStatus() { : " Restart with 'ocx start', or install the persistent service: 'ocx service install'."); // Restarting is only half the choice. A user who cannot sign in to Codex at all needs the // way out that does not require this proxy to come back (#5261). - for (const line of deadProxyRoutingAdviceLines({ - proxyUp: false, - routingKind: status.json.startup.routingKind, - })) { + for (const line of statusDeadProxyLines(status.json)) { console.log(` ${line}`); } } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 70290fdbe53..2544078424b 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -50,7 +50,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "stop", usage: "ocx stop [--json]", - summary: "Stop the proxy and restore native Codex config.", + summary: "Stop the proxy; preserve connected-client integrations, restore native Codex config for standalone owners.", details: [ "--json keeps the stop path unchanged and prints one structured summary document on stdout; human output moves to stderr.", "Exit codes are identical with and without --json: 0, 1, 79 (history cleanup incomplete), 80 (teardown deferred).", diff --git a/src/cli/status.ts b/src/cli/status.ts index b5722ac9dca..b70c5364377 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -715,12 +715,34 @@ export function resolveStatusPid( * unused", but startupHealthSummary already renders both as AT RISK with a * remedy command, and `custom-remote` is a deliberate operator choice. Warning * on all four would teach operators to skip the line that matters. + * + * A connected client (hub client or Remote Link Child) needs a different remedy (#7563): its + * runtime never writes Codex routing on start, so `ocx start` cannot re-point it — with the + * proxy up it only answers "already running". `ocx sync --restart-codex` writes the routing + * and restarts Codex. A client whose proxy is also down is told to start it first, because + * nothing else in the not-running report mentions that its routing is native. */ export function unusedProxyWarningLines(input: { proxyUp: boolean; routingKind: StartupHealth["routingKind"]; + connectedClient?: { transport: "hub" | "link"; serviceInstalled: boolean }; }): string[] { - if (!input.proxyUp || input.routingKind !== "native") return []; + if (input.routingKind !== "native") return []; + const client = input.connectedClient; + if (client) { + const peer = client.transport === "link" ? "the Home" : "the hub"; + if (input.proxyUp) { + return [ + "⚠️ Codex routing is native — this client's proxy is unused.", + ` Codex requests go to OpenAI, not ${peer}. Re-point with: ocx sync --restart-codex`, + ]; + } + return [ + `⚠️ Codex routing is native, so Codex requests go to OpenAI, not ${peer}.`, + ` Start this client's proxy with '${client.serviceInstalled ? "ocx service start" : "ocx start"}', then re-point with: ocx sync --restart-codex`, + ]; + } + if (!input.proxyUp) return []; return [ "⚠️ Codex routing is native — the running proxy is unused.", " Codex requests go to OpenAI, not this proxy. Re-point with: ocx start", @@ -746,11 +768,47 @@ export function unusedProxyWarningLines(input: { export function deadProxyRoutingAdviceLines(input: { proxyUp: boolean; routingKind: StartupHealth["routingKind"]; + /** #7563: on a connected client nothing but `ocx sync` writes the routing back after a restore. */ + connectedClient?: boolean; }): string[] { if (input.proxyUp || input.routingKind !== "opencodex-local") return []; return [ "Codex is still pointed at this proxy, so sign-in and model requests both fail while it is down.", "To hand Codex back to its own account and endpoints without starting anything: ocx restore", + ...(input.connectedClient ? ["After a restore, 'ocx sync --restart-codex' points Codex back at this client."] : []), + ]; +} + +type StatusRoutingView = Pick; + +export function statusProxyUp(json: StatusRoutingView): boolean { + return json.remoteHub.connected && json.connection.transport === "link" + ? json.proxy.health.ok : Boolean(json.proxy.pid || json.proxy.health.ok); +} + +/** `unusedProxyWarningLines` for the human `ocx status` report, role taken from the report itself. */ +export function statusUnusedProxyLines(json: StatusRoutingView): string[] { + return unusedProxyWarningLines({ + proxyUp: statusProxyUp(json), + routingKind: json.startup.routingKind, + ...(json.remoteHub.connected ? { connectedClient: { + transport: json.connection.transport === "link" ? "link" as const : "hub" as const, + serviceInstalled: json.startup.serviceInstalled && !json.startup.serviceConflict, + } } : {}), + }); +} + +/** `deadProxyRoutingAdviceLines` for the not-running half of the human `ocx status` report. */ +export function statusDeadProxyLines(json: StatusRoutingView): string[] { + return [ + json.startup.routingKind === "native" + ? "↳ Not running — Claude requests will fail with connection errors." + : "↳ Not running — Codex/Claude requests will fail with connection errors.", + ...deadProxyRoutingAdviceLines({ + proxyUp: false, + routingKind: json.startup.routingKind, + connectedClient: json.remoteHub.connected, + }), ]; } diff --git a/src/cli/stop-restore.ts b/src/cli/stop-restore.ts index 6b6bcb791b2..3003df0300a 100644 --- a/src/cli/stop-restore.ts +++ b/src/cli/stop-restore.ts @@ -1,6 +1,119 @@ import { restoreNativeCodexAsync } from "../codex/inject"; import type { RetainedCodexProviderTable } from "../codex/inject/restore"; import { stripGrokConfig } from "../grok/inject"; +import { readClientConnectionState, type ClientConnectionState } from "../client/state"; +import { readPid, readRuntimePort } from "../config/process-state"; +import { loadConfig } from "../config"; +import { configuredAdminToken } from "../lib/admin-secrets"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import { gracefulStopHost, isProcessAlive, ProxyOwnershipRefusedError } from "../lib/process-control"; +import { directLocalHttpFetch } from "../server/direct-local-http"; +import { findLiveProxy, probeEndpointLiveness, proveLiveProxyOwnedByHome } from "../server/proxy-liveness"; +import { observeGuardedManagerStopped } from "../service/guarded-manager-target"; +import type { StartExitTeardown } from "./dispatch"; +import type { StopOutcome } from "./stop-report"; + +/** Re-read ownership when an ordinary runtime actually exits, including after API drain. */ +export function preserveConnectedClientRoutingOnExit(teardown: StartExitTeardown): StartExitTeardown { + return readClientConnectionState().kind === "connected" + ? { ...teardown, restoreNativeCodex: false, stripGrokConfig: false } + : teardown; +} + +type ConnectedStopTarget = { pid: number; port: number; hostname?: string }; +const CONNECTED_STOP_PROBE_MS = 2000; +const unsupportedConnectedStop = () => new ProxyOwnershipRefusedError( + "Connected-client stop refused: runtime identity, shutdown preservation or safe runtime absence could not be verified. Inspect 'ocx service status' and update/restart an incompatible runtime through its supervisor, then retry; use 'ocx disconnect' only if you intend to release those integrations.", + "connected_client_stop_unsupported", +); + +/** A positive, PID-bound contract is required; old and new builds may share a version. */ +export async function assertConnectedClientStopSupported(target: ConnectedStopTarget | null): Promise { + if (!target) throw unsupportedConnectedStop(); + if (!isProcessAlive(target.pid)) return; // A manager may already have ended this exact target. + const origin = `http://${gracefulStopHost(target.hostname)}:${target.port}`; + const signal = AbortSignal.timeout(CONNECTED_STOP_PROBE_MS); + const probe = async (path: string, authenticated: boolean): Promise | null> => { + try { + const headers: Record = {}; + if (authenticated) { + const token = configuredAdminToken(process.env.OPENCODEX_HOME?.trim() || undefined, process.env); + if (token) headers["x-opencodex-api-key"] = token; + } + const response = await directLocalHttpFetch(`${origin}${path}`, { method: "GET", headers, redirect: "error", signal }, + { timeoutMs: CONNECTED_STOP_PROBE_MS }); + if (!response.ok) { void response.body?.cancel().catch(() => {}); return null; } + const body = await readBoundedResponseBody(response, { maxBytes: 4096, signal, fatalUtf8: true }); + if (body.truncated) return null; + const parsed: unknown = JSON.parse(body.text); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; + } catch { return null; } + }; + const health = await probe("/healthz", false); + // The native machine listener has always preserved integrations on signal and 404s /api/stop. + if (health?.service === "opencodex" && health.role === "client" && health.pid === target.pid) return; + const capability = await probe("/api/stop", true); + if (capability?.schema === "ocx-stop-capabilities/1" + && capability.pid === target.pid && capability.connectedClientOwnership === true) return; + throw unsupportedConnectedStop(); +} + +/** Fail before either service-manager path, including a manager that cascades process exit. */ +export async function preflightConnectedClientStop(approved?: ConnectedStopTarget): Promise { + try { + let target = approved; + if (!target) { + const pid = readPid(); + const runtime = readRuntimePort(pid ?? undefined); + if (pid && isProcessAlive(pid)) { + if (!runtime) throw unsupportedConnectedStop(); + target = { ...runtime, pid }; + } + else { + const live = await findLiveProxy({ acceptPackageTreeFenced: true }); + if (live?.pid) { + if (await proveLiveProxyOwnedByHome(live) !== "proven") throw unsupportedConnectedStop(); + target = { ...live, pid: live.pid }; + } else { + const config = loadConfig(); + const endpoints = [...(runtime ? [runtime] : []), { port: config.port, hostname: config.hostname }]; + if (endpoints.some(endpoint => !Number.isInteger(endpoint.port) || endpoint.port <= 0 || endpoint.port > 65535) + || await observeGuardedManagerStopped({ kind: "absent" }) !== "inactive") throw unsupportedConnectedStop(); + for (const endpoint of endpoints) { + if (await probeEndpointLiveness(endpoint) !== "dead") throw unsupportedConnectedStop(); + } + return "already-stopped"; + } + } + } + if (!isProcessAlive(target.pid)) throw unsupportedConnectedStop(); + await assertConnectedClientStopSupported(target); + return null; + } catch { + const error = unsupportedConnectedStop(); + console.error(error.message); + process.exitCode = 1; + return { ok: false, summary: { schema: "ocx-stop/1", ok: false, outcome: "failed", exitCode: 1, + runtimeDown: false, service: "absent", proxy: "ownership-refused", sharedTeardown: "skipped", message: error.message } }; + } +} + +/** + * The line `ocx stop` prints in place of the shared teardown on a connected client, or null + * when this home is not one (#7563). + * + * A hub client or Remote Link Child gets its Codex routing and catalog from `ocx connect` / + * `ocx sync`, and its client runtime never writes them on start. The standalone teardown + * restored native Codex, rewrote the catalog and stripped the Grok block, and nothing on the + * client put any of it back: `ocx start` brought the runtime up with Codex still native and + * Grok unconfigured. Leaving them in place makes stop/start a round trip; `ocx disconnect` + * remains the teardown that owns them, with its own ownership checks. + */ +export function connectedClientStopSkipLine(state: ClientConnectionState): string | null { + if (state.kind !== "connected") return null; + const role = state.value.transport === "link" ? "Remote Link Child" : "hub client"; + return `↩️ Codex routing, its catalog and the Grok config stay as they are: this ${role}'s connection owns them, and 'ocx start' serves them again. To hand Codex back to its own account, run 'ocx disconnect'.`; +} /** * Restore shared client state after a stop. diff --git a/src/server/management-api.ts b/src/server/management-api.ts index e4baeac4d71..177d66cc84b 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -411,6 +411,11 @@ export async function handleManagementAPI( } if (routed) return routed; + if (url.pathname === "/api/stop" && req.method === "GET") { + const response = jsonResponse((await import("./stop-teardown")).stopCapabilities(), 200, req, config); + response.headers.set("Cache-Control", "no-store"); + return response; + } if (url.pathname === "/api/stop" && req.method === "POST") { const { installedServiceRespawnRisk, stopServiceIfInstalledDetailed, isServiceOwnershipError } = await import("../service"); // `ocx stop` performs its own shared teardown AFTER verifying the scheduler did not diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 5e11e8f6e32..4dcb19d7167 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -95,6 +95,7 @@ export interface ManagementRoute { /** Every reachable management route. */ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management-api + { method: "GET", path: "/api/stop", module: "server/management-api", mutates: false }, { method: "POST", path: "/api/stop", module: "server/management-api", mutates: true }, // codex/auth-api { method: "GET", path: "/api/codex-auth/preserve-signed-in", module: "codex/auth-api/routes", mutates: false }, diff --git a/src/server/stop-teardown.ts b/src/server/stop-teardown.ts index 965e1659082..d31e2b5dd19 100644 --- a/src/server/stop-teardown.ts +++ b/src/server/stop-teardown.ts @@ -15,6 +15,11 @@ import type { ManagementPrincipal } from "./management-auth"; export type GrokStripResult = { ok: boolean; changed: boolean; message: string }; +/** Read-only promise covering API teardown and the CLI runtime's signal/manager exit cleanup. */ +export function stopCapabilities() { + return { schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: process.pid }; +} + export type StopTeardownIo = { /** Does the nonce this request carries name a readable obligation on disk? */ ownsReceipt?: (nonce: string | null) => boolean; @@ -25,7 +30,7 @@ export type StopTeardownIo = { export type StopTeardownBody = { success: boolean; message: string; - /** `not-owned`: a sibling instance, whose shared client routing belongs to the live owner. */ + /** `not-owned`: shared routing belongs to a live sibling owner or a client connection. */ sharedTeardown: "deferred" | "performed" | "not-owned"; }; @@ -79,6 +84,16 @@ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Pr if (siblingOfLivePort() !== null) { return { success: true, message: `Proxy stopping. ${siblingSkipMessage()}`, sharedTeardown: "not-owned" }; } + // A connection owns its integrations across stop/start, even for a successful API stop. + // This is persisted authority, not a caller's request to skip an obligation it owns. + const connection = (await import("../client/state")).readClientConnectionState(); + if (connection.kind === "connected") { + return { + success: true, + message: "Proxy stopping; connection-owned Codex routing, catalog and Grok config preserved. Run `ocx disconnect` to release them.", + sharedTeardown: "not-owned", + }; + } const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt; if (deferralHonored(url, ownsReceipt)) { // Not "native Codex restored": nothing was restored here, and claiming otherwise diff --git a/structure/cli-management.md b/structure/cli-management.md index 3057ebb10fe..ba33a69f227 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -43,6 +43,8 @@ The agents/routing leaf declares local `message sessions` and `message send`, pl ## Generated operating reference +The stop declaration in `src/cli/capabilities-observe-system.ts` covers the read-only preservation preflight and shutdown methods at `/api/stop`, both declared in `src/server/management/route-registry.ts`. Discovery and generated help describe the existing `ocx stop [--json]` envelope and ownership exceptions; the declaration grants no shutdown authority. [Runtime](runtime.md) owns refusal, absence and teardown semantics. + `scripts/generate-ocx-skill-surface.ts` renders the compact `skills/ocx/references/01_management_surface.md` index and eight flat domain chapters from the same capability data. The index retains canonical fragment forwarders and links to complete chapter entries. Counts derive from declarations; grouping does not choose runtime dispatch or grant authority. Unknown roots and duplicate anchors are rejected rather than silently omitted. Generated flag-table cells escape literal pipes, backslashes and line breaks, and absent value types render as empty cells. Rendered Markdown regressions verify that enum choices stay in the meaning cell. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index ea886fa80e7..44729c1de4a 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -286,7 +286,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. Fast observations and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority): the provider editor classifies `responseTierAuthoritative` as an operator-owned boolean under the existing redaction and write boundaries, `src/usage/log.ts` retains it on final outcomes and attempts (old records keep their legacy meaning), and `src/usage/cost.ts` never prices a non-authoritative echo or confirmed label as response-confirmed. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | -| Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | +| Stop | `GET /api/stop` reports a read-only PID-bound connected-client shutdown-preservation capability. `POST /api/stop` — stop any installed service and exit the proxy; ordinary owners restore native Codex/Grok. Siblings and validated connected hub/Link clients preserve shared integrations (`sharedTeardown: "not-owned"`; [runtime contract](runtime.md#lifecycle)). A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`, and `GET/PUT /api/auto-review-settings`. The auto-review settings route validates the global reviewer selector, persists its enabled/model fields with rollback before publication, then converges the catalog and returns its disposition; the Models panel keeps the server echo and displays a pending-sync notice whenever no commit is reported. Sidecar PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. An effective `vision.enabled` change persists before one bounded catalog-only convergence updates the managed catalog and Codex model cache; the PUT response returns `catalogRefreshPending` for incomplete work. Identical effective values and unrelated partial writes do not converge, and no result claims client reload. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-6-luna` and `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. PUT refuses a qualified target that only the router's default-provider fallback accepts. Provider disable and delete report the target they leave behind as `dependentShadowIntercept` (`shadowInterceptProviderDependency` in `src/server/management/shadow-call-validation.ts`), and at request time an unresolvable target returns `409 intercept_target_unavailable` before any send (`src/server/responses/shadow-target-availability.ts`); it never falls back to the native source model or the default provider. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | @@ -447,7 +447,7 @@ The desktop shell needs only the dashboard, so it attaches to a client-role list The dashboard sidebar includes a stop button that calls `POST /api/stop`. The button shows a confirmation prompt, then fires the request and accepts the connection drop (the proxy exits). The -endpoint restores native Codex config, stops any installed service to prevent respawn, and exits. +endpoint restores native Codex config for ordinary owners; connected clients and siblings preserve their integrations. It stops any installed service to prevent respawn and exits, as described in the [runtime contract](runtime.md#lifecycle). While the desktop app supervises the proxy it refuses a dashboard session with 409 `desktop_supervised` before touching anything, because the app would start the proxy again ([desktop shell](desktop-shell.md#keeping-the-runtime-alive)); the button reports the refusal. diff --git a/structure/runtime.md b/structure/runtime.md index e6e7664cb19..65a345ef8dd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -107,7 +107,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). An inherited explicit `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is scoped to a `bun-` leaf beneath the configured directory — appended on a version change, kept unchanged when the path already ends in that leaf — for the bundled or PATH runtime it starts (`src/lib/bun-transpiler-cache-scope.mjs`), because another Bun version sharing the directory evicts its entries. Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Stop preserves connected hub/Link client integrations; disconnect owns their teardown, and start serves them without re-injecting routing. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | | `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` | CLI head and compact/full help; see [head and help navigation](cli-management.md#head-and-help-navigation). | | `src/cli/help-catalog.ts`, `src/cli/help-models-context.ts`, `src/cli/help-recovery.ts` | Pure help resolution, contextual recovery and output sinks; see [head and help navigation](cli-management.md#head-and-help-navigation). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | @@ -175,7 +175,7 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- `ocx start` refuses a duplicate PID, starts the proxy, writes `~/.opencodex/ocx.pid` and `runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves -until shutdown. Normal shutdown restores native Codex; a sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets +until shutdown. Standalone shutdown restores native Codex; connected hub clients and Remote Link Children preserve connection-owned Codex routing, catalog and Grok config across `ocx stop`/`ocx start`. An ordinary runtime rechecks persisted connection ownership in its exit cleanup too, preserving Codex/Grok while retaining its existing environment cleanup. Client start does not re-inject routing: use `ocx sync --restart-codex` to reapply it, or `ocx disconnect` to release connection ownership. A sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. @@ -335,7 +335,7 @@ field for every tool. Only bare or `default.`-prefixed `exec` and `apply_patch` one recognized alternate body field or remove one complete outer Markdown fence; ambiguous alternate fields and every other freeform grammar pass through unchanged. -The server exposes `POST /api/stop` which restores native Codex config (a sibling instance answers `sharedTeardown: "not-owned"` instead), stops any installed service +The server exposes read-only authenticated `GET /api/stop` with `schema: "ocx-stop-capabilities/1"`, PID and connected-client preservation for API, signal and manager shutdown. `src/cli/stop-restore.ts` requires matching client-role health identity or this capability before any connected-client service stop, then rechecks the pinned target before POST/kill. Its probes and connected-stop POST use the proxy-independent TCP transport in `src/server/direct-local-http.ts`, a shared two-second deadline and a 4 KiB capability-document limit; unknown targets refuse without mutation. A stopped connected client skips manager mutation only after the manager is observed inactive and all known local endpoints are definitively dead; the ordinary receipt and environment cleanup still runs. `POST /api/stop`: `src/server/stop-teardown.ts` checks sibling then validated persisted connected-client ownership before receipt deferral or restoration. Both owners answer `sharedTeardown: "not-owned"` without restoring Codex/catalog or stripping Grok; an existing receipt remains outstanding. Other owners restore native Codex/Grok, or defer only with the matching receipt. The route stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. > Decision record: [ADR-0004](decisions/ADR-0004-lifecycle.md) diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index b95024466f5..38ad1c134ce 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -71,6 +71,18 @@ describe("capability table is a checked data graph", () => { expect(CAPABILITIES.some(c => c.command[0] === "capabilities")).toBe(true); }); + test("stop discovery covers preservation preflight and shutdown with its JSON envelope", () => { + const stop = capabilitiesForRoute("/api/stop"); + expect(stop.map(capabilityInvocation)).toEqual(["ocx stop"]); + expect(stop[0]?.routes).toEqual([ + { method: "GET", path: "/api/stop" }, + { method: "POST", path: "/api/stop" }, + ]); + expect(stop[0]?.mutates).toBe(true); + expect(stop[0]?.json).toBe("envelope"); + expect(stop[0]?.usage).toBe("ocx stop [--json]"); + }); + test("logs follow does not claim to imply JSONL output", () => { const logs = CAPABILITIES.find(c => c.command.length === 1 && c.command[0] === "logs"); const follow = logs?.flags.find(flag => flag.name === "--follow"); @@ -213,7 +225,6 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "PATCH /api/oauth/accounts/pool", "POST /api/codex-auth/accounts", "POST /api/model-discovery/acknowledge", - "POST /api/stop", "POST /api/system/restart", "POST /api/windows-tray", "PUT /api/codex-auth/failover", diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index bdc47b91d37..522877401dd 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -622,15 +622,17 @@ describe("a sibling start leaves shared client routing to the live owner", () => // Read before the first stop can remove the record. expect(readAt).toBeLessThan(stop.indexOf("stopServiceIfInstalledDetailed()")); expect(stop).toContain("if (teardownNonce || stoppingSibling) return;"); - expect(stop).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling;"); - expect(stop).toContain('if (nativeRestoreHandledByProxy && !stoppingSibling) record.sharedTeardown = "performed-by-proxy";'); + // #7563 adds a connected client to the same gate; the sibling term is unchanged. + expect(stop).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling || connectedClientSkip !== null;"); + expect(stop).toContain('if (nativeRestoreHandledByProxy && !stoppingSibling && !connectedClientSkip) record.sharedTeardown = "performed-by-proxy";'); + expect(stop).toContain("fetchFn: connectedClientSkip ? directLocalHttpFetch as typeof fetch : undefined,"); // The system env was never the sibling's to set, so its stop does not roll it back either. expect(stop).toContain("if (!stoppingSibling) { try { revertSystemEnv(); } catch { /* best-effort */ } }"); expect(stop).not.toMatch(/\n {2}try \{ revertSystemEnv\(\); \}/); // A sibling never runs under a service manager, so an installed one is the live owner's. Asking // it failed the ownership check from the sibling's home: exit 1, and after a hard kill the // stale sibling records were never purged. - expect(stop).toContain(': stoppingSibling ? "absent" : stopServiceIfInstalledDetailed();'); + expect(stop).toContain(': stoppingSibling || connectedClientAlreadyStopped ? "absent" : stopServiceIfInstalledDetailed();'); // The only other caller is the guarded desktop step, which reaches the manager only when it // provably owns the approved pid; a sibling's pid never is. expect(stop.split("stopServiceIfInstalledDetailed()").length - 1).toBe(2); diff --git a/tests/fixtures/parent-stop-runner.ts b/tests/fixtures/parent-stop-runner.ts index 9a26d528451..93ef7fae037 100644 --- a/tests/fixtures/parent-stop-runner.ts +++ b/tests/fixtures/parent-stop-runner.ts @@ -10,14 +10,47 @@ const options = JSON.parse(readFileSync(0, "utf8")) as { response: unknown; restore: CodexNativeRestoreResult; status?: number; + /** #7563: run the stop as a connected client of this transport. */ + clientTransport?: "hub" | "link"; + serverTeardown?: boolean; + json?: boolean; + clientServer?: "legacy" | "unknown" | "wrong-pid" | "malformed" | "oversized" | "unauthorized" | "redirect" | "body-timeout"; + managerCascades?: boolean; + guarded?: boolean; + orphan?: boolean; + exitCleanup?: boolean; + nativeSignal?: boolean; + apiFailure?: boolean; + proxyProbePort?: number; + alreadyStopped?: boolean; + absence?: "dead" | "unknown" | "live"; + managerState?: "inactive" | "active" | "unknown"; }; +if (options.proxyProbePort) { + // Deliberately bypass every fixture mock for the real proxy-environment regression. + const origin = `http://127.0.0.1:${options.proxyProbePort}`; + const control = await fetch(`${origin}/__proxy-control`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()); + if (control.via !== "proxy") throw new Error("proxy environment control was not intercepted"); + const { assertConnectedClientStopSupported } = await import("../../src/cli/stop-restore"); + await assertConnectedClientStopSupported({ pid: process.pid, port: options.proxyProbePort, hostname: "127.0.0.1" }); + const { stopProxyGracefully } = await import("../../src/lib/process-control"); + const { directLocalHttpFetch } = await import("../../src/server/direct-local-http"); + const result = await stopProxyGracefully(process.pid, { + runtimeEndpoint: { port: options.proxyProbePort, hostname: "127.0.0.1" }, + fetchFn: directLocalHttpFetch, waitExit: () => true, + }); + if (result !== true) throw new Error("direct stop request failed"); + console.log(JSON.stringify({ ok: true, control: control.via })); + process.exit(0); +} const home = process.env.OPENCODEX_HOME!; const endpoint = { hostname: "127.0.0.1", port: 10100 }; const fakePid = 4242; -const calls = { killed: 0, native: 0, grok: 0, cleared: 0, exited: 0 }; +const calls = { killed: 0, native: 0, grok: 0, cleared: 0, exited: 0, service: 0 }; const urls: string[] = []; +const probes: string[] = []; const unexpectedIo: string[] = []; -let alive = true; +let alive = !options.alreadyStopped; let nonce: string | undefined; function unexpected(operation: string): never { @@ -27,6 +60,7 @@ function unexpected(operation: string): never { // Neither an accidental POSIX signal nor the Windows taskkill fallback may reach the host. process.kill = ((pid: number, signal?: number | NodeJS.Signals) => { + if (options.nativeSignal && pid === fakePid && signal === "SIGTERM") { calls.killed++; alive = false; return true; } if (pid !== fakePid || signal !== 0) { calls.killed += 1; return unexpected(`process.kill(${pid}, ${signal})`); @@ -37,13 +71,16 @@ process.kill = ((pid: number, signal?: number | NodeJS.Signals) => { }) as typeof process.kill; mock.module("node:child_process", () => ({ ...childProcess, - execFileSync: () => { calls.killed += 1; return unexpected("execFileSync"); }, + execFileSync: (file: string) => { + if (options.nativeSignal && file.endsWith("taskkill.exe")) { calls.killed++; alive = false; return Buffer.alloc(0); } + calls.killed += 1; return unexpected("execFileSync"); + }, })); const receipts = await import("../../src/config/pending-teardown"); process.on("exit", () => { writeFileSync(join(home, "parent-stop-result.json"), JSON.stringify({ - calls, urls, unexpectedIo, nonce, + calls, urls, probes, unexpectedIo, nonce, receiptExists: nonce !== undefined && existsSync(receipts.pendingTeardownPathFor(nonce)), })); }); @@ -63,13 +100,45 @@ mock.module("../../src/config/pending-teardown", () => ({ const state = await import("../../src/config/process-state"); mock.module("../../src/config/process-state", () => ({ ...state, - readPid: () => fakePid, - readRuntimePort: () => endpoint, + readPid: () => options.orphan || options.alreadyStopped ? null : fakePid, + readRuntimePort: () => options.alreadyStopped ? null : endpoint, removePid() {}, removeRuntimePort() {}, })); const service = await import("../../src/service"); -mock.module("../../src/service", () => ({ ...service, stopServiceIfInstalledDetailed: () => "absent" })); +mock.module("../../src/service", () => ({ ...service, stopServiceIfInstalledDetailed: () => { + calls.service++; + if (options.managerCascades) { alive = false; return "stopped"; } + return "absent"; +} })); +if (options.guarded) { + const approval = { pid: fakePid, ...endpoint, configHome: home, cliVersion: "2.93.0", compatibilityToken: "fixture" }; + const manager = { kind: "bound", pid: fakePid, managerPid: fakePid + 1, backend: "launchd" }; + const stopApproval = await import("../../src/cli/stop-approval"); + mock.module("../../src/cli/stop-approval", () => ({ ...stopApproval, + parseStopApproval: () => ({ ok: true, json: true, approval }), + runApprovedStop: async (_a: unknown, _r: unknown, _f: unknown, _m: unknown, stop: (s: unknown) => unknown) => stop({ approval, manager }), + settleApprovedTarget: async () => !alive, + })); + const guarded = await import("../../src/service/guarded-manager-target"); + mock.module("../../src/service/guarded-manager-target", () => ({ ...guarded, + inspectGuardedManagerTarget: () => manager, observeGuardedManagerStopped: () => "stopped", + })); +} +if (options.orphan || options.alreadyStopped) { + const liveness = await import("../../src/server/proxy-liveness"); + mock.module("../../src/server/proxy-liveness", () => ({ ...liveness, + findLiveProxy: async () => alive ? { ...endpoint, pid: fakePid, source: "config" } : null, + proveLiveProxyOwnedByHome: async () => "proven", + probeEndpointLiveness: async () => options.absence ?? "dead", + })); +} +if (options.alreadyStopped) { + const guarded = await import("../../src/service/guarded-manager-target"); + mock.module("../../src/service/guarded-manager-target", () => ({ ...guarded, + observeGuardedManagerStopped: async () => options.managerState ?? "inactive", + })); +} const native = await import("../../src/codex/inject"); mock.module("../../src/codex/inject", () => ({ ...native, @@ -87,13 +156,69 @@ mock.module("../../src/server/port-reclaim", () => ({ ...portReclaim, reclaimLis // Only shim preflight is unrelated to this stop contract; parsing and dispatch stay real. mock.module("../../src/cli/codex-shim-autorestore", () => ({ maybeAutoRestoreCodexShim() {} })); -globalThis.fetch = (async (input: string | URL | Request) => { +globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); + if (init?.method === "GET") { + probes.push(url); + if (init.redirect !== "error") return unexpected("redirectable compatibility probe"); + if (url.endsWith("/healthz")) { + if (new Headers(init.headers).has("x-opencodex-api-key")) return unexpected("credential on health probe"); + return Response.json({ service: "opencodex", version: "2.93.0", pid: fakePid, + ...(!options.serverTeardown && !options.clientServer ? { role: "client" } : {}) }); + } + if (!url.endsWith("/api/stop")) return unexpected(`GET ${url}`); + if (options.clientServer === "unknown") throw new Error("fixture probe unavailable"); + if (options.clientServer === "legacy") return Response.json({ error: "not found" }, { status: 404 }); + if (options.clientServer === "unauthorized") return new Response(null, { status: 401 }); + if (options.clientServer === "redirect") return Response.redirect("http://example.test/", 302); + if (options.clientServer === "malformed") return new Response("{bad json"); + if (options.clientServer === "oversized") return new Response(new ReadableStream({ start(c) { c.enqueue(new Uint8Array(4097)); c.close(); } })); + if (options.clientServer === "body-timeout") return new Response(new ReadableStream({ start(c) { + init.signal?.addEventListener("abort", () => c.error(init.signal!.reason), { once: true }); + } })); + return Response.json({ schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, + pid: options.clientServer === "wrong-pid" ? fakePid + 1 : fakePid }); + } if (!url.startsWith("http://127.0.0.1:10100/api/stop")) return unexpected(`fetch(${url})`); urls.push(url); - if ((options.status ?? 200) !== 409) alive = false; - return Response.json(options.response, { status: options.status ?? 200 }); + const body = options.serverTeardown && !options.apiFailure + ? await (await import("../../src/server/stop-teardown")).performStopTeardown(new URL(url)) + : options.response; + if (!options.nativeSignal && (options.status ?? 200) !== 409) alive = false; + return Response.json(body, { status: options.status ?? 200 }); }) as typeof fetch; -process.argv = [process.execPath, "ocx", "stop"]; +// Only the real proxy-environment mode above exercises TCP; other cases inject responses. +const direct = await import("../../src/server/direct-local-http"); +mock.module("../../src/server/direct-local-http", () => ({ ...direct, + directLocalHttpFetch: async (input: string | URL | Request, init?: RequestInit) => { + if (!alive && String(input).endsWith("/healthz")) throw Object.assign(new Error("fixture listener closed"), { code: "ECONNREFUSED" }); + return globalThis.fetch(input, { ...init, method: init?.method ?? "GET", redirect: "error" }); + }, +})); + +process.argv = [process.execPath, "ocx", "stop", ...(options.json ? ["--json"] : [])]; +if (options.exitCleanup) { + delete process.env.OCX_SERVICE; + // Execute the actual startup exit closure, with only host/config effects replaced. + const { repoPath } = await import("../helpers/repo-root"); + const source = readFileSync(repoPath("src", "cli", "index.ts"), "utf8"); + const from = source.indexOf(" const syncCleanup = () => {"); + const to = source.indexOf("\n let shuttingDown", from); + if (from < 0 || to < 0) throw new Error("startup cleanup closure missing"); + const restore = await import("../../src/cli/stop-restore"); + const dispatch = await import("../../src/cli/dispatch"); + const bindings = { + catalogHealer: null, routingHealer: null, guardian: { stop() {} }, historyGuardian: null, healthSentinel: null, + siblingStart: false, isRecyclingForExit: () => false, decideStartExitTeardown: dispatch.decideStartExitTeardown, + preserveConnectedClientRoutingOnExit: (restore as Record).preserveConnectedClientRoutingOnExit, + revertSystemEnv() {}, removePid() {}, removeRuntimePort() {}, serviceEnvironmentOwnedHere: () => true, + getCodexHome: () => "fixture", codexHomeIsAbsent: () => false, + restoreNativeCodex: () => { calls.native++; return options.restore; }, + stripGrokConfig: () => { calls.grok++; return { ok: true, changed: true, message: "stripped" }; }, + }; + const cleanup = new Function(...Object.keys(bindings), "let cleaned = false; let cleanupSucceeded = true;\n" + source.slice(from, to) + "\nreturn syncCleanup();"); + cleanup(...Object.values(bindings)); + process.exit(0); +} await import("../../src/cli/index"); diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index b829e1c87c7..8f1dbcae0a9 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -111,9 +111,10 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).toContain("isServiceOwnershipError(err)"); expect(stopFn).toContain("ownershipBlocked = true"); // Ownership is one reason to skip the restore; others are an inherited obligation whose - // proxy could not be confirmed down (#3008) and a sibling runtime, whose shared client - // routing belongs to the live proxy it ran beside. - expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling;"); + // proxy could not be confirmed down (#3008), a sibling runtime, whose shared client + // routing belongs to the live proxy it ran beside, and a connected client, whose routing, + // catalog and Grok config belong to its connection and are never written back on start (#7563). + expect(stopFn).toContain("const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling || connectedClientSkip !== null;"); expect(stopFn).toContain("if (!restoreBlocked) {"); expect(stopFn).toContain("await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable)"); expect(restoreFn).toContain("restoreNativeCodexAsync()"); diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts index 24b3b920f86..46e111de051 100644 --- a/tests/service/autostart-health.test.ts +++ b/tests/service/autostart-health.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { deriveDesktopStartup } from "../../src/service/desktop-startup"; import { collectStartupHealth, deriveStartupHealth, formatStartupRoutingDetail, injectedRoutingRestartWarningLines, startupHealthSummary } from "../../src/codex/autostart-health"; -import { unusedProxyWarningLines } from "../../src/cli/status"; +import { deadProxyRoutingAdviceLines, statusDeadProxyLines, statusUnusedProxyLines, unusedProxyWarningLines } from "../../src/cli/status"; import { classifyCodexRouting, hasInjectedCodexRouting } from "../../src/codex/inject"; import { isCodexClientProcess, listCodexClientProcesses } from "../../src/codex/native-profile-processes"; import { collectRoutingAdoption, deriveRoutingAdoption } from "../../src/codex/routing-adoption"; @@ -433,6 +433,63 @@ describe("routing visibility (#2411)", () => { expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "unknown" })).toEqual([]); }); + // #7563: `ocx start` never writes a connected client's routing, so naming it sent a Child's + // operator to "Proxy already running" with Codex still native. + test("a connected client is told to re-point with ocx sync --restart-codex, never ocx start", () => { + const link = { transport: "link" as const, serviceInstalled: true }; + const up = unusedProxyWarningLines({ proxyUp: true, routingKind: "native", connectedClient: link }).join("\n"); + expect(up).toContain("Re-point with: ocx sync --restart-codex"); + expect(up).toContain("not the Home"); + expect(up).not.toContain("ocx start"); + const hub = unusedProxyWarningLines({ proxyUp: true, routingKind: "native", connectedClient: { transport: "hub", serviceInstalled: false } }).join("\n"); + expect(hub).toContain("not the hub"); + expect(hub).toContain("ocx sync --restart-codex"); + // A client whose proxy is down is told to start it first, naming the installed service. + const down = unusedProxyWarningLines({ proxyUp: false, routingKind: "native", connectedClient: link }).join("\n"); + expect(down).toContain("'ocx service start', then re-point with: ocx sync --restart-codex"); + expect(unusedProxyWarningLines({ proxyUp: false, routingKind: "native", connectedClient: { ...link, serviceInstalled: false } }).join("\n")) + .toContain("'ocx start', then re-point with: ocx sync --restart-codex"); + // Routing that is already ours needs no re-point. + expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "opencodex-local", connectedClient: link })).toEqual([]); + expect(unusedProxyWarningLines({ proxyUp: false, routingKind: "opencodex-local", connectedClient: link })).toEqual([]); + // Standalone wording is unchanged. + expect(unusedProxyWarningLines({ proxyUp: true, routingKind: "native" }).join("\n")).toContain("Re-point with: ocx start"); + }); + + test("the dead-proxy restore advice names the way back only on a connected client", () => { + const client = deadProxyRoutingAdviceLines({ proxyUp: false, routingKind: "opencodex-local", connectedClient: true }).join("\n"); + expect(client).toContain("ocx restore"); + expect(client).toContain("'ocx sync --restart-codex' points Codex back at this client"); + expect(deadProxyRoutingAdviceLines({ proxyUp: false, routingKind: "opencodex-local" }).join("\n")).not.toContain("ocx sync"); + }); + + test("ocx status takes the client role, transport and service from its own report", () => { + const view = (connected: boolean, transport: "hub" | "link" | undefined, routingKind: "native" | "opencodex-local", up: boolean) => ({ + proxy: { pid: up ? 1 : null, health: { ok: up } }, + startup: { ...base, routingKind, serviceInstalled: true, serviceConflict: false }, + remoteHub: { connected }, + connection: { transport }, + }) as unknown as Parameters[0]; + expect(statusUnusedProxyLines(view(true, "link", "native", true)).join("\n")).toContain("not the Home. Re-point with: ocx sync --restart-codex"); + expect(statusUnusedProxyLines(view(true, undefined, "native", false)).join("\n")).toContain("not the hub"); + expect(statusUnusedProxyLines(view(true, "link", "native", false)).join("\n")).toContain("'ocx service start'"); + expect(statusUnusedProxyLines(view(false, undefined, "native", true)).join("\n")).toContain("Re-point with: ocx start"); + expect(statusDeadProxyLines(view(true, "link", "opencodex-local", false)).join("\n")).toContain("ocx sync --restart-codex"); + expect(statusDeadProxyLines(view(false, undefined, "opencodex-local", false)).join("\n")).not.toContain("ocx sync"); + const routedDown = statusDeadProxyLines(view(true, "link", "opencodex-local", false)).join("\n"); + expect(routedDown).toContain("Codex/Claude requests will fail with connection errors"); + const nativeDown = statusDeadProxyLines(view(true, "link", "native", false)).join("\n"); + expect(nativeDown).toContain("Claude requests will fail with connection errors"); + expect(nativeDown).not.toContain("Codex/Claude requests will fail"); + expect(nativeDown).not.toContain("Codex is still pointed at this proxy"); + const staleLink = view(true, "link", "native", false); + staleLink.proxy.pid = 99; + expect(statusUnusedProxyLines(staleLink).join("\n")).toContain("'ocx service start'"); + const staleHub = view(true, "hub", "native", false); + staleHub.proxy.pid = 99; + expect(statusUnusedProxyLines(staleHub).join("\n")).not.toContain("'ocx service start'"); + }); + // #5261: setup writes routing that outlives the session and then ends on a success line. // The warning reuses the health model rather than re-deriving the condition, so it cannot // disagree with what status and doctor say about the same install. diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index c54fb511ff4..1a8eb9727f1 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -50,7 +50,25 @@ function restoreResult(success: boolean): CodexNativeRestoreResult { } as unknown as CodexNativeRestoreResult; } -async function runParentStop(options: { receipt: boolean; response: unknown; restore: CodexNativeRestoreResult; status?: number }) { +function connectClient(transport: "hub" | "link") { + const serverUrl = transport === "link" ? "http://127.0.0.1:10200" : "https://hub.example.test"; + writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "client", providers: {}, defaultProvider: "openai", client: { + serverUrl, managementUrl: serverUrl, managementTransport: "direct", transport, + ...(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", + } })); +} + +async function runParentStop(options: { + receipt: boolean; response: unknown; restore: CodexNativeRestoreResult; status?: number; clientTransport?: "hub" | "link"; + serverTeardown?: boolean; json?: boolean; + clientServer?: "legacy" | "unknown" | "wrong-pid" | "malformed" | "oversized" | "unauthorized" | "redirect" | "body-timeout"; + managerCascades?: boolean; guarded?: boolean; orphan?: boolean; exitCleanup?: boolean; + nativeSignal?: boolean; apiFailure?: boolean; + alreadyStopped?: boolean; absence?: "dead" | "unknown" | "live"; managerState?: "inactive" | "active" | "unknown"; +}) { + if (options.clientTransport) connectClient(options.clientTransport); const child = spawnSync(process.execPath, [fixturePath("parent-stop-runner.ts")], { cwd: repoPath(), env: { ...process.env, OPENCODEX_HOME: home }, @@ -64,15 +82,162 @@ async function runParentStop(options: { receipt: boolean; response: unknown; res const reportPath = join(home, "parent-stop-result.json"); expect(existsSync(reportPath), child.stderr).toBe(true); const report = JSON.parse(readFileSync(reportPath, "utf8")) as { - calls: { killed: number; native: number; grok: number; cleared: number; exited: number }; + calls: { killed: number; native: number; grok: number; cleared: number; exited: number; service: number }; + probes: string[]; urls: string[]; nonce?: string; receiptExists: boolean; unexpectedIo: string[]; }; expect(report.unexpectedIo, child.stderr).toEqual([]); - expect(report.urls, child.stderr).toHaveLength(1); - return { ...report, exitCode: child.status, stderr: child.stderr }; + expect(report.urls, child.stderr).toHaveLength(options.clientServer || options.exitCleanup || options.alreadyStopped ? 0 : 1); + return { ...report, exitCode: child.status, stderr: child.stderr, stdout: child.stdout }; } describe("parent CLI shared teardown completion", () => { + test("consecutive connected stops preserve configuration and the stopped managed client remains update-eligible", async () => { + const first = await runParentStop({ receipt: true, clientTransport: "link", serverTeardown: true, json: true, + response: null, restore: restoreResult(true) }); + const before = readFileSync(join(home, "config.json"), "utf8"); + const second = await runParentStop({ receipt: true, clientTransport: "link", alreadyStopped: true, json: true, + response: null, restore: restoreResult(true) }); + expect(first.exitCode).toBe(0); + expect(second.exitCode, second.stderr).toBe(0); + expect(second.calls).toMatchObject({ service: 0, killed: 0, native: 0, grok: 0, cleared: 0 }); + expect(JSON.parse(second.stdout)).toMatchObject({ ok: true, runtimeDown: true, proxy: "not-running", sharedTeardown: "skipped" }); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(before); + const { decidePostStopUpdate } = await import("../../src/update/stop-decision.mjs"); + expect(decidePostStopUpdate({ status: second.exitCode, hasRuntimeState: false, liveness: "dead", teardownOutstanding: false })) + .toMatchObject({ proceed: true, reason: "ok" }); + }); + for (const [absence, managerState] of [["unknown", "inactive"], ["live", "inactive"], ["dead", "active"], ["dead", "unknown"]] as const) { + test(`missing connected target still refuses ${absence} liveness / ${managerState} manager`, async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "hub", alreadyStopped: true, absence, managerState, + json: true, response: null, restore: restoreResult(true) }); + expect(outcome.exitCode).toBe(1); + expect(outcome.calls).toMatchObject({ service: 0, killed: 0, native: 0, grok: 0, cleared: 0 }); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: false, runtimeDown: false }); + }); + } + test("preservation probes bypass environment proxies without exposing the admin token", async () => { + const targetPaths: string[] = []; + const proxyPaths: string[] = []; + const proxyTokens: string[] = []; + const targetTokens: string[] = []; + let childPid = 0; + const token = "ocx-test-stop-proof-token"; + const reply = (request: Request) => { + const path = new URL(request.url).pathname; + if (request.method === "POST") return Response.json({ success: true, sharedTeardown: "not-owned" }); + return Response.json(path === "/healthz" ? { service: "opencodex", pid: childPid } + : { schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: childPid }); + }; + const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + targetPaths.push(`${request.method} ${new URL(request.url).pathname}`); + const auth = request.headers.get("x-opencodex-api-key"); + if (auth) targetTokens.push(auth); + return reply(request); + } }); + const proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + const path = new URL(request.url).pathname; + proxyPaths.push(path); + const auth = request.headers.get("x-opencodex-api-key"); + if (auth) proxyTokens.push(auth); + return path === "/__proxy-control" ? Response.json({ via: "proxy" }) : reply(request); + } }); + let child: ReturnType | undefined; + let watchdog: ReturnType | undefined; + try { + const env = { ...process.env, OPENCODEX_HOME: home, OPENCODEX_ADMIN_AUTH_TOKEN: token }; + delete env.NO_PROXY; delete env.no_proxy; + for (const key of ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) { + env[key] = `http://127.0.0.1:${proxy.port}`; + } + child = Bun.spawn([process.execPath, fixturePath("parent-stop-runner.ts")], { + cwd: repoPath(), env, stdin: new Blob([JSON.stringify({ proxyProbePort: target.port })]), stdout: "pipe", stderr: "pipe", + }); + childPid = child.pid; + watchdog = setTimeout(() => child?.kill(), 8000); + const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(exitCode, stderr).toBe(0); + expect(JSON.parse(stdout)).toEqual({ ok: true, control: "proxy" }); + expect(proxyPaths).toEqual(["/__proxy-control"]); + expect(proxyTokens).toEqual([]); + expect(targetPaths).toEqual(["GET /healthz", "GET /api/stop", "POST /api/stop"]); + expect(targetTokens).toEqual([token, token]); + } finally { + if (watchdog) clearTimeout(watchdog); + if (child && child.exitCode === null) { child.kill(); await child.exited; } + await target.stop(true); await proxy.stop(true); + } + }); + for (const clientServer of ["legacy", "unknown", "wrong-pid", "malformed", "oversized", "unauthorized", "redirect", "body-timeout"] as const) { + test(`connected stop refuses ${clientServer} preservation before service or POST`, async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "link", clientServer, json: true, + response: { success: true, sharedTeardown: "performed" }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ service: 0, killed: 0, native: 0, grok: 0, cleared: 0 }); + expect(outcome.nonce).toBeUndefined(); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: false, runtimeDown: false, sharedTeardown: "skipped" }); + expect(outcome.stderr).toContain("ocx disconnect"); + expect(outcome.exitCode).toBe(1); + }); + } + for (const mode of ["tracked", "orphan", "guarded"] as const) { + test(`connected ${mode} stop refuses an old server before a cascading manager`, async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "hub", clientServer: "legacy", json: true, + orphan: mode === "orphan", guarded: mode === "guarded", managerCascades: true, + response: { success: true, sharedTeardown: "performed" }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ service: 0, killed: 0, native: 0, grok: 0, cleared: 0 }); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: false, sharedTeardown: "skipped" }); + expect(outcome.exitCode).toBe(1); + }); + } + test("actual startup exit cleanup preserves freshly connected routing after a signal or API exit", async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "link", exitCleanup: true, + response: null, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ native: 0, grok: 0, service: 0, cleared: 0, killed: 0 }); + expect(outcome.exitCode).toBe(0); + }); + test("ordinary startup exit cleanup retains native and Grok restoration", async () => { + const outcome = await runParentStop({ receipt: true, exitCleanup: true, response: null, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ native: 1, grok: 1, service: 0 }); + expect(outcome.exitCode).toBe(0); + }); + test("PID-identified native client keeps the 404 to signal path", async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "link", nativeSignal: true, json: true, + status: 404, response: { error: "not found" }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ service: 1, killed: 1, native: 0, grok: 0 }); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); + expect(outcome.exitCode).toBe(0); + }); + test("full preservation capability permits signal fallback after an API failure", async () => { + const outcome = await runParentStop({ receipt: true, clientTransport: "hub", nativeSignal: true, json: true, + serverTeardown: true, apiFailure: true, status: 503, response: { error: "unavailable" }, restore: restoreResult(true) }); + expect(outcome.calls).toMatchObject({ service: 1, killed: 1, native: 0, grok: 0 }); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); + expect(outcome.exitCode).toBe(0); + }); + // #7563: nothing on a connected client writes its Codex routing, catalog or Grok block back + // on start, so a stop that tore them down left them torn down. + for (const clientTransport of ["link", "hub"] as const) { + test(`a successful ${clientTransport} API stop preserves integrations and reports skipped JSON`, async () => { + const outcome = await runParentStop({ receipt: true, clientTransport, serverTeardown: true, json: true, + response: null, restore: restoreResult(true) }); + expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + expect(outcome.nonce).toBeUndefined(); + expect(outcome.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); + expect(JSON.parse(outcome.stdout)).toMatchObject({ ok: true, sharedTeardown: "skipped" }); + expect(outcome.exitCode, outcome.stderr).toBe(0); + }); + + test(`a ${clientTransport} native client stop leaves the connection's integrations and claims no receipt`, async () => { + const outcome = await runParentStop({ receipt: true, clientTransport, + response: { error: "not found" }, status: 404, restore: restoreResult(true) }); + expect(outcome.urls).toEqual(["http://127.0.0.1:10100/api/stop"]); + expect(outcome.nonce).toBeUndefined(); + expect(outcome.calls).toMatchObject({ native: 0, grok: 0, cleared: 0 }); + expect(outcome.stdout).toContain("this " + (clientTransport === "link" ? "Remote Link Child" : "hub client") + "'s connection owns them"); + expect(outcome.exitCode, outcome.stderr).toBe(0); + }); + } + test("receipt failure and unconfirmed child teardown cause real parent restoration without a kill", async () => { const outcome = await runParentStop({ receipt: false, response: { success: false, sharedTeardown: "performed" }, restore: restoreResult(true) }); @@ -262,6 +427,51 @@ describe("stopProxyGracefully deferral flag", () => { }); describe("performStopTeardown", () => { + test("GET stop capability is bound to the server PID and leaves shared state alone", async () => { + connectClient("link"); + const before = readFileSync(join(home, "config.json"), "utf8"); + const { handleManagementAPI } = await import("../../src/server/management-api"); + const url = new URL("http://127.0.0.1:10100/api/stop"); + const response = await handleManagementAPI(new Request(url, { headers: { Host: "127.0.0.1:10100" } }), url, { + port: 10100, providers: {}, defaultProvider: "openai", + } as never); + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual({ schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: process.pid }); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(before); + }); + for (const transport of ["hub", "link"] as const) { + test(`persisted ${transport} ownership precedes receipt deferral and leaves its receipt intact`, async () => { + connectClient(transport); + const { claimPendingTeardown, readPendingTeardown } = await import("../../src/config/pending-teardown"); + const receipt = claimPendingTeardown(ENDPOINT, "exact"); + let restored = 0; + let stripped = 0; + for (const query of ["", `?deferSharedTeardown=1&teardownNonce=${receipt.nonce}`]) { + const body = await performStopTeardown(new URL(`http://127.0.0.1:10100/api/stop${query}`), { + restoreNativeCodex: async () => { restored++; return restoreResult(true); }, + stripGrok: () => { stripped++; return { ok: true, changed: true, message: "stripped" }; }, + }); + expect(body).toMatchObject({ success: true, sharedTeardown: "not-owned" }); + expect(body.message).toContain("ocx disconnect"); + } + expect(restored).toBe(0); + expect(stripped).toBe(0); + expect(readPendingTeardown(receipt.nonce).state).toBe("valid"); + }); + } + + test("a hub server without a client connection still restores its own integrations", async () => { + writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "hub" })); + let restored = 0; + let stripped = 0; + const body = await performStopTeardown(new URL("http://127.0.0.1:10100/api/stop"), { + restoreNativeCodex: async () => { restored++; return restoreResult(true); }, + stripGrok: () => { stripped++; return { ok: true, changed: true, message: "stripped" }; }, + }); + expect(body).toMatchObject({ success: true, sharedTeardown: "performed" }); + expect([restored, stripped]).toEqual([1, 1]); + }); + test("an ordinary stop restores native Codex and strips the Grok fence", async () => { let restored = 0; let stripped = 0;