From 19e917bdfc165452c4b5a27861c405f1f02eae76 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 11:47:12 +0200 Subject: [PATCH] Preserve snapshots before Codex login rewrites auth The login hook restores the pinned session before running Codex. When auth.json was still a symlink to the saved snapshot and the pinned snapshot already matched, restore skipped activation and left the symlink in place. Official codex login could then write through the symlink and replace the saved admin snapshot with the newly logged-in account. Materialize auth.json during restore before the identity-match early return so Codex writes only to the working auth file. The regression keeps the snapshot symlink case and simulates an official login writing Odin credentials after restore. Constraint: Existing installations may still have symlinked auth.json from older versions. Rejected: Only rely on syncExternalAuthSnapshotIfNeeded after Codex exits | the snapshot is already overwritten by then. Confidence: high Scope-risk: narrow Directive: Restore-session must keep auth.json as a regular file before launching Codex. Tested: npm test Tested: npm test -- --test-name-pattern 'restoreSessionSnapshotIfNeeded materializes matching auth symlink' Tested: openspec validate --specs --- src/lib/accounts/account-service.ts | 1 + src/tests/save-account-safety.test.ts | 70 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/lib/accounts/account-service.ts b/src/lib/accounts/account-service.ts index 591ad3c..a88c740 100644 --- a/src/lib/accounts/account-service.ts +++ b/src/lib/accounts/account-service.ts @@ -208,6 +208,7 @@ export class AccountService { const authPath = resolveAuthPath(); if (await this.pathExists(authPath)) { + await this.materializeAuthSymlink(authPath); const [sessionSnapshot, activeSnapshot] = await Promise.all([ parseAuthSnapshotFile(snapshotPath), parseAuthSnapshotFile(authPath), diff --git a/src/tests/save-account-safety.test.ts b/src/tests/save-account-safety.test.ts index 47a852b..82d1fa1 100644 --- a/src/tests/save-account-safety.test.ts +++ b/src/tests/save-account-safety.test.ts @@ -1073,6 +1073,76 @@ test("syncExternalAuthSnapshotIfNeeded materializes auth symlink so external cod }); }); +test("restoreSessionSnapshotIfNeeded materializes matching auth symlink before codex can overwrite the snapshot", 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 = "admin@megkapja.hu"; + const snapshotPath = path.join(accountsDir, `${activeName}.json`); + const currentPath = path.join(codexDir, "current"); + const sessionMapPath = path.join(accountsDir, "sessions.json"); + const sessionKey = `ppid:${process.ppid}`; + + process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1"; + + await fsp.writeFile( + snapshotPath, + buildAuthPayload(activeName, { + accountId: "acct-admin", + userId: "user-admin", + tokenSeed: "admin-snapshot", + }), + "utf8", + ); + await fsp.writeFile(currentPath, `${activeName}\n`, "utf8"); + await fsp.writeFile( + sessionMapPath, + `${JSON.stringify( + { + version: 1, + sessions: { + [sessionKey]: { + accountName: activeName, + updatedAt: new Date().toISOString(), + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await fsp.symlink(snapshotPath, authPath); + + const restored = await service.restoreSessionSnapshotIfNeeded(); + assert.deepEqual(restored, { + restored: false, + accountName: activeName, + }); + + const authStat = await fsp.lstat(authPath); + assert.equal(authStat.isSymbolicLink(), false); + + await fsp.writeFile( + authPath, + buildAuthPayload("odin@megkapja.hu", { + accountId: "acct-odin", + userId: "user-odin", + tokenSeed: "official-login", + }), + "utf8", + ); + + const snapshotAfterLogin = await parseAuthSnapshotFile(snapshotPath); + assert.equal(snapshotAfterLogin.email, activeName); + assert.equal(snapshotAfterLogin.accountId, "acct-admin"); + }); +}); + test("useAccount writes auth.json as a regular file (never symlink)", async (t) => { await withIsolatedCodexDir(t, async ({ accountsDir }) => { const service = new AccountService();