Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/lib/accounts/account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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 };
Expand All @@ -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),
Expand Down Expand Up @@ -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, ""))
Expand Down
81 changes: 78 additions & 3 deletions src/tests/save-account-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
});
});
});
Expand Down Expand Up @@ -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();
Expand Down