Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
81 changes: 65 additions & 16 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import { getConfigPath, saveConfigPreservingClaudeCode, withConfigMutationLockSync } from "../config";
import { removeCodexAccountCredential } from "./account-store";
import { clearAccountNeedsReauth } from "./account-runtime-state";
import { getMainChatgptAccountId } from "./auth-collision";
Expand All @@ -13,6 +15,13 @@ import type { OcxConfig } from "../types";

let observedMainChatgptAccountId: string | undefined;

export class CodexAccountDeleteCleanupError extends Error {
constructor() {
super("Account deletion was saved, but local credential cleanup did not complete. Retry removal.");
this.name = "CodexAccountDeleteCleanupError";
}
}

export function purgeCodexAccountRuntimeState(accountId: string): void {
clearAccountNeedsReauth(accountId);
clearAccountQuota(accountId);
Expand Down Expand Up @@ -71,25 +80,65 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void {
clearMainAccountCredentialPresence();
}

function restoreRuntimeConfig(target: OcxConfig, snapshot: OcxConfig): void {
for (const key of Object.keys(target) as Array<keyof OcxConfig>) delete target[key];
Object.assign(target, snapshot);
}

/**
* Delete a stored account while retaining its selector binding.
*
* When the runtime config is backed by an existing config.json, commit the config deletion before
* credentials or runtime state are destroyed. Pure in-memory callers intentionally remain
* side-effect free because they have no durable account row to protect. The whole sequence shares
* the config mutation coordinator so a cooperating writer cannot re-add a persisted account
* between the durable config commit and credential cleanup.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
*
* Returns true when a picker-visible row disappeared and the catalog must converge.
*/
export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): boolean {
const hadStoredAccount = (runtimeConfig.codexAccounts ?? [])
.some(account => !account.isMain && account.id === accountId);
const hadVisiblePickerBinding = hadStoredAccount
&& codexAccountPickerEnabled(runtimeConfig)
&& codexAccountNamespaceEntries(runtimeConfig)
.some(([, boundAccountId]) => boundAccountId === accountId);
removeCodexAccountCredential(accountId);
runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
.filter(account => account.isMain || account.id !== accountId);
forgetCodexAccountPause(runtimeConfig, accountId);
forgetCodexAccountPriority(runtimeConfig, accountId);
clearCodexAccountPin(runtimeConfig, accountId);
if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
purgeCodexAccountRuntimeState(accountId);
invalidateCodexWebSocketsForAccount(accountId);
return hadVisiblePickerBinding;
let cleanupFailed = false;
const pickerVisibilityChanged = withConfigMutationLockSync(() => {
const previousConfig = structuredClone(runtimeConfig);
const hasPersistedConfig = existsSync(getConfigPath());
const hadStoredAccount = (runtimeConfig.codexAccounts ?? [])
.some(account => !account.isMain && account.id === accountId);
const hadVisiblePickerBinding = hadStoredAccount
&& codexAccountPickerEnabled(runtimeConfig)
&& codexAccountNamespaceEntries(runtimeConfig)
.some(([, boundAccountId]) => boundAccountId === accountId);

runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
.filter(account => account.isMain || account.id !== accountId);
forgetCodexAccountPause(runtimeConfig, accountId);
forgetCodexAccountPriority(runtimeConfig, accountId);
clearCodexAccountPin(runtimeConfig, accountId);
if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;

if (hasPersistedConfig) {
try {
// Persist first for durable configs. Destructive cleanup below must never run for a
// deletion that failed to commit. Transient configs intentionally skip this write.
saveConfigPreservingClaudeCode(runtimeConfig);
} catch (error) {
restoreRuntimeConfig(runtimeConfig, previousConfig);
throw error;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore durable config state when persistence fails after the file write.

saveConfigPreservingClaudeCode can complete persistConfigUnlocked and then throw in bumpGenerationForCooperatingConfigWrite() or adoptCustomModelCatalogMigration(). The catch at Line [124] restores only runtimeConfig; it does not restore config.json. The caller can receive a persistence error while the account is already durably removed, leaving credentials without the corresponding account row.

Make the persistence API report whether the file replacement completed, or perform a compensating durable restore before rethrowing. Restore runtimeConfig only when the durable write did not complete. This is the same unresolved durability gap identified in the previous review.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/codex/account-lifecycle.ts` around lines 118 - 126, Update the
hasPersistedConfig error path around saveConfigPreservingClaudeCode so it tracks
whether the durable file replacement completed, compensates by restoring the
previous config when it did, and restores runtimeConfig before rethrowing.
Ensure runtimeConfig is restored on every failure, while the durable config is
restored only after a completed write, and expose the completion status through
the persistence API as needed.

}

try {
removeCodexAccountCredential(accountId);
purgeCodexAccountRuntimeState(accountId);
invalidateCodexWebSocketsForAccount(accountId);
} catch {
// Do not throw through the mutation coordinator after config.json committed: that would roll
// back only the SQLite generation transaction, not the already-atomic file replacement.
cleanupFailed = true;
}

return hadVisiblePickerBinding;
});

if (cleanupFailed) throw new CodexAccountDeleteCleanupError();
return pickerVisibilityChanged;
}
150 changes: 150 additions & 0 deletions tests/codex-account-delete-atomicity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import * as accountStoreModule from "../src/codex/account-store";
import {
getCodexAccountCredential,
saveCodexAccountCredential,
} from "../src/codex/account-store";
import {
CodexAccountDeleteCleanupError,
deleteCodexAccount,
} from "../src/codex/account-lifecycle";
import {
isAccountNeedsReauth,
markAccountNeedsReauth,
} from "../src/codex/account-runtime-state";
import {
getAccountQuota,
updateAccountQuota,
} from "../src/codex/quota";
import { loadConfig, saveConfig } from "../src/config";
import * as configModule from "../src/config";
import type { OcxConfig } from "../src/types";

const TEST_DIR = join(import.meta.dir, ".tmp-codex-account-delete-atomicity");
const ACCOUNT_ID = "delete-atomicity";
let previousHome: string | undefined;

function seededConfig(): OcxConfig {
const config = loadConfig();
config.codexAccounts = [{
id: ACCOUNT_ID,
email: "delete-atomicity@example.test",
isMain: false,
}];
config.codexAccountNamespaces = { stable: ACCOUNT_ID };
config.codexAccountPickerEnabled = true;
config.pausedCodexAccountIds = [ACCOUNT_ID];
config.codexAccountPriorities = { [ACCOUNT_ID]: 7 };
config.activeCodexAccountPinned = ACCOUNT_ID;
config.activeCodexAccountId = ACCOUNT_ID;
saveConfig(config);
saveCodexAccountCredential(ACCOUNT_ID, {
accessToken: "delete-access",
refreshToken: "delete-refresh",
expiresAt: Date.now() + 60_000,
chatgptAccountId: "delete-chatgpt-id",
});
markAccountNeedsReauth(ACCOUNT_ID);
updateAccountQuota(ACCOUNT_ID, 42);
return config;
}

beforeEach(() => {
previousHome = process.env.OPENCODEX_HOME;
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
mkdirSync(TEST_DIR, { recursive: true });
process.env.OPENCODEX_HOME = TEST_DIR;
});

afterEach(() => {
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousHome;
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
});

describe("Codex account delete persistence ordering", () => {
test("a config persistence failure leaves the account and destructive state intact", () => {
const config = seededConfig();
const before = structuredClone(config);
const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode")
.mockImplementation(() => { throw new Error("forced config write failure"); });

try {
expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow("forced config write failure");

expect(config).toEqual(before);
expect(loadConfig().codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(true);
expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull();
expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true);
expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull();
} finally {
saveSpy.mockRestore();
}
});

test("the durable config deletion happens before credential and runtime cleanup", () => {
const config = seededConfig();
const realSave = configModule.saveConfigPreservingClaudeCode;
const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode")
.mockImplementation(candidate => {
expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull();
expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true);
expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull();
realSave(candidate);
});

try {
expect(deleteCodexAccount(config, ACCOUNT_ID)).toBe(true);
} finally {
saveSpy.mockRestore();
}

const persisted = loadConfig();
expect(persisted.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false);
expect(persisted.codexAccountNamespaces).toEqual({ stable: ACCOUNT_ID });
expect(config.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false);
expect(config.pausedCodexAccountIds).toBeUndefined();
expect(config.codexAccountPriorities).toBeUndefined();
expect(config.activeCodexAccountPinned).toBeUndefined();
expect(config.activeCodexAccountId).toBeUndefined();
expect(getCodexAccountCredential(ACCOUNT_ID)).toBeNull();
expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(false);
expect(getAccountQuota(ACCOUNT_ID)).toBeNull();
});

test("a cleanup failure keeps the deletion durable and exposes only a fixed recovery error", () => {
const config = seededConfig();
const removeSpy = spyOn(accountStoreModule, "removeCodexAccountCredential")
.mockImplementation(() => {
throw new Error("private cleanup detail /private/codex-accounts.json Bearer secret-token");
});

try {
let thrown: unknown;
try {
deleteCodexAccount(config, ACCOUNT_ID);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(CodexAccountDeleteCleanupError);
expect(String((thrown as Error).message)).toBe(
"Account deletion was saved, but local credential cleanup did not complete. Retry removal.",
);
expect(String((thrown as Error).message)).not.toContain("private");
expect(String((thrown as Error).message)).not.toContain("secret-token");
expect(loadConfig().codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false);
expect(config.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false);
expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull();
} finally {
removeSpy.mockRestore();
}

// The route is retry-safe even after the durable row is gone: a second delete can finish the
// tombstone/runtime cleanup without recreating the account or selector mapping.
expect(deleteCodexAccount(config, ACCOUNT_ID)).toBe(false);
expect(getCodexAccountCredential(ACCOUNT_ID)).toBeNull();
expect(loadConfig().codexAccountNamespaces).toEqual({ stable: ACCOUNT_ID });
});
});
Loading