/** * Multi-credential pool storage for the CLEO LLM layer (T-LLM-CRED Phase 2). * * The pool lives in the global `accounts` table (T13715), written through * the encrypting accessor `store/llm-accounts-accessor.ts`: secrets are * stored under the machine key and the pool syncs on the home stream. * * `~/.cleo/llm-credentials.json` (resolved via `getCleoHome()`) is a LEGACY * source only: {@link migrateLegacyCredentialsFile} moves its entries into * `accounts` once, idempotently, and removes the plaintext file after reading * every entry back. Until then the sync reader also serves the file's entries * that `accounts` lacks, so resolution stays correct mid-migration. * * ## Goals * * - Multiple credentials per provider (label → entry), each with priority, * disabled-flag, expiry, and a free-form `metadata` bag. * - Secrets encrypted at rest; never written in plaintext (T13715). * - 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: encrypted at rest in `accounts.refresh_enc` under the same * machine-key id as `accessToken` (T13715). * * @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 LEGACY credential store file (migrated into the * `accounts` table by {@link migrateLegacyCredentialsFile}, T13715). * * 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; /** * Move the LEGACY `llm-credentials.json` entries into the `accounts` table, * once. Idempotent: an entry `accounts` already holds is kept as it is, and * a missing file is a no-op. The plaintext file is removed only after every * entry reads back from `accounts`; a malformed file is left in place. * * @returns How many entries were moved. * @task T13715 */ export declare function migrateLegacyCredentialsFile(): Promise; /** * Type-narrow check used during read to drop malformed entries silently. * * Exported so the credential transfer path (T12326) validates unsealed * entries with the SAME rule the store applies on read. * * @param value - Candidate entry. * @returns `true` when `value` is a well-formed {@link StoredCredential}. */ export declare function isStoredCredential(value: unknown): value is StoredCredential; /** 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. * - Stored in the `accounts` table with its secrets encrypted under the * machine key (T13715). * * Returns the inserted (or replaced) entry as it now lives in the pool. * * @task T9257 * @task T13715 */ 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. A legacy-file entry is migrated first, so it cannot reappear. * * @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