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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions lib/runtime/app-bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -612,9 +621,19 @@ export async function getAppBindStatus(options: AppBindOptions = {}): Promise<Ap
const paths = resolveAppBindPaths(options);
const state = await readAppBindState(paths.statePath);
const router = await readRouterStatus(paths.statusPath);
// When no state file is present, the bind may still be live in config.toml
// (orphaned bind, #614). Detect that from the config directly so status and
// downstream callers don't report a bound config as "not configured".
let unmanagedBind = false;
if (state === null) {
const current = await readConfigIfExists(paths.configPath);
unmanagedBind =
current.existed && configHasRuntimeRotationProvider(current.content);
}
return {
bound: state !== null,
bound: state !== null || unmanagedBind,
running: router !== null && router.state === "running" && isProcessAlive(router.pid),
unmanagedBind,
state,
router,
paths,
Expand Down Expand Up @@ -765,6 +784,7 @@ async function unbindCodexAppRuntimeRotationLocked(
}

const backup = await readAppBindBackup(paths.backupPath);
let selfHealed = false;
if (backup) {
const current = await readConfigIfExists(backup.configPath);
if (state && current.existed && sha256(current.content) !== state.boundConfigHash) {
Expand All @@ -786,6 +806,22 @@ async function unbindCodexAppRuntimeRotationLocked(
restoreConfigTomlFromAppBind(current.content, ""),
);
}
} else {
// Orphaned-bind recovery (#614): no backup and no state file, but the
// config may still be bound to the runtime proxy (e.g. the state/backup
// were lost while config.toml stayed rewritten). The state-file checks
// above can't see this, so consult the config directly and self-heal it
// back to a working provider when it is bound.
const current = await readConfigIfExists(paths.configPath);
if (current.existed && configHasRuntimeRotationProvider(current.content)) {
await atomicWriteFile(
paths.configPath,
restoreConfigTomlFromRuntimeRotationProviderWithoutBackup(
current.content,
),
);
selfHealed = true;
}
}

for (const candidate of [
Expand All @@ -801,15 +837,31 @@ async function unbindCodexAppRuntimeRotationLocked(
}

const status = await getAppBindStatus(options);
let message: string;
if (backup) {
message = `Unbound Codex app config ${backup.configPath}`;
} else if (selfHealed) {
message = `Restored Codex app config ${paths.configPath} from an orphaned runtime-proxy bind (no backup was present)`;
} else {
message = "Codex app bind was not configured";
}
return {
status,
message: backup
? `Unbound Codex app config ${backup.configPath}`
: "Codex app bind was not configured",
message,
};
}

export function formatAppBindStatus(status: AppBindStatus): string {
if (status.unmanagedBind && !status.state) {
return [
`Codex app bind: bound but unmanaged (config=${status.paths.configPath} points at the runtime proxy, but no app-bind state/backup is present)`,
[
"Run `codex-multi-auth rotation unbind-app` to restore the original",
"Codex provider/config. This recovers the orphaned bind even though no",
"backup was saved (#614).",
].join(" "),
].join("\n");
}
if (!status.bound || !status.state) return "Codex app bind: not configured";
const parts = [
status.running ? "running" : "configured but router not running",
Expand Down
96 changes: 87 additions & 9 deletions lib/runtime/config-toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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.<id>]` 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");
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

133 changes: 133 additions & 0 deletions test/app-bind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
bindCodexAppRuntimeRotation,
formatAppBindStatus,
getAppBindStatus,
resolveAppBindPaths,
restoreConfigTomlFromAppBind,
rewriteConfigTomlForAppBind,
Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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',
);
});
});
Loading