@gajae-code/ai 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +61 -2
  2. package/dist/types/auth-broker/client.d.ts +9 -1
  3. package/dist/types/auth-broker/redact.d.ts +7 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +50 -9
  5. package/dist/types/auth-broker/types.d.ts +14 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
  7. package/dist/types/auth-storage.d.ts +200 -6
  8. package/dist/types/core.d.ts +1 -0
  9. package/dist/types/model-cache.d.ts +4 -1
  10. package/dist/types/model-manager.d.ts +11 -0
  11. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  12. package/dist/types/provider-models/special.d.ts +3 -0
  13. package/dist/types/providers/anthropic.d.ts +31 -0
  14. package/dist/types/providers/cursor.d.ts +9 -1
  15. package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
  16. package/dist/types/providers/mock.d.ts +8 -0
  17. package/dist/types/providers/register-builtins.d.ts +1 -0
  18. package/dist/types/providers/transform-messages.d.ts +18 -0
  19. package/dist/types/types.d.ts +34 -8
  20. package/dist/types/usage/grok-cli.d.ts +5 -0
  21. package/dist/types/usage.d.ts +6 -0
  22. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  23. package/dist/types/utils/event-stream.d.ts +4 -2
  24. package/dist/types/utils/fallback-transport.d.ts +10 -0
  25. package/dist/types/utils/http-inspector.d.ts +1 -0
  26. package/dist/types/utils/idle-iterator.d.ts +13 -1
  27. package/dist/types/utils/json-parse.d.ts +19 -0
  28. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  29. package/dist/types/utils/oauth/kiro.d.ts +71 -0
  30. package/dist/types/utils/oauth/types.d.ts +1 -1
  31. package/dist/types/utils/parse-bind.d.ts +8 -5
  32. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  33. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  34. package/package.json +3 -2
  35. package/src/auth-broker/client.ts +30 -0
  36. package/src/auth-broker/redact.ts +15 -0
  37. package/src/auth-broker/refresher.ts +4 -2
  38. package/src/auth-broker/remote-store.ts +693 -70
  39. package/src/auth-broker/server.ts +57 -12
  40. package/src/auth-broker/types.ts +16 -0
  41. package/src/auth-broker/wire-schemas.ts +21 -0
  42. package/src/auth-gateway/server.ts +84 -19
  43. package/src/auth-storage.ts +985 -41
  44. package/src/core.ts +1 -0
  45. package/src/model-cache.ts +23 -4
  46. package/src/model-manager.ts +70 -11
  47. package/src/model-thinking.ts +45 -1
  48. package/src/models.json +9604 -1932
  49. package/src/openai-completions-compat.ts +2 -1
  50. package/src/provider-models/descriptors.ts +7 -1
  51. package/src/provider-models/openai-compat.ts +52 -28
  52. package/src/provider-models/special.ts +12 -0
  53. package/src/providers/amazon-bedrock.ts +2 -1
  54. package/src/providers/anthropic.ts +831 -27
  55. package/src/providers/cursor.ts +83 -3
  56. package/src/providers/kiro-codewhisperer.ts +572 -0
  57. package/src/providers/mock.ts +15 -2
  58. package/src/providers/ollama.ts +9 -2
  59. package/src/providers/openai-codex-responses.ts +16 -9
  60. package/src/providers/openai-completions.ts +6 -1
  61. package/src/providers/openai-responses-shared.ts +180 -18
  62. package/src/providers/register-builtins.ts +24 -2
  63. package/src/providers/transform-messages.ts +64 -1
  64. package/src/stream.ts +25 -2
  65. package/src/types.ts +36 -7
  66. package/src/usage/grok-cli.ts +86 -1
  67. package/src/usage.ts +7 -0
  68. package/src/utils/discovery/openai-compatible.ts +89 -4
  69. package/src/utils/event-stream.ts +11 -2
  70. package/src/utils/fallback-transport.ts +44 -2
  71. package/src/utils/http-inspector.ts +1 -0
  72. package/src/utils/idle-iterator.ts +29 -6
  73. package/src/utils/json-parse.ts +80 -0
  74. package/src/utils/oauth/callback-server.ts +31 -1
  75. package/src/utils/oauth/index.ts +14 -1
  76. package/src/utils/oauth/kiro.ts +448 -0
  77. package/src/utils/oauth/synthetic.ts +2 -3
  78. package/src/utils/oauth/types.ts +1 -0
  79. package/src/utils/parse-bind.ts +27 -0
  80. package/src/utils/tool-call-healing.ts +13 -2
  81. package/src/utils/tool-choice-capability.ts +386 -6
@@ -184,6 +184,116 @@ export interface StoredAuthCredential {
184
184
  provider: string;
185
185
  credential: AuthCredential;
186
186
  disabledCause: string | null;
187
+ /** Monotonic local row revision used by optimistic hard-removal actions. */
188
+ revision?: number;
189
+ }
190
+
191
+ /**
192
+ * Payload-free inventory projection used by account-management and presentation
193
+ * surfaces. This deliberately has no credential/token fields; `listAuthCredentials`
194
+ * remains the active full-fidelity selection contract.
195
+ */
196
+ export interface CredentialInventoryRecord {
197
+ id: number;
198
+ provider: string;
199
+ credentialKind: "oauth" | "api_key";
200
+ identityLabel: string | null;
201
+ accountId?: string;
202
+ email?: string;
203
+ projectId?: string;
204
+ disabled: boolean;
205
+ disabledCause: string | null;
206
+ }
207
+
208
+ /** Safe usage observation supplied by a remote store's presentation cache. */
209
+ export interface CachedUsagePresentation {
210
+ credentialId: number;
211
+ provider: string;
212
+ inventoryGeneration: number;
213
+ identityDigest: string;
214
+ usage: SafeUsageReport;
215
+ fetchedAt: number;
216
+ freshUntil: number;
217
+ retainUntil: number;
218
+ }
219
+
220
+ /** Opaque local action target for an all-or-nothing OAuth hard removal. */
221
+ export interface CredentialRemovalTarget {
222
+ id: number;
223
+ provider: string;
224
+ expectedRevision: number;
225
+ }
226
+
227
+ export type AuthCredentialHardRemovalResult =
228
+ | { kind: "removed"; ids: readonly number[] }
229
+ | { kind: "conflict"; currentIds: readonly number[] };
230
+
231
+ /** Usage report projection safe to cross a presentation boundary. */
232
+ export type SafeUsageReport = Omit<UsageReport, "raw">;
233
+
234
+ export type CachedUsageFreshness = "fresh" | "stale-last-good";
235
+
236
+ export interface CachedUsageReport {
237
+ report: SafeUsageReport;
238
+ fetchedAt: number;
239
+ freshUntil: number;
240
+ retainUntil: number;
241
+ freshness: CachedUsageFreshness;
242
+ }
243
+
244
+ export type CachedCredentialHealthStatus = "ok" | "failed" | "unverifiable" | "unknown";
245
+
246
+ export interface CachedCredentialHealth {
247
+ status: CachedCredentialHealthStatus;
248
+ reason: string | null;
249
+ checkedAt?: number;
250
+ retainUntil?: number;
251
+ }
252
+
253
+ /** Safe result from an explicit API-key probe whose key bytes are invocation-only. */
254
+ export interface ApiKeyCredentialCheckResult {
255
+ provider: string;
256
+ type: "api_key";
257
+ ok: boolean | null;
258
+ reason?: string;
259
+ report?: SafeUsageReport;
260
+ }
261
+
262
+ /** Typed failure raised when an OAuth-only selector cannot be applied. */
263
+ export type OAuthCredentialSelectorFailureReason =
264
+ | "api-key-row"
265
+ | "api-key-provider"
266
+ | "override-active"
267
+ | "not-found"
268
+ | "disabled"
269
+ | "ambiguous"
270
+ | "gateway-managed";
271
+
272
+ export class OAuthCredentialSelectorError extends Error {
273
+ readonly reason: OAuthCredentialSelectorFailureReason;
274
+ readonly provider: string;
275
+ readonly selector: AuthCredentialSelector;
276
+ readonly candidateIds: readonly number[];
277
+
278
+ constructor(
279
+ reason: OAuthCredentialSelectorFailureReason,
280
+ provider: string,
281
+ selector: AuthCredentialSelector,
282
+ message: string,
283
+ candidateIds: readonly number[] = [],
284
+ ) {
285
+ super(message);
286
+ this.name = "OAuthCredentialSelectorError";
287
+ this.reason = reason;
288
+ this.provider = provider;
289
+ this.selector = selector;
290
+ this.candidateIds = candidateIds;
291
+ }
292
+ }
293
+
294
+ export interface OAuthPinTarget {
295
+ credentialId: number;
296
+ canonicalSelector: AuthCredentialSelector;
187
297
  }
188
298
 
189
299
  /**
@@ -215,18 +325,25 @@ export interface CredentialHealthResult {
215
325
  ok: boolean | null;
216
326
  /** Failure / unverifiable reason; absent when `ok === true`. */
217
327
  reason?: string;
218
- /** Probe usage report (raw payload stripped) when `ok === true`. */
219
- report?: Omit<UsageReport, "raw">;
328
+ report?: SafeUsageReport;
220
329
  }
221
330
 
222
331
  export interface CheckCredentialsOptions {
223
332
  signal?: AbortSignal;
333
+ provider?: string;
224
334
  /** Per-credential probe timeout (ms). Defaults to the configured usage request timeout. */
225
335
  timeoutMs?: number;
226
336
  /** Provider → base URL override, same shape as {@link AuthStorage.fetchUsageReports}. */
227
337
  baseUrlResolver?: (provider: Provider) => string | undefined;
228
338
  }
229
339
 
340
+ /** Options for the explicit, invocation-only API-key probe. */
341
+ export interface ApiKeyCredentialCheckOptions {
342
+ signal?: AbortSignal;
343
+ timeoutMs?: number;
344
+ baseUrl?: string;
345
+ }
346
+
230
347
  // ─────────────────────────────────────────────────────────────────────────────
231
348
  // Auth Broker Snapshot Types
232
349
  // ─────────────────────────────────────────────────────────────────────────────
@@ -316,6 +433,15 @@ export type OAuthRefreshLeaseClaim =
316
433
  export interface AuthCredentialStore {
317
434
  close(): void;
318
435
  listAuthCredentials(provider?: string): StoredAuthCredential[];
436
+ /** Payload-free account inventory; active and soft-disabled rows are included. */
437
+ listCredentialInventory?(provider?: string): CredentialInventoryRecord[];
438
+ /** Local opaque removal targets; remote stores may omit this capability. */
439
+ listCredentialRemovalTargets?(provider?: string): CredentialRemovalTarget[];
440
+ /** Transactional local hard removal; remote stores must reject this capability. */
441
+ removeAuthCredentialsHard?(
442
+ provider: string,
443
+ targets: readonly CredentialRemovalTarget[],
444
+ ): AuthCredentialHardRemovalResult;
319
445
  updateAuthCredential(id: number, credential: AuthCredential): void;
320
446
  deleteAuthCredential(id: number, disabledCause: string): void;
321
447
  tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
@@ -388,6 +514,21 @@ export interface AuthCredentialStore {
388
514
  * `signal` propagates the agent's cancel down to the broker fetch.
389
515
  */
390
516
  fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
517
+ /** Synchronous, zero-network usage presentation peek. */
518
+ peekCachedUsagePresentation?(provider: Provider, credentialId: number): CachedUsagePresentation | undefined;
519
+ /** Record a safe usage observation after an explicit fetch/check. */
520
+ recordUsagePresentation?(observation: CachedUsagePresentation): void;
521
+ /** Read a safe, durable health observation for one credential row. */
522
+ peekCachedCredentialHealth?(provider: Provider, credentialId: number): CachedCredentialHealth | undefined;
523
+ /** Persist a safe health observation for one credential row. */
524
+ recordCredentialHealth?(provider: Provider, credentialId: number, health: CachedCredentialHealth): void;
525
+ /** Persist a safe usage observation without exposing credential payloads. */
526
+ recordCredentialUsage?(provider: Provider, credentialId: number, report: SafeUsageReport): void;
527
+ /**
528
+ * Optional readiness hook for stores that must hydrate payload-free metadata
529
+ * before one-shot inventory consumers read their first snapshot.
530
+ */
531
+ waitForReady?(): Promise<void>;
391
532
  /**
392
533
  * Optional store-supplied per-credential usage report lookup. When present,
393
534
  * `AuthStorage` consults this before its own per-credential upstream fetch
@@ -434,8 +575,8 @@ export interface AuthCredentialStore {
434
575
  replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
435
576
  /**
436
577
  * Optional async write hook for clearing every credential for a provider
437
- * (logout). When present, `AuthStorage.remove` routes through this instead
438
- * of the sync `deleteAuthCredentialsForProvider`.
578
+ * (logout or a provider-wide invalidation). Remote stores must perform this
579
+ * through their authoritative broker rather than mutating the client cache.
439
580
  */
440
581
  deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise<void>;
441
582
  }
@@ -728,7 +869,10 @@ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>(
728
869
  "grok-build",
729
870
  {
730
871
  findWindowLimits(report) {
731
- return { secondary: report.limits.find(limit => limit.id === "grok-build:7d") };
872
+ const weekly = report.limits.find(limit => limit.id === "grok-build:weekly");
873
+ return {
874
+ secondary: weekly ?? report.limits.find(limit => limit.id === "grok-build:7d"),
875
+ };
732
876
  },
733
877
  windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 30 * 24 * 60 * 60 * 1000 },
734
878
  } satisfies CredentialRankingStrategy,
@@ -862,6 +1006,8 @@ type AuthApiKeyOptions = {
862
1006
  signal?: AbortSignal;
863
1007
  /** Pin selection to one stored credential instead of using round-robin/ranking. */
864
1008
  credentialSelector?: AuthCredentialSelector;
1009
+ /** Prefer one stored OAuth credential while preserving quota-triggered fallback. */
1010
+ preferredCredentialSelector?: AuthCredentialSelector;
865
1011
  };
866
1012
  export type AuthCredentialSelectorKind = "id" | "email" | "account" | "project";
867
1013
 
@@ -898,6 +1044,27 @@ function isAbortSignalOption(
898
1044
  return typeof value === "object" && value !== null && "aborted" in value && "addEventListener" in value;
899
1045
  }
900
1046
 
1047
+ const HEALTH_CACHE_PREFIX = "account_health:v1:local:row:";
1048
+ const SOURCE_HEALTH_CACHE_PREFIX = "account_health:v1:source:";
1049
+ const PRESENTATION_RETENTION_MS = 24 * 60 * 60_000;
1050
+
1051
+ function safeUsageReport(report: UsageReport): SafeUsageReport {
1052
+ const { raw: _raw, ...safe } = report;
1053
+ return safe;
1054
+ }
1055
+
1056
+ function scrubHealthReason(reason: unknown, secrets: readonly string[] = []): string {
1057
+ let value = reason instanceof Error ? reason.message : String(reason);
1058
+ for (const secret of secrets) {
1059
+ if (secret.length > 0) value = value.split(secret).join("[redacted]");
1060
+ }
1061
+ value = value.replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]");
1062
+ value = value.replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]");
1063
+ value = value.replace(/[\r\n\t ]+/g, " ").trim();
1064
+ if (value.length > 256) value = `${value.slice(0, 253)}...`;
1065
+ return value || "credential check failed";
1066
+ }
1067
+
901
1068
  function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
902
1069
  return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
903
1070
  }
@@ -1065,6 +1232,14 @@ export class AuthStorage {
1065
1232
  #runtimeOverrides: Map<string, string> = new Map();
1066
1233
  #configOverrides: Map<string, string> = new Map();
1067
1234
  #runtimeCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
1235
+ /** Soft runtime credential preference per provider; quota failures may rotate away from it. */
1236
+ #runtimePreferredCredentialSelectors: Map<string, AuthCredentialSelector> = new Map();
1237
+ /** Credential selectors explicitly attached to a credential session scope. */
1238
+ #sessionCredentialSelectors: Map<string, Map<string, AuthCredentialSelector>> = new Map();
1239
+ /** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */
1240
+ #sessionCredentialAutoMasks: Map<string, Set<string>> = new Map();
1241
+ /** Reference counts for sessions sharing one credential scope (top-level + subagents). */
1242
+ #credentialScopeLeases: Map<string, number> = new Map();
1068
1243
  /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
1069
1244
  #providerRoundRobinIndex: Map<string, number> = new Map();
1070
1245
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
@@ -1159,6 +1334,10 @@ export class AuthStorage {
1159
1334
  close(): void {
1160
1335
  if (this.#closed) return;
1161
1336
  this.#closed = true;
1337
+ this.#credentialScopeLeases.clear();
1338
+ this.#sessionCredentialSelectors.clear();
1339
+ this.#sessionCredentialAutoMasks.clear();
1340
+ this.#sessionLastCredential.clear();
1162
1341
  this.#store.close();
1163
1342
  }
1164
1343
 
@@ -1182,7 +1361,7 @@ export class AuthStorage {
1182
1361
  const evidenceApiKey = resolvedApiKey;
1183
1362
  let selectedCredential: ({ index: number } & StoredCredential) | undefined;
1184
1363
  try {
1185
- selectedCredential = this.#resolveSelectedStoredCredential(provider);
1364
+ selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
1186
1365
  } catch {
1187
1366
  return crypto
1188
1367
  .createHash("sha256")
@@ -1306,11 +1485,234 @@ export class AuthStorage {
1306
1485
  */
1307
1486
  setRuntimeCredentialSelector(provider: string, selector: AuthCredentialSelector): void {
1308
1487
  const storageProvider = resolveOAuthStorageProvider(provider);
1488
+ if (this.#runtimePreferredCredentialSelectors.has(storageProvider)) {
1489
+ throw new Error(`Credential selector cannot be combined with a preferred credential selector for ${provider}`);
1490
+ }
1309
1491
  this.#assertCredentialSelectorUsable(storageProvider, selector);
1310
1492
  this.#runtimeCredentialSelectors.set(storageProvider, selector);
1311
1493
  this.#bumpGeneration("set-runtime-credential-selector", provider);
1312
1494
  }
1313
1495
 
1496
+ /** Acquire a reference-counted credential scope for a session or shared subagent scope. */
1497
+ acquireCredentialScope(scopeId: string): void {
1498
+ const scope = scopeId.trim();
1499
+ if (!scope) throw new Error("Credential scope id must not be empty");
1500
+ this.#credentialScopeLeases.set(scope, (this.#credentialScopeLeases.get(scope) ?? 0) + 1);
1501
+ }
1502
+
1503
+ /** Whether a credential scope already has at least one live owner. */
1504
+ hasCredentialScopeLease(scopeId: string): boolean {
1505
+ const scope = scopeId.trim();
1506
+ return scope.length > 0 && (this.#credentialScopeLeases.get(scope) ?? 0) > 0;
1507
+ }
1508
+
1509
+ /** Release one credential-scope lease; final release clears only that scope's derived state. */
1510
+ releaseCredentialScope(scopeId: string): void {
1511
+ const scope = scopeId.trim();
1512
+ if (!scope) return;
1513
+ const leases = this.#credentialScopeLeases.get(scope);
1514
+ if (leases === undefined) return;
1515
+ if (leases > 1) {
1516
+ this.#credentialScopeLeases.set(scope, leases - 1);
1517
+ return;
1518
+ }
1519
+ this.#credentialScopeLeases.delete(scope);
1520
+ this.#sessionCredentialSelectors.delete(scope);
1521
+ this.#sessionCredentialAutoMasks.delete(scope);
1522
+ for (const [provider, sessions] of this.#sessionLastCredential) {
1523
+ if (!sessions.delete(scope)) continue;
1524
+ if (sessions.size === 0) this.#sessionLastCredential.delete(provider);
1525
+ }
1526
+ }
1527
+
1528
+ /** Set the selector derived from a durable session pin or a session seed. */
1529
+ setSessionCredentialSelector(scopeId: string, provider: string, selector: AuthCredentialSelector): void {
1530
+ const scope = scopeId.trim();
1531
+ if (!scope) throw new Error("Credential scope id must not be empty");
1532
+ const storageProvider = resolveOAuthStorageProvider(provider);
1533
+ this.#assertCredentialSelectorUsable(storageProvider, selector);
1534
+ const selectors = this.#sessionCredentialSelectors.get(scope) ?? new Map<string, AuthCredentialSelector>();
1535
+ selectors.set(storageProvider, selector);
1536
+ this.#sessionCredentialSelectors.set(scope, selectors);
1537
+ this.#sessionCredentialAutoMasks.get(scope)?.delete(storageProvider);
1538
+ this.#bumpGeneration("set-session-credential-selector", storageProvider);
1539
+ }
1540
+
1541
+ /** Explicitly mask persistent/process-global selection and return the provider to AUTO for one scope. */
1542
+ setSessionCredentialAuto(provider: string, scopeId: string): void {
1543
+ const scope = scopeId.trim();
1544
+ if (!scope) throw new Error("Credential scope id must not be empty");
1545
+ const storageProvider = resolveOAuthStorageProvider(provider);
1546
+ this.#sessionCredentialSelectors.get(scope)?.delete(storageProvider);
1547
+ const masks = this.#sessionCredentialAutoMasks.get(scope) ?? new Set<string>();
1548
+ masks.add(storageProvider);
1549
+ this.#sessionCredentialAutoMasks.set(scope, masks);
1550
+ this.#bumpGeneration("set-session-credential-auto", storageProvider);
1551
+ }
1552
+
1553
+ /** Clear a scope's explicit selector and AUTO mask, restoring normal precedence. */
1554
+ clearSessionCredentialSelector(provider: string, scopeId: string): void {
1555
+ const scope = scopeId.trim();
1556
+ if (!scope) return;
1557
+ const storageProvider = resolveOAuthStorageProvider(provider);
1558
+ const selectors = this.#sessionCredentialSelectors.get(scope);
1559
+ const masks = this.#sessionCredentialAutoMasks.get(scope);
1560
+ const changed = Boolean(selectors?.delete(storageProvider) || masks?.delete(storageProvider));
1561
+ if (selectors?.size === 0) this.#sessionCredentialSelectors.delete(scope);
1562
+ if (masks?.size === 0) this.#sessionCredentialAutoMasks.delete(scope);
1563
+ if (changed) this.#bumpGeneration("clear-session-credential-selector", storageProvider);
1564
+ }
1565
+
1566
+ /** Whether the effective selection for a scope is explicitly pinned (AUTO masks are not pins). */
1567
+ hasSessionCredentialSelector(provider: string, scopeId?: string): boolean {
1568
+ if (!scopeId) return false;
1569
+ return this.#getCredentialSelector(provider, undefined, scopeId) !== undefined;
1570
+ }
1571
+
1572
+ /** Whether this scope explicitly masks provider pins and uses AUTO ranking. */
1573
+ hasSessionCredentialAuto(provider: string, scopeId?: string): boolean {
1574
+ if (!scopeId) return false;
1575
+ return this.#sessionCredentialAutoMasks.get(scopeId)?.has(resolveOAuthStorageProvider(provider)) === true;
1576
+ }
1577
+
1578
+ /** Resolve the effective selector precedence for a provider/scope. */
1579
+ resolveEffectiveCredentialSelector(
1580
+ provider: string,
1581
+ scopeId?: string,
1582
+ explicitSelector?: AuthCredentialSelector,
1583
+ ): AuthCredentialSelector | undefined {
1584
+ return this.#getCredentialSelector(
1585
+ provider,
1586
+ explicitSelector ? { credentialSelector: explicitSelector } : undefined,
1587
+ scopeId,
1588
+ );
1589
+ }
1590
+
1591
+ /** Validate and canonicalize an OAuth-only selector for account pinning. */
1592
+ resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector): OAuthPinTarget {
1593
+ const storageProvider = resolveOAuthStorageProvider(provider);
1594
+ if (
1595
+ this.#runtimeOverrides.has(storageProvider) ||
1596
+ this.#configOverrides.has(storageProvider) ||
1597
+ getEnvApiKey(storageProvider)
1598
+ ) {
1599
+ throw new OAuthCredentialSelectorError(
1600
+ "override-active",
1601
+ storageProvider,
1602
+ selector,
1603
+ `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${storageProvider} while an API-key override is active; remove the override or choose AUTO`,
1604
+ );
1605
+ }
1606
+ const allRows = this.#store.listCredentialInventory?.(storageProvider) ?? [];
1607
+ const matchingCredentials = this.#getStoredCredentials(storageProvider).filter(entry =>
1608
+ this.#credentialMatchesSelector(entry, selector),
1609
+ );
1610
+ const matchingIds = new Set(matchingCredentials.map(entry => entry.id));
1611
+ const matchingRows = allRows.filter(
1612
+ row => matchingIds.has(row.id) && !row.disabled && row.credentialKind === "oauth",
1613
+ );
1614
+ if (matchingRows.length === 0) {
1615
+ const providerRows = allRows.filter(row => row.provider === storageProvider);
1616
+ if (
1617
+ selector.kind === "id" &&
1618
+ providerRows.some(row => String(row.id) === selector.value && row.credentialKind === "api_key")
1619
+ ) {
1620
+ throw new OAuthCredentialSelectorError(
1621
+ "api-key-row",
1622
+ storageProvider,
1623
+ selector,
1624
+ `Credential ${selector.value} is an API-key row and cannot be pinned; choose an OAuth account`,
1625
+ );
1626
+ }
1627
+ if (providerRows.length > 0 && providerRows.every(row => row.credentialKind === "api_key")) {
1628
+ throw new OAuthCredentialSelectorError(
1629
+ "api-key-provider",
1630
+ storageProvider,
1631
+ selector,
1632
+ `Provider ${storageProvider} has no OAuth credentials to pin`,
1633
+ );
1634
+ }
1635
+ const disabled = providerRows.find(
1636
+ row => row.disabled && this.#credentialMatchesInventorySelector(row, selector),
1637
+ );
1638
+ if (disabled) {
1639
+ throw new OAuthCredentialSelectorError(
1640
+ "disabled",
1641
+ storageProvider,
1642
+ selector,
1643
+ `Credential ${this.#formatCredentialSelector(selector)} is disabled${disabled.disabledCause ? `: ${disabled.disabledCause}` : ""}; run /login ${storageProvider} or choose an active account`,
1644
+ );
1645
+ }
1646
+ throw new OAuthCredentialSelectorError(
1647
+ "not-found",
1648
+ storageProvider,
1649
+ selector,
1650
+ `No active OAuth credential found for ${storageProvider} matching ${this.#formatCredentialSelector(selector)}; run /login ${storageProvider} or choose AUTO`,
1651
+ );
1652
+ }
1653
+ if (matchingRows.length > 1) {
1654
+ throw new OAuthCredentialSelectorError(
1655
+ "ambiguous",
1656
+ storageProvider,
1657
+ selector,
1658
+ `Selector ${this.#formatCredentialSelector(selector)} matches multiple OAuth credentials; choose id:<row-id>`,
1659
+ matchingRows.map(row => row.id),
1660
+ );
1661
+ }
1662
+ const target = matchingRows[0];
1663
+ if (!target || target.disabled || target.credentialKind !== "oauth") {
1664
+ throw new OAuthCredentialSelectorError(
1665
+ "disabled",
1666
+ storageProvider,
1667
+ selector,
1668
+ `Credential ${this.#formatCredentialSelector(selector)} is not an active OAuth credential; choose an active account`,
1669
+ );
1670
+ }
1671
+ return { credentialId: target.id, canonicalSelector: { kind: "id", value: String(target.id) } };
1672
+ }
1673
+
1674
+ /** Return all local inventory rows, including soft-disabled metadata, without payloads. */
1675
+ listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
1676
+ return this.#store.listCredentialInventory?.(provider) ?? [];
1677
+ }
1678
+
1679
+ /** Return local OAuth hard-removal action targets, including disabled rows. */
1680
+ listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[] {
1681
+ return this.#store.listCredentialRemovalTargets?.(provider) ?? [];
1682
+ }
1683
+
1684
+ /** Remove selected local OAuth rows atomically; conflict leaves all rows intact. */
1685
+ removeAuthCredentialsHard(
1686
+ provider: string,
1687
+ targets: readonly CredentialRemovalTarget[],
1688
+ ): AuthCredentialHardRemovalResult {
1689
+ const storageProvider = resolveOAuthStorageProvider(provider);
1690
+ const result = this.#store.removeAuthCredentialsHard?.(storageProvider, targets) ?? {
1691
+ kind: "conflict",
1692
+ currentIds: [],
1693
+ };
1694
+ if (result.kind !== "removed") return result;
1695
+ const removed = new Set(result.ids);
1696
+ const previousEntries = this.#getStoredCredentials(storageProvider);
1697
+ const entries = previousEntries.filter(entry => !removed.has(entry.id));
1698
+ this.#setStoredCredentials(storageProvider, entries);
1699
+ this.#usageRequestInFlight.clear();
1700
+ this.#usageReportsInFlight.clear();
1701
+ this.#usageCache.deletePrefix?.(`report:${storageProvider}:`);
1702
+ for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
1703
+ const selector = selectors.get(storageProvider);
1704
+ const selected = selector
1705
+ ? previousEntries.find(entry => this.#credentialMatchesSelector(entry, selector))
1706
+ : undefined;
1707
+ if (selected && removed.has(selected.id)) this.clearSessionCredentialSelector(storageProvider, scopeId);
1708
+ }
1709
+ for (const [sessionId, sticky] of this.#sessionLastCredential.get(storageProvider) ?? []) {
1710
+ if (removed.has(previousEntries[sticky.index]?.id ?? -1))
1711
+ this.#clearSessionCredential(storageProvider, sessionId);
1712
+ }
1713
+ this.#resetProviderAssignments(storageProvider);
1714
+ return result;
1715
+ }
1314
1716
  /**
1315
1717
  * Remove a runtime credential selector.
1316
1718
  */
@@ -1321,6 +1723,58 @@ export class AuthStorage {
1321
1723
  }
1322
1724
  }
1323
1725
 
1726
+ /** Whether a provider currently has a soft runtime credential preference. */
1727
+ hasRuntimePreferredCredentialSelector(provider: string): boolean {
1728
+ return this.#runtimePreferredCredentialSelectors.has(resolveOAuthStorageProvider(provider));
1729
+ }
1730
+
1731
+ /** Resolve an unqualified preferred selector to the single active OAuth provider it matches. */
1732
+ resolveRuntimePreferredCredentialSelectorProvider(selector: AuthCredentialSelector): string {
1733
+ const providers = [...this.#data.entries()]
1734
+ .filter(([, entries]) =>
1735
+ entries.some(
1736
+ entry => entry.credential.type === "oauth" && this.#credentialMatchesSelector(entry, selector),
1737
+ ),
1738
+ )
1739
+ .map(([provider]) => provider);
1740
+ if (providers.length === 0) {
1741
+ throw new Error(`No active credential found matching ${this.#formatCredentialSelector(selector)}`);
1742
+ }
1743
+ if (providers.length > 1) {
1744
+ throw new Error(
1745
+ `Preferred credential selector ${this.#formatCredentialSelector(selector)} matches multiple providers; use provider/${this.#formatCredentialSelector(selector)}`,
1746
+ );
1747
+ }
1748
+ return providers[0]!;
1749
+ }
1750
+
1751
+ /**
1752
+ * Prefer one stored OAuth credential for a provider while retaining quota
1753
+ * fallback to the rest of the pool (not persisted to disk). Used for CLI
1754
+ * `--prefer-credential`. Unlike {@link setRuntimeCredentialSelector}, a
1755
+ * quota/rate-limit failure on the preferred row still rotates to another
1756
+ * active credential instead of failing the session.
1757
+ */
1758
+ setRuntimePreferredCredentialSelector(provider: string, selector: AuthCredentialSelector): void {
1759
+ const storageProvider = resolveOAuthStorageProvider(provider);
1760
+ if (this.#runtimeCredentialSelectors.has(storageProvider)) {
1761
+ throw new Error(`Preferred credential selector cannot be combined with a credential selector for ${provider}`);
1762
+ }
1763
+ this.#assertPreferredCredentialSelectorUsable(storageProvider, selector);
1764
+ this.#runtimePreferredCredentialSelectors.set(storageProvider, selector);
1765
+ this.#bumpGeneration("set-runtime-preferred-credential-selector", provider);
1766
+ }
1767
+
1768
+ /**
1769
+ * Remove a runtime preferred credential selector.
1770
+ */
1771
+ removeRuntimePreferredCredentialSelector(provider: string): void {
1772
+ const storageProvider = resolveOAuthStorageProvider(provider);
1773
+ if (this.#runtimePreferredCredentialSelectors.delete(storageProvider)) {
1774
+ this.#bumpGeneration("remove-runtime-preferred-credential-selector", provider);
1775
+ }
1776
+ }
1777
+
1324
1778
  /**
1325
1779
  * Remove a runtime API key override.
1326
1780
  */
@@ -1353,6 +1807,11 @@ export class AuthStorage {
1353
1807
  return this.#runtimeCredentialSelectors.has(resolveOAuthStorageProvider(provider));
1354
1808
  }
1355
1809
 
1810
+ /** Whether the effective selector for a session scope is pinned. */
1811
+ hasEffectiveCredentialSelector(provider: string, sessionId?: string): boolean {
1812
+ return this.#getCredentialSelector(provider, undefined, sessionId) !== undefined;
1813
+ }
1814
+
1356
1815
  /**
1357
1816
  * Opaque stored row id of the credential this session is currently using.
1358
1817
  *
@@ -1372,6 +1831,62 @@ export class AuthStorage {
1372
1831
  return this.#getStoredCredentials(storageProvider)[session.index]?.id;
1373
1832
  }
1374
1833
 
1834
+ /**
1835
+ * Force a running session's OAuth credential for a provider to a specific
1836
+ * stored row, independent of quota/rate-limit state. Used for a mid-session
1837
+ * `/credential <selector>` switch that has nothing to do with exhaustion —
1838
+ * the user just wants a different account for the rest of the session.
1839
+ *
1840
+ * This mutates ONLY the session-scoped sticky pointer
1841
+ * ({@link AuthStorage.#recordSessionCredential}), never a provider-wide
1842
+ * runtime override, so it cannot bleed into other sessions in the same
1843
+ * process whose credential identity differs. The sticky pointer is keyed by
1844
+ * `sessionId`, and subagents/team workers inherit their parent's
1845
+ * `credentialSessionId` by design so they keep using the same account as
1846
+ * the parent — a switch therefore applies to the whole session family
1847
+ * sharing that identity, not to unrelated sessions.
1848
+ *
1849
+ * Fails closed rather than silently no-op when a stronger override already
1850
+ * decides this provider's credential every call: a hard pin
1851
+ * ({@link AuthStorage.setRuntimeCredentialSelector}, `--credential`), a
1852
+ * runtime API-key override (`--api-key`), or a config-sourced API key
1853
+ * (`models.yml` `apiKey`) would each re-decide the credential on the very
1854
+ * next {@link AuthStorage.getApiKey} call and make this switch appear to
1855
+ * silently do nothing.
1856
+ *
1857
+ * Deliberately does not touch credential-blocked state: if the target row
1858
+ * is still backoff-blocked from a prior quota failure, the existing
1859
+ * `#resolveOAuthSelection` ranking safely ignores this sticky pointer and
1860
+ * falls back to a usable account instead of re-issuing a request that would
1861
+ * just draw another 429/quota error.
1862
+ */
1863
+ switchSessionCredential(provider: string, sessionId: string, selector: AuthCredentialSelector): void {
1864
+ const storageProvider = resolveOAuthStorageProvider(provider);
1865
+ if (this.#runtimeOverrides.has(storageProvider)) {
1866
+ throw new Error(
1867
+ `Cannot switch credential for ${provider}: a runtime API key override (--api-key) is active and always wins`,
1868
+ );
1869
+ }
1870
+ if (this.#configOverrides.has(storageProvider)) {
1871
+ throw new Error(
1872
+ `Cannot switch credential for ${provider}: a config API key override (models.yml) is active and always wins`,
1873
+ );
1874
+ }
1875
+ if (this.#runtimeCredentialSelectors.has(storageProvider)) {
1876
+ throw new Error(
1877
+ `Cannot switch credential for ${provider}: --credential already pins this session to one stored row`,
1878
+ );
1879
+ }
1880
+ const matched = this.#findCredentialBySelector(storageProvider, selector);
1881
+ if (matched?.credential.type !== "oauth") {
1882
+ throw new Error(
1883
+ `No active OAuth credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`,
1884
+ );
1885
+ }
1886
+ this.#recordSessionCredential(storageProvider, sessionId, "oauth", matched.index);
1887
+ this.#bumpGeneration("switch-session-credential");
1888
+ }
1889
+
1375
1890
  /**
1376
1891
  * Register a per-provider API key sourced from user configuration
1377
1892
  * (e.g. `models.yml` `providers.<name>.apiKey`). Higher priority than
@@ -1419,6 +1934,7 @@ export class AuthStorage {
1419
1934
  * Reload credentials from storage.
1420
1935
  */
1421
1936
  async reload(): Promise<void> {
1937
+ await this.#store.waitForReady?.();
1422
1938
  const records = this.#store.listAuthCredentials();
1423
1939
  const grouped = new Map<string, StoredCredential[]>();
1424
1940
  for (const record of records) {
@@ -1528,6 +2044,7 @@ export class AuthStorage {
1528
2044
  for (const entry of removed) {
1529
2045
  this.#store.deleteAuthCredential(entry.id, "deduplicated duplicate credential");
1530
2046
  }
2047
+ this.#clearSelectorsForRemovedCredential(provider, new Set(removed.map(entry => entry.id)), entries);
1531
2048
  this.#resetProviderAssignments(provider);
1532
2049
  }
1533
2050
  return kept.reverse();
@@ -1682,8 +2199,38 @@ export class AuthStorage {
1682
2199
  return undefined;
1683
2200
  }
1684
2201
 
1685
- #getCredentialSelector(provider: string, options?: AuthApiKeyOptions): AuthCredentialSelector | undefined {
1686
- return options?.credentialSelector ?? this.#runtimeCredentialSelectors.get(resolveOAuthStorageProvider(provider));
2202
+ #credentialMatchesInventorySelector(row: CredentialInventoryRecord, selector: AuthCredentialSelector): boolean {
2203
+ if (row.disabled || row.credentialKind !== "oauth") return false;
2204
+ switch (selector.kind) {
2205
+ case "id":
2206
+ return String(row.id) === selector.value;
2207
+ case "email":
2208
+ return row.identityLabel?.toLowerCase() === selector.value.toLowerCase();
2209
+ case "account":
2210
+ case "project":
2211
+ return row.identityLabel === selector.value;
2212
+ }
2213
+ }
2214
+ #getCredentialSelector(
2215
+ provider: string,
2216
+ options?: AuthApiKeyOptions,
2217
+ sessionId?: string,
2218
+ ): AuthCredentialSelector | undefined {
2219
+ if (options?.credentialSelector) return options.credentialSelector;
2220
+ const storageProvider = resolveOAuthStorageProvider(provider);
2221
+ if (sessionId) {
2222
+ if (this.#sessionCredentialAutoMasks.get(sessionId)?.has(storageProvider)) return undefined;
2223
+ const scoped = this.#sessionCredentialSelectors.get(sessionId)?.get(storageProvider);
2224
+ if (scoped) return scoped;
2225
+ }
2226
+ return this.#runtimeCredentialSelectors.get(storageProvider);
2227
+ }
2228
+
2229
+ #getPreferredCredentialSelector(provider: string, options?: AuthApiKeyOptions): AuthCredentialSelector | undefined {
2230
+ return (
2231
+ options?.preferredCredentialSelector ??
2232
+ this.#runtimePreferredCredentialSelectors.get(resolveOAuthStorageProvider(provider))
2233
+ );
1687
2234
  }
1688
2235
 
1689
2236
  #assertCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
@@ -1702,11 +2249,32 @@ export class AuthStorage {
1702
2249
  }
1703
2250
  }
1704
2251
 
2252
+ /**
2253
+ * Validates a preferred-credential selector (`--prefer-credential`). Unlike
2254
+ * {@link AuthStorage.#assertCredentialSelectorUsable}, the match must resolve
2255
+ * to an OAuth row specifically — the soft-preference/quota-fallback path is
2256
+ * meaningless for a single static API key.
2257
+ */
2258
+ #assertPreferredCredentialSelectorUsable(provider: string, selector: AuthCredentialSelector): void {
2259
+ if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
2260
+ throw new Error(
2261
+ `Preferred credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while an API key override is active`,
2262
+ );
2263
+ }
2264
+ const selected = this.#findCredentialBySelector(provider, selector);
2265
+ if (selected?.credential.type !== "oauth") {
2266
+ throw new Error(
2267
+ `No active credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`,
2268
+ );
2269
+ }
2270
+ }
2271
+
1705
2272
  #resolveSelectedStoredCredential(
1706
2273
  provider: string,
1707
2274
  options?: AuthApiKeyOptions,
2275
+ sessionId?: string,
1708
2276
  ): ({ index: number } & StoredCredential) | undefined {
1709
- const selector = this.#getCredentialSelector(provider, options);
2277
+ const selector = this.#getCredentialSelector(provider, options, sessionId);
1710
2278
  if (!selector) return undefined;
1711
2279
  this.#assertCredentialSelectorUsable(resolveOAuthStorageProvider(provider), selector);
1712
2280
  const selected = this.#findCredentialBySelector(provider, selector);
@@ -1846,6 +2414,7 @@ export class AuthStorage {
1846
2414
  if (!disabled) return false;
1847
2415
  const updated = entries.filter((_value, idx) => idx !== index);
1848
2416
  this.#setStoredCredentials(provider, updated);
2417
+ this.#clearSelectorsForRemovedCredential(provider, new Set([target.id]), entries);
1849
2418
  this.#resetProviderAssignments(provider);
1850
2419
  this.#emitCredentialDisabled({ provider, disabledCause });
1851
2420
  return true;
@@ -1875,10 +2444,30 @@ export class AuthStorage {
1875
2444
  provider,
1876
2445
  entries.filter(entry => entry.id !== credentialId),
1877
2446
  );
2447
+ this.#clearSelectorsForRemovedCredential(provider, new Set([credentialId]), entries);
1878
2448
  this.#resetProviderAssignments(provider);
1879
2449
  this.#emitCredentialDisabled({ provider, disabledCause });
1880
2450
  }
1881
2451
 
2452
+ /** Clear every selector whose durable/in-memory target was just removed. */
2453
+ #clearSelectorsForRemovedCredential(
2454
+ provider: string,
2455
+ removedIds: ReadonlySet<number>,
2456
+ previousEntries: readonly StoredCredential[] = this.#getStoredCredentials(provider),
2457
+ ): void {
2458
+ const storageProvider = resolveOAuthStorageProvider(provider);
2459
+ for (const [scopeId, selectors] of this.#sessionCredentialSelectors) {
2460
+ const selector = selectors.get(storageProvider);
2461
+ if (!selector) continue;
2462
+ const selected = previousEntries.find(entry => this.#credentialMatchesSelector(entry, selector));
2463
+ if (selected && removedIds.has(selected.id)) this.clearSessionCredentialSelector(storageProvider, scopeId);
2464
+ }
2465
+ for (const [sessionId, sticky] of this.#sessionLastCredential.get(storageProvider) ?? []) {
2466
+ if (removedIds.has(previousEntries[sticky.index]?.id ?? -1))
2467
+ this.#clearSessionCredential(storageProvider, sessionId);
2468
+ }
2469
+ }
2470
+
1882
2471
  #emitCredentialDisabled(event: CredentialDisabledEvent): void {
1883
2472
  if (this.#credentialDisabledListeners.size === 0) {
1884
2473
  // No subscribers — buffer for later replay. Cap the backlog so a process that runs
@@ -2020,7 +2609,13 @@ export class AuthStorage {
2020
2609
  } else {
2021
2610
  this.#store.deleteAuthCredentialsForProvider(storageProvider, "deleted by user");
2022
2611
  }
2612
+ const previousEntries = this.#getStoredCredentials(storageProvider);
2023
2613
  this.#setStoredCredentials(storageProvider, []);
2614
+ this.#clearSelectorsForRemovedCredential(
2615
+ storageProvider,
2616
+ new Set(previousEntries.map(entry => entry.id)),
2617
+ previousEntries,
2618
+ );
2024
2619
  this.#resetProviderAssignments(storageProvider);
2025
2620
  }
2026
2621
 
@@ -2051,10 +2646,10 @@ export class AuthStorage {
2051
2646
  return false;
2052
2647
  }
2053
2648
 
2054
- hasAuth(provider: string): boolean {
2649
+ hasAuth(provider: string, sessionId?: string): boolean {
2055
2650
  const storageProvider = resolveOAuthStorageProvider(provider);
2056
2651
  try {
2057
- this.#resolveSelectedStoredCredential(storageProvider);
2652
+ this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
2058
2653
  } catch {
2059
2654
  return false;
2060
2655
  }
@@ -2070,7 +2665,7 @@ export class AuthStorage {
2070
2665
  const storageProvider = resolveOAuthStorageProvider(provider);
2071
2666
  let selected: ({ index: number } & StoredCredential) | undefined;
2072
2667
  try {
2073
- selected = this.#resolveSelectedStoredCredential(storageProvider);
2668
+ selected = this.#resolveSelectedStoredCredential(storageProvider, undefined, sessionId);
2074
2669
  } catch {
2075
2670
  return undefined;
2076
2671
  }
@@ -2097,7 +2692,7 @@ export class AuthStorage {
2097
2692
  hasUsableAuth(provider: string): boolean {
2098
2693
  const storageProvider = resolveOAuthStorageProvider(provider);
2099
2694
  try {
2100
- const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider);
2695
+ const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider, undefined, undefined);
2101
2696
  if (this.hasRuntimeApiKey(storageProvider)) return true;
2102
2697
  if (this.#configOverrides.has(storageProvider)) return true;
2103
2698
  if (selectedCredential) {
@@ -2153,7 +2748,14 @@ export class AuthStorage {
2153
2748
  /**
2154
2749
  * Get OAuth credentials for a provider.
2155
2750
  */
2156
- getOAuthCredential(provider: string): OAuthCredential | undefined {
2751
+ getOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined {
2752
+ const selected = this.#resolveSelectedStoredCredential(
2753
+ resolveOAuthStorageProvider(provider),
2754
+ undefined,
2755
+ sessionId,
2756
+ );
2757
+ if (selected?.credential.type === "oauth") return selected.credential;
2758
+ if (selected) return undefined;
2157
2759
  return this.#getCredentialsForProvider(provider).find(
2158
2760
  (credential): credential is OAuthCredential => credential.type === "oauth",
2159
2761
  );
@@ -2177,6 +2779,13 @@ export class AuthStorage {
2177
2779
  if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) return undefined;
2178
2780
 
2179
2781
  // Prefer the session-sticky credential when available.
2782
+
2783
+ const scopedSelection = this.#resolveSelectedStoredCredential(provider, undefined, sessionId);
2784
+ if (scopedSelection?.credential.type === "api_key") return undefined;
2785
+ if (scopedSelection?.credential.type === "oauth") {
2786
+ const accountId = scopedSelection.credential.accountId;
2787
+ return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
2788
+ }
2180
2789
  const sessionPref = this.#getSessionCredential(provider, sessionId);
2181
2790
  // If the session has been routed to a stored API key, do not inject OAuth account_uuid.
2182
2791
  if (sessionPref !== undefined && sessionPref.type !== "oauth") return undefined;
@@ -2623,6 +3232,7 @@ export class AuthStorage {
2623
3232
  projectId: credential.projectId,
2624
3233
  email: credential.email,
2625
3234
  enterpriseUrl: credential.enterpriseUrl,
3235
+ mcpBinding: credential.mcpBinding,
2626
3236
  };
2627
3237
  }
2628
3238
 
@@ -2697,6 +3307,7 @@ export class AuthStorage {
2697
3307
  projectId: credential.projectId,
2698
3308
  email: credential.email,
2699
3309
  enterpriseUrl: credential.enterpriseUrl,
3310
+ mcpBinding: credential.mcpBinding,
2700
3311
  };
2701
3312
  }
2702
3313
 
@@ -2710,6 +3321,7 @@ export class AuthStorage {
2710
3321
  projectId: refreshed.projectId ?? credential.projectId,
2711
3322
  email: refreshed.email ?? credential.email,
2712
3323
  enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
3324
+ mcpBinding: credential.mcpBinding,
2713
3325
  };
2714
3326
  }
2715
3327
 
@@ -2757,6 +3369,7 @@ export class AuthStorage {
2757
3369
  projectId: next.projectId,
2758
3370
  email: next.email,
2759
3371
  enterpriseUrl: next.enterpriseUrl,
3372
+ mcpBinding: next.mcpBinding ?? existing.mcpBinding,
2760
3373
  });
2761
3374
  }
2762
3375
 
@@ -3208,9 +3821,172 @@ export class AuthStorage {
3208
3821
  * Environment-variable API keys are not enumerated — the caller's intent
3209
3822
  * here is "which of my stored credentials is broken".
3210
3823
  */
3824
+ /** Return a safe cache-only usage observation. */
3825
+ getCachedUsageReport(provider: Provider, credentialId: number, baseUrl?: string): CachedUsageReport | undefined {
3826
+ const storageProvider = resolveOAuthStorageProvider(provider);
3827
+ const presentation = this.#store.peekCachedUsagePresentation?.(storageProvider, credentialId);
3828
+ if (presentation) {
3829
+ const now = Date.now();
3830
+ if (presentation.retainUntil > now) {
3831
+ return {
3832
+ report: presentation.usage,
3833
+ fetchedAt: presentation.fetchedAt,
3834
+ freshUntil: presentation.freshUntil,
3835
+ retainUntil: presentation.retainUntil,
3836
+ freshness: presentation.freshUntil > now ? "fresh" : "stale-last-good",
3837
+ };
3838
+ }
3839
+ }
3840
+ const entry = this.#getStoredCredentials(storageProvider).find(candidate => candidate.id === credentialId);
3841
+ if (entry?.credential.type !== "oauth") return undefined;
3842
+ const request = this.#buildUsageRequestForOauth(storageProvider, entry.credential, baseUrl);
3843
+ const cached = this.#usageCache.getStale<UsageReport | null>(this.#buildUsageReportCacheKey(request));
3844
+ if (!cached || cached.value === null || cached.expiresAt + PRESENTATION_RETENTION_MS < Date.now())
3845
+ return undefined;
3846
+ return {
3847
+ report: safeUsageReport(cached.value),
3848
+ fetchedAt: cached.value.fetchedAt,
3849
+ freshUntil: cached.expiresAt,
3850
+ retainUntil: cached.expiresAt + PRESENTATION_RETENTION_MS,
3851
+ freshness: cached.expiresAt > Date.now() ? "fresh" : "stale-last-good",
3852
+ };
3853
+ }
3854
+
3855
+ /** Cache-only health observation; unknown means no retained explicit check. */
3856
+ getCachedCredentialHealth(credentialId: number): CachedCredentialHealth {
3857
+ const inventory = this.#store.listCredentialInventory?.() ?? [];
3858
+ const row = inventory.find(candidate => candidate.id === credentialId);
3859
+ if (row?.disabled) return { status: "failed", reason: scrubHealthReason(row.disabledCause ?? "disabled") };
3860
+ const remote = row ? this.#store.peekCachedCredentialHealth?.(row.provider as Provider, credentialId) : undefined;
3861
+ if (remote) return remote;
3862
+ const raw = this.#store.getCache(`${HEALTH_CACHE_PREFIX}${credentialId}`);
3863
+ if (!raw) return { status: "unknown", reason: null };
3864
+ try {
3865
+ const value = JSON.parse(raw) as {
3866
+ status?: unknown;
3867
+ reason?: unknown;
3868
+ checkedAt?: unknown;
3869
+ retainUntil?: unknown;
3870
+ };
3871
+ if (typeof value.retainUntil !== "number" || value.retainUntil <= Date.now())
3872
+ return { status: "unknown", reason: null };
3873
+ const status =
3874
+ value.status === "ok" || value.status === "failed" || value.status === "unverifiable"
3875
+ ? value.status
3876
+ : "unknown";
3877
+ return {
3878
+ status,
3879
+ reason: typeof value.reason === "string" ? value.reason : null,
3880
+ checkedAt: typeof value.checkedAt === "number" ? value.checkedAt : undefined,
3881
+ retainUntil: value.retainUntil,
3882
+ };
3883
+ } catch {
3884
+ return { status: "unknown", reason: null };
3885
+ }
3886
+ }
3887
+
3888
+ peekCachedCredentialHealthForSource(provider: string, source: "env" | "config" | "runtime"): CachedCredentialHealth {
3889
+ const raw = this.#store.getCache(`${SOURCE_HEALTH_CACHE_PREFIX}${provider}:${source}`);
3890
+ if (!raw) return { status: "unknown", reason: null };
3891
+ try {
3892
+ const value = JSON.parse(raw) as CachedCredentialHealth;
3893
+ if (!value.retainUntil || value.retainUntil <= Date.now()) return { status: "unknown", reason: null };
3894
+ return {
3895
+ status:
3896
+ value.status === "ok" || value.status === "failed" || value.status === "unverifiable"
3897
+ ? value.status
3898
+ : "unknown",
3899
+ reason: value.reason ? scrubHealthReason(value.reason) : null,
3900
+ checkedAt: value.checkedAt,
3901
+ retainUntil: value.retainUntil,
3902
+ };
3903
+ } catch {
3904
+ return { status: "unknown", reason: null };
3905
+ }
3906
+ }
3907
+
3908
+ recordCredentialHealthForSource(
3909
+ provider: string,
3910
+ source: "env" | "config" | "runtime",
3911
+ health: CachedCredentialHealth,
3912
+ ): void {
3913
+ if (health.status === "unknown" || !health.retainUntil) return;
3914
+ const retainUntil = health.retainUntil;
3915
+ const payload: CachedCredentialHealth = {
3916
+ status: health.status,
3917
+ reason: health.reason ? scrubHealthReason(health.reason) : null,
3918
+ checkedAt: health.checkedAt ?? Date.now(),
3919
+ retainUntil,
3920
+ };
3921
+ this.#store.setCache(
3922
+ `${SOURCE_HEALTH_CACHE_PREFIX}${resolveOAuthStorageProvider(provider)}:${source}`,
3923
+ JSON.stringify(payload),
3924
+ Math.floor(retainUntil / 1000),
3925
+ );
3926
+ }
3927
+
3928
+ #recordCredentialHealth(provider: Provider, credentialId: number, health: CachedCredentialHealth): void {
3929
+ if (health.status !== "unknown") this.#store.recordCredentialHealth?.(provider, credentialId, health);
3930
+ if (health.status === "unknown" || !health.retainUntil) return;
3931
+ const healthPayload = {
3932
+ v: 1,
3933
+ status: health.status,
3934
+ reason: health.reason ? scrubHealthReason(health.reason) : null,
3935
+ checkedAt: health.checkedAt ?? Date.now(),
3936
+ retainUntil: health.retainUntil,
3937
+ };
3938
+ this.#store.setCache(
3939
+ `${HEALTH_CACHE_PREFIX}${credentialId}`,
3940
+ JSON.stringify(healthPayload),
3941
+ Math.floor(healthPayload.retainUntil / 1000),
3942
+ );
3943
+ }
3944
+ /** Explicit API-key probe; key bytes are not retained in the returned result. */
3945
+ async checkApiKeyCredential(
3946
+ provider: Provider,
3947
+ apiKey: string,
3948
+ options: ApiKeyCredentialCheckOptions = {},
3949
+ ): Promise<ApiKeyCredentialCheckResult> {
3950
+ const providerImpl = this.#usageProviderResolver?.(provider);
3951
+ const base: ApiKeyCredentialCheckResult = { provider, type: "api_key", ok: null };
3952
+ if (!providerImpl) {
3953
+ base.reason = `unsupported API-key probe for ${provider}`;
3954
+ return base;
3955
+ }
3956
+ const request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, options.baseUrl);
3957
+ if (providerImpl.supports && !providerImpl.supports(request)) {
3958
+ base.reason = `unsupported API-key probe for ${provider}`;
3959
+ return base;
3960
+ }
3961
+ options.signal?.throwIfAborted();
3962
+ const timeoutMs = options.timeoutMs ?? this.#usageRequestTimeoutMs;
3963
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
3964
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
3965
+ try {
3966
+ const report = await providerImpl.fetchUsage(
3967
+ { ...request, signal },
3968
+ {
3969
+ fetch: this.#usageFetch,
3970
+ logger: this.#usageLogger,
3971
+ },
3972
+ );
3973
+ if (!report) {
3974
+ base.reason = "API-key probe returned no verifiable data";
3975
+ return base;
3976
+ }
3977
+ base.ok = true;
3978
+ base.report = safeUsageReport(report);
3979
+ return base;
3980
+ } catch (error) {
3981
+ base.ok = false;
3982
+ base.reason = scrubHealthReason(error, [apiKey]);
3983
+ return base;
3984
+ }
3985
+ }
3986
+
3211
3987
  async checkCredentials(options?: CheckCredentialsOptions): Promise<CredentialHealthResult[]> {
3212
3988
  options?.signal?.throwIfAborted();
3213
- const stored = this.#store.listAuthCredentials();
3989
+ const stored = this.#store.listAuthCredentials(options?.provider);
3214
3990
  const resolver = this.#usageProviderResolver;
3215
3991
  const timeoutMs = options?.timeoutMs ?? this.#usageRequestTimeoutMs;
3216
3992
  const ctx: UsageFetchContext = { fetch: this.#usageFetch, logger: this.#usageLogger };
@@ -3281,12 +4057,21 @@ export class AuthStorage {
3281
4057
  params = { ...params, credential: refreshedCredential };
3282
4058
  } catch (error) {
3283
4059
  base.ok = false;
3284
- base.reason = `oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`;
3285
- results.push(base);
3286
- continue;
4060
+ base.reason = `oauth refresh failed: ${scrubHealthReason(error)}`;
3287
4061
  }
3288
4062
  }
3289
4063
  }
4064
+ if (base.ok === false && base.reason?.startsWith("oauth refresh failed:")) {
4065
+ results.push(base);
4066
+ const healthPayload: CachedCredentialHealth = {
4067
+ status: "failed",
4068
+ reason: scrubHealthReason(base.reason),
4069
+ checkedAt: Date.now(),
4070
+ retainUntil: Date.now() + PRESENTATION_RETENTION_MS,
4071
+ };
4072
+ this.#recordCredentialHealth(row.provider as Provider, row.id, healthPayload);
4073
+ continue;
4074
+ }
3290
4075
 
3291
4076
  try {
3292
4077
  const report = await providerImpl.fetchUsage(params, ctx);
@@ -3300,13 +4085,27 @@ export class AuthStorage {
3300
4085
  if (email) base.email = email;
3301
4086
  const { raw: _raw, ...trimmed } = report;
3302
4087
  base.report = trimmed;
4088
+ this.#usageCache.set(this.#buildUsageReportCacheKey(params), {
4089
+ value: report,
4090
+ expiresAt: Date.now() + USAGE_REPORT_TTL_MS,
4091
+ });
4092
+ this.#store.recordCredentialUsage?.(row.provider as Provider, row.id, trimmed);
3303
4093
  }
3304
4094
  } catch (error) {
3305
4095
  base.ok = false;
3306
- base.reason = error instanceof Error ? error.message : String(error);
4096
+ base.reason = scrubHealthReason(error, cred.type === "api_key" ? [cred.key] : []);
3307
4097
  }
3308
4098
 
3309
4099
  results.push(base);
4100
+ const healthPayload: CachedCredentialHealth = {
4101
+ status: base.ok === true ? "ok" : base.ok === false ? "failed" : "unverifiable",
4102
+ reason: base.reason
4103
+ ? scrubHealthReason(base.reason, row.credential.type === "api_key" ? [row.credential.key] : [])
4104
+ : null,
4105
+ checkedAt: Date.now(),
4106
+ retainUntil: Date.now() + PRESENTATION_RETENTION_MS,
4107
+ };
4108
+ this.#recordCredentialHealth(row.provider as Provider, row.id, healthPayload);
3310
4109
  }
3311
4110
 
3312
4111
  return results;
@@ -3559,7 +4358,7 @@ export class AuthStorage {
3559
4358
  });
3560
4359
  return undefined;
3561
4360
  }
3562
- const selectedCredential = this.#resolveSelectedStoredCredential(provider, options);
4361
+ const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
3563
4362
  const selectedOAuthCredential =
3564
4363
  selectedCredential?.credential.type === "oauth"
3565
4364
  ? { credential: selectedCredential.credential, index: selectedCredential.index }
@@ -3597,6 +4396,32 @@ export class AuthStorage {
3597
4396
  .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
3598
4397
  .map(selection => ({ selection, usage: null, usageChecked: false }));
3599
4398
 
4399
+ // Soft `--prefer-credential` preference: reorder the preferred row to the
4400
+ // front when it is usable, ahead of the session-stickiness reorder below so
4401
+ // a blocked preferred row falls through to whatever the session already
4402
+ // stuck to (its own quota-triggered fallback) instead of overriding it.
4403
+ if (!selectedCredential) {
4404
+ const preferredSelector = this.#getPreferredCredentialSelector(provider, options);
4405
+ if (preferredSelector) {
4406
+ this.#assertPreferredCredentialSelectorUsable(resolveOAuthStorageProvider(provider), preferredSelector);
4407
+ }
4408
+ const preferredSelection = preferredSelector
4409
+ ? this.#findCredentialBySelector(provider, preferredSelector)
4410
+ : undefined;
4411
+ if (
4412
+ preferredSelection?.credential.type === "oauth" &&
4413
+ !this.#isCredentialBlocked(providerKey, preferredSelection.index)
4414
+ ) {
4415
+ const preferredCandidate = candidates.findIndex(
4416
+ candidate => candidate.selection.index === preferredSelection.index,
4417
+ );
4418
+ if (preferredCandidate > 0) {
4419
+ const [preferred] = candidates.splice(preferredCandidate, 1);
4420
+ candidates.unshift(preferred);
4421
+ }
4422
+ }
4423
+ }
4424
+
3600
4425
  if (!selectedCredential && sessionPreferredIndex !== undefined && !requiresProModel) {
3601
4426
  const sessionPreferredCandidate = candidates.findIndex(
3602
4427
  candidate =>
@@ -4089,6 +4914,23 @@ export class AuthStorage {
4089
4914
  if (attemptedCredentialId !== undefined) {
4090
4915
  const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
4091
4916
  const latestCredential = latestRow?.credential;
4917
+ if (!latestRow) {
4918
+ // The row we just tried is no longer active: a peer disabled it after a
4919
+ // definitive refresh failure, the user removed it, or a re-login replaced
4920
+ // it with a new row. Our in-memory snapshot is stale, and the refresh
4921
+ // helper can only answer "credential disappeared" for it — an error that
4922
+ // classifies as transient and would temp-block a row that will never come
4923
+ // back. Long-lived processes (resumed sessions) would then keep replaying
4924
+ // the vanished credential until restart while a valid re-login row sits
4925
+ // unused in the store. Reload and re-resolve against the persisted truth.
4926
+ logger.debug("OAuth credential vanished from the store; reloading snapshot", {
4927
+ provider,
4928
+ index: selection.index,
4929
+ credentialId: attemptedCredentialId,
4930
+ });
4931
+ await this.reload();
4932
+ return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed + 1);
4933
+ }
4092
4934
  if (latestCredential?.type === "oauth" && latestCredential.refresh !== attemptedRefreshToken) {
4093
4935
  logger.debug("OAuth refresh race detected; another process rotated token first", {
4094
4936
  provider,
@@ -4110,7 +4952,7 @@ export class AuthStorage {
4110
4952
  logger.warn("OAuth token refresh failed", {
4111
4953
  provider,
4112
4954
  index: selection.index,
4113
- error: errorMsg,
4955
+ error: scrubHealthReason(error, [selection.credential.access, selection.credential.refresh]),
4114
4956
  isDefinitiveFailure,
4115
4957
  });
4116
4958
 
@@ -4155,7 +4997,7 @@ export class AuthStorage {
4155
4997
  }
4156
4998
  }
4157
4999
  if (
4158
- !this.#getCredentialSelector(provider, options) &&
5000
+ !this.#getCredentialSelector(provider, options, sessionId) &&
4159
5001
  this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")
4160
5002
  ) {
4161
5003
  return this.#resolveOAuthSelection(provider, sessionId, options, reloadsUsed);
@@ -4165,8 +5007,8 @@ export class AuthStorage {
4165
5007
  this.#markCredentialBlocked(providerKey, selection.index, Date.now() + 5 * 60 * 1000);
4166
5008
  }
4167
5009
  }
4168
- if (this.#getCredentialSelector(provider, options)) {
4169
- const selector = this.#getCredentialSelector(provider, options);
5010
+ if (this.#getCredentialSelector(provider, options, sessionId)) {
5011
+ const selector = this.#getCredentialSelector(provider, options, sessionId);
4170
5012
  throw new Error(
4171
5013
  `Selected credential for ${provider} (${selector ? this.#formatCredentialSelector(selector) : "unknown"}) is unavailable`,
4172
5014
  );
@@ -4251,7 +5093,7 @@ export class AuthStorage {
4251
5093
  const configKey = this.#configOverrides.get(provider);
4252
5094
  if (configKey) return configKey;
4253
5095
 
4254
- const selectedCredential = this.#resolveSelectedStoredCredential(provider);
5096
+ const selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
4255
5097
  if (selectedCredential?.credential.type === "api_key") {
4256
5098
  return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
4257
5099
  }
@@ -4311,7 +5153,7 @@ export class AuthStorage {
4311
5153
  */
4312
5154
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
4313
5155
  provider = resolveOAuthStorageProvider(provider);
4314
- const selectedCredential = this.#resolveSelectedStoredCredential(provider, options);
5156
+ const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
4315
5157
 
4316
5158
  // Runtime override takes highest priority after selector validation.
4317
5159
  const runtimeKey = this.#runtimeOverrides.get(provider);
@@ -4666,14 +5508,16 @@ export class AuthStorage {
4666
5508
  * `POST /v1/credential/:id/disable`. Returns `false` when no such row exists.
4667
5509
  */
4668
5510
  disableCredentialById(id: number, disabledCause: string): boolean {
5511
+ const cause = normalizeDisabledCause(disabledCause);
4669
5512
  for (const [provider, entries] of this.#data) {
4670
5513
  const index = entries.findIndex(entry => entry.id === id);
4671
5514
  if (index === -1) continue;
4672
- this.#store.deleteAuthCredential(id, disabledCause);
5515
+ this.#store.deleteAuthCredential(id, cause);
4673
5516
  const next = entries.filter((_value, idx) => idx !== index);
4674
5517
  this.#setStoredCredentials(provider, next);
5518
+ this.#clearSelectorsForRemovedCredential(provider, new Set([id]), entries);
4675
5519
  this.#resetProviderAssignments(provider);
4676
- this.#emitCredentialDisabled({ provider, disabledCause });
5520
+ this.#emitCredentialDisabled({ provider, disabledCause: cause });
4677
5521
  return true;
4678
5522
  }
4679
5523
  return false;
@@ -4759,6 +5603,7 @@ type AuthRow = {
4759
5603
  data: string;
4760
5604
  disabled_cause: string | null;
4761
5605
  identity_key: string | null;
5606
+ revision: number;
4762
5607
  };
4763
5608
 
4764
5609
  type SerializedCredentialRecord = {
@@ -4767,7 +5612,7 @@ type SerializedCredentialRecord = {
4767
5612
  identityKey: string | null;
4768
5613
  };
4769
5614
 
4770
- const AUTH_SCHEMA_VERSION = 4;
5615
+ const AUTH_SCHEMA_VERSION = 5;
4771
5616
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
4772
5617
 
4773
5618
  function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
@@ -4827,12 +5672,19 @@ function deserializeCredential(row: AuthRow): AuthCredential | null {
4827
5672
  }
4828
5673
 
4829
5674
  function normalizeDisabledCause(disabledCause: string): string {
4830
- const normalized = disabledCause.trim();
5675
+ const normalized = disabledCause
5676
+ .replace(/bearer\s+[^\s,;]+/gi, "Bearer [redacted]")
5677
+ .replace(/(api[_-]?key|token|secret|authorization)[=:]\s*[^\s,;]+/gi, "$1=[redacted]")
5678
+ .replace(/https?:\/\/[^\s?#]+\?[^\s]+/gi, value => value.split("?")[0] ?? "[redacted URL]")
5679
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
5680
+ .replace(/\s+/g, " ")
5681
+ .trim()
5682
+ .slice(0, 240);
4831
5683
  return normalized.length > 0 ? normalized : "disabled";
4832
5684
  }
4833
5685
 
4834
5686
  function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
4835
- return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
5687
+ return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause, revision: row.revision };
4836
5688
  }
4837
5689
 
4838
5690
  function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
@@ -4941,6 +5793,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4941
5793
  #db: Database;
4942
5794
  #listActiveStmt: Statement;
4943
5795
  #listActiveByProviderStmt: Statement;
5796
+ #listAllStmt: Statement;
5797
+ #listAllByProviderStmt: Statement;
4944
5798
  #listDisabledByProviderStmt: Statement;
4945
5799
  #insertStmt: Statement;
4946
5800
  #updateStmt: Statement;
@@ -4960,28 +5814,34 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4960
5814
  this.#initializeSchema();
4961
5815
 
4962
5816
  this.#listActiveStmt = this.#db.prepare(
4963
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
5817
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
4964
5818
  );
4965
5819
  this.#listActiveByProviderStmt = this.#db.prepare(
4966
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
5820
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
5821
+ );
5822
+ this.#listAllStmt = this.#db.prepare(
5823
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials ORDER BY id ASC",
5824
+ );
5825
+ this.#listAllByProviderStmt = this.#db.prepare(
5826
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? ORDER BY id ASC",
4967
5827
  );
4968
5828
  this.#listDisabledByProviderStmt = this.#db.prepare(
4969
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
5829
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
4970
5830
  );
4971
5831
  this.#insertStmt = this.#db.prepare(
4972
- `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
5832
+ `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
4973
5833
  );
4974
5834
  this.#updateStmt = this.#db.prepare(
4975
- `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
5835
+ `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
4976
5836
  );
4977
5837
  this.#deleteStmt = this.#db.prepare(
4978
- `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
5838
+ `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
4979
5839
  );
4980
5840
  this.#deleteIfMatchesStmt = this.#db.prepare(
4981
- `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
5841
+ `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
4982
5842
  );
4983
5843
  this.#deleteByProviderStmt = this.#db.prepare(
4984
- `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
5844
+ `UPDATE auth_credentials SET disabled_cause = ?, revision = revision + 1, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
4985
5845
  );
4986
5846
  this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
4987
5847
  this.#getCacheStmt = this.#db.prepare(
@@ -5106,6 +5966,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5106
5966
  data TEXT NOT NULL,
5107
5967
  disabled_cause TEXT DEFAULT NULL,
5108
5968
  identity_key TEXT DEFAULT NULL,
5969
+ revision INTEGER NOT NULL DEFAULT 1,
5109
5970
  created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
5110
5971
  updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
5111
5972
  );
@@ -5130,6 +5991,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5130
5991
  if (fromVersion < 4) {
5131
5992
  this.#migrateAuthSchemaV3ToV4();
5132
5993
  }
5994
+ if (fromVersion < 5) {
5995
+ this.#migrateAuthSchemaV4ToV5();
5996
+ }
5133
5997
  }
5134
5998
 
5135
5999
  #migrateAuthSchemaV0ToV1(): void {
@@ -5209,11 +6073,16 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5209
6073
  });
5210
6074
  migrate();
5211
6075
  }
6076
+ #migrateAuthSchemaV4ToV5(): void {
6077
+ const columns = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
6078
+ if (columns.some(column => column.name === "revision")) return;
6079
+ this.#db.run("ALTER TABLE auth_credentials ADD COLUMN revision INTEGER NOT NULL DEFAULT 1");
6080
+ }
5212
6081
 
5213
6082
  #backfillCredentialIdentityKeys(): void {
5214
6083
  const rows = this.#db
5215
6084
  .prepare(
5216
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
6085
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
5217
6086
  )
5218
6087
  .all() as AuthRow[];
5219
6088
  if (rows.length === 0) return;
@@ -5241,6 +6110,79 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5241
6110
  }
5242
6111
  return results;
5243
6112
  }
6113
+
6114
+ listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
6115
+ const rows =
6116
+ provider === undefined
6117
+ ? (this.#listAllStmt.all() as AuthRow[])
6118
+ : (this.#listAllByProviderStmt.all(provider) as AuthRow[]);
6119
+ const results: CredentialInventoryRecord[] = [];
6120
+ for (const row of rows) {
6121
+ const credential = deserializeCredential(row);
6122
+ if (!credential) continue;
6123
+ const identityLabel =
6124
+ credential.type === "oauth"
6125
+ ? (credential.email ?? credential.accountId ?? credential.projectId ?? null)
6126
+ : null;
6127
+ results.push({
6128
+ id: row.id,
6129
+ provider: row.provider,
6130
+ credentialKind: credential.type,
6131
+ identityLabel,
6132
+ ...(credential.type === "oauth" && credential.accountId ? { accountId: credential.accountId } : {}),
6133
+ ...(credential.type === "oauth" && credential.email ? { email: credential.email } : {}),
6134
+ ...(credential.type === "oauth" && credential.projectId ? { projectId: credential.projectId } : {}),
6135
+ disabled: row.disabled_cause !== null,
6136
+ disabledCause: row.disabled_cause,
6137
+ });
6138
+ }
6139
+ return results;
6140
+ }
6141
+
6142
+ listCredentialRemovalTargets(provider?: string): CredentialRemovalTarget[] {
6143
+ const rows =
6144
+ provider === undefined
6145
+ ? (this.#listAllStmt.all() as AuthRow[])
6146
+ : (this.#listAllByProviderStmt.all(provider) as AuthRow[]);
6147
+ return rows
6148
+ .filter(row => row.credential_type === "oauth")
6149
+ .map(row => ({ id: row.id, provider: row.provider, expectedRevision: row.revision }));
6150
+ }
6151
+ removeAuthCredentialsHard(
6152
+ provider: string,
6153
+ targets: readonly CredentialRemovalTarget[],
6154
+ ): AuthCredentialHardRemovalResult {
6155
+ const unique = [...new Map(targets.map(target => [target.id, target])).values()];
6156
+ const remove = this.#db.transaction((): AuthCredentialHardRemovalResult => {
6157
+ const currentIds: number[] = [];
6158
+ for (const target of unique) {
6159
+ const row = this.#db
6160
+ .prepare("SELECT id, provider, credential_type, revision FROM auth_credentials WHERE id = ?")
6161
+ .get(target.id) as
6162
+ | { id?: number; provider?: string; credential_type?: string; revision?: number }
6163
+ | undefined;
6164
+ if (
6165
+ !row ||
6166
+ row.provider !== provider ||
6167
+ row.credential_type !== "oauth" ||
6168
+ row.revision !== target.expectedRevision
6169
+ ) {
6170
+ currentIds.push(row?.id ?? target.id);
6171
+ }
6172
+ }
6173
+ if (currentIds.length > 0) return { kind: "conflict", currentIds };
6174
+ for (const target of unique) {
6175
+ this.#hardDeleteStmt.run(target.id);
6176
+ this.#db.prepare("DELETE FROM oauth_refresh_leases WHERE credential_id = ?").run(target.id);
6177
+ this.#db.prepare("DELETE FROM cache WHERE key = ?").run(`${HEALTH_CACHE_PREFIX}${target.id}`);
6178
+ }
6179
+ this.#db
6180
+ .prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?")
6181
+ .run(`usage_cache:report:${provider}:`.length, `usage_cache:report:${provider}:`);
6182
+ return { kind: "removed", ids: unique.map(target => target.id) };
6183
+ });
6184
+ return remove();
6185
+ }
5244
6186
  claimOAuthRefreshLease(
5245
6187
  credentialId: number,
5246
6188
  expectedRefresh: string,
@@ -5252,7 +6194,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5252
6194
  const claim = this.#db.transaction((): OAuthRefreshLeaseClaim => {
5253
6195
  const row = this.#db
5254
6196
  .prepare(
5255
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
6197
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key, revision FROM auth_credentials WHERE id = ? AND disabled_cause IS NULL",
5256
6198
  )
5257
6199
  .get(credentialId) as AuthRow | undefined;
5258
6200
  const credential = row ? deserializeCredential(row) : null;
@@ -5699,6 +6641,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5699
6641
  this.#closed = true;
5700
6642
  this.#listActiveStmt.finalize();
5701
6643
  this.#listActiveByProviderStmt.finalize();
6644
+ this.#listAllStmt.finalize();
6645
+ this.#listAllByProviderStmt.finalize();
5702
6646
  this.#listDisabledByProviderStmt.finalize();
5703
6647
  this.#insertStmt.finalize();
5704
6648
  this.#updateStmt.finalize();