diff --git a/src/lib/accounts/_internal/auth-state.ts b/src/lib/accounts/_internal/auth-state.ts new file mode 100644 index 0000000..1860b25 --- /dev/null +++ b/src/lib/accounts/_internal/auth-state.ts @@ -0,0 +1,70 @@ +// Internal: auth.json read/state helpers — symlink materialization, +// ensure-exists guard, current-name file I/O. Extracted from the +// monolithic AccountService (Theme N2). + +import path from "node:path"; +import fsp from "node:fs/promises"; +import { + resolveAuthPath, + resolveCurrentNamePath, +} from "../../config/paths"; +import { + ensureSecureDir, + secureWriteFile, +} from "../../io/secure-fs"; +import { AuthFileMissingError } from "../errors"; +import { + setSessionAccountName, +} from "../session/pin"; +import { pathExists, removeIfExists } from "./fs-helpers"; + +export async function ensureAuthFileExists(authPath: string): Promise { + if (!(await pathExists(authPath))) { + throw new AuthFileMissingError(authPath); + } +} + +export async function materializeAuthSymlink(authPath: string): Promise { + const stat = await fsp.lstat(authPath); + if (!stat.isSymbolicLink()) { + return; + } + + const snapshotData = await fsp.readFile(authPath); + await removeIfExists(authPath); + await secureWriteFile(authPath, snapshotData); +} + +export async function writeCurrentName( + name: string, + options?: { authFingerprint?: string }, +): Promise { + const currentNamePath = resolveCurrentNamePath(); + await ensureSecureDir(path.dirname(currentNamePath)); + await secureWriteFile(currentNamePath, `${name}\n`); + await setSessionAccountName(name, options?.authFingerprint); +} + +export async function readCurrentNameFile(currentNamePath: string): Promise { + try { + const contents = await fsp.readFile(currentNamePath, "utf8"); + const trimmed = contents.trim(); + return trimmed.length ? trimmed : null; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + return null; + } + throw error; + } +} + +export async function clearActivePointers( + clearSessionAccountName: () => Promise, +): Promise { + const currentPath = resolveCurrentNamePath(); + const authPath = resolveAuthPath(); + await removeIfExists(currentPath); + await removeIfExists(authPath); + await clearSessionAccountName(); +} diff --git a/src/lib/accounts/_internal/fs-helpers.ts b/src/lib/accounts/_internal/fs-helpers.ts new file mode 100644 index 0000000..ed0c39d --- /dev/null +++ b/src/lib/accounts/_internal/fs-helpers.ts @@ -0,0 +1,64 @@ +// Internal filesystem helpers shared across the extracted clusters. +// Not part of the public API — only modules under `src/lib/accounts/` +// should import from here. + +import fs from "node:fs"; +import fsp from "node:fs/promises"; + +export async function pathExists(targetPath: string): Promise { + try { + await fsp.access(targetPath, fs.constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function filesMatch(firstPath: string, secondPath: string): Promise { + try { + const [first, second] = await Promise.all([ + fsp.readFile(firstPath), + fsp.readFile(secondPath), + ]); + return first.equals(second); + } catch { + return false; + } +} + +export async function removeIfExists(target: string): Promise { + try { + await fsp.rm(target, { force: true }); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "ENOENT") { + throw error; + } + } +} + +export interface AuthSyncState { + fingerprint: string; + isSymbolicLink: boolean; +} + +export async function readAuthSyncState(authPath: string): Promise { + try { + const stat = await fsp.lstat(authPath); + return { + fingerprint: createAuthSyncFingerprint(stat), + isSymbolicLink: stat.isSymbolicLink(), + }; + } catch { + return null; + } +} + +export function createAuthSyncFingerprint(stat: fs.Stats): string { + return [ + stat.isSymbolicLink() ? "symlink" : "file", + typeof stat.ino === "number" ? Math.trunc(stat.ino) : 0, + Math.trunc(stat.size), + Math.trunc(stat.mtimeMs), + ].join(":"); +} diff --git a/src/lib/accounts/_internal/name-resolution.ts b/src/lib/accounts/_internal/name-resolution.ts new file mode 100644 index 0000000..3b3f2bb --- /dev/null +++ b/src/lib/accounts/_internal/name-resolution.ts @@ -0,0 +1,196 @@ +// Internal: resolve the snapshot file name for a given live auth snapshot. +// Used by save/use/external-sync — kept under _internal/ because the +// public surface only exposes the orchestrator wrappers. + +import { + AccountNameInferenceError, +} from "../errors"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { loadRegistry } from "../registry"; +import { ParsedAuthSnapshot } from "../types"; +import { listAccountNames } from "../read/listing"; +import { accountFilePath, normalizeAccountName } from "../naming"; +import { pathExists } from "./fs-helpers"; +import { + registryEntrySharesEmail, + registryEntrySharesIdentity, + snapshotsShareEmail, + snapshotsShareIdentity, +} from "../identity/equality"; + +export type ResolvedAccountNameSource = "active" | "existing" | "inferred"; + +export interface ResolvedDefaultAccountName { + name: string; + source: ResolvedAccountNameSource; + forceOverwrite?: boolean; +} + +export interface ResolvedLoginAccountName { + name: string; + source: ResolvedAccountNameSource; + forceOverwrite?: boolean; +} + +function 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(normalizeAccountName(incomingEmail)); + } catch { + // Invalid email-shaped snapshot names fall through to identity scan. + } + } + + for (const name of accountNames) { + add(name); + } + + return ordered; +} + +async function resolveRegistryAccountNameForIncomingSnapshot( + incomingSnapshot: ParsedAuthSnapshot, + candidates: string[], + activeName: string | null, +): Promise { + const registry = await loadRegistry(); + let activeEmailMatch: ResolvedDefaultAccountName | null = null; + + for (const name of candidates) { + const entry = registry.accounts[name]; + if (!entry || !(await pathExists(accountFilePath(name)))) continue; + + if (registryEntrySharesIdentity(entry, incomingSnapshot)) { + return { + name, + source: activeName === name ? "active" : "existing", + }; + } + + if (!activeEmailMatch && registryEntrySharesEmail(entry, incomingSnapshot)) { + activeEmailMatch = { + name, + source: activeName === name ? "active" : "existing", + forceOverwrite: true, + }; + } + } + + return activeEmailMatch; +} + +export async function resolveExistingAccountNameForIncomingSnapshot( + incomingSnapshot: ParsedAuthSnapshot, + activeName: string | null, +): Promise { + let emailMatch: ResolvedDefaultAccountName | null = null; + const accountNames = await listAccountNames(); + const candidates = orderReloginSnapshotCandidates( + accountNames, + incomingSnapshot, + activeName, + ); + const registryMatch = await resolveRegistryAccountNameForIncomingSnapshot( + incomingSnapshot, + candidates, + activeName, + ); + if (registryMatch) { + return registryMatch; + } + + for (const name of candidates) { + const snapshotPath = accountFilePath(name); + if (!(await pathExists(snapshotPath))) continue; + + const existingSnapshot = await parseAuthSnapshotFile(snapshotPath); + if (snapshotsShareIdentity(existingSnapshot, incomingSnapshot)) { + return { + name, + source: activeName === name ? "active" : "existing", + }; + } + + if (!emailMatch && snapshotsShareEmail(existingSnapshot, incomingSnapshot)) { + emailMatch = { + name, + source: activeName === name ? "active" : "existing", + forceOverwrite: true, + }; + } + } + + return emailMatch; +} + +export async function resolveUniqueInferredName( + baseName: string, + incomingSnapshot: ParsedAuthSnapshot, +): Promise { + const hasMatchingIdentity = async (name: string): Promise => { + const parsed = await parseAuthSnapshotFile(accountFilePath(name)); + return snapshotsShareIdentity(parsed, incomingSnapshot); + }; + + const basePath = accountFilePath(baseName); + if (!(await pathExists(basePath))) { + return baseName; + } + if (await hasMatchingIdentity(baseName)) { + return baseName; + } + + for (let i = 2; i <= 99; i += 1) { + const candidate = normalizeAccountName(`${baseName}--dup-${i}`); + const candidatePath = accountFilePath(candidate); + if (!(await pathExists(candidatePath))) { + return candidate; + } + if (await hasMatchingIdentity(candidate)) { + return candidate; + } + } + + throw new AccountNameInferenceError(); +} + +export async function inferAccountNameFromSnapshot( + incomingSnapshot: ParsedAuthSnapshot, +): Promise { + const email = incomingSnapshot.email?.trim().toLowerCase(); + if (!email || !email.includes("@")) { + throw new AccountNameInferenceError(); + } + + const baseCandidate = normalizeAccountName(email); + return resolveUniqueInferredName(baseCandidate, incomingSnapshot); +} + +export async function resolveLoginAccountNameForSnapshot( + incomingSnapshot: ParsedAuthSnapshot, + activeName: string | null, +): Promise { + const existing = await resolveExistingAccountNameForIncomingSnapshot( + incomingSnapshot, + activeName, + ); + if (existing) return existing; + + return { + name: await inferAccountNameFromSnapshot(incomingSnapshot), + source: "inferred", + }; +} diff --git a/src/lib/accounts/_internal/registry-ops.ts b/src/lib/accounts/_internal/registry-ops.ts new file mode 100644 index 0000000..0263f8c --- /dev/null +++ b/src/lib/accounts/_internal/registry-ops.ts @@ -0,0 +1,50 @@ +// Internal registry helpers shared across write clusters. +// All durable writes funnel through `persistRegistryAtomic` (Theme N1) — +// this module just adds the reconcile-by-account-name step on top. + +import { + persistRegistryAtomic, + reconcileRegistryWithAccounts, +} from "../registry"; +import { RegistryData } from "../types"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { accountFilePath } from "../naming"; +import { listAccountNames } from "../read/listing"; + +export async function persistRegistry(registry: RegistryData): Promise { + const reconciled = reconcileRegistryWithAccounts( + registry, + await listAccountNames(), + ); + await persistRegistryAtomic(reconciled); +} + +export async function hydrateSnapshotMetadata( + registry: RegistryData, + accountName: string, +): Promise { + const parsed = await parseAuthSnapshotFile(accountFilePath(accountName)); + const entry = registry.accounts[accountName] ?? { + name: accountName, + createdAt: new Date().toISOString(), + }; + + if (parsed.email) entry.email = parsed.email; + if (parsed.accountId) entry.accountId = parsed.accountId; + if (parsed.userId) entry.userId = parsed.userId; + if (parsed.planType) entry.planType = parsed.planType; + + registry.accounts[accountName] = entry; +} + +export async function hydrateSnapshotMetadataIfMissing( + registry: RegistryData, + accountName: string, +): Promise { + const entry = registry.accounts[accountName]; + if (entry?.email && entry.accountId && entry.userId && entry.planType) { + return; + } + + await hydrateSnapshotMetadata(registry, accountName); +} diff --git a/src/lib/accounts/account-service.ts b/src/lib/accounts/account-service.ts index 38f4f20..7251f6e 100644 --- a/src/lib/accounts/account-service.ts +++ b/src/lib/accounts/account-service.ts @@ -1,1675 +1,164 @@ -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import path from "node:path"; -import { - resolveAccountsDir, - resolveAuthPath, - resolveCodexDir, - resolveCurrentNamePath, - resolveSessionMapPath, - resolveSnapshotBackupDir, -} from "../config/paths"; -import { - AccountNotFoundError, - AccountNameInferenceError, - AmbiguousAccountQueryError, - AuthFileMissingError, - AutoSwitchConfigError, - InvalidAccountNameError, - SnapshotEmailMismatchError, -} from "./errors"; -import { parseAuthSnapshotFile } from "./auth-parser"; -import { - createDefaultRegistry, - loadRegistry, - persistRegistryAtomic, - reconcileRegistryWithAccounts, -} from "./registry"; +// Thin orchestrator class. The 1,675-LOC god-file is gone; every method +// here delegates to one of the focused modules under `src/lib/accounts/`. +// See `docs/future/01-ARCHITECTURE.md` §2.1 for the cluster layout. +// +// Behavior is byte-identical to the pre-N2 implementation. Public method +// signatures must NOT change — the singleton `accountService` in +// `index.ts` and every `BaseCommand` subclass via `this.accounts` depend +// on them. + import { AccountMapping, AutoSwitchRunResult, - ParsedAuthSnapshot, - RegistryData, StatusReport, - UsageSnapshot, - AccountRegistryEntry, } from "./types"; import { - fetchUsageFromApi, - fetchUsageFromLocal, - fetchUsageFromProxy, - ProxyUsageIndex, - remainingPercent, - resolveRateWindow, - shouldSwitchCurrent, - usageScore, -} from "./usage"; + AccountChoice, + ListAccountMappingsOptions, + findMatchingAccounts as findMatchingAccountsImpl, + getCurrentAccountName as getCurrentAccountNameImpl, + listAccountChoices as listAccountChoicesImpl, + listAccountMappings as listAccountMappingsImpl, + listAccountNames as listAccountNamesImpl, +} from "./read/listing"; import { - disableManagedService, - enableManagedService, - getManagedServiceState, -} from "./service-manager"; + ExternalAuthSyncResult, + restoreSessionSnapshotIfNeeded as restoreSessionSnapshotIfNeededImpl, + syncExternalAuthSnapshotIfNeeded as syncExternalAuthSnapshotIfNeededImpl, +} from "./sync/external-sync"; import { - chmodSecureDir, - chmodSecureFile, - ensureSecureDir, - secureWriteFile, -} from "../io/secure-fs"; - -const ACCOUNT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@+-]*$/; -const EXTERNAL_SYNC_FORCE_ENV = "CODEX_AUTH_FORCE_EXTERNAL_SYNC"; -const SESSION_KEY_ENV = "CODEX_AUTH_SESSION_KEY"; -const SESSION_ACTIVE_OVERRIDE_ENV = "CODEX_AUTH_SESSION_ACTIVE_OVERRIDE"; -const LIST_USAGE_REFRESH_CONCURRENCY = 6; - -interface SessionMapEntry { - accountName: string; - authFingerprint?: string; - updatedAt: string; -} - -interface SessionMapData { - version: 1; - sessions: Record; -} - -export interface AccountChoice { - name: string; - email?: string; - active: boolean; -} - -export interface ListAccountMappingsOptions { - refreshUsage?: "never" | "missing" | "always"; -} - -export interface RemoveResult { - removed: string[]; - activated?: string; -} - -export interface SaveAccountOptions { - force?: boolean; -} - -type ResolvedAccountNameSource = "active" | "existing" | "inferred"; - -export interface ResolvedDefaultAccountName { - name: string; - source: ResolvedAccountNameSource; - forceOverwrite?: boolean; -} - -export interface ResolvedLoginAccountName { - name: string; - source: ResolvedAccountNameSource; - forceOverwrite?: boolean; -} - -export interface ExternalAuthSyncResult { - synchronized: boolean; - savedName?: string; - autoSwitchDisabled: boolean; -} + SaveAccountOptions, + inferAccountNameFromCurrentAuth as inferAccountNameFromCurrentAuthImpl, + resolveDefaultAccountNameFromCurrentAuth as resolveDefaultAccountNameFromCurrentAuthImpl, + resolveLoginAccountNameFromCurrentAuth as resolveLoginAccountNameFromCurrentAuthImpl, + saveAccount as saveAccountImpl, +} from "./write/save"; +import { + useAccount as useAccountImpl, +} from "./write/use"; +import { + RemoveResult, + removeAccounts as removeAccountsImpl, + removeAllAccounts as removeAllAccountsImpl, + removeByQuery as removeByQueryImpl, +} from "./write/remove"; +import { + configureAutoSwitchThresholds as configureAutoSwitchThresholdsImpl, + getStatus as getStatusImpl, + setApiUsageEnabled as setApiUsageEnabledImpl, + setAutoSwitchEnabled as setAutoSwitchEnabledImpl, +} from "./config/auto-switch-config"; +import { + runAutoSwitchOnce as runAutoSwitchOnceImpl, + runDaemon as runDaemonImpl, +} from "./auto-switch/policy"; +import { refreshListUsageIfNeeded } from "./usage/adapter"; +import { + ResolvedDefaultAccountName, + ResolvedLoginAccountName, +} from "./_internal/name-resolution"; + +export type { + AccountChoice, + ListAccountMappingsOptions, +} from "./read/listing"; +export type { RemoveResult } from "./write/remove"; +export type { SaveAccountOptions } from "./write/save"; +export type { + ResolvedDefaultAccountName, + ResolvedLoginAccountName, +} from "./_internal/name-resolution"; +export type { ExternalAuthSyncResult } from "./sync/external-sync"; export class AccountService { - public async syncExternalAuthSnapshotIfNeeded(): Promise { - const authPath = resolveAuthPath(); - if (!(await this.pathExists(authPath))) { - return { - synchronized: false, - autoSwitchDisabled: false, - }; - } - - const initialAuthState = await this.readAuthSyncState(authPath); - const externalSyncForced = this.isExternalSyncForced(); - if ( - initialAuthState && - !initialAuthState.isSymbolicLink && - !externalSyncForced && - (await this.getSessionAuthFingerprint()) === initialAuthState.fingerprint && - (await this.sessionSnapshotExists()) - ) { - return { - synchronized: false, - autoSwitchDisabled: false, - }; - } - - await this.materializeAuthSymlink(authPath); - const rememberAuthState = async (result: ExternalAuthSyncResult): Promise => { - await this.rememberSessionAuthFingerprint(authPath); - return result; - }; - - // Repair any snapshot file that codex clobbered through a stale symlink - // before we attempt name resolution — otherwise the identity-based scan - // mistakes the clobbered file for a refresh of the previous account. - await this.restoreClobberedSnapshotsFromBackup(); - - const incomingSnapshot = await parseAuthSnapshotFile(authPath); - if (incomingSnapshot.authMode !== "chatgpt") { - return rememberAuthState({ - synchronized: false, - autoSwitchDisabled: false, - }); - } - - const sessionAccountName = await this.getActiveSessionAccountName(); - if (sessionAccountName) { - const sessionSnapshotPath = this.accountFilePath(sessionAccountName); - if (await this.pathExists(sessionSnapshotPath)) { - const sessionSnapshot = await parseAuthSnapshotFile(sessionSnapshotPath); - if ( - sessionSnapshot.authMode === "chatgpt" && - !this.snapshotsShareIdentity(sessionSnapshot, incomingSnapshot) && - !externalSyncForced - ) { - return rememberAuthState({ - synchronized: false, - autoSwitchDisabled: false, - }); - } - } - } - - const activeName = await this.getCurrentAccountName(); - 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 sameActiveAccountRefresh = activeName === resolvedName.name && resolvedName.source === "active"; - const autoSwitchDisabled = status.autoSwitchEnabled && !sameActiveAccountRefresh; - if (autoSwitchDisabled) { - await this.setAutoSwitchEnabled(false); - } - - const savedName = await this.saveAccount(resolvedName.name, { - force: Boolean(resolvedName.forceOverwrite), - }); - - // The backup vault has served its purpose for this codex run. - await this.clearSnapshotBackupVault(); - - return rememberAuthState({ - synchronized: true, - savedName, - autoSwitchDisabled, - }); + public syncExternalAuthSnapshotIfNeeded(): Promise { + return syncExternalAuthSnapshotIfNeededImpl(); } - 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); - } - - // Defensive safety net: snapshot every saved account into a backup vault - // before codex runs. If the materialize step is bypassed (e.g., this - // function isn't invoked because the shell hook is shadowed by another - // codex() function), the next sync after codex exits can still recover - // any snapshot file that got clobbered. - await this.backupAllSnapshots(); - - const sessionAccountName = await this.getActiveSessionAccountName(); - if (!sessionAccountName) { - return { restored: false }; - } - - const snapshotPath = this.accountFilePath(sessionAccountName); - if (!(await this.pathExists(snapshotPath))) { - await this.clearSessionAccountName(); - return { restored: false }; - } - - if (await this.pathExists(authPath)) { - const [sessionSnapshot, activeSnapshot] = await Promise.all([ - parseAuthSnapshotFile(snapshotPath), - parseAuthSnapshotFile(authPath), - ]); - if (this.snapshotsShareIdentity(sessionSnapshot, activeSnapshot)) { - return { - restored: false, - accountName: sessionAccountName, - }; - } - } - - await this.activateSnapshot(sessionAccountName); - return { - restored: true, - accountName: sessionAccountName, - }; + public restoreSessionSnapshotIfNeeded(): Promise<{ restored: boolean; accountName?: string }> { + return restoreSessionSnapshotIfNeededImpl(); } - public async listAccountNames(): Promise { - const accountsDir = resolveAccountsDir(); - if (!(await this.pathExists(accountsDir))) { - return []; - } - - const sessionMapPath = resolveSessionMapPath(); - const sessionMapBasename = - path.dirname(sessionMapPath) === accountsDir - ? path.basename(sessionMapPath) - : undefined; - - const entries = await fsp.readdir(accountsDir, { withFileTypes: true }); - return entries - .filter( - (entry) => - 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, "")) - .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + public listAccountNames(): Promise { + return listAccountNamesImpl(); } - public async listAccountChoices(): Promise { - const [accounts, current, registry] = await Promise.all([ - this.listAccountNames(), - this.getCurrentAccountName(), - this.loadReconciledRegistry(), - ]); - - return accounts.map((name) => ({ - name, - email: registry.accounts[name]?.email, - active: current === name, - })); + public listAccountChoices(): Promise { + return listAccountChoicesImpl(() => this.getCurrentAccountName()); } - public async listAccountMappings(options?: ListAccountMappingsOptions): Promise { - const [accounts, current, registry] = await Promise.all([ - this.listAccountNames(), - this.getCurrentAccountName(), - this.loadReconciledRegistry(), - ]); - const nowSeconds = Math.floor(Date.now() / 1000); - await this.refreshListUsageIfNeeded( - accounts, - current, - registry, - options?.refreshUsage ?? "never", - nowSeconds, - ); - - return Promise.all( - accounts.map(async (name) => { - const entry = registry.accounts[name]; - let fallbackSnapshot: ParsedAuthSnapshot | undefined; - - if (!entry?.email || !entry?.accountId || !entry?.userId || !entry?.planType) { - fallbackSnapshot = await parseAuthSnapshotFile(this.accountFilePath(name)); - } - - const remaining5hPercent = remainingPercent(resolveRateWindow(entry?.lastUsage, 300, true), nowSeconds); - const remainingWeeklyPercent = remainingPercent( - resolveRateWindow(entry?.lastUsage, 10080, false), - nowSeconds, - ); - - return { - name, - active: current === name, - email: entry?.email ?? fallbackSnapshot?.email, - accountId: entry?.accountId ?? fallbackSnapshot?.accountId, - userId: entry?.userId ?? fallbackSnapshot?.userId, - planType: entry?.planType ?? fallbackSnapshot?.planType, - lastUsageAt: entry?.lastUsageAt, - usageSource: entry?.lastUsage?.source, - remaining5hPercent, - remainingWeeklyPercent, - }; - }), + public listAccountMappings(options?: ListAccountMappingsOptions): Promise { + return listAccountMappingsImpl( + () => this.getCurrentAccountName(), + refreshListUsageIfNeeded, + options, ); } - public async findMatchingAccounts(query: string): Promise { - const normalized = query.trim().toLowerCase(); - if (!normalized) return []; - - const choices = await this.listAccountChoices(); - const registry = await this.loadReconciledRegistry(); - return choices.filter((choice) => { - if (choice.name.toLowerCase().includes(normalized)) return true; - if (choice.email && choice.email.toLowerCase().includes(normalized)) return true; - const meta = registry.accounts[choice.name]; - if (meta?.accountId?.toLowerCase().includes(normalized)) return true; - if (meta?.userId?.toLowerCase().includes(normalized)) return true; - return false; - }); + public findMatchingAccounts(query: string): Promise { + return findMatchingAccountsImpl(query, () => this.getCurrentAccountName()); } - public async getCurrentAccountName(): Promise { - const sessionAccountName = await this.getActiveSessionAccountName(); - if (sessionAccountName) { - const sessionSnapshotPath = this.accountFilePath(sessionAccountName); - if (await this.pathExists(sessionSnapshotPath)) { - return sessionAccountName; - } - - await this.clearSessionAccountName(); - } - - const currentNamePath = resolveCurrentNamePath(); - const currentName = await this.readCurrentNameFile(currentNamePath); - if (currentName) { - await this.setSessionAccountName(currentName); - return currentName; - } - - const authPath = resolveAuthPath(); - if (!(await this.pathExists(authPath))) return null; - - const stat = await fsp.lstat(authPath); - if (!stat.isSymbolicLink()) return null; - - const rawTarget = await fsp.readlink(authPath); - const resolvedTarget = path.resolve(path.dirname(authPath), rawTarget); - const accountsRoot = path.resolve(resolveAccountsDir()); - const relative = path.relative(accountsRoot, resolvedTarget); - if (relative.startsWith("..")) return null; - - const base = path.basename(resolvedTarget); - if (!base.endsWith(".json") || base === "registry.json") return null; - const resolvedName = base.replace(/\.json$/i, ""); - await this.setSessionAccountName(resolvedName); - return resolvedName; + public getCurrentAccountName(): Promise { + return getCurrentAccountNameImpl(); } - public async saveAccount(rawName: string, options?: SaveAccountOptions): Promise { - const name = this.normalizeAccountName(rawName); - const authPath = resolveAuthPath(); - const accountsDir = resolveAccountsDir(); - - await this.ensureAuthFileExists(authPath); - await this.ensureDir(accountsDir); - const destination = this.accountFilePath(name); - await this.assertSafeSnapshotOverwrite({ - authPath, - destinationPath: destination, - accountName: name, - force: Boolean(options?.force), - }); - await fsp.copyFile(authPath, destination); - await chmodSecureFile(destination); - await chmodSecureDir(accountsDir); - - await this.writeCurrentName(name); - - const registry = await this.loadReconciledRegistry(); - await this.hydrateSnapshotMetadata(registry, name); - registry.activeAccountName = name; - await this.persistRegistry(registry); - - return name; + public saveAccount(rawName: string, options?: SaveAccountOptions): Promise { + return saveAccountImpl(rawName, options); } - public async inferAccountNameFromCurrentAuth(): Promise { - const authPath = resolveAuthPath(); - await this.ensureAuthFileExists(authPath); - - const parsed = await parseAuthSnapshotFile(authPath); - const email = parsed.email?.trim().toLowerCase(); - if (!email || !email.includes("@")) { - throw new AccountNameInferenceError(); - } - - const baseCandidate = this.normalizeAccountName(email); - const uniqueName = await this.resolveUniqueInferredName(baseCandidate, parsed); - return uniqueName; + public inferAccountNameFromCurrentAuth(): Promise { + return inferAccountNameFromCurrentAuthImpl(); } - public async resolveDefaultAccountNameFromCurrentAuth(): Promise { - const authPath = resolveAuthPath(); - await this.ensureAuthFileExists(authPath); - const incomingSnapshot = await parseAuthSnapshotFile(authPath); - const activeName = await this.getCurrentAccountName(); - const existing = await this.resolveExistingAccountNameForIncomingSnapshot(incomingSnapshot, activeName); - if (existing) return existing; - - return { - name: await this.inferAccountNameFromSnapshot(incomingSnapshot), - source: "inferred", - }; + public resolveDefaultAccountNameFromCurrentAuth(): Promise { + return resolveDefaultAccountNameFromCurrentAuthImpl(() => this.getCurrentAccountName()); } - public async resolveLoginAccountNameFromCurrentAuth(): Promise { - const authPath = resolveAuthPath(); - await this.ensureAuthFileExists(authPath); - const incomingSnapshot = await parseAuthSnapshotFile(authPath); - const activeName = await this.getCurrentAccountName(); - return this.resolveLoginAccountNameForSnapshot(incomingSnapshot, activeName); + public resolveLoginAccountNameFromCurrentAuth(): Promise { + return resolveLoginAccountNameFromCurrentAuthImpl(() => this.getCurrentAccountName()); } - public async useAccount(rawName: string): Promise { - const name = this.normalizeAccountName(rawName); - const resolvedName = await this.resolveUsableAccountName(name); - await this.activateSnapshot(resolvedName); - - const registry = await loadRegistry(); - await this.hydrateSnapshotMetadataIfMissing(registry, resolvedName); - registry.activeAccountName = resolvedName; - await this.persistRegistry(registry); - - return resolvedName; + public useAccount(rawName: string): Promise { + return useAccountImpl(rawName, () => this.syncExternalAuthSnapshotIfNeeded()); } - public async removeAccounts(accountNames: string[]): Promise { - const uniqueNames = [...new Set(accountNames.map((name) => this.normalizeAccountName(name)))]; - if (uniqueNames.length === 0) { - return { removed: [] }; - } - - const current = await this.getCurrentAccountName(); - const registry = await this.loadReconciledRegistry(); - const removed: string[] = []; - - for (const name of uniqueNames) { - const snapshotPath = this.accountFilePath(name); - if (!(await this.pathExists(snapshotPath))) { - throw new AccountNotFoundError(name); - } - - await fsp.rm(snapshotPath, { force: true }); - delete registry.accounts[name]; - removed.push(name); - } - - const removedSet = new Set(removed); - let activated: string | undefined; - - if (current && removedSet.has(current)) { - const remaining = (await this.listAccountNames()).filter((name) => !removedSet.has(name)); - if (remaining.length > 0) { - const best = this.selectBestCandidateFromRegistry(remaining, registry); - await this.activateSnapshot(best); - activated = best; - registry.activeAccountName = best; - } else { - await this.clearActivePointers(); - delete registry.activeAccountName; - } - } else if (registry.activeAccountName && removedSet.has(registry.activeAccountName)) { - delete registry.activeAccountName; - } - - await this.persistRegistry(registry); - return { - removed, - activated, - }; + public removeAccounts(accountNames: string[]): Promise { + return removeAccountsImpl(accountNames, () => this.getCurrentAccountName()); } - public async removeByQuery(query: string): Promise { - const matches = await this.findMatchingAccounts(query); - if (matches.length === 0) { - throw new AccountNotFoundError(query); - } - if (matches.length > 1) { - throw new AmbiguousAccountQueryError(query); - } - - return this.removeAccounts([matches[0].name]); + public removeByQuery(query: string): Promise { + return removeByQueryImpl(query, () => this.getCurrentAccountName()); } - public async removeAllAccounts(): Promise { - const all = await this.listAccountNames(); - return this.removeAccounts(all); + public removeAllAccounts(): Promise { + return removeAllAccountsImpl(() => this.getCurrentAccountName()); } - public async getStatus(): Promise { - const registry = await this.loadReconciledRegistry(); - return { - autoSwitchEnabled: registry.autoSwitch.enabled, - serviceState: getManagedServiceState(), - threshold5hPercent: registry.autoSwitch.threshold5hPercent, - thresholdWeeklyPercent: registry.autoSwitch.thresholdWeeklyPercent, - usageMode: registry.api.usage ? "api" : "local", - }; + public getStatus(): Promise { + return getStatusImpl(); } - public async setAutoSwitchEnabled(enabled: boolean): Promise { - const registry = await this.loadReconciledRegistry(); - registry.autoSwitch.enabled = enabled; - - if (enabled) { - try { - await enableManagedService(); - } catch (error) { - registry.autoSwitch.enabled = false; - await this.persistRegistry(registry); - throw new AutoSwitchConfigError( - `Failed to enable managed auto-switch service: ${(error as Error).message}`, - ); - } - } else { - await disableManagedService(); - } - - await this.persistRegistry(registry); - return this.getStatus(); + public setAutoSwitchEnabled(enabled: boolean): Promise { + return setAutoSwitchEnabledImpl(enabled); } - public async setApiUsageEnabled(enabled: boolean): Promise { - const registry = await this.loadReconciledRegistry(); - registry.api.usage = enabled; - await this.persistRegistry(registry); - return this.getStatus(); + public setApiUsageEnabled(enabled: boolean): Promise { + return setApiUsageEnabledImpl(enabled); } - public async configureAutoSwitchThresholds(input: { + public configureAutoSwitchThresholds(input: { threshold5hPercent?: number; thresholdWeeklyPercent?: number; }): Promise { - const registry = await this.loadReconciledRegistry(); - - if (typeof input.threshold5hPercent === "number") { - if (!this.isValidPercent(input.threshold5hPercent)) { - throw new AutoSwitchConfigError("`--5h` must be an integer from 1 to 100."); - } - registry.autoSwitch.threshold5hPercent = Math.round(input.threshold5hPercent); - } - - if (typeof input.thresholdWeeklyPercent === "number") { - if (!this.isValidPercent(input.thresholdWeeklyPercent)) { - throw new AutoSwitchConfigError("`--weekly` must be an integer from 1 to 100."); - } - registry.autoSwitch.thresholdWeeklyPercent = Math.round(input.thresholdWeeklyPercent); - } - - await this.persistRegistry(registry); - return this.getStatus(); - } - - public async runAutoSwitchOnce(): Promise { - const registry = await this.loadReconciledRegistry(); - if (!registry.autoSwitch.enabled) { - return { switched: false, reason: "auto-switch is disabled" }; - } - - const accountNames = await this.listAccountNames(); - if (accountNames.length === 0) { - return { switched: false, reason: "no saved accounts" }; - } - - const active = (await this.getCurrentAccountName()) ?? registry.activeAccountName; - if (!active || !accountNames.includes(active)) { - return { switched: false, reason: "no active account" }; - } - - const nowSeconds = Math.floor(Date.now() / 1000); - - const activeUsage = await this.refreshAccountUsage(registry, active, { - preferApi: registry.api.usage, - allowLocalFallback: true, - }); - - if (!shouldSwitchCurrent(activeUsage, { - threshold5hPercent: registry.autoSwitch.threshold5hPercent, - thresholdWeeklyPercent: registry.autoSwitch.thresholdWeeklyPercent, - }, nowSeconds)) { - await this.persistRegistry(registry); - return { switched: false, reason: "active account is above configured thresholds" }; - } - - const currentScore = usageScore(activeUsage, nowSeconds) ?? 0; - - let bestCandidate: string | undefined; - let bestScore = currentScore; - - for (const candidate of accountNames) { - if (candidate === active) continue; - - const usage = await this.refreshAccountUsage(registry, candidate, { - preferApi: registry.api.usage, - allowLocalFallback: false, - }); - - const score = usageScore(usage, nowSeconds) ?? 100; - if (!bestCandidate || score > bestScore) { - bestCandidate = candidate; - bestScore = score; - } - } - - if (!bestCandidate || bestScore <= currentScore) { - await this.persistRegistry(registry); - return { - switched: false, - reason: "no candidate has better remaining quota", - }; - } - - await this.activateSnapshot(bestCandidate); - registry.activeAccountName = bestCandidate; - await this.hydrateSnapshotMetadata(registry, bestCandidate); - await this.persistRegistry(registry); - - return { - switched: true, - fromAccount: active, - toAccount: bestCandidate, - reason: "switched due to low credits on active account", - }; - } - - public async runDaemon(mode: "once" | "watch"): Promise { - if (mode === "once") { - await this.runAutoSwitchOnce(); - return; - } - - for (;;) { - try { - await this.runAutoSwitchOnce(); - } catch { - // keep daemon alive - } - await new Promise((resolve) => setTimeout(resolve, 30_000)); - } - } - - private selectBestCandidateFromRegistry(candidates: string[], registry: RegistryData): string { - const nowSeconds = Math.floor(Date.now() / 1000); - let best = candidates[0]; - let bestScore = usageScore(registry.accounts[best]?.lastUsage, nowSeconds) ?? -1; - - for (const candidate of candidates.slice(1)) { - const score = usageScore(registry.accounts[candidate]?.lastUsage, nowSeconds) ?? -1; - if (score > bestScore) { - best = candidate; - bestScore = score; - } - } - - return best; - } - - private async refreshAccountUsage( - registry: RegistryData, - accountName: string, - options: { preferApi: boolean; allowLocalFallback: boolean; proxyUsageIndex?: ProxyUsageIndex | null }, - ): Promise { - const snapshotPath = this.accountFilePath(accountName); - const parsed = await parseAuthSnapshotFile(snapshotPath); - - const entry = registry.accounts[accountName] ?? { - name: accountName, - createdAt: new Date().toISOString(), - }; - - if (parsed.email) entry.email = parsed.email; - if (parsed.accountId) entry.accountId = parsed.accountId; - if (parsed.userId) entry.userId = parsed.userId; - if (parsed.planType) entry.planType = parsed.planType; - - let usage: UsageSnapshot | null = null; - if (options.preferApi) { - usage = this.resolveProxyUsage(options.proxyUsageIndex, accountName, entry, parsed); - } - - if (!usage && options.preferApi) { - usage = await fetchUsageFromApi(parsed); - } - - if (!usage && options.allowLocalFallback) { - usage = await fetchUsageFromLocal(resolveCodexDir()); - } - - if (usage) { - entry.lastUsage = usage; - entry.lastUsageAt = usage.fetchedAt; - if (usage.planType) { - entry.planType = usage.planType; - } - } - - registry.accounts[accountName] = entry; - return entry.lastUsage; - } - - private async refreshListUsageIfNeeded( - accountNames: string[], - currentAccountName: string | null, - registry: RegistryData, - refreshUsage: "never" | "missing" | "always", - nowSeconds: number, - ): Promise { - if (refreshUsage === "never" || accountNames.length === 0) { - return; - } - - const accountNamesToRefresh = accountNames.filter((accountName) => { - if (!registry.api.usage && currentAccountName !== accountName) { - return false; - } - - if (refreshUsage === "always") { - return true; - } - - return this.isUsageMissingForList(registry.accounts[accountName]?.lastUsage, nowSeconds); - }); - - if (accountNamesToRefresh.length === 0) { - return; - } - - let index = 0; - const workerCount = Math.min(LIST_USAGE_REFRESH_CONCURRENCY, accountNamesToRefresh.length); - const proxyUsageIndex = registry.api.usage - ? await fetchUsageFromProxy() - : null; - await Promise.all( - Array.from({ length: workerCount }, async () => { - for (;;) { - const accountName = accountNamesToRefresh[index]; - index += 1; - if (!accountName) { - return; - } - - await this.refreshAccountUsage(registry, accountName, { - preferApi: registry.api.usage, - allowLocalFallback: currentAccountName === accountName, - proxyUsageIndex, - }); - } - }), - ); - - await this.persistRegistry(registry); - } - - private isUsageMissingForList(usage: UsageSnapshot | undefined, nowSeconds: number): boolean { - const remaining5hPercent = remainingPercent(resolveRateWindow(usage, 300, true), nowSeconds); - const remainingWeeklyPercent = remainingPercent(resolveRateWindow(usage, 10080, false), nowSeconds); - return typeof remaining5hPercent !== "number" || typeof remainingWeeklyPercent !== "number"; - } - - private resolveProxyUsage( - proxyUsageIndex: ProxyUsageIndex | null | undefined, - accountName: string, - entry: RegistryData["accounts"][string], - parsed: ParsedAuthSnapshot, - ): UsageSnapshot | null { - if (!proxyUsageIndex) { - return null; - } - - const candidates = [ - parsed.accountId, - entry.accountId, - ]; - for (const candidate of candidates) { - const usage = this.lookupProxyUsage(proxyUsageIndex.byAccountId, candidate); - if (usage) { - return usage; - } - } - - const emailCandidates = [ - parsed.email, - entry.email, - ]; - for (const candidate of emailCandidates) { - const usage = this.lookupProxyUsage(proxyUsageIndex.byEmail, candidate); - if (usage) { - return usage; - } - } - - return this.lookupProxyUsage(proxyUsageIndex.bySnapshotName, accountName); - } - - private lookupProxyUsage(map: Map, rawValue: string | undefined): UsageSnapshot | null { - if (!rawValue) { - return null; - } - - const normalized = rawValue.trim().toLowerCase(); - if (!normalized) { - return null; - } - - return map.get(normalized) ?? null; - } - - private accountFilePath(name: string): string { - return path.join(resolveAccountsDir(), `${name}.json`); - } - - private snapshotBackupPath(name: string): string { - return path.join(resolveSnapshotBackupDir(), `${name}.json`); - } - - private async backupAllSnapshots(): Promise { - let accountNames: string[]; - try { - accountNames = await this.listAccountNames(); - } catch { - return; - } - - const backupDir = resolveSnapshotBackupDir(); - // Replace stale vault contents from a previous codex run with the current - // snapshot state so recovery only ever restores from this run's backup. - await this.clearSnapshotBackupVault(); - - if (accountNames.length === 0) { - return; - } - - try { - await this.ensureDir(backupDir); - } catch { - return; - } - - await Promise.all( - accountNames.map(async (name) => { - const source = this.accountFilePath(name); - const destination = this.snapshotBackupPath(name); - try { - await fsp.copyFile(source, destination); - await chmodSecureFile(destination); - } catch { - // Best-effort backup; one failure shouldn't block codex from running. - } - }), - ); - } - - private async restoreClobberedSnapshotsFromBackup(): Promise { - const backupDir = resolveSnapshotBackupDir(); - if (!(await this.pathExists(backupDir))) { - return; - } - - let entries: string[]; - try { - entries = await fsp.readdir(backupDir); - } catch { - return; - } - - for (const entry of entries) { - if (!entry.endsWith(".json")) continue; - const name = entry.replace(/\.json$/i, ""); - const destination = this.accountFilePath(name); - const source = path.join(backupDir, entry); - - try { - const backupSnapshot = await parseAuthSnapshotFile(source); - if (backupSnapshot.authMode !== "chatgpt") continue; - } catch { - continue; - } - - if (!(await this.pathExists(destination))) { - // Destination missing: codex deleted it (or never saved). Recover. - try { - await this.ensureDir(path.dirname(destination)); - await fsp.copyFile(source, destination); - await chmodSecureFile(destination); - } catch { - // Best-effort; skip on failure. - } - continue; - } - - // Destination exists. If its identity differs from the backup's - // identity, codex clobbered it through a stale symlink. Restore. - try { - const [backupSnapshot, currentSnapshot] = await Promise.all([ - parseAuthSnapshotFile(source), - parseAuthSnapshotFile(destination), - ]); - if (this.snapshotsShareIdentity(backupSnapshot, currentSnapshot)) { - continue; - } - await fsp.copyFile(source, destination); - await chmodSecureFile(destination); - } catch { - // Skip on any read/write failure rather than abort the whole recovery. - } - } - } - - private async clearSnapshotBackupVault(): Promise { - const backupDir = resolveSnapshotBackupDir(); - try { - await fsp.rm(backupDir, { recursive: true, force: true }); - } catch { - // Best-effort cleanup; do not propagate. - } - } - - private normalizeAccountName(rawName: string | undefined): string { - if (typeof rawName !== "string") { - throw new InvalidAccountNameError(); - } - - const trimmed = rawName.trim(); - if (!trimmed.length) { - throw new InvalidAccountNameError(); - } - - const withoutExtension = trimmed.replace(/\.json$/i, ""); - if (!ACCOUNT_NAME_PATTERN.test(withoutExtension)) { - throw new InvalidAccountNameError(); - } - - return withoutExtension; - } - - private isValidPercent(value: number): boolean { - return Number.isFinite(value) && Number.isInteger(value) && value >= 1 && value <= 100; - } - - private async ensureAuthFileExists(authPath: string): Promise { - if (!(await this.pathExists(authPath))) { - throw new AuthFileMissingError(authPath); - } - } - - private async ensureDir(dirPath: string): Promise { - await ensureSecureDir(dirPath); - } - - private async materializeAuthSymlink(authPath: string): Promise { - const stat = await fsp.lstat(authPath); - if (!stat.isSymbolicLink()) { - return; - } - - const snapshotData = await fsp.readFile(authPath); - await this.removeIfExists(authPath); - await secureWriteFile(authPath, snapshotData); - } - - private async assertSafeSnapshotOverwrite(input: { - authPath: string; - destinationPath: string; - accountName: string; - force: boolean; - }): Promise { - if (input.force || !(await this.pathExists(input.destinationPath))) { - return; - } - - const [existingSnapshot, incomingSnapshot] = await Promise.all([ - parseAuthSnapshotFile(input.destinationPath), - parseAuthSnapshotFile(input.authPath), - ]); - - const existingEmail = existingSnapshot.email?.trim().toLowerCase(); - const incomingEmail = incomingSnapshot.email?.trim().toLowerCase(); - - if (existingEmail && incomingEmail && existingEmail !== incomingEmail) { - throw new SnapshotEmailMismatchError(input.accountName, existingEmail, incomingEmail); - } - - if (this.snapshotsShareIdentity(existingSnapshot, incomingSnapshot)) return; - - if (!existingEmail || !incomingEmail) return; - - const existingIdentity = this.renderSnapshotIdentity(existingSnapshot, existingEmail); - const incomingIdentity = this.renderSnapshotIdentity(incomingSnapshot, incomingEmail); - throw new SnapshotEmailMismatchError(input.accountName, existingIdentity, incomingIdentity); - } - - private async removeIfExists(target: string): Promise { - try { - await fsp.rm(target, { force: true }); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code !== "ENOENT") { - throw error; - } - } - } - - private async writeCurrentName(name: string, options?: { authFingerprint?: string }): Promise { - const currentNamePath = resolveCurrentNamePath(); - await this.ensureDir(path.dirname(currentNamePath)); - await secureWriteFile(currentNamePath, `${name}\n`); - await this.setSessionAccountName(name, options?.authFingerprint); - } - - private async readCurrentNameFile(currentNamePath: string): Promise { - try { - const contents = await fsp.readFile(currentNamePath, "utf8"); - const trimmed = contents.trim(); - return trimmed.length ? trimmed : null; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - return null; - } - throw error; - } - } - - private async pathExists(targetPath: string): Promise { - try { - await fsp.access(targetPath, fs.constants.F_OK); - return true; - } catch { - return false; - } - } - - private async filesMatch(firstPath: string, secondPath: string): Promise { - try { - const [first, second] = await Promise.all([fsp.readFile(firstPath), fsp.readFile(secondPath)]); - return first.equals(second); - } catch { - return false; - } - } - - private async readAuthSyncState(authPath: string): Promise<{ fingerprint: string; isSymbolicLink: boolean } | null> { - try { - const stat = await fsp.lstat(authPath); - return { - fingerprint: this.createAuthSyncFingerprint(stat), - isSymbolicLink: stat.isSymbolicLink(), - }; - } catch { - return null; - } - } - - private createAuthSyncFingerprint(stat: fs.Stats): string { - return [ - stat.isSymbolicLink() ? "symlink" : "file", - typeof stat.ino === "number" ? Math.trunc(stat.ino) : 0, - Math.trunc(stat.size), - Math.trunc(stat.mtimeMs), - ].join(":"); - } - - private async hydrateSnapshotMetadata(registry: RegistryData, accountName: string): Promise { - const parsed = await parseAuthSnapshotFile(this.accountFilePath(accountName)); - const entry = registry.accounts[accountName] ?? { - name: accountName, - createdAt: new Date().toISOString(), - }; - - if (parsed.email) entry.email = parsed.email; - if (parsed.accountId) entry.accountId = parsed.accountId; - if (parsed.userId) entry.userId = parsed.userId; - if (parsed.planType) entry.planType = parsed.planType; - - registry.accounts[accountName] = entry; - } - - private async hydrateSnapshotMetadataIfMissing(registry: RegistryData, accountName: string): Promise { - 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 { - 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 { - let emailMatch: ResolvedDefaultAccountName | null = null; - const accountNames = await this.listAccountNames(); - const candidates = this.orderReloginSnapshotCandidates(accountNames, incomingSnapshot, activeName); - const registryMatch = await this.resolveRegistryAccountNameForIncomingSnapshot( - incomingSnapshot, - candidates, - activeName, - ); - if (registryMatch) { - return registryMatch; - } - - 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 && this.snapshotsShareEmail(existingSnapshot, incomingSnapshot)) { - emailMatch = { - name, - source: activeName === name ? "active" : "existing", - forceOverwrite: true, - }; - } - } - - return emailMatch; - } - - private async resolveRegistryAccountNameForIncomingSnapshot( - incomingSnapshot: ParsedAuthSnapshot, - candidates: string[], - activeName: string | null, - ): Promise { - const registry = await loadRegistry(); - let activeEmailMatch: ResolvedDefaultAccountName | null = null; - - for (const name of candidates) { - const entry = registry.accounts[name]; - if (!entry || !(await this.pathExists(this.accountFilePath(name)))) continue; - - if (this.registryEntrySharesIdentity(entry, incomingSnapshot)) { - return { - name, - source: activeName === name ? "active" : "existing", - }; - } - - if ( - !activeEmailMatch && - this.registryEntrySharesEmail(entry, incomingSnapshot) - ) { - activeEmailMatch = { - name, - source: activeName === name ? "active" : "existing", - forceOverwrite: true, - }; - } - } - - return activeEmailMatch; - } - - 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, - ): Promise { - const accountPathFor = (name: string): string => this.accountFilePath(name); - const hasMatchingIdentity = async (name: string): Promise => { - const parsed = await parseAuthSnapshotFile(accountPathFor(name)); - return this.snapshotsShareIdentity(parsed, incomingSnapshot); - }; - - const basePath = accountPathFor(baseName); - if (!(await this.pathExists(basePath))) { - return baseName; - } - if (await hasMatchingIdentity(baseName)) { - return baseName; - } - - for (let i = 2; i <= 99; i += 1) { - const candidate = this.normalizeAccountName(`${baseName}--dup-${i}`); - const candidatePath = accountPathFor(candidate); - if (!(await this.pathExists(candidatePath))) { - return candidate; - } - if (await hasMatchingIdentity(candidate)) { - return candidate; - } - } - - throw new AccountNameInferenceError(); - } - - private async inferAccountNameFromSnapshot(incomingSnapshot: ParsedAuthSnapshot): Promise { - 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 { - const accountNames = await this.listAccountNames(); - const loaded = await loadRegistry(); - const base = loaded.version === 1 ? loaded : createDefaultRegistry(); - return reconcileRegistryWithAccounts(base, accountNames); - } - - private async persistRegistry(registry: RegistryData): Promise { - const reconciled = reconcileRegistryWithAccounts(registry, await this.listAccountNames()); - await persistRegistryAtomic(reconciled); - } - - private async activateSnapshot(accountName: string): Promise { - const name = this.normalizeAccountName(accountName); - const source = this.accountFilePath(name); - - if (!(await this.pathExists(source))) { - throw new AccountNotFoundError(name); - } - - const authPath = resolveAuthPath(); - await this.ensureDir(path.dirname(authPath)); - await fsp.copyFile(source, authPath); - await chmodSecureFile(authPath); - - const authState = await this.readAuthSyncState(authPath); - await this.writeCurrentName(name, { - authFingerprint: authState && !authState.isSymbolicLink ? authState.fingerprint : undefined, - }); - } - - private async resolveUsableAccountName(accountName: string): Promise { - 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 { - 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 { - const sessionAccountName = await this.getSessionAccountName(); - if (!sessionAccountName) { - return true; - } - return this.pathExists(this.accountFilePath(sessionAccountName)); - } - - private async clearActivePointers(): Promise { - const currentPath = resolveCurrentNamePath(); - const authPath = resolveAuthPath(); - await this.removeIfExists(currentPath); - await this.removeIfExists(authPath); - await this.clearSessionAccountName(); - } - - private isExternalSyncForced(): boolean { - const raw = process.env[EXTERNAL_SYNC_FORCE_ENV]; - if (!raw) return false; - const normalized = raw.trim().toLowerCase(); - if (!normalized) return false; - return !["0", "false", "no", "off"].includes(normalized); - } - - private resolveSessionScopeKey(): string | null { - const explicit = process.env[SESSION_KEY_ENV]?.trim(); - if (explicit) { - const sanitized = explicit.replace(/\s+/g, " ").slice(0, 160); - return `session:${sanitized}`; - } - - if (typeof process.ppid === "number" && process.ppid > 1) { - return `ppid:${process.ppid}`; - } - - return null; - } - - private async getSessionAccountName(): Promise { - const sessionKey = this.resolveSessionScopeKey(); - if (!sessionKey) return null; - - const sessionMap = await this.readSessionMap(); - const entry = sessionMap.sessions[sessionKey]; - if (!entry?.accountName) return null; - - try { - return this.normalizeAccountName(entry.accountName); - } catch { - return null; - } - } - - private async getSessionAuthFingerprint(): Promise { - const sessionKey = this.resolveSessionScopeKey(); - if (!sessionKey) return null; - - const sessionMap = await this.readSessionMap(); - const entry = sessionMap.sessions[sessionKey]; - if (!entry?.authFingerprint || typeof entry.authFingerprint !== "string") { - return null; - } - - return entry.authFingerprint.trim() || null; - } - - private async getActiveSessionAccountName(): Promise { - const sessionAccountName = await this.getSessionAccountName(); - if (!sessionAccountName) return null; - - const sessionIsActive = await this.isSessionPinnedToActiveCodex(); - if (sessionIsActive) return sessionAccountName; - - await this.clearSessionAccountName(); - return null; - } - - private async setSessionAccountName(accountName: string, authFingerprint?: string): Promise { - const sessionKey = this.resolveSessionScopeKey(); - if (!sessionKey) return; - - const sessionMap = await this.readSessionMap(); - const existing = sessionMap.sessions[sessionKey]; - sessionMap.sessions[sessionKey] = { - accountName, - authFingerprint: authFingerprint ?? existing?.authFingerprint, - updatedAt: new Date().toISOString(), - }; - await this.writeSessionMap(sessionMap); - } - - private async clearSessionAccountName(): Promise { - const sessionKey = this.resolveSessionScopeKey(); - if (!sessionKey) return; - - const sessionMap = await this.readSessionMap(); - if (!sessionMap.sessions[sessionKey]) return; - delete sessionMap.sessions[sessionKey]; - await this.writeSessionMap(sessionMap); - } - - private async readSessionMap(): Promise { - const sessionMapPath = resolveSessionMapPath(); - try { - const raw = await fsp.readFile(sessionMapPath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object") { - return { - version: 1, - sessions: {}, - }; - } - - const root = parsed as Record; - const sessionsRaw = root.sessions && typeof root.sessions === "object" - ? (root.sessions as Record) - : {}; - const sessions: Record = {}; - - for (const [key, value] of Object.entries(sessionsRaw)) { - if (!value || typeof value !== "object") continue; - const rawEntry = value as Record; - const accountName = typeof rawEntry.accountName === "string" ? rawEntry.accountName.trim() : ""; - if (!accountName) continue; - const authFingerprint = - typeof rawEntry.authFingerprint === "string" && rawEntry.authFingerprint.trim().length > 0 - ? rawEntry.authFingerprint.trim() - : undefined; - sessions[key] = { - accountName, - authFingerprint, - updatedAt: - typeof rawEntry.updatedAt === "string" && rawEntry.updatedAt.length > 0 - ? rawEntry.updatedAt - : new Date().toISOString(), - }; - } - - return { - version: 1, - sessions, - }; - } catch { - return { - version: 1, - sessions: {}, - }; - } - } - - private async writeSessionMap(sessionMap: SessionMapData): Promise { - const sessionMapPath = resolveSessionMapPath(); - await this.ensureDir(path.dirname(sessionMapPath)); - await secureWriteFile(sessionMapPath, `${JSON.stringify(sessionMap, null, 2)}\n`); - } - - private async rememberSessionAuthFingerprint(authPath: string): Promise { - const sessionKey = this.resolveSessionScopeKey(); - if (!sessionKey) return; - - const authState = await this.readAuthSyncState(authPath); - if (!authState || authState.isSymbolicLink) return; - - const sessionMap = await this.readSessionMap(); - const existing = sessionMap.sessions[sessionKey]; - if (!existing?.accountName || existing.authFingerprint === authState.fingerprint) { - return; - } - - sessionMap.sessions[sessionKey] = { - ...existing, - authFingerprint: authState.fingerprint, - updatedAt: new Date().toISOString(), - }; - await this.writeSessionMap(sessionMap); - } - - private async isSessionPinnedToActiveCodex(): Promise { - const override = process.env[SESSION_ACTIVE_OVERRIDE_ENV]?.trim().toLowerCase(); - if (override) { - if (["1", "true", "yes", "on"].includes(override)) return true; - if (["0", "false", "no", "off"].includes(override)) return false; - } - - const sessionKey = this.resolveSessionScopeKey(); - if (!sessionKey) return false; - - if (sessionKey.startsWith("session:")) { - return true; - } - - if (process.platform !== "linux") { - return true; - } - - const ppidMatch = sessionKey.match(/^ppid:(\d+)$/); - if (!ppidMatch) return false; - - const parentPid = Number(ppidMatch[1]); - if (!Number.isFinite(parentPid) || parentPid <= 1) return false; - - const childPids = await this.readChildPids(parentPid); - if (childPids.length === 0) return false; - - for (const childPid of childPids) { - if (await this.isCodexProcess(childPid)) { - return true; - } - } - - return false; - } - - private async readChildPids(parentPid: number): Promise { - try { - const childrenRaw = await fsp.readFile(`/proc/${parentPid}/task/${parentPid}/children`, "utf8"); - return childrenRaw - .split(/\s+/) - .map((value) => Number(value)) - .filter((value) => Number.isInteger(value) && value > 1); - } catch { - return []; - } - } - - private async isCodexProcess(pid: number): Promise { - try { - const cmdline = await fsp.readFile(`/proc/${pid}/cmdline`, "utf8"); - const normalized = cmdline.replace(/\0/g, " ").trim(); - if (!normalized) return false; - if (/\bauthmux\b/.test(normalized)) return false; - if (/(^|\s|\/)codex(\s|$)/.test(normalized)) return true; - if (/(^|\s|\/)codex-linux-[^\s]*($|\s)/.test(normalized)) return true; - return false; - } catch { - return false; - } - } - - private snapshotsShareIdentity(a: ParsedAuthSnapshot, b: ParsedAuthSnapshot): boolean { - if (a.authMode !== "chatgpt" || b.authMode !== "chatgpt") { - return false; - } - - if (a.userId && b.userId && a.accountId && b.accountId) { - return a.userId === b.userId && a.accountId === b.accountId; - } - - if (a.accountId && b.accountId) { - return a.accountId === b.accountId; - } - - if (a.userId && b.userId) { - return a.userId === b.userId; - } - - const aEmail = a.email?.trim().toLowerCase(); - const bEmail = b.email?.trim().toLowerCase(); - if (aEmail && bEmail) { - return aEmail === bEmail; - } - - return false; - } - - private registryEntrySharesIdentity(entry: AccountRegistryEntry, snapshot: ParsedAuthSnapshot): boolean { - if (snapshot.authMode !== "chatgpt") { - return false; - } - - if (entry.userId && snapshot.userId && entry.accountId && snapshot.accountId) { - return entry.userId === snapshot.userId && entry.accountId === snapshot.accountId; - } - - if (entry.accountId && snapshot.accountId) { - return entry.accountId === snapshot.accountId; - } - - if (entry.userId && snapshot.userId) { - return entry.userId === snapshot.userId; - } - - return this.registryEntrySharesEmail(entry, snapshot); - } - - private registryEntrySharesEmail(entry: AccountRegistryEntry, snapshot: ParsedAuthSnapshot): boolean { - const entryEmail = entry.email?.trim().toLowerCase(); - const snapshotEmail = snapshot.email?.trim().toLowerCase(); - return Boolean(entryEmail && snapshotEmail && entryEmail === snapshotEmail); + return configureAutoSwitchThresholdsImpl(input); } - 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); + public runAutoSwitchOnce(): Promise { + return runAutoSwitchOnceImpl(() => this.getCurrentAccountName()); } - private renderSnapshotIdentity(snapshot: ParsedAuthSnapshot, fallbackEmail: string): string { - const parts = [fallbackEmail]; - if (snapshot.accountId) parts.push(`account:${snapshot.accountId}`); - if (snapshot.userId) parts.push(`user:${snapshot.userId}`); - return parts.join(" | "); + public runDaemon(mode: "once" | "watch"): Promise { + return runDaemonImpl(mode, () => this.getCurrentAccountName()); } } diff --git a/src/lib/accounts/auto-switch/policy.ts b/src/lib/accounts/auto-switch/policy.ts new file mode 100644 index 0000000..e72edc3 --- /dev/null +++ b/src/lib/accounts/auto-switch/policy.ts @@ -0,0 +1,141 @@ +// Auto-switch policy extracted from AccountService (Theme N2). +// +// Drives the "should I switch the active account now?" decision: pulls +// fresh usage for the active account, compares to the configured +// thresholds, and if low promotes the candidate with the highest +// remaining quota. `runDaemon("watch")` runs the loop with a 30s cycle. + +import { AutoSwitchRunResult, RegistryData } from "../types"; +import { + shouldSwitchCurrent, + usageScore, +} from "../usage"; +import { listAccountNames, loadReconciledRegistry } from "../read/listing"; +import { + hydrateSnapshotMetadata, + persistRegistry, +} from "../_internal/registry-ops"; +import { activateSnapshot } from "../write/use"; +import { refreshAccountUsage } from "../usage/adapter"; + +export function selectBestCandidateFromRegistry( + candidates: string[], + registry: RegistryData, +): string { + const nowSeconds = Math.floor(Date.now() / 1000); + let best = candidates[0]; + let bestScore = usageScore(registry.accounts[best]?.lastUsage, nowSeconds) ?? -1; + + for (const candidate of candidates.slice(1)) { + const score = + usageScore(registry.accounts[candidate]?.lastUsage, nowSeconds) ?? -1; + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + + return best; +} + +export async function runAutoSwitchOnce( + getCurrentAccountName: () => Promise, +): Promise { + const registry = await loadReconciledRegistry(); + if (!registry.autoSwitch.enabled) { + return { switched: false, reason: "auto-switch is disabled" }; + } + + const accountNames = await listAccountNames(); + if (accountNames.length === 0) { + return { switched: false, reason: "no saved accounts" }; + } + + const active = (await getCurrentAccountName()) ?? registry.activeAccountName; + if (!active || !accountNames.includes(active)) { + return { switched: false, reason: "no active account" }; + } + + const nowSeconds = Math.floor(Date.now() / 1000); + + const activeUsage = await refreshAccountUsage(registry, active, { + preferApi: registry.api.usage, + allowLocalFallback: true, + }); + + if ( + !shouldSwitchCurrent( + activeUsage, + { + threshold5hPercent: registry.autoSwitch.threshold5hPercent, + thresholdWeeklyPercent: registry.autoSwitch.thresholdWeeklyPercent, + }, + nowSeconds, + ) + ) { + await persistRegistry(registry); + return { + switched: false, + reason: "active account is above configured thresholds", + }; + } + + const currentScore = usageScore(activeUsage, nowSeconds) ?? 0; + + let bestCandidate: string | undefined; + let bestScore = currentScore; + + for (const candidate of accountNames) { + if (candidate === active) continue; + + const usage = await refreshAccountUsage(registry, candidate, { + preferApi: registry.api.usage, + allowLocalFallback: false, + }); + + const score = usageScore(usage, nowSeconds) ?? 100; + if (!bestCandidate || score > bestScore) { + bestCandidate = candidate; + bestScore = score; + } + } + + if (!bestCandidate || bestScore <= currentScore) { + await persistRegistry(registry); + return { + switched: false, + reason: "no candidate has better remaining quota", + }; + } + + await activateSnapshot(bestCandidate); + registry.activeAccountName = bestCandidate; + await hydrateSnapshotMetadata(registry, bestCandidate); + await persistRegistry(registry); + + return { + switched: true, + fromAccount: active, + toAccount: bestCandidate, + reason: "switched due to low credits on active account", + }; +} + +export async function runDaemon( + mode: "once" | "watch", + getCurrentAccountName: () => Promise, +): Promise { + if (mode === "once") { + await runAutoSwitchOnce(getCurrentAccountName); + return; + } + + for (;;) { + try { + await runAutoSwitchOnce(getCurrentAccountName); + } catch { + // keep daemon alive + } + await new Promise((resolve) => setTimeout(resolve, 30_000)); + } +} diff --git a/src/lib/accounts/config/auto-switch-config.ts b/src/lib/accounts/config/auto-switch-config.ts new file mode 100644 index 0000000..d66625e --- /dev/null +++ b/src/lib/accounts/config/auto-switch-config.ts @@ -0,0 +1,82 @@ +// Config surface extracted from AccountService (Theme N2). +// +// Status reader + threshold setters + the api-usage toggle. The threshold +// setters validate the percent input via `isValidPercent` to keep the +// `AutoSwitchConfigError` message text identical to the pre-N2 wording. + +import { + AutoSwitchConfigError, +} from "../errors"; +import { StatusReport } from "../types"; +import { + disableManagedService, + enableManagedService, + getManagedServiceState, +} from "../service-manager"; +import { isValidPercent } from "../naming"; +import { persistRegistry } from "../_internal/registry-ops"; +import { loadReconciledRegistry } from "../read/listing"; + +export async function getStatus(): Promise { + const registry = await loadReconciledRegistry(); + return { + autoSwitchEnabled: registry.autoSwitch.enabled, + serviceState: getManagedServiceState(), + threshold5hPercent: registry.autoSwitch.threshold5hPercent, + thresholdWeeklyPercent: registry.autoSwitch.thresholdWeeklyPercent, + usageMode: registry.api.usage ? "api" : "local", + }; +} + +export async function setAutoSwitchEnabled(enabled: boolean): Promise { + const registry = await loadReconciledRegistry(); + registry.autoSwitch.enabled = enabled; + + if (enabled) { + try { + await enableManagedService(); + } catch (error) { + registry.autoSwitch.enabled = false; + await persistRegistry(registry); + throw new AutoSwitchConfigError( + `Failed to enable managed auto-switch service: ${(error as Error).message}`, + ); + } + } else { + await disableManagedService(); + } + + await persistRegistry(registry); + return getStatus(); +} + +export async function setApiUsageEnabled(enabled: boolean): Promise { + const registry = await loadReconciledRegistry(); + registry.api.usage = enabled; + await persistRegistry(registry); + return getStatus(); +} + +export async function configureAutoSwitchThresholds(input: { + threshold5hPercent?: number; + thresholdWeeklyPercent?: number; +}): Promise { + const registry = await loadReconciledRegistry(); + + if (typeof input.threshold5hPercent === "number") { + if (!isValidPercent(input.threshold5hPercent)) { + throw new AutoSwitchConfigError("`--5h` must be an integer from 1 to 100."); + } + registry.autoSwitch.threshold5hPercent = Math.round(input.threshold5hPercent); + } + + if (typeof input.thresholdWeeklyPercent === "number") { + if (!isValidPercent(input.thresholdWeeklyPercent)) { + throw new AutoSwitchConfigError("`--weekly` must be an integer from 1 to 100."); + } + registry.autoSwitch.thresholdWeeklyPercent = Math.round(input.thresholdWeeklyPercent); + } + + await persistRegistry(registry); + return getStatus(); +} diff --git a/src/lib/accounts/identity/equality.ts b/src/lib/accounts/identity/equality.ts new file mode 100644 index 0000000..4b236a4 --- /dev/null +++ b/src/lib/accounts/identity/equality.ts @@ -0,0 +1,86 @@ +// Identity equality helpers extracted from AccountService (Theme N2). +// Pure functions over ParsedAuthSnapshot / AccountRegistryEntry — no I/O, +// no globals. Two snapshots "share identity" when they describe the same +// upstream account (same userId + accountId, or same email as fallback). + +import { AccountRegistryEntry, ParsedAuthSnapshot } from "../types"; + +export function snapshotsShareIdentity( + a: ParsedAuthSnapshot, + b: ParsedAuthSnapshot, +): boolean { + if (a.authMode !== "chatgpt" || b.authMode !== "chatgpt") { + return false; + } + + if (a.userId && b.userId && a.accountId && b.accountId) { + return a.userId === b.userId && a.accountId === b.accountId; + } + + if (a.accountId && b.accountId) { + return a.accountId === b.accountId; + } + + if (a.userId && b.userId) { + return a.userId === b.userId; + } + + const aEmail = a.email?.trim().toLowerCase(); + const bEmail = b.email?.trim().toLowerCase(); + if (aEmail && bEmail) { + return aEmail === bEmail; + } + + return false; +} + +export function snapshotsShareEmail( + a: ParsedAuthSnapshot, + b: ParsedAuthSnapshot, +): boolean { + const aEmail = a.email?.trim().toLowerCase(); + const bEmail = b.email?.trim().toLowerCase(); + return Boolean(aEmail && bEmail && aEmail === bEmail); +} + +export function registryEntrySharesIdentity( + entry: AccountRegistryEntry, + snapshot: ParsedAuthSnapshot, +): boolean { + if (snapshot.authMode !== "chatgpt") { + return false; + } + + if (entry.userId && snapshot.userId && entry.accountId && snapshot.accountId) { + return entry.userId === snapshot.userId && entry.accountId === snapshot.accountId; + } + + if (entry.accountId && snapshot.accountId) { + return entry.accountId === snapshot.accountId; + } + + if (entry.userId && snapshot.userId) { + return entry.userId === snapshot.userId; + } + + return registryEntrySharesEmail(entry, snapshot); +} + +export function registryEntrySharesEmail( + entry: AccountRegistryEntry, + snapshot: ParsedAuthSnapshot, +): boolean { + const entryEmail = entry.email?.trim().toLowerCase(); + const snapshotEmail = snapshot.email?.trim().toLowerCase(); + return Boolean(entryEmail && snapshotEmail && entryEmail === snapshotEmail); +} + +export function renderSnapshotIdentity( + snapshot: ParsedAuthSnapshot, + fallbackEmail: string, +): string { + const parts = [fallbackEmail]; + if (snapshot.accountId) parts.push(`account:${snapshot.accountId}`); + if (snapshot.userId) parts.push(`user:${snapshot.userId}`); + return parts.join(" | "); +} diff --git a/src/lib/accounts/naming.ts b/src/lib/accounts/naming.ts new file mode 100644 index 0000000..2ec8cd4 --- /dev/null +++ b/src/lib/accounts/naming.ts @@ -0,0 +1,40 @@ +// Name/path utilities extracted from AccountService (Theme N2). +// Pure helpers for account-name validation + the `accounts/.json` +// path mapping. Behavior is byte-identical to the pre-N2 inline versions. + +import path from "node:path"; +import { resolveAccountsDir } from "../config/paths"; +import { InvalidAccountNameError } from "./errors"; + +const ACCOUNT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@+-]*$/; + +export function normalizeAccountName(rawName: string | undefined): string { + if (typeof rawName !== "string") { + throw new InvalidAccountNameError(); + } + + const trimmed = rawName.trim(); + if (!trimmed.length) { + throw new InvalidAccountNameError(); + } + + const withoutExtension = trimmed.replace(/\.json$/i, ""); + if (!ACCOUNT_NAME_PATTERN.test(withoutExtension)) { + throw new InvalidAccountNameError(); + } + + return withoutExtension; +} + +export function accountFilePath(name: string): string { + return path.join(resolveAccountsDir(), `${name}.json`); +} + +export function isValidPercent(value: number): boolean { + return ( + Number.isFinite(value) && + Number.isInteger(value) && + value >= 1 && + value <= 100 + ); +} diff --git a/src/lib/accounts/read/listing.ts b/src/lib/accounts/read/listing.ts new file mode 100644 index 0000000..049b28e --- /dev/null +++ b/src/lib/accounts/read/listing.ts @@ -0,0 +1,211 @@ +// Read-side listings extracted from AccountService (Theme N2). +// +// Owns the read-only "show me what accounts exist" surface: a directory +// scan of `~/.codex/accounts/`, joined with registry metadata to produce +// AccountMapping / AccountChoice rows. Write paths live elsewhere. + +import path from "node:path"; +import fsp from "node:fs/promises"; +import { + resolveAccountsDir, + resolveAuthPath, + resolveCurrentNamePath, + resolveSessionMapPath, +} from "../../config/paths"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { + createDefaultRegistry, + loadRegistry, + reconcileRegistryWithAccounts, +} from "../registry"; +import { + AccountMapping, + ParsedAuthSnapshot, + RegistryData, +} from "../types"; +import { + remainingPercent, + resolveRateWindow, +} from "../usage"; +import { accountFilePath } from "../naming"; +import { pathExists } from "../_internal/fs-helpers"; +import { + clearSessionAccountName, + getActiveSessionAccountName, + setSessionAccountName, +} from "../session/pin"; +import { readCurrentNameFile } from "../_internal/auth-state"; + +export interface AccountChoice { + name: string; + email?: string; + active: boolean; +} + +export interface ListAccountMappingsOptions { + refreshUsage?: "never" | "missing" | "always"; +} + +export async function listAccountNames(): Promise { + const accountsDir = resolveAccountsDir(); + if (!(await pathExists(accountsDir))) { + return []; + } + + const sessionMapPath = resolveSessionMapPath(); + const sessionMapBasename = + path.dirname(sessionMapPath) === accountsDir + ? path.basename(sessionMapPath) + : undefined; + + const entries = await fsp.readdir(accountsDir, { withFileTypes: true }); + return entries + .filter( + (entry) => + 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, "")) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); +} + +export async function loadReconciledRegistry(): Promise { + const accountNames = await listAccountNames(); + const loaded = await loadRegistry(); + const base = loaded.version === 1 ? loaded : createDefaultRegistry(); + return reconcileRegistryWithAccounts(base, accountNames); +} + +export async function listAccountChoices( + getCurrentAccountName: () => Promise, +): Promise { + const [accounts, current, registry] = await Promise.all([ + listAccountNames(), + getCurrentAccountName(), + loadReconciledRegistry(), + ]); + + return accounts.map((name) => ({ + name, + email: registry.accounts[name]?.email, + active: current === name, + })); +} + +export async function listAccountMappings( + getCurrentAccountName: () => Promise, + refreshListUsageIfNeeded: ( + accounts: string[], + current: string | null, + registry: RegistryData, + refreshUsage: "never" | "missing" | "always", + nowSeconds: number, + ) => Promise, + options?: ListAccountMappingsOptions, +): Promise { + const [accounts, current, registry] = await Promise.all([ + listAccountNames(), + getCurrentAccountName(), + loadReconciledRegistry(), + ]); + const nowSeconds = Math.floor(Date.now() / 1000); + await refreshListUsageIfNeeded( + accounts, + current, + registry, + options?.refreshUsage ?? "never", + nowSeconds, + ); + + return Promise.all( + accounts.map(async (name) => { + const entry = registry.accounts[name]; + let fallbackSnapshot: ParsedAuthSnapshot | undefined; + + if (!entry?.email || !entry?.accountId || !entry?.userId || !entry?.planType) { + fallbackSnapshot = await parseAuthSnapshotFile(accountFilePath(name)); + } + + const remaining5hPercent = remainingPercent( + resolveRateWindow(entry?.lastUsage, 300, true), + nowSeconds, + ); + const remainingWeeklyPercent = remainingPercent( + resolveRateWindow(entry?.lastUsage, 10080, false), + nowSeconds, + ); + + return { + name, + active: current === name, + email: entry?.email ?? fallbackSnapshot?.email, + accountId: entry?.accountId ?? fallbackSnapshot?.accountId, + userId: entry?.userId ?? fallbackSnapshot?.userId, + planType: entry?.planType ?? fallbackSnapshot?.planType, + lastUsageAt: entry?.lastUsageAt, + usageSource: entry?.lastUsage?.source, + remaining5hPercent, + remainingWeeklyPercent, + }; + }), + ); +} + +export async function getCurrentAccountName(): Promise { + const sessionAccountName = await getActiveSessionAccountName(); + if (sessionAccountName) { + const sessionSnapshotPath = accountFilePath(sessionAccountName); + if (await pathExists(sessionSnapshotPath)) { + return sessionAccountName; + } + + await clearSessionAccountName(); + } + + const currentNamePath = resolveCurrentNamePath(); + const currentName = await readCurrentNameFile(currentNamePath); + if (currentName) { + await setSessionAccountName(currentName); + return currentName; + } + + const authPath = resolveAuthPath(); + if (!(await pathExists(authPath))) return null; + + const stat = await fsp.lstat(authPath); + if (!stat.isSymbolicLink()) return null; + + const rawTarget = await fsp.readlink(authPath); + const resolvedTarget = path.resolve(path.dirname(authPath), rawTarget); + const accountsRoot = path.resolve(resolveAccountsDir()); + const relative = path.relative(accountsRoot, resolvedTarget); + if (relative.startsWith("..")) return null; + + const base = path.basename(resolvedTarget); + if (!base.endsWith(".json") || base === "registry.json") return null; + const resolvedName = base.replace(/\.json$/i, ""); + await setSessionAccountName(resolvedName); + return resolvedName; +} + +export async function findMatchingAccounts( + query: string, + getCurrentAccountName: () => Promise, +): Promise { + const normalized = query.trim().toLowerCase(); + if (!normalized) return []; + + const choices = await listAccountChoices(getCurrentAccountName); + const registry = await loadReconciledRegistry(); + return choices.filter((choice) => { + if (choice.name.toLowerCase().includes(normalized)) return true; + if (choice.email && choice.email.toLowerCase().includes(normalized)) return true; + const meta = registry.accounts[choice.name]; + if (meta?.accountId?.toLowerCase().includes(normalized)) return true; + if (meta?.userId?.toLowerCase().includes(normalized)) return true; + return false; + }); +} diff --git a/src/lib/accounts/safety/snapshot-vault.ts b/src/lib/accounts/safety/snapshot-vault.ts new file mode 100644 index 0000000..43730ba --- /dev/null +++ b/src/lib/accounts/safety/snapshot-vault.ts @@ -0,0 +1,125 @@ +// Snapshot crash-safety vault extracted from AccountService (Theme N2). +// +// Before codex runs we copy every saved snapshot into a backup dir; if +// codex clobbers a snapshot through a stale symlink we can restore it +// after codex exits. Best-effort throughout — a missing/unreadable +// backup should never block normal account sync. + +import path from "node:path"; +import fsp from "node:fs/promises"; +import { + resolveSnapshotBackupDir, +} from "../../config/paths"; +import { ensureSecureDir, chmodSecureFile } from "../../io/secure-fs"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { accountFilePath } from "../naming"; +import { pathExists } from "../_internal/fs-helpers"; +import { snapshotsShareIdentity } from "../identity/equality"; + +function snapshotBackupPath(name: string): string { + return path.join(resolveSnapshotBackupDir(), `${name}.json`); +} + +export async function backupAllSnapshots( + listAccountNames: () => Promise, +): Promise { + let accountNames: string[]; + try { + accountNames = await listAccountNames(); + } catch { + return; + } + + const backupDir = resolveSnapshotBackupDir(); + // Replace stale vault contents from a previous codex run with the current + // snapshot state so recovery only ever restores from this run's backup. + await clearSnapshotBackupVault(); + + if (accountNames.length === 0) { + return; + } + + try { + await ensureSecureDir(backupDir); + } catch { + return; + } + + await Promise.all( + accountNames.map(async (name) => { + const source = accountFilePath(name); + const destination = snapshotBackupPath(name); + try { + await fsp.copyFile(source, destination); + await chmodSecureFile(destination); + } catch { + // Best-effort backup; one failure shouldn't block codex from running. + } + }), + ); +} + +export async function restoreClobberedSnapshotsFromBackup(): Promise { + const backupDir = resolveSnapshotBackupDir(); + if (!(await pathExists(backupDir))) { + return; + } + + let entries: string[]; + try { + entries = await fsp.readdir(backupDir); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const name = entry.replace(/\.json$/i, ""); + const destination = accountFilePath(name); + const source = path.join(backupDir, entry); + + try { + const backupSnapshot = await parseAuthSnapshotFile(source); + if (backupSnapshot.authMode !== "chatgpt") continue; + } catch { + continue; + } + + if (!(await pathExists(destination))) { + // Destination missing: codex deleted it (or never saved). Recover. + try { + await ensureSecureDir(path.dirname(destination)); + await fsp.copyFile(source, destination); + await chmodSecureFile(destination); + } catch { + // Best-effort; skip on failure. + } + continue; + } + + // Destination exists. If its identity differs from the backup's + // identity, codex clobbered it through a stale symlink. Restore. + try { + const [backupSnapshot, currentSnapshot] = await Promise.all([ + parseAuthSnapshotFile(source), + parseAuthSnapshotFile(destination), + ]); + if (snapshotsShareIdentity(backupSnapshot, currentSnapshot)) { + continue; + } + await fsp.copyFile(source, destination); + await chmodSecureFile(destination); + } catch { + // Skip on any read/write failure rather than abort the whole recovery. + } + } +} + +export async function clearSnapshotBackupVault(): Promise { + const backupDir = resolveSnapshotBackupDir(); + try { + await fsp.rm(backupDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup; do not propagate. + } +} diff --git a/src/lib/accounts/session/pin.ts b/src/lib/accounts/session/pin.ts new file mode 100644 index 0000000..7323fc5 --- /dev/null +++ b/src/lib/accounts/session/pin.ts @@ -0,0 +1,243 @@ +// Session-pin module extracted from AccountService (Theme N2). +// +// Owns: sessions.json I/O, the session-scope key (env CODEX_AUTH_SESSION_KEY +// or `ppid:`), and the Linux-only PPID heuristic that decides whether the +// pinned session is still attached to a running codex process. + +import path from "node:path"; +import fsp from "node:fs/promises"; +import { + resolveSessionMapPath, +} from "../../config/paths"; +import { ensureSecureDir, secureWriteFile } from "../../io/secure-fs"; +import { normalizeAccountName } from "../naming"; +import { readAuthSyncState } from "../_internal/fs-helpers"; + +const SESSION_KEY_ENV = "CODEX_AUTH_SESSION_KEY"; +const SESSION_ACTIVE_OVERRIDE_ENV = "CODEX_AUTH_SESSION_ACTIVE_OVERRIDE"; + +export interface SessionMapEntry { + accountName: string; + authFingerprint?: string; + updatedAt: string; +} + +export interface SessionMapData { + version: 1; + sessions: Record; +} + +export function resolveSessionScopeKey(): string | null { + const explicit = process.env[SESSION_KEY_ENV]?.trim(); + if (explicit) { + const sanitized = explicit.replace(/\s+/g, " ").slice(0, 160); + return `session:${sanitized}`; + } + + if (typeof process.ppid === "number" && process.ppid > 1) { + return `ppid:${process.ppid}`; + } + + return null; +} + +export async function readSessionMap(): Promise { + const sessionMapPath = resolveSessionMapPath(); + try { + const raw = await fsp.readFile(sessionMapPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") { + return { version: 1, sessions: {} }; + } + + const root = parsed as Record; + const sessionsRaw = + root.sessions && typeof root.sessions === "object" + ? (root.sessions as Record) + : {}; + const sessions: Record = {}; + + for (const [key, value] of Object.entries(sessionsRaw)) { + if (!value || typeof value !== "object") continue; + const rawEntry = value as Record; + const accountName = + typeof rawEntry.accountName === "string" ? rawEntry.accountName.trim() : ""; + if (!accountName) continue; + const authFingerprint = + typeof rawEntry.authFingerprint === "string" && + rawEntry.authFingerprint.trim().length > 0 + ? rawEntry.authFingerprint.trim() + : undefined; + sessions[key] = { + accountName, + authFingerprint, + updatedAt: + typeof rawEntry.updatedAt === "string" && rawEntry.updatedAt.length > 0 + ? rawEntry.updatedAt + : new Date().toISOString(), + }; + } + + return { version: 1, sessions }; + } catch { + return { version: 1, sessions: {} }; + } +} + +export async function writeSessionMap(sessionMap: SessionMapData): Promise { + const sessionMapPath = resolveSessionMapPath(); + await ensureSecureDir(path.dirname(sessionMapPath)); + await secureWriteFile(sessionMapPath, `${JSON.stringify(sessionMap, null, 2)}\n`); +} + +export async function getSessionAccountName(): Promise { + const sessionKey = resolveSessionScopeKey(); + if (!sessionKey) return null; + + const sessionMap = await readSessionMap(); + const entry = sessionMap.sessions[sessionKey]; + if (!entry?.accountName) return null; + + try { + return normalizeAccountName(entry.accountName); + } catch { + return null; + } +} + +export async function getSessionAuthFingerprint(): Promise { + const sessionKey = resolveSessionScopeKey(); + if (!sessionKey) return null; + + const sessionMap = await readSessionMap(); + const entry = sessionMap.sessions[sessionKey]; + if (!entry?.authFingerprint || typeof entry.authFingerprint !== "string") { + return null; + } + + return entry.authFingerprint.trim() || null; +} + +export async function setSessionAccountName( + accountName: string, + authFingerprint?: string, +): Promise { + const sessionKey = resolveSessionScopeKey(); + if (!sessionKey) return; + + const sessionMap = await readSessionMap(); + const existing = sessionMap.sessions[sessionKey]; + sessionMap.sessions[sessionKey] = { + accountName, + authFingerprint: authFingerprint ?? existing?.authFingerprint, + updatedAt: new Date().toISOString(), + }; + await writeSessionMap(sessionMap); +} + +export async function clearSessionAccountName(): Promise { + const sessionKey = resolveSessionScopeKey(); + if (!sessionKey) return; + + const sessionMap = await readSessionMap(); + if (!sessionMap.sessions[sessionKey]) return; + delete sessionMap.sessions[sessionKey]; + await writeSessionMap(sessionMap); +} + +export async function rememberSessionAuthFingerprint(authPath: string): Promise { + const sessionKey = resolveSessionScopeKey(); + if (!sessionKey) return; + + const authState = await readAuthSyncState(authPath); + if (!authState || authState.isSymbolicLink) return; + + const sessionMap = await readSessionMap(); + const existing = sessionMap.sessions[sessionKey]; + if (!existing?.accountName || existing.authFingerprint === authState.fingerprint) { + return; + } + + sessionMap.sessions[sessionKey] = { + ...existing, + authFingerprint: authState.fingerprint, + updatedAt: new Date().toISOString(), + }; + await writeSessionMap(sessionMap); +} + +export async function isSessionPinnedToActiveCodex(): Promise { + const override = process.env[SESSION_ACTIVE_OVERRIDE_ENV]?.trim().toLowerCase(); + if (override) { + if (["1", "true", "yes", "on"].includes(override)) return true; + if (["0", "false", "no", "off"].includes(override)) return false; + } + + const sessionKey = resolveSessionScopeKey(); + if (!sessionKey) return false; + + if (sessionKey.startsWith("session:")) { + return true; + } + + if (process.platform !== "linux") { + return true; + } + + const ppidMatch = sessionKey.match(/^ppid:(\d+)$/); + if (!ppidMatch) return false; + + const parentPid = Number(ppidMatch[1]); + if (!Number.isFinite(parentPid) || parentPid <= 1) return false; + + const childPids = await readChildPids(parentPid); + if (childPids.length === 0) return false; + + for (const childPid of childPids) { + if (await isCodexProcess(childPid)) { + return true; + } + } + + return false; +} + +export async function readChildPids(parentPid: number): Promise { + try { + const childrenRaw = await fsp.readFile( + `/proc/${parentPid}/task/${parentPid}/children`, + "utf8", + ); + return childrenRaw + .split(/\s+/) + .map((value) => Number(value)) + .filter((value) => Number.isInteger(value) && value > 1); + } catch { + return []; + } +} + +export async function isCodexProcess(pid: number): Promise { + try { + const cmdline = await fsp.readFile(`/proc/${pid}/cmdline`, "utf8"); + const normalized = cmdline.replace(/\0/g, " ").trim(); + if (!normalized) return false; + if (/\bauthmux\b/.test(normalized)) return false; + if (/(^|\s|\/)codex(\s|$)/.test(normalized)) return true; + if (/(^|\s|\/)codex-linux-[^\s]*($|\s)/.test(normalized)) return true; + return false; + } catch { + return false; + } +} + +export async function getActiveSessionAccountName(): Promise { + const sessionAccountName = await getSessionAccountName(); + if (!sessionAccountName) return null; + + const sessionIsActive = await isSessionPinnedToActiveCodex(); + if (sessionIsActive) return sessionAccountName; + + await clearSessionAccountName(); + return null; +} diff --git a/src/lib/accounts/sync/external-sync.ts b/src/lib/accounts/sync/external-sync.ts new file mode 100644 index 0000000..07a4e19 --- /dev/null +++ b/src/lib/accounts/sync/external-sync.ts @@ -0,0 +1,216 @@ +// External-auth sync orchestrator extracted from AccountService (Theme N2). +// +// The two entry points: `syncExternalAuthSnapshotIfNeeded` runs on most +// CLI invocations to detect a fresh `codex login` and import it as a +// saved snapshot; `restoreSessionSnapshotIfNeeded` runs before codex +// starts to defend against codex clobbering the saved snapshot through +// a stale symlink. + +import { resolveAuthPath } from "../../config/paths"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { + ensureAuthFileExists, + materializeAuthSymlink, +} from "../_internal/auth-state"; +import { + pathExists, + filesMatch, + readAuthSyncState, +} from "../_internal/fs-helpers"; +import { accountFilePath } from "../naming"; +import { snapshotsShareIdentity } from "../identity/equality"; +import { getCurrentAccountName } from "../read/listing"; +import { saveAccount } from "../write/save"; +import { activateSnapshot, sessionSnapshotExists } from "../write/use"; +import { + getStatus, + setAutoSwitchEnabled, +} from "../config/auto-switch-config"; +import { + backupAllSnapshots, + clearSnapshotBackupVault, + restoreClobberedSnapshotsFromBackup, +} from "../safety/snapshot-vault"; +import { + clearSessionAccountName, + getActiveSessionAccountName, + getSessionAccountName, + getSessionAuthFingerprint, + rememberSessionAuthFingerprint, +} from "../session/pin"; +import { listAccountNames } from "../read/listing"; +import { resolveLoginAccountNameForSnapshot } from "../_internal/name-resolution"; + +const EXTERNAL_SYNC_FORCE_ENV = "CODEX_AUTH_FORCE_EXTERNAL_SYNC"; + +export interface ExternalAuthSyncResult { + synchronized: boolean; + savedName?: string; + autoSwitchDisabled: boolean; +} + +function isExternalSyncForced(): boolean { + const raw = process.env[EXTERNAL_SYNC_FORCE_ENV]; + if (!raw) return false; + const normalized = raw.trim().toLowerCase(); + if (!normalized) return false; + return !["0", "false", "no", "off"].includes(normalized); +} + +export async function syncExternalAuthSnapshotIfNeeded(): Promise { + const authPath = resolveAuthPath(); + if (!(await pathExists(authPath))) { + return { + synchronized: false, + autoSwitchDisabled: false, + }; + } + + const initialAuthState = await readAuthSyncState(authPath); + const externalSyncForced = isExternalSyncForced(); + if ( + initialAuthState && + !initialAuthState.isSymbolicLink && + !externalSyncForced && + (await getSessionAuthFingerprint()) === initialAuthState.fingerprint && + (await sessionSnapshotExists(getSessionAccountName)) + ) { + return { + synchronized: false, + autoSwitchDisabled: false, + }; + } + + await materializeAuthSymlink(authPath); + const rememberAuthState = async ( + result: ExternalAuthSyncResult, + ): Promise => { + await rememberSessionAuthFingerprint(authPath); + return result; + }; + + // Repair any snapshot file that codex clobbered through a stale symlink + // before we attempt name resolution — otherwise the identity-based scan + // mistakes the clobbered file for a refresh of the previous account. + await restoreClobberedSnapshotsFromBackup(); + + const incomingSnapshot = await parseAuthSnapshotFile(authPath); + if (incomingSnapshot.authMode !== "chatgpt") { + return rememberAuthState({ + synchronized: false, + autoSwitchDisabled: false, + }); + } + + const sessionAccountName = await getActiveSessionAccountName(); + if (sessionAccountName) { + const sessionSnapshotPath = accountFilePath(sessionAccountName); + if (await pathExists(sessionSnapshotPath)) { + const sessionSnapshot = await parseAuthSnapshotFile(sessionSnapshotPath); + if ( + sessionSnapshot.authMode === "chatgpt" && + !snapshotsShareIdentity(sessionSnapshot, incomingSnapshot) && + !externalSyncForced + ) { + return rememberAuthState({ + synchronized: false, + autoSwitchDisabled: false, + }); + } + } + } + + const activeName = await getCurrentAccountName(); + const resolvedName = await resolveLoginAccountNameForSnapshot( + incomingSnapshot, + activeName, + ); + const resolvedSnapshotPath = accountFilePath(resolvedName.name); + if ( + activeName === resolvedName.name && + (await pathExists(resolvedSnapshotPath)) && + (await filesMatch(authPath, resolvedSnapshotPath)) + ) { + return rememberAuthState({ + synchronized: false, + autoSwitchDisabled: false, + }); + } + + const status = await getStatus(); + const sameActiveAccountRefresh = + activeName === resolvedName.name && resolvedName.source === "active"; + const autoSwitchDisabled = status.autoSwitchEnabled && !sameActiveAccountRefresh; + if (autoSwitchDisabled) { + await setAutoSwitchEnabled(false); + } + + const savedName = await saveAccount(resolvedName.name, { + force: Boolean(resolvedName.forceOverwrite), + }); + + // The backup vault has served its purpose for this codex run. + await clearSnapshotBackupVault(); + + return rememberAuthState({ + synchronized: true, + savedName, + autoSwitchDisabled, + }); +} + +export async function 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 pathExists(authPath)) { + await materializeAuthSymlink(authPath); + } + + // Defensive safety net: snapshot every saved account into a backup vault + // before codex runs. If the materialize step is bypassed (e.g., this + // function isn't invoked because the shell hook is shadowed by another + // codex() function), the next sync after codex exits can still recover + // any snapshot file that got clobbered. + await backupAllSnapshots(listAccountNames); + + const sessionAccountName = await getActiveSessionAccountName(); + if (!sessionAccountName) { + return { restored: false }; + } + + const snapshotPath = accountFilePath(sessionAccountName); + if (!(await pathExists(snapshotPath))) { + await clearSessionAccountName(); + return { restored: false }; + } + + if (await pathExists(authPath)) { + const [sessionSnapshot, activeSnapshot] = await Promise.all([ + parseAuthSnapshotFile(snapshotPath), + parseAuthSnapshotFile(authPath), + ]); + if (snapshotsShareIdentity(sessionSnapshot, activeSnapshot)) { + return { + restored: false, + accountName: sessionAccountName, + }; + } + } + + await activateSnapshot(sessionAccountName); + return { + restored: true, + accountName: sessionAccountName, + }; +} + +// Used by the auth-parser ensure check from saveAccount() in particular. +// Re-exported so callers don't need to reach into _internal/. +export { ensureAuthFileExists }; diff --git a/src/lib/accounts/usage/adapter.ts b/src/lib/accounts/usage/adapter.ts new file mode 100644 index 0000000..81ffcde --- /dev/null +++ b/src/lib/accounts/usage/adapter.ts @@ -0,0 +1,192 @@ +// Usage-refresh adapter extracted from AccountService (Theme N2). +// +// Wraps the three usage fetchers in `accounts/usage.ts` (API, local, +// proxy) and writes the result onto a registry entry. Also handles the +// list-side concurrent refresh loop used by `listAccountMappings`. + +import { resolveCodexDir } from "../../config/paths"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { accountFilePath } from "../naming"; +import { persistRegistry } from "../_internal/registry-ops"; +import { + ParsedAuthSnapshot, + RegistryData, + UsageSnapshot, +} from "../types"; +import { + fetchUsageFromApi, + fetchUsageFromLocal, + fetchUsageFromProxy, + ProxyUsageIndex, + remainingPercent, + resolveRateWindow, +} from "../usage"; + +const LIST_USAGE_REFRESH_CONCURRENCY = 6; + +export function lookupProxyUsage( + map: Map, + rawValue: string | undefined, +): UsageSnapshot | null { + if (!rawValue) { + return null; + } + + const normalized = rawValue.trim().toLowerCase(); + if (!normalized) { + return null; + } + + return map.get(normalized) ?? null; +} + +export function resolveProxyUsage( + proxyUsageIndex: ProxyUsageIndex | null | undefined, + accountName: string, + entry: RegistryData["accounts"][string], + parsed: ParsedAuthSnapshot, +): UsageSnapshot | null { + if (!proxyUsageIndex) { + return null; + } + + const candidates = [parsed.accountId, entry.accountId]; + for (const candidate of candidates) { + const usage = lookupProxyUsage(proxyUsageIndex.byAccountId, candidate); + if (usage) { + return usage; + } + } + + const emailCandidates = [parsed.email, entry.email]; + for (const candidate of emailCandidates) { + const usage = lookupProxyUsage(proxyUsageIndex.byEmail, candidate); + if (usage) { + return usage; + } + } + + return lookupProxyUsage(proxyUsageIndex.bySnapshotName, accountName); +} + +export async function refreshAccountUsage( + registry: RegistryData, + accountName: string, + options: { + preferApi: boolean; + allowLocalFallback: boolean; + proxyUsageIndex?: ProxyUsageIndex | null; + }, +): Promise { + const snapshotPath = accountFilePath(accountName); + const parsed = await parseAuthSnapshotFile(snapshotPath); + + const entry = registry.accounts[accountName] ?? { + name: accountName, + createdAt: new Date().toISOString(), + }; + + if (parsed.email) entry.email = parsed.email; + if (parsed.accountId) entry.accountId = parsed.accountId; + if (parsed.userId) entry.userId = parsed.userId; + if (parsed.planType) entry.planType = parsed.planType; + + let usage: UsageSnapshot | null = null; + if (options.preferApi) { + usage = resolveProxyUsage(options.proxyUsageIndex, accountName, entry, parsed); + } + + if (!usage && options.preferApi) { + usage = await fetchUsageFromApi(parsed); + } + + if (!usage && options.allowLocalFallback) { + usage = await fetchUsageFromLocal(resolveCodexDir()); + } + + if (usage) { + entry.lastUsage = usage; + entry.lastUsageAt = usage.fetchedAt; + if (usage.planType) { + entry.planType = usage.planType; + } + } + + registry.accounts[accountName] = entry; + return entry.lastUsage; +} + +export function isUsageMissingForList( + usage: UsageSnapshot | undefined, + nowSeconds: number, +): boolean { + const remaining5hPercent = remainingPercent( + resolveRateWindow(usage, 300, true), + nowSeconds, + ); + const remainingWeeklyPercent = remainingPercent( + resolveRateWindow(usage, 10080, false), + nowSeconds, + ); + return ( + typeof remaining5hPercent !== "number" || + typeof remainingWeeklyPercent !== "number" + ); +} + +export async function refreshListUsageIfNeeded( + accountNames: string[], + currentAccountName: string | null, + registry: RegistryData, + refreshUsage: "never" | "missing" | "always", + nowSeconds: number, +): Promise { + if (refreshUsage === "never" || accountNames.length === 0) { + return; + } + + const accountNamesToRefresh = accountNames.filter((accountName) => { + if (!registry.api.usage && currentAccountName !== accountName) { + return false; + } + + if (refreshUsage === "always") { + return true; + } + + return isUsageMissingForList( + registry.accounts[accountName]?.lastUsage, + nowSeconds, + ); + }); + + if (accountNamesToRefresh.length === 0) { + return; + } + + let index = 0; + const workerCount = Math.min( + LIST_USAGE_REFRESH_CONCURRENCY, + accountNamesToRefresh.length, + ); + const proxyUsageIndex = registry.api.usage ? await fetchUsageFromProxy() : null; + await Promise.all( + Array.from({ length: workerCount }, async () => { + for (;;) { + const accountName = accountNamesToRefresh[index]; + index += 1; + if (!accountName) { + return; + } + + await refreshAccountUsage(registry, accountName, { + preferApi: registry.api.usage, + allowLocalFallback: currentAccountName === accountName, + proxyUsageIndex, + }); + } + }), + ); + + await persistRegistry(registry); +} diff --git a/src/lib/accounts/write/remove.ts b/src/lib/accounts/write/remove.ts new file mode 100644 index 0000000..5c16582 --- /dev/null +++ b/src/lib/accounts/write/remove.ts @@ -0,0 +1,105 @@ +// Write-side: remove one / many / all accounts. Extracted from +// AccountService (Theme N2). +// +// On removing the active account we promote the best remaining candidate +// (highest registry usageScore) and activate it. If the registry has no +// remaining accounts we clear ~/.codex/auth.json and the current-name file. + +import fsp from "node:fs/promises"; +import { + AccountNotFoundError, + AmbiguousAccountQueryError, +} from "../errors"; +import { accountFilePath, normalizeAccountName } from "../naming"; +import { persistRegistry } from "../_internal/registry-ops"; +import { + findMatchingAccounts, + listAccountNames, + loadReconciledRegistry, +} from "../read/listing"; +import { clearActivePointers } from "../_internal/auth-state"; +import { pathExists } from "../_internal/fs-helpers"; +import { activateSnapshot } from "./use"; +import { clearSessionAccountName } from "../session/pin"; +import { selectBestCandidateFromRegistry } from "../auto-switch/policy"; + +export interface RemoveResult { + removed: string[]; + activated?: string; +} + +export async function removeAccounts( + accountNames: string[], + getCurrentAccountName: () => Promise, +): Promise { + const uniqueNames = [ + ...new Set(accountNames.map((name) => normalizeAccountName(name))), + ]; + if (uniqueNames.length === 0) { + return { removed: [] }; + } + + const current = await getCurrentAccountName(); + const registry = await loadReconciledRegistry(); + const removed: string[] = []; + + for (const name of uniqueNames) { + const snapshotPath = accountFilePath(name); + if (!(await pathExists(snapshotPath))) { + throw new AccountNotFoundError(name); + } + + await fsp.rm(snapshotPath, { force: true }); + delete registry.accounts[name]; + removed.push(name); + } + + const removedSet = new Set(removed); + let activated: string | undefined; + + if (current && removedSet.has(current)) { + const remaining = (await listAccountNames()).filter((name) => !removedSet.has(name)); + if (remaining.length > 0) { + const best = selectBestCandidateFromRegistry(remaining, registry); + await activateSnapshot(best); + activated = best; + registry.activeAccountName = best; + } else { + await clearActivePointers(clearSessionAccountName); + delete registry.activeAccountName; + } + } else if ( + registry.activeAccountName && + removedSet.has(registry.activeAccountName) + ) { + delete registry.activeAccountName; + } + + await persistRegistry(registry); + return { + removed, + activated, + }; +} + +export async function removeByQuery( + query: string, + getCurrentAccountName: () => Promise, +): Promise { + const matches = await findMatchingAccounts(query, getCurrentAccountName); + if (matches.length === 0) { + throw new AccountNotFoundError(query); + } + if (matches.length > 1) { + throw new AmbiguousAccountQueryError(query); + } + + return removeAccounts([matches[0].name], getCurrentAccountName); +} + +export async function removeAllAccounts( + getCurrentAccountName: () => Promise, +): Promise { + const all = await listAccountNames(); + return removeAccounts(all, getCurrentAccountName); +} diff --git a/src/lib/accounts/write/save.ts b/src/lib/accounts/write/save.ts new file mode 100644 index 0000000..d63d2a6 --- /dev/null +++ b/src/lib/accounts/write/save.ts @@ -0,0 +1,159 @@ +// Write-side: saveAccount + safety guard + inference helpers (Theme N2). +// +// Owns the "copy ~/.codex/auth.json into ~/.codex/accounts/.json, +// update the registry, mark active" path. Pre-write safety check +// refuses to clobber a snapshot belonging to a different email unless the +// caller passes `force: true`. + +import fsp from "node:fs/promises"; +import { + resolveAccountsDir, + resolveAuthPath, +} from "../../config/paths"; +import { + chmodSecureDir, + chmodSecureFile, + ensureSecureDir, +} from "../../io/secure-fs"; +import { + AccountNameInferenceError, + SnapshotEmailMismatchError, +} from "../errors"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { accountFilePath, normalizeAccountName } from "../naming"; +import { + hydrateSnapshotMetadata, + persistRegistry, +} from "../_internal/registry-ops"; +import { loadReconciledRegistry } from "../read/listing"; +import { + ensureAuthFileExists, + writeCurrentName, +} from "../_internal/auth-state"; +import { pathExists } from "../_internal/fs-helpers"; +import { + renderSnapshotIdentity, + snapshotsShareIdentity, +} from "../identity/equality"; +import { + inferAccountNameFromSnapshot, + resolveExistingAccountNameForIncomingSnapshot, + resolveLoginAccountNameForSnapshot, + resolveUniqueInferredName, + ResolvedDefaultAccountName, + ResolvedLoginAccountName, +} from "../_internal/name-resolution"; + +export interface SaveAccountOptions { + force?: boolean; +} + +export async function assertSafeSnapshotOverwrite(input: { + authPath: string; + destinationPath: string; + accountName: string; + force: boolean; +}): Promise { + if (input.force || !(await pathExists(input.destinationPath))) { + return; + } + + const [existingSnapshot, incomingSnapshot] = await Promise.all([ + parseAuthSnapshotFile(input.destinationPath), + parseAuthSnapshotFile(input.authPath), + ]); + + const existingEmail = existingSnapshot.email?.trim().toLowerCase(); + const incomingEmail = incomingSnapshot.email?.trim().toLowerCase(); + + if (existingEmail && incomingEmail && existingEmail !== incomingEmail) { + throw new SnapshotEmailMismatchError(input.accountName, existingEmail, incomingEmail); + } + + if (snapshotsShareIdentity(existingSnapshot, incomingSnapshot)) return; + + if (!existingEmail || !incomingEmail) return; + + const existingIdentity = renderSnapshotIdentity(existingSnapshot, existingEmail); + const incomingIdentity = renderSnapshotIdentity(incomingSnapshot, incomingEmail); + throw new SnapshotEmailMismatchError( + input.accountName, + existingIdentity, + incomingIdentity, + ); +} + +export async function saveAccount( + rawName: string, + options?: SaveAccountOptions, +): Promise { + const name = normalizeAccountName(rawName); + const authPath = resolveAuthPath(); + const accountsDir = resolveAccountsDir(); + + await ensureAuthFileExists(authPath); + await ensureSecureDir(accountsDir); + const destination = accountFilePath(name); + await assertSafeSnapshotOverwrite({ + authPath, + destinationPath: destination, + accountName: name, + force: Boolean(options?.force), + }); + await fsp.copyFile(authPath, destination); + await chmodSecureFile(destination); + await chmodSecureDir(accountsDir); + + await writeCurrentName(name); + + const registry = await loadReconciledRegistry(); + await hydrateSnapshotMetadata(registry, name); + registry.activeAccountName = name; + await persistRegistry(registry); + + return name; +} + +export async function inferAccountNameFromCurrentAuth(): Promise { + const authPath = resolveAuthPath(); + await ensureAuthFileExists(authPath); + + const parsed = await parseAuthSnapshotFile(authPath); + const email = parsed.email?.trim().toLowerCase(); + if (!email || !email.includes("@")) { + throw new AccountNameInferenceError(); + } + + const baseCandidate = normalizeAccountName(email); + const uniqueName = await resolveUniqueInferredName(baseCandidate, parsed); + return uniqueName; +} + +export async function resolveDefaultAccountNameFromCurrentAuth( + getCurrentAccountName: () => Promise, +): Promise { + const authPath = resolveAuthPath(); + await ensureAuthFileExists(authPath); + const incomingSnapshot = await parseAuthSnapshotFile(authPath); + const activeName = await getCurrentAccountName(); + const existing = await resolveExistingAccountNameForIncomingSnapshot( + incomingSnapshot, + activeName, + ); + if (existing) return existing; + + return { + name: await inferAccountNameFromSnapshot(incomingSnapshot), + source: "inferred", + }; +} + +export async function resolveLoginAccountNameFromCurrentAuth( + getCurrentAccountName: () => Promise, +): Promise { + const authPath = resolveAuthPath(); + await ensureAuthFileExists(authPath); + const incomingSnapshot = await parseAuthSnapshotFile(authPath); + const activeName = await getCurrentAccountName(); + return resolveLoginAccountNameForSnapshot(incomingSnapshot, activeName); +} diff --git a/src/lib/accounts/write/use.ts b/src/lib/accounts/write/use.ts new file mode 100644 index 0000000..ed7061e --- /dev/null +++ b/src/lib/accounts/write/use.ts @@ -0,0 +1,128 @@ +// Write-side: useAccount + activateSnapshot + resolveUsableAccountName. +// Extracted from AccountService (Theme N2). +// +// `activateSnapshot` is the I/O primitive: copy a saved snapshot file over +// ~/.codex/auth.json, fix permissions, mark the active name. `useAccount` +// is the public wrapper that also syncs the registry. + +import path from "node:path"; +import fsp from "node:fs/promises"; +import { + resolveAuthPath, +} from "../../config/paths"; +import { + chmodSecureFile, + ensureSecureDir, +} from "../../io/secure-fs"; +import { + AccountNotFoundError, + AmbiguousAccountQueryError, +} from "../errors"; +import { parseAuthSnapshotFile } from "../auth-parser"; +import { loadRegistry } from "../registry"; +import { accountFilePath, normalizeAccountName } from "../naming"; +import { + hydrateSnapshotMetadataIfMissing, + persistRegistry, +} from "../_internal/registry-ops"; +import { listAccountNames } from "../read/listing"; +import { writeCurrentName } from "../_internal/auth-state"; +import { pathExists, readAuthSyncState } from "../_internal/fs-helpers"; + +export async function activateSnapshot(accountName: string): Promise { + const name = normalizeAccountName(accountName); + const source = accountFilePath(name); + + if (!(await pathExists(source))) { + throw new AccountNotFoundError(name); + } + + const authPath = resolveAuthPath(); + await ensureSecureDir(path.dirname(authPath)); + await fsp.copyFile(source, authPath); + await chmodSecureFile(authPath); + + const authState = await readAuthSyncState(authPath); + await writeCurrentName(name, { + authFingerprint: authState && !authState.isSymbolicLink ? authState.fingerprint : undefined, + }); +} + +async function findSnapshotNamesByExactEmail(rawEmail: string): Promise { + const normalizedEmail = rawEmail.trim().toLowerCase(); + if (!normalizedEmail.includes("@")) { + return []; + } + + const accountNames = await listAccountNames(); + const matches: string[] = []; + for (const name of accountNames) { + const snapshotPath = 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" }), + ); +} + +export async function resolveUsableAccountName( + accountName: string, + syncExternalAuthSnapshotIfNeeded: () => Promise, +): Promise { + if (await pathExists(accountFilePath(accountName))) { + return accountName; + } + + await syncExternalAuthSnapshotIfNeeded(); + + if (await pathExists(accountFilePath(accountName))) { + return accountName; + } + + const emailMatches = await findSnapshotNamesByExactEmail(accountName); + if (emailMatches.length === 1) { + return emailMatches[0]; + } + if (emailMatches.length > 1) { + throw new AmbiguousAccountQueryError(accountName); + } + + throw new AccountNotFoundError(accountName); +} + +export async function useAccount( + rawName: string, + syncExternalAuthSnapshotIfNeeded: () => Promise, +): Promise { + const name = normalizeAccountName(rawName); + const resolvedName = await resolveUsableAccountName( + name, + syncExternalAuthSnapshotIfNeeded, + ); + await activateSnapshot(resolvedName); + + const registry = await loadRegistry(); + await hydrateSnapshotMetadataIfMissing(registry, resolvedName); + registry.activeAccountName = resolvedName; + await persistRegistry(registry); + + return resolvedName; +} + +export async function sessionSnapshotExists( + getSessionAccountName: () => Promise, +): Promise { + const sessionAccountName = await getSessionAccountName(); + if (!sessionAccountName) { + return true; + } + return pathExists(accountFilePath(sessionAccountName)); +} diff --git a/src/tests/accounts-modules.test.ts b/src/tests/accounts-modules.test.ts new file mode 100644 index 0000000..380e2bf --- /dev/null +++ b/src/tests/accounts-modules.test.ts @@ -0,0 +1,347 @@ +// Cluster-level smoke tests for the modules extracted in Theme N2. +// One suite per cluster: listing, session/pin, snapshot-vault, auto-switch +// config, auto-switch policy, usage adapter, write/use, write/remove, +// external-sync. Heavy save-account behavior is covered by +// `save-account-safety.test.ts`; list-side usage refresh by +// `account-list-usage-refresh.test.ts`. These add the per-cluster +// regression seam the protocol calls for. + +import test, { TestContext } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fsp from "node:fs/promises"; + +import { listAccountNames, loadReconciledRegistry } from "../lib/accounts/read/listing"; +import { + clearSessionAccountName, + getActiveSessionAccountName, + getSessionAccountName, + resolveSessionScopeKey, + setSessionAccountName, +} from "../lib/accounts/session/pin"; +import { + backupAllSnapshots, + clearSnapshotBackupVault, + restoreClobberedSnapshotsFromBackup, +} from "../lib/accounts/safety/snapshot-vault"; +import { + configureAutoSwitchThresholds, + getStatus, + setApiUsageEnabled, +} from "../lib/accounts/config/auto-switch-config"; +import { + runAutoSwitchOnce, + selectBestCandidateFromRegistry, +} from "../lib/accounts/auto-switch/policy"; +import { + isUsageMissingForList, + lookupProxyUsage, +} from "../lib/accounts/usage/adapter"; +import { activateSnapshot } from "../lib/accounts/write/use"; +import { removeAccounts } from "../lib/accounts/write/remove"; +import { syncExternalAuthSnapshotIfNeeded } from "../lib/accounts/sync/external-sync"; +import { AutoSwitchConfigError, AccountNotFoundError } from "../lib/accounts/errors"; +import { resolveAuthPath, resolveSnapshotBackupDir } from "../lib/config/paths"; +import { RegistryData, UsageSnapshot } from "../lib/accounts/types"; + +function encodeBase64Url(input: string): string { + return Buffer.from(input, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function buildAuthPayload( + email: string, + options?: { accountId?: string; userId?: string }, +): string { + const accountId = options?.accountId ?? "acct-1"; + const userId = options?.userId ?? "user-1"; + const idTokenPayload = { + email, + "https://api.openai.com/auth": { + chatgpt_account_id: accountId, + chatgpt_user_id: userId, + chatgpt_plan_type: "team", + }, + }; + const idToken = `${encodeBase64Url(JSON.stringify({ alg: "none" }))}.${encodeBase64Url( + JSON.stringify(idTokenPayload), + )}.sig`; + return JSON.stringify( + { + tokens: { + access_token: `token-${email}`, + refresh_token: `refresh-${email}`, + id_token: idToken, + account_id: accountId, + }, + }, + null, + 2, + ); +} + +async function withIsolatedCodexDir( + t: TestContext, + fn: (paths: { codexDir: string; accountsDir: string; authPath: string }) => Promise, +): Promise { + const codexDir = await fsp.mkdtemp(path.join(os.tmpdir(), "authmux-modules-")); + const accountsDir = path.join(codexDir, "accounts"); + const authPath = path.join(codexDir, "auth.json"); + await fsp.mkdir(accountsDir, { recursive: true }); + + const previousEnv = { + CODEX_AUTH_CODEX_DIR: process.env.CODEX_AUTH_CODEX_DIR, + 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, + }; + + process.env.CODEX_AUTH_CODEX_DIR = codexDir; + delete process.env.CODEX_AUTH_ACCOUNTS_DIR; + delete process.env.CODEX_AUTH_JSON_PATH; + delete process.env.CODEX_AUTH_CURRENT_PATH; + process.env.CODEX_AUTH_SESSION_KEY = `test-${path.basename(codexDir)}`; + process.env.CODEX_AUTH_SESSION_ACTIVE_OVERRIDE = "1"; + + t.after(async () => { + 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 }); + }); + + await fn({ codexDir, accountsDir, authPath }); +} + +// -- read/listing.ts --------------------------------------------------------- + +test("listAccountNames returns sorted .json names and ignores registry.json", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir }) => { + await fsp.writeFile(path.join(accountsDir, "bob.json"), "{}", "utf8"); + await fsp.writeFile(path.join(accountsDir, "alice.json"), "{}", "utf8"); + await fsp.writeFile(path.join(accountsDir, "registry.json"), "{}", "utf8"); + await fsp.writeFile(path.join(accountsDir, "ignored.txt"), "x", "utf8"); + const names = await listAccountNames(); + assert.deepEqual(names, ["alice", "bob"]); + }); +}); + +test("loadReconciledRegistry creates entries for snapshots without a row", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir }) => { + await fsp.writeFile(path.join(accountsDir, "alice.json"), "{}", "utf8"); + const reg = await loadReconciledRegistry(); + assert.ok(reg.accounts["alice"]); + assert.equal(reg.accounts["alice"].name, "alice"); + }); +}); + +// -- session/pin.ts ---------------------------------------------------------- + +test("session/pin round-trips an account name through the session map", async (t) => { + await withIsolatedCodexDir(t, async () => { + const key = resolveSessionScopeKey(); + assert.ok(key && key.startsWith("session:")); + await setSessionAccountName("alice"); + assert.equal(await getSessionAccountName(), "alice"); + assert.equal(await getActiveSessionAccountName(), "alice"); + await clearSessionAccountName(); + assert.equal(await getSessionAccountName(), null); + }); +}); + +// -- safety/snapshot-vault.ts ----------------------------------------------- + +test("backup vault copies snapshots and restores after clobber", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir }) => { + const aliceFile = path.join(accountsDir, "alice.json"); + const original = buildAuthPayload("alice@example.com", { accountId: "orig" }); + await fsp.writeFile(aliceFile, original, "utf8"); + + await backupAllSnapshots(listAccountNames); + const vault = path.join(resolveSnapshotBackupDir(), "alice.json"); + const backed = await fsp.readFile(vault, "utf8"); + assert.equal(backed, original); + + // Simulate codex clobbering alice.json with a different identity. + await fsp.writeFile( + aliceFile, + buildAuthPayload("alice@example.com", { accountId: "clobbered" }), + "utf8", + ); + await restoreClobberedSnapshotsFromBackup(); + const restored = await fsp.readFile(aliceFile, "utf8"); + assert.equal(restored, original); + + await clearSnapshotBackupVault(); + assert.equal( + await fsp + .access(resolveSnapshotBackupDir()) + .then(() => true) + .catch(() => false), + false, + ); + }); +}); + +// -- config/auto-switch-config.ts ------------------------------------------- + +test("getStatus returns defaults on a fresh dir, and setApiUsageEnabled toggles", async (t) => { + await withIsolatedCodexDir(t, async () => { + const before = await getStatus(); + assert.equal(before.autoSwitchEnabled, false); + assert.equal(before.usageMode, "api"); + + await setApiUsageEnabled(false); + const after = await getStatus(); + assert.equal(after.usageMode, "local"); + }); +}); + +test("configureAutoSwitchThresholds rejects out-of-range values", async (t) => { + await withIsolatedCodexDir(t, async () => { + await assert.rejects( + () => configureAutoSwitchThresholds({ threshold5hPercent: 0 }), + AutoSwitchConfigError, + ); + await assert.rejects( + () => configureAutoSwitchThresholds({ thresholdWeeklyPercent: 101 }), + AutoSwitchConfigError, + ); + const ok = await configureAutoSwitchThresholds({ threshold5hPercent: 15 }); + assert.equal(ok.threshold5hPercent, 15); + }); +}); + +// -- auto-switch/policy.ts -------------------------------------------------- + +test("selectBestCandidateFromRegistry prefers highest usageScore", () => { + const lowUsage: UsageSnapshot = { + primary: { usedPercent: 90 }, + secondary: { usedPercent: 95 }, + fetchedAt: new Date().toISOString(), + source: "cached", + }; + const highUsage: UsageSnapshot = { + primary: { usedPercent: 10 }, + secondary: { usedPercent: 5 }, + fetchedAt: new Date().toISOString(), + source: "cached", + }; + const registry: RegistryData = { + version: 1, + autoSwitch: { + enabled: true, + threshold5hPercent: 10, + thresholdWeeklyPercent: 5, + }, + api: { usage: true }, + accounts: { + tired: { name: "tired", createdAt: "now", lastUsage: lowUsage }, + fresh: { name: "fresh", createdAt: "now", lastUsage: highUsage }, + }, + }; + assert.equal(selectBestCandidateFromRegistry(["tired", "fresh"], registry), "fresh"); +}); + +test("runAutoSwitchOnce no-ops when disabled", async (t) => { + await withIsolatedCodexDir(t, async () => { + const result = await runAutoSwitchOnce(async () => null); + assert.equal(result.switched, false); + assert.equal(result.reason, "auto-switch is disabled"); + }); +}); + +// -- usage/adapter.ts ------------------------------------------------------- + +test("lookupProxyUsage normalizes case + trims", () => { + const sample: UsageSnapshot = { + primary: { usedPercent: 10 }, + fetchedAt: new Date().toISOString(), + source: "proxy", + }; + const map = new Map([["alice@example.com", sample]]); + assert.equal(lookupProxyUsage(map, " ALICE@example.com "), sample); + assert.equal(lookupProxyUsage(map, ""), null); + assert.equal(lookupProxyUsage(map, undefined), null); +}); + +test("isUsageMissingForList true when no usage at all", () => { + assert.equal(isUsageMissingForList(undefined, 0), true); +}); + +// -- write/use.ts (activateSnapshot) --------------------------------------- + +test("activateSnapshot copies snapshot to auth.json and writes current-name", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir }) => { + const aliceSnap = buildAuthPayload("alice@example.com"); + await fsp.writeFile(path.join(accountsDir, "alice.json"), aliceSnap, "utf8"); + await activateSnapshot("alice"); + const authPath = resolveAuthPath(); + const written = await fsp.readFile(authPath, "utf8"); + assert.equal(written, aliceSnap); + assert.equal(await getSessionAccountName(), "alice"); + }); +}); + +test("activateSnapshot throws AccountNotFoundError for an unknown name", async (t) => { + await withIsolatedCodexDir(t, async () => { + await assert.rejects(() => activateSnapshot("ghost"), AccountNotFoundError); + }); +}); + +// -- write/remove.ts ------------------------------------------------------- + +test("removeAccounts deletes the snapshot file and prunes registry", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir }) => { + await fsp.writeFile(path.join(accountsDir, "alice.json"), "{}", "utf8"); + await fsp.writeFile(path.join(accountsDir, "bob.json"), "{}", "utf8"); + + const result = await removeAccounts(["alice"], async () => null); + assert.deepEqual(result.removed, ["alice"]); + const names = await listAccountNames(); + assert.deepEqual(names, ["bob"]); + }); +}); + +test("removeAccounts refuses a name that does not exist", async (t) => { + await withIsolatedCodexDir(t, async () => { + await assert.rejects( + () => removeAccounts(["ghost"], async () => null), + AccountNotFoundError, + ); + }); +}); + +// -- sync/external-sync.ts ------------------------------------------------- + +test("syncExternalAuthSnapshotIfNeeded returns no-op when no auth file", async (t) => { + await withIsolatedCodexDir(t, async () => { + const result = await syncExternalAuthSnapshotIfNeeded(); + assert.equal(result.synchronized, false); + assert.equal(result.autoSwitchDisabled, false); + }); +}); + +test("syncExternalAuthSnapshotIfNeeded imports a fresh codex login", async (t) => { + await withIsolatedCodexDir(t, async ({ accountsDir, authPath }) => { + await fsp.writeFile(authPath, buildAuthPayload("alice@example.com"), "utf8"); + const result = await syncExternalAuthSnapshotIfNeeded(); + assert.equal(result.synchronized, true); + assert.equal(result.savedName, "alice@example.com"); + const written = await fsp.readFile( + path.join(accountsDir, "alice@example.com.json"), + "utf8", + ); + assert.ok(written.includes("alice@example.com")); + }); +}); diff --git a/src/tests/identity-equality.test.ts b/src/tests/identity-equality.test.ts new file mode 100644 index 0000000..70e2ba8 --- /dev/null +++ b/src/tests/identity-equality.test.ts @@ -0,0 +1,102 @@ +// Unit tests for src/lib/accounts/identity/equality.ts (Theme N2). +// Pure functions, no I/O — exercise each branch. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + registryEntrySharesEmail, + registryEntrySharesIdentity, + renderSnapshotIdentity, + snapshotsShareEmail, + snapshotsShareIdentity, +} from "../lib/accounts/identity/equality"; +import { AccountRegistryEntry, ParsedAuthSnapshot } from "../lib/accounts/types"; + +function snap(partial: Partial): ParsedAuthSnapshot { + return { authMode: "chatgpt", ...partial }; +} + +test("snapshotsShareIdentity matches on accountId+userId", () => { + assert.equal( + snapshotsShareIdentity( + snap({ accountId: "a", userId: "u" }), + snap({ accountId: "a", userId: "u" }), + ), + true, + ); + assert.equal( + snapshotsShareIdentity( + snap({ accountId: "a", userId: "u1" }), + snap({ accountId: "a", userId: "u2" }), + ), + false, + ); +}); + +test("snapshotsShareIdentity falls back to accountId alone", () => { + assert.equal( + snapshotsShareIdentity(snap({ accountId: "a" }), snap({ accountId: "a" })), + true, + ); +}); + +test("snapshotsShareIdentity falls back to userId alone", () => { + assert.equal( + snapshotsShareIdentity(snap({ userId: "u" }), snap({ userId: "u" })), + true, + ); +}); + +test("snapshotsShareIdentity falls back to lower-cased email when all else missing", () => { + assert.equal( + snapshotsShareIdentity(snap({ email: "FOO@bar.com" }), snap({ email: "foo@BAR.com" })), + true, + ); +}); + +test("snapshotsShareIdentity refuses non-chatgpt modes", () => { + assert.equal( + snapshotsShareIdentity( + { authMode: "apikey", email: "a@b" }, + { authMode: "chatgpt", email: "a@b" }, + ), + false, + ); +}); + +test("snapshotsShareEmail is true only when both emails set & match", () => { + assert.equal(snapshotsShareEmail(snap({ email: "a@b" }), snap({ email: "a@b" })), true); + assert.equal(snapshotsShareEmail(snap({ email: "a@b" }), snap({ email: "x@y" })), false); + assert.equal(snapshotsShareEmail(snap({}), snap({ email: "a@b" })), false); +}); + +test("registryEntrySharesIdentity / Email round-trip", () => { + const entry: AccountRegistryEntry = { + name: "n", + createdAt: "now", + email: "user@example.com", + accountId: "acct", + userId: "uid", + }; + assert.equal( + registryEntrySharesIdentity(entry, snap({ accountId: "acct", userId: "uid" })), + true, + ); + assert.equal( + registryEntrySharesIdentity(entry, snap({ accountId: "OTHER", userId: "OTHER" })), + false, + ); + assert.equal( + registryEntrySharesEmail(entry, snap({ email: "user@example.com" })), + true, + ); +}); + +test("renderSnapshotIdentity joins email + account + user", () => { + const out = renderSnapshotIdentity( + snap({ accountId: "acct", userId: "uid" }), + "user@example.com", + ); + assert.equal(out, "user@example.com | account:acct | user:uid"); +}); diff --git a/src/tests/naming.test.ts b/src/tests/naming.test.ts new file mode 100644 index 0000000..636856b --- /dev/null +++ b/src/tests/naming.test.ts @@ -0,0 +1,63 @@ +// Unit tests for src/lib/accounts/naming.ts (Theme N2). + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + accountFilePath, + isValidPercent, + normalizeAccountName, +} from "../lib/accounts/naming"; +import { InvalidAccountNameError } from "../lib/accounts/errors"; + +test("normalizeAccountName strips a trailing .json", () => { + assert.equal(normalizeAccountName("alice.json"), "alice"); +}); + +test("normalizeAccountName accepts emails, plus, dots, underscores", () => { + assert.equal(normalizeAccountName("foo.bar+work@example.com"), "foo.bar+work@example.com"); +}); + +test("normalizeAccountName rejects empty / whitespace", () => { + assert.throws(() => normalizeAccountName(""), InvalidAccountNameError); + assert.throws(() => normalizeAccountName(" "), InvalidAccountNameError); +}); + +test("normalizeAccountName rejects names starting with a special char", () => { + assert.throws(() => normalizeAccountName(".hidden"), InvalidAccountNameError); + assert.throws(() => normalizeAccountName("-leading-dash"), InvalidAccountNameError); +}); + +test("normalizeAccountName rejects non-string input", () => { + assert.throws( + () => normalizeAccountName(undefined as unknown as string), + InvalidAccountNameError, + ); +}); + +test("accountFilePath joins the accounts dir with `.json`", () => { + const previous = process.env.CODEX_AUTH_ACCOUNTS_DIR; + const os = require("node:os") as typeof import("node:os"); + const path = require("node:path") as typeof import("node:path"); + const dir = path.join(os.tmpdir(), "authmux-test-accounts"); + process.env.CODEX_AUTH_ACCOUNTS_DIR = dir; + try { + assert.equal(accountFilePath("alice"), path.join(dir, "alice.json")); + } finally { + if (previous === undefined) { + delete process.env.CODEX_AUTH_ACCOUNTS_DIR; + } else { + process.env.CODEX_AUTH_ACCOUNTS_DIR = previous; + } + } +}); + +test("isValidPercent enforces 1..100 integer range", () => { + assert.equal(isValidPercent(1), true); + assert.equal(isValidPercent(100), true); + assert.equal(isValidPercent(50), true); + assert.equal(isValidPercent(0), false); + assert.equal(isValidPercent(101), false); + assert.equal(isValidPercent(1.5), false); + assert.equal(isValidPercent(NaN), false); +});