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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Speed up account switching and relogin snapshot sync

## Why

`codex-auth use <account>` should switch accounts immediately, without spending the command budget on external-login reconciliation that is handled by the login hook. When a user relogs in through the official `codex login` flow, the saved snapshot for that Codex account should be refreshed with the new auth bytes instead of returning early or creating an avoidable duplicate.

## What changes

- Make direct account switching use the fast path.
- Preserve and refresh matching saved snapshot names, including aliases, during official relogin sync.
- Update command copy for reused saved account names.
- Add regression coverage for same-identity token refresh, alias preservation, and session fingerprint updates.

## Verification

- `npm test --silent`
- `openspec validate agent-codex-speed-up-codex-account-switch-relogin-sn-2026-04-28-17-46 --strict`
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# snapshot-sync Spec Delta

## ADDED Requirements

### Requirement: Fast direct account switch

`codex-auth use <account>` SHALL switch `~/.codex/auth.json` to the selected saved snapshot without running the external-login sync preflight.

#### Scenario: Switching a saved account

- **WHEN** the user runs `codex-auth use team-primary`
- **THEN** the command copies `accounts/team-primary.json` into `auth.json`
- **AND** updates the active account pointers for the current terminal session
- **AND** does not run the external-login sync preflight before the copy

### Requirement: Relogin refreshes matching saved snapshot

External Codex login sync SHALL refresh the saved snapshot that matches the newly written `auth.json` by identity or email, preferring the active session snapshot and preserving alias names.

#### Scenario: Same account relogin refreshes tokens

- **GIVEN** `team-primary` is the active saved snapshot
- **AND** `auth.json` is rewritten by `codex login` for the same Codex identity with new tokens
- **WHEN** external sync runs
- **THEN** `accounts/team-primary.json` is overwritten with the new auth bytes
- **AND** the active account remains `team-primary`

#### Scenario: New account relogin is added

- **GIVEN** no saved snapshot matches the newly written `auth.json`
- **WHEN** external sync runs
- **THEN** a new snapshot name is inferred from the Codex auth email
- **AND** that snapshot becomes the active account
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Tasks

## 1. Spec

- [x] Define account-switch fast path and relogin snapshot refresh behavior.

## 2. Tests

- [x] Add regression tests for same-account relogin token refresh.
- [x] Add regression tests for alias-preserving relogin sync.
- [x] Add regression tests for session fingerprint refresh after `useAccount`.

## 3. Implementation

- [x] Make `codex-auth use` skip pre-run external sync.
- [x] Update `useAccount` to avoid full registry reconciliation for the switch hot path.
- [x] Make external relogin sync refresh matching saved snapshots, including aliases.

## 4. Verification

- [x] Run `npm test --silent`.
- [x] Run `openspec validate agent-codex-speed-up-codex-account-switch-relogin-sn-2026-04-28-17-46 --strict`.

## 5. Cleanup

- [ ] Commit, push, create/update PR, wait for `MERGED`, and prune sandbox with `gx branch finish --branch agent/codex/speed-up-codex-account-switch-relogin-sn-2026-04-28-17-46 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] Record PR URL and final `MERGED` evidence.
2 changes: 2 additions & 0 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export default class LoginCommand extends BaseCommand {
? ""
: resolvedName.source === "active"
? " (reused active account name)"
: resolvedName.source === "existing"
? " (reused saved account name)"
: " (inferred from auth email)";
this.log(`Saved current Codex auth tokens as "${savedName}"${suffix}.`);
});
Expand Down
2 changes: 2 additions & 0 deletions src/commands/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export default class SaveCommand extends BaseCommand {
? ""
: resolvedName.source === "active"
? " (reused active account name)"
: resolvedName.source === "existing"
? " (reused saved account name)"
: " (inferred from auth email)";
this.log(`Saved current Codex auth tokens as "${savedName}"${suffix}.`);
});
Expand Down
2 changes: 2 additions & 0 deletions src/commands/use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { BaseCommand } from "../lib/base-command";
import { NoAccountsSavedError, PromptCancelledError } from "../lib/accounts";

export default class UseCommand extends BaseCommand {
protected readonly syncExternalAuthBeforeRun = false;

static description = "Switch ~/.codex/auth.json to the selected account";

static args = {
Expand Down
215 changes: 122 additions & 93 deletions src/lib/accounts/account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,17 @@ export interface SaveAccountOptions {
force?: boolean;
}

type ResolvedAccountNameSource = "active" | "existing" | "inferred";

export interface ResolvedDefaultAccountName {
name: string;
source: "active" | "inferred";
source: ResolvedAccountNameSource;
forceOverwrite?: boolean;
}

export interface ResolvedLoginAccountName {
name: string;
source: "active" | "inferred";
source: ResolvedAccountNameSource;
forceOverwrite?: boolean;
}

Expand Down Expand Up @@ -158,33 +160,23 @@ export class AccountService {
}
}

const resolvedName = await this.resolveLoginAccountNameFromCurrentAuth();
const activeName = await this.getCurrentAccountName();
if (activeName) {
const activeSnapshotPath = this.accountFilePath(activeName);
if (await this.pathExists(activeSnapshotPath)) {
const activeSnapshot = await parseAuthSnapshotFile(activeSnapshotPath);
if (this.snapshotsShareIdentity(activeSnapshot, incomingSnapshot)) {
if (activeName === resolvedName.name) {
return rememberAuthState({
synchronized: false,
autoSwitchDisabled: false,
});
}

const authMatchesActiveSnapshot = await this.filesMatch(authPath, activeSnapshotPath);
if (authMatchesActiveSnapshot) {
return rememberAuthState({
synchronized: false,
autoSwitchDisabled: false,
});
}
}
}
const resolvedName = await this.resolveLoginAccountNameForSnapshot(incomingSnapshot, activeName);
const resolvedSnapshotPath = this.accountFilePath(resolvedName.name);
if (
activeName === resolvedName.name &&
(await this.pathExists(resolvedSnapshotPath)) &&
(await this.filesMatch(authPath, resolvedSnapshotPath))
) {
return rememberAuthState({
synchronized: false,
autoSwitchDisabled: false,
});
}

const status = await this.getStatus();
const autoSwitchDisabled = status.autoSwitchEnabled;
const sameActiveAccountRefresh = activeName === resolvedName.name && resolvedName.source === "active";
const autoSwitchDisabled = status.autoSwitchEnabled && !sameActiveAccountRefresh;
if (autoSwitchDisabled) {
await this.setAutoSwitchEnabled(false);
}
Expand Down Expand Up @@ -416,32 +408,12 @@ export class AccountService {
const authPath = resolveAuthPath();
await this.ensureAuthFileExists(authPath);
const incomingSnapshot = await parseAuthSnapshotFile(authPath);

const activeName = await this.getCurrentAccountName();
if (activeName) {
const activeSnapshotPath = this.accountFilePath(activeName);
if (await this.pathExists(activeSnapshotPath)) {
const activeSnapshot = await parseAuthSnapshotFile(activeSnapshotPath);

if (this.snapshotsShareIdentity(activeSnapshot, incomingSnapshot)) {
return {
name: activeName,
source: "active",
};
}

if (this.canRefreshActiveCanonicalEmailSnapshot(activeName, activeSnapshot, incomingSnapshot)) {
return {
name: activeName,
source: "active",
forceOverwrite: true,
};
}
}
}
const existing = await this.resolveExistingAccountNameForIncomingSnapshot(incomingSnapshot, activeName);
if (existing) return existing;

return {
name: await this.inferAccountNameFromCurrentAuth(),
name: await this.inferAccountNameFromSnapshot(incomingSnapshot),
source: "inferred",
};
}
Expand All @@ -451,41 +423,17 @@ export class AccountService {
await this.ensureAuthFileExists(authPath);
const incomingSnapshot = await parseAuthSnapshotFile(authPath);
const activeName = await this.getCurrentAccountName();

if (activeName) {
const activeSnapshotPath = this.accountFilePath(activeName);
if (await this.pathExists(activeSnapshotPath)) {
const activeSnapshot = await parseAuthSnapshotFile(activeSnapshotPath);

if (this.canRefreshActiveCanonicalEmailSnapshot(activeName, activeSnapshot, incomingSnapshot)) {
return this.snapshotsShareIdentity(activeSnapshot, incomingSnapshot)
? {
name: activeName,
source: "active",
}
: {
name: activeName,
source: "active",
forceOverwrite: true,
};
}
}
}

return {
name: await this.inferAccountNameFromCurrentAuth(),
source: "inferred",
};
return this.resolveLoginAccountNameForSnapshot(incomingSnapshot, activeName);
}

public async useAccount(rawName: string): Promise<string> {
const name = this.normalizeAccountName(rawName);
await this.activateSnapshot(name);

const registry = await this.loadReconciledRegistry();
await this.hydrateSnapshotMetadata(registry, name);
const registry = await loadRegistry();
await this.hydrateSnapshotMetadataIfMissing(registry, name);
registry.activeAccountName = name;
await this.persistRegistry(registry);
await saveRegistry(registry);

return name;
}
Expand Down Expand Up @@ -1035,6 +983,89 @@ export class AccountService {
registry.accounts[accountName] = entry;
}

private async hydrateSnapshotMetadataIfMissing(registry: RegistryData, accountName: string): Promise<void> {
const entry = registry.accounts[accountName];
if (entry?.email && entry.accountId && entry.userId && entry.planType) {
return;
}

await this.hydrateSnapshotMetadata(registry, accountName);
}

private async resolveLoginAccountNameForSnapshot(
incomingSnapshot: ParsedAuthSnapshot,
activeName: string | null,
): Promise<ResolvedLoginAccountName> {
const existing = await this.resolveExistingAccountNameForIncomingSnapshot(incomingSnapshot, activeName);
if (existing) return existing;

return {
name: await this.inferAccountNameFromSnapshot(incomingSnapshot),
source: "inferred",
};
}

private async resolveExistingAccountNameForIncomingSnapshot(
incomingSnapshot: ParsedAuthSnapshot,
activeName: string | null,
): Promise<ResolvedDefaultAccountName | null> {
let emailMatch: ResolvedDefaultAccountName | null = null;
const accountNames = await this.listAccountNames();
const candidates = this.orderReloginSnapshotCandidates(accountNames, incomingSnapshot, activeName);

for (const name of candidates) {
const snapshotPath = this.accountFilePath(name);
if (!(await this.pathExists(snapshotPath))) continue;

const existingSnapshot = await parseAuthSnapshotFile(snapshotPath);
if (this.snapshotsShareIdentity(existingSnapshot, incomingSnapshot)) {
return {
name,
source: activeName === name ? "active" : "existing",
};
}

if (!emailMatch && activeName === name && this.snapshotsShareEmail(existingSnapshot, incomingSnapshot)) {
emailMatch = {
name,
source: "active",
forceOverwrite: true,
};
}
}

return emailMatch;
}

private orderReloginSnapshotCandidates(
accountNames: string[],
incomingSnapshot: ParsedAuthSnapshot,
activeName: string | null,
): string[] {
const ordered: string[] = [];
const add = (name: string | null | undefined): void => {
if (!name || !accountNames.includes(name) || ordered.includes(name)) return;
ordered.push(name);
};

add(activeName);

const incomingEmail = incomingSnapshot.email?.trim().toLowerCase();
if (incomingEmail) {
try {
add(this.normalizeAccountName(incomingEmail));
} catch {
// Invalid email-shaped snapshot names fall through to identity scan.
}
}

for (const name of accountNames) {
add(name);
}

return ordered;
}

private async resolveUniqueInferredName(
baseName: string,
incomingSnapshot: ParsedAuthSnapshot,
Expand Down Expand Up @@ -1067,6 +1098,16 @@ export class AccountService {
throw new AccountNameInferenceError();
}

private async inferAccountNameFromSnapshot(incomingSnapshot: ParsedAuthSnapshot): Promise<string> {
const email = incomingSnapshot.email?.trim().toLowerCase();
if (!email || !email.includes("@")) {
throw new AccountNameInferenceError();
}

const baseCandidate = this.normalizeAccountName(email);
return this.resolveUniqueInferredName(baseCandidate, incomingSnapshot);
}

private async loadReconciledRegistry(): Promise<RegistryData> {
const accountNames = await this.listAccountNames();
const loaded = await loadRegistry();
Expand All @@ -1092,6 +1133,7 @@ export class AccountService {
await fsp.copyFile(source, authPath);

await this.writeCurrentName(name);
await this.rememberSessionAuthFingerprint(authPath);
}

private async clearActivePointers(): Promise<void> {
Expand Down Expand Up @@ -1351,23 +1393,10 @@ export class AccountService {
return false;
}

private canRefreshActiveCanonicalEmailSnapshot(
activeName: string,
activeSnapshot: ParsedAuthSnapshot,
incomingSnapshot: ParsedAuthSnapshot,
): boolean {
const activeEmail = activeSnapshot.email?.trim().toLowerCase();
const incomingEmail = incomingSnapshot.email?.trim().toLowerCase();

if (!activeEmail || !incomingEmail || activeEmail !== incomingEmail) {
return false;
}

try {
return activeName === this.normalizeAccountName(incomingEmail);
} catch {
return false;
}
private snapshotsShareEmail(a: ParsedAuthSnapshot, b: ParsedAuthSnapshot): boolean {
const aEmail = a.email?.trim().toLowerCase();
const bEmail = b.email?.trim().toLowerCase();
return Boolean(aEmail && bEmail && aEmail === bEmail);
}

private renderSnapshotIdentity(snapshot: ParsedAuthSnapshot, fallbackEmail: string): string {
Expand Down
Loading