From 655e40a114ccab079c21f4c0c69dd4365a7e8f59 Mon Sep 17 00:00:00 2001
From: t
Date: Wed, 9 Sep 2026 04:23:34 +0900
Subject: [PATCH 1/6] fix(catalog): read the hub capability context window
Closes #4032
---
scripts/test-layout/layout.json | 1 +
src/codex/catalog/provider-fetch.ts | 7 ++
.../catalog-hub-context-window.test.ts | 71 +++++++++++++++++++
tests/fixtures/test-layout-expected.json | 1 +
4 files changed, 80 insertions(+)
create mode 100644 tests/codex-integration/catalog-hub-context-window.test.ts
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 616f650968..53a8e65898 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -267,6 +267,7 @@
"catalog-cursor-search.test.ts": "codex-integration",
"catalog-full-picker-order.test.ts": "codex-integration",
"catalog-go-exact-efforts.test.ts": "codex-integration",
+ "catalog-hub-context-window.test.ts": "codex-integration",
"catalog-input-modality-enum.test.ts": "codex-integration",
"catalog-llamacpp-capabilities.test.ts": "codex-integration",
"catalog-oauth-observation.test.ts": "codex-integration",
diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts
index 1e361ad7bc..add955bb45 100644
--- a/src/codex/catalog/provider-fetch.ts
+++ b/src/codex/catalog/provider-fetch.ts
@@ -1425,6 +1425,13 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
// supplying a recognized field changes behavior (#1797).
plainRecord(item.meta)?.n_ctx,
plainRecord(item.meta)?.n_ctx_train,
+ // A chained OpenCodex hub (and other re-serving gateways) reports the per-model
+ // window on the same capability record this function already reads for
+ // `max_output_tokens` below (#4032). Without it every routed row fell through to
+ // the 128k compatibility floor in parsing.ts while local forward rows kept their
+ // real values. Appended after the recognized fields for the same reason as the
+ // llama.cpp entries above: no provider that already resolves changes behavior.
+ capabilityRecord?.context_length,
);
const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
const maxOutputTokens = positiveSafeInteger(
diff --git a/tests/codex-integration/catalog-hub-context-window.test.ts b/tests/codex-integration/catalog-hub-context-window.test.ts
new file mode 100644
index 0000000000..fb44a64dd1
--- /dev/null
+++ b/tests/codex-integration/catalog-hub-context-window.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, test } from "bun:test";
+import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/provider-fetch";
+
+/**
+ * Regression coverage for #4032 (chained clients / provider hub).
+ *
+ * A hub that re-serves an upstream catalog reports the per-model window under
+ * `capabilities.context_length`. `catalogHintsFromModelsApiItem` already read that
+ * same record for `max_output_tokens`, but never for the context window, so every
+ * routed row fell through to the 128k compatibility floor in parsing.ts while local
+ * forward rows kept their real values.
+ *
+ * The capability field is appended AFTER the recognized metadata/limits fields and
+ * after the Copilot-specific `capabilities.limits.max_context_window_tokens`, so no
+ * provider that already resolved a window changes behaviour.
+ */
+
+const HUB_MODELS_ITEM = {
+ id: "anthropic/claude-opus-5",
+ object: "model" as const,
+ owned_by: "opencodex-hub",
+ capabilities: {
+ context_length: 922000,
+ max_output_tokens: 64000,
+ },
+};
+
+describe("provider-hub capabilities.context_length (#4032)", () => {
+ test("absorbs capabilities.context_length from a hub-shaped /v1/models item", () => {
+ const hints = catalogHintsFromModelsApiItem("hub", HUB_MODELS_ITEM);
+ expect(hints.contextWindow).toBe(922000);
+ });
+
+ test("the same record still yields max_output_tokens (asymmetry is gone)", () => {
+ const hints = catalogHintsFromModelsApiItem("hub", HUB_MODELS_ITEM);
+ expect(hints.maxOutputTokens).toBe(64000);
+ });
+
+ test("reads the capability record from metadata.capabilities too", () => {
+ const hints = catalogHintsFromModelsApiItem("hub", {
+ id: "meta-shaped",
+ metadata: { capabilities: { context_length: 400000 } },
+ });
+ expect(hints.contextWindow).toBe(400000);
+ });
+
+ test("a recognized context field still wins over the capability record", () => {
+ // Contested on purpose: the capability field is appended last so no provider
+ // already supplying a recognized field changes behaviour.
+ const hints = catalogHintsFromModelsApiItem("hub", {
+ id: "both",
+ context_length: 32768,
+ capabilities: { context_length: 922000 },
+ });
+ expect(hints.contextWindow).toBe(32768);
+ });
+
+ test("Copilot's max_context_window_tokens still wins over the capability record", () => {
+ const hints = catalogHintsFromModelsApiItem("copilot", {
+ id: "gpt-5.6-sol",
+ capabilities: { context_length: 922000, limits: { max_context_window_tokens: 128000 } },
+ });
+ expect(hints.contextWindow).toBe(128000);
+ });
+
+ test("a non-positive or non-integer capability window is ignored", () => {
+ expect(catalogHintsFromModelsApiItem("hub", { id: "zero", capabilities: { context_length: 0 } }).contextWindow).toBeUndefined();
+ expect(catalogHintsFromModelsApiItem("hub", { id: "neg", capabilities: { context_length: -1 } }).contextWindow).toBeUndefined();
+ expect(catalogHintsFromModelsApiItem("hub", { id: "str", capabilities: { context_length: "922000" } }).contextWindow).toBeUndefined();
+ });
+});
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 738bac5141..da6be012bc 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -102,6 +102,7 @@
"catalog-cursor-search.test.ts": "codex-integration",
"catalog-full-picker-order.test.ts": "codex-integration",
"catalog-go-exact-efforts.test.ts": "codex-integration",
+ "catalog-hub-context-window.test.ts": "codex-integration",
"catalog-input-modality-enum.test.ts": "codex-integration",
"catalog-llamacpp-capabilities.test.ts": "codex-integration",
"catalog-oauth-observation.test.ts": "codex-integration",
From 56d5845822e97419008bce951f13851bef2a23bc Mon Sep 17 00:00:00 2001
From: t
Date: Wed, 9 Sep 2026 04:23:35 +0900
Subject: [PATCH 2/6] fix(codex): retire a dead runtime pin when resolution
degrades
Closes #4035
---
src/codex/runtime.ts | 35 ++++++++-
tests/codex-integration/codex-runtime.test.ts | 71 +++++++++++++++++++
2 files changed, 105 insertions(+), 1 deletion(-)
diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts
index 51150e6aa7..4c1914cbf7 100644
--- a/src/codex/runtime.ts
+++ b/src/codex/runtime.ts
@@ -86,6 +86,8 @@ export interface PersistedCodexRuntimeState {
const PERSIST_FILE = "codex-runtime.json";
const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json";
+/** Probe rejection for an absolute candidate whose file is gone. Matched when retiring a dead pin (#4035). */
+const PATH_MISSING_REASON = "path does not exist";
function cloneAndDeepFreeze(value: T): DeepReadonly {
const clone = (current: unknown): unknown => {
@@ -283,6 +285,23 @@ export function persistCodexRuntime(
atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`);
}
+/**
+ * Delete `codex-runtime.json`. Used to retire a pin whose path no longer exists, so a
+ * later resolve stops re-probing it (#4035).
+ *
+ * Invalidates the process resolve memo the same way `persistCodexRuntime` does: the memo
+ * folds the persisted `updatedAt` into its key, and a removed file has no stamp to fold.
+ */
+export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): void {
+ const configDir = deps.configDir ?? getConfigDir();
+ clearCodexRuntimeResolveCache();
+ try {
+ unlinkSync(codexRuntimeStatePath(configDir));
+ } catch {
+ // Already gone, or not ours to remove. Either way the pin is not authoritative.
+ }
+}
+
function probeVersion(
command: string,
deps: ResolveCodexRuntimeDeps,
@@ -290,7 +309,7 @@ function probeVersion(
const platform = deps.platform ?? process.platform;
if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
const exists = deps.existsSync ?? existsSync;
- if (!exists(command)) return { ok: false, reason: "path does not exist" };
+ if (!exists(command)) return { ok: false, reason: PATH_MISSING_REASON };
if (!isSpawnableCodexCandidate(command, platform)) {
return { ok: false, reason: "not a spawnable Codex launcher on this platform" };
}
@@ -654,6 +673,20 @@ export function resolveAndPersistCodexRuntime(
return cloneAndDeepFreeze({ ...result, persistError });
}
}
+ // A pin whose path has vanished must be RETIRED, not merely skipped. A Codex App update
+ // replaces the hashed plugin directory the pin names, the probe rejects it with
+ // "path does not exist", nothing else resolves, and the selection degrades to `fallback` —
+ // which the write guard above declines. The dead entry then survived every later resolve
+ // and each one re-probed a path that cannot exist (#4035). Bound narrowly: only when the
+ // degraded result is `fallback`, only for the persisted command, and only for the
+ // path-does-not-exist rejection, so a present-but-unusable binary is left for the operator.
+ else if (result.runtime.source === "fallback" && persistedRuntime?.command) {
+ const pinVanished = result.failures.some(
+ failure => sameRuntimeCommand(failure.command, persistedRuntime.command)
+ && failure.reason === PATH_MISSING_REASON,
+ );
+ if (pinVanished) clearPersistedCodexRuntime(deps);
+ }
return result;
}
diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts
index 2dc5d6347d..ee45d41e61 100644
--- a/tests/codex-integration/codex-runtime.test.ts
+++ b/tests/codex-integration/codex-runtime.test.ts
@@ -970,3 +970,74 @@ describe("resolveCodexRuntime", () => {
expect(diagnostics[0]?.affectedModels).toEqual(["openrouter/example"]);
});
});
+
+describe("dead configured pin recovery (#4035)", () => {
+ test("a dead configured pin is cleared when resolution degrades to fallback", () => {
+ // A Codex App update deletes the hashed plugin directory the pin names. The probe
+ // rejects the vanished absolute path ("path does not exist"), no PATH candidate
+ // exists, and resolution degrades to `fallback` — which the persist guard skipped,
+ // so the dead pin survived forever and every later resolve re-probed a path that
+ // cannot exist.
+ const configDir = tempConfigDir();
+ const dead = join(configDir, "gone", "codex");
+ persistCodexRuntime({ command: dead, version: "0.153.0", source: "configured" }, { configDir });
+ expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(dead);
+
+ const result = resolveAndPersistCodexRuntime({
+ configDir,
+ env: { PATH: "" },
+ platform: "linux",
+ existsSync: (path) => !String(path).includes("gone"),
+ execFileSync: () => { throw new Error("ENOENT"); },
+ });
+
+ expect(result.runtime.source).toBe("fallback");
+ expect(existsSync(join(configDir, "codex-runtime.json"))).toBe(false);
+ expect(loadPersistedCodexRuntime({ configDir })).toBeNull();
+ });
+
+ test("a fallback resolve with no persisted pin writes nothing", () => {
+ const configDir = tempConfigDir();
+ const result = resolveAndPersistCodexRuntime({
+ configDir,
+ env: { PATH: "" },
+ platform: "linux",
+ existsSync: () => false,
+ execFileSync: () => { throw new Error("ENOENT"); },
+ });
+ expect(result.runtime.source).toBe("fallback");
+ expect(existsSync(join(configDir, "codex-runtime.json"))).toBe(false);
+ });
+
+ test("a live configured pin is NOT cleared when the resolve succeeds", () => {
+ // The clear is bound to a dead pin, not to every fallback-shaped result.
+ const configDir = tempConfigDir();
+ const live = join(configDir, "bin", "codex");
+ persistCodexRuntime({ command: live, version: "0.153.0", source: "configured" }, { configDir });
+ const result = resolveAndPersistCodexRuntime({
+ configDir,
+ env: { PATH: "" },
+ platform: "linux",
+ existsSync: () => true,
+ execFileSync: () => "codex-cli 0.153.0",
+ });
+ expect(result.runtime.source).toBe("configured");
+ expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(live);
+ });
+
+ test("a pin rejected for a NON-path reason is left alone", () => {
+ // "unrecognized --version output" means the file is present but unusable; that is a
+ // different failure than a vanished path and is not this issue's recovery case.
+ const configDir = tempConfigDir();
+ const weird = join(configDir, "weird", "codex");
+ persistCodexRuntime({ command: weird, version: "0.153.0", source: "configured" }, { configDir });
+ resolveAndPersistCodexRuntime({
+ configDir,
+ env: { PATH: "" },
+ platform: "linux",
+ existsSync: () => true,
+ execFileSync: () => "not a codex binary",
+ });
+ expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(weird);
+ });
+});
From 2c9bc967e956c283151d4537fc805d6c5ee3647b Mon Sep 17 00:00:00 2001
From: t
Date: Wed, 9 Sep 2026 04:23:35 +0900
Subject: [PATCH 3/6] fix(service): refuse a dashboard stop that would unload
its own service
Closes #4023
---
.../src/content/docs/guides/web-dashboard.md | 2 +-
.../content/docs/reference/cli/lifecycle.md | 6 ++
.../content/docs/reference/management-api.md | 2 +-
src/server/management-api.ts | 14 ++++
src/service.ts | 22 +++++-
tests/service/stop-deferred-teardown.test.ts | 72 +++++++++++++++++++
6 files changed, 114 insertions(+), 4 deletions(-)
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index 72adcdcd70..7e86901233 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -331,7 +331,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. |
| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, generatedAt, total, logs }` where `total` is the filtered row count before pagination. |
| `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. |
-| `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. Refused with `respawnable_service` on the Windows Task Scheduler backend, and with `service_state_unknown` when that state cannot be read; nothing is changed either way. |
+| `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. Refused with `respawnable_service` on the Windows Task Scheduler backend, with `self_unload_service` when this proxy is itself the installed launchd/systemd job, and with `service_state_unknown` when the Task Scheduler state cannot be read; nothing is changed in any of those cases. |
:::tip
Adding **Ollama Cloud** or another catalog provider from the dashboard copies its text-versus-vision
diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md
index 13a5b17616..fdf24dd5f9 100644
--- a/docs-site/src/content/docs/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/reference/cli/lifecycle.md
@@ -38,6 +38,12 @@ and only a stop running outside the proxy can verify that restart window before
your client config — so the dashboard refuses with `respawnable_service`, changes nothing,
and asks you to run `ocx stop`.
+The dashboard also refuses when the proxy is running *as* the installed launchd or systemd
+service. Stopping that manager from inside the proxy would terminate the process before
+native Codex is restored, leaving your client config pointed at a proxy that is gone, so the
+dashboard returns `self_unload_service`, changes nothing, and asks you to run `ocx stop` —
+which stops the service from outside and completes the restore.
+
### `ocx restart`
When a proxy is running, ask that exact attested PID and port to restart in place, wait for its
diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md
index c0dd38f1fb..b7fdbf072f 100644
--- a/docs-site/src/content/docs/reference/management-api.md
+++ b/docs-site/src/content/docs/reference/management-api.md
@@ -389,7 +389,7 @@ whether to star the repository.
| --- | --- | --- |
| `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — |
| `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection | Returns 202; repeated calls report the existing drain |
-| `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) |
+| `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 `self_unload_service` when this proxy is running as the installed launchd/systemd service, because stopping the manager from inside it would end the process before native Codex is restored — run `ocx stop` instead (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) |
| `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — |
| `POST /api/system/codex-restart` | Refresh the catalog, then ask stale Codex app-servers to exit so the model picker reloads | Returns 200 with `code: partially_stopped` when a target survives |
diff --git a/src/server/management-api.ts b/src/server/management-api.ts
index c703a33e07..118afd5ffd 100644
--- a/src/server/management-api.ts
+++ b/src/server/management-api.ts
@@ -300,6 +300,20 @@ export async function handleManagementAPI(
message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.",
}, 409, req, config);
}
+ if (respawnRisk === "self-unload") {
+ // This proxy IS the launchd/systemd job, so stopping the manager below would
+ // terminate the handler before the shared teardown at the end of this route restores
+ // the native Codex keys — the dashboard Stop button left `openai_base_url`,
+ // `experimental_realtime_ws_base_url` and `model_catalog_json` pointed at a dead
+ // proxy (#4023). Refuse before touching anything, like the Windows branch above.
+ // `ocx stop` is safe because it runs outside this process and owns the teardown
+ // through its receipt, which is why the receipt-backed caller never reaches here.
+ return jsonResponse({
+ success: false,
+ code: "self_unload_service",
+ message: "This proxy is running as the installed service, so stopping the manager from inside it would end this process before native Codex is restored. Run `ocx stop`, which stops the service from outside and completes the restore. Nothing was changed.",
+ }, 409, req, config);
+ }
if (respawnRisk === "unknown") {
// Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop
// failure, so that advice would be a loop. The scheduler query itself is what needs
diff --git a/src/service.ts b/src/service.ts
index fa8770ec55..687bce2c23 100644
--- a/src/service.ts
+++ b/src/service.ts
@@ -3860,10 +3860,28 @@ export async function installFreshWindowsSchedulerSafely(
export function installedServiceRespawnRisk(
probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
platform: NodeJS.Platform = process.platform,
-): "none" | "respawnable" | "unknown" {
+ io: { env?: NodeJS.ProcessEnv; exists?: (path: string) => boolean } = {},
+): "none" | "respawnable" | "unknown" | "self-unload" {
// launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
// wrapper survives its task ending (#764).
- if (platform !== "win32") return "none";
+ //
+ // "Down when they report stopped" answers the RESPAWN question but not the SELF-UNLOAD
+ // one (#4023). When the proxy is itself the managed job, `launchctl unload` /
+ // `systemctl stop` terminate this very process, so the manager stop can kill the request
+ // handler before the shared teardown restores the native Codex config keys — leaving
+ // `openai_base_url`, `experimental_realtime_ws_base_url` and `model_catalog_json`
+ // pointed at a proxy that is gone. Reordering teardown ahead of the manager stop is not
+ // available here: the #3008 contract requires the manager to be proven stopped first.
+ // So refuse, exactly as Windows does, and send the operator to `ocx stop`, which stops
+ // the proxy from the outside and owns the teardown through its receipt.
+ if (platform !== "win32") {
+ const env = io.env ?? process.env;
+ if (env.OCX_SERVICE !== "1") return "none";
+ const exists = io.exists ?? existsSync;
+ if (platform === "darwin") return exists(plistPath()) ? "self-unload" : "none";
+ if (platform === "linux") return exists(unitPath()) ? "self-unload" : "none";
+ return "none";
+ }
try {
// `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
// fail — it does not throw — so testing for "present" let an unanswerable probe
diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts
index 61eadbdc93..9b6cc2a9ad 100644
--- a/tests/service/stop-deferred-teardown.test.ts
+++ b/tests/service/stop-deferred-teardown.test.ts
@@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stopProxyGracefully } from "../../src/lib/process-control";
import { performStopTeardown } from "../../src/server/stop-teardown";
import type { CodexNativeRestoreResult } from "../../src/codex/inject";
import { removeTreeWithRetry } from "../helpers/remove-tree";
+import { repoPath } from "../helpers/repo-root";
/**
* Behavioural cover for the deferred shared teardown (#3008).
@@ -456,3 +458,73 @@ describe("pending teardown receipts", () => {
expect(mod.deferralMatchesReceipt("")).toBe(false);
});
});
+
+describe("self-unloading manager refusal (#4023)", () => {
+ test("a darwin proxy running AS the launchd job reports a self-unload risk", async () => {
+ // `stopServiceIfInstalledDetailed()` calls `launchctl unload` on the plist that owns
+ // THIS process, so the manager stop can terminate the request handler before the
+ // shared teardown two statements later restores native Codex. The Windows guard that
+ // prevents exactly this returned early for every non-Windows platform.
+ const { installedServiceRespawnRisk } = await import("../../src/service");
+ expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", {
+ env: { OCX_SERVICE: "1" },
+ exists: () => true,
+ })).toBe("self-unload");
+ });
+
+ test("linux systemd is exempted identically and gets the same answer", async () => {
+ const { installedServiceRespawnRisk } = await import("../../src/service");
+ expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "linux", {
+ env: { OCX_SERVICE: "1" },
+ exists: () => true,
+ })).toBe("self-unload");
+ });
+
+ test("a manually started proxy is unaffected, even with a service installed", async () => {
+ // OCX_SERVICE is set by the plist/unit only. Without it this process is not the
+ // managed job, so no unload can reach it and the inline stop stays available.
+ const { installedServiceRespawnRisk } = await import("../../src/service");
+ expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", {
+ env: {},
+ exists: () => true,
+ })).toBe("none");
+ });
+
+ test("the managed job with no service definition on disk is not at risk", async () => {
+ const { installedServiceRespawnRisk } = await import("../../src/service");
+ expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", {
+ env: { OCX_SERVICE: "1" },
+ exists: () => false,
+ })).toBe("none");
+ });
+
+ test("Windows classification is untouched by the new branch", async () => {
+ const { installedServiceRespawnRisk } = await import("../../src/service");
+ expect(installedServiceRespawnRisk(() => ({ status: "present" }) as never, "win32", {
+ env: { OCX_SERVICE: "1" },
+ exists: () => true,
+ })).toBe("respawnable");
+ expect(installedServiceRespawnRisk(() => ({ status: "unknown" }) as never, "win32")).toBe("unknown");
+ expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "win32")).toBe("none");
+ });
+
+ test("the route refuses a self-unload before the manager is touched", () => {
+ const source = readFileSync(repoPath("src", "server", "management-api.ts"), "utf8");
+ const from = source.indexOf('"/api/stop"');
+ const handler = source.slice(from, source.indexOf("/api/codex-auth/", from));
+ expect(handler).toContain('code: "self_unload_service"');
+ // Same invariant the Windows guard carries: refuse BEFORE acting, and say so.
+ expect(handler.indexOf('code: "self_unload_service"'))
+ .toBeLessThan(handler.indexOf("stopServiceIfInstalledDetailed()"));
+ const branch = handler.slice(handler.indexOf('code: "self_unload_service"'), handler.indexOf('code: "self_unload_service"') + 600);
+ expect(branch).toContain("Nothing was changed.");
+ expect(branch).toContain("ocx stop");
+ });
+
+ test("a receipt-backed ocx stop keeps its deferral path", () => {
+ // `ocx stop` claims a receipt, defers the teardown, and performs it itself once the
+ // proxy is proven down — so it must not be refused by the new branch.
+ const source = readFileSync(repoPath("src", "server", "management-api.ts"), "utf8");
+ expect(source).toContain('const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();');
+ });
+});
From 4a0895988c106a8bc71fa42acb3540f961831bea Mon Sep 17 00:00:00 2001
From: t
Date: Wed, 9 Sep 2026 04:23:36 +0900
Subject: [PATCH 4/6] fix(responses): admit a sub-agent seed whose call_id is
empty or null
Closes #3807
---
src/responses/task-input.ts | 22 +++++-
.../responses-compaction-routing.test.ts | 69 ++++++++++++++++++-
tests/responses/responses-parser.test.ts | 23 ++++++-
3 files changed, 109 insertions(+), 5 deletions(-)
diff --git a/src/responses/task-input.ts b/src/responses/task-input.ts
index e72973ab90..44c636b6c6 100644
--- a/src/responses/task-input.ts
+++ b/src/responses/task-input.ts
@@ -20,9 +20,29 @@ function supportedBlock(value: unknown): value is TaskInputBlock {
return value.detail === undefined || (typeof value.detail === "string" && imageDetails.has(value.detail));
}
+/**
+ * Does this item carry a pairing key? A tool result is paired by `call_id`; a seed is not.
+ *
+ * Presence of the FIELD is not presence of a KEY (#3807). Codex desktop seeds a sub-agent
+ * thread with a lone `function_call_output` that some client builds emit with an explicit
+ * `call_id: null` or `""` rather than omitting it. Those values can never pair with a
+ * `function_call`, so treating them as a paired result sent the item to the guard in
+ * core.ts and answered 400 for a turn that is really external task input.
+ *
+ * A wrong-typed key (number, object) is NOT relaxed: that is malformed input rather than
+ * the absent-pairing seed shape, and it keeps the #3259 rejection.
+ */
+function hasPairingKey(item: Record): boolean {
+ if (!("call_id" in item)) return false;
+ const callId = item.call_id;
+ if (callId === null) return false;
+ if (typeof callId === "string") return callId.trim().length > 0;
+ return true;
+}
+
/** Recognize Codex external task input without repairing ordinary orphaned tool results. */
export function externalTaskInputContent(item: unknown): string | OcxContentPart[] | undefined {
- if (!isObj(item) || item.type !== "function_call_output" || "call_id" in item) return undefined;
+ if (!isObj(item) || item.type !== "function_call_output" || hasPairingKey(item)) return undefined;
if (!nonBlank(item.id) || !nonBlank(item.name) || !nonBlank(item.namespace)) return undefined;
const output = item.output;
if (typeof output === "string") return nonBlank(output) ? output : undefined;
diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts
index fafbbd6806..f703a1899e 100644
--- a/tests/responses/responses-compaction-routing.test.ts
+++ b/tests/responses/responses-compaction-routing.test.ts
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { handleResponses, handleResponsesCompact } from "../../src/server/responses";
import { OPAQUE_COMPACTION_NOTE, SUMMARY_PREFIX } from "../../src/responses/compaction";
+import { externalTaskInputContent } from "../../src/responses/task-input";
import { looksLikeBackendCiphertext } from "../../src/server/responses/encrypted-payload";
import * as adapterResolveModule from "../../src/server/adapter-resolve";
import * as visionModule from "../../src/vision";
@@ -2391,9 +2392,27 @@ describe("external task-input envelopes (#3735)", () => {
expect(captured[0]!.messages).toEqual([{ role: "user", content: "plaintext task" }]);
});
+ test("an empty or null call_id is task input, not a rejection (#3807 supersedes)", async () => {
+ // These two shapes were in the invalid list above until #3807 showed they are the same
+ // seed as the absent-field form: neither value can pair with a `function_call`, and a
+ // Codex desktop sub-agent seed emitted with an explicit `call_id: null` was answered
+ // 400 for a turn that is really external task input. A wrong-TYPED key stays rejected.
+ const captured: Array> = [];
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
+ captured.push(JSON.parse(String(init?.body)));
+ return jsonResponse({ id: "chat_seed", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } });
+ }) as typeof fetch;
+ for (const callId of [null, ""]) {
+ captured.length = 0;
+ const res = await handleResponses(compactionRequest(body({ ...external("seeded task"), call_id: callId })),
+ keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" });
+ expect(res.status).toBe(200);
+ await res.text();
+ expect(captured[0]!.messages).toEqual([{ role: "user", content: "seeded task" }]);
+ }
+ });
+
const invalid: Array<[string, Record]> = [
- ["empty call id", { ...external(), call_id: "" }],
- ["null call id", { ...external(), call_id: null }],
["numeric call id", { ...external(), call_id: 42 }],
["incomplete metadata", { ...external(), namespace: "" }],
["custom output", { ...external(), type: "custom_tool_call_output" }],
@@ -2668,3 +2687,49 @@ describe("unpaired tool result boundary (#3259)", () => {
expect(bodies[0]).not.toContain("undefined");
});
});
+
+describe("unusable-call_id task-input seed (#3807)", () => {
+ const seed = (extra: Record) => ({
+ type: "function_call_output", id: "fc_seed", name: "create_thread", namespace: "codex",
+ output: "continue", ...extra,
+ });
+
+ test("a seed carrying call_id: null is admitted as task input", () => {
+ // `null` is not a pairing key, so the item is the same external seed the absent-field
+ // form already carries. Rejecting it produced the reported 400 on clients that emit
+ // the field explicitly.
+ expect(externalTaskInputContent(seed({ call_id: null }))).toBe("continue");
+ });
+
+ test("a seed carrying an empty-string call_id is admitted identically", () => {
+ expect(externalTaskInputContent(seed({ call_id: "" }))).toBe("continue");
+ expect(externalTaskInputContent(seed({ call_id: " " }))).toBe("continue");
+ });
+
+ test("the absent-field form still works (no regression on a73bb160f)", () => {
+ expect(externalTaskInputContent(seed({}))).toBe("continue");
+ });
+
+ test("a REAL call_id is still a paired tool result, never task input", () => {
+ // The pairing key is what separates a tool result from a seed. Admitting a paired
+ // result as user text would silently drop a real tool round-trip.
+ expect(externalTaskInputContent(seed({ call_id: "call_1" }))).toBeUndefined();
+ });
+
+ test("a non-string, non-null call_id stays rejected", () => {
+ // A numeric id is malformed input, not the absent-pairing seed shape; it keeps the
+ // #3259 rejection so a wrong-typed key cannot reach a translating adapter.
+ expect(externalTaskInputContent(seed({ call_id: 42 }))).toBeUndefined();
+ expect(externalTaskInputContent(seed({ call_id: {} }))).toBeUndefined();
+ });
+
+ test("every other #3735 validation still holds with an unusable call_id", () => {
+ // The relaxation is ONLY about the pairing key. Envelope completeness, blank output,
+ // and opaque ciphertext keep their existing rejections.
+ expect(externalTaskInputContent({ type: "function_call_output", call_id: null, output: "x" })).toBeUndefined();
+ expect(externalTaskInputContent(seed({ call_id: null, namespace: "" }))).toBeUndefined();
+ expect(externalTaskInputContent(seed({ call_id: null, output: " " }))).toBeUndefined();
+ expect(externalTaskInputContent(seed({ call_id: null, output: [] }))).toBeUndefined();
+ expect(externalTaskInputContent(seed({ call_id: null, output: [{ type: "input_image", image_url: 42 }] }))).toBeUndefined();
+ });
+});
diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts
index 0debca2d0a..12ad3d9082 100644
--- a/tests/responses/responses-parser.test.ts
+++ b/tests/responses/responses-parser.test.ts
@@ -1020,8 +1020,6 @@ describe("external task-input envelopes (#3735)", () => {
{ name: "blank name", item: { type: "function_call_output", id: "i", name: "", namespace: "ns", output: "ok" } },
{ name: "missing namespace", item: { type: "function_call_output", id: "i", name: "n", output: "ok" } },
{ name: "blank namespace", item: { type: "function_call_output", id: "i", name: "n", namespace: "\t", output: "ok" } },
- { name: "empty call_id", item: { type: "function_call_output", call_id: "", id: "i", name: "n", namespace: "ns", output: "ok" } },
- { name: "null call_id", item: { type: "function_call_output", call_id: null, id: "i", name: "n", namespace: "ns", output: "ok" } },
{ name: "number call_id", item: { type: "function_call_output", call_id: 1, id: "i", name: "n", namespace: "ns", output: "ok" } },
{ name: "custom_tool_call_output", item: { type: "custom_tool_call_output", id: "i", name: "n", namespace: "ns", output: "ok" } },
{
@@ -1096,6 +1094,27 @@ describe("external task-input envelopes (#3735)", () => {
expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(true);
});
+ test.each([
+ { name: "empty call_id", callId: "" },
+ { name: "null call_id", callId: null },
+ ])("$name is a seed on the user path, not a tool result (#3807 supersedes)", ({ callId }) => {
+ // These rows asserted a toolResult until #3807: neither value can pair with a
+ // `function_call`, so a client that emits the field explicitly was carrying the same
+ // seed as the absent-field form and had it answered 400 downstream. A wrong-TYPED
+ // key ("number call_id" above) is malformed input and keeps its rejection.
+ //
+ // A whitespace-only `call_id` is deliberately absent from this table: it satisfies the
+ // schema's `z.string().min(1)`, so functionCallOutputItemSchema claims the item and
+ // strips id/name/namespace before the parser runs. The helper admits it (covered in
+ // responses-compaction-routing), but the envelope never survives to reach it here.
+ const parsed = parseFrozen([{
+ type: "function_call_output", call_id: callId,
+ id: "i", name: "n", namespace: "ns", output: "ok",
+ }]);
+ expect(parsed.context.messages).toMatchObject([{ role: "user", content: "ok" }]);
+ expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(false);
+ });
+
test("own and inherited call_id properties are helper-ineligible", () => {
const base = {
type: "function_call_output",
From e8995c25ce62789093f69b6f5df27c757cf993fe Mon Sep 17 00:00:00 2001
From: t
Date: Wed, 9 Sep 2026 04:32:21 +0900
Subject: [PATCH 5/6] fix(service): identify the managed job by a wrapper-only
marker
The dashboard-stop refusal added for #4023 keyed on OCX_SERVICE=1, but that
variable does not identify the managed job. ocx claude and ocx opencode both
set it on the detached proxy they spawn, to borrow its routing-preservation
meaning, so a user with the service installed but stopped would have had their
dashboard Stop refused for a proxy no manager supervises.
The launchd plist and the systemd unit now also write OCX_SERVICE_MANAGED=1,
and the refusal discriminates on that. OCX_SERVICE keeps its existing meaning
everywhere it is already read. The added case fails against the old
discriminator and passes with this one.
---
.../000_plan.md | 113 ++++++++++++++++++
.../001_audit_record.md | 53 ++++++++
.../010_phase1_stack_build.md | 54 +++++++++
.../020_phase2_publish.md | 92 ++++++++++++++
.../030_phase3_merge_and_settle.md | 106 ++++++++++++++++
src/service.ts | 18 ++-
tests/service/stop-deferred-teardown.test.ts | 30 ++++-
7 files changed, 459 insertions(+), 7 deletions(-)
create mode 100644 devlog/_plan/260908_d_group_test_infra_stack/000_plan.md
create mode 100644 devlog/_plan/260908_d_group_test_infra_stack/001_audit_record.md
create mode 100644 devlog/_plan/260908_d_group_test_infra_stack/010_phase1_stack_build.md
create mode 100644 devlog/_plan/260908_d_group_test_infra_stack/020_phase2_publish.md
create mode 100644 devlog/_plan/260908_d_group_test_infra_stack/030_phase3_merge_and_settle.md
diff --git a/devlog/_plan/260908_d_group_test_infra_stack/000_plan.md b/devlog/_plan/260908_d_group_test_infra_stack/000_plan.md
new file mode 100644
index 0000000000..35b2a65c67
--- /dev/null
+++ b/devlog/_plan/260908_d_group_test_infra_stack/000_plan.md
@@ -0,0 +1,113 @@
+# D-group test-infrastructure delivery as a single-CI manual stack
+
+## Objective
+
+Land the two D-group test-infrastructure items on `dev` as one dependency-ordered
+branch chain whose **tip is the only pull request**, so the cumulative tree is
+verified by exactly one Cross-platform CI run. Merge the tip once that run is
+green, then settle the original pull requests and any linked issues.
+
+| Layer | Source | Content |
+|---|---|---|
+| 1 (bottom) | PR #3924 by @luvs01 | `scripts/test.ts` keeps captured lane output after a timeout; runner regressions; contributing note |
+| 2 (tip) | PR #3930 by @luvs01 | `tests/providers/cursor/cursor-stream-health.test.ts` load-scaled watchdog budgets |
+
+Both source pull requests carry exactly one commit each, authored by `luvs01`
+(`27862058+luvs01@users.noreply.github.com`), so `git cherry-pick -x` preserves
+authorship without needing a reconstructed `Co-authored-by` trailer. The trailer is
+added to the tip pull-request description anyway, because the repository squashes
+and `.github/scripts/pr-carry-attribution.cjs` reads the trailer, not prose.
+
+## Why a stack, and why only one pull request
+
+`.github/workflows/ci.yml` triggers on a bare `pull_request:` with no base-branch
+filter. That is deliberate — the comment in the file records that a
+`branches: [main, dev]` filter once silently excluded stacked child pull requests.
+The consequence for this unit is mechanical: **every open pull request starts a
+Cross-platform CI run**, whatever its base. A two-pull-request stack therefore costs
+two runs, and a child pull request based on the parent's head costs one more.
+
+The only way to get a single run covering both changes is to give the stack exactly
+one pull request, at the tip, based on `dev`. The lower layer is pushed as a branch
+for provenance and review navigation, and never gets a pull request of its own.
+Pushing a branch does not start CI either: `ci.yml`'s `push:` trigger is pinned to
+`branches: [main, preview, dev]`, and this stack pushes neither.
+
+The tip run covers the PR-enabled producers, not every job in the file. `changes`
+sets `ci: true` for `tests/**` and `scripts/**` (`ci.yml:193-194`), which this stack
+touches, so the four Linux shards, `gates`, `storage policy`, `api usage`,
+`platform-macos`, `keyring` and `docker smoke` all execute. Three job families do
+**not** run on a pull request and must never be reported as passing evidence:
+
+| Job | Guard | Status on this PR |
+|---|---|---|
+| `windows /6` | `github.event_name == 'workflow_dispatch' && (inputs.lane == '' \|\| inputs.lane == 'all')` (`ci.yml:742-743`) | SKIPPED BY WORKFLOW |
+| `macos control` | `github.event_name == 'workflow_dispatch'` (`ci.yml:633`) | SKIPPED BY WORKFLOW |
+| `npm-global ` | `needs.changes.outputs.packaging == 'true'` (`ci.yml:943`); the packaging allowlist (`ci.yml:215-229`) excludes all four files | SKIPPED BY WORKFLOW |
+
+That exclusion is acceptable for this unit: nothing here ships in the package tree,
+and `scripts/test.ts` is the test runner rather than runtime source. The Windows
+lane is dispatch-only for every ordinary pull request in this repository, so
+requiring it here would be a new policy, not this unit's job.
+
+## Dependency order
+
+Layer 1 is the runner change; layer 2 is a fixture that the runner executes. Ordering
+them the other way would put a test-timing change under an unverified runner. The
+order is a build-order statement, not an effort estimate.
+
+## Work phases
+
+| Phase | Outcome |
+|---|---|
+| wp0 | This roadmap: stack shape, single-trigger proof, merge/close order, attribution |
+| wp1 | Build both layers locally on fresh `origin/dev` with `cherry-pick -x` |
+| wp2 | Push both branches with `--no-verify`; open exactly one pull request (tip → `dev`) |
+| wp3 | Record tip CI, merge the tip, settle #3924/#3930 and linked issues |
+
+Diff-level detail for each phase: `010_phase1_stack_build.md`,
+`020_phase2_publish.md`, `030_phase3_merge_and_settle.md`.
+
+## Constraints in force
+
+The owner set these for this unit, and they override the repository's default
+verification habits:
+
+- **No local suite.** No `bun run test`, `bun test`, `bun run test:changed`,
+ `bun run typecheck`, or build used as a gate. Every such row is recorded
+ `NOT RUN (owner instruction)`, never as a pass.
+- **Push with `--no-verify`.** Local hooks are skipped by instruction.
+- **CI on the tip only.** Never open a pull request for a lower layer.
+- **One green run, then merge.** The tip's exact head SHA is the product gate.
+- **Preserve original authorship** for carried work.
+- **Close linked issues** at the moment the change is on `dev`.
+
+## Verification model
+
+The product evidence is the hosted Cross-platform CI run on the tip's exact head
+SHA — run id, head SHA, per-job conclusions — read as a job matrix, not as the
+aggregate `ci` summary alone. The three dispatch-only or packaging-gated job
+families above are recorded SKIPPED BY WORKFLOW.
+
+Merge additionally requires the current gate checks to be green on that same head:
+`enforce-target` and `hygiene` (`enforce-pr-target.yml:679-692` folds deterministic
+hygiene failures into its verdict; `pr-hygiene.yml:236-238` fails and labels on a
+violation), plus resolution of any actionable automated review finding.
+
+Landing evidence is the squash SHA GitHub returns, proven to be an ancestor of
+fetched `origin/dev`, with its tree compared against the reviewed tip. Local checks
+are `NOT RUN` by instruction and are never reported as passing.
+
+A verifier honesty note, since this unit's plan names commands it will not run:
+`bun run test` would observe `scripts/test.ts` and both test files, and
+`bun run typecheck` would observe `scripts/test.ts`. Both are in scope for the
+change and both are withheld by owner instruction, so their acceptance rows are
+delegated to hosted CI rather than claimed locally.
+
+## Terminal outcomes
+
+- **DONE** — tip CI green on its exact head, tip merged into `dev`, #3924 and #3930
+ settled with authorship preserved, linked issues closed, evidence recorded.
+- **BLOCKED** — a required merge right is missing, or CI fails for a cause outside
+ these four files.
+- **NEEDS_HUMAN** — a policy decision beyond restoring existing behavior.
diff --git a/devlog/_plan/260908_d_group_test_infra_stack/001_audit_record.md b/devlog/_plan/260908_d_group_test_infra_stack/001_audit_record.md
new file mode 100644
index 0000000000..b668981357
--- /dev/null
+++ b/devlog/_plan/260908_d_group_test_infra_stack/001_audit_record.md
@@ -0,0 +1,53 @@
+# Audit record — roadmap gate
+
+An independent reviewer (a separate context, `gpt-6-astra` at high effort) audited
+the roadmap before any branch was built. Three rounds ran; the first two failed.
+The findings are recorded here because they changed the plan, and because two of
+them would have produced a false completion claim.
+
+## Round 1 — FAIL, four blocking defects
+
+1. **Overstated CI coverage.** The plan promised platform and packaging coverage
+ from the tip pull-request run. In fact `windows /6` and `macos control` are
+ `workflow_dispatch`-only (`ci.yml:633`, `742-743`), and `npm-global` needs
+ `packaging == 'true'`, which the packaging allowlist (`ci.yml:215-229`) does not
+ set for any of the four files. Fixed by adding an explicit RUN vs
+ SKIPPED BY WORKFLOW matrix and forbidding the skipped families from being
+ reported as passes.
+2. **CI success treated as sufficient for merge.** `enforce-target` folds
+ deterministic hygiene failures into its verdict (`enforce-pr-target.yml:679-692`)
+ and `pr-hygiene` fails and labels on a violation (`pr-hygiene.yml:236-238`).
+ `MAINTAINERS.md:61` also requires the integration decision and exact-head
+ verification to be recorded. Fixed by adding those gates and the record step.
+3. **Wrong ancestry object.** The plan checked whether the tip commit was an
+ ancestor of `dev`. A squash merge never makes the tip an ancestor, so that check
+ would have failed on a perfectly good landing — or worse, been waved through.
+ Fixed by recording the squash SHA GitHub returns and testing that.
+4. **Attribution assumed rather than controlled.** The repository sets
+ `squash_merge_commit_message: COMMIT_MESSAGES`, so the pull-request description
+ is not the landed message. A description trailer satisfies the hygiene checker
+ and still leaves the contributor uncredited in the commit. Fixed by supplying the
+ squash body explicitly and verifying the landed trailer before closing anything.
+
+## Round 2 — FAIL, two blocking defects
+
+1. **Missing administrator bypass.** `Protect dev` requires an approving review and
+ code-owner review, so the merge call is refused without `--admin`. The plan named
+ the policy exception without naming the mechanism that exercises it.
+2. **Bot findings mistaken for all findings.** The gate covered automated review
+ findings but not human ones. `MAINTAINERS.md:62-64` requires outstanding
+ maintainer change requests to be resolved or explicitly withdrawn.
+
+## Round 3 — PASS
+
+The reviewer set the phase-1 acceptance bar: freshly fetched base SHA, both
+constructed commit SHAs, evidence that layer 1 follows the base and layer 2 follows
+layer 1, both authors reading `luvs01`, both `-x` provenance lines, per-layer and
+cumulative name/numstat comparisons, blob comparisons against the source pull
+requests, and the roadmap commit accounted for separately so it stays out of the
+four-file implementation delta.
+
+## Standing note
+
+Local suite, typecheck and build are **NOT RUN** for this unit by owner
+instruction. That is a recorded absence of evidence, not a pass.
diff --git a/devlog/_plan/260908_d_group_test_infra_stack/010_phase1_stack_build.md b/devlog/_plan/260908_d_group_test_infra_stack/010_phase1_stack_build.md
new file mode 100644
index 0000000000..003cd0ad82
--- /dev/null
+++ b/devlog/_plan/260908_d_group_test_infra_stack/010_phase1_stack_build.md
@@ -0,0 +1,54 @@
+# Phase 1 — Build the stack locally
+
+Base: fetched `origin/dev`. Both source commits live in the `luvs01` remote
+(`https://github.com/luvs01/opencodex.git`), already configured in this checkout.
+
+## Commands
+
+```sh
+git fetch origin dev
+git fetch luvs01 e24163231edeaa09a30a99ca1746e3b573af78ae 141077f7270e2f2a0564fb036d091f0cf793b784
+
+# Layer 1 — PR #3924
+git switch -c codex/260908-d-group-l1-test-runner-output origin/dev
+git cherry-pick -x e24163231edeaa09a30a99ca1746e3b573af78ae
+
+# Layer 2 — PR #3930, tip
+git switch -c codex/260908-d-group-l2-cursor-watchdog
+git cherry-pick -x 141077f7270e2f2a0564fb036d091f0cf793b784
+```
+
+`cherry-pick -x` keeps the original author identity
+(`luvs01 <27862058+luvs01@users.noreply.github.com`>) and appends the
+`(cherry picked from commit ...)` provenance line. No `Co-authored-by` trailer is
+needed on the commits themselves because authorship is not being reassigned. The
+trailer goes in the tip pull-request description for hygiene acceptance; phase 3
+separately supplies and verifies the trailer on the landed squash commit, which is
+the only thing GitHub reads for contributor credit.
+
+## Expected change map
+
+| Layer | File | Change |
+|---|---|---|
+| 1 | `scripts/test.ts` | +81 −8 — incremental capture, retained output on timeout, bounded drain, incomplete-capture exit policy |
+| 1 | `tests/ci-workflows/test-runner.test.ts` | +147 −1 — regressions for timeout/failure/success output, split UTF-8, open pipes, read failure |
+| 1 | `docs-site/src/content/docs/contributing.md` | +6 — documents the timeout and incomplete-capture behavior |
+| 2 | `tests/providers/cursor/cursor-stream-health.test.ts` | +59 −26 — one scaled silence budget S, 2S heartbeat-only, ≥3S observed progress after first received text |
+
+Cumulative tip versus `origin/dev`: exactly those four files.
+
+## Conflict expectation
+
+None. The two file sets are disjoint, and the Cursor test file plus its
+`tests/helpers/ci-watchdog.ts` import carry identical blob SHAs at `dev` and at
+#3924's head (`dc7b572bf1` and `f8adcfe3d9`), so layer 2's preimage is unchanged by
+layer 1.
+
+## Acceptance
+
+- `git log --format='%an <%ae>'` on both new commits reports `luvs01`.
+- `git diff --name-only origin/dev..tip` lists exactly the four files above.
+- `git diff --stat` matches the per-file counts in the table.
+- Each cherry-picked tree is byte-identical to the source PR head's version of its files.
+
+Local suite: NOT RUN (owner instruction).
diff --git a/devlog/_plan/260908_d_group_test_infra_stack/020_phase2_publish.md b/devlog/_plan/260908_d_group_test_infra_stack/020_phase2_publish.md
new file mode 100644
index 0000000000..2af6acab12
--- /dev/null
+++ b/devlog/_plan/260908_d_group_test_infra_stack/020_phase2_publish.md
@@ -0,0 +1,92 @@
+# Phase 2 — Publish the stack, one pull request only
+
+## Push
+
+```sh
+git push --no-verify origin codex/260908-d-group-l1-test-runner-output
+git push --no-verify origin codex/260908-d-group-l2-cursor-watchdog
+```
+
+`--no-verify` is the owner's instruction for this unit. Neither push starts
+Cross-platform CI: `ci.yml`'s `push:` trigger is limited to
+`branches: [main, preview, dev]` (`ci.yml:26-27`).
+
+## Open exactly one pull request
+
+Tip only, targeting `dev`:
+
+```sh
+gh pr create --repo lidge-jun/opencodex --base dev \
+ --head codex/260908-d-group-l2-cursor-watchdog \
+ --title "fix(test): preserve lane output after timeouts and stabilize the Cursor stream-health watchdog" \
+ --body-file
+```
+
+The lower layer gets **no** pull request. `ci.yml` triggers on a bare
+`pull_request:` with no base filter (`ci.yml:7`), so a second pull request would
+start a second Cross-platform CI run; draft status does not suppress it either —
+no job in `ci.yml` reads a draft condition.
+
+A stacked child pull request based on the layer-1 branch is also unavailable here:
+`enforce-target` grants the wrong-base exemption only when the parent branch has
+its own **open** pull request (`enforce-pr-target.yml:536-537`), which is exactly
+what this design avoids. The tip therefore targets `dev` directly.
+
+## Description requirements
+
+`.github/PULL_REQUEST_TEMPLATE.md` requires Summary, Verification, and Checklist;
+`enforce-target` rejects thin or malformed descriptions. The description must also:
+
+- name both source pull requests (#3924, #3930) and describe the stack layering;
+- carry `Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>`. The
+ hygiene checker reads that trailer from the description or a commit message
+ (`pr-carry-attribution.cjs:190`), and the carry verbs in the description are what
+ make it demand one at all. The description trailer satisfies the gate; it does
+ **not** by itself put the trailer in the landed commit — see phase 3, where the
+ squash body carries it explicitly;
+- state honestly that local suite, typecheck and build were **NOT RUN** by owner
+ instruction, and that hosted CI on this exact head is the verification gate,
+ naming which job families are skipped by the workflow;
+- be substantive: `pr-quality.cjs` strips template boilerplate and requires real
+ content (two substantial sections, or 120+ characters across two blocks), so
+ placeholder bullets fail the gate.
+
+As a maintainer-authored pull request this needs no readiness checklist and no
+`review-ready` label (`enforce-pr-target.yml:766-768`, `1096-1103`). Do not tick a
+local-CI attestation box that was not earned — the owner forbade the local suite.
+
+No GUI files change, so the screenshot rule does not apply.
+
+## Other workflows that will fire
+
+Expected and unavoidable for any pull request: `enforce-target`, `pr-hygiene`,
+`pr-labeler`, `react-doctor`, plus CodeRabbit. `service-lifecycle` does **not**
+fire — none of the four paths is in its allowlist. These are gate/lint signals, not
+the product suite; only Cross-platform CI is the product gate.
+
+## Outcome
+
+Executed 2026-09-08 against base `942c02873`.
+
+| Ref | SHA | Pull request |
+|---|---|---|
+| `codex/260908-d-group-l1-test-runner-output` | `ab06523e6` | none, by design |
+| `codex/260908-d-group-l2-cursor-watchdog` (tip) | `8b81676ac` | [#3940](https://github.com/lidge-jun/opencodex/pull/3940), base `dev` |
+
+Both pushes used `--no-verify`. Neither started Cross-platform CI, as predicted by
+the `push` branch filter. Opening #3940 started exactly one run on `8b81676ac`; the
+first check-runs to appear were `changes`, `select windows runner`, `hygiene`,
+`label`, `resolve-pr` and `react-doctor`, which matches the expected set.
+
+The layer-1 branch has zero pull requests in any state, which is the property that
+keeps the stack to a single CI run.
+
+## Acceptance
+
+- Both branches exist on `origin` at the expected SHAs.
+- `gh pr list --head codex/260908-d-group-l1-test-runner-output` returns empty.
+- Exactly one open pull request has head `codex/260908-d-group-l2-cursor-watchdog`
+ and base `dev`.
+- Exactly one Cross-platform CI run exists for the tip head SHA. "Exactly one" is
+ scoped to the pre-merge candidate: landing on `dev` starts a separate push run,
+ and a base refresh replaces the candidate with a new head and a new run.
diff --git a/devlog/_plan/260908_d_group_test_infra_stack/030_phase3_merge_and_settle.md b/devlog/_plan/260908_d_group_test_infra_stack/030_phase3_merge_and_settle.md
new file mode 100644
index 0000000000..5e6d3ece96
--- /dev/null
+++ b/devlog/_plan/260908_d_group_test_infra_stack/030_phase3_merge_and_settle.md
@@ -0,0 +1,106 @@
+# Phase 3 — Merge the tip, settle the stack
+
+## Gate
+
+The product gate is the Cross-platform CI run on the tip's **exact** head SHA.
+Record run id, head SHA, and each producer's conclusion. A cancelled or superseded
+run is not evidence, and a run on an earlier head is not evidence for the merged
+head. Read the producers, not only the aggregate `ci` check.
+
+Expected to RUN (`ci: true` via `tests/**` and `scripts/**`): four Linux `test`
+shards, `gates`, `storage policy`, `api usage`, `macos /2`, `keyring` (three OS),
+`docker smoke`.
+
+Expected to be SKIPPED BY WORKFLOW, and recorded as such rather than as passes:
+`windows /6` and `macos control` (both `workflow_dispatch`-only), and
+`npm-global ` (needs `packaging == 'true'`, which these four files do not set).
+
+Merge also requires, on the same head:
+
+- `enforce-target` success and `hygiene` success;
+- every actionable automated review finding resolved;
+- **outstanding maintainer change requests resolved or explicitly withdrawn**
+ (`MAINTAINERS.md:62-64`) — read the human reviews immediately before merging, not
+ only the bot findings;
+- a refreshed read of head, base, merge state, and the integrating actor's
+ repository permission immediately before merging.
+
+## Maintainer integration record
+
+`MAINTAINERS.md:59-63` permits a maintainer with `maintain` or `admin` to integrate
+into `dev` without a second approval, and requires the decision and the exact-head
+verification to be recorded in the pull request. Post that record as a comment
+before merging: the integrating maintainer, the exact head SHA, the CI run link,
+the job matrix including the skipped families, and the statement that local suite,
+typecheck and build were NOT RUN by owner instruction.
+
+The `Protect dev` ruleset still requires an approving review and code-owner review,
+so the merge call itself will be refused without an explicit administrator bypass.
+That bypass is the mechanism this policy exception is exercised through, and it is
+conditional: verified maintainer identity with `admin`, base `dev`, every planned
+check green on the exact head, and the integration record posted. It is never a way
+past failing CI or an unresolved objection.
+
+## Merge order
+
+1. Refresh the tip against `dev` if `dev` moved; a moved base means the green run
+ no longer describes the merge result, so re-run the gate on the new head.
+2. Merge the tip pull request into `dev` with an explicit squash body. The repository
+ sets `squash_merge_commit_message: COMMIT_MESSAGES`, so the landed message is not
+ the pull-request description: supply it directly and make it carry the trailer.
+
+ ```sh
+ gh pr merge --repo lidge-jun/opencodex --squash --admin \
+ --match-head-commit --body-file
+ ```
+
+ `--body-file` supplies the **merge commit body**, which under `--squash` is the
+ squash commit body, replacing the repository's `COMMIT_MESSAGES` default
+ (verified against the installed `gh` 2.91.0 help). `--subject` is optional and
+ only controls the title. `--match-head-commit` refuses the merge if the head
+ moved after the gate was read.
+
+ The squash body must contain, on its own line:
+
+ ```text
+ Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
+ ```
+
+3. Read the landed squash SHA from GitHub, fetch `origin/dev`, and prove:
+ `git merge-base --is-ancestor origin/dev` exits 0, the landed commit
+ message contains the trailer, and the four files on `dev` match the reviewed tip.
+
+Merging is an external state change and stays user-authorized.
+
+## Settling the source pull requests
+
+Both #3924 and #3930 were carried by `cherry-pick -x`, so GitHub will not mark them
+merged automatically. After the tip lands:
+
+- Verify the landed commit's trailer **before** closing either source pull request.
+ A closing comment is prose; only the trailer is contributor-graph data
+ (`CREDITS.md` exists because that distinction was missed 27 times).
+- Then close #3924 and #3930 with a comment naming the landed squash SHA, the tip
+ pull request, and the preserved authorship.
+- Do not delete the contributor branches on the fork; they are not ours.
+
+## Linked issues
+
+Neither #3924 nor #3930 declares a closing issue reference
+(`closingIssuesReferences` is empty for both). If none is discovered during the
+cycle, the "close linked issues" obligation is satisfied vacuously and recorded as
+such. Any issue found to be resolved by this landing is closed at the moment the
+change is on `dev`, with a comment naming the commit.
+
+## Acceptance
+
+- Tip CI: run id + head SHA + per-job conclusions on the merged head, with the three
+ skipped job families named as skipped.
+- `enforce-target` and `hygiene` green on that head; maintainer-integration record
+ posted on the pull request.
+- `git merge-base --is-ancestor origin/dev` exits 0 after fetch, the
+ landed commit carries the `luvs01` trailer, and the four files on `dev` match the
+ reviewed tip.
+- #3924 and #3930 closed after that verification; no lower-layer pull request was
+ ever opened.
+- Linked-issue status stated explicitly (closed, or none exists).
diff --git a/src/service.ts b/src/service.ts
index 687bce2c23..1fa97c424d 100644
--- a/src/service.ts
+++ b/src/service.ts
@@ -16,6 +16,13 @@ import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
import { stripGrokConfig } from "./grok/inject";
import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
+
+/**
+ * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in
+ * for it: `ocx claude` and `ocx opencode` set that on the proxies they spawn to borrow its
+ * routing-preservation meaning, so a proxy carrying it is not necessarily the managed job.
+ */
+export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime";
import { isProcessAlive, stopProxy } from "./lib/process-control";
import { serviceApiTokenFilePath } from "./lib/service-secrets";
@@ -508,6 +515,11 @@ export function buildPlist(
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
const envLines = [
` OCX_SERVICE1`,
+ // OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode`
+ // also set it on the proxies they spawn, to borrow its routing-preservation meaning
+ // (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so
+ // the dashboard-stop refusal below can tell a real launchd job from an ordinary child.
+ ` ${SERVICE_MANAGED_ENV}1`,
...(launcher ? [] : [
` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`,
` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`,
@@ -3328,6 +3340,7 @@ export function buildUnit(
const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
const envLines = [
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
+ systemdEnvironmentAssignment(SERVICE_MANAGED_ENV, "1"),
...(launcher ? [] : [
systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
@@ -3876,7 +3889,10 @@ export function installedServiceRespawnRisk(
// the proxy from the outside and owns the teardown through its receipt.
if (platform !== "win32") {
const env = io.env ?? process.env;
- if (env.OCX_SERVICE !== "1") return "none";
+ // Discriminate on the wrapper-only marker, not on OCX_SERVICE: `ocx claude` and
+ // `ocx opencode` set OCX_SERVICE=1 on the proxies they spawn (for preserveRouting),
+ // and refusing their dashboard stop would break a proxy that no manager supervises.
+ if (env[SERVICE_MANAGED_ENV] !== "1") return "none";
const exists = io.exists ?? existsSync;
if (platform === "darwin") return exists(plistPath()) ? "self-unload" : "none";
if (platform === "linux") return exists(unitPath()) ? "self-unload" : "none";
diff --git a/tests/service/stop-deferred-teardown.test.ts b/tests/service/stop-deferred-teardown.test.ts
index 9b6cc2a9ad..9e98375b63 100644
--- a/tests/service/stop-deferred-teardown.test.ts
+++ b/tests/service/stop-deferred-teardown.test.ts
@@ -467,7 +467,7 @@ describe("self-unloading manager refusal (#4023)", () => {
// prevents exactly this returned early for every non-Windows platform.
const { installedServiceRespawnRisk } = await import("../../src/service");
expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", {
- env: { OCX_SERVICE: "1" },
+ env: { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" },
exists: () => true,
})).toBe("self-unload");
});
@@ -475,14 +475,14 @@ describe("self-unloading manager refusal (#4023)", () => {
test("linux systemd is exempted identically and gets the same answer", async () => {
const { installedServiceRespawnRisk } = await import("../../src/service");
expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "linux", {
- env: { OCX_SERVICE: "1" },
+ env: { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" },
exists: () => true,
})).toBe("self-unload");
});
test("a manually started proxy is unaffected, even with a service installed", async () => {
- // OCX_SERVICE is set by the plist/unit only. Without it this process is not the
- // managed job, so no unload can reach it and the inline stop stays available.
+ // Only the plist and unit write OCX_SERVICE_MANAGED. Without it this process is not
+ // the managed job, so no unload can reach it and the inline stop stays available.
const { installedServiceRespawnRisk } = await import("../../src/service");
expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", {
env: {},
@@ -493,7 +493,7 @@ describe("self-unloading manager refusal (#4023)", () => {
test("the managed job with no service definition on disk is not at risk", async () => {
const { installedServiceRespawnRisk } = await import("../../src/service");
expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", {
- env: { OCX_SERVICE: "1" },
+ env: { OCX_SERVICE: "1", OCX_SERVICE_MANAGED: "1" },
exists: () => false,
})).toBe("none");
});
@@ -508,7 +508,25 @@ describe("self-unloading manager refusal (#4023)", () => {
expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "win32")).toBe("none");
});
- test("the route refuses a self-unload before the manager is touched", () => {
+
+ test("a proxy spawned by an ensure path is not the managed job", async () => {
+ // Both `ocx claude` and `ocx opencode` set OCX_SERVICE=1 on their detached child to
+ // borrow its routing-preservation meaning (src/cli/claude.ts, src/cli/opencode.ts),
+ // so that variable cannot identify the managed job. A user with the service installed
+ // but stopped, running one of those commands, must keep a working dashboard Stop.
+ const { installedServiceRespawnRisk } = await import("../../src/service");
+ expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "darwin", {
+ env: { OCX_SERVICE: "1" },
+ exists: () => true,
+ })).toBe("none");
+ expect(installedServiceRespawnRisk(() => ({ status: "absent" }) as never, "linux", {
+ env: { OCX_SERVICE: "1" },
+ exists: () => true,
+ })).toBe("none");
+ });
+
+
+test("the route refuses a self-unload before the manager is touched", () => {
const source = readFileSync(repoPath("src", "server", "management-api.ts"), "utf8");
const from = source.indexOf('"/api/stop"');
const handler = source.slice(from, source.indexOf("/api/codex-auth/", from));
From ba008a927f90a1effe9ee433f32b76c61a749bca Mon Sep 17 00:00:00 2001
From: t
Date: Wed, 9 Sep 2026 04:57:44 +0900
Subject: [PATCH 6/6] fix(codex): retire a runtime pin only on an exact path
match
Two review findings on the #4035 retirement branch.
sameRuntimeCommand() lowercases, so on a case-sensitive filesystem it reports
/plugins/Codex and /plugins/codex as the same command. A CODEX_CLI_PATH naming
the missing lowercase path would then retire a live uppercase pin. The
retirement check now compares the failed probe to the persisted command
exactly; the helper keeps its existing callers.
clearPersistedCodexRuntime() swallowed every unlink error, so a read-only
config directory or a Windows file lock left the pin authoritative on disk with
no diagnostic and #4035 silently unfixed. ENOENT is still the success case;
anything else now warns with the redacted path and the reason.
The added case fails against the lowercasing comparison and passes with the
exact one.
---
src/codex/runtime.ts | 17 +++++++++++++---
tests/codex-integration/codex-runtime.test.ts | 20 +++++++++++++++++++
2 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts
index 4c1914cbf7..9a12395808 100644
--- a/src/codex/runtime.ts
+++ b/src/codex/runtime.ts
@@ -297,8 +297,16 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}):
clearCodexRuntimeResolveCache();
try {
unlinkSync(codexRuntimeStatePath(configDir));
- } catch {
- // Already gone, or not ours to remove. Either way the pin is not authoritative.
+ } catch (error) {
+ // An already-missing file is the success case: the pin is gone, which is the point.
+ // Anything else means the pin SURVIVES and stays authoritative, so every later
+ // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once.
+ const code = (error as NodeJS.ErrnoException | null)?.code;
+ if (code === "ENOENT") return;
+ console.warn(
+ `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}`
+ + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`,
+ );
}
}
@@ -682,7 +690,10 @@ export function resolveAndPersistCodexRuntime(
// path-does-not-exist rejection, so a present-but-unusable binary is left for the operator.
else if (result.runtime.source === "fallback" && persistedRuntime?.command) {
const pinVanished = result.failures.some(
- failure => sameRuntimeCommand(failure.command, persistedRuntime.command)
+ // Exact comparison, not `sameRuntimeCommand`: that helper lowercases, and on a
+ // case-sensitive filesystem `/plugins/Codex` and `/plugins/codex` are different
+ // files. A missing lowercase path must not retire a live uppercase pin.
+ failure => failure.command.trim() === persistedRuntime.command.trim()
&& failure.reason === PATH_MISSING_REASON,
);
if (pinVanished) clearPersistedCodexRuntime(deps);
diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts
index ee45d41e61..bf95f304a6 100644
--- a/tests/codex-integration/codex-runtime.test.ts
+++ b/tests/codex-integration/codex-runtime.test.ts
@@ -1040,4 +1040,24 @@ describe("dead configured pin recovery (#4035)", () => {
});
expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(weird);
});
+
+ test("a case-different missing path does not retire a live pin on linux", () => {
+ // sameRuntimeCommand() lowercases, so on a case-sensitive filesystem it reports
+ // /plugins/Codex and /plugins/codex as the same command. They are different files.
+ // If CODEX_CLI_PATH names the missing lowercase one, its PATH_MISSING failure must
+ // not retire the uppercase pin that is still live (review finding on #4035).
+ const configDir = tempConfigDir();
+ const live = join(configDir, "plugins", "Codex");
+ const missing = join(configDir, "plugins", "codex");
+ persistCodexRuntime({ command: live, version: "0.153.0", source: "configured" }, { configDir });
+ resolveAndPersistCodexRuntime({
+ configDir,
+ env: { PATH: "", CODEX_CLI_PATH: missing },
+ platform: "linux",
+ existsSync: (p: string) => String(p) === live,
+ execFileSync: () => "codex-cli 0.153.0",
+ });
+ expect(loadPersistedCodexRuntime({ configDir })?.command).toBe(live);
+ });
+
});