import fs from "node:fs"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import lockfile from "proper-lockfile"; import { resolveOAuthPath } from "../../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, CODEX_CLI_PROFILE_ID, log, } from "./constants.js"; import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js"; type LegacyAuthStore = Record; function _syncAuthProfileStore(target: AuthProfileStore, source: AuthProfileStore): void { target.version = source.version; target.profiles = source.profiles; target.order = source.order; target.lastGood = source.lastGood; target.usageStats = source.usageStats; } export async function updateAuthProfileStoreWithLock(params: { agentDir?: string; updater: (store: AuthProfileStore) => boolean; }): Promise { const authPath = resolveAuthStorePath(params.agentDir); ensureAuthStoreFile(authPath); let release: (() => Promise) | undefined; try { release = await lockfile.lock(authPath, AUTH_STORE_LOCK_OPTIONS); const store = ensureAuthProfileStore(params.agentDir); const shouldSave = params.updater(store); if (shouldSave) { saveAuthProfileStore(store, params.agentDir); } return store; } catch { return null; } finally { if (release) { try { await release(); } catch { // ignore unlock errors } } } } function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; if ("profiles" in record) return null; const entries: LegacyAuthStore = {}; for (const [key, value] of Object.entries(record)) { if (!value || typeof value !== "object") continue; const typed = value as Partial; if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") { continue; } entries[key] = { ...typed, provider: String(typed.provider ?? key), } as AuthProfileCredential; } return Object.keys(entries).length > 0 ? entries : null; } function coerceAuthStore(raw: unknown): AuthProfileStore | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; if (!record.profiles || typeof record.profiles !== "object") return null; const profiles = record.profiles as Record; const normalized: Record = {}; for (const [key, value] of Object.entries(profiles)) { if (!value || typeof value !== "object") continue; const typed = value as Partial; if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") { continue; } if (!typed.provider) continue; normalized[key] = typed as AuthProfileCredential; } const order = record.order && typeof record.order === "object" ? Object.entries(record.order as Record).reduce( (acc, [provider, value]) => { if (!Array.isArray(value)) return acc; const list = value .map((entry) => (typeof entry === "string" ? entry.trim() : "")) .filter(Boolean); if (list.length === 0) return acc; acc[provider] = list; return acc; }, {} as Record, ) : undefined; return { version: Number(record.version ?? AUTH_STORE_VERSION), profiles: normalized, order, lastGood: record.lastGood && typeof record.lastGood === "object" ? (record.lastGood as Record) : undefined, usageStats: record.usageStats && typeof record.usageStats === "object" ? (record.usageStats as Record) : undefined, }; } function mergeRecord( base?: Record, override?: Record, ): Record | undefined { if (!base && !override) return undefined; if (!base) return { ...override }; if (!override) return { ...base }; return { ...base, ...override }; } function mergeAuthProfileStores( base: AuthProfileStore, override: AuthProfileStore, ): AuthProfileStore { if ( Object.keys(override.profiles).length === 0 && !override.order && !override.lastGood && !override.usageStats ) { return base; } return { version: Math.max(base.version, override.version ?? base.version), profiles: { ...base.profiles, ...override.profiles }, order: mergeRecord(base.order, override.order), lastGood: mergeRecord(base.lastGood, override.lastGood), usageStats: mergeRecord(base.usageStats, override.usageStats), }; } function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { const oauthPath = resolveOAuthPath(); const oauthRaw = loadJsonFile(oauthPath); if (!oauthRaw || typeof oauthRaw !== "object") return false; const oauthEntries = oauthRaw as Record; let mutated = false; for (const [provider, creds] of Object.entries(oauthEntries)) { if (!creds || typeof creds !== "object") continue; const profileId = `${provider}:default`; if (store.profiles[profileId]) continue; store.profiles[profileId] = { type: "oauth", provider, ...creds, }; mutated = true; } return mutated; } export function loadAuthProfileStore(): AuthProfileStore { const authPath = resolveAuthStorePath(); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); if (asStore) { // Sync from external CLI tools on every load const synced = syncExternalCliCredentials(asStore); if (synced) { saveJsonFile(authPath, asStore); } return asStore; } const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); const legacy = coerceLegacyStore(legacyRaw); if (legacy) { const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {}, }; for (const [provider, cred] of Object.entries(legacy)) { const profileId = `${provider}:default`; if (cred.type === "api_key") { store.profiles[profileId] = { type: "api_key", provider: String(cred.provider ?? provider), key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; } else if (cred.type === "token") { store.profiles[profileId] = { type: "token", provider: String(cred.provider ?? provider), token: cred.token, ...(typeof cred.expires === "number" ? { expires: cred.expires } : {}), ...(cred.email ? { email: cred.email } : {}), }; } else { store.profiles[profileId] = { type: "oauth", provider: String(cred.provider ?? provider), access: cred.access, refresh: cred.refresh, expires: cred.expires, ...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}), ...(cred.projectId ? { projectId: cred.projectId } : {}), ...(cred.accountId ? { accountId: cred.accountId } : {}), ...(cred.email ? { email: cred.email } : {}), }; } } syncExternalCliCredentials(store); return store; } const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} }; syncExternalCliCredentials(store); return store; } function loadAuthProfileStoreForAgent( agentDir?: string, options?: { allowKeychainPrompt?: boolean }, ): AuthProfileStore { const authPath = resolveAuthStorePath(agentDir); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); if (asStore) { // Sync from external CLI tools on every load const synced = syncExternalCliCredentials(asStore, options); if (synced) { saveJsonFile(authPath, asStore); } return asStore; } // Fallback: inherit auth-profiles from main agent if subagent has none if (agentDir) { const mainAuthPath = resolveAuthStorePath(); // without agentDir = main const mainRaw = loadJsonFile(mainAuthPath); const mainStore = coerceAuthStore(mainRaw); if (mainStore && Object.keys(mainStore.profiles).length > 0) { // Clone main store to subagent directory for auth inheritance saveJsonFile(authPath, mainStore); log.info("inherited auth-profiles from main agent", { agentDir }); return mainStore; } } const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir)); const legacy = coerceLegacyStore(legacyRaw); const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {}, }; if (legacy) { for (const [provider, cred] of Object.entries(legacy)) { const profileId = `${provider}:default`; if (cred.type === "api_key") { store.profiles[profileId] = { type: "api_key", provider: String(cred.provider ?? provider), key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; } else if (cred.type === "token") { store.profiles[profileId] = { type: "token", provider: String(cred.provider ?? provider), token: cred.token, ...(typeof cred.expires === "number" ? { expires: cred.expires } : {}), ...(cred.email ? { email: cred.email } : {}), }; } else { store.profiles[profileId] = { type: "oauth", provider: String(cred.provider ?? provider), access: cred.access, refresh: cred.refresh, expires: cred.expires, ...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}), ...(cred.projectId ? { projectId: cred.projectId } : {}), ...(cred.accountId ? { accountId: cred.accountId } : {}), ...(cred.email ? { email: cred.email } : {}), }; } } } const mergedOAuth = mergeOAuthFileIntoStore(store); const syncedCli = syncExternalCliCredentials(store, options); const shouldWrite = legacy !== null || mergedOAuth || syncedCli; if (shouldWrite) { saveJsonFile(authPath, store); } // PR #368: legacy auth.json could get re-migrated from other agent dirs, // overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only // after we've successfully written auth-profiles.json. if (shouldWrite && legacy !== null) { const legacyPath = resolveLegacyAuthStorePath(agentDir); try { fs.unlinkSync(legacyPath); } catch (err) { if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { log.warn("failed to delete legacy auth.json after migration", { err, legacyPath, }); } } } return store; } export function ensureAuthProfileStore( agentDir?: string, options?: { allowKeychainPrompt?: boolean }, ): AuthProfileStore { const store = loadAuthProfileStoreForAgent(agentDir, options); const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); if (!agentDir || authPath === mainAuthPath) { return store; } const mainStore = loadAuthProfileStoreForAgent(undefined, options); const merged = mergeAuthProfileStores(mainStore, store); // Keep per-agent view clean even if the main store has codex-cli. const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID]; if (codexProfile?.type === "oauth") { const duplicateId = findDuplicateCodexProfile(merged, codexProfile); if (duplicateId) { delete merged.profiles[CODEX_CLI_PROFILE_ID]; } } return merged; } export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { const authPath = resolveAuthStorePath(agentDir); const payload = { version: AUTH_STORE_VERSION, profiles: store.profiles, order: store.order ?? undefined, lastGood: store.lastGood ?? undefined, usageStats: store.usageStats ?? undefined, } satisfies AuthProfileStore; saveJsonFile(authPath, payload); }