From fb3ec8a5bc9252dc1eb2a9649fdc8bc5de9b2f59 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 16 Jun 2026 22:02:49 +0800 Subject: [PATCH 1/4] fix(app-bind): self-heal orphaned runtime-proxy bind with no backup (#614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When app-bind rewrote ~/.codex/config.toml to the runtime-proxy provider but its state/backup files were later lost, the config stayed bound while `getAppBindStatus` and `unbind-app` — which inferred "bound" purely from the state files — reported "not configured" and refused to act. The user was left with a config pointing at a dead proxy port, recoverable only by hand-editing config.toml. Fixes: - config-toml: add `configHasRuntimeRotationProvider()` (detect a bound config from the top-level model_provider or the proxy provider block) and `restoreConfigTomlFromRuntimeRotationProviderWithoutBackup()` (strip the proxy block and fall back the top-level provider to "openai" when no original backup exists; pins line endings to the input style). - app-bind: `unbindCodexAppRuntimeRotationLocked` now self-heals — when there is no backup and no state but config.toml is still bound, it restores the config and reports the recovery. - app-bind: `getAppBindStatus` derives `bound` from the config when no state file is present and exposes a new `unmanagedBind` flag; `formatAppBindStatus` surfaces "bound but unmanaged" with the unbind remedy instead of "not configured". Adds 11 regression tests (detection, no-backup restore incl. CRLF, unmanaged-status detection, self-heal unbind, clean-config no-op). Full suite: 4947 passed, 3 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/runtime/app-bind.ts | 60 ++++++++++++++++++++-- lib/runtime/config-toml.ts | 62 ++++++++++++++++++++++ test/app-bind.test.ts | 88 ++++++++++++++++++++++++++++++++ test/config-toml-restore.test.ts | 77 ++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 4 deletions(-) diff --git a/lib/runtime/app-bind.ts b/lib/runtime/app-bind.ts index 31ef17141..d8d2c58da 100644 --- a/lib/runtime/app-bind.ts +++ b/lib/runtime/app-bind.ts @@ -9,7 +9,9 @@ import { fileURLToPath } from "node:url"; import { withFileOperationRetry } from "../fs-retry.js"; import { getCodexMultiAuthDir } from "../runtime-paths.js"; import { + configHasRuntimeRotationProvider, restoreConfigTomlFromRuntimeRotationProvider, + restoreConfigTomlFromRuntimeRotationProviderWithoutBackup, rewriteConfigTomlForRuntimeRotationProvider, } from "./config-toml.js"; @@ -81,6 +83,13 @@ export interface AppBindRouterStatus { export interface AppBindStatus { bound: boolean; running: boolean; + /** + * True when config.toml is bound to the runtime proxy but the app-bind state + * file is gone (orphaned bind, #614). In this case `bound` is also true and + * `state` is null — the config needs `unbind-app` to recover even though the + * normal state-file tracking is missing. + */ + unmanagedBind: boolean; state: AppBindState | null; router: AppBindRouterStatus | null; paths: AppBindPaths; @@ -612,9 +621,19 @@ export async function getAppBindStatus(options: AppBindOptions = {}): Promise]` block is present. Used to recover an orphaned + * bind whose app-bind state/backup files were lost: in that situation the + * state-file-based status check reports "not configured" even though the config + * is still bound, so unbind/status must consult the config itself. + */ +export function configHasRuntimeRotationProvider(rawConfig: string): boolean { + if (rawConfig.length === 0) return false; + const providerTable = `model_providers.${RUNTIME_ROTATION_PROXY_PROVIDER_ID}`; + let inTopLevel = true; + for (const line of rawConfig.split(/\r?\n/)) { + const tableName = readTomlTableName(line); + if (tableName !== null) { + if (tableName === providerTable) return true; + inTopLevel = false; + continue; + } + if ( + inTopLevel && + /^\s*model_provider\s*=/.test(line) && + line.includes(RUNTIME_ROTATION_PROXY_PROVIDER_ID) + ) { + return true; + } + } + return false; +} + +/** + * Restores a bound config when no backup of the user's original config exists + * (the orphaned-bind recovery path). Strips the proxy provider block and any + * bind-written top-level lines, and — because there is no original + * `model_provider` line to bring back — falls back to `defaultProvider` + * (Codex's native `"openai"`) so the config is left on a working provider + * rather than the dangling proxy id. + */ +export function restoreConfigTomlFromRuntimeRotationProviderWithoutBackup( + currentConfig: string, + defaultProvider = "openai", +): string { + const lineEnding = currentConfig.includes("\r\n") ? "\r\n" : "\n"; + // Synthesize a minimal "original" config carrying only the default + // top-level model_provider, so the shared restore path rewrites the proxy + // line back to a usable provider instead of leaving it dangling. + const syntheticOriginal = `model_provider = ${tomlStringLiteral(defaultProvider)}${lineEnding}`; + const restored = restoreConfigTomlFromRuntimeRotationProvider( + currentConfig, + syntheticOriginal, + ); + // Normalize line endings to match the input config. The shared restore path + // derives its EOL from intermediate state, which can collapse to "\n" when + // the bound config was almost entirely proxy content; pin it back to the + // original style so a CRLF (Windows-authored) config stays CRLF. + if (lineEnding === "\r\n") { + return restored.replace(/\r?\n/g, "\r\n"); + } + return restored.replace(/\r\n/g, "\n"); +} + diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index e0b8b6a8a..4b16cd9c1 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { bindCodexAppRuntimeRotation, formatAppBindStatus, + getAppBindStatus, resolveAppBindPaths, restoreConfigTomlFromAppBind, rewriteConfigTomlForAppBind, @@ -813,3 +814,90 @@ describe("Codex app runtime rotation bind", () => { expect(await readFile(logPath, "utf8")).toContain("log truncated"); }); }); + +describe("orphaned app-bind recovery (#614)", () => { + const boundConfig = [ + 'model_provider = "codex-multi-auth-runtime-proxy"', + "[profiles.default]", + 'model = "gpt-5"', + "", + "[model_providers.codex-multi-auth-runtime-proxy]", + 'name = "codex-multi-auth"', + 'base_url = "http://127.0.0.1:51758"', + "requires_openai_auth = false", + 'wire_api = "responses"', + "", + ].join("\n"); + + async function seedOrphanedBind(): Promise<{ + root: string; + codexHome: string; + env: NodeJS.ProcessEnv; + }> { + const root = await createTempRoot("codex-app-bind-orphan-"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: join(root, "multi-auth"), + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await mkdir(codexHome, { recursive: true }); + // Bound config on disk, but NO state file and NO backup (the orphan case). + await writeFile(join(codexHome, "config.toml"), boundConfig, "utf8"); + return { root, codexHome, env }; + } + + it("reports unmanagedBind when config is bound but no state file exists", async () => { + const { root, env } = await seedOrphanedBind(); + const status = await getAppBindStatus({ platform: "linux", home: root, env }); + expect(status.bound).toBe(true); + expect(status.unmanagedBind).toBe(true); + expect(status.state).toBeNull(); + expect(formatAppBindStatus(status)).toContain("bound but unmanaged"); + }); + + it("self-heals a bound config with no backup/state on unbind", async () => { + const { root, codexHome, env } = await seedOrphanedBind(); + + const unbound = await unbindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + spawnDetached: false, + }); + + const restored = await readFile(join(codexHome, "config.toml"), "utf8"); + expect(restored).toContain('model_provider = "openai"'); + expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + expect(restored).toContain("[profiles.default]"); + expect(unbound.message).toContain("orphaned runtime-proxy bind"); + expect(unbound.status.bound).toBe(false); + expect(unbound.status.unmanagedBind).toBe(false); + }); + + it("is a no-op for an already-clean config", async () => { + const root = await createTempRoot("codex-app-bind-clean-"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: join(root, "multi-auth"), + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await mkdir(codexHome, { recursive: true }); + await writeFile( + join(codexHome, "config.toml"), + 'model_provider = "openai"\n', + "utf8", + ); + + const unbound = await unbindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + spawnDetached: false, + }); + + expect(unbound.message).toBe("Codex app bind was not configured"); + expect(await readFile(join(codexHome, "config.toml"), "utf8")).toBe( + 'model_provider = "openai"\n', + ); + }); +}); diff --git a/test/config-toml-restore.test.ts b/test/config-toml-restore.test.ts index 738f267e2..29d9be191 100644 --- a/test/config-toml-restore.test.ts +++ b/test/config-toml-restore.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + configHasRuntimeRotationProvider, + restoreConfigTomlFromRuntimeRotationProviderWithoutBackup, restoreTopLevelModelProvider, restoreTopLevelResponseStorage, } from "../lib/runtime/config-toml.js"; @@ -113,3 +115,78 @@ describe("restoreTopLevelResponseStorage", () => { expect(restored).toContain("disable_response_storage = false"); }); }); + +describe("configHasRuntimeRotationProvider", () => { + it("detects a top-level model_provider bound to the proxy", () => { + const config = + 'model_provider = "codex-multi-auth-runtime-proxy"\n[profiles.default]\nmodel = "gpt-5"\n'; + expect(configHasRuntimeRotationProvider(config)).toBe(true); + }); + + it("detects the proxy provider block even when model_provider is native", () => { + const config = + 'model_provider = "openai"\n[model_providers.codex-multi-auth-runtime-proxy]\nname = "codex-multi-auth"\n'; + expect(configHasRuntimeRotationProvider(config)).toBe(true); + }); + + it("returns false for an unbound config", () => { + const config = 'model_provider = "openai"\n[profiles.default]\nmodel = "gpt-5"\n'; + expect(configHasRuntimeRotationProvider(config)).toBe(false); + }); + + it("returns false for empty config", () => { + expect(configHasRuntimeRotationProvider("")).toBe(false); + }); + + it("does not match a proxy id that only appears inside a non-provider section", () => { + // A stray mention in some other section's value must not be treated as a + // top-level bind (the top-level scan stops at the first table header). + const config = + '[profiles.default]\nnote = "codex-multi-auth-runtime-proxy"\n'; + expect(configHasRuntimeRotationProvider(config)).toBe(false); + }); +}); + +describe("restoreConfigTomlFromRuntimeRotationProviderWithoutBackup", () => { + it("rewrites a bound model_provider to the default and strips the proxy block", () => { + const bound = [ + 'model_provider = "codex-multi-auth-runtime-proxy"', + "[profiles.default]", + 'model = "gpt-5"', + "", + "[model_providers.codex-multi-auth-runtime-proxy]", + 'name = "codex-multi-auth"', + 'base_url = "http://127.0.0.1:51758"', + "requires_openai_auth = false", + 'wire_api = "responses"', + "", + ].join("\n"); + + const restored = + restoreConfigTomlFromRuntimeRotationProviderWithoutBackup(bound); + + expect(restored).toContain('model_provider = "openai"'); + expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + expect(restored).toContain("[profiles.default]"); + expect(configHasRuntimeRotationProvider(restored)).toBe(false); + }); + + it("honors a custom default provider", () => { + const bound = 'model_provider = "codex-multi-auth-runtime-proxy"\n'; + const restored = restoreConfigTomlFromRuntimeRotationProviderWithoutBackup( + bound, + "my-provider", + ); + expect(restored).toContain('model_provider = "my-provider"'); + }); + + it("preserves CRLF endings when recovering", () => { + const bound = + 'model_provider = "codex-multi-auth-runtime-proxy"\r\n[model_providers.codex-multi-auth-runtime-proxy]\r\nname = "codex-multi-auth"\r\n'; + const restored = + restoreConfigTomlFromRuntimeRotationProviderWithoutBackup(bound); + expect(restored).toContain('model_provider = "openai"'); + expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + expect(restored.replace(/\r\n/g, "")).not.toContain("\n"); + }); +}); From 46c2bdc6772bfb12f0adf9cf107abf095c337353 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 16 Jun 2026 22:16:16 +0800 Subject: [PATCH 2/4] fix(config-toml): avoid duplicate model_provider in half-orphan recovery (#614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review (P1) on #615: when the proxy provider block was present but the top-level model_provider already pointed at a non-proxy value (e.g. a half-orphaned config, or one where unbind partially ran), the no-backup recovery path duplicated the model_provider key — invalid TOML that codex-cli refuses to parse, leaving the user worse off. Root cause was the shared restoreTopLevelModelProvider: its fallback splice fired whenever no *proxy* model_provider line was found, even when a valid non-proxy top-level line already existed. Guard the splice so it only inserts the original line when there is no top-level model_provider at all; an existing line is left untouched (removing the proxy block is sufficient). Adds the two P2 tests Greptile requested: half-orphan recovery (asserts a single model_provider line) and disable_response_storage cleanup in the no-backup path. Full suite: 4949 passed, 3 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/runtime/config-toml.ts | 34 +++++++++++++++------ test/config-toml-restore.test.ts | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/lib/runtime/config-toml.ts b/lib/runtime/config-toml.ts index 431df3113..c26730aa0 100644 --- a/lib/runtime/config-toml.ts +++ b/lib/runtime/config-toml.ts @@ -136,15 +136,31 @@ export function restoreTopLevelModelProvider( } if (!handled && originalLine) { - // Splice the restored line into the root table — appending at tail - // would land it inside whatever section appears last in `output`. - const firstSectionIdx = output.findIndex( - (line) => readTomlTableName(line) !== null, - ); - if (firstSectionIdx === -1) { - output.push(originalLine); - } else { - output.splice(firstSectionIdx, 0, originalLine); + // Only splice the original line back when the current config has no + // top-level model_provider at all (bind stripped it). If a non-proxy + // top-level model_provider already exists — e.g. a half-orphaned config + // where the proxy block is present but the provider line already points + // elsewhere — inserting another line would create a duplicate top-level + // key and produce invalid TOML. In that case the existing line is + // already correct, so leave it untouched. + const hasTopLevelModelProvider = (() => { + for (const line of output) { + if (readTomlTableName(line) !== null) return false; + if (/^\s*model_provider\s*=/.test(line)) return true; + } + return false; + })(); + if (!hasTopLevelModelProvider) { + // Splice the restored line into the root table — appending at tail + // would land it inside whatever section appears last in `output`. + const firstSectionIdx = output.findIndex( + (line) => readTomlTableName(line) !== null, + ); + if (firstSectionIdx === -1) { + output.push(originalLine); + } else { + output.splice(firstSectionIdx, 0, originalLine); + } } } diff --git a/test/config-toml-restore.test.ts b/test/config-toml-restore.test.ts index 29d9be191..aee3075f1 100644 --- a/test/config-toml-restore.test.ts +++ b/test/config-toml-restore.test.ts @@ -189,4 +189,55 @@ describe("restoreConfigTomlFromRuntimeRotationProviderWithoutBackup", () => { expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); expect(restored.replace(/\r\n/g, "")).not.toContain("\n"); }); + + it("does not duplicate model_provider when the top-level line is already non-proxy (half-orphan)", () => { + // The proxy *block* is present but the top-level model_provider already + // points at a real provider. Recovery must strip the block and leave the + // single existing line — inserting a second one is invalid TOML. + const halfOrphan = [ + 'model_provider = "openai"', + "[profiles.default]", + 'model = "gpt-5"', + "", + "[model_providers.codex-multi-auth-runtime-proxy]", + 'name = "codex-multi-auth"', + 'base_url = "http://127.0.0.1:51758"', + 'wire_api = "responses"', + "", + ].join("\n"); + + const restored = + restoreConfigTomlFromRuntimeRotationProviderWithoutBackup(halfOrphan); + + const providerLines = ( + restored.match(/^\s*model_provider\s*=/gm) ?? [] + ).length; + expect(providerLines).toBe(1); + expect(restored).toContain('model_provider = "openai"'); + expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + expect(restored).toContain("[profiles.default]"); + }); + + it("drops bind-injected disable_response_storage during no-backup recovery", () => { + // A full bind injects `disable_response_storage = false` at top level; + // recovery with no backup must remove that residue. + const bound = [ + 'model_provider = "codex-multi-auth-runtime-proxy"', + "disable_response_storage = false", + "[profiles.default]", + 'model = "gpt-5"', + "", + "[model_providers.codex-multi-auth-runtime-proxy]", + 'name = "codex-multi-auth"', + 'wire_api = "responses"', + "", + ].join("\n"); + + const restored = + restoreConfigTomlFromRuntimeRotationProviderWithoutBackup(bound); + + expect(restored).toContain('model_provider = "openai"'); + expect(restored).not.toContain("disable_response_storage"); + expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + }); }); From cd4c5d79e1411a7824ea3a30b24a2c10229c0685 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 16 Jun 2026 22:19:44 +0800 Subject: [PATCH 3/4] test(app-bind): add integration coverage for half-orphan unbind (#614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile/CodeRabbit follow-up: add an integration-level regression that drives the partial-orphan case (proxy block present, top-level model_provider already native) through unbindCodexAppRuntimeRotation and asserts exactly one model_provider line survives — complementing the unit-level coverage of restoreConfigTomlFromRuntimeRotationProviderWithoutBackup. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/app-bind.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index 4b16cd9c1..3e6f05ab0 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -874,6 +874,49 @@ describe("orphaned app-bind recovery (#614)", () => { expect(unbound.status.unmanagedBind).toBe(false); }); + it("self-heals a half-orphan (proxy block present, model_provider already native) without duplicating keys", async () => { + const root = await createTempRoot("codex-app-bind-half-orphan-"); + const codexHome = join(root, "codex-home"); + const env = { + CODEX_MULTI_AUTH_DIR: join(root, "multi-auth"), + CODEX_MULTI_AUTH_APP_BIND_CODEX_HOME: codexHome, + }; + await mkdir(codexHome, { recursive: true }); + // Top-level provider is already native, but the proxy block lingers — the + // partial-orphan case that previously produced a duplicate model_provider. + await writeFile( + join(codexHome, "config.toml"), + [ + 'model_provider = "openai"', + "[profiles.default]", + 'model = "gpt-5"', + "", + "[model_providers.codex-multi-auth-runtime-proxy]", + 'name = "codex-multi-auth"', + 'wire_api = "responses"', + "", + ].join("\n"), + "utf8", + ); + + const unbound = await unbindCodexAppRuntimeRotation({ + platform: "linux", + home: root, + env, + spawnDetached: false, + }); + + const restored = await readFile(join(codexHome, "config.toml"), "utf8"); + const providerLines = ( + restored.match(/^\s*model_provider\s*=/gm) ?? [] + ).length; + expect(providerLines).toBe(1); + expect(restored).toContain('model_provider = "openai"'); + expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + expect(restored).toContain("[profiles.default]"); + expect(unbound.status.bound).toBe(false); + }); + it("is a no-op for an already-clean config", async () => { const root = await createTempRoot("codex-app-bind-clean-"); const codexHome = join(root, "codex-home"); From a490274ce88197bde81b382bdcd4e6b50151b65d Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 16 Jun 2026 22:27:55 +0800 Subject: [PATCH 4/4] test(app-bind): assert disable_response_storage cleanup in orphan unbind (#614) CodeRabbit follow-up: the orphaned-bind fixture now also carries the bind-injected `disable_response_storage = false` line, and the self-heal unbind test asserts it is removed during recovery (integration-level coverage to match the unit-level test in config-toml-restore). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/app-bind.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/app-bind.test.ts b/test/app-bind.test.ts index 3e6f05ab0..04312b283 100644 --- a/test/app-bind.test.ts +++ b/test/app-bind.test.ts @@ -818,6 +818,7 @@ describe("Codex app runtime rotation bind", () => { describe("orphaned app-bind recovery (#614)", () => { const boundConfig = [ 'model_provider = "codex-multi-auth-runtime-proxy"', + "disable_response_storage = false", "[profiles.default]", 'model = "gpt-5"', "", @@ -868,6 +869,7 @@ describe("orphaned app-bind recovery (#614)", () => { const restored = await readFile(join(codexHome, "config.toml"), "utf8"); expect(restored).toContain('model_provider = "openai"'); expect(restored).not.toContain("codex-multi-auth-runtime-proxy"); + expect(restored).not.toContain("disable_response_storage"); expect(restored).toContain("[profiles.default]"); expect(unbound.message).toContain("orphaned runtime-proxy bind"); expect(unbound.status.bound).toBe(false);