@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,407 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Load-time repair pass for corrupted CC account records.
3
+ //
4
+ // Historical context
5
+ // ------------------
6
+ // An older version of the plugin (or a partial write outside the current
7
+ // mutation sites) could leave a CC-imported row with `source="oauth"`,
8
+ // `identity={kind:"legacy",…}`, and missing `label`/`email`. The row's `id`
9
+ // still starts with `cc-cc-keychain-…` / `cc-cc-file-…` because the id
10
+ // prefix is set once at CC-import time (see accounts.ts createManagedAccount
11
+ // call for the import branch) and never rewritten by any current code path.
12
+ //
13
+ // When the loader sees that corrupted row it computes its identity as
14
+ // `{kind:"legacy", refreshToken}`. After CC rotates the refresh token
15
+ // through `claude -p . --model haiku`, the row can no longer dedupe
16
+ // against the current CC credential — the identity kinds differ (`legacy`
17
+ // vs `cc`) and the refresh tokens differ — so a second CC row gets
18
+ // created, producing two rows for the same upstream Anthropic account.
19
+ //
20
+ // What this pass does
21
+ // -------------------
22
+ // 1. Detect rows whose `id` proves they were born as CC imports.
23
+ // 2. If the row looks corrupted (wrong source, legacy identity, or
24
+ // missing label/identity), try to rehydrate from the live CC
25
+ // credentials. If that fails, at least restore `source` from the
26
+ // id prefix and clear the bogus legacy identity so the existing
27
+ // unlabeled fallback in AccountManager.load() can finish the heal.
28
+ // 3. Collapse any duplicates that now share a `{kind:"cc",…}` identity.
29
+ // When merging, keep the freshest auth by `tokenUpdatedAt`, keep
30
+ // `max(lastUsed)`, OR-enable, and preserve the richer identity.
31
+ // Stats are NOT summed — both rows represent the same upstream
32
+ // account and summation would double-count local counters.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ import type { CCCredential } from "../cc-credentials.js";
36
+ import { readCCCredentials } from "../cc-credentials.js";
37
+ import { identitiesMatch, resolveIdentity, type AccountIdentity } from "../account-identity.js";
38
+ import type { ManagedAccount } from "../accounts.js";
39
+ import type { AccountMetadata, AccountStorage } from "../storage.js";
40
+ import { loadAccounts, saveAccounts } from "../storage.js";
41
+
42
+ type CCAccountSource = "cc-keychain" | "cc-file";
43
+
44
+ const CC_ID_PATTERN = /^cc-(cc-keychain|cc-file)-\d+:/;
45
+
46
+ export interface RepairResult {
47
+ repaired: number;
48
+ collapsed: number;
49
+ }
50
+
51
+ /**
52
+ * Infer the intended CC source from an account id generated by the CC
53
+ * auto-import path. Returns null for ids that were not born as CC imports.
54
+ */
55
+ export function inferCCSourceFromId(id: string): CCAccountSource | null {
56
+ const match = CC_ID_PATTERN.exec(id);
57
+ if (!match) return null;
58
+ const source = match[1];
59
+ return source === "cc-keychain" || source === "cc-file" ? source : null;
60
+ }
61
+
62
+ function isCCIdentity(identity: AccountIdentity | undefined): identity is Extract<AccountIdentity, { kind: "cc" }> {
63
+ return identity?.kind === "cc";
64
+ }
65
+
66
+ function looksCorrupted(account: ManagedAccount, inferredSource: CCAccountSource): boolean {
67
+ if (account.source !== inferredSource) return true;
68
+ if (!isCCIdentity(account.identity)) return true;
69
+ if (!account.label) return true;
70
+ return false;
71
+ }
72
+
73
+ function findMatchingCredential(
74
+ account: ManagedAccount,
75
+ inferredSource: CCAccountSource,
76
+ ccCredentials: CCCredential[],
77
+ ): CCCredential | null {
78
+ const sameSource = ccCredentials.filter((credential) => credential.source === inferredSource);
79
+ if (sameSource.length === 0) return null;
80
+
81
+ const byRefresh = sameSource.find((credential) => credential.refreshToken === account.refreshToken);
82
+ if (byRefresh) return byRefresh;
83
+
84
+ if (account.access) {
85
+ const byAccess = sameSource.find((credential) => credential.accessToken === account.access);
86
+ if (byAccess) return byAccess;
87
+ }
88
+
89
+ // Last-resort: when there's exactly one live credential of the inferred
90
+ // source, the row must be that credential — CC's keychain holds at most
91
+ // one credential per service name, so a single-source match is unique.
92
+ // This mirrors the unlabeled-fallback in AccountManager.load() at the
93
+ // CC auto-import loop and is what enables collapse against a healthy
94
+ // sibling row that was already promoted by auto-import.
95
+ if (sameSource.length === 1) return sameSource[0]!;
96
+
97
+ return null;
98
+ }
99
+
100
+ function repairAccount(
101
+ account: ManagedAccount,
102
+ inferredSource: CCAccountSource,
103
+ ccCredentials: CCCredential[],
104
+ ): boolean {
105
+ const matched = findMatchingCredential(account, inferredSource, ccCredentials);
106
+
107
+ if (matched) {
108
+ account.source = matched.source;
109
+ account.label = matched.label;
110
+ account.identity = {
111
+ kind: "cc",
112
+ source: matched.source,
113
+ label: matched.label,
114
+ };
115
+ return true;
116
+ }
117
+
118
+ // Partial heal: restore the source from the id prefix and drop the bogus
119
+ // legacy identity. The unlabeled-CC fallback in AccountManager.load() at
120
+ // the CC auto-detect step can then reclaim the row on the same load pass.
121
+ let mutated = false;
122
+ if (account.source !== inferredSource) {
123
+ account.source = inferredSource;
124
+ mutated = true;
125
+ }
126
+ if (account.identity?.kind === "legacy") {
127
+ account.identity = undefined;
128
+ mutated = true;
129
+ }
130
+ return mutated;
131
+ }
132
+
133
+ function mergeDuplicate(keep: ManagedAccount, loser: ManagedAccount): void {
134
+ const keepTokenTs = keep.tokenUpdatedAt ?? 0;
135
+ const loserTokenTs = loser.tokenUpdatedAt ?? 0;
136
+ const loserHasFresherAuth = loserTokenTs > keepTokenTs;
137
+
138
+ if (loserHasFresherAuth) {
139
+ keep.refreshToken = loser.refreshToken;
140
+ keep.access = loser.access;
141
+ keep.expires = loser.expires;
142
+ keep.tokenUpdatedAt = loserTokenTs;
143
+ }
144
+
145
+ keep.lastUsed = Math.max(keep.lastUsed, loser.lastUsed);
146
+ keep.enabled = keep.enabled || loser.enabled;
147
+ keep.addedAt = Math.min(keep.addedAt, loser.addedAt);
148
+
149
+ // Prefer a non-legacy identity. If keep is legacy and loser is richer,
150
+ // adopt loser's identity metadata. This preserves the best representation
151
+ // of the underlying account.
152
+ const keepIsLegacy = keep.identity?.kind === "legacy" || keep.identity === undefined;
153
+ const loserIsRicher = loser.identity !== undefined && loser.identity.kind !== "legacy";
154
+ if (keepIsLegacy && loserIsRicher) {
155
+ keep.identity = loser.identity;
156
+ if (loser.source) keep.source = loser.source;
157
+ if (loser.label !== undefined) keep.label = loser.label;
158
+ if (loser.email !== undefined) keep.email = loser.email;
159
+ }
160
+ }
161
+
162
+ function collapseDuplicates(accounts: ManagedAccount[]): { kept: ManagedAccount[]; collapsed: number } {
163
+ const kept: ManagedAccount[] = [];
164
+ let collapsed = 0;
165
+
166
+ for (const account of accounts) {
167
+ const identity = resolveIdentity(account);
168
+ // Never collapse rows whose identity is `legacy` — those are not
169
+ // confidently the same account, and collapsing them could destroy
170
+ // a legitimately distinct user's record.
171
+ if (identity.kind === "legacy") {
172
+ kept.push(account);
173
+ continue;
174
+ }
175
+
176
+ const duplicate = kept.find((existing) => identitiesMatch(resolveIdentity(existing), identity));
177
+ if (duplicate) {
178
+ mergeDuplicate(duplicate, account);
179
+ collapsed += 1;
180
+ continue;
181
+ }
182
+
183
+ kept.push(account);
184
+ }
185
+
186
+ return { kept, collapsed };
187
+ }
188
+
189
+ /**
190
+ * Apply the full repair pass to a list of managed accounts. Mutates rows in
191
+ * place for healing; returns a possibly-shorter array when duplicates are
192
+ * collapsed. Call from AccountManager.load() after records are loaded into
193
+ * memory but before the CC auto-import loop.
194
+ */
195
+ export function repairCorruptedCCAccounts(
196
+ accounts: ManagedAccount[],
197
+ ccCredentials: CCCredential[],
198
+ ): { accounts: ManagedAccount[]; result: RepairResult } {
199
+ let repaired = 0;
200
+
201
+ for (const account of accounts) {
202
+ const inferredSource = inferCCSourceFromId(account.id);
203
+ if (!inferredSource) continue;
204
+ if (!looksCorrupted(account, inferredSource)) continue;
205
+ if (repairAccount(account, inferredSource, ccCredentials)) {
206
+ repaired += 1;
207
+ }
208
+ }
209
+
210
+ const { kept, collapsed } = collapseDuplicates(accounts);
211
+ return {
212
+ accounts: kept,
213
+ result: { repaired, collapsed },
214
+ };
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Storage-level variant — operates on AccountMetadata so the standalone CLI
219
+ // commands (which use raw loadAccounts) get the same heal that AccountManager
220
+ // already gets in-memory. Field shapes are nearly identical to ManagedAccount
221
+ // except for `token_updated_at` (snake_case) instead of `tokenUpdatedAt`.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ function looksCorruptedStored(account: AccountMetadata, inferredSource: CCAccountSource): boolean {
225
+ if (account.source !== inferredSource) return true;
226
+ if (!isCCIdentity(account.identity)) return true;
227
+ if (!account.label) return true;
228
+ return false;
229
+ }
230
+
231
+ function findMatchingCredentialStored(
232
+ account: AccountMetadata,
233
+ inferredSource: CCAccountSource,
234
+ ccCredentials: CCCredential[],
235
+ ): CCCredential | null {
236
+ const sameSource = ccCredentials.filter((credential) => credential.source === inferredSource);
237
+ if (sameSource.length === 0) return null;
238
+
239
+ const byRefresh = sameSource.find((credential) => credential.refreshToken === account.refreshToken);
240
+ if (byRefresh) return byRefresh;
241
+
242
+ if (account.access) {
243
+ const byAccess = sameSource.find((credential) => credential.accessToken === account.access);
244
+ if (byAccess) return byAccess;
245
+ }
246
+
247
+ if (sameSource.length === 1) return sameSource[0]!;
248
+
249
+ return null;
250
+ }
251
+
252
+ function repairStoredAccount(
253
+ account: AccountMetadata,
254
+ inferredSource: CCAccountSource,
255
+ ccCredentials: CCCredential[],
256
+ ): boolean {
257
+ const matched = findMatchingCredentialStored(account, inferredSource, ccCredentials);
258
+
259
+ if (matched) {
260
+ account.source = matched.source;
261
+ account.label = matched.label;
262
+ account.identity = {
263
+ kind: "cc",
264
+ source: matched.source,
265
+ label: matched.label,
266
+ };
267
+ return true;
268
+ }
269
+
270
+ let mutated = false;
271
+ if (account.source !== inferredSource) {
272
+ account.source = inferredSource;
273
+ mutated = true;
274
+ }
275
+ if (account.identity?.kind === "legacy") {
276
+ account.identity = undefined;
277
+ mutated = true;
278
+ }
279
+ return mutated;
280
+ }
281
+
282
+ function mergeStoredDuplicate(keep: AccountMetadata, loser: AccountMetadata): void {
283
+ const keepTokenTs = keep.token_updated_at ?? 0;
284
+ const loserTokenTs = loser.token_updated_at ?? 0;
285
+
286
+ if (loserTokenTs > keepTokenTs) {
287
+ keep.refreshToken = loser.refreshToken;
288
+ keep.access = loser.access;
289
+ keep.expires = loser.expires;
290
+ keep.token_updated_at = loserTokenTs;
291
+ }
292
+
293
+ keep.lastUsed = Math.max(keep.lastUsed, loser.lastUsed);
294
+ keep.enabled = keep.enabled || loser.enabled;
295
+ keep.addedAt = Math.min(keep.addedAt, loser.addedAt);
296
+
297
+ const keepIsLegacy = keep.identity?.kind === "legacy" || keep.identity === undefined;
298
+ const loserIsRicher = loser.identity !== undefined && loser.identity.kind !== "legacy";
299
+ if (keepIsLegacy && loserIsRicher) {
300
+ keep.identity = loser.identity;
301
+ if (loser.source) keep.source = loser.source;
302
+ if (loser.label !== undefined) keep.label = loser.label;
303
+ if (loser.email !== undefined) keep.email = loser.email;
304
+ }
305
+ }
306
+
307
+ function collapseStoredDuplicates(accounts: AccountMetadata[]): {
308
+ kept: AccountMetadata[];
309
+ collapsed: number;
310
+ droppedIds: string[];
311
+ } {
312
+ const kept: AccountMetadata[] = [];
313
+ const droppedIds: string[] = [];
314
+ let collapsed = 0;
315
+
316
+ for (const account of accounts) {
317
+ const identity = resolveIdentity(account);
318
+ if (identity.kind === "legacy") {
319
+ kept.push(account);
320
+ continue;
321
+ }
322
+
323
+ const duplicate = kept.find((existing) => identitiesMatch(resolveIdentity(existing), identity));
324
+ if (duplicate) {
325
+ mergeStoredDuplicate(duplicate, account);
326
+ droppedIds.push(account.id);
327
+ collapsed += 1;
328
+ continue;
329
+ }
330
+
331
+ kept.push(account);
332
+ }
333
+
334
+ return { kept, collapsed, droppedIds };
335
+ }
336
+
337
+ /**
338
+ * Storage-level variant of `repairCorruptedCCAccounts` that operates on raw
339
+ * `AccountMetadata` rows. Used by `loadAccountsWithRepair` so the standalone
340
+ * CLI commands and any other consumer of `loadAccounts()` get the same heal
341
+ * that `AccountManager.load()` already provides in-memory. The returned
342
+ * `droppedIds` must be forwarded to `saveAccounts` so the disk-only union
343
+ * does not restore the collapsed rows.
344
+ */
345
+ export function repairStoredCCAccounts(
346
+ accounts: AccountMetadata[],
347
+ ccCredentials: CCCredential[],
348
+ ): { accounts: AccountMetadata[]; result: RepairResult; droppedIds: string[] } {
349
+ let repaired = 0;
350
+
351
+ for (const account of accounts) {
352
+ const inferredSource = inferCCSourceFromId(account.id);
353
+ if (!inferredSource) continue;
354
+ if (!looksCorruptedStored(account, inferredSource)) continue;
355
+ if (repairStoredAccount(account, inferredSource, ccCredentials)) {
356
+ repaired += 1;
357
+ }
358
+ }
359
+
360
+ const { kept, collapsed, droppedIds } = collapseStoredDuplicates(accounts);
361
+ return {
362
+ accounts: kept,
363
+ result: { repaired, collapsed },
364
+ droppedIds,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Drop-in replacement for `loadAccounts()` that runs the load-time repair
370
+ * pass and persists the healed state when something changed. Returns the
371
+ * healed storage object, or null if the file does not exist.
372
+ */
373
+ export async function loadAccountsWithRepair(): Promise<AccountStorage | null> {
374
+ const stored = await loadAccounts();
375
+ if (!stored) return stored;
376
+
377
+ let ccCredentials: CCCredential[];
378
+ try {
379
+ ccCredentials = readCCCredentials();
380
+ } catch {
381
+ ccCredentials = [];
382
+ }
383
+
384
+ const { accounts, result, droppedIds } = repairStoredCCAccounts(stored.accounts, ccCredentials);
385
+
386
+ if (result.repaired === 0 && result.collapsed === 0) {
387
+ return stored;
388
+ }
389
+
390
+ const healed: AccountStorage = {
391
+ ...stored,
392
+ accounts,
393
+ activeIndex: accounts.length === 0 ? 0 : Math.max(0, Math.min(stored.activeIndex, accounts.length - 1)),
394
+ };
395
+
396
+ try {
397
+ await saveAccounts(healed, { droppedIds: new Set(droppedIds) });
398
+ } catch (err) {
399
+ // Best-effort persistence: the in-memory healed state is still correct
400
+ // for this call's caller even if writing back failed. Surface the error
401
+ // on stderr so misconfigurations don't fail silently.
402
+ // eslint-disable-next-line no-console -- diagnostic for an unexpected save failure
403
+ console.error("[opencode-anthropic-auth] loadAccountsWithRepair: save failed:", (err as Error).message);
404
+ }
405
+
406
+ return healed;
407
+ }