From 697b0c703b5f6c63471987429376eb007ecd847e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 13 May 2026 20:30:01 +0200 Subject: [PATCH] fix(accounts): preserve saved snapshots on codex login from a fresh terminal restoreSessionSnapshotIfNeeded materialized the auth symlink only after the session-pin lookups. In a fresh terminal with no pinned session, it early-returned without materializing, leaving ~/.codex/auth.json as a symlink into accounts/.json from older installs. The shell hook then ran `codex login`, which wrote through the symlink and overwrote the previously-active snapshot in place; syncExternalAuthSnapshotIfNeeded afterward saw the corrupted file matched the incoming identity and renamed it, wiping the previous account. Move the materialize step above all early returns so the symlink is flattened before `codex login` ever sees auth.json. Also exclude update-check.json (the npm version cache) from listAccountNames so it stops appearing as a phantom account in `codex-auth list`. Realign the email-shaped-duplicate test with the refresh-by-email behavior introduced in #23: same-email re-login now refreshes the canonical snapshot in place with forceOverwrite=true, instead of spawning a --dup-2 entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/accounts/account-service.ts | 13 ++++- src/tests/save-account-safety.test.ts | 81 ++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/lib/accounts/account-service.ts b/src/lib/accounts/account-service.ts index f0bbece..7a1a002 100644 --- a/src/lib/accounts/account-service.ts +++ b/src/lib/accounts/account-service.ts @@ -195,6 +195,16 @@ export class AccountService { } public async restoreSessionSnapshotIfNeeded(): Promise<{ restored: boolean; accountName?: string }> { + // Materialize the auth symlink up front, before any early returns. Older + // installations (and stray `ln -s` setups) can leave ~/.codex/auth.json as + // a symlink into accounts/.json; if the upcoming `codex login` writes + // through that symlink, it overwrites the saved snapshot for the previous + // account and we lose it. + const authPath = resolveAuthPath(); + if (await this.pathExists(authPath)) { + await this.materializeAuthSymlink(authPath); + } + const sessionAccountName = await this.getActiveSessionAccountName(); if (!sessionAccountName) { return { restored: false }; @@ -206,9 +216,7 @@ export class AccountService { return { restored: false }; } - const authPath = resolveAuthPath(); if (await this.pathExists(authPath)) { - await this.materializeAuthSymlink(authPath); const [sessionSnapshot, activeSnapshot] = await Promise.all([ parseAuthSnapshotFile(snapshotPath), parseAuthSnapshotFile(authPath), @@ -247,6 +255,7 @@ export class AccountService { entry.isFile() && entry.name.endsWith(".json") && entry.name !== "registry.json" && + entry.name !== "update-check.json" && entry.name !== sessionMapBasename, ) .map((entry) => entry.name.replace(/\.json$/i, "")) diff --git a/src/tests/save-account-safety.test.ts b/src/tests/save-account-safety.test.ts index 82d1fa1..f7ba3e6 100644 --- a/src/tests/save-account-safety.test.ts +++ b/src/tests/save-account-safety.test.ts @@ -223,7 +223,7 @@ test("inferAccountNameFromCurrentAuth returns email-shaped duplicate suffix for }); }); -test("resolveLoginAccountNameFromCurrentAuth creates an email-shaped duplicate when canonical email snapshot identity differs", async (t) => { +test("resolveLoginAccountNameFromCurrentAuth refreshes the canonical email snapshot when only accountId differs", async (t) => { await withIsolatedCodexDir(t, async ({ accountsDir, authPath }) => { const service = new AccountService(); const email = "csoves@edixai.com"; @@ -247,8 +247,9 @@ test("resolveLoginAccountNameFromCurrentAuth creates an email-shaped duplicate w const resolved = await service.resolveLoginAccountNameFromCurrentAuth(); assert.deepEqual(resolved, { - name: `${email}--dup-2`, - source: "inferred", + name: email, + source: "existing", + forceOverwrite: true, }); }); }); @@ -1143,6 +1144,80 @@ test("restoreSessionSnapshotIfNeeded materializes matching auth symlink before c }); }); +test("restoreSessionSnapshotIfNeeded materializes the auth symlink even when no session is pinned", 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 activeName = "kreta@lebenyse.hu"; + const snapshotPath = path.join(accountsDir, `${activeName}.json`); + const currentPath = path.join(codexDir, "current"); + + process.env.CODEX_AUTH_SESSION_KEY = "restore-no-session-pin"; + process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1"; + + await fsp.writeFile( + snapshotPath, + buildAuthPayload(activeName, { + accountId: "acct-kreta", + userId: "user-kreta", + tokenSeed: "kreta-original", + }), + "utf8", + ); + await fsp.writeFile(currentPath, `${activeName}\n`, "utf8"); + await fsp.symlink(snapshotPath, authPath); + + const restored = await service.restoreSessionSnapshotIfNeeded(); + assert.deepEqual(restored, { restored: false }); + + const authStat = await fsp.lstat(authPath); + assert.equal(authStat.isSymbolicLink(), false); + + // Simulate `codex login` overwriting auth.json with a different account. + await fsp.writeFile( + authPath, + buildAuthPayload("admin@kollarrobert.sk", { + accountId: "acct-admin", + userId: "user-admin", + tokenSeed: "admin-login", + }), + "utf8", + ); + + const previousSnapshot = await parseAuthSnapshotFile(snapshotPath); + assert.equal(previousSnapshot.email, activeName); + assert.equal(previousSnapshot.accountId, "acct-kreta"); + }); +}); + +test("listAccountNames excludes the update-check cache file", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir }) => { + const service = new AccountService(); + await fsp.writeFile( + path.join(accountsDir, "alice@example.com.json"), + buildAuthPayload("alice@example.com"), + "utf8", + ); + await fsp.writeFile( + path.join(accountsDir, "update-check.json"), + JSON.stringify({ + version: 1, + packageName: "@imdeadpool/codex-account-switcher", + latestVersion: "0.1.23", + checkedAt: 1778696332060, + }), + "utf8", + ); + + const names = await service.listAccountNames(); + assert.deepEqual(names, ["alice@example.com"]); + }); +}); + test("useAccount writes auth.json as a regular file (never symlink)", async (t) => { await withIsolatedCodexDir(t, async ({ accountsDir }) => { const service = new AccountService();