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
65 changes: 60 additions & 5 deletions src/lib/accounts/account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -429,14 +430,15 @@ export class AccountService {

public async useAccount(rawName: string): Promise<string> {
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<RemoveResult> {
Expand Down Expand Up @@ -1182,6 +1184,59 @@ export class AccountService {
});
}

private async resolveUsableAccountName(accountName: string): Promise<string> {
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<string[]> {
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<boolean> {
const sessionAccountName = await this.getSessionAccountName();
if (!sessionAccountName) {
return true;
}
return this.pathExists(this.accountFilePath(sessionAccountName));
}

private async clearActivePointers(): Promise<void> {
const currentPath = resolveCurrentNamePath();
const authPath = resolveAuthPath();
Expand Down
77 changes: 72 additions & 5 deletions src/tests/save-account-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -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 });
});

Expand Down Expand Up @@ -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();
Expand Down