/** * CredentialStore implementation backed by auth.json. * Provider auth orchestration belongs to ModelRuntime and pi-ai Models. */ import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { setTimeout as sleep } from "timers/promises"; import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; import { stripBom } from "../utils/text.ts"; import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts"; type AuthStorageData = Record; type LockResult = { result: T; next?: string; }; // The mode applies only on creation so administrator-managed modes and ACLs remain intact. const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const; type AuthFileReload = { controller: AbortController; promise: Promise; readers: number; }; type AuthFileReadState = { data: AuthStorageData; revision?: string; reload?: AuthFileReload; }; let sharedAuthFileReadState: { authPath: string; readState: AuthFileReadState } | undefined; export interface AuthStorageBackend { withLock(fn: (current: string | undefined) => LockResult): T; withLockAsync( fn: (current: string | undefined) => Promise>, options?: AuthOperationOptions, ): Promise; } export class FileAuthStorageBackend implements AuthStorageBackend { private authPath: string; constructor(authPath: string = join(getAgentDir(), "auth.json")) { this.authPath = normalizePath(authPath); } private ensureParentDir(): void { const dir = dirname(this.authPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); } } private ensureFileExists(): void { if (!existsSync(this.authPath)) { writeFileSync(this.authPath, "{}", AUTH_FILE_WRITE_OPTIONS); } } private acquireLockSyncWithRetry(path: string): () => void { const maxAttempts = 10; const delayMs = 20; let lastError: unknown; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return lockfile.lockSync(path, { realpath: false }); } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : undefined; if (code !== "ELOCKED" || attempt === maxAttempts) { throw error; } lastError = error; const start = Date.now(); while (Date.now() - start < delayMs) { // Sleep synchronously to avoid changing callers to async. } } } throw (lastError as Error) ?? new Error("Failed to acquire auth storage lock"); } withLock(fn: (current: string | undefined) => LockResult): T { this.ensureParentDir(); this.ensureFileExists(); let release: (() => void) | undefined; try { release = this.acquireLockSyncWithRetry(this.authPath); const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; const { result, next } = fn(current); if (next !== undefined) { writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); } return result; } finally { if (release) { release(); } } } private async acquireLockAsync( signal: AbortSignal | undefined, onCompromised: (error: Error) => void, ): Promise<() => Promise> { const staleMs = 30_000; const maxDelayMs = 2_000; const deadline = Date.now() + staleMs; let retry = 0; while (true) { signal?.throwIfAborted(); let release: (() => Promise) | undefined; try { release = await lockfile.lock(this.authPath, { realpath: false, retries: 0, stale: staleMs, onCompromised, }); } catch (error) { signal?.throwIfAborted(); const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : undefined; const remainingMs = deadline - Date.now(); if (code !== "ELOCKED" || remainingMs <= 0) throw error; const baseDelayMs = Math.min(10 * 2 ** retry, maxDelayMs / 2); retry++; const delayMs = Math.min(Math.round(baseDelayMs * (1 + Math.random())), remainingMs); if (signal) await sleep(delayMs, undefined, { signal }); else await sleep(delayMs); continue; } if (signal?.aborted) { await release(); signal.throwIfAborted(); } return release; } } async withLockAsync( fn: (current: string | undefined) => Promise>, options?: AuthOperationOptions, ): Promise { options?.signal?.throwIfAborted(); this.ensureParentDir(); this.ensureFileExists(); let release: (() => Promise) | undefined; let lockCompromised = false; let lockCompromisedError: Error | undefined; const throwIfCompromised = () => { if (lockCompromised) { throw lockCompromisedError ?? new Error("Auth storage lock was compromised"); } }; try { release = await this.acquireLockAsync(options?.signal, (error) => { lockCompromised = true; lockCompromisedError = error; }); throwIfCompromised(); options?.signal?.throwIfAborted(); const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; const { result, next } = await fn(current); throwIfCompromised(); options?.signal?.throwIfAborted(); if (next !== undefined) { writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); } throwIfCompromised(); return result; } finally { if (release) { try { await release(); } catch { // Ignore unlock errors when lock is compromised. } } } } } export class ReadOnlyAuthStorage implements CredentialStore { private readonly authPath: string; private data: AuthStorageData | undefined; constructor(authPath: string = join(getAgentDir(), "auth.json")) { this.authPath = normalizePath(authPath); } private load(): AuthStorageData { if (this.data) return this.data; let parsed: unknown; try { parsed = JSON.parse(stripBom(readFileSync(this.authPath, "utf-8"))); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.data = {}; return this.data; } throw new Error(`Failed to read auth.json: ${error instanceof Error ? error.message : String(error)}`); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("Invalid auth.json: expected an object"); } for (const [providerId, credential] of Object.entries(parsed)) { if (typeof credential !== "object" || credential === null || Array.isArray(credential)) { throw new Error(`Invalid auth.json credential for provider "${providerId}"`); } const value = credential as Record; if (value.type === "api_key") { const validKey = value.key === undefined || typeof value.key === "string"; const validEnv = value.env === undefined || (typeof value.env === "object" && value.env !== null && !Array.isArray(value.env) && Object.values(value.env).every((entry) => typeof entry === "string")); if (validKey && validEnv) continue; } else if ( value.type === "oauth" && typeof value.access === "string" && typeof value.refresh === "string" && typeof value.expires === "number" && Number.isFinite(value.expires) ) { continue; } throw new Error(`Invalid auth.json credential for provider "${providerId}"`); } this.data = parsed as AuthStorageData; return this.data; } async read(providerId: string, options?: AuthOperationOptions): Promise { options?.signal?.throwIfAborted(); const credential = this.load()[providerId]; options?.signal?.throwIfAborted(); if (!credential) return undefined; if (credential.type !== "api_key" || !credential.key || isCommandConfigValue(credential.key)) { return structuredClone(credential); } return { ...credential, key: resolveConfigValue(credential.key, credential.env) }; } async list(options?: AuthOperationOptions): Promise { options?.signal?.throwIfAborted(); const credentials = Object.entries(this.load()).map(([providerId, credential]) => ({ providerId, type: credential.type, })); options?.signal?.throwIfAborted(); return credentials; } async modify( _providerId: string, _fn: (current: Credential | undefined) => Promise, _options?: AuthOperationOptions, ): Promise { throw new Error("Read-only credential storage cannot modify auth.json"); } async delete(_providerId: string, _options?: AuthOperationOptions): Promise { throw new Error("Read-only credential storage cannot modify auth.json"); } } export class InMemoryAuthStorageBackend implements AuthStorageBackend { private value: string | undefined; private asyncChain: Promise = Promise.resolve(); withLock(fn: (current: string | undefined) => LockResult): T { const { result, next } = fn(this.value); if (next !== undefined) { this.value = next; } return result; } withLockAsync( fn: (current: string | undefined) => Promise>, options?: AuthOperationOptions, ): Promise { const previous = this.asyncChain; const operation = (async () => { await previous.catch(() => {}); options?.signal?.throwIfAborted(); const { result, next } = await fn(this.value); options?.signal?.throwIfAborted(); if (next !== undefined) { this.value = next; } return result; })(); this.asyncChain = operation.catch(() => {}); return raceWithAbortSignal(operation, options?.signal); } } /** * Credential storage backed by a JSON file. */ export class AuthStorage implements CredentialStore { private storage: AuthStorageBackend; private authPath: string | undefined; private readState: AuthFileReadState; private constructor(storage: AuthStorageBackend, authPath?: string) { this.storage = storage; this.authPath = authPath; this.readState = authPath && sharedAuthFileReadState?.authPath === authPath ? sharedAuthFileReadState.readState : { data: {} }; if (authPath && !sharedAuthFileReadState) { sharedAuthFileReadState = { authPath, readState: this.readState }; } if (authPath) { const revision = getFileRevision(authPath); if (revision !== undefined && revision === this.readState.revision) return; } this.reload(); } static create(authPath: string = join(getAgentDir(), "auth.json")): AuthStorage { const normalizedAuthPath = normalizePath(authPath); return new AuthStorage(new FileAuthStorageBackend(normalizedAuthPath), normalizedAuthPath); } static fromStorage(storage: AuthStorageBackend): AuthStorage { return new AuthStorage(storage); } static inMemory(data: AuthStorageData = {}): AuthStorage { const storage = new InMemoryAuthStorageBackend(); storage.withLock(() => ({ result: undefined, next: JSON.stringify(data, null, 2) })); return AuthStorage.fromStorage(storage); } private parseStorageData(content: string | undefined): AuthStorageData { if (!content) { return {}; } return JSON.parse(stripBom(content)) as AuthStorageData; } private updateReadState(data: AuthStorageData, revision?: string): void { this.readState.data = data; this.readState.revision = revision; } /** * Reload credentials from storage. */ reload(): void { let content: string | undefined; let revision: string | undefined; try { this.storage.withLock((current) => { content = current; revision = this.authPath ? getFileRevision(this.authPath) : undefined; return { result: undefined }; }); this.updateReadState(this.parseStorageData(content), revision); } catch { // Preserve the last valid in-memory snapshot. } } private async reloadFromStorageAsync(options?: AuthOperationOptions): Promise { return this.storage.withLockAsync(async (content) => { const currentData = this.parseStorageData(content); const revision = this.authPath ? getFileRevision(this.authPath) : undefined; this.updateReadState(currentData, revision); return { result: currentData }; }, options); } private async readLatestData(options?: AuthOperationOptions): Promise { options?.signal?.throwIfAborted(); if (!this.authPath) { const reload = this.reloadFromStorageAsync(options); return options?.signal ? reload : reload.catch(() => this.readState.data); } const revision = getFileRevision(this.authPath); if (revision !== undefined && revision === this.readState.revision) return this.readState.data; if (!this.readState.reload) { const controller = new AbortController(); const reload: AuthFileReload = { controller, promise: this.reloadFromStorageAsync({ signal: controller.signal }), readers: 0, }; this.readState.reload = reload; void reload.promise.then( () => { if (this.readState.reload === reload) this.readState.reload = undefined; }, () => { if (this.readState.reload === reload) this.readState.reload = undefined; }, ); } const reload = this.readState.reload; reload.readers++; try { const result = raceWithAbortSignal(reload.promise, options?.signal); return options?.signal ? await result : await result.catch(() => this.readState.data); } finally { reload.readers--; if (reload.readers === 0 && this.readState.reload === reload) { this.readState.reload = undefined; reload.controller.abort(); } } } async read(provider: string, options?: AuthOperationOptions): Promise { const credential = (await this.readLatestData(options))[provider]; options?.signal?.throwIfAborted(); if (credential?.type !== "api_key") return credential; if (credential.key === undefined) return credential; return { ...credential, key: resolveConfigValue(credential.key, credential.env) }; } async modify( provider: string, fn: (current: Credential | undefined) => Promise, options?: AuthOperationOptions, ): Promise { let latestData = this.readState.data; let revision: string | undefined; const result = await this.storage.withLockAsync(async (content) => { const currentData = this.parseStorageData(content); const next = await fn(currentData[provider]); if (next === undefined) { latestData = currentData; revision = this.authPath ? getFileRevision(this.authPath) : undefined; return { result: currentData[provider] }; } const merged: AuthStorageData = { ...currentData, [provider]: next }; latestData = merged; return { result: next, next: JSON.stringify(merged, null, 2) }; }, options); this.updateReadState(latestData, revision); return result; } async delete(provider: string, options?: AuthOperationOptions): Promise { let latestData = this.readState.data; await this.storage.withLockAsync(async (content) => { const currentData = this.parseStorageData(content); delete currentData[provider]; latestData = currentData; return { result: undefined, next: JSON.stringify(currentData, null, 2) }; }, options); this.updateReadState(latestData); } /** List credential metadata without resolving configured key values. */ async list(options?: AuthOperationOptions): Promise { const entries = Object.entries(await this.readLatestData(options)); options?.signal?.throwIfAborted(); return entries.map(([providerId, credential]) => ({ providerId, type: credential.type })); } } /** * One-off synchronous read of a stored credential from an auth.json file, * without instantiating a store or resolving configured key values. */ export function readStoredCredential( providerId: string, authPath: string = join(getAgentDir(), "auth.json"), ): Credential | undefined { try { const data = JSON.parse(stripBom(readFileSync(normalizePath(authPath), "utf-8"))) as AuthStorageData; return data[providerId]; } catch { return undefined; } }