/** * Credential storage for API keys and OAuth tokens. * Handles loading, saving, refreshing credentials, and usage tracking. * * This module defines: * - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …) * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation */ import { Database } from "bun:sqlite"; import type { Provider } from "./types"; import type { CredentialRankingStrategy, UsageLogger, UsageProvider, UsageReport } from "./usage"; import type { OAuthController, OAuthCredentials, OAuthLoginOptions, OAuthProviderId } from "./utils/oauth/types"; export type ApiKeyCredential = { type: "api_key"; key: string; }; export interface MCPOAuthBinding { /** Exact HTTP(S) origin of the MCP resource endpoint. */ resourceOrigin: string; /** Exact canonical HTTP(S) token endpoint used to create and refresh the credential. */ tokenEndpoint: string; } export declare function resolveMCPOAuthResourceOrigin(value: string): string | undefined; export declare function resolveMCPOAuthTokenEndpoint(value: string): string | undefined; export declare function isCanonicalMCPOAuthBinding(binding: MCPOAuthBinding): boolean; export declare function assertCanonicalMCPOAuthBinding(binding: MCPOAuthBinding | undefined): asserts binding is MCPOAuthBinding; export type OAuthCredential = { type: "oauth"; /** Present only for credentials created by runtime MCP OAuth. */ mcpBinding?: MCPOAuthBinding; } & OAuthCredentials; export type AuthCredential = ApiKeyCredential | OAuthCredential; export interface MCPOAuthRefreshClient { clientId?: string; clientSecret?: string; } export type AuthCredentialEntry = AuthCredential | AuthCredential[]; export type AuthStorageData = Record; /** * Serialized representation of AuthStorage for passing to subagent workers. * Contains only the essential credential data, not runtime state. */ export interface SerializedAuthStorage { credentials: Record; }>>; runtimeOverrides?: Record; dbPath?: string; } /** * Auth credential with database row ID for updates/deletes. * Wraps AuthCredential with storage metadata. */ export interface StoredAuthCredential { id: number; provider: string; credential: AuthCredential; disabledCause: string | null; /** Monotonic local row revision used by optimistic hard-removal actions. */ revision?: number; } /** * Payload-free inventory projection used by account-management and presentation * surfaces. This deliberately has no credential/token fields; `listAuthCredentials` * remains the active full-fidelity selection contract. */ export interface CredentialInventoryRecord { id: number; provider: string; credentialKind: "oauth" | "api_key"; identityLabel: string | null; accountId?: string; email?: string; projectId?: string; disabled: boolean; disabledCause: string | null; } /** Safe usage observation supplied by a remote store's presentation cache. */ export interface CachedUsagePresentation { credentialId: number; provider: string; inventoryGeneration: number; identityDigest: string; usage: SafeUsageReport; fetchedAt: number; freshUntil: number; retainUntil: number; } /** Opaque local action target for an all-or-nothing OAuth hard removal. */ export interface CredentialRemovalTarget { id: number; provider: string; expectedRevision: number; } export type AuthCredentialHardRemovalResult = { kind: "removed"; ids: readonly number[]; } | { kind: "conflict"; currentIds: readonly number[]; }; /** Usage report projection safe to cross a presentation boundary. */ export type SafeUsageReport = Omit; export type CachedUsageFreshness = "fresh" | "stale-last-good"; export interface CachedUsageReport { report: SafeUsageReport; fetchedAt: number; freshUntil: number; retainUntil: number; freshness: CachedUsageFreshness; } export type CachedCredentialHealthStatus = "ok" | "failed" | "unverifiable" | "unknown"; export interface CachedCredentialHealth { status: CachedCredentialHealthStatus; reason: string | null; checkedAt?: number; retainUntil?: number; } /** Safe result from an explicit API-key probe whose key bytes are invocation-only. */ export interface ApiKeyCredentialCheckResult { provider: string; type: "api_key"; ok: boolean | null; reason?: string; report?: SafeUsageReport; } /** Typed failure raised when an OAuth-only selector cannot be applied. */ export type OAuthCredentialSelectorFailureReason = "api-key-row" | "api-key-provider" | "override-active" | "not-found" | "disabled" | "ambiguous" | "gateway-managed"; export declare class OAuthCredentialSelectorError extends Error { readonly reason: OAuthCredentialSelectorFailureReason; readonly provider: string; readonly selector: AuthCredentialSelector; readonly candidateIds: readonly number[]; constructor(reason: OAuthCredentialSelectorFailureReason, provider: string, selector: AuthCredentialSelector, message: string, candidateIds?: readonly number[]); } export interface OAuthPinTarget { credentialId: number; canonicalSelector: AuthCredentialSelector; } /** * Per-credential health record returned by {@link AuthStorage.checkCredentials}. * * Use this to identify which credential in a multi-account pool is causing * auth errors. `ok` is tri-state: * * - `true` — credential authenticated against the provider's auth-verifying * probe (today: the usage endpoint). For OAuth this also exercises refresh * when the access token was expired. * - `false` — the probe rejected the credential (401/403/refresh failure/etc). * `reason` carries the upstream error string. * - `null` — no probe is configured for this provider (or the configured * probe doesn't support this credential type). The credential's auth * status is unverifiable from here. */ export interface CredentialHealthResult { /** Database row id (matches {@link StoredAuthCredential.id}). */ id: number; provider: string; type: AuthCredential["type"]; /** OAuth email if known on the stored credential or surfaced by the probe. */ email?: string; /** OAuth account id / org id if known. */ accountId?: string; /** `true` when the refresh token lives on a remote broker (sentinel was present). */ remoteRefresh?: true; ok: boolean | null; /** Failure / unverifiable reason; absent when `ok === true`. */ reason?: string; report?: SafeUsageReport; } export interface CheckCredentialsOptions { signal?: AbortSignal; provider?: string; /** Per-credential probe timeout (ms). Defaults to the configured usage request timeout. */ timeoutMs?: number; /** Provider → base URL override, same shape as {@link AuthStorage.fetchUsageReports}. */ baseUrlResolver?: (provider: Provider) => string | undefined; } /** Options for the explicit, invocation-only API-key probe. */ export interface ApiKeyCredentialCheckOptions { signal?: AbortSignal; timeoutMs?: number; baseUrl?: string; } /** * Sentinel value placed in OAuth `refresh` fields when a credential is shared * via {@link AuthStorage.exportSnapshot}. Refresh tokens never leave the broker; * clients must call back to refresh. */ export declare const REMOTE_REFRESH_SENTINEL: "__remote__"; export type RemoteRefreshSentinel = typeof REMOTE_REFRESH_SENTINEL; /** OAuth credential with refresh token replaced by the broker sentinel. */ export type RemoteOAuthCredential = Omit & { refresh: RemoteRefreshSentinel; }; /** Discriminated credential payload as published by the broker. */ export type SnapshotCredential = ApiKeyCredential | RemoteOAuthCredential; export interface AuthCredentialSnapshotEntry { id: number; provider: string; credential: SnapshotCredential; identityKey: string | null; } export type AuthCredentialIfAbsentReason = "inserted" | "updated-existing" | "skipped-existing" | "skipped-existing-runtime" | "skipped-existing-config" | "skipped-existing-env" | "skipped-existing-fallback" | "skipped-invalid"; export interface AuthCredentialIfAbsentResult { inserted: boolean; reason: AuthCredentialIfAbsentReason; provider: string; entries: StoredAuthCredential[]; } export interface AuthCredentialIfAbsentSnapshotResult { inserted: boolean; reason: AuthCredentialIfAbsentReason; provider: string; entries: AuthCredentialSnapshotEntry[]; } /** * Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and * served by the auth-broker server on `GET /v1/snapshot`. */ export interface AuthCredentialSnapshot { generation: number; generatedAt: number; credentials: AuthCredentialSnapshotEntry[]; } /** * Persistence abstraction consumed by {@link AuthStorage}. * * Concrete implementations: * - {@link SqliteAuthCredentialStore} — local SQLite-backed store (default). * - `RemoteAuthCredentialStore` from `./auth-broker` — client-side snapshot of * a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`) * throw because login flows route through the broker, not the client. */ export type OAuthRefreshLease = { credentialId: number; owner: string; tokenFingerprint: string; }; export type OAuthRefreshLeaseClaim = { kind: "claimed"; credential: OAuthCredential; lease: OAuthRefreshLease; } | { kind: "adopted"; credential: OAuthCredential; } | { kind: "busy"; expiresAt: number; } | { kind: "missing"; }; export interface AuthCredentialStore { close(): void; listAuthCredentials(provider?: string): StoredAuthCredential[]; /** Payload-free account inventory; active and soft-disabled rows are included. */ listCredentialInventory?(provider?: string): CredentialInventoryRecord[]; /** Local opaque removal targets; remote stores may omit this capability. */ listCredentialRemovalTargets?(provider?: string): CredentialRemovalTarget[]; /** Transactional local hard removal; remote stores must reject this capability. */ removeAuthCredentialsHard?(provider: string, targets: readonly CredentialRemovalTarget[]): AuthCredentialHardRemovalResult; updateAuthCredential(id: number, credential: AuthCredential): void; deleteAuthCredential(id: number, disabledCause: string): void; tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean; replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[]; upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[]; upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult; deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void; getCache(key: string, options?: { includeExpired?: boolean; }): string | null; setCache(key: string, value: string, expiresAtSec: number): void; deleteCachePrefix?(prefix: string): void; cleanExpiredCache(): void; /** * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses * it before the per-provider local refresh path. `RemoteAuthCredentialStore` * implements this against the broker; SQLite stores leave it undefined. * * Precedence: `AuthStorageOptions.refreshOAuthCredential` > this hook > local. * * `signal` propagates the agent's cancel (ESC, request abort, …) all the * way to the broker fetch so a hung connection can't strand the caller * for `timeoutMs * (maxRetries + 1)`. */ refreshOAuthCredential?(provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal): Promise; /** Broker-backed MCP refresh using the broker's stored token endpoint and refresh secret. */ refreshMCPOAuthCredential?(credentialId: number, credential: OAuthCredential, client: MCPOAuthRefreshClient, signal?: AbortSignal): Promise; /** * Atomically adopts a fresh row or claims the current refresh token for one * local provider dial. SQLite-backed stores use this to prevent another * process from replaying a rotating refresh token between a pre-read and * the provider request. */ claimOAuthRefreshLease?(credentialId: number, expectedRefresh: string, force: boolean, owner: string, nowMs: number, leaseMs: number): OAuthRefreshLeaseClaim; /** Atomically persists a successful claimed refresh and releases its lease. */ completeOAuthRefreshLease?(lease: OAuthRefreshLease, credential: OAuthCredential): boolean; /** Releases an uncompleted refresh lease owned by this process. */ releaseOAuthRefreshLease?(lease: OAuthRefreshLease): void; /** * Optional async pre-read hook invoked after AuthStorage selects a stored * credential but before it returns that credential for an outbound request. * Remote broker stores use this to wait out imminent rotations and refresh * their local snapshot before the caller sees a stale access token. */ prepareForRequest?(credentialId: number, opts?: { signal?: AbortSignal; }): Promise; /** * Optional store-supplied aggregate usage fetch. When present, `AuthStorage` * routes `fetchUsageReports()` here instead of fanning out per-credential. * `RemoteAuthCredentialStore` proxies to the broker (whose datacenter IP * isn't rate-limited like a heavy residential client). * * Precedence: `AuthStorageOptions.fetchUsageReports` > this hook > local fan-out. * * `signal` propagates the agent's cancel down to the broker fetch. */ fetchUsageReports?(signal?: AbortSignal): Promise; /** Synchronous, zero-network usage presentation peek. */ peekCachedUsagePresentation?(provider: Provider, credentialId: number): CachedUsagePresentation | undefined; /** Record a safe usage observation after an explicit fetch/check. */ recordUsagePresentation?(observation: CachedUsagePresentation): void; /** Read a safe, durable health observation for one credential row. */ peekCachedCredentialHealth?(provider: Provider, credentialId: number): CachedCredentialHealth | undefined; /** Persist a safe health observation for one credential row. */ recordCredentialHealth?(provider: Provider, credentialId: number, health: CachedCredentialHealth): void; /** Persist a safe usage observation without exposing credential payloads. */ recordCredentialUsage?(provider: Provider, credentialId: number, report: SafeUsageReport): void; /** * Optional readiness hook for stores that must hydrate payload-free metadata * before one-shot inventory consumers read their first snapshot. */ waitForReady?(): Promise; /** * Optional store-supplied per-credential usage report lookup. When present, * `AuthStorage` consults this before its own per-credential upstream fetch * (`#getUsageReport`). `RemoteAuthCredentialStore` implements this against * the broker's aggregate `/v1/usage` (one coalesced round-trip shared across * all callers) so multi-credential ranking on the client never hits the * upstream provider's rate-limited usage endpoint from the laptop IP. * * Returning `null` is authoritative — `AuthStorage` does NOT fall back to * the local fetch path. The store hook owns the decision, since falling * back would re-introduce the per-IP rate-limit problem the broker exists * to avoid. * * `signal` propagates the agent's cancel down to the broker fetch. */ getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise; /** * Optional store hook to invalidate a specific credential after the upstream * provider returned 401 on a supposedly-fresh key. Remote stores force the * broker to re-issue the row; local stores can leave it undefined and let * {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`. */ markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal; }): Promise; /** * Optional async write hook for upserting a single credential. When present, * `AuthStorage.#upsertOAuthCredential` routes through this instead of the * sync `upsertAuthCredentialForProvider`. `RemoteAuthCredentialStore` uses * it to send the upsert to the broker via `POST /v1/credential`. * * Implementations MUST update the in-memory snapshot before returning so the * post-write read path is consistent. */ upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise; upsertAuthCredentialRemoteIfAbsent?(provider: string, credential: AuthCredential): Promise; /** * Optional async write hook for replace-all semantics (e.g. API-key login * overwriting any previous keys for the same provider). When present, * `AuthStorage.set` routes through this instead of the sync * `replaceAuthCredentialsForProvider`. */ replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise; /** * Optional async write hook for clearing every credential for a provider * (logout or a provider-wide invalidation). Remote stores must perform this * through their authoritative broker rather than mutating the client cache. */ deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise; } /** * Event payload describing a credential that was just soft-disabled. * * Today the only call site is OAuth refresh failures with a definitive cause * (`invalid_grant`, `401/403` not from a network blip, etc.) — the * disabled_cause string is the verbatim error captured for forensics. * * Subscribers can use this to surface a notification, banner, or auto-launch * a re-login flow instead of letting the credential silently disappear. */ export interface CredentialDisabledEvent { provider: string; disabledCause: string; } /** * How {@link AuthStorage} orders multiple healthy OAuth credentials of the same * provider:type pool when selecting one for a (new) session. * * - `balanced` (default): prefer the least-used / lowest-drain-rate account. * Spreads load across accounts and keeps burst headroom on every account. * - `earliest-reset`: prefer the non-blocked account whose usage window resets * soonest (earliest-expiry-first). Tumbling-window quota is perishable — * unused quota is lost at reset — so draining the soonest-to-reset account * first minimizes wasted quota. Drain/used metrics remain tiebreakers. * * Only affects ranking, which the `shouldRank` guard already limits to session * start (or when the session's preferred credential is blocked), so this never * thrashes accounts mid-session / cold-starts the server-side prompt cache. */ export type CredentialRankingMode = "balanced" | "earliest-reset"; export type AuthStorageOptions = { usageProviderResolver?: (provider: Provider) => UsageProvider | undefined; rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined; credentialRankingMode?: CredentialRankingMode; usageFetch?: typeof fetch; usageRequestTimeoutMs?: number; usageLogger?: UsageLogger; /** * Resolve a config value (API key, header value, etc.) to an actual value. * - coding-agent injects its resolveConfigValue (supports "!command" syntax via pi-natives) * - Default: checks environment variable first, then treats as literal * `cacheScope` changes whenever the provider credential configuration changes. */ configValueResolver?: (config: string, cacheScope?: string) => Promise; /** * Optional callback fired when AuthStorage automatically disables a * credential because something detected it as no longer usable — today * that's the OAuth refresh-failure path in `getApiKey`. NOT fired for * user-initiated `remove()` (the user already knows) or dedup of * duplicate credentials (uninteresting hygiene). */ onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise; /** * Override OAuth refresh. When set, `AuthStorage` calls this instead of the * per-provider local refresh function. Receives the credential id so the * implementation can address remote credentials. * * Must return updated {@link OAuthCredentials} with at least `access` and * `expires`. `refresh` may be an opaque sentinel (e.g. `"__remote__"`) when * the actual refresh token never leaves the broker. */ refreshOAuthCredential?: (provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal) => Promise; /** * Human-readable description of the credential store backing this * AuthStorage instance. Surfaced through {@link AuthStorage.describeCredentialSource} * so the TUI can show where a token came from (broker URL or local SQLite path). * * Examples: * - `"local ~/.gjc/agent/agent.db"` * - `"broker http://can.internal:8765"` */ sourceLabel?: string; /** * Override `fetchUsageReports`. When set, `AuthStorage.fetchUsageReports` * calls this instead of fanning out per-credential. The primary use case is * routing through a broker that egresses from a less-throttled IP — e.g. a * residential laptop trips Anthropic's per-IP rate limit on the usage * endpoint and drops 2-of-5 credentials, while the VPS broker gets all 5. * * Implementations may return null when no usage data is available; the * AuthStorage caller surfaces that to its own consumer unchanged. */ fetchUsageReports?: (signal?: AbortSignal) => Promise; }; type AuthApiKeyOptions = { baseUrl?: string; modelId?: string; /** * Caller's cancel signal. Threaded into any broker-bound OAuth refresh so * `ESC` / request abort actually kills a hung broker fetch instead of * stranding the caller for `timeoutMs * (maxRetries + 1)`. */ signal?: AbortSignal; /** Pin selection to one stored credential instead of using round-robin/ranking. */ credentialSelector?: AuthCredentialSelector; /** Prefer one stored OAuth credential while preserving quota-triggered fallback. */ preferredCredentialSelector?: AuthCredentialSelector; }; export type AuthCredentialSelectorKind = "id" | "email" | "account" | "project"; export interface AuthCredentialSelector { kind: AuthCredentialSelectorKind; value: string; } /** * Refreshed OAuth access plus identity metadata returned by * {@link AuthStorage.getOAuthAccess}. Callers that authenticate via a bearer * AND need the credential's identity (OpenAI code backend `chatgpt-account-id`, Google * `projectId`, GitHub `enterpriseUrl`) consume this shape directly; the * refresh slot is deliberately omitted because rotating refresh tokens never * leave {@link AuthStorage}. */ export interface OAuthAccess { accessToken: string; accountId?: string; email?: string; projectId?: string; enterpriseUrl?: string; } export interface InvalidateCredentialMatchingOptions { signal?: AbortSignal; sessionId?: string; } /** * Credential storage backed by an AuthCredentialStore. * Reads from storage on reload(), manages round-robin credential selection, * usage limit tracking, and OAuth token refresh. */ export declare class AuthStorage { #private; constructor(store: AuthCredentialStore, options?: AuthStorageOptions); /** * Create an AuthStorage instance backed by a AuthCredentialStore. * Convenience factory for standalone use (e.g., pi-ai CLI). * @param dbPath - Path to SQLite database */ static create(dbPath: string, options?: AuthStorageOptions): Promise; /** * Close the underlying credential store. * * After calling this, the instance must not be reused. */ close(): void; getGeneration(): number; getProviderConfigurationGeneration(provider: string): number; getProviderOAuthRefreshGeneration(provider: string): number; getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string; onGenerationChanged(listener: (generation: number) => void): () => void; offGenerationChanged(listener: (generation: number) => void): void; /** * Subscribe to {@link CredentialDisabledEvent}s. Multiple subscribers are supported and * each fires for every disable event; subscribers are invoked in registration order with * exceptions and async rejections isolated per-listener so a misbehaving subscriber * cannot break the disable path or starve the rest of the chain. * * If `credential_disabled` events were emitted while no listener was subscribed, they are * replayed (in insertion order) to the listener that triggers the empty→non-empty * transition. The drain is one-shot — listeners that subscribe after that no longer see * past events. * * Returns an unsubscribe function. The function is idempotent: calling it more than once * is a no-op. After every subscriber has unsubscribed, subsequent disable events buffer * again until the next subscribe. * * @param listener Callback invoked with each disable event. May be sync or async. * @returns A function that removes this listener from the subscriber set. */ onCredentialDisabled(listener: (event: CredentialDisabledEvent) => void | Promise): () => void; /** * Set a runtime API key override (not persisted to disk). * Used for CLI --api-key flag. */ setRuntimeApiKey(provider: string, apiKey: string): void; /** * Pin credential selection for a provider (not persisted to disk). * Used for CLI --credential. */ setRuntimeCredentialSelector(provider: string, selector: AuthCredentialSelector): void; /** Acquire a reference-counted credential scope for a session or shared subagent scope. */ acquireCredentialScope(scopeId: string): void; /** Whether a credential scope already has at least one live owner. */ hasCredentialScopeLease(scopeId: string): boolean; /** Release one credential-scope lease; final release clears only that scope's derived state. */ releaseCredentialScope(scopeId: string): void; /** Set the selector derived from a durable session pin or a session seed. */ setSessionCredentialSelector(scopeId: string, provider: string, selector: AuthCredentialSelector): void; /** Explicitly mask persistent/process-global selection and return the provider to AUTO for one scope. */ setSessionCredentialAuto(provider: string, scopeId: string): void; /** Clear a scope's explicit selector and AUTO mask, restoring normal precedence. */ clearSessionCredentialSelector(provider: string, scopeId: string): void; /** Whether the effective selection for a scope is explicitly pinned (AUTO masks are not pins). */ hasSessionCredentialSelector(provider: string, scopeId?: string): boolean; /** Whether this scope explicitly masks provider pins and uses AUTO ranking. */ hasSessionCredentialAuto(provider: string, scopeId?: string): boolean; /** Resolve the effective selector precedence for a provider/scope. */ resolveEffectiveCredentialSelector(provider: string, scopeId?: string, explicitSelector?: AuthCredentialSelector): AuthCredentialSelector | undefined; /** Validate and canonicalize an OAuth-only selector for account pinning. */ resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector): OAuthPinTarget; /** Return all local inventory rows, including soft-disabled metadata, without payloads. */ listCredentialInventory(provider?: string): CredentialInventoryRecord[]; /** Return local credential hard-removal action targets, including disabled rows. */ listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[]; /** Remove selected local credential rows atomically; conflict leaves all rows intact. */ removeAuthCredentialsHard(provider: string, targets: readonly CredentialRemovalTarget[]): AuthCredentialHardRemovalResult; /** * Remove a runtime credential selector. */ removeRuntimeCredentialSelector(provider: string): void; /** Whether a provider currently has a soft runtime credential preference. */ hasRuntimePreferredCredentialSelector(provider: string): boolean; /** Resolve an unqualified preferred selector to the single active OAuth provider it matches. */ resolveRuntimePreferredCredentialSelectorProvider(selector: AuthCredentialSelector): string; /** * Prefer one stored OAuth credential for a provider while retaining quota * fallback to the rest of the pool (not persisted to disk). Used for CLI * `--prefer-credential`. Unlike {@link setRuntimeCredentialSelector}, a * quota/rate-limit failure on the preferred row still rotates to another * active credential instead of failing the session. */ setRuntimePreferredCredentialSelector(provider: string, selector: AuthCredentialSelector): void; /** * Remove a runtime preferred credential selector. */ removeRuntimePreferredCredentialSelector(provider: string): void; /** * Remove a runtime API key override. */ removeRuntimeApiKey(provider: string): void; /** Whether a provider is currently authenticated by a runtime API-key override. */ hasRuntimeApiKey(provider: string): boolean; /** Whether a provider is currently authenticated by a config API-key override. */ hasConfigApiKey(provider: string): boolean; /** * Whether credential selection for a provider is pinned to one stored row by * a runtime selector (`--credential`). * * Distinct from {@link AuthStorage.hasRuntimeApiKey}: that reports the * `--api-key` override, which lives in a different map and is mutually * exclusive with a selector. Callers that must not rotate away from a pinned * credential have to consult BOTH. */ hasRuntimeCredentialSelector(provider: string): boolean; /** Whether the effective selector for a session scope is pinned. */ hasEffectiveCredentialSelector(provider: string, sessionId?: string): boolean; /** * Opaque stored row id of the credential this session is currently using. * * Deliberately non-identifying: the persisted primary key, never an email, * account id, project id, or key material. Callers that need to correlate a * credential across a session boundary use this instead of projecting * personal metadata. * * Returns `undefined` when the session has not been routed to a stored * credential yet, or when it authenticated through an env key or fallback * resolver rather than a stored row. */ getSessionCredentialRowId(provider: string, sessionId?: string): number | undefined; /** * Force a running session's OAuth credential for a provider to a specific * stored row, independent of quota/rate-limit state. Used for a mid-session * `/credential ` switch that has nothing to do with exhaustion — * the user just wants a different account for the rest of the session. * * This mutates ONLY the session-scoped sticky pointer * ({@link AuthStorage.#recordSessionCredential}), never a provider-wide * runtime override, so it cannot bleed into other sessions in the same * process whose credential identity differs. The sticky pointer is keyed by * `sessionId`, and subagents/team workers inherit their parent's * `credentialSessionId` by design so they keep using the same account as * the parent — a switch therefore applies to the whole session family * sharing that identity, not to unrelated sessions. * * Fails closed rather than silently no-op when a stronger override already * decides this provider's credential every call: a hard pin * ({@link AuthStorage.setRuntimeCredentialSelector}, `--credential`), a * runtime API-key override (`--api-key`), or a config-sourced API key * (`models.yml` `apiKey`) would each re-decide the credential on the very * next {@link AuthStorage.getApiKey} call and make this switch appear to * silently do nothing. * * Deliberately does not touch credential-blocked state: if the target row * is still backoff-blocked from a prior quota failure, the existing * `#resolveOAuthSelection` ranking safely ignores this sticky pointer and * falls back to a usable account instead of re-issuing a request that would * just draw another 429/quota error. */ switchSessionCredential(provider: string, sessionId: string, selector: AuthCredentialSelector): void; /** * Register a per-provider API key sourced from user configuration * (e.g. `models.yml` `providers..apiKey`). Higher priority than * stored credentials and OAuth tokens — when the user pins a key in * config, that key is what authenticates outbound requests, regardless * of whatever the broker happens to have loaded for that provider. * * Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key` * still wins for the duration of a single invocation. */ setConfigApiKey(provider: string, apiKey: string): void; /** * Remove a single config-sourced API key override. */ removeConfigApiKey(provider: string): void; /** * Drop every config-sourced API key. Called by `ModelRegistry` before * re-parsing `models.yml` so removed entries actually disappear. */ clearConfigApiKeys(): void; /** * Set a fallback resolver for API keys not found in storage or env vars. * Used for custom provider keys from models.json. */ setFallbackResolver(resolver: (provider: string) => string | undefined): void; /** * Reload credentials from storage. */ reload(): Promise; /** Returns the credential type selected for a provider/session, if one has been recorded. */ getSessionCredentialType(provider: string, sessionId?: string): AuthCredential["type"] | undefined; /** * Get credential for a provider (first entry if multiple). */ get(provider: string): AuthCredential | undefined; /** * Set credential for a provider. */ set(provider: string, credential: AuthCredentialEntry): Promise; importCredentialIfAbsent(provider: string, credential: AuthCredential): Promise; /** * Remove credential for a provider. */ remove(provider: string): Promise; /** * List all providers with credentials. */ list(): string[]; /** * Check if credentials exist for a provider in storage. */ has(provider: string): boolean; hasAuth(provider: string, sessionId?: string): boolean; /** * Credential type that a provider/session will dispatch first without performing I/O. * Mirrors getApiKey selector validation, overrides, session OAuth stickiness, * cached command-key usability, OAuth retry, and environment fallback order. */ getEffectiveCredentialType(provider: string, sessionId?: string): AuthCredential["type"] | undefined; /** * Check whether configured auth is currently usable without resolving credentials. */ hasUsableAuth(provider: string): boolean; /** * Check if OAuth credentials are configured for a provider. */ hasOAuth(provider: string): boolean; /** * Get OAuth credentials for a provider. */ getOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined; /** * Get the OAuth `accountId` for a provider, preferring the credential that is * session-sticky for `sessionId` when multiple OAuth credentials are configured. * Falls back to the first OAuth credential when no session preference exists (e.g. * first call before any `getApiKey` has been issued, or single-credential setups). * Returns `undefined` when no OAuth credential carries an `accountId`. */ getOAuthAccountId(provider: string, sessionId?: string): string | undefined; /** * Get all credentials. */ getAll(): AuthStorageData; /** * Login to an OAuth provider. */ login(provider: OAuthProviderId, ctrl: OAuthController & { /** onAuth is required by auth-storage but optional in OAuthController */ onAuth: (info: { url: string; instructions?: string; }) => void; /** onPrompt is required for some providers (github-copilot, OpenAI code provider) */ onPrompt: (prompt: { message: string; placeholder?: string; }) => Promise; }, options?: OAuthLoginOptions): Promise; /** * Logout from a provider. */ logout(provider: string): Promise; fetchUsageReports(options?: { baseUrlResolver?: (provider: Provider) => string | undefined; /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */ signal?: AbortSignal; /** Disable provider/account/error logging for secret-safe control surfaces. */ logDetails?: boolean; }): Promise; /** * Probe each stored credential against its provider's auth-verifying usage * endpoint and report per-credential auth health. * * Surfaces the identity of failing credentials so callers running a * multi-account pool (e.g. a broker-backed auth-gateway) can tell which * row is producing 401s. The probe mirrors the per-credential fan-out * inside {@link AuthStorage.fetchUsageReports} (OAuth refresh-on-expiry, * then `UsageProvider.fetchUsage`) but does NOT swallow errors — every * credential gets either `ok: true`, `ok: false` with `reason`, or * `ok: null` when no probe is configured for the provider. * * Iterates sequentially to avoid synchronized N-account fan-out that * upstream `/usage` rate limiters (per source IP) treat as a burst. * * Only inspects active rows from {@link AuthCredentialStore.listAuthCredentials}; * soft-disabled rows are already known-bad and don't need a network probe. * Environment-variable API keys are not enumerated — the caller's intent * here is "which of my stored credentials is broken". */ /** Return a safe cache-only usage observation. */ getCachedUsageReport(provider: Provider, credentialId: number, baseUrl?: string): CachedUsageReport | undefined; /** Cache-only health observation; unknown means no retained explicit check. */ getCachedCredentialHealth(credentialId: number): CachedCredentialHealth; peekCachedCredentialHealthForSource(provider: string, source: "env" | "config" | "runtime"): CachedCredentialHealth; recordCredentialHealthForSource(provider: string, source: "env" | "config" | "runtime", health: CachedCredentialHealth): void; /** Explicit API-key probe; key bytes are not retained in the returned result. */ checkApiKeyCredential(provider: Provider, apiKey: string, options?: ApiKeyCredentialCheckOptions): Promise; checkCredentials(options?: CheckCredentialsOptions): Promise; /** * Marks the current session's credential as temporarily blocked due to usage limits. * Uses usage reports to determine accurate reset time when available. * Returns true if a credential was blocked, enabling automatic fallback to the next credential. */ markUsageLimitReached(provider: string, sessionId: string | undefined, options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; }): Promise; /** * Earliest instant at which any currently blocked stored credential for this * provider becomes usable again. Undefined when nothing is blocked. * When `sessionId` is provided, only the session's active credential type is * considered — API-key and OAuth backoff pools are independent. * Informational only: callers must not treat this as authorization to wait. */ getEarliestUnblockAt(provider: string, sessionId?: string): number | undefined; /** * Peek at API key for a provider without refreshing OAuth tokens. * Used for model discovery where we only need to know if credentials exist * and get a best-effort token. For GitHub Copilot we preserve enterprise * routing metadata so discovery can hit the correct host. */ peekApiKey(provider: string): Promise; /** * Get API key for a provider. * Priority: * 1. Runtime override (CLI --api-key) * 2. Config override (models.yml `providers..apiKey`) * 3. Session-selected OAuth credential, when present * 4. Usable or unresolved API key from storage * 5. OAuth token from storage (auto-refreshed) * 6. Previously unusable command-backed API key retry * 7. Environment variable * 8. Fallback resolver (models.yml custom providers, last-resort) */ getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise; /** * Resolve the OAuth credential for `provider`, refreshing through the same * pipeline as {@link AuthStorage.getApiKey} but returning the refreshed * {@link OAuthAccess} (raw access token + identity metadata) instead of * the API-key bytes. * * Use this when the caller needs to inject identity headers alongside the * bearer (OpenAI code backend `chatgpt-account-id`, Google `project`, GitHub * `enterpriseUrl`). For pure "give me the bytes for `Authorization`" * scenarios, prefer {@link AuthStorage.getApiKey}. * * Returns `undefined` when no OAuth credential is available, the * credential fails to refresh, or runtime/config overrides have replaced * OAuth with an explicit API key. */ getOAuthAccess(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise; invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise; invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise; /** * Build a redacted snapshot of all loaded credentials for the auth-broker * wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL} * so clients never see the actual refresh token. * * Callers must {@link AuthStorage.reload} first when serving a stale snapshot * (the broker server's HTTP handler does this). */ exportSnapshot(): AuthCredentialSnapshot; /** * Refresh the OAuth credential with the given id through a per-credential * single-flight. Concurrent callers for the same row await the same upstream * refresh attempt, which is required for providers that rotate refresh tokens * on every successful refresh. */ refreshCredentialById(id: number, signal?: AbortSignal, mcpClient?: MCPOAuthRefreshClient): Promise; /** * Force-refresh the OAuth credential with the given id, bypassing the * not-yet-expired guard. Used by the auth-broker server to honour * `POST /v1/credential/:id/refresh`. * * Returns the redacted snapshot entry for the refreshed row. * Throws when no OAuth credential with that id is loaded. */ forceRefreshCredentialById(id: number, signal?: AbortSignal): Promise; /** Force-refresh the first OAuth credential stored for a provider. */ forceRefreshOAuthCredential(provider: string, expected: OAuthCredential, client?: MCPOAuthRefreshClient, signal?: AbortSignal): Promise; /** * Disable the credential with the given id and emit a * {@link CredentialDisabledEvent}. Used by the auth-broker server to honour * `POST /v1/credential/:id/disable`. Returns `false` when no such row exists. */ disableCredentialById(id: number, disabledCause: string): boolean; /** * Upsert a credential into the underlying store, refresh the in-memory * snapshot, and return the redacted snapshot entries for the provider. * * Used by the auth-broker server to honour `POST /v1/credential`. The * persistence layer (`SqliteAuthCredentialStore.upsertAuthCredentialForProvider`) * does identity-key matching, so re-uploading the same email/account replaces * the existing row instead of inserting a duplicate. */ upsertCredential(provider: string, credential: AuthCredential): AuthCredentialSnapshotEntry[]; /** * Describe where the active credential for a provider came from. * * Surfaces four layers, highest precedence first: * 1. Runtime override (`--api-key`). * 2. Config override (`models.yml` `providers..apiKey`). * 3. Stored credential (the one this session is currently sticky to, or the * one round-robin would pick next when no session id is supplied). * 4. Env var / fallback resolver — when no stored credential exists. * * The string is purely informational; consumers must not parse it. */ describeCredentialSource(provider: string, sessionId?: string): string | undefined; } /** * Default SQLite-backed implementation of {@link AuthCredentialStore}. * * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly * without going through `AuthStorage`. */ export declare class SqliteAuthCredentialStore implements AuthCredentialStore { #private; constructor(db: Database); static open(dbPath?: string): Promise; listAuthCredentials(provider?: string): StoredAuthCredential[]; listCredentialInventory(provider?: string): CredentialInventoryRecord[]; listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[]; removeAuthCredentialsHard(provider: string, targets: readonly CredentialRemovalTarget[]): AuthCredentialHardRemovalResult; claimOAuthRefreshLease(credentialId: number, expectedRefresh: string, force: boolean, owner: string, nowMs: number, leaseMs: number): OAuthRefreshLeaseClaim; completeOAuthRefreshLease(lease: OAuthRefreshLease, credential: OAuthCredential): boolean; releaseOAuthRefreshLease(lease: OAuthRefreshLease): void; replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[]; upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[]; upsertAuthCredentialForProviderIfAbsent(provider: string, credential: AuthCredential): AuthCredentialIfAbsentResult; updateAuthCredential(id: number, credential: AuthCredential): void; deleteAuthCredential(id: number, disabledCause: string): void; /** * CAS-style disable: only soft-deletes the row when its `data` column still * matches `expectedData` and the row has not already been disabled. Used by * the OAuth refresh-failure path to avoid clobbering a peer that rotated the * row between our pre-check and the disable. */ tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean; deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void; getCache(key: string, options?: { includeExpired?: boolean; }): string | null; setCache(key: string, value: string, expiresAtSec: number): void; deleteCachePrefix(prefix: string): void; cleanExpiredCache(): void; /** * Save OAuth credentials for a provider. * Preserves unrelated identities and replaces only the matching credential. */ saveOAuth(provider: string, credentials: OAuthCredentials): void; /** * Get OAuth credentials for a provider. */ getOAuth(provider: string): OAuthCredentials | null; /** * Save API key for a provider (replaces existing). */ saveApiKey(provider: string, apiKey: string): void; /** * Get API key for a provider. */ getApiKey(provider: string): string | null; /** * List all providers with credentials. */ listProviders(): string[]; /** * Delete all credentials for a provider. */ deleteProvider(provider: string): void; close(): void; } export {};