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); + }); + });