diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 95c3fb5fbe..ac152db316 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -122,7 +122,7 @@ Vérifie l’identité du proxy actif. La sortie destinée aux utilisateurs indi ### `ocx ready [--json] [--wait [--timeout ]]` -Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status}`. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation. +Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`. `protocol` est la version courante du protocole distant du hub, `minimumClientProtocol` la plus ancienne version cliente compatible et `managementUrl` l’origine canonique de gestion visible par le navigateur. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation. Par défaut, la commande effectue une seule sonde. Avec `--wait`, elle interroge le service jusqu’à ce qu’il soit prêt ou jusqu’à l’expiration du délai, mais s’arrête immédiatement si elle observe l’état terminal `failed`. Le délai par défaut est de 45 secondes. `--timeout ` exige `--wait` et accepte un entier positif compris entre 1 et 300. La sortie JSON de la CLI est `{ready, status, pid, port}`, où `status` vaut `ready`, `pending`, `failed` ou `unreachable`. Les codes de sortie sont 0 si le service est prêt ; 1 s’il n’est pas prêt, reste en attente, échoue, dépasse le délai ou est inaccessible ; et 64 si les arguments sont invalides. diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 72da38b139..bad04dd6de 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -255,3 +255,5 @@ compte et la charge de travail prévus. ## Clés Remote Hub et valeurs par défaut `runtimeRole` vaut `standalone` par défaut. Un hub utilise `hub.managementPublicOrigin`, `hub.managementIngress` limité au loopback (`enabled:false` si absent) et les identités exactes de `remoteGui.allowedTailscaleUsers` (liste vide si absente). La clé client reste dans `service-api-token`, jamais dans `config.json`; `service-api-token.prev` peut exister pendant une rotation. Les usages ne sont pas répliqués. + +`remoteGui.allowInsecureHttp` est un ancien no-op déprécié, conservé uniquement pour que les anciens fichiers passent encore le schéma strict. Supprimez-le de la configuration : les grants de pairing ne sont acceptés que sur loopback ou via HTTPS authentifié, et `true` ne réactive pas le pairing HTTP en clair. 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 97f3f3fbd0..d6e9425b52 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -123,7 +123,7 @@ ocx status --json 認証不要の `GET /readyz` エンドポイントで同期後の準備状態を確認します。準備完了時は `200`、 `pending` または終端状態の `failed` では `Retry-After: 1` とともに `503` を返します。HTTP の -サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status}` です。`/readyz` がない +サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}` です。`protocol` は hub の現在の remote protocol、`minimumClientProtocol` は互換性のある最小 client protocol、`managementUrl` は browser から見える canonical management origin です。`/readyz` がない 旧プロキシは `unreachable` として fail-closed し、`/healthz` は readiness ではなく別の liveness 確認です。 デフォルトでは 1 回だけ probe します。`--wait` は準備完了または timeout まで polling しますが、 終端 `failed` を確認すると即座に終了します。デフォルト timeout は 45 秒で、`--timeout ` には diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index a5e7af3385..86b6cbfa5f 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -165,3 +165,5 @@ Anthropic OAuth サイドカーは、opencodex の既存のクロード コー ## Remote Hub のキーと既定値 `runtimeRole` の既定値は `standalone` です。hub は `hub.managementPublicOrigin`、loopback 限定の `hub.managementIngress`(未設定時 `enabled:false`)、正確な `remoteGui.allowedTailscaleUsers`(未設定時は空)を使います。クライアントキーは `config.json` ではなく `service-api-token` に保存され、更新中だけ `service-api-token.prev` が存在する場合があります。使用量はミラーリングされません。 + +`remoteGui.allowInsecureHttp` は、古い strict-schema 設定を読み込むためだけに残された非推奨の no-op です。設定から削除してください。pairing grant は loopback または認証済み HTTPS でのみ受け付けられ、この値を `true` にしても平文 HTTP pairing は再び有効になりません。 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 271830ee1d..081c791bbb 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -156,7 +156,7 @@ ocx status --json 인증이 필요 없는 `GET /readyz` 엔드포인트로 동기화 후 준비 상태를 확인합니다. 준비되면 `200`, `pending` 또는 종단 상태인 `failed`이면 `Retry-After: 1`과 함께 `503`을 반환합니다. HTTP의 정제된 -식별 필드는 `{service, version, uptime, pid, port, status}`입니다. `/readyz`가 없는 이전 프록시는 +식별 필드는 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`입니다. `protocol`은 허브의 현재 원격 프로토콜, `minimumClientProtocol`은 호환되는 최소 클라이언트 프로토콜, `managementUrl`은 브라우저에서 보이는 표준 관리 origin입니다. `/readyz`가 없는 이전 프록시는 `unreachable`로 fail-closed하며, `/healthz`는 준비 상태가 아닌 별도의 liveness 확인입니다. 기본값은 한 번의 probe이며, `--wait`는 준비 또는 timeout까지 polling하지만 종단 `failed`를 확인하면 즉시 종료합니다. 기본 timeout은 45초이며, `--timeout `는 `--wait`와 함께 써야 하고 양의 정수인 1~300초 범위를 받습니다. CLI JSON은 diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 0e280e668d..879b9d40a6 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -165,3 +165,5 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin ## Remote Hub 키와 기본값 `runtimeRole` 기본값은 `standalone`입니다. 허브는 `hub.managementPublicOrigin`, 로컬에만 열리는 `hub.managementIngress`(없으면 `enabled:false`), 정확한 `remoteGui.allowedTailscaleUsers`(없으면 빈 목록)를 사용합니다. 클라이언트 데이터 키는 `config.json`이 아니라 `service-api-token`에 저장되며 교체 중에는 `service-api-token.prev`가 잠시 생길 수 있습니다. 사용량 기록은 서로 복제하지 않습니다. + +`remoteGui.allowInsecureHttp`는 이전 strict-schema 설정을 계속 읽기 위해서만 남겨 둔 폐기된 no-op입니다. 설정에서 제거하세요. 페어링 grant는 loopback 또는 인증된 HTTPS에서만 허용되며, 이 값을 `true`로 설정해도 평문 HTTP 페어링은 다시 활성화되지 않습니다. 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 df777b802b..30b4e627a3 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -166,7 +166,7 @@ Identity-check живого прокси. Текстовый вывод сооб Проверяет готовность после синхронизации через не требующий аутентификации `GET /readyz`. При готовности возвращается `200`; для `pending` и терминального `failed` возвращается `503` с -`Retry-After: 1`. Санитизированные поля HTTP-ответа: `{service, version, uptime, pid, port, status}`. +`Retry-After: 1`. Санитизированные поля HTTP-ответа: `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`. `protocol` — текущая версия удалённого протокола hub, `minimumClientProtocol` — минимальная совместимая версия клиента, а `managementUrl` — канонический origin управления для браузера. Старые прокси без `/readyz` fail-closed как `unreachable`; `/healthz` — отдельная проверка liveness, а не готовности. По умолчанию команда выполняет одну пробу. `--wait` опрашивает до готовности или тайм-аута, но при терминальном `failed` завершается немедленно. Тайм-аут по умолчанию — 45 секунд; diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index c65daab76c..306534a3e1 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -213,3 +213,5 @@ opencodex. Перед использованием прогоните soak-test ## Ключи Remote Hub и значения по умолчанию `runtimeRole` по умолчанию равен `standalone`. Hub использует `hub.managementPublicOrigin`, loopback-only `hub.managementIngress` (`enabled:false`, если отсутствует) и точные `remoteGui.allowedTailscaleUsers` (пустой список, если отсутствует). Ключ клиента хранится в `service-api-token`, не в `config.json`; во время ротации может появиться `service-api-token.prev`. Статистика не зеркалируется. + +`remoteGui.allowInsecureHttp` — устаревший no-op, оставленный только для загрузки старых файлов со строгой схемой. Удалите его из конфигурации: pairing grants принимаются лишь через loopback или аутентифицированный HTTPS, а значение `true` не включает pairing по открытому HTTP. diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 533463cfb6..e26f4c8662 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -177,7 +177,7 @@ takdirde 1 ile çıkar, bu da onu servis probları için uygun hale getirir. Kimliği doğrulanmamış `GET /readyz` uç noktası aracılığıyla senkronizasyon sonrası hazırlığı kontrol edin. Hazır olduğunda `200` veya `pending` ve terminal `failed` için `Retry-After: 1` ile `503` döndürür. Temizlenmiş HTTP kimliği -`{service, version, uptime, pid, port, status}` şeklindedir. `/readyz` içermeyen +`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}` şeklindedir. `protocol` hub'ın güncel uzak protokolünü, `minimumClientProtocol` uyumlu en düşük istemci protokolünü ve `managementUrl` tarayıcıya görünen kanonik yönetim origin'ini belirtir. `/readyz` içermeyen eski proxy'ler `unreachable` olarak kapalı başarısız olur; `/healthz` hazırlık değil, ayrı bir canlılıktır. Komut varsayılan olarak bir prob gerçekleştirir; `--wait`, hazır olana veya zaman aşımına kadar yoklar, ancak terminal `failed` diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index c701c06805..edbd593eee 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -285,3 +285,5 @@ yeniden kullanır. Hedeflenen hesap ve iş yükünü kapsamlı bir şekilde test ## Remote Hub anahtarları ve varsayılanlar `runtimeRole` varsayılan olarak `standalone` değerindedir. Hub; `hub.managementPublicOrigin`, yalnız loopback `hub.managementIngress` (yokken `enabled:false`) ve tam `remoteGui.allowedTailscaleUsers` (yokken boş) kullanır. İstemci anahtarı `config.json` yerine `service-api-token` içinde kalır; döndürme sırasında `service-api-token.prev` geçici olarak bulunabilir. Kullanım kayıtları yansıtılmaz. + +`remoteGui.allowInsecureHttp`, yalnızca eski strict-schema yapılandırmalarının yüklenebilmesi için tutulan, kullanımdan kaldırılmış bir no-op'tur. Yapılandırmadan silin: pairing grant'leri yalnız loopback veya kimliği doğrulanmış HTTPS üzerinden kabul edilir ve `true` değeri düz HTTP pairing'i yeniden açmaz. 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 9ab50802d1..dfae403438 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 @@ -123,7 +123,7 @@ ocx status --json 通过无需认证的 `GET /readyz` 端点检查同步后的就绪状态。就绪时返回 `200`;状态为 `pending` 或 终态 `failed` 时返回 `503`,并带有 `Retry-After: 1`。HTTP 仅返回经脱敏的身份字段 -`{service, version, uptime, pid, port, status}`。不支持 `/readyz` 的旧代理会按 `unreachable` 失败关闭; +`{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`。`protocol` 是 Hub 当前的远程协议版本,`minimumClientProtocol` 是兼容的最低客户端协议版本,`managementUrl` 是浏览器可见的规范管理 origin。不支持 `/readyz` 的旧代理会按 `unreachable` 失败关闭; `/healthz` 是独立的存活检查,不是就绪检查。默认只探测一次;`--wait` 会轮询到就绪或超时,但遇到终态 `failed` 会立即退出。默认超时为 45 秒;`--timeout ` 必须与 `--wait` 一起使用,取值范围为 1–300 秒的正整数。CLI JSON 输出 `{ready, status, pid, port}`,其中 `status` 为 `ready`、`pending`、`failed` 或 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index df1452ee7b..bee7942398 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -179,3 +179,5 @@ Anthropic OAuth 侧车会复用 opencodex 现有的 Claude Code OAuth 指纹。 ## Remote Hub 密钥与默认值 `runtimeRole` 默认为 `standalone`。Hub 使用 `hub.managementPublicOrigin`、仅回环的 `hub.managementIngress`(缺省为 `enabled:false`)和准确的 `remoteGui.allowedTailscaleUsers`(缺省为空)。客户端密钥保存在 `service-api-token` 而不是 `config.json`;轮换期间可能暂时存在 `service-api-token.prev`。使用记录不会镜像。 + +`remoteGui.allowInsecureHttp` 是已弃用的 no-op,仅为让旧的严格 schema 配置继续加载而保留。请从配置中删除它:pairing grant 只接受 loopback 或已认证的 HTTPS;设为 `true` 也不会重新开放明文 HTTP pairing。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 2307dbf6c3..bb377466fd 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -121,7 +121,7 @@ ocx status --json ### `ocx ready [--json] [--wait [--timeout ]]` -透過免認證的 `GET /readyz` 端點檢查同步後的就緒狀態。就緒時回傳 `200`,或 `pending` 與終端 `failed` 時回傳附帶 `Retry-After: 1` 的 `503`。其淨化的 HTTP 身分為 `{service, version, uptime, pid, port, status}`。沒有 `/readyz` 的舊代理會以 `unreachable` 方式 fail closed;`/healthz` 是分開的存活檢查,而非就緒檢查。此指令預設執行一次探測;`--wait` 輪詢直到就緒或逾時,但在觀察到終端 `failed` 狀態時立即退出。預設逾時為 45 秒;`--timeout ` 需要 `--wait`,接受 1–300 的正整數秒。CLI JSON 輸出 `{ready, status, pid, port}`,其中 `status` 為 `ready`、`pending`、`failed` 或 `unreachable`。離開碼為:就緒 0;未就緒、pending、failed、逾時或 unreachable 1;無效引數 64。 +透過免認證的 `GET /readyz` 端點檢查同步後的就緒狀態。就緒時回傳 `200`,或 `pending` 與終端 `failed` 時回傳附帶 `Retry-After: 1` 的 `503`。其淨化的 HTTP 身分為 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`。`protocol` 是 Hub 目前的遠端協定版本,`minimumClientProtocol` 是相容的最低用戶端協定版本,`managementUrl` 是瀏覽器可見的標準管理 origin。沒有 `/readyz` 的舊代理會以 `unreachable` 方式 fail closed;`/healthz` 是分開的存活檢查,而非就緒檢查。此指令預設執行一次探測;`--wait` 輪詢直到就緒或逾時,但在觀察到終端 `failed` 狀態時立即退出。預設逾時為 45 秒;`--timeout ` 需要 `--wait`,接受 1–300 的正整數秒。CLI JSON 輸出 `{ready, status, pid, port}`,其中 `status` 為 `ready`、`pending`、`failed` 或 `unreachable`。離開碼為:就緒 0;未就緒、pending、failed、逾時或 unreachable 1;無效引數 64。 ### `ocx doctor` diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index aab24e9f26..1d80f5911c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -198,3 +198,5 @@ Anthropic OAuth sidecar 重用 opencodex 既有的 Claude Code OAuth 指紋。 ## Remote Hub 金鑰與預設值 `runtimeRole` 預設為 `standalone`。Hub 使用 `hub.managementPublicOrigin`、僅限迴路的 `hub.managementIngress`(缺省為 `enabled:false`)與正確的 `remoteGui.allowedTailscaleUsers`(缺省為空)。用戶端金鑰保存在 `service-api-token` 而不是 `config.json`;輪替期間可能暫時存在 `service-api-token.prev`。用量不會鏡像。 + +`remoteGui.allowInsecureHttp` 是已棄用的 no-op,只為讓舊的 strict-schema 設定繼續載入而保留。請從設定移除:pairing grant 僅接受 loopback 或已驗證的 HTTPS;設為 `true` 也不會重新開放明文 HTTP pairing。 diff --git a/gui/src/components/MemoryObservabilityCard.tsx b/gui/src/components/MemoryObservabilityCard.tsx index d19134c756..ebf12a4ad7 100644 --- a/gui/src/components/MemoryObservabilityCard.tsx +++ b/gui/src/components/MemoryObservabilityCard.tsx @@ -299,7 +299,9 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } inFlight = true; const bounded = createBoundedFetch(5_000); active = bounded; - void fetch(`${apiBase}/healthz`, { cache: "no-store", signal: bounded.signal }) + // Restart is a shared-plane action, so reconnect through its authenticated management + // health route. Remote Hub intentionally does not expose /healthz on management ingress. + void fetch(`${apiBase}/api/system/health`, { cache: "no-store", signal: bounded.signal }) .then(async (res) => { if (cancelled) return; if (!res.ok) { diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index 5198801889..f6bb653452 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -254,7 +254,7 @@ export async function fetchDashboardOverview( ): Promise { try { const [hRes, pRes] = await Promise.all([ - fetch(`${apiBase}/healthz`, { signal }), + fetch(`${apiBase}/api/system/health`, { signal }), fetch(`${apiBase}/api/providers`, { signal }), ]); const health = await requireJson(hRes); diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index a1ccca9890..c411168daf 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -79,7 +79,8 @@ test("Dashboard overview status widgets do not wait on injection-model", async ( expect(overviewStart).toBeGreaterThan(-1); expect(multiStart).toBeGreaterThan(overviewStart); const overviewBody = core.slice(overviewStart, multiStart); - expect(overviewBody).toContain("/healthz"); + expect(overviewBody).toContain("/api/system/health"); + expect(overviewBody).not.toContain("/healthz"); expect(overviewBody).toContain("/api/providers"); expect(overviewBody).not.toContain("/api/injection-model"); expect(overviewBody).not.toContain("/api/v2"); diff --git a/gui/tests/memory-observability-card.test.tsx b/gui/tests/memory-observability-card.test.tsx index 556f3646ef..1fca1bcd33 100644 --- a/gui/tests/memory-observability-card.test.tsx +++ b/gui/tests/memory-observability-card.test.tsx @@ -186,6 +186,47 @@ test("Drain & restart posts /api/system/restart after confirm", async () => { await act(async () => { root.unmount(); }); }); +test("restart reconnect polls authenticated management health instead of denied /healthz", async () => { + let memoryReads = 0; + const { root, container, testWindow, calls } = await mountCard((url) => { + if (url.includes("/api/startup-health")) return Response.json({ protection: "service" }); + if (url.includes("/api/system/restart")) { + return Response.json({ success: true, activeTurnCount: 2 }, { status: 202 }); + } + if (url.includes("/api/system/memory")) { + memoryReads += 1; + return memoryReads === 1 + ? Response.json(MEMORY_PAYLOAD) + : new Response("restarting", { status: 503 }); + } + if (url.includes("/api/system/health")) { + return Response.json({ status: "ok", version: "test", uptime: 1, pid: 4243 }); + } + return new Response(null, { status: 404 }); + }); + + originalConfirm = window.confirm; + window.confirm = () => true; + const button = Array.from(container.querySelectorAll("button")).find( + (el) => (el.textContent ?? "").includes("Drain & restart"), + ); + expect(button).toBeTruthy(); + + await act(async () => { + button!.dispatchEvent(new testWindow.MouseEvent("click", { bubbles: true })); + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + if (calls().some(call => call.includes("/api/system/health"))) break; + } + }); + + expect(calls().some(call => call.includes("/api/system/health"))).toBe(true); + expect(calls().some(call => /\/healthz(?:$|\?)/.test(call))).toBe(false); + expect(container.textContent ?? "").toContain("Drain & restart"); + + await act(async () => { root.unmount(); }); +}); + test("older memory payloads without activeTurnCount hide the restart action", async () => { const legacy = { ...MEMORY_PAYLOAD } as Record; delete legacy.activeTurnCount; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 1e49c2c278..7c3e8a8836 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -220,7 +220,7 @@ export async function handleManagementAPI( } } catch { /* best-effort */ } } - const ctx: ManagementContext = { req, url, config, deps, principal, sessionControl, convergeCodexCatalog, syncClaudeAgentDefsBestEffort }; + const ctx: ManagementContext = { req, url, config, deps, version: VERSION, principal, sessionControl, convergeCodexCatalog, syncClaudeAgentDefsBestEffort }; let routed: Response | null; try { routed = handleSessionRoutes(ctx) diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 5d57378bb3..49aedf4990 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -109,6 +109,8 @@ export interface ManagementContext { url: URL; config: OcxConfig; deps: ManagementApiDeps; + /** Installed package version projected through bounded system identity routes. */ + version: string; /** * Which credential authorized this request, resolved by the auth gate before * dispatch. Routes that spend the USER's identity (not just the proxy's) must diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index a8a7a983da..68c72285b5 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -291,6 +291,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/storage/codex-logs/repair", module: "server/management/storage-log-guard-routes", mutates: true }, { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true }, // server/management/system-routes + { method: "GET", path: "/api/system/health", module: "server/management/system-routes", mutates: false }, { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false }, { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false }, { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true }, diff --git a/src/server/management/system-routes.ts b/src/server/management/system-routes.ts index 867a3f94a0..b163b8a8ad 100644 --- a/src/server/management/system-routes.ts +++ b/src/server/management/system-routes.ts @@ -48,7 +48,19 @@ import { acceptSystemRestart } from "./system-restart"; const ENDPOINT_SAMPLE_LIMIT = 60; export async function handleSystemRoutes(ctx: ManagementContext): Promise { - const { req, url, config } = ctx; + const { req, url, config, version } = ctx; + if (url.pathname === "/api/system/health" && req.method === "GET") { + // Authenticated management counterpart to /healthz. Remote Hub deliberately keeps the + // unauthenticated liveness route off its management ingress, while the connected dashboard + // still needs bounded process identity and PID replacement evidence (#3158). + return jsonResponse({ + status: "ok", + service: "opencodex", + version, + uptime: process.uptime(), + pid: process.pid, + }); + } if (url.pathname === "/api/system/memory" && req.method === "GET") { const usage = process.memoryUsage(); let jscHeap: { heapSize: number; heapCapacity: number; objectCount: number } | null = null; diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 9d011d7c4a..ff75b15e88 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -129,7 +129,7 @@ this document owns is which module holds which area and what invariant that area | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | -| 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/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. 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`. | +| 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; it does not widen a Remote Hub management ingress 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. 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. | | 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`. 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. `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-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | diff --git a/tests/cli-capabilities.test.ts b/tests/cli-capabilities.test.ts index 17e4beecf0..262e4e6fcd 100644 --- a/tests/cli-capabilities.test.ts +++ b/tests/cli-capabilities.test.ts @@ -248,6 +248,7 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "GET /api/storage/codex-logs", "GET /api/subagent-model-fallback", "GET /api/subagent-models", + "GET /api/system/health", "GET /api/system/memory", "GET /api/system/windows-replace-retries", "GET /api/update/badge", diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index cbcdf16161..c3eb6bfc1e 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -1003,16 +1003,35 @@ describe("management and data-plane credential separation", () => { const html = await issued.text(); const token = /name="opencodex-session-token" content="([^"]+)"/.exec(html)?.[1]; expect(token).toBeDefined(); + const sessionHeaders = { + Host: "hub.example.test", + Origin: "https://hub.example.test", + "x-opencodex-api-key": token!, + "x-opencodex-gui-origin": "https://hub.example.test", + }; const management = await fetch(`http://127.0.0.1:${managementPort}/api/config`, { - headers: { - Host: "hub.example.test", - Origin: "https://hub.example.test", - "x-opencodex-api-key": token!, - "x-opencodex-gui-origin": "https://hub.example.test", - }, + headers: sessionHeaders, }); expect(management.status).toBe(200); + // Connected GUI status/restart polling stays authenticated without widening the ingress: + // raw liveness remains absent, while its bounded management counterpart is available. + const rawHealth = await fetch(`http://127.0.0.1:${managementPort}/healthz`, { + headers: sessionHeaders, + }); + expect(rawHealth.status).toBe(404); + const managementHealth = await fetch(`http://127.0.0.1:${managementPort}/api/system/health`, { + headers: sessionHeaders, + }); + expect(managementHealth.status).toBe(200); + expect(await managementHealth.json()).toMatchObject({ + status: "ok", + service: "opencodex", + version: expect.any(String), + uptime: expect.any(Number), + pid: process.pid, + }); + const adminConsent = await fetch(`http://127.0.0.1:${managementPort}/api/github/star`, { method: "POST", headers: {