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();