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
119 changes: 119 additions & 0 deletions src/lib/accounts/account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolveCodexDir,
resolveCurrentNamePath,
resolveSessionMapPath,
resolveSnapshotBackupDir,
} from "../config/paths";
import {
AccountNotFoundError,
Expand Down Expand Up @@ -136,6 +137,11 @@ export class AccountService {
return result;
};

// Repair any snapshot file that codex clobbered through a stale symlink
// before we attempt name resolution — otherwise the identity-based scan
// mistakes the clobbered file for a refresh of the previous account.
await this.restoreClobberedSnapshotsFromBackup();

const incomingSnapshot = await parseAuthSnapshotFile(authPath);
if (incomingSnapshot.authMode !== "chatgpt") {
return rememberAuthState({
Expand Down Expand Up @@ -187,6 +193,9 @@ export class AccountService {
force: Boolean(resolvedName.forceOverwrite),
});

// The backup vault has served its purpose for this codex run.
await this.clearSnapshotBackupVault();

return rememberAuthState({
synchronized: true,
savedName,
Expand All @@ -205,6 +214,13 @@ export class AccountService {
await this.materializeAuthSymlink(authPath);
}

// Defensive safety net: snapshot every saved account into a backup vault
// before codex runs. If the materialize step is bypassed (e.g., this
// function isn't invoked because the shell hook is shadowed by another
// codex() function), the next sync after codex exits can still recover
// any snapshot file that got clobbered.
await this.backupAllSnapshots();

const sessionAccountName = await this.getActiveSessionAccountName();
if (!sessionAccountName) {
return { restored: false };
Expand Down Expand Up @@ -836,6 +852,109 @@ export class AccountService {
return path.join(resolveAccountsDir(), `${name}.json`);
}

private snapshotBackupPath(name: string): string {
return path.join(resolveSnapshotBackupDir(), `${name}.json`);
}

private async backupAllSnapshots(): Promise<void> {
let accountNames: string[];
try {
accountNames = await this.listAccountNames();
} catch {
return;
}

const backupDir = resolveSnapshotBackupDir();
// Replace stale vault contents from a previous codex run with the current
// snapshot state so recovery only ever restores from this run's backup.
await this.clearSnapshotBackupVault();

if (accountNames.length === 0) {
return;
}

try {
await this.ensureDir(backupDir);
} catch {
return;
}

await Promise.all(
accountNames.map(async (name) => {
const source = this.accountFilePath(name);
const destination = this.snapshotBackupPath(name);
try {
await fsp.copyFile(source, destination);
} catch {
// Best-effort backup; one failure shouldn't block codex from running.
}
}),
);
}

private async restoreClobberedSnapshotsFromBackup(): Promise<void> {
const backupDir = resolveSnapshotBackupDir();
if (!(await this.pathExists(backupDir))) {
return;
}

let entries: string[];
try {
entries = await fsp.readdir(backupDir);
} catch {
return;
}

for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const name = entry.replace(/\.json$/i, "");
const destination = this.accountFilePath(name);
const source = path.join(backupDir, entry);

try {
const backupSnapshot = await parseAuthSnapshotFile(source);
if (backupSnapshot.authMode !== "chatgpt") continue;
} catch {
continue;
}

if (!(await this.pathExists(destination))) {
// Destination missing: codex deleted it (or never saved). Recover.
try {
await this.ensureDir(path.dirname(destination));
await fsp.copyFile(source, destination);
} catch {
// Best-effort; skip on failure.
}
continue;
}

// Destination exists. If its identity differs from the backup's
// identity, codex clobbered it through a stale symlink. Restore.
try {
const [backupSnapshot, currentSnapshot] = await Promise.all([
parseAuthSnapshotFile(source),
parseAuthSnapshotFile(destination),
]);
if (this.snapshotsShareIdentity(backupSnapshot, currentSnapshot)) {
continue;
}
await fsp.copyFile(source, destination);
} catch {
// Skip on any read/write failure rather than abort the whole recovery.
}
}
}

private async clearSnapshotBackupVault(): Promise<void> {
const backupDir = resolveSnapshotBackupDir();
try {
await fsp.rm(backupDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; do not propagate.
}
}

private normalizeAccountName(rawName: string | undefined): string {
if (typeof rawName !== "string") {
throw new InvalidAccountNameError();
Expand Down
4 changes: 4 additions & 0 deletions src/lib/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export function resolveSessionMapPath(): string {
return path.join(resolveAccountsDir(), "sessions.json");
}

export function resolveSnapshotBackupDir(): string {
return path.join(resolveAccountsDir(), ".snapshot-backups");
}

export const codexDir: string = resolveCodexDir();
export const accountsDir: string = resolveAccountsDir();
export const authPath: string = resolveAuthPath();
Expand Down
66 changes: 66 additions & 0 deletions src/tests/save-account-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,72 @@ test("restoreSessionSnapshotIfNeeded materializes the auth symlink even when no
});
});

test("syncExternalAuthSnapshotIfNeeded recovers a snapshot that codex login clobbered through a symlink", async (t) => {
if (process.platform === "win32") {
t.skip("symlink conversion behavior is Unix-specific in this test");
return;
}

await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
const service = new AccountService();
const previousName = "admin@kollarrobert.sk";
const previousSnapshotPath = path.join(accountsDir, `${previousName}.json`);
const currentPath = path.join(codexDir, "current");

process.env.CODEX_AUTH_SESSION_KEY = "recovery-test";
process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1";
process.env.CODEX_AUTH_FORCE_EXTERNAL_SYNC = "1";

t.after(() => {
delete process.env.CODEX_AUTH_FORCE_EXTERNAL_SYNC;
});

await fsp.writeFile(
previousSnapshotPath,
buildAuthPayload(previousName, {
accountId: "acct-admin",
userId: "user-admin",
tokenSeed: "admin-original",
}),
"utf8",
);
await fsp.writeFile(currentPath, `${previousName}\n`, "utf8");
await fsp.symlink(previousSnapshotPath, authPath);

// restore-session runs the pre-codex backup vault.
await service.restoreSessionSnapshotIfNeeded();

// Simulate codex login writing through the still-stale symlink
// (this is what happens when the shell hook is shadowed): the previous
// snapshot file gets the new account's tokens written into it directly.
await fsp.rm(authPath, { force: true });
await fsp.symlink(previousSnapshotPath, authPath);
await fsp.writeFile(
previousSnapshotPath,
buildAuthPayload("zeus@mite.hu", {
accountId: "acct-zeus",
userId: "user-zeus",
tokenSeed: "zeus-login",
}),
"utf8",
);

// Post-codex sync runs syncExternalAuthSnapshotIfNeeded; it should save
// zeus under its own name AND recover the admin snapshot from backup.
const result = await service.syncExternalAuthSnapshotIfNeeded();
assert.equal(result.synchronized, true);
assert.equal(result.savedName, "zeus@mite.hu");

const recovered = await parseAuthSnapshotFile(previousSnapshotPath);
assert.equal(recovered.email, previousName);
assert.equal(recovered.accountId, "acct-admin");

const zeus = await parseAuthSnapshotFile(path.join(accountsDir, "zeus@mite.hu.json"));
assert.equal(zeus.email, "zeus@mite.hu");
assert.equal(zeus.accountId, "acct-zeus");
});
});

test("listAccountNames excludes the update-check cache file", async (t) => {
await withIsolatedCodexDir(t, async ({ accountsDir }) => {
const service = new AccountService();
Expand Down