Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/fr/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Exemple de structure abrégée :

L’objet réel comprend également `listen` (port, nom d’hôte, source du runtime et de la configuration), les diagnostics de chargement de la configuration et les diagnostics du plug-in Codex intégré. Le schéma JSON est uniquement extensible : de futures versions peuvent ajouter des champs, mais les champs existants doivent rester stables. Les clés d’API, jetons OAuth, en-têtes d’autorisation, contenus de requêtes, adresses électroniques et identités de compte en sont volontairement exclus.

La lecture en direct laisse la sonde de service à durée limitée se terminer : jusqu’à 6,5 secondes sous macOS/Linux et 16,5 secondes sous Windows lorsque le cache de diagnostic est vide ou expiré. Les lectures depuis le cache renvoient rapidement leur résultat. En cas de dépassement du délai, la commande se replie toujours sur les diagnostics locaux ; un `/healthz` sain ne suffit pas à confirmer que la protection contre les redémarrages est active.

### `ocx health [--json]`

Vérifie l’identité du proxy actif. La sortie destinée aux utilisateurs indique le PID et le port ; `--json` produit `{ok, pid, port}`. La commande renvoie 0 uniquement lorsque le proxy est sain, et 1 dans le cas contraire, ce qui permet de l’utiliser comme sonde de service.
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ja/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ ocx status --json

実際のオブジェクトには、`listen` (ポート、ホスト名、ランタイム/構成ソース)、構成ロード診断、およびバンドルされた Codex プラグイン診断も含まれています。 JSON スキーマは加算専用です。将来のバージョンではフィールドが追加される可能性がありますが、既存のフィールドは安定したままになるはずです。 API キー、OAuth トークン、認証ヘッダー、リクエスト コンテンツ、電子メール、アカウント ID は意図的に除外されます。

ライブ読み取りは、時間制限のあるサービス診断が完了するまで待機します。診断キャッシュが空または期限切れの場合、macOS/Linux では最大 6.5 秒、Windows では最大 16.5 秒です。キャッシュ済みの読み取りは速やかに返ります。タイムアウト時は引き続きローカル診断にフォールバックします。`/healthz` が正常であることだけでは、再起動保護が有効とは確認できません。

### `ocx health [--json]`

稼働中のプロキシの ID を確認します。ヒューマン出力は PID/ポートをレポートします。 `--json` は `{ok, pid, port}` を出力します。このコマンドは正常な場合のみ 0 で終了し、それ以外の場合は 1 で終了するため、サービス プローブに適しています。
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ ocx status --json
기존 필드는 안정적으로 유지되어야 합니다. 이 스키마는 API 키, OAuth 토큰, Authorization 헤더,
요청 내용, 이메일, 계정 식별자를 의도적으로 제외합니다.

실시간 읽기는 제한된 서비스 진단이 끝날 때까지 기다립니다. 진단 캐시가 비어 있거나 만료되면 macOS/Linux에서는 최대 6.5초, Windows에서는 최대 16.5초입니다. 캐시된 결과는 신속하게 반환됩니다. 시간 초과 시에는 기존대로 로컬 진단으로 전환합니다. `/healthz`가 정상이라는 사실만으로 재시작 보호가 유효함을 확인할 수는 없습니다.

### `ocx health [--json]`

실행 중인 프록시의 신원 확인을 수행합니다. 일반 출력은 PID/포트를 보고하고, `--json`은
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,11 @@ section, so the two commands should agree on restart protection. If you are diag
compare the reported live startup verdict with the local service details rather than treating the shell
probe as more authoritative.

The live read allows the bounded service probe to finish: up to 6.5 seconds on macOS/Linux
and 16.5 seconds on Windows when the diagnostic cache is cold or expired. Cached reads return
promptly. A timeout still falls back to local diagnostics; a healthy `/healthz` alone does not
establish restart protection.
Comment thread
Yuxin-Qiao marked this conversation as resolved.

The `clients=pending-restart(...)` diagnostic lists Codex CLI clients that predate the routing
injection. On macOS, Electron renderer, utility, and crashpad helpers under Codex.app's framework
are excluded from that client list, including helpers whose executable paths contain spaces.
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ru/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ ocx status --json
включает API-key'и, OAuth-token'ы, заголовки авторизации, содержимое запросов, email и
идентификаторы аккаунтов.

Чтение отчёта работающего прокси ожидает завершения ограниченной по времени диагностики службы: до 6,5 секунды на macOS/Linux и до 16,5 секунды на Windows, если кэш диагностики пуст или устарел. Чтение из кэша возвращает результат без такого ожидания. При тайм-ауте по-прежнему используются локальные диагностические данные; успешная проверка `/healthz` сама по себе не подтверждает защиту при перезапуске.

### `ocx health [--json]`

Identity-check живого прокси. Текстовый вывод сообщает PID/порт; `--json` отдаёт
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/tr/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ gelecekteki sürümler alanlar ekleyebilir, ancak mevcut alanlar kararlı
kalmalıdır. API anahtarlarını, OAuth belirteçlerini, yetkilendirme başlıklarını,
istek içeriğini, e-postaları ve hesap kimliklerini kasıtlı olarak hariç tutar.

Canlı okuma, süre sınırı olan servis probunun tamamlanmasını bekler: tanılama
önbelleği boşsa veya süresi dolmuşsa macOS/Linux'ta en fazla 6,5 saniye,
Windows'ta en fazla 16,5 saniye. Önbellekten yapılan okumalar hızla sonuç döndürür.
Zaman aşımında yine yerel tanılamalara dönülür; yalnızca `/healthz` sağlıklı diye
yeniden başlatma korumasının etkin olduğu doğrulanamaz.

### `ocx health [--json]`

Canlı proxy'yi kimlik kontrolünden geçirin. İnsan çıktısı PID/port bildirir;
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ ocx status --json

真实对象还会包含 `listen`(端口、主机名、运行时/配置来源)、配置加载诊断,以及 bundled Codex 插件诊断。JSON schema 仅允许追加字段:未来版本可能新增字段,但现有字段应保持稳定。它刻意不包含 API keys、OAuth tokens、授权头、请求内容、邮箱和账户身份。

实时读取会等待有上限的服务探测完成:诊断缓存为空或过期时,macOS/Linux 最多等待 6.5 秒,Windows 最多等待 16.5 秒。已有缓存的读取会及时返回。超时仍会回退到本地诊断;仅凭 `/healthz` 健康不能证明重启保护有效。

### `ocx health [--json]`

对正在运行的代理做身份校验。人类可读输出报告 PID/端口;`--json` 输出 `{ok, pid, port}`。只有在健康时该命令才以 0 退出,否则以 1 退出,因此适合用作服务探针。
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ ocx status --json

實際物件還包含 `listen`(連接埠、主機名稱、runtime/config 來源)、設定載入診斷,以及 bundled Codex plugin 診斷。JSON schema 為附加式:未來版本可能新增欄位,但既有欄位應保持穩定。它刻意排除 API 金鑰、OAuth token、授權標頭、請求內容、電子郵件與帳號身分。

即時讀取會等待有時間上限的服務探測完成:診斷快取為空或已過期時,macOS/Linux 最多等待 6.5 秒,Windows 最多等待 16.5 秒。從快取讀取的結果會迅速回傳。逾時仍會退回本機診斷;僅憑 `/healthz` 健康,無法確認重新啟動保護已生效。

### `ocx health [--json]`

對即時代理進行身分檢查。人類可讀輸出回報 PID/連接埠;`--json` 輸出 `{ok, pid, port}`。此指令僅在健康時離開 0,否則離開 1,使其適合服務探測。
Expand Down
4 changes: 2 additions & 2 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../cod
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
import type { OcxConfig } from "../types";
import { diagnoseService, serviceLogPath } from "../service";
import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
import { collectStartupHealth, startupHealthReadBudgetMs, type StartupHealth } from "../codex/autostart-health";
import { getCodexRoutingKind } from "../codex/inject";
import { missingOwnedCatalogPath } from "../codex/inject/config-toml";
import { CODEX_CONFIG_PATH } from "../codex/paths";
Expand Down Expand Up @@ -251,7 +251,7 @@ export async function fetchLiveStartupHealth(
deps: Parameters<typeof fetchBoundLocalManagementRead>[2] = {},
): Promise<StartupHealth | null> {
const result = await fetchBoundLocalManagementRead(
live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: 1_500, ...deps, requireResponseProof: true },
live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: startupHealthReadBudgetMs(), ...deps, requireResponseProof: true },
);
if (result.kind !== "response" || !result.response.ok) return null;
let payload: unknown;
Expand Down
10 changes: 10 additions & 0 deletions src/codex/autostart-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ export type StartupProtection = "service" | "desktop" | "shim" | "none";
export type StartupHealthStatus = "native" | "protected" | "at-risk";
export type ShimCoverage = "full" | "cli-only" | "none";

/** Bound the isolated service-manager probe and let its reader outlive that probe. */
export function startupHealthProbeBudgetMs(platform: NodeJS.Platform = process.platform): number {
return platform === "win32" ? 15_000 : 5_000;
}

export function startupHealthReadBudgetMs(platform: NodeJS.Platform = process.platform): number {
// The endpoint waits an extra 500ms for child settlement; reserve another second for HTTP.
return startupHealthProbeBudgetMs(platform) + 1_500;
}

export interface StartupHealthInputs {
desktop?: DesktopStartupDiagnostic;
routingKind: CodexRoutingKind;
Expand Down
26 changes: 15 additions & 11 deletions src/server/local-management-read-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type LocalManagementReadResult =

export interface LocalManagementReadDeps {
fetchImpl?: typeof fetch;
/** Default transport seam; receives the caller deadline. */
directFetch?: typeof directLocalHttpFetch;
readRuntime?: (pid: number) => RuntimePortState | null;
createNonce?: () => string;
now?: () => number;
Expand Down Expand Up @@ -84,18 +86,20 @@ export async function fetchBoundLocalManagementRead(
if (!capability) return { kind: "unavailable", reason: "capability-unavailable" };

try {
const response = await (deps.fetchImpl ?? directLocalHttpFetch)(
`http://${probeHostname(target.hostname)}:${target.port}${path}`,
{
headers: {
[LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid),
[LOCAL_MANAGEMENT_NONCE_HEADER]: nonce,
[LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt),
[LOCAL_MANAGEMENT_CAPABILITY_HEADER]: capability,
},
signal: AbortSignal.timeout(deps.timeoutMs ?? 4_000),
const timeoutMs = deps.timeoutMs ?? 4_000;
const url = `http://${probeHostname(target.hostname)}:${target.port}${path}`;
const init: RequestInit = {
headers: {
[LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid),
[LOCAL_MANAGEMENT_NONCE_HEADER]: nonce,
[LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt),
[LOCAL_MANAGEMENT_CAPABILITY_HEADER]: capability,
},
);
signal: AbortSignal.timeout(timeoutMs),
};
const response = deps.fetchImpl
? await deps.fetchImpl(url, init)
: await (deps.directFetch ?? directLocalHttpFetch)(url, init, { timeoutMs });
if (deps.requireResponseProof && !verifyLocalAttestationProof(
runtime.attestationSecret, nonce, target.pid, target.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER),
)) {
Expand Down
8 changes: 2 additions & 6 deletions src/server/startup-health-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { join } from "node:path";
import { codexAutoStartEnabled } from "../config";
import { deriveStartupHealth, type StartupHealth } from "../codex/autostart-health";
import { deriveStartupHealth, startupHealthProbeBudgetMs, type StartupHealth } from "../codex/autostart-health";
import { getCodexRoutingKind } from "../codex/inject";
import { diagnoseCodexShim } from "../codex/shim";
import { durableBunPath } from "../lib/bun-runtime";
Expand All @@ -11,7 +11,7 @@ import type { OcxConfig } from "../types";
import { truncateRetainedUtf8 } from "../lib/admission";

const CACHE_TTL_MS = 30_000;
const PROBE_TIMEOUT_MS = probeTimeoutMs();
const PROBE_TIMEOUT_MS = startupHealthProbeBudgetMs();
const INITIAL_PROBE_WAIT_MS = PROBE_TIMEOUT_MS + 500;
const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;

Expand All @@ -31,10 +31,6 @@ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
* bound in both cases: a wedged probe is still abandoned, and the caller still
* receives the previous reading rather than waiting on it.
*/
function probeTimeoutMs(): number {
return process.platform === "win32" ? 15_000 : 5_000;
}

/** The probe bound, so a test's own budget cannot fall below what it must wait for. */
export function startupHealthProbeTimeoutMs(): number {
return PROBE_TIMEOUT_MS;
Expand Down
6 changes: 6 additions & 0 deletions structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ barrier: a timeout verdict alone does not make the home removable. The contract

## Service-manager probe

CLI status and doctor give the attested startup-health read the isolated probe budget plus
1.5 seconds (6.5 seconds on POSIX, 16.5 seconds on Windows), covering the endpoint's child
settlement grace. The read client passes the same deadline to the direct local transport, so its
default 10-second exchange bound does not cut the Windows read short. Identity/proof validation
and local fallback on timeout remain mandatory.

`src/service-manager-probe.ts` (`inspectServiceManagerInstallation`) reports what the platform
service manager has installed for opencodex, read-only and fail-closed. It reads the service
definition itself and parses the `CODEX_HOME` and `OPENCODEX_HOME` values embedded in it, because
Expand Down
65 changes: 65 additions & 0 deletions tests/cli/cli-status-startup-health.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test";
import { fetchLiveStartupHealth, selectStatusStartupHealth, statusServiceSummary } from "../../src/cli/status";
import type { StartupHealth } from "../../src/codex/autostart-health";
import { startupHealthProbeBudgetMs, startupHealthReadBudgetMs } from "../../src/codex/autostart-health";
import { LOCAL_ATTESTATION_PROOF_HEADER, createLocalAttestationProof } from "../../src/lib/local-management-attestation";
import type { fetchBoundLocalManagementRead } from "../../src/server/local-management-read-client";

const LIVE = {
pid: 4242,
Expand Down Expand Up @@ -62,6 +64,69 @@ function deps(body: unknown, proof: string | null = createLocalAttestationProof(
}

describe("ocx status live startup health", () => {
test("the reader covers the endpoint's bounded probe wait on every supported platform", () => {
for (const platform of ["darwin", "linux", "win32"] as const) {
expect(startupHealthReadBudgetMs(platform)).toBeGreaterThan(startupHealthProbeBudgetMs(platform) + 500);
expect(startupHealthReadBudgetMs(platform)).toBeLessThanOrEqual(16_500);
}
});

test("the direct transport receives the reader deadline instead of its own 10-second default", async () => {
const attested = deps(startupPayload());
let timeoutMs: number | undefined;
let directFetchCalls = 0;
const fixture: Parameters<typeof fetchBoundLocalManagementRead>[2] = {
...attested,
directFetch: async (_url, _init, io) => {
timeoutMs = io?.timeoutMs;
directFetchCalls += 1;
return attested.fetchImpl();
},
};
delete fixture.fetchImpl;
expect((await fetchLiveStartupHealth(LIVE, fixture))?.serviceViable).toBe(true);
expect(timeoutMs).toBe(startupHealthReadBudgetMs());
expect(directFetchCalls).toBe(1);

fixture.fetchImpl = attested.fetchImpl;
expect((await fetchLiveStartupHealth(LIVE, fixture))?.serviceViable).toBe(true);
expect(directFetchCalls).toBe(1);
});

test("waits for a cold attested service probe beyond the old 1.5-second deadline", async () => {
const fixture = deps(startupPayload());
const observed = await fetchLiveStartupHealth(LIVE, {
...fixture,
fetchImpl: async (_url, init) => {
await new Promise<void>((resolve, reject) => {
const signal = init!.signal!;
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, 1_650);
signal.addEventListener("abort", onAbort, { once: true });
});
return fixture.fetchImpl();
},
});
expect(observed?.serviceViable).toBe(true);
});

test("a probe exceeding the reader deadline still falls back", async () => {
const observed = await fetchLiveStartupHealth(LIVE, {
...deps(startupPayload()),
timeoutMs: 10,
fetchImpl: async (_url, init) => new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true });
}),
});
expect(observed).toBeNull();
});

test("uses an attested live startup verdict when the shell-local service probe would disagree", async () => {
const observed = await fetchLiveStartupHealth(LIVE, deps(startupPayload()));
expect(observed?.status).toBe("protected");
Expand Down
Loading