diff --git a/src/lib/accounts/account-service.ts b/src/lib/accounts/account-service.ts index 7a1a0027..6224a5d2 100644 --- a/src/lib/accounts/account-service.ts +++ b/src/lib/accounts/account-service.ts @@ -7,6 +7,7 @@ import { resolveCodexDir, resolveCurrentNamePath, resolveSessionMapPath, + resolveSnapshotBackupDir, } from "../config/paths"; import { AccountNotFoundError, @@ -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({ @@ -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, @@ -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 }; @@ -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 { + 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 { + 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 { + 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(); diff --git a/src/lib/config/paths.ts b/src/lib/config/paths.ts index f321345e..31eb6a49 100644 --- a/src/lib/config/paths.ts +++ b/src/lib/config/paths.ts @@ -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(); diff --git a/src/tests/save-account-safety.test.ts b/src/tests/save-account-safety.test.ts index f7ba3e6b..1a4c149e 100644 --- a/src/tests/save-account-safety.test.ts +++ b/src/tests/save-account-safety.test.ts @@ -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();