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 e3bf32db11c..fa689495fdf 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -32,7 +32,7 @@ ocx start --socks5-off 接続済み hub クライアントでは `ocx stop` は接続所有の Codex ルーティング、カタログ、Grok 設定を保持します。接続を解除するには `ocx disconnect` を実行してください。 Child を `ocx stop` で正常に停止すると、リスナーを閉じる前に、その接続が所有するジャーナルから Codex のルーティングを復元します。接続情報、キー、カタログ、その他の連携設定は保持されます。Home のルーティングを再開するには `ocx start` または `ocx service start` の後に `ocx sync --restart-codex` を実行してください。起動だけではルーティングは再適用されません。`ocx restart` の通常の引き継ぎ経路ではルーティングを保持します。所有権やジャーナルの競合は失敗として報告し、復旧の証拠を保持します。クラッシュや強制終了では正常停止時の復元を実行できません。 -互換性を確認できない古い通常プロキシは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。 +接続済みクライアントの停止では、保護されたローカルのランタイム記録とリスナーの証明を照合します。クライアント用リスナーと通常プロキシの両方にこの確認が必要で、通常プロキシでは設定の保持機能も認証付きの読み取りで確認します。古いランタイムや確認できないランタイムは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。 既に停止済みのクライアントでは、サービス管理側が非アクティブで、既知のローカル接続先にランタイムが存在しないことを確認してから、通常の未実行結果を返します。 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 4019cee1fe8..bd6df98ebd6 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -56,7 +56,7 @@ ocx start --socks5-off 연결된 hub 클라이언트에서는 `ocx stop`이 연결 소유의 Codex 라우팅, 카탈로그와 Grok 설정을 보존합니다. 연결을 해제하려면 `ocx disconnect`를 실행하세요. Child에서 `ocx stop`으로 정상 종료하면 리스너를 닫기 전에 해당 연결이 소유한 저널을 사용해 Codex 라우팅을 복원합니다. 연결 정보, 키, 카탈로그와 다른 연동 설정은 유지됩니다. Home 라우팅을 다시 사용하려면 `ocx start` 또는 `ocx service start`를 실행한 뒤 `ocx sync --restart-codex`를 실행하세요. 시작만으로는 라우팅이 다시 적용되지 않습니다. `ocx restart`의 정상 인계 경로는 라우팅을 유지합니다. 소유권이나 저널 충돌은 실패로 보고하고 복구 근거를 보존하며, 강제 종료나 충돌 시에는 정상 종료 복원이 실행되지 않습니다. -보존 기능을 확인할 수 없는 오래된 일반 프록시는 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요. +연결된 클라이언트를 중지할 때는 보호된 로컬 런타임 기록과 리스너의 신원 증명을 대조합니다. 클라이언트 리스너와 일반 프록시 모두 이 검증이 필요하며, 일반 프록시는 인증된 조회로 설정 보존 기능도 확인합니다. 오래되었거나 검증할 수 없는 런타임은 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요. 이미 중지된 클라이언트는 서비스 관리자가 비활성이고 알려진 로컬 주소에 런타임이 없음을 확인한 뒤 정상적인 미실행 결과를 반환합니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index a95c1b84da1..e8d77657c28 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -92,9 +92,10 @@ catalog and other integrations remain in place. After a Child stop, run `ocx sta re-inject routing. The bound `ocx restart` handoff preserves it. Use `ocx disconnect` to release the connection. Conflicting ownership or edited/unverified journal files produce a restoration failure and retain recovery evidence; abrupt crashes and forced termination cannot run orderly cleanup. -A connected stop verifies the local runtime before changing its service: a client-role listener -with the matching PID is supported, while an ordinary proxy must explicitly advertise connected-client ownership handling -across API, signal and service shutdown. Older or unverified ordinary runtimes are refused before +A connected stop verifies the listener against the protected local runtime record before changing +its service. Both client-role listeners and ordinary proxies must prove that identity; an ordinary +proxy must also advertise connected-client ownership handling across API, signal and service +shutdown in an authenticated read. Older or unverified 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 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 ea35f3912d7..27140834828 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -56,7 +56,7 @@ ocx start --socks5-off На подключённом клиенте hub команда `ocx stop` сохраняет маршрутизацию Codex, каталог и настройки Grok, принадлежащие соединению. Для отключения выполните `ocx disconnect`. При штатной остановке Child командой `ocx stop` маршрутизация Codex восстанавливается из журнала, принадлежащего этому соединению, до закрытия слушателя. Соединение, ключ, каталог и остальные интеграции сохраняются. Чтобы снова направлять запросы в Home, выполните `ocx start` или `ocx service start`, затем `ocx sync --restart-codex`: один запуск не применяет маршрутизацию повторно. Штатная передача управления через `ocx restart` сохраняет маршрутизацию. Конфликт владельца или журнала сообщает об ошибке и сохраняет данные для восстановления; аварийное завершение или принудительная остановка не выполняют штатное восстановление. -Старая обычная версия прокси без подтверждённой поддержки сохранения отклоняется до остановки службы или процесса. Обновите и перезапустите её через управляющий супервизор, затем повторите команду. +При остановке подключённого клиента команда сверяет доказательство идентичности слушателя с защищённой локальной записью рантайма. Это требуется и для клиентского слушателя, и для обычного прокси; обычный прокси также должен подтвердить сохранение настроек через аутентифицированный запрос чтения. Старые или непроверенные рантаймы отклоняются до остановки службы или процесса. Обновите и перезапустите рантайм через управляющий супервизор, затем повторите команду. Для уже остановленного клиента команда подтверждает неактивность службы и отсутствие рантайма на известных локальных адресах, затем возвращает обычный результат «не запущен». 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 486ca3f4791..25eff80bbf1 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 @@ -32,7 +32,7 @@ ocx start --socks5-off 已连接的 hub 客户端在 `ocx stop` 时保留连接拥有的 Codex 路由、目录和 Grok 配置。要解除连接,请运行 `ocx disconnect`。 在 Child 上通过 `ocx stop` 正常停止时,会在关闭监听器之前,仅从该连接拥有的日志恢复 Codex 路由。连接信息、密钥、目录和其他集成配置保持不变。要恢复 Home 路由,请先运行 `ocx start` 或 `ocx service start`,再运行 `ocx sync --restart-codex`;仅启动不会重新应用路由。`ocx restart` 的正常交接路径会保留路由。所有权或日志冲突会报告失败并保留恢复依据;崩溃或强制终止无法执行正常停止时的恢复。 -若旧版普通代理无法确认支持保留配置,命令会在停止服务或进程前拒绝操作。请通过管理该运行时的应用或服务更新并重启它,然后重试。 +停止已连接的客户端时,命令会依据受保护的本地运行时记录验证监听器的身份证明。客户端监听器和普通代理都必须通过此验证;普通代理还必须通过经过身份验证的读取确认配置保留能力。对于旧版或无法验证的运行时,命令会在停止服务或进程前拒绝操作。请通过管理该运行时的应用或服务更新并重启它,然后重试。 对于已停止的客户端,命令会确认服务管理器未活动且已知本地端点没有运行时,然后返回正常的“未运行”结果。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 32ffbd92f55..62ec945001a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1777,7 +1777,7 @@ "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", "stepfun-provider.test.ts": "providers", - "stop-deferred-teardown.test.ts": "service", "stop-direct-fetch.test.ts": "service", + "stop-deferred-teardown.test.ts": "service", "connected-stop-capability-auth.test.ts": "service", "stop-direct-fetch.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", "storage-policy-config-race.test.ts": "storage", diff --git a/src/cli/stop-restore.ts b/src/cli/stop-restore.ts index bdf030dd807..c185b63376f 100644 --- a/src/cli/stop-restore.ts +++ b/src/cli/stop-restore.ts @@ -4,10 +4,12 @@ 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 { createLocalAttestationChallenge, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof } from "../lib/local-management-attestation"; +import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; import { gracefulStopHost, isProcessAlive, ProxyOwnershipRefusedError } from "../lib/process-control"; import { directLocalHttpFetch } from "../server/direct-local-http"; +import { fetchBoundLocalManagementRead } from "../server/local-management-read-client"; import { findLiveProxy, probeEndpointLiveness, proveLiveProxyOwnedByHome } from "../server/proxy-liveness"; import { observeGuardedManagerStopped } from "../service/guarded-manager-target"; import type { StartExitTeardown } from "./dispatch"; @@ -35,38 +37,57 @@ export function restoreCodexRoutingOnExit(): { success: boolean; message: string 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 refused: runtime identity, shutdown ownership handling 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. */ +/** Require the pinned runtime's proof before accepting its shutdown contract. */ 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 runtime = readRuntimePort(target.pid); + if (!runtime?.attestationSecret || runtime.pid !== target.pid || runtime.port !== target.port + || gracefulStopHost(runtime.hostname) !== gracefulStopHost(target.hostname)) throw unsupportedConnectedStop(); + const endpoint = { ...target, hostname: gracefulStopHost(runtime.hostname) }; + const readPinnedRuntime = () => { + const current = readRuntimePort(target.pid); + return current?.pid === runtime.pid && current.port === runtime.port && current.hostname === runtime.hostname + && current.attestationSecret === runtime.attestationSecret ? current : null; + }; + const origin = `http://${endpoint.hostname}:${endpoint.port}`; + const deadline = Date.now() + CONNECTED_STOP_PROBE_MS; 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 readDocument = async (response: Response): Promise | null> => { + 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; }; - const health = await probe("/healthz", false); - // The native machine listener owns its transport-specific signal cleanup and 404s /api/stop. - if (health?.service === "opencodex" && health.role === "client" && health.pid === target.pid) return; - const capability = await probe("/api/stop", true); - if (capability?.schema === "ocx-stop-capabilities/1" - && capability.pid === target.pid && capability.connectedClientOwnership === true) return; + try { + const nonce = createLocalAttestationChallenge(); + const response = await directLocalHttpFetch(`${origin}/healthz`, { + method: "GET", headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: nonce }, redirect: "error", signal, + }, { timeoutMs: CONNECTED_STOP_PROBE_MS }); + if (!readPinnedRuntime() || !verifyLocalAttestationProof(runtime.attestationSecret, nonce, target.pid, target.port, + response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER))) { + void response.body?.cancel().catch(() => {}); + throw unsupportedConnectedStop(); + } + const health = await readDocument(response); + if (!readPinnedRuntime() || health?.service !== "opencodex" || health.pid !== target.pid) throw unsupportedConnectedStop(); + // Native machine listeners own transport-specific signal cleanup and have no /api/stop route. + if (health.role === "client") return; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw unsupportedConnectedStop(); + const read = await fetchBoundLocalManagementRead({ ...endpoint, source: "runtime" }, LOCAL_MANAGEMENT_READ_PATHS.stop, { + readRuntime: readPinnedRuntime, timeoutMs: remaining, requireResponseProof: true, + }); + if (read.kind !== "response") throw unsupportedConnectedStop(); + const capability = await readDocument(read.response); + if (readPinnedRuntime() && capability?.schema === "ocx-stop-capabilities/1" + && capability.pid === target.pid && capability.connectedClientOwnership === true) return; + } catch { /* A missing or replaced runtime never grants shutdown authority. */ } throw unsupportedConnectedStop(); } diff --git a/src/lib/local-management-capability.ts b/src/lib/local-management-capability.ts index 77d8feb9cfd..65dae27f4f6 100644 --- a/src/lib/local-management-capability.ts +++ b/src/lib/local-management-capability.ts @@ -19,6 +19,7 @@ export const LOCAL_MANAGEMENT_READ_PATHS = { startupHealth: "/api/startup-health", providerQuotas: "/api/provider-quotas", usageTimeline: "/api/usage/timeline", + stop: "/api/stop", } as const; export type LocalManagementReadPath = @@ -50,7 +51,8 @@ function isLocalManagementReadPath(path: string): path is LocalManagementReadPat || path === LOCAL_MANAGEMENT_READ_PATHS.usage || path === LOCAL_MANAGEMENT_READ_PATHS.startupHealth || path === LOCAL_MANAGEMENT_READ_PATHS.providerQuotas - || path === LOCAL_MANAGEMENT_READ_PATHS.usageTimeline; + || path === LOCAL_MANAGEMENT_READ_PATHS.usageTimeline + || path === LOCAL_MANAGEMENT_READ_PATHS.stop; } function localReadCapabilityPayload( diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 0feee37f5d6..161b8f998a5 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -139,7 +139,7 @@ over that request's nonce, PID, and port. A caller that decides something from t with `requireResponseProof`, which refuses an unsigned or mismatched response: the capability proves the request to the server, and only the proof tells the caller that the answer came from it and not from a process that took the port. `ocx status` requires the proof before it prefers the -live proxy's `/api/startup-health` verdict over its own shell-local probe (#5977). Local `ocx usage compare` also requires proof for each exact query and pins the runtime record across both windows with 60-second direct reads. +live proxy's `/api/startup-health` verdict over its own shell-local probe (#5977). Local `ocx usage compare` also requires proof for each exact query and pins the runtime record across both windows with 60-second direct reads. Connected-stop preflight pins the runtime record across a credential-free health challenge and an exact `GET /api/stop` read with response proof; the read grant authorizes no stop POST or other route (see the [runtime contract](runtime.md#lifecycle)). The desktop tray uses the same read-v1 contract for its fixed GET allowlist in `src/lib/local-management-capability.ts`, including `/api/oauth/accounts` and @@ -286,7 +286,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. Fast observations and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority): the provider editor classifies `responseTierAuthoritative` as an operator-owned boolean under the existing redaction and write boundaries, `src/usage/log.ts` retains it on final outcomes and attempts (old records keep their legacy meaning), and `src/usage/cost.ts` never prices a non-authoritative echo or confirmed label as response-confirmed. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | -| Stop | `GET /api/stop` reports a read-only PID-bound connected-client shutdown-preservation capability. `POST /api/stop` — stop any installed service and exit the proxy; ordinary owners restore native Codex/Grok. Hub clients preserve connection-owned integrations (`sharedTeardown: "not-owned"`); Link Children restore only their owned Codex journal before listener release and preserve the connection, catalog and other integrations ([runtime contract](runtime.md#lifecycle)). A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | +| Stop | `GET /api/stop` reports a read-only PID-bound connected-client shutdown-ownership capability and accepts an exact single-use local-read grant, returning an attestation proof over its nonce, PID and port. That grant cannot authorize `POST /api/stop`, which retains ordinary management authorization: stop any installed service and exit the proxy; ordinary owners restore native Codex/Grok. Hub clients preserve connection-owned integrations (`sharedTeardown: "not-owned"`); Link Children restore only their owned Codex journal before listener release and preserve the connection, catalog and other integrations ([runtime contract](runtime.md#lifecycle)). A sibling instance restores nothing and answers `sharedTeardown: "not-owned"` ([Codex home](codex-home.md#codex-home)). | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`, and `GET/PUT /api/auto-review-settings`. The auto-review settings route validates the global reviewer selector, persists its enabled/model fields with rollback before publication, then converges the catalog and returns its disposition; the Models panel keeps the server echo and displays a pending-sync notice whenever no commit is reported. Sidecar PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. An effective `vision.enabled` change persists before one bounded catalog-only convergence updates the managed catalog and Codex model cache; the PUT response returns `catalogRefreshPending` for incomplete work. Identical effective values and unrelated partial writes do not converge, and no result claims client reload. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-6-luna` and `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. PUT refuses a qualified target that only the router's default-provider fallback accepts. Provider disable and delete report the target they leave behind as `dependentShadowIntercept` (`shadowInterceptProviderDependency` in `src/server/management/shadow-call-validation.ts`), and at request time an unresolvable target returns `409 intercept_target_unavailable` before any send (`src/server/responses/shadow-target-availability.ts`); it never falls back to the native source model or the default provider. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | diff --git a/structure/runtime.md b/structure/runtime.md index 80f51886ecb..8238ab92fb2 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -335,7 +335,7 @@ field for every tool. Only bare or `default.`-prefixed `exec` and `apply_patch` one recognized alternate body field or remove one complete outer Markdown fence; ambiguous alternate fields and every other freeform grammar pass through unchanged. -The server exposes read-only authenticated `GET /api/stop` with `schema: "ocx-stop-capabilities/1"`, PID and connected-client ownership handling for API, signal and manager shutdown. `src/cli/stop-restore.ts` requires matching client-role health identity or this capability before any connected-client service stop, then rechecks the pinned target before POST/kill. Its probes 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. `src/lib/process-control.ts` defaults every local runtime stop POST to that direct transport, ignoring HTTP(S)/ALL proxy environment variables for CLI stop, service cleanup and uninstall; its injected `fetchFn` takes precedence. Connected clients use this shared default so an observed 409 remains a stop refusal even when its response body is truncated or times out; the direct transport exposes the parsed status before body completion. A stopped connected client skips manager mutation only after the manager is observed inactive and all known local endpoints are definitively dead; the ordinary receipt and environment cleanup still runs. `POST /api/stop`: `src/server/stop-teardown.ts` checks sibling then validated persisted connected-client ownership before receipt deferral or restoration. Siblings and hub clients answer `sharedTeardown: "not-owned"` without restoring Codex/catalog or stripping Grok; an existing receipt remains outstanding. Link clients restore only their owned Codex journal before receipt deferral, while the listener still owns its port, and report the result as `sharedTeardown: "performed"`; a parent retry rechecks the original connection owner and is idempotent. Other owners restore native Codex/Grok, or defer only with the matching receipt. The route stops any installed service +The server exposes read-only authenticated `GET /api/stop` with `schema: "ocx-stop-capabilities/1"`, PID and connected-client ownership handling for API, signal and manager shutdown. Before a connected-client service stop, `src/cli/stop-restore.ts` pins the protected runtime record's PID, port, hostname and attestation key and requires a fresh health proof. A proven client-role listener owns transport-specific cleanup and needs no capability route; an ordinary proxy also requires a valid response proof and ownership-handling document from a single-use `GET /api/stop` capability. Both preflight reads omit reusable credentials and refuse a changed runtime record, sharing a two-second deadline and 4 KiB document limits. The probes use the proxy-independent TCP transport in `src/server/direct-local-http.ts`; the pinned target is rechecked before POST/kill, and unknown targets refuse without mutation. `src/lib/process-control.ts` defaults every local runtime stop POST to that direct transport, ignoring HTTP(S)/ALL proxy environment variables for CLI stop, service cleanup and uninstall; its injected `fetchFn` takes precedence. Connected clients use this shared default so an observed 409 remains a stop refusal even when its response body is truncated or times out; the direct transport exposes the parsed status before body completion. A stopped connected client skips manager mutation only after the manager is observed inactive and all known local endpoints are definitively dead; the ordinary receipt and environment cleanup still runs. `POST /api/stop`: `src/server/stop-teardown.ts` checks sibling then validated persisted connected-client ownership before receipt deferral or restoration. Siblings and hub clients answer `sharedTeardown: "not-owned"` without restoring Codex/catalog or stripping Grok; an existing receipt remains outstanding. Link clients restore only their owned Codex journal before receipt deferral, while the listener still owns its port, and report the result as `sharedTeardown: "performed"`; a parent retry rechecks the original connection owner and is idempotent. Other owners restore native Codex/Grok, or defer only with the matching receipt. The route stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. > Decision record: [ADR-0004](decisions/ADR-0004-lifecycle.md) diff --git a/tests/fixtures/parent-stop-runner.ts b/tests/fixtures/parent-stop-runner.ts index d714b619f62..92f8a227d3e 100644 --- a/tests/fixtures/parent-stop-runner.ts +++ b/tests/fixtures/parent-stop-runner.ts @@ -4,6 +4,10 @@ import * as childProcess from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { CodexNativeRestoreResult } from "../../src/codex/inject"; +import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; +import { LOCAL_MANAGEMENT_NONCE_HEADER } from "../../src/lib/local-management-capability"; + +const ATTESTATION_SECRET = "C".repeat(43); const options = JSON.parse(readFileSync(0, "utf8")) as { receipt: boolean; @@ -32,6 +36,9 @@ if (options.proxyProbePort) { 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"); + writeFileSync(join(process.env.OPENCODEX_HOME!, "runtime-port.json"), JSON.stringify({ + pid: process.pid, port: options.proxyProbePort, hostname: "127.0.0.1", attestationSecret: ATTESTATION_SECRET, + })); 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"); @@ -103,7 +110,7 @@ const state = await import("../../src/config/process-state"); mock.module("../../src/config/process-state", () => ({ ...state, readPid: () => options.orphan || options.alreadyStopped ? null : fakePid, - readRuntimePort: () => options.alreadyStopped ? null : endpoint, + readRuntimePort: () => options.alreadyStopped ? null : { ...endpoint, pid: fakePid, attestationSecret: ATTESTATION_SECRET }, removePid() {}, removeRuntimePort() {}, })); @@ -173,23 +180,27 @@ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => if (init?.method === "GET") { probes.push(url); if (init.redirect !== "error") return unexpected("redirectable compatibility probe"); + const requestHeaders = new Headers(init.headers); + if (requestHeaders.has("x-opencodex-api-key")) return unexpected("credential on compatibility probe"); + const nonce = requestHeaders.get(url.endsWith("/healthz") ? LOCAL_ATTESTATION_CHALLENGE_HEADER : LOCAL_MANAGEMENT_NONCE_HEADER); + const proof = nonce ? createLocalAttestationProof(ATTESTATION_SECRET, nonce, fakePid, endpoint.port) : null; + const headers = proof ? { [LOCAL_ATTESTATION_PROOF_HEADER]: proof } : undefined; 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" } : {}) }); + ...(!options.serverTeardown && !options.clientServer ? { role: "client" } : {}) }, { headers }); } 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 === "malformed") return new Response("{bad json", { headers }); + if (options.clientServer === "oversized") return new Response(new ReadableStream({ start(c) { c.enqueue(new Uint8Array(4097)); c.close(); } }), { headers }); if (options.clientServer === "body-timeout") return new Response(new ReadableStream({ start(c) { init.signal?.addEventListener("abort", () => c.error(init.signal!.reason), { once: true }); - } })); + } }), { headers }); return Response.json({ schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, - pid: options.clientServer === "wrong-pid" ? fakePid + 1 : fakePid }); + pid: options.clientServer === "wrong-pid" ? fakePid + 1 : fakePid }, { headers }); } if (!url.startsWith("http://127.0.0.1:10100/api/stop")) return unexpected(`fetch(${url})`); urls.push(url); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 62a70198499..faf4edc3553 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1235,7 +1235,7 @@ "start-ownership-publication.test.ts": "cli", "startup-action-control-elevation.test.ts": "server", "startup-action-control.test.ts": "server", "login-shell-path.test.ts": "server", "startup-health-ui.test.ts": "gui", "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", - "stepfun-provider.test.ts": "providers", "stop-deferred-teardown.test.ts": "service", + "stepfun-provider.test.ts": "providers", "stop-deferred-teardown.test.ts": "service", "connected-stop-capability-auth.test.ts": "service", "stop-direct-fetch.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", "storage-policy-config-race.test.ts": "storage", "storage-policy-job-responsive.test.ts": "storage", diff --git a/tests/service/connected-stop-capability-auth.test.ts b/tests/service/connected-stop-capability-auth.test.ts new file mode 100644 index 00000000000..c8cf2836970 --- /dev/null +++ b/tests/service/connected-stop-capability-auth.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertConnectedClientStopSupported } from "../../src/cli/stop-restore"; +import { startMachineListener } from "../../src/client/machine-listener"; +import { saveConfig } from "../../src/config"; +import { writeRuntimePort } from "../../src/config/process-state"; +import { + createLocalAttestationChallenge, + createLocalAttestationProof, + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + verifyLocalAttestationProof, +} from "../../src/lib/local-management-attestation"; +import { + createLocalManagementReadCapability, + LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS, + LOCAL_MANAGEMENT_EXPECTED_PID_HEADER, + LOCAL_MANAGEMENT_NONCE_HEADER, +} from "../../src/lib/local-management-capability"; +import { startServer } from "../../src/server"; +import { directLocalHttpFetch } from "../../src/server/direct-local-http"; +import { requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { serverAuthConfig } from "../helpers/server-auth-config"; +import { currentServerFixtureConfig, settleServerAuthFixture } from "../helpers/server-auth-fixture"; + +const SECRET = "A".repeat(43); +const ADMIN = "ocx-test-connected-stop-admin"; +let home: string; +let oldHome: string | undefined; +let oldAdmin: string | undefined; +let server: ReturnType | undefined; +let proxy: ReturnType | undefined; +let codexHome: IsolatedCodexHome | undefined; + +beforeEach(() => { + oldHome = process.env.OPENCODEX_HOME; + oldAdmin = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + home = mkdtempSync(join(tmpdir(), "ocx-stop-capability-auth-")); + process.env.OPENCODEX_HOME = home; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = ADMIN; +}); + +afterEach(async () => { + await server?.stop(true); + server = undefined; + await proxy?.stop(true); + proxy = undefined; + if (codexHome) { + await settleServerAuthFixture(home, codexHome.path); + codexHome.restore(); + codexHome = undefined; + } + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + if (oldAdmin === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = oldAdmin; + removeTreeWithRetry(home); +}); + +type FixtureOptions = { + health?: "proxy" | "client" | "foreign"; + healthProof?: boolean; + readProof?: boolean; + missingRecord?: boolean; + record?: Record; + replaceAfter?: "health" | "capability"; +}; + +function listener(options: FixtureOptions = {}) { + const requests: Array<{ path: string; method: string; headers: Headers }> = []; + const auth: ManagementAuthState = { + available: true, token: ADMIN, source: "environment", sessions: new Map(), pairingGrants: new Map(), + }; + const record = () => ({ pid: process.pid, port: server!.port, hostname: "127.0.0.1", attestationSecret: SECRET }); + const writeRecord = (overrides: Record = {}) => { + writeFileSync(join(home, "runtime-port.json"), JSON.stringify({ ...record(), ...overrides }), { mode: 0o600 }); + }; + server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + const path = new URL(req.url).pathname; + requests.push({ path, method: req.method, headers: new Headers(req.headers) }); + const headers = new Headers(); + const health = path === "/healthz"; + const nonce = req.headers.get(health ? LOCAL_ATTESTATION_CHALLENGE_HEADER : LOCAL_MANAGEMENT_NONCE_HEADER); + if (nonce && (health ? options.healthProof : options.readProof)) { + headers.set(LOCAL_ATTESTATION_PROOF_HEADER, createLocalAttestationProof(SECRET, nonce, process.pid, server!.port!)!); + } + if (options.replaceAfter === (health ? "health" : "capability")) writeRecord({ attestationSecret: "B".repeat(43) }); + if (health) { + return options.health === "foreign" + ? Response.json({ service: "other" }, { status: 404 }) + : Response.json({ service: "opencodex", pid: process.pid, + ...(options.health === "client" ? { role: "client" } : {}) }, { headers }); + } + const error = requireManagementAuth(req, auth, undefined, record()); + if (error) return error; + return Response.json({ schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: process.pid }, { headers }); + } }); + if (!options.missingRecord) writeRecord(options.record); + return { target: { pid: process.pid, port: server.port!, hostname: "127.0.0.1" }, requests }; +} + +describe("connected stop preflight runtime authority", () => { + test("a foreign health response never receives a reusable administrator credential", async () => { + const fixture = listener({ health: "foreign" }); + let refused = false; + try { await assertConnectedClientStopSupported(fixture.target); } catch { refused = true; } + expect(fixture.requests.some(req => req.headers.has("x-opencodex-api-key"))).toBe(false); + expect(refused).toBe(true); + }); + + test("a claimed native client PID without attestation cannot authorize stop", async () => { + const fixture = listener({ health: "client" }); + await expect(assertConnectedClientStopSupported(fixture.target)).rejects.toThrow("Connected-client stop refused"); + expect(fixture.requests.map(req => req.path)).toEqual(["/healthz"]); + }); + + test("an attested native client keeps its credential-free health-only path", async () => { + const fixture = listener({ health: "client", healthProof: true }); + await assertConnectedClientStopSupported(fixture.target); + expect(fixture.requests.map(req => req.path)).toEqual(["/healthz"]); + expect(fixture.requests[0]!.headers.has(LOCAL_ATTESTATION_CHALLENGE_HEADER)).toBe(true); + expect(fixture.requests[0]!.headers.has("x-opencodex-api-key")).toBe(false); + }); + + test("an ordinary proxy proves its bound read without receiving the admin token", async () => { + const fixture = listener({ healthProof: true, readProof: true }); + await assertConnectedClientStopSupported(fixture.target); + expect(fixture.requests.map(req => `${req.method} ${req.path}`)).toEqual(["GET /healthz", "GET /api/stop"]); + expect(fixture.requests.every(req => !req.headers.has("x-opencodex-api-key"))).toBe(true); + expect(fixture.requests[1]!.headers.has(LOCAL_MANAGEMENT_CAPABILITY_HEADER)).toBe(true); + }); + + for (const hostname of ["localhost", "localhost."]) { + test(`${hostname} preflight uses the same literal IPv4 endpoint as stop`, async () => { + const fixture = listener({ healthProof: true, readProof: true, record: { hostname } }); + await assertConnectedClientStopSupported({ ...fixture.target, hostname }); + expect(fixture.requests.map(req => req.path)).toEqual(["/healthz", "/api/stop"]); + expect(fixture.requests.map(req => req.headers.get("host"))) + .toEqual([`127.0.0.1:${fixture.target.port}`, `127.0.0.1:${fixture.target.port}`]); + }); + } + + test("a stop response without the request's proof is refused", async () => { + const fixture = listener({ healthProof: true }); + await expect(assertConnectedClientStopSupported(fixture.target)).rejects.toThrow("Connected-client stop refused"); + }); + + for (const options of [ + { missingRecord: true }, + { record: { attestationSecret: undefined } }, + { record: { pid: process.pid + 1 } }, + { record: { port: 1 } }, + { record: { hostname: "127.0.0.2" } }, + ]) { + test(`unbound runtime refuses before network I/O: ${JSON.stringify(options)}`, async () => { + const fixture = listener({ ...options, healthProof: true, readProof: true }); + await expect(assertConnectedClientStopSupported(fixture.target)).rejects.toThrow("Connected-client stop refused"); + expect(fixture.requests).toHaveLength(0); + }); + } + + for (const replaceAfter of ["health", "capability"] as const) { + test(`replacement after ${replaceAfter} discards the preflight`, async () => { + const fixture = listener({ healthProof: true, readProof: true, replaceAfter }); + await expect(assertConnectedClientStopSupported(fixture.target)).rejects.toThrow("Connected-client stop refused"); + expect(fixture.requests).toHaveLength(replaceAfter === "health" ? 1 : 2); + expect(fixture.requests.every(req => !req.headers.has("x-opencodex-api-key"))).toBe(true); + }); + } +}); + +describe("production connected-stop listeners", () => { + function signed(port: number) { + const nonce = createLocalAttestationChallenge(); + const expiresAt = Date.now() + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS; + const proof = createLocalManagementReadCapability(SECRET, nonce, "GET", "/api/stop", process.pid, port, expiresAt); + expect(proof).not.toBeNull(); + return new Headers({ + [LOCAL_MANAGEMENT_NONCE_HEADER]: nonce, + [LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(process.pid), + [LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt), + [LOCAL_MANAGEMENT_CAPABILITY_HEADER]: proof!, + }); + } + + test("the proxy admits and proves one stop read, refusing replay and altered scope", async () => { + codexHome = installIsolatedCodexHome("ocx-stop-capability-codex-"); + saveConfig(currentServerFixtureConfig({ + ...serverAuthConfig("127.0.0.1"), clientIntegrations: { codex: false }, asideProfileSync: { allProfiles: false }, + })); + proxy = startServer(0, { + localAttestationSecret: SECRET, + inspectNativeCodexOwnership: ownedServiceHomeInspection("Connected stop capability fixture"), + }); + const target = { pid: process.pid, port: proxy.port!, hostname: "127.0.0.1" }; + writeRuntimePort({ ...target, attestationSecret: SECRET }); + const configBefore = readFileSync(join(home, "config.json"), "utf8"); + await assertConnectedClientStopSupported(target); + const send = (path: string, headers: Headers, method = "GET") => directLocalHttpFetch( + `http://127.0.0.1:${target.port}${path}`, { method, headers, redirect: "error" }, + ); + const headers = signed(target.port); + const accepted = await send("/api/stop", headers); + expect(accepted.status).toBe(200); + expect(accepted.headers.get("cache-control")).toBe("no-store"); + expect(verifyLocalAttestationProof(SECRET, headers.get(LOCAL_MANAGEMENT_NONCE_HEADER)!, process.pid, target.port, + accepted.headers.get(LOCAL_ATTESTATION_PROOF_HEADER))).toBe(true); + expect(await accepted.json()).toEqual({ schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: process.pid }); + const replay = await send("/api/stop", headers); + expect(replay.status).toBe(401); + await replay.arrayBuffer(); + for (const [method, path] of [["POST", "/api/stop"], ["GET", "/api/config"], ["GET", "/api/stop?deferSharedTeardown=1"]] as const) { + const refused = await send(path, signed(target.port), method); + expect(refused.status).toBe(401); + await refused.arrayBuffer(); + } + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(configBefore); + }); + + test("the native machine listener's attested health supports the preflight", async () => { + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 0, providers: {}, defaultProvider: "openai" })); + server = startMachineListener(0, { + state: { serverUrl: "https://hub.example.test", managementUrl: "https://hub.example.test", managementTransport: "direct", + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "client-stop-fixture", + tokenFingerprint: "a".repeat(64), protocolVersion: 1, connectedAt: "2026-10-01T00:00:00.000Z", catalogSyncedAt: "2026-10-01T00:00:00.000Z" }, + managementAuthState: { available: true, token: ADMIN, source: "environment", sessions: new Map(), pairingGrants: new Map() }, + restart: { attestationSecret: SECRET, startedAt: "2026-10-01T00:00:00.000Z", + request: async () => { throw new Error("stop preflight must not request a restart"); } }, + }); + const target = { pid: process.pid, port: server.port!, hostname: "127.0.0.1" }; + writeRuntimePort({ ...target, attestationSecret: SECRET }); + await assertConnectedClientStopSupported(target); + const unsupported = await directLocalHttpFetch(`http://127.0.0.1:${target.port}/api/stop`); + expect(unsupported.status).toBe(404); + await unsupported.arrayBuffer(); + }); +}); diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts index a068625e299..8ab860b8b7f 100644 --- a/tests/service/stop-deferred-teardown.test.ts +++ b/tests/service/stop-deferred-teardown.test.ts @@ -10,6 +10,8 @@ import type { CodexNativeRestoreResult } from "../../src/codex/inject"; import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../../src/update/stop-contract.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { fixturePath, repoPath } from "../helpers/repo-root"; +import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; +import { LOCAL_MANAGEMENT_NONCE_HEADER } from "../../src/lib/local-management-capability"; /** * Behavioural cover for the deferred shared teardown (#3008). @@ -126,8 +128,11 @@ describe("parent CLI shared teardown completion", () => { const reply = (request: Request) => { const path = new URL(request.url).pathname; if (request.method === "POST") return Response.json({ success: true, sharedTeardown: "not-owned" }); + const nonce = request.headers.get(path === "/healthz" ? LOCAL_ATTESTATION_CHALLENGE_HEADER : LOCAL_MANAGEMENT_NONCE_HEADER); + const proof = nonce ? createLocalAttestationProof("C".repeat(43), nonce, childPid, target.port!) : null; + const headers = proof ? { [LOCAL_ATTESTATION_PROOF_HEADER]: proof } : undefined; return Response.json(path === "/healthz" ? { service: "opencodex", pid: childPid } - : { schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: childPid }); + : { schema: "ocx-stop-capabilities/1", connectedClientOwnership: true, pid: childPid }, { headers }); }; const target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { targetPaths.push(`${request.method} ${new URL(request.url).pathname}`); @@ -161,7 +166,7 @@ describe("parent CLI shared teardown completion", () => { expect(proxyPaths).toEqual(["/__proxy-control"]); expect(proxyTokens).toEqual([]); expect(targetPaths).toEqual(["GET /healthz", "GET /api/stop", "POST /api/stop"]); - expect(targetTokens).toEqual([token, token]); + expect(targetTokens).toEqual([token]); } finally { if (watchdog) clearTimeout(watchdog); if (child && child.exitCode === null) { child.kill(); await child.exited; } diff --git a/tests/service/stop-direct-fetch.test.ts b/tests/service/stop-direct-fetch.test.ts index f4cabe14271..9ff9dfc3297 100644 --- a/tests/service/stop-direct-fetch.test.ts +++ b/tests/service/stop-direct-fetch.test.ts @@ -122,8 +122,8 @@ describe("local stop transport", () => { const calls = [...source.slice(start, end).matchAll(/await stopProxy\(([\s\S]*?)\);/g)]; expect(calls).toHaveLength(count); for (const call of calls) { - // A type assertion is not a value override; the connected-client direct - // transport is permitted while every other call uses the shared default. + // Connected stops also retain the shared default's refusal-status handling. + expect(call[1]!).not.toMatch(/\bfetchFn\s*:/); expect(call[1]!.replace(/as typeof fetch\b/g, "")).not.toMatch(/\bfetch\b/); } });