@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

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 (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -0,0 +1,432 @@
1
+ import { findByIdentity, resolveIdentity } from "../account-identity.js";
2
+ import type { ManagedAccount, StatsDelta } from "../accounts.js";
3
+ import type { AccountMetadata, AccountStats, AccountStorage } from "../storage.js";
4
+ import {
5
+ createManagedAccount,
6
+ findMatchingManagedAccount,
7
+ resolveManagedAccountIdentity,
8
+ updateManagedAccountFromStorage,
9
+ } from "./matching.js";
10
+
11
+ export type AuthFallback = {
12
+ refresh: string;
13
+ access?: string;
14
+ expires?: number;
15
+ };
16
+
17
+ type DiskLookup = {
18
+ accounts: AccountMetadata[];
19
+ byId: Map<string, AccountMetadata>;
20
+ byAddedAt: Map<number, AccountMetadata[]>;
21
+ byRefreshToken: Map<string, AccountMetadata>;
22
+ };
23
+
24
+ type PersistedAccountState = {
25
+ refreshToken: string;
26
+ access?: string;
27
+ expires?: number;
28
+ tokenUpdatedAt: number;
29
+ stats: AccountStats;
30
+ };
31
+
32
+ export type PreparedStorageSave = {
33
+ storage: AccountStorage;
34
+ persistedStateById: Map<string, PersistedAccountState>;
35
+ droppedIds: ReadonlySet<string>;
36
+ };
37
+
38
+ export type ReconciledAccounts = {
39
+ accounts: ManagedAccount[];
40
+ currentIndex: number;
41
+ cursor: number;
42
+ shouldRebuildTrackers: boolean;
43
+ resetHealthTrackerIndex: number | null;
44
+ staleDeltaIds: string[];
45
+ };
46
+
47
+ export function loadManagedAccountsFromStorage(stored: AccountStorage): {
48
+ accounts: ManagedAccount[];
49
+ currentIndex: number;
50
+ } {
51
+ const accounts = stored.accounts.map((account, index) =>
52
+ createManagedAccount({
53
+ id: account.id || `${account.addedAt}:${account.refreshToken.slice(0, 12)}`,
54
+ index,
55
+ email: account.email,
56
+ identity: account.identity,
57
+ label: account.label,
58
+ refreshToken: account.refreshToken,
59
+ access: account.access,
60
+ expires: account.expires,
61
+ tokenUpdatedAt: account.token_updated_at,
62
+ addedAt: account.addedAt,
63
+ lastUsed: account.lastUsed,
64
+ enabled: account.enabled,
65
+ rateLimitResetTimes: account.rateLimitResetTimes,
66
+ consecutiveFailures: account.consecutiveFailures,
67
+ lastFailureTime: account.lastFailureTime,
68
+ lastSwitchReason: account.lastSwitchReason,
69
+ stats: account.stats,
70
+ source: account.source || "oauth",
71
+ }),
72
+ );
73
+
74
+ return {
75
+ accounts,
76
+ currentIndex: accounts.length > 0 ? Math.min(stored.activeIndex, accounts.length - 1) : -1,
77
+ };
78
+ }
79
+
80
+ export function mergeAuthFallbackIntoAccounts(accounts: ManagedAccount[], authFallback: AuthFallback): void {
81
+ if (accounts.length === 0) {
82
+ return;
83
+ }
84
+
85
+ const fallbackIdentity = resolveManagedAccountIdentity({
86
+ refreshToken: authFallback.refresh,
87
+ source: "oauth",
88
+ });
89
+ const match = findMatchingManagedAccount(accounts, {
90
+ identity: fallbackIdentity,
91
+ refreshToken: authFallback.refresh,
92
+ });
93
+
94
+ if (!match) {
95
+ return;
96
+ }
97
+
98
+ const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
99
+ const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
100
+ const matchExpires = typeof match.expires === "number" ? match.expires : 0;
101
+ const fallbackLooksFresh = fallbackHasAccess && fallbackExpires > Date.now();
102
+ const shouldAdoptFallback =
103
+ fallbackLooksFresh && (!match.access || !match.expires || fallbackExpires > matchExpires);
104
+
105
+ if (!shouldAdoptFallback) {
106
+ return;
107
+ }
108
+
109
+ match.access = authFallback.access;
110
+ match.expires = authFallback.expires;
111
+ match.tokenUpdatedAt = Math.max(match.tokenUpdatedAt || 0, fallbackExpires);
112
+ }
113
+
114
+ export function createBootstrapAccountFromFallback(authFallback: AuthFallback, now = Date.now()): ManagedAccount {
115
+ return createManagedAccount({
116
+ id: `${now}:${authFallback.refresh.slice(0, 12)}`,
117
+ index: 0,
118
+ refreshToken: authFallback.refresh,
119
+ access: authFallback.access,
120
+ expires: authFallback.expires,
121
+ tokenUpdatedAt: now,
122
+ addedAt: now,
123
+ lastSwitchReason: "initial",
124
+ source: "oauth",
125
+ });
126
+ }
127
+
128
+ function createDiskLookup(diskData: AccountStorage | null): DiskLookup {
129
+ const accounts = diskData?.accounts ?? [];
130
+ const byAddedAt = new Map<number, AccountMetadata[]>();
131
+
132
+ for (const account of accounts) {
133
+ const bucket = byAddedAt.get(account.addedAt) ?? [];
134
+ bucket.push(account);
135
+ byAddedAt.set(account.addedAt, bucket);
136
+ }
137
+
138
+ return {
139
+ accounts,
140
+ byId: new Map(accounts.map((account) => [account.id, account])),
141
+ byAddedAt,
142
+ byRefreshToken: new Map(accounts.map((account) => [account.refreshToken, account])),
143
+ };
144
+ }
145
+
146
+ function findMatchingDiskAccount(account: ManagedAccount, diskLookup: DiskLookup): AccountMetadata | null {
147
+ const byId = diskLookup.byId.get(account.id);
148
+ if (byId) return byId;
149
+
150
+ const byIdentity = findByIdentity(diskLookup.accounts, resolveIdentity(account));
151
+ if (byIdentity) return byIdentity;
152
+
153
+ const byAddedAt = diskLookup.byAddedAt.get(account.addedAt);
154
+ if (byAddedAt?.length === 1) return byAddedAt[0]!;
155
+
156
+ const byRefreshToken = diskLookup.byRefreshToken.get(account.refreshToken);
157
+ if (byRefreshToken) return byRefreshToken;
158
+
159
+ return byAddedAt?.[0] ?? null;
160
+ }
161
+
162
+ function mergePersistedStats(
163
+ account: ManagedAccount,
164
+ diskAccount: AccountMetadata | null,
165
+ delta: StatsDelta | undefined,
166
+ ): AccountStats {
167
+ if (!delta) {
168
+ return account.stats;
169
+ }
170
+
171
+ if (delta.isReset) {
172
+ return {
173
+ requests: delta.requests,
174
+ inputTokens: delta.inputTokens,
175
+ outputTokens: delta.outputTokens,
176
+ cacheReadTokens: delta.cacheReadTokens,
177
+ cacheWriteTokens: delta.cacheWriteTokens,
178
+ lastReset: delta.resetTimestamp ?? account.stats.lastReset,
179
+ };
180
+ }
181
+
182
+ if (!diskAccount?.stats) {
183
+ return account.stats;
184
+ }
185
+
186
+ return {
187
+ requests: diskAccount.stats.requests + delta.requests,
188
+ inputTokens: diskAccount.stats.inputTokens + delta.inputTokens,
189
+ outputTokens: diskAccount.stats.outputTokens + delta.outputTokens,
190
+ cacheReadTokens: diskAccount.stats.cacheReadTokens + delta.cacheReadTokens,
191
+ cacheWriteTokens: diskAccount.stats.cacheWriteTokens + delta.cacheWriteTokens,
192
+ lastReset: diskAccount.stats.lastReset,
193
+ };
194
+ }
195
+
196
+ function applyFreshestAuth(account: ManagedAccount, diskAccount: AccountMetadata | null): PersistedAccountState {
197
+ const memoryTokenUpdatedAt = account.tokenUpdatedAt || 0;
198
+ const diskTokenUpdatedAt = diskAccount?.token_updated_at || 0;
199
+ const freshestAuth =
200
+ diskAccount && diskTokenUpdatedAt > memoryTokenUpdatedAt
201
+ ? {
202
+ refreshToken: diskAccount.refreshToken,
203
+ access: diskAccount.access,
204
+ expires: diskAccount.expires,
205
+ tokenUpdatedAt: diskTokenUpdatedAt,
206
+ }
207
+ : {
208
+ refreshToken: account.refreshToken,
209
+ access: account.access,
210
+ expires: account.expires,
211
+ tokenUpdatedAt: memoryTokenUpdatedAt,
212
+ };
213
+
214
+ account.refreshToken = freshestAuth.refreshToken;
215
+ account.access = freshestAuth.access;
216
+ account.expires = freshestAuth.expires;
217
+ account.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
218
+
219
+ return {
220
+ ...freshestAuth,
221
+ stats: account.stats,
222
+ };
223
+ }
224
+
225
+ export function prepareStorageForSave(params: {
226
+ accounts: ManagedAccount[];
227
+ currentIndex: number;
228
+ statsDeltas: Map<string, StatsDelta>;
229
+ diskData: AccountStorage | null;
230
+ droppedIds?: ReadonlySet<string>;
231
+ }): PreparedStorageSave {
232
+ const diskLookup = createDiskLookup(params.diskData);
233
+ const matchedDiskAccounts = new Set<AccountMetadata>();
234
+ const activeAccountId = params.accounts[params.currentIndex]?.id ?? null;
235
+ const persistedStateById = new Map<string, PersistedAccountState>();
236
+ const accountsToPersist = params.accounts.filter(
237
+ (account) => account.enabled || !!findMatchingDiskAccount(account, diskLookup),
238
+ );
239
+
240
+ const persistedAccounts = accountsToPersist.map((account) => {
241
+ const delta = params.statsDeltas.get(account.id);
242
+ const diskAccount = findMatchingDiskAccount(account, diskLookup);
243
+
244
+ if (diskAccount) {
245
+ matchedDiskAccounts.add(diskAccount);
246
+ }
247
+
248
+ const mergedStats = mergePersistedStats(account, diskAccount, delta);
249
+ account.stats = mergedStats;
250
+ const freshestAuth = applyFreshestAuth(account, diskAccount);
251
+ freshestAuth.stats = mergedStats;
252
+ persistedStateById.set(account.id, freshestAuth);
253
+
254
+ return {
255
+ id: account.id,
256
+ email: account.email,
257
+ identity: account.identity,
258
+ label: account.label,
259
+ refreshToken: freshestAuth.refreshToken,
260
+ access: freshestAuth.access,
261
+ expires: freshestAuth.expires,
262
+ token_updated_at: freshestAuth.tokenUpdatedAt,
263
+ addedAt: account.addedAt,
264
+ lastUsed: account.lastUsed,
265
+ enabled: account.enabled,
266
+ rateLimitResetTimes: Object.keys(account.rateLimitResetTimes).length > 0 ? account.rateLimitResetTimes : {},
267
+ consecutiveFailures: account.consecutiveFailures,
268
+ lastFailureTime: account.lastFailureTime,
269
+ lastSwitchReason: account.lastSwitchReason,
270
+ stats: mergedStats,
271
+ source: account.source,
272
+ } satisfies AccountMetadata;
273
+ });
274
+
275
+ const droppedIds = params.droppedIds;
276
+ const diskOnlyAccounts = diskLookup.accounts.filter(
277
+ (account) => !matchedDiskAccounts.has(account) && !(droppedIds && droppedIds.has(account.id)),
278
+ );
279
+ const allAccounts = accountsToPersist.length > 0 ? [...persistedAccounts, ...diskOnlyAccounts] : persistedAccounts;
280
+ const resolvedActiveIndex = activeAccountId
281
+ ? allAccounts.findIndex((account) => account.id === activeAccountId)
282
+ : -1;
283
+
284
+ return {
285
+ storage: {
286
+ version: 1,
287
+ accounts: allAccounts,
288
+ activeIndex:
289
+ resolvedActiveIndex >= 0
290
+ ? resolvedActiveIndex
291
+ : allAccounts.length > 0
292
+ ? Math.max(0, Math.min(params.currentIndex, allAccounts.length - 1))
293
+ : 0,
294
+ },
295
+ persistedStateById,
296
+ droppedIds: droppedIds ?? new Set<string>(),
297
+ };
298
+ }
299
+
300
+ export function reconcileManagedAccountsWithStorage(params: {
301
+ accounts: ManagedAccount[];
302
+ stored: AccountStorage;
303
+ currentIndex: number;
304
+ statsDeltaIds: Iterable<string>;
305
+ }): ReconciledAccounts {
306
+ const matchedAccounts = new Set<ManagedAccount>();
307
+ const reconciledAccounts: ManagedAccount[] = [];
308
+ let structuralChange = false;
309
+
310
+ for (const [index, storedAccount] of params.stored.accounts.entries()) {
311
+ const existing = findMatchingManagedAccount(params.accounts, {
312
+ id: storedAccount.id,
313
+ identity: resolveIdentity(storedAccount),
314
+ refreshToken: storedAccount.refreshToken,
315
+ });
316
+
317
+ if (existing) {
318
+ updateManagedAccountFromStorage(existing, storedAccount, index);
319
+ matchedAccounts.add(existing);
320
+ reconciledAccounts.push(existing);
321
+ continue;
322
+ }
323
+
324
+ const addedAccount = createManagedAccount({
325
+ id: storedAccount.id,
326
+ index,
327
+ email: storedAccount.email,
328
+ identity: storedAccount.identity,
329
+ label: storedAccount.label,
330
+ refreshToken: storedAccount.refreshToken,
331
+ access: storedAccount.access,
332
+ expires: storedAccount.expires,
333
+ tokenUpdatedAt: storedAccount.token_updated_at,
334
+ addedAt: storedAccount.addedAt,
335
+ lastUsed: storedAccount.lastUsed,
336
+ enabled: storedAccount.enabled,
337
+ rateLimitResetTimes: storedAccount.rateLimitResetTimes,
338
+ consecutiveFailures: storedAccount.consecutiveFailures,
339
+ lastFailureTime: storedAccount.lastFailureTime,
340
+ lastSwitchReason: storedAccount.lastSwitchReason,
341
+ stats: storedAccount.stats,
342
+ source: storedAccount.source || "oauth",
343
+ });
344
+ matchedAccounts.add(addedAccount);
345
+ reconciledAccounts.push(addedAccount);
346
+ structuralChange = true;
347
+ }
348
+
349
+ for (const account of params.accounts) {
350
+ if (matchedAccounts.has(account)) {
351
+ continue;
352
+ }
353
+
354
+ if (account.enabled) {
355
+ account.enabled = false;
356
+ structuralChange = true;
357
+ }
358
+
359
+ reconciledAccounts.push(account);
360
+ }
361
+
362
+ const orderChanged =
363
+ reconciledAccounts.length !== params.accounts.length ||
364
+ reconciledAccounts.some((account, index) => params.accounts[index] !== account);
365
+
366
+ const currentIds = new Set(reconciledAccounts.map((account) => account.id));
367
+ const staleDeltaIds = Array.from(params.statsDeltaIds).filter((id) => !currentIds.has(id));
368
+ const enabledAccounts = reconciledAccounts.filter((account) => account.enabled);
369
+
370
+ if (enabledAccounts.length === 0) {
371
+ return {
372
+ accounts: reconciledAccounts,
373
+ currentIndex: -1,
374
+ cursor: 0,
375
+ shouldRebuildTrackers: orderChanged || structuralChange,
376
+ resetHealthTrackerIndex: null,
377
+ staleDeltaIds,
378
+ };
379
+ }
380
+
381
+ const diskIndex = Math.min(params.stored.activeIndex, params.stored.accounts.length - 1);
382
+ const diskAccount = diskIndex >= 0 ? params.stored.accounts[diskIndex] : undefined;
383
+
384
+ if (!diskAccount || !diskAccount.enabled) {
385
+ if (!reconciledAccounts[params.currentIndex]?.enabled) {
386
+ const fallback = enabledAccounts[0]!;
387
+ return {
388
+ accounts: reconciledAccounts,
389
+ currentIndex: fallback.index,
390
+ cursor: fallback.index,
391
+ shouldRebuildTrackers: orderChanged || structuralChange,
392
+ resetHealthTrackerIndex: null,
393
+ staleDeltaIds,
394
+ };
395
+ }
396
+
397
+ return {
398
+ accounts: reconciledAccounts,
399
+ currentIndex: params.currentIndex,
400
+ cursor: params.currentIndex >= 0 ? params.currentIndex : enabledAccounts[0]!.index,
401
+ shouldRebuildTrackers: orderChanged || structuralChange,
402
+ resetHealthTrackerIndex: null,
403
+ staleDeltaIds,
404
+ };
405
+ }
406
+
407
+ const activeAccount = findMatchingManagedAccount(reconciledAccounts, {
408
+ id: diskAccount.id,
409
+ identity: resolveIdentity(diskAccount),
410
+ refreshToken: diskAccount.refreshToken,
411
+ });
412
+
413
+ if (activeAccount && activeAccount.enabled && activeAccount.index !== params.currentIndex) {
414
+ return {
415
+ accounts: reconciledAccounts,
416
+ currentIndex: activeAccount.index,
417
+ cursor: activeAccount.index,
418
+ shouldRebuildTrackers: orderChanged || structuralChange,
419
+ resetHealthTrackerIndex: activeAccount.index,
420
+ staleDeltaIds,
421
+ };
422
+ }
423
+
424
+ return {
425
+ accounts: reconciledAccounts,
426
+ currentIndex: params.currentIndex,
427
+ cursor: params.currentIndex >= 0 ? params.currentIndex : enabledAccounts[0]!.index,
428
+ shouldRebuildTrackers: orderChanged || structuralChange,
429
+ resetHealthTrackerIndex: null,
430
+ staleDeltaIds,
431
+ };
432
+ }
@@ -0,0 +1,276 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DEFAULT_CONFIG } from "../config.js";
3
+ import type { AccountStorage } from "../storage.js";
4
+ import { createInMemoryStorage, makeAccountsData, makeStoredAccount } from "../__tests__/helpers/in-memory-storage.js";
5
+ import type * as StorageModule from "../storage.js";
6
+
7
+ type CCCredential = {
8
+ accessToken: string;
9
+ refreshToken: string;
10
+ expiresAt: number;
11
+ source: "cc-keychain" | "cc-file";
12
+ label: string;
13
+ subscriptionType?: string;
14
+ };
15
+
16
+ type LoadManagerOptions = {
17
+ ccCredentials?: CCCredential[];
18
+ config?: typeof DEFAULT_CONFIG;
19
+ initialStorage?: AccountStorage;
20
+ };
21
+
22
+ async function loadManager(options: LoadManagerOptions = {}) {
23
+ vi.resetModules();
24
+
25
+ const storage = createInMemoryStorage(options.initialStorage);
26
+ const createDefaultStats = vi.fn((now?: number) => ({
27
+ requests: 0,
28
+ inputTokens: 0,
29
+ outputTokens: 0,
30
+ cacheReadTokens: 0,
31
+ cacheWriteTokens: 0,
32
+ lastReset: now ?? Date.now(),
33
+ }));
34
+
35
+ vi.doMock("../storage.js", async (importOriginal) => {
36
+ const actual = await importOriginal<typeof StorageModule>();
37
+ return {
38
+ ...actual,
39
+ createDefaultStats,
40
+ loadAccounts: storage.loadAccountsMock,
41
+ saveAccounts: storage.saveAccountsMock,
42
+ };
43
+ });
44
+
45
+ vi.doMock("../cc-credentials.js", () => ({
46
+ readCCCredentials: () => options.ccCredentials ?? [],
47
+ }));
48
+
49
+ const { AccountManager } = await import("../accounts.js");
50
+ const manager = await AccountManager.load(options.config ?? DEFAULT_CONFIG, null);
51
+ return { manager, storage };
52
+ }
53
+
54
+ describe("repairCorruptedCCAccounts (load-time heal)", () => {
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ vi.setSystemTime(new Date("2026-04-13T12:00:00Z"));
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.useRealTimers();
62
+ vi.clearAllMocks();
63
+ vi.resetModules();
64
+ });
65
+
66
+ it("full heal: cc-born id + matching live credential restores source/label/identity", async () => {
67
+ const corruptedRefresh = "sk-ant-ort01-corrupted-refresh-token-value";
68
+ const initialStorage = makeAccountsData([
69
+ makeStoredAccount({
70
+ id: "cc-cc-keychain-1775606505008:sk-ant-ort01",
71
+ refreshToken: corruptedRefresh,
72
+ access: "sk-ant-corrupted-access",
73
+ source: "oauth",
74
+ identity: { kind: "legacy", refreshToken: corruptedRefresh },
75
+ label: undefined,
76
+ email: undefined,
77
+ }),
78
+ ]);
79
+
80
+ const { manager } = await loadManager({
81
+ initialStorage,
82
+ ccCredentials: [
83
+ {
84
+ refreshToken: corruptedRefresh,
85
+ accessToken: "sk-ant-corrupted-access",
86
+ expiresAt: Date.now() + 3_600_000,
87
+ source: "cc-keychain",
88
+ label: "Claude Code-credentials",
89
+ subscriptionType: "max",
90
+ },
91
+ ],
92
+ });
93
+
94
+ const snapshot = manager.getAccountsSnapshot();
95
+ expect(snapshot).toHaveLength(1);
96
+ expect(snapshot[0]).toMatchObject({
97
+ id: "cc-cc-keychain-1775606505008:sk-ant-ort01",
98
+ source: "cc-keychain",
99
+ label: "Claude Code-credentials",
100
+ identity: {
101
+ kind: "cc",
102
+ source: "cc-keychain",
103
+ label: "Claude Code-credentials",
104
+ },
105
+ });
106
+ });
107
+
108
+ it("partial heal: cc-born id with no live credential restores source from id and clears legacy identity", async () => {
109
+ const refresh = "sk-ant-ort01-stale-token";
110
+ const initialStorage = makeAccountsData([
111
+ makeStoredAccount({
112
+ id: "cc-cc-keychain-1775606505008:sk-ant-ort01",
113
+ refreshToken: refresh,
114
+ source: "oauth",
115
+ identity: { kind: "legacy", refreshToken: refresh },
116
+ label: undefined,
117
+ }),
118
+ ]);
119
+
120
+ const { manager } = await loadManager({
121
+ initialStorage,
122
+ ccCredentials: [],
123
+ });
124
+
125
+ const snapshot = manager.getAccountsSnapshot();
126
+ expect(snapshot).toHaveLength(1);
127
+ expect(snapshot[0]?.source).toBe("cc-keychain");
128
+ expect(snapshot[0]?.identity).toBeUndefined();
129
+ });
130
+
131
+ it("collapses corrupted cc-born duplicate into healthy cc row on load", async () => {
132
+ const corruptedRefresh = "sk-ant-ort01-old-rotated-token";
133
+ const freshRefresh = "sk-ant-ort01-new-rotated-token";
134
+ const initialStorage = makeAccountsData([
135
+ makeStoredAccount({
136
+ id: "cc-cc-keychain-1775606505008:sk-ant-ort01",
137
+ refreshToken: corruptedRefresh,
138
+ access: "sk-ant-old-access",
139
+ source: "oauth",
140
+ identity: { kind: "legacy", refreshToken: corruptedRefresh },
141
+ label: undefined,
142
+ addedAt: 1775606505008,
143
+ token_updated_at: 1775606505008,
144
+ }),
145
+ makeStoredAccount({
146
+ id: "cc-cc-keychain-1775767359131:sk-ant-ort01",
147
+ refreshToken: freshRefresh,
148
+ access: "sk-ant-new-access",
149
+ source: "cc-keychain",
150
+ label: "Claude Code-credentials",
151
+ identity: {
152
+ kind: "cc",
153
+ source: "cc-keychain",
154
+ label: "Claude Code-credentials",
155
+ },
156
+ addedAt: 1775767359131,
157
+ token_updated_at: 1775767359131,
158
+ }),
159
+ ]);
160
+
161
+ const { manager } = await loadManager({
162
+ initialStorage,
163
+ ccCredentials: [
164
+ {
165
+ refreshToken: freshRefresh,
166
+ accessToken: "sk-ant-new-access",
167
+ expiresAt: Date.now() + 3_600_000,
168
+ source: "cc-keychain",
169
+ label: "Claude Code-credentials",
170
+ subscriptionType: "max",
171
+ },
172
+ ],
173
+ });
174
+
175
+ const snapshot = manager.getAccountsSnapshot();
176
+ expect(snapshot).toHaveLength(1);
177
+ expect(snapshot[0]).toMatchObject({
178
+ source: "cc-keychain",
179
+ label: "Claude Code-credentials",
180
+ refreshToken: freshRefresh,
181
+ identity: {
182
+ kind: "cc",
183
+ source: "cc-keychain",
184
+ label: "Claude Code-credentials",
185
+ },
186
+ });
187
+ });
188
+
189
+ it("addAccount() refuses to downgrade a CC row to oauth when called without CC options", async () => {
190
+ const initialStorage = makeAccountsData([
191
+ makeStoredAccount({
192
+ id: "cc-cc-keychain-1775606505008:sk-ant-ort01",
193
+ refreshToken: "cc-refresh-original",
194
+ access: "cc-access-original",
195
+ source: "cc-keychain",
196
+ label: "Claude Code-credentials",
197
+ identity: {
198
+ kind: "cc",
199
+ source: "cc-keychain",
200
+ label: "Claude Code-credentials",
201
+ },
202
+ }),
203
+ ]);
204
+
205
+ const { manager } = await loadManager({ initialStorage });
206
+
207
+ const result = manager.addAccount(
208
+ "cc-refresh-original",
209
+ "sk-ant-fresh-access",
210
+ Date.now() + 7_200_000,
211
+ undefined,
212
+ );
213
+
214
+ expect(result).not.toBeNull();
215
+ const snapshot = manager.getAccountsSnapshot();
216
+ expect(snapshot).toHaveLength(1);
217
+ expect(snapshot[0]).toMatchObject({
218
+ source: "cc-keychain",
219
+ label: "Claude Code-credentials",
220
+ access: "sk-ant-fresh-access",
221
+ identity: {
222
+ kind: "cc",
223
+ source: "cc-keychain",
224
+ label: "Claude Code-credentials",
225
+ },
226
+ });
227
+ });
228
+
229
+ it("addAccount() with explicit oauth source does NOT downgrade a healthy CC row", async () => {
230
+ const initialStorage = makeAccountsData([
231
+ makeStoredAccount({
232
+ id: "cc-cc-keychain-1775606505008:sk-ant-ort01",
233
+ refreshToken: "cc-refresh",
234
+ source: "cc-keychain",
235
+ label: "Claude Code-credentials",
236
+ identity: {
237
+ kind: "cc",
238
+ source: "cc-keychain",
239
+ label: "Claude Code-credentials",
240
+ },
241
+ }),
242
+ ]);
243
+
244
+ const { manager } = await loadManager({ initialStorage });
245
+
246
+ manager.addAccount("cc-refresh", "fresh-access", Date.now() + 7_200_000, "alice@example.com", {
247
+ source: "oauth",
248
+ });
249
+
250
+ const snapshot = manager.getAccountsSnapshot();
251
+ expect(snapshot[0]?.source).toBe("cc-keychain");
252
+ expect(snapshot[0]?.identity).toMatchObject({ kind: "cc" });
253
+ });
254
+
255
+ it("does NOT touch healthy non-CC rows", async () => {
256
+ const initialStorage = makeAccountsData([
257
+ makeStoredAccount({
258
+ id: "1234567890:sk-ant-oauth1",
259
+ refreshToken: "oauth-refresh",
260
+ email: "alice@example.com",
261
+ source: "oauth",
262
+ identity: { kind: "oauth", email: "alice@example.com" },
263
+ }),
264
+ ]);
265
+
266
+ const { manager } = await loadManager({ initialStorage });
267
+
268
+ const snapshot = manager.getAccountsSnapshot();
269
+ expect(snapshot).toHaveLength(1);
270
+ expect(snapshot[0]).toMatchObject({
271
+ source: "oauth",
272
+ email: "alice@example.com",
273
+ identity: { kind: "oauth", email: "alice@example.com" },
274
+ });
275
+ });
276
+ });