From 55d06d781c6129d610e20d46861a77b59a520c9b Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 4 May 2026 13:32:58 +0200 Subject: [PATCH] Recover snapshots when Codex login state says synced Codex login can leave the current auth fingerprint marked as already synchronized even after the matching saved snapshot file is removed or pruned. The sync fast path now verifies the session snapshot still exists before skipping, and use resolves exact email requests to the saved duplicate snapshot when the canonical filename is absent. Constraint: Operators use official codex login as the primary auth source Rejected: Only improving the not-found message | leaves the refresh-required loop and missing snapshot unrepaired Confidence: high Scope-risk: narrow Directive: Do not skip external auth sync solely from fingerprint unless the mapped snapshot file still exists Tested: npm test -- --runInBand Tested: npm pack --dry-run Not-tested: live codex login against real OpenAI account Co-authored-by: NagyVikt Co-authored-by: OmX --- src/lib/accounts/account-service.ts | 65 ++++++++++++++++++++-- src/tests/save-account-safety.test.ts | 77 +++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 10 deletions(-) diff --git a/src/lib/accounts/account-service.ts b/src/lib/accounts/account-service.ts index 175a22b..591ad3c 100644 --- a/src/lib/accounts/account-service.ts +++ b/src/lib/accounts/account-service.ts @@ -121,7 +121,8 @@ export class AccountService { initialAuthState && !initialAuthState.isSymbolicLink && !externalSyncForced && - (await this.getSessionAuthFingerprint()) === initialAuthState.fingerprint + (await this.getSessionAuthFingerprint()) === initialAuthState.fingerprint && + (await this.sessionSnapshotExists()) ) { return { synchronized: false, @@ -429,14 +430,15 @@ export class AccountService { public async useAccount(rawName: string): Promise { const name = this.normalizeAccountName(rawName); - await this.activateSnapshot(name); + const resolvedName = await this.resolveUsableAccountName(name); + await this.activateSnapshot(resolvedName); const registry = await loadRegistry(); - await this.hydrateSnapshotMetadataIfMissing(registry, name); - registry.activeAccountName = name; + await this.hydrateSnapshotMetadataIfMissing(registry, resolvedName); + registry.activeAccountName = resolvedName; await saveRegistry(registry); - return name; + return resolvedName; } public async removeAccounts(accountNames: string[]): Promise { @@ -1182,6 +1184,59 @@ export class AccountService { }); } + private async resolveUsableAccountName(accountName: string): Promise { + if (await this.pathExists(this.accountFilePath(accountName))) { + return accountName; + } + + await this.syncExternalAuthSnapshotIfNeeded(); + + if (await this.pathExists(this.accountFilePath(accountName))) { + return accountName; + } + + const emailMatches = await this.findSnapshotNamesByExactEmail(accountName); + if (emailMatches.length === 1) { + return emailMatches[0]; + } + if (emailMatches.length > 1) { + throw new AmbiguousAccountQueryError(accountName); + } + + throw new AccountNotFoundError(accountName); + } + + private async findSnapshotNamesByExactEmail(rawEmail: string): Promise { + const normalizedEmail = rawEmail.trim().toLowerCase(); + if (!normalizedEmail.includes("@")) { + return []; + } + + const accountNames = await this.listAccountNames(); + const matches: string[] = []; + for (const name of accountNames) { + const snapshotPath = this.accountFilePath(name); + try { + const snapshot = await parseAuthSnapshotFile(snapshotPath); + if (snapshot.email?.trim().toLowerCase() === normalizedEmail) { + matches.push(name); + } + } catch { + // Ignore unreadable snapshots here so the existing not-found path + // remains actionable for the requested email. + } + } + return matches.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + } + + private async sessionSnapshotExists(): Promise { + const sessionAccountName = await this.getSessionAccountName(); + if (!sessionAccountName) { + return true; + } + return this.pathExists(this.accountFilePath(sessionAccountName)); + } + private async clearActivePointers(): Promise { const currentPath = resolveCurrentNamePath(); const authPath = resolveAuthPath(); diff --git a/src/tests/save-account-safety.test.ts b/src/tests/save-account-safety.test.ts index cd8ac3e..47a852b 100644 --- a/src/tests/save-account-safety.test.ts +++ b/src/tests/save-account-safety.test.ts @@ -63,6 +63,7 @@ async function withIsolatedCodexDir( CODEX_AUTH_ACCOUNTS_DIR: process.env.CODEX_AUTH_ACCOUNTS_DIR, CODEX_AUTH_JSON_PATH: process.env.CODEX_AUTH_JSON_PATH, CODEX_AUTH_CURRENT_PATH: process.env.CODEX_AUTH_CURRENT_PATH, + CODEX_AUTH_SESSION_KEY: process.env.CODEX_AUTH_SESSION_KEY, CODEX_AUTH_SESSION_ACTIVE_OVERRIDE: process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE, }; @@ -72,11 +73,13 @@ async function withIsolatedCodexDir( delete process.env.CODEX_AUTH_CURRENT_PATH; t.after(async () => { - process.env.CODEX_AUTH_CODEX_DIR = previousEnv.CODEX_AUTH_CODEX_DIR; - process.env.CODEX_AUTH_ACCOUNTS_DIR = previousEnv.CODEX_AUTH_ACCOUNTS_DIR; - process.env.CODEX_AUTH_JSON_PATH = previousEnv.CODEX_AUTH_JSON_PATH; - process.env.CODEX_AUTH_CURRENT_PATH = previousEnv.CODEX_AUTH_CURRENT_PATH; - process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = previousEnv.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE; + for (const [key, value] of Object.entries(previousEnv)) { + if (typeof value === "string") { + process.env[key] = value; + } else { + delete process.env[key]; + } + } await fsp.rm(codexDir, { recursive: true, force: true }); }); @@ -805,6 +808,70 @@ test("syncExternalAuthSnapshotIfNeeded refreshes active same-identity snapshot a }); }); +test("syncExternalAuthSnapshotIfNeeded recreates a missing snapshot even when auth fingerprint is unchanged", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir, authPath }) => { + const service = new AccountService(); + const email = "moncsi@gitguardex.com"; + process.env.CODEX_AUTH_SESSION_KEY = "terminal-moncsi"; + process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1"; + + await fsp.writeFile( + authPath, + buildAuthPayload(email, { + accountId: "acct-moncsi", + userId: "user-moncsi", + }), + "utf8", + ); + + const firstSync = await service.syncExternalAuthSnapshotIfNeeded(); + assert.equal(firstSync.savedName, email); + + const snapshotPath = path.join(accountsDir, `${email}.json`); + await fsp.rm(snapshotPath, { force: true }); + + const secondSync = await service.syncExternalAuthSnapshotIfNeeded(); + assert.deepEqual(secondSync, { + synchronized: true, + savedName: email, + autoSwitchDisabled: false, + }); + + const restored = await parseAuthSnapshotFile(snapshotPath); + assert.equal(restored.email, email); + }); +}); + +test("useAccount resolves an exact email to its saved duplicate snapshot", async (t) => { + await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => { + const service = new AccountService(); + const email = "moncsi@gitguardex.com"; + const duplicateName = `${email}--dup-2`; + + await fsp.writeFile( + path.join(accountsDir, `${duplicateName}.json`), + buildAuthPayload(email, { + accountId: "acct-moncsi", + userId: "user-moncsi", + }), + "utf8", + ); + await fsp.writeFile( + authPath, + buildAuthPayload(email, { + accountId: "acct-moncsi", + userId: "user-moncsi", + }), + "utf8", + ); + + const activated = await service.useAccount(email); + + assert.equal(activated, duplicateName); + assert.equal((await fsp.readFile(path.join(codexDir, "current"), "utf8")).trim(), duplicateName); + }); +}); + test("syncExternalAuthSnapshotIfNeeded reuses a saved alias that matches relogin identity", async (t) => { await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => { const service = new AccountService();