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 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); + } } } @@ -271,3 +287,65 @@ export function restoreConfigTomlFromRuntimeRotationProvider( ), ); } + +/** + * Detects whether a config.toml is currently bound to the runtime rotation + * proxy — either the top-level `model_provider` points at the proxy id, or the + * proxy `[model_providers.]` 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..04312b283 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,135 @@ 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"', + "disable_response_storage = false", + "[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).not.toContain("disable_response_storage"); + 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("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"); + 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..aee3075f1 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,129 @@ 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"); + }); + + 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"); + }); +});