Spaces:
Paused
Paused
| 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, log } from "./constants.js"; | |
| import { syncExternalCliCredentials } from "./external-cli-sync.js"; | |
| import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; | |
| import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js"; | |
| type LegacyAuthStore = Record<string, AuthProfileCredential>; | |
| 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<AuthProfileStore | null> { | |
| const authPath = resolveAuthStorePath(params.agentDir); | |
| ensureAuthStoreFile(authPath); | |
| let release: (() => Promise<void>) | 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<string, unknown>; | |
| 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<AuthProfileCredential>; | |
| 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<string, unknown>; | |
| if (!record.profiles || typeof record.profiles !== "object") return null; | |
| const profiles = record.profiles as Record<string, unknown>; | |
| const normalized: Record<string, AuthProfileCredential> = {}; | |
| for (const [key, value] of Object.entries(profiles)) { | |
| if (!value || typeof value !== "object") continue; | |
| const typed = value as Partial<AuthProfileCredential>; | |
| 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<string, unknown>).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<string, string[]>, | |
| ) | |
| : undefined; | |
| return { | |
| version: Number(record.version ?? AUTH_STORE_VERSION), | |
| profiles: normalized, | |
| order, | |
| lastGood: | |
| record.lastGood && typeof record.lastGood === "object" | |
| ? (record.lastGood as Record<string, string>) | |
| : undefined, | |
| usageStats: | |
| record.usageStats && typeof record.usageStats === "object" | |
| ? (record.usageStats as Record<string, ProfileUsageStats>) | |
| : undefined, | |
| }; | |
| } | |
| function mergeRecord<T>( | |
| base?: Record<string, T>, | |
| override?: Record<string, T>, | |
| ): Record<string, T> | 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<string, OAuthCredentials>; | |
| 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); | |
| 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); | |
| 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); | |
| 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); | |
| } | |