/** * Multi-credential pool storage for the CLEO LLM layer (T-LLM-CRED Phase 2). * * Persists a versioned list of provider credentials at * `~/.cleo/llm-credentials.json` (XDG-aware, resolved via `getCleoHome()` so * the test-only `XDG_DATA_HOME` override applies identically to every CLEO * global file). * * ## Goals * * - Multiple credentials per provider (label → entry), each with priority, * disabled-flag, expiry, and a free-form `metadata` bag. * - Strict 0600 perms on every successful write. * - Cross-process locking via `proper-lockfile` reused through `withLock`. * - Atomic writes via `writeJsonFileAtomic` (temp → rename + numbered backup). * - Synchronous read path so the resolver in `credentials.ts` can call it * from its existing sync function. * * Design lock-in (per plan): * • `provider` MUST be a valid `ModelTransport`. * • `(provider, label)` is the uniqueness key — `addCredential` upserts. * • `priority` defaults to `max(existing) + 10` so new entries lose to all * existing ones unless an explicit priority is supplied. * • Round-robin picker keeps state in-memory per provider — sufficient for * a single-process orchestrator; durable round-robin is a future change. * • Storage-time auth type widens the wire-time `AuthType` to include * `'aws_sdk'` for forward-compat with Bedrock. `credentials.ts` narrows * `'aws_sdk' → 'api_key'` until Phase 3 widens the on-wire union. * * Reference: Hermes `credential_pool.py:32-33` defined the on-disk schema * we mirror here. * * @module llm/credentials-store * @task T9257 * @epic T-LLM-CRED-CENTRALIZATION */ import type { ModelTransport } from './types-config.js'; /** * Authentication scheme as persisted on disk. * * Wider than `AuthType` in `credentials.ts` (Phase 1 wire-level): adds * `'aws_sdk'` so a Bedrock / Vertex credential can be stored today and the * resolver can route it once Phase 3 widens the on-wire `AuthType`. * * @task T9257 */ export type StoredAuthType = 'api_key' | 'oauth' | 'aws_sdk'; /** * Persisted strategy for `pickCredentialForProvider` when the caller does * not pass an explicit `strategy` option. * * - `priorityWithFallback` — sort by priority asc, pick first eligible. * On callers that surface failures, the next eligible entry can be tried. * - `priorityOnly` — only the top-priority entry is returned; no * fallback list. Useful when a caller wants to fail fast on a known role. * - `roundRobin` — rotate across eligible entries (in-memory * index per provider). Best-effort load distribution. * * @task T9257 */ export type CredentialsStoreStrategy = 'priorityWithFallback' | 'roundRobin' | 'priorityOnly'; /** * One stored credential entry. * * `(provider, label)` is unique. `accessToken` MAY be the empty string when * `authType === 'aws_sdk'` and the AWS SDK provides auth out-of-band. * * @task T9257 */ export interface StoredCredential { /** LLM transport this credential is for. */ provider: ModelTransport; /** Human-readable identifier, unique within `provider`. */ label: string; /** Storage-level auth scheme; see `StoredAuthType`. */ authType: StoredAuthType; /** Bearer token / API key. May be `""` for `aws_sdk`. */ accessToken: string; /** Unix epoch ms; entries past this time are excluded by the picker. */ expiresAt?: number | null; /** Lower wins. Defaults to file order on read; `max+10` on add. */ priority: number; /** Free-form provenance label (`claude-code`, `cli-input`, etc.). */ source?: string; /** Optional override for provider base URL. */ baseUrl?: string | null; /** Extra HTTP headers carried alongside the credential. */ extraHeaders?: Record; /** Free-form metadata bag (e.g. Bedrock region, account id). */ metadata?: Record; /** * Status of last request through this credential. * * `'ok'` — healthy; `'exhausted'` — in active cooldown (rate-limited / * billing / server error); `'invalid'` — auth rejected and credential * should be considered permanently broken until operator rotation. * * `undefined` means no request has been observed yet. * * @task T9265 */ lastStatus?: 'ok' | 'exhausted' | 'invalid'; /** * Last HTTP error code observed (401, 402, 429, 500, etc.). * * Set by `CredentialPool.markExhausted`; cleared by `markOk`. * * @task T9265 */ lastErrorCode?: number; /** * Epoch ms when the active cooldown expires. * * `undefined` (or a value <= `Date.now()`) means no active cooldown. * Set by `CredentialPool.markExhausted`; cleared by `markOk`. * * @task T9265 */ lastErrorResetAt?: number; /** * Cumulative request count via this credential since pool start. * * Incremented on every successful `CredentialPool.pick()` call. Used by * the `least_used` rotation strategy. * * @task T9265 */ requestCount?: number; /** When true, the picker skips this entry entirely. */ disabled?: boolean; /** * OAuth refresh token. * * Stored alongside the access token when an OAuth device-code or PKCE * flow provides one. The refresh flow (T9266 / Phase 3) consumes this * field to obtain a new `accessToken` when the current one expires. * * The writer (`addCredential`) preserves this value through upserts so * that a re-add of the same `(provider, label)` pair does not silently * drop an existing refresh token. * * Security: stored in plaintext alongside the access token. The same * 0600 file permission that protects `accessToken` applies here. See the * security-review note in the S-07 block above for the Phase 2 rationale * for deferral and Phase 3 reintroduction. * * @task T9266 */ refreshToken?: string; } /** * S-07 (CWE-256) — resolution history: Phase 2 removed `refreshToken` * because no code consumed it (dead secret on disk = blast radius for zero * benefit). Phase 3 reintroduced it together with the refresh flow that * consumes it (`credential-pool.ts` `_refreshViaPkce` / `proactiveRefresh` * and the kimi-code device-code refresh), which is why the field exists on * {@link StoredCredential} above and is persisted by `addCredential`. The * 0600 file permission protecting `accessToken` covers it. * * @task T9257 — security review S-07 (Phase-2 removal) * @task T9260 — Phase-3 reintroduction with the refresh flow */ /** * On-disk shape of `~/.cleo/llm-credentials.json`. * * `version` is reserved for forward-compatible migrations. * * @task T9257 */ export interface CredentialsStoreData { version: 1; defaultStrategy: CredentialsStoreStrategy; credentials: StoredCredential[]; } /** * Absolute path of the credential store file. * * Resolved through `getCleoHome()` so XDG overrides apply uniformly with * `credentials.ts`'s global-config tier. * * @task T9257 */ export declare function credentialsStorePath(): string; /** Internal test hook: reset the once-warned latch. */ export declare function _resetPermsWarningForTests(): void; /** Internal test hook: reset round-robin cursor state. */ export declare function _resetRoundRobinForTests(): void; /** * Synchronous variant of `pickCredentialForProvider`. Called from * `credentials.ts`'s sync `resolveCredentials()` tier-3 block. See the * async wrapper below for the canonical public API. * * Strategy resolution falls back to the store's `defaultStrategy` when * `opts.strategy` is not provided. Empty / disabled / expired pools yield * `null`. * * @task T9257 */ export declare function pickCredentialForProviderSync(provider: ModelTransport, opts?: { strategy?: CredentialsStoreStrategy; preferLabel?: string; }): StoredCredential | null; /** * List credentials in the pool, optionally filtered to a single provider. * * Returns `[]` when the file does not exist. Never throws. * * @task T9257 */ export declare function listCredentials(provider?: ModelTransport): Promise; /** * Look up a single credential by `(provider, label)`. Returns `null` when * no match exists. * * @task T9257 */ export declare function getCredentialByLabel(provider: ModelTransport, label: string): Promise; /** * Upsert a credential — replaces any existing `(provider, label)` pair. * * - When `input.priority` is omitted, the new entry receives * `max(existing priorities) + 10` so it ranks lowest by default. * - Acquires the file lock via `withLock`; safe under concurrent writers. * - chmod 0600 is enforced on the written file. * * Returns the inserted (or replaced) entry as it now lives in the file. * * @task T9257 */ export declare function addCredential(input: Omit & { priority?: number; }): Promise; /** * Remove a credential by `(provider, label)`. * * Returns `true` when a matching entry was found and removed, `false` * otherwise. Does NOT create the file when it does not yet exist. * * @task T9257 */ export declare function removeCredential(provider: ModelTransport, label: string): Promise; /** * Public async picker — thin wrapper over `pickCredentialForProviderSync`. * * The sync variant exists because the resolver in `credentials.ts` is * synchronous; both paths share the same filter + strategy logic. * * @task T9257 */ export declare function pickCredentialForProvider(provider: ModelTransport, opts?: { strategy?: CredentialsStoreStrategy; preferLabel?: string; }): Promise; //# sourceMappingURL=credentials-store.d.ts.map