@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
@@ -6,693 +6,693 @@ import type * as StorageModule from "./storage.js";
6
6
  import type * as ConfigModule from "./config.js";
7
7
 
8
8
  type CCCredential = {
9
- accessToken: string;
10
- refreshToken: string;
11
- expiresAt: number;
12
- source: "cc-keychain" | "cc-file";
13
- label: string;
14
- subscriptionType?: string;
9
+ accessToken: string;
10
+ refreshToken: string;
11
+ expiresAt: number;
12
+ source: "cc-keychain" | "cc-file";
13
+ label: string;
14
+ subscriptionType?: string;
15
15
  };
16
16
 
17
17
  type LoadManagerOptions = {
18
- authFallback?: {
19
- refresh: string;
20
- access?: string;
21
- expires?: number;
22
- } | null;
23
- ccCredentials?: CCCredential[];
24
- config?: typeof DEFAULT_CONFIG;
25
- initialStorage?: AccountStorage;
18
+ authFallback?: {
19
+ refresh: string;
20
+ access?: string;
21
+ expires?: number;
22
+ } | null;
23
+ ccCredentials?: CCCredential[];
24
+ config?: typeof DEFAULT_CONFIG;
25
+ initialStorage?: AccountStorage;
26
26
  };
27
27
 
28
28
  type ExchangeSuccess = {
29
- type: "success";
30
- refresh: string;
31
- access: string;
32
- expires: number;
33
- email?: string;
29
+ type: "success";
30
+ refresh: string;
31
+ access: string;
32
+ expires: number;
33
+ email?: string;
34
34
  };
35
35
 
36
36
  type LoadPluginOptions = {
37
- ccCredentials?: CCCredential[];
38
- config?: typeof DEFAULT_CONFIG;
39
- exchangeResult?: ExchangeSuccess;
40
- initialStorage?: AccountStorage;
37
+ ccCredentials?: CCCredential[];
38
+ config?: typeof DEFAULT_CONFIG;
39
+ exchangeResult?: ExchangeSuccess;
40
+ initialStorage?: AccountStorage;
41
41
  };
42
42
 
43
43
  function makeStats(lastReset = Date.now()) {
44
- return {
45
- requests: 0,
46
- inputTokens: 0,
47
- outputTokens: 0,
48
- cacheReadTokens: 0,
49
- cacheWriteTokens: 0,
50
- lastReset,
51
- };
44
+ return {
45
+ requests: 0,
46
+ inputTokens: 0,
47
+ outputTokens: 0,
48
+ cacheReadTokens: 0,
49
+ cacheWriteTokens: 0,
50
+ lastReset,
51
+ };
52
52
  }
53
53
 
54
54
  function makeCCCredential(overrides: Partial<CCCredential> = {}): CCCredential {
55
- return {
56
- accessToken: "cc-access-fresh",
57
- refreshToken: "cc-refresh-fresh",
58
- expiresAt: Date.now() + 3_600_000,
59
- source: "cc-keychain",
60
- label: "Claude Code-credentials:alice@example.com",
61
- subscriptionType: "max",
62
- ...overrides,
63
- };
55
+ return {
56
+ accessToken: "cc-access-fresh",
57
+ refreshToken: "cc-refresh-fresh",
58
+ expiresAt: Date.now() + 3_600_000,
59
+ source: "cc-keychain",
60
+ label: "Claude Code-credentials:alice@example.com",
61
+ subscriptionType: "max",
62
+ ...overrides,
63
+ };
64
64
  }
65
65
 
66
66
  async function loadManager(options: LoadManagerOptions = {}) {
67
- vi.resetModules();
67
+ vi.resetModules();
68
68
 
69
- const storage = createInMemoryStorage(options.initialStorage);
70
- const createDefaultStats = vi.fn((now?: number) => makeStats(now ?? Date.now()));
69
+ const storage = createInMemoryStorage(options.initialStorage);
70
+ const createDefaultStats = vi.fn((now?: number) => makeStats(now ?? Date.now()));
71
71
 
72
- vi.doMock("./storage.js", async (importOriginal) => {
73
- const actual = await importOriginal<typeof StorageModule>();
72
+ vi.doMock("./storage.js", async (importOriginal) => {
73
+ const actual = await importOriginal<typeof StorageModule>();
74
74
 
75
- return {
76
- ...actual,
77
- createDefaultStats,
78
- loadAccounts: storage.loadAccountsMock,
79
- saveAccounts: storage.saveAccountsMock,
80
- };
81
- });
75
+ return {
76
+ ...actual,
77
+ createDefaultStats,
78
+ loadAccounts: storage.loadAccountsMock,
79
+ saveAccounts: storage.saveAccountsMock,
80
+ };
81
+ });
82
82
 
83
- vi.doMock("./cc-credentials.js", () => ({
84
- readCCCredentials: () => options.ccCredentials ?? [],
85
- }));
83
+ vi.doMock("./cc-credentials.js", () => ({
84
+ readCCCredentials: () => options.ccCredentials ?? [],
85
+ }));
86
86
 
87
- const { AccountManager } = await import("./accounts.js");
87
+ const { AccountManager } = await import("./accounts.js");
88
88
 
89
- const manager = await AccountManager.load(options.config ?? DEFAULT_CONFIG, options.authFallback ?? null);
89
+ const manager = await AccountManager.load(options.config ?? DEFAULT_CONFIG, options.authFallback ?? null);
90
90
 
91
- return {
92
- manager,
93
- storage,
94
- };
91
+ return {
92
+ manager,
93
+ storage,
94
+ };
95
95
  }
96
96
 
97
97
  function makeClient() {
98
- return {
99
- auth: {
100
- set: vi.fn().mockResolvedValue(undefined),
101
- },
102
- session: {
103
- prompt: vi.fn().mockResolvedValue(undefined),
104
- },
105
- tui: {
106
- showToast: vi.fn().mockResolvedValue(undefined),
107
- },
108
- };
109
- }
110
-
111
- async function loadPlugin(options: LoadPluginOptions = {}) {
112
- vi.resetModules();
113
-
114
- const storage = createInMemoryStorage(options.initialStorage);
115
- const createDefaultStats = vi.fn((now?: number) => makeStats(now ?? Date.now()));
116
- const authorizeMock = vi.fn().mockResolvedValue({
117
- url: "https://claude.ai/oauth/authorize?state=test-state",
118
- verifier: "test-verifier",
119
- state: "test-state",
120
- });
121
- const exchangeMock = vi.fn().mockResolvedValue(
122
- options.exchangeResult ?? {
123
- type: "success",
124
- refresh: "oauth-refresh-fresh",
125
- access: "oauth-access-fresh",
126
- expires: Date.now() + 3_600_000,
127
- email: "alice@example.com",
128
- },
129
- );
130
-
131
- vi.doMock("./storage.js", () => ({
132
- createDefaultStats,
133
- loadAccounts: storage.loadAccountsMock,
134
- saveAccounts: storage.saveAccountsMock,
135
- clearAccounts: vi.fn().mockResolvedValue(undefined),
136
- }));
137
-
138
- vi.doMock("./cc-credentials.js", () => ({
139
- readCCCredentials: () => options.ccCredentials ?? [],
140
- }));
141
-
142
- vi.doMock("./config.js", async (importOriginal) => {
143
- const actual = await importOriginal<typeof ConfigModule>();
144
-
145
98
  return {
146
- ...actual,
147
- DEFAULT_CONFIG,
148
- loadConfig: vi.fn(() => ({
149
- ...DEFAULT_CONFIG,
150
- signature_emulation: {
151
- ...DEFAULT_CONFIG.signature_emulation,
152
- fetch_claude_code_version_on_startup: false,
99
+ auth: {
100
+ set: vi.fn().mockResolvedValue(undefined),
153
101
  },
154
- idle_refresh: {
155
- ...DEFAULT_CONFIG.idle_refresh,
156
- enabled: false,
102
+ session: {
103
+ prompt: vi.fn().mockResolvedValue(undefined),
157
104
  },
158
- cc_credential_reuse: {
159
- ...DEFAULT_CONFIG.cc_credential_reuse,
105
+ tui: {
106
+ showToast: vi.fn().mockResolvedValue(undefined),
160
107
  },
161
- ...(options.config ?? {}),
162
- })),
163
108
  };
164
- });
165
-
166
- vi.doMock("./oauth.js", () => ({
167
- authorize: authorizeMock,
168
- exchange: exchangeMock,
169
- }));
170
-
171
- vi.doMock("./commands/prompts.js", () => ({
172
- promptAccountMenu: vi.fn().mockResolvedValue("add"),
173
- promptManageAccounts: vi.fn().mockResolvedValue(undefined),
174
- }));
175
-
176
- vi.doMock("./bun-fetch.js", () => ({
177
- createBunFetch: () => ({
178
- fetch: vi.fn(),
179
- }),
180
- }));
181
-
182
- const { AnthropicAuthPlugin } = await import("./index.js");
183
-
184
- return {
185
- plugin: await AnthropicAuthPlugin({ client: makeClient() }),
186
- storage,
187
- };
188
109
  }
189
110
 
190
- function lastSavedStorage(storage: ReturnType<typeof createInMemoryStorage>): AccountStorage {
191
- const calls = (storage.saveAccountsMock as unknown as Mock).mock.calls as AccountStorage[][];
192
- const saved = calls[calls.length - 1]?.[0] as AccountStorage | undefined;
193
- expect(saved).toBeDefined();
194
- return saved!;
195
- }
196
-
197
- describe("AccountManager identity-based dedup RED", () => {
198
- beforeEach(() => {
199
- vi.useFakeTimers();
200
- vi.setSystemTime(new Date("2026-04-10T12:00:00Z"));
201
- });
202
-
203
- afterEach(() => {
204
- vi.useRealTimers();
205
- vi.clearAllMocks();
111
+ async function loadPlugin(options: LoadPluginOptions = {}) {
206
112
  vi.resetModules();
207
- });
208
-
209
- it("updates an OAuth account by email instead of creating a duplicate on refresh rotation", async () => {
210
- const initialStorage = makeAccountsData([
211
- {
212
- id: "oauth-1",
213
- email: "alice@example.com",
214
- refreshToken: "oauth-refresh-old",
215
- access: "oauth-access-old",
216
- source: "oauth",
217
- },
218
- ]);
219
-
220
- const { manager } = await loadManager({ initialStorage });
221
-
222
- manager.addAccount("oauth-refresh-new", "oauth-access-new", Date.now() + 7_200_000, "alice@example.com");
223
-
224
- const snapshot = manager.getAccountsSnapshot();
225
- expect(snapshot).toHaveLength(1);
226
- expect(snapshot[0]).toMatchObject({
227
- id: "oauth-1",
228
- refreshToken: "oauth-refresh-new",
229
- access: "oauth-access-new",
230
- email: "alice@example.com",
231
- });
232
- });
233
-
234
- it("matches duplicates by identity rather than refresh token for OAuth accounts", async () => {
235
- const initialStorage = makeAccountsData([
236
- {
237
- id: "oauth-identity",
238
- email: "identity@example.com",
239
- refreshToken: "oauth-refresh-a",
240
- access: "oauth-access-a",
241
- },
242
- ]);
243
-
244
- const { manager } = await loadManager({ initialStorage });
245
-
246
- const updated = manager.addAccount(
247
- "oauth-refresh-b",
248
- "oauth-access-b",
249
- Date.now() + 7_200_000,
250
- "identity@example.com",
251
- );
252
113
 
253
- expect(updated?.id).toBe("oauth-identity");
254
- expect(manager.getAccountsSnapshot()).toHaveLength(1);
255
- });
256
-
257
- it("preserves account metadata when an OAuth identity is updated", async () => {
258
- const initialStorage = makeAccountsData([
259
- {
260
- id: "oauth-meta",
261
- email: "meta@example.com",
262
- refreshToken: "oauth-meta-old",
263
- access: "oauth-meta-access-old",
264
- addedAt: 1_111,
265
- lastUsed: 2_222,
266
- token_updated_at: 3_333,
267
- lastSwitchReason: "sticky",
268
- source: "oauth",
269
- stats: {
270
- requests: 9,
271
- inputTokens: 90,
272
- outputTokens: 45,
273
- cacheReadTokens: 0,
274
- cacheWriteTokens: 0,
275
- lastReset: 4_444,
276
- },
277
- },
278
- ]);
279
-
280
- const { manager } = await loadManager({ initialStorage });
281
-
282
- manager.addAccount("oauth-meta-new", "oauth-meta-access-new", Date.now() + 9_000_000, "meta@example.com");
283
-
284
- const snapshot = manager.getAccountsSnapshot();
285
- expect(snapshot).toHaveLength(1);
286
- expect(snapshot[0]).toMatchObject({
287
- id: "oauth-meta",
288
- addedAt: 1_111,
289
- lastUsed: 2_222,
290
- lastSwitchReason: "sticky",
291
- source: "oauth",
292
- stats: expect.objectContaining({
293
- requests: 9,
294
- inputTokens: 90,
295
- outputTokens: 45,
296
- }),
114
+ const storage = createInMemoryStorage(options.initialStorage);
115
+ const createDefaultStats = vi.fn((now?: number) => makeStats(now ?? Date.now()));
116
+ const authorizeMock = vi.fn().mockResolvedValue({
117
+ url: "https://claude.ai/oauth/authorize?state=test-state",
118
+ verifier: "test-verifier",
119
+ state: "test-state",
297
120
  });
298
- });
299
-
300
- it("preserves the active index when dedup updates an existing OAuth identity", async () => {
301
- const initialStorage = makeAccountsData(
302
- [
303
- {
304
- id: "oauth-active-a",
305
- email: "alpha@example.com",
306
- refreshToken: "oauth-alpha-old",
307
- source: "oauth",
121
+ const exchangeMock = vi.fn().mockResolvedValue(
122
+ options.exchangeResult ?? {
123
+ type: "success",
124
+ refresh: "oauth-refresh-fresh",
125
+ access: "oauth-access-fresh",
126
+ expires: Date.now() + 3_600_000,
127
+ email: "alice@example.com",
308
128
  },
309
- {
310
- id: "oauth-active-b",
311
- email: "beta@example.com",
312
- refreshToken: "oauth-beta",
313
- source: "oauth",
314
- },
315
- ],
316
- { activeIndex: 1 },
317
129
  );
318
130
 
319
- const { manager } = await loadManager({ initialStorage });
320
-
321
- manager.addAccount("oauth-alpha-new", "oauth-alpha-access-new", Date.now() + 7_200_000, "alpha@example.com");
131
+ vi.doMock("./storage.js", () => ({
132
+ createDefaultStats,
133
+ loadAccounts: storage.loadAccountsMock,
134
+ saveAccounts: storage.saveAccountsMock,
135
+ clearAccounts: vi.fn().mockResolvedValue(undefined),
136
+ }));
322
137
 
323
- expect(manager.getAccountsSnapshot()).toHaveLength(2);
324
- expect(manager.getCurrentIndex()).toBe(1);
325
- });
138
+ vi.doMock("./cc-credentials.js", () => ({
139
+ readCCCredentials: () => options.ccCredentials ?? [],
140
+ }));
326
141
 
327
- it("deduplicates CC accounts by source and label across rotation cycles", async () => {
328
- const initialStorage = makeAccountsData([
329
- {
330
- id: "cc-1",
331
- refreshToken: "cc-refresh-old",
332
- access: "cc-access-old",
333
- source: "cc-keychain",
334
- },
335
- ]);
336
-
337
- const { manager } = await loadManager({
338
- initialStorage,
339
- ccCredentials: [
340
- makeCCCredential({
341
- refreshToken: "cc-refresh-new",
342
- accessToken: "cc-access-new",
343
- source: "cc-keychain",
344
- label: "Claude Code-credentials:alice@example.com",
345
- }),
346
- ],
142
+ vi.doMock("./config.js", async (importOriginal) => {
143
+ const actual = await importOriginal<typeof ConfigModule>();
144
+
145
+ return {
146
+ ...actual,
147
+ DEFAULT_CONFIG,
148
+ loadConfig: vi.fn(() => ({
149
+ ...DEFAULT_CONFIG,
150
+ signature_emulation: {
151
+ ...DEFAULT_CONFIG.signature_emulation,
152
+ fetch_claude_code_version_on_startup: false,
153
+ },
154
+ idle_refresh: {
155
+ ...DEFAULT_CONFIG.idle_refresh,
156
+ enabled: false,
157
+ },
158
+ cc_credential_reuse: {
159
+ ...DEFAULT_CONFIG.cc_credential_reuse,
160
+ },
161
+ ...(options.config ?? {}),
162
+ })),
163
+ };
347
164
  });
348
165
 
349
- const snapshot = manager.getAccountsSnapshot();
350
- expect(snapshot).toHaveLength(1);
351
- expect(snapshot[0]).toMatchObject({
352
- id: "cc-1",
353
- refreshToken: "cc-refresh-new",
354
- access: "cc-access-new",
355
- source: "cc-keychain",
356
- });
357
- });
358
-
359
- it("keeps OAuth and CC accounts separate even when they share an email", async () => {
360
- const initialStorage = makeAccountsData([
361
- {
362
- id: "oauth-shared-email",
363
- email: "shared@example.com",
364
- refreshToken: "oauth-shared-refresh",
365
- source: "oauth",
366
- },
367
- {
368
- id: "cc-shared-email",
369
- refreshToken: "cc-refresh-old",
370
- access: "cc-access-old",
371
- source: "cc-keychain",
372
- },
373
- ]);
374
-
375
- const { manager } = await loadManager({
376
- initialStorage,
377
- ccCredentials: [
378
- makeCCCredential({
379
- refreshToken: "cc-refresh-new",
380
- accessToken: "cc-access-new",
381
- source: "cc-keychain",
382
- label: "Claude Code-credentials:shared@example.com",
166
+ vi.doMock("./oauth.js", () => ({
167
+ authorize: authorizeMock,
168
+ exchange: exchangeMock,
169
+ }));
170
+
171
+ vi.doMock("./commands/prompts.js", () => ({
172
+ promptAccountMenu: vi.fn().mockResolvedValue("add"),
173
+ promptManageAccounts: vi.fn().mockResolvedValue(undefined),
174
+ }));
175
+
176
+ vi.doMock("./bun-fetch.js", () => ({
177
+ createBunFetch: () => ({
178
+ fetch: vi.fn(),
383
179
  }),
384
- ],
180
+ }));
181
+
182
+ const { AnthropicAuthPlugin } = await import("./index.js");
183
+
184
+ return {
185
+ plugin: await AnthropicAuthPlugin({ client: makeClient() }),
186
+ storage,
187
+ };
188
+ }
189
+
190
+ function lastSavedStorage(storage: ReturnType<typeof createInMemoryStorage>): AccountStorage {
191
+ const calls = (storage.saveAccountsMock as unknown as Mock).mock.calls as AccountStorage[][];
192
+ const saved = calls[calls.length - 1]?.[0] as AccountStorage | undefined;
193
+ expect(saved).toBeDefined();
194
+ return saved!;
195
+ }
196
+
197
+ describe("AccountManager identity-based dedup RED", () => {
198
+ beforeEach(() => {
199
+ vi.useFakeTimers();
200
+ vi.setSystemTime(new Date("2026-04-10T12:00:00Z"));
385
201
  });
386
202
 
387
- const snapshot = manager.getAccountsSnapshot();
388
- expect(snapshot).toHaveLength(2);
389
- expect(snapshot.filter((account) => account.source === "oauth")).toHaveLength(1);
390
- expect(snapshot.filter((account) => account.source === "cc-keychain")).toHaveLength(1);
391
- });
392
-
393
- it("Flow A: CC auto-detect re-auth updates the existing account without creating a duplicate", async () => {
394
- const { plugin, storage } = await loadPlugin({
395
- initialStorage: makeAccountsData([
396
- {
397
- id: "cc-flow-a",
398
- refreshToken: "cc-refresh-stale",
399
- access: "cc-access-stale",
400
- source: "cc-keychain",
401
- label: "Claude Code-credentials:alice@example.com",
402
- },
403
- ]),
404
- ccCredentials: [
405
- makeCCCredential({
406
- refreshToken: "cc-refresh-rotated",
407
- accessToken: "cc-access-rotated",
408
- source: "cc-keychain",
409
- label: "Claude Code-credentials:alice@example.com",
410
- }),
411
- ],
203
+ afterEach(() => {
204
+ vi.useRealTimers();
205
+ vi.clearAllMocks();
206
+ vi.resetModules();
412
207
  });
413
208
 
414
- const method = plugin.auth.methods[0];
415
- expect(method).toBeDefined();
416
- if (!method) {
417
- throw new Error("Expected Claude Code auth method");
418
- }
419
- expect(method.authorize).toBeTypeOf("function");
420
- if (!method.authorize) {
421
- throw new Error("Expected Claude Code authorize handler");
422
- }
423
-
424
- const credentials = await method.authorize();
425
-
426
- expect(credentials).toMatchObject({
427
- type: "success",
428
- refresh: "cc-refresh-rotated",
429
- access: "cc-access-rotated",
209
+ it("updates an OAuth account by email instead of creating a duplicate on refresh rotation", async () => {
210
+ const initialStorage = makeAccountsData([
211
+ {
212
+ id: "oauth-1",
213
+ email: "alice@example.com",
214
+ refreshToken: "oauth-refresh-old",
215
+ access: "oauth-access-old",
216
+ source: "oauth",
217
+ },
218
+ ]);
219
+
220
+ const { manager } = await loadManager({ initialStorage });
221
+
222
+ manager.addAccount("oauth-refresh-new", "oauth-access-new", Date.now() + 7_200_000, "alice@example.com");
223
+
224
+ const snapshot = manager.getAccountsSnapshot();
225
+ expect(snapshot).toHaveLength(1);
226
+ expect(snapshot[0]).toMatchObject({
227
+ id: "oauth-1",
228
+ refreshToken: "oauth-refresh-new",
229
+ access: "oauth-access-new",
230
+ email: "alice@example.com",
231
+ });
430
232
  });
431
233
 
432
- const saved = lastSavedStorage(storage);
433
- expect(saved.accounts).toHaveLength(1);
434
- expect(saved.accounts[0]).toMatchObject({
435
- id: "cc-flow-a",
436
- refreshToken: "cc-refresh-rotated",
437
- access: "cc-access-rotated",
438
- source: "cc-keychain",
439
- label: "Claude Code-credentials:alice@example.com",
440
- identity: {
441
- kind: "cc",
442
- source: "cc-keychain",
443
- label: "Claude Code-credentials:alice@example.com",
444
- },
234
+ it("matches duplicates by identity rather than refresh token for OAuth accounts", async () => {
235
+ const initialStorage = makeAccountsData([
236
+ {
237
+ id: "oauth-identity",
238
+ email: "identity@example.com",
239
+ refreshToken: "oauth-refresh-a",
240
+ access: "oauth-access-a",
241
+ },
242
+ ]);
243
+
244
+ const { manager } = await loadManager({ initialStorage });
245
+
246
+ const updated = manager.addAccount(
247
+ "oauth-refresh-b",
248
+ "oauth-access-b",
249
+ Date.now() + 7_200_000,
250
+ "identity@example.com",
251
+ );
252
+
253
+ expect(updated?.id).toBe("oauth-identity");
254
+ expect(manager.getAccountsSnapshot()).toHaveLength(1);
445
255
  });
446
- });
447
-
448
- it("Flow B: OAuth re-auth updates the existing account for the same email without creating a duplicate", async () => {
449
- const { plugin, storage } = await loadPlugin({
450
- initialStorage: makeAccountsData([
451
- {
452
- id: "oauth-flow-b",
453
- email: "alice@example.com",
454
- refreshToken: "oauth-refresh-stale",
455
- access: "oauth-access-stale",
456
- source: "oauth",
457
- },
458
- ]),
459
- exchangeResult: {
460
- type: "success",
461
- refresh: "oauth-refresh-rotated",
462
- access: "oauth-access-rotated",
463
- expires: Date.now() + 7_200_000,
464
- email: "alice@example.com",
465
- },
256
+
257
+ it("preserves account metadata when an OAuth identity is updated", async () => {
258
+ const initialStorage = makeAccountsData([
259
+ {
260
+ id: "oauth-meta",
261
+ email: "meta@example.com",
262
+ refreshToken: "oauth-meta-old",
263
+ access: "oauth-meta-access-old",
264
+ addedAt: 1_111,
265
+ lastUsed: 2_222,
266
+ token_updated_at: 3_333,
267
+ lastSwitchReason: "sticky",
268
+ source: "oauth",
269
+ stats: {
270
+ requests: 9,
271
+ inputTokens: 90,
272
+ outputTokens: 45,
273
+ cacheReadTokens: 0,
274
+ cacheWriteTokens: 0,
275
+ lastReset: 4_444,
276
+ },
277
+ },
278
+ ]);
279
+
280
+ const { manager } = await loadManager({ initialStorage });
281
+
282
+ manager.addAccount("oauth-meta-new", "oauth-meta-access-new", Date.now() + 9_000_000, "meta@example.com");
283
+
284
+ const snapshot = manager.getAccountsSnapshot();
285
+ expect(snapshot).toHaveLength(1);
286
+ expect(snapshot[0]).toMatchObject({
287
+ id: "oauth-meta",
288
+ addedAt: 1_111,
289
+ lastUsed: 2_222,
290
+ lastSwitchReason: "sticky",
291
+ source: "oauth",
292
+ stats: expect.objectContaining({
293
+ requests: 9,
294
+ inputTokens: 90,
295
+ outputTokens: 45,
296
+ }),
297
+ });
466
298
  });
467
299
 
468
- const method = plugin.auth.methods[1];
469
- expect(method).toBeDefined();
470
- if (!method) {
471
- throw new Error("Expected OAuth auth method");
472
- }
473
- expect(method.authorize).toBeTypeOf("function");
474
- if (!method.authorize) {
475
- throw new Error("Expected OAuth authorize handler");
476
- }
477
-
478
- const authResult = await method.authorize();
479
- expect(authResult.callback).toBeTypeOf("function");
480
- if (!authResult.callback) {
481
- throw new Error("Expected OAuth callback");
482
- }
483
-
484
- const credentials = await authResult.callback("oauth-code#test-state");
485
-
486
- expect(credentials).toMatchObject({
487
- type: "success",
488
- refresh: "oauth-refresh-rotated",
489
- access: "oauth-access-rotated",
490
- email: "alice@example.com",
300
+ it("preserves the active index when dedup updates an existing OAuth identity", async () => {
301
+ const initialStorage = makeAccountsData(
302
+ [
303
+ {
304
+ id: "oauth-active-a",
305
+ email: "alpha@example.com",
306
+ refreshToken: "oauth-alpha-old",
307
+ source: "oauth",
308
+ },
309
+ {
310
+ id: "oauth-active-b",
311
+ email: "beta@example.com",
312
+ refreshToken: "oauth-beta",
313
+ source: "oauth",
314
+ },
315
+ ],
316
+ { activeIndex: 1 },
317
+ );
318
+
319
+ const { manager } = await loadManager({ initialStorage });
320
+
321
+ manager.addAccount("oauth-alpha-new", "oauth-alpha-access-new", Date.now() + 7_200_000, "alpha@example.com");
322
+
323
+ expect(manager.getAccountsSnapshot()).toHaveLength(2);
324
+ expect(manager.getCurrentIndex()).toBe(1);
491
325
  });
492
326
 
493
- const saved = lastSavedStorage(storage);
494
- expect(saved.accounts).toHaveLength(1);
495
- expect(saved.accounts[0]).toMatchObject({
496
- id: "oauth-flow-b",
497
- email: "alice@example.com",
498
- refreshToken: "oauth-refresh-rotated",
499
- access: "oauth-access-rotated",
500
- source: "oauth",
501
- identity: {
502
- kind: "oauth",
503
- email: "alice@example.com",
504
- },
327
+ it("deduplicates CC accounts by source and label across rotation cycles", async () => {
328
+ const initialStorage = makeAccountsData([
329
+ {
330
+ id: "cc-1",
331
+ refreshToken: "cc-refresh-old",
332
+ access: "cc-access-old",
333
+ source: "cc-keychain",
334
+ },
335
+ ]);
336
+
337
+ const { manager } = await loadManager({
338
+ initialStorage,
339
+ ccCredentials: [
340
+ makeCCCredential({
341
+ refreshToken: "cc-refresh-new",
342
+ accessToken: "cc-access-new",
343
+ source: "cc-keychain",
344
+ label: "Claude Code-credentials:alice@example.com",
345
+ }),
346
+ ],
347
+ });
348
+
349
+ const snapshot = manager.getAccountsSnapshot();
350
+ expect(snapshot).toHaveLength(1);
351
+ expect(snapshot[0]).toMatchObject({
352
+ id: "cc-1",
353
+ refreshToken: "cc-refresh-new",
354
+ access: "cc-access-new",
355
+ source: "cc-keychain",
356
+ });
505
357
  });
506
- });
507
-
508
- it("enforces MAX_ACCOUNTS during CC auto-detect instead of overflowing capacity", async () => {
509
- const initialStorage = makeAccountsData(
510
- Array.from({ length: 10 }, (_, index) => ({
511
- id: `oauth-${index + 1}`,
512
- email: `user${index + 1}@example.com`,
513
- refreshToken: `oauth-refresh-${index + 1}`,
514
- source: "oauth" as const,
515
- })),
516
- );
517
358
 
518
- const { manager } = await loadManager({
519
- initialStorage,
520
- ccCredentials: [
521
- makeCCCredential({
522
- refreshToken: "cc-refresh-overflow",
523
- source: "cc-file",
524
- label: "/Users/test/.claude/.credentials.json",
525
- }),
526
- ],
359
+ it("keeps OAuth and CC accounts separate even when they share an email", async () => {
360
+ const initialStorage = makeAccountsData([
361
+ {
362
+ id: "oauth-shared-email",
363
+ email: "shared@example.com",
364
+ refreshToken: "oauth-shared-refresh",
365
+ source: "oauth",
366
+ },
367
+ {
368
+ id: "cc-shared-email",
369
+ refreshToken: "cc-refresh-old",
370
+ access: "cc-access-old",
371
+ source: "cc-keychain",
372
+ },
373
+ ]);
374
+
375
+ const { manager } = await loadManager({
376
+ initialStorage,
377
+ ccCredentials: [
378
+ makeCCCredential({
379
+ refreshToken: "cc-refresh-new",
380
+ accessToken: "cc-access-new",
381
+ source: "cc-keychain",
382
+ label: "Claude Code-credentials:shared@example.com",
383
+ }),
384
+ ],
385
+ });
386
+
387
+ const snapshot = manager.getAccountsSnapshot();
388
+ expect(snapshot).toHaveLength(2);
389
+ expect(snapshot.filter((account) => account.source === "oauth")).toHaveLength(1);
390
+ expect(snapshot.filter((account) => account.source === "cc-keychain")).toHaveLength(1);
527
391
  });
528
392
 
529
- expect(manager.getAccountsSnapshot()).toHaveLength(10);
530
- });
531
-
532
- it("preserves the source field when syncing a rotated account from disk", async () => {
533
- const initialStorage = makeAccountsData([
534
- {
535
- id: "cc-sync-source",
536
- refreshToken: "cc-sync-old",
537
- access: "cc-sync-access-old",
538
- source: "cc-file",
539
- },
540
- ]);
541
-
542
- const { manager, storage } = await loadManager({ initialStorage });
543
-
544
- storage.mutateDiskOnly((disk) => ({
545
- ...disk,
546
- accounts: disk.accounts.map((account) => ({
547
- ...account,
548
- refreshToken: "cc-sync-new",
549
- access: "cc-sync-access-new",
550
- token_updated_at: Date.now() + 5_000,
551
- source: "cc-file",
552
- })),
553
- }));
393
+ it("Flow A: CC auto-detect re-auth updates the existing account without creating a duplicate", async () => {
394
+ const { plugin, storage } = await loadPlugin({
395
+ initialStorage: makeAccountsData([
396
+ {
397
+ id: "cc-flow-a",
398
+ refreshToken: "cc-refresh-stale",
399
+ access: "cc-access-stale",
400
+ source: "cc-keychain",
401
+ label: "Claude Code-credentials:alice@example.com",
402
+ },
403
+ ]),
404
+ ccCredentials: [
405
+ makeCCCredential({
406
+ refreshToken: "cc-refresh-rotated",
407
+ accessToken: "cc-access-rotated",
408
+ source: "cc-keychain",
409
+ label: "Claude Code-credentials:alice@example.com",
410
+ }),
411
+ ],
412
+ });
413
+
414
+ const method = plugin.auth.methods[0];
415
+ expect(method).toBeDefined();
416
+ if (!method) {
417
+ throw new Error("Expected Claude Code auth method");
418
+ }
419
+ expect(method.authorize).toBeTypeOf("function");
420
+ if (!method.authorize) {
421
+ throw new Error("Expected Claude Code authorize handler");
422
+ }
423
+
424
+ const credentials = await method.authorize();
425
+
426
+ expect(credentials).toMatchObject({
427
+ type: "success",
428
+ refresh: "cc-refresh-rotated",
429
+ access: "cc-access-rotated",
430
+ });
431
+
432
+ const saved = lastSavedStorage(storage);
433
+ expect(saved.accounts).toHaveLength(1);
434
+ expect(saved.accounts[0]).toMatchObject({
435
+ id: "cc-flow-a",
436
+ refreshToken: "cc-refresh-rotated",
437
+ access: "cc-access-rotated",
438
+ source: "cc-keychain",
439
+ label: "Claude Code-credentials:alice@example.com",
440
+ identity: {
441
+ kind: "cc",
442
+ source: "cc-keychain",
443
+ label: "Claude Code-credentials:alice@example.com",
444
+ },
445
+ });
446
+ });
554
447
 
555
- await manager.syncActiveIndexFromDisk();
556
-
557
- expect(manager.getAccountsSnapshot()[0]?.source).toBe("cc-file");
558
- });
559
-
560
- it("preserves in-flight object references while syncing rotated auth from disk", async () => {
561
- const initialStorage = makeAccountsData([
562
- {
563
- id: "oauth-ref-preserve",
564
- email: "ref@example.com",
565
- refreshToken: "oauth-ref-old",
566
- access: "oauth-ref-access-old",
567
- source: "oauth",
568
- },
569
- ]);
570
-
571
- const { manager, storage } = await loadManager({ initialStorage });
572
-
573
- const currentAccount = manager.getCurrentAccount();
574
- expect(currentAccount).not.toBeNull();
575
-
576
- storage.mutateDiskOnly((disk) => ({
577
- ...disk,
578
- accounts: disk.accounts.map((account) => ({
579
- ...account,
580
- refreshToken: "oauth-ref-new",
581
- access: "oauth-ref-access-new",
582
- token_updated_at: Date.now() + 5_000,
583
- })),
584
- }));
448
+ it("Flow B: OAuth re-auth updates the existing account for the same email without creating a duplicate", async () => {
449
+ const { plugin, storage } = await loadPlugin({
450
+ initialStorage: makeAccountsData([
451
+ {
452
+ id: "oauth-flow-b",
453
+ email: "alice@example.com",
454
+ refreshToken: "oauth-refresh-stale",
455
+ access: "oauth-access-stale",
456
+ source: "oauth",
457
+ },
458
+ ]),
459
+ exchangeResult: {
460
+ type: "success",
461
+ refresh: "oauth-refresh-rotated",
462
+ access: "oauth-access-rotated",
463
+ expires: Date.now() + 7_200_000,
464
+ email: "alice@example.com",
465
+ },
466
+ });
467
+
468
+ const method = plugin.auth.methods[1];
469
+ expect(method).toBeDefined();
470
+ if (!method) {
471
+ throw new Error("Expected OAuth auth method");
472
+ }
473
+ expect(method.authorize).toBeTypeOf("function");
474
+ if (!method.authorize) {
475
+ throw new Error("Expected OAuth authorize handler");
476
+ }
477
+
478
+ const authResult = await method.authorize();
479
+ expect(authResult.callback).toBeTypeOf("function");
480
+ if (!authResult.callback) {
481
+ throw new Error("Expected OAuth callback");
482
+ }
483
+
484
+ const credentials = await authResult.callback("oauth-code#test-state");
485
+
486
+ expect(credentials).toMatchObject({
487
+ type: "success",
488
+ refresh: "oauth-refresh-rotated",
489
+ access: "oauth-access-rotated",
490
+ email: "alice@example.com",
491
+ });
492
+
493
+ const saved = lastSavedStorage(storage);
494
+ expect(saved.accounts).toHaveLength(1);
495
+ expect(saved.accounts[0]).toMatchObject({
496
+ id: "oauth-flow-b",
497
+ email: "alice@example.com",
498
+ refreshToken: "oauth-refresh-rotated",
499
+ access: "oauth-access-rotated",
500
+ source: "oauth",
501
+ identity: {
502
+ kind: "oauth",
503
+ email: "alice@example.com",
504
+ },
505
+ });
506
+ });
585
507
 
586
- await manager.syncActiveIndexFromDisk();
587
-
588
- const activeAfterSync = manager.getCurrentAccount();
589
- expect(activeAfterSync).toBe(currentAccount);
590
- expect(currentAccount?.refreshToken).toBe("oauth-ref-new");
591
- });
592
-
593
- it("unions disk-only accounts during save instead of dropping them", async () => {
594
- const initialStorage = makeAccountsData([
595
- {
596
- id: "oauth-save-primary",
597
- email: "primary@example.com",
598
- refreshToken: "oauth-save-primary",
599
- source: "oauth",
600
- },
601
- ]);
602
-
603
- const { manager, storage } = await loadManager({ initialStorage });
604
-
605
- storage.mutateDiskOnly((disk) => ({
606
- ...disk,
607
- accounts: [
608
- ...disk.accounts,
609
- makeStoredAccount({
610
- id: "oauth-disk-only",
611
- email: "disk-only@example.com",
612
- refreshToken: "oauth-disk-only",
613
- source: "oauth",
614
- addedAt: 9_999,
615
- stats: makeStats(9_999),
616
- }),
617
- ],
618
- }));
508
+ it("enforces MAX_ACCOUNTS during CC auto-detect instead of overflowing capacity", async () => {
509
+ const initialStorage = makeAccountsData(
510
+ Array.from({ length: 10 }, (_, index) => ({
511
+ id: `oauth-${index + 1}`,
512
+ email: `user${index + 1}@example.com`,
513
+ refreshToken: `oauth-refresh-${index + 1}`,
514
+ source: "oauth" as const,
515
+ })),
516
+ );
517
+
518
+ const { manager } = await loadManager({
519
+ initialStorage,
520
+ ccCredentials: [
521
+ makeCCCredential({
522
+ refreshToken: "cc-refresh-overflow",
523
+ source: "cc-file",
524
+ label: "/Users/test/.claude/.credentials.json",
525
+ }),
526
+ ],
527
+ });
528
+
529
+ expect(manager.getAccountsSnapshot()).toHaveLength(10);
530
+ });
619
531
 
620
- await manager.saveToDisk();
621
-
622
- const saved = lastSavedStorage(storage);
623
- expect(saved.accounts.map((account) => account.id).sort()).toEqual(["oauth-disk-only", "oauth-save-primary"]);
624
- });
625
-
626
- it("does not lose disk-only accounts on repeated saves", async () => {
627
- const initialStorage = makeAccountsData([
628
- {
629
- id: "oauth-repeat-primary",
630
- email: "repeat-primary@example.com",
631
- refreshToken: "oauth-repeat-primary",
632
- source: "oauth",
633
- },
634
- ]);
635
-
636
- const { manager, storage } = await loadManager({ initialStorage });
637
-
638
- storage.mutateDiskOnly((disk) => ({
639
- ...disk,
640
- accounts: [
641
- ...disk.accounts,
642
- makeStoredAccount({
643
- id: "oauth-repeat-disk-only",
644
- email: "repeat-disk-only@example.com",
645
- refreshToken: "oauth-repeat-disk-only",
646
- source: "oauth",
647
- addedAt: 8_888,
648
- stats: makeStats(8_888),
649
- }),
650
- ],
651
- }));
532
+ it("preserves the source field when syncing a rotated account from disk", async () => {
533
+ const initialStorage = makeAccountsData([
534
+ {
535
+ id: "cc-sync-source",
536
+ refreshToken: "cc-sync-old",
537
+ access: "cc-sync-access-old",
538
+ source: "cc-file",
539
+ },
540
+ ]);
541
+
542
+ const { manager, storage } = await loadManager({ initialStorage });
543
+
544
+ storage.mutateDiskOnly((disk) => ({
545
+ ...disk,
546
+ accounts: disk.accounts.map((account) => ({
547
+ ...account,
548
+ refreshToken: "cc-sync-new",
549
+ access: "cc-sync-access-new",
550
+ token_updated_at: Date.now() + 5_000,
551
+ source: "cc-file",
552
+ })),
553
+ }));
554
+
555
+ await manager.syncActiveIndexFromDisk();
556
+
557
+ expect(manager.getAccountsSnapshot()[0]?.source).toBe("cc-file");
558
+ });
652
559
 
653
- await manager.saveToDisk();
654
- await manager.saveToDisk();
655
-
656
- const saved = lastSavedStorage(storage);
657
- expect(saved.accounts.map((account) => account.id).sort()).toEqual([
658
- "oauth-repeat-disk-only",
659
- "oauth-repeat-primary",
660
- ]);
661
- });
662
-
663
- it("keeps the same active account when disk-only unions shift array positions", async () => {
664
- const initialStorage = makeAccountsData(
665
- [
666
- {
667
- id: "oauth-active-keep",
668
- email: "active@example.com",
669
- refreshToken: "oauth-active-keep",
670
- source: "oauth",
671
- },
672
- ],
673
- { activeIndex: 0 },
674
- );
560
+ it("preserves in-flight object references while syncing rotated auth from disk", async () => {
561
+ const initialStorage = makeAccountsData([
562
+ {
563
+ id: "oauth-ref-preserve",
564
+ email: "ref@example.com",
565
+ refreshToken: "oauth-ref-old",
566
+ access: "oauth-ref-access-old",
567
+ source: "oauth",
568
+ },
569
+ ]);
570
+
571
+ const { manager, storage } = await loadManager({ initialStorage });
572
+
573
+ const currentAccount = manager.getCurrentAccount();
574
+ expect(currentAccount).not.toBeNull();
575
+
576
+ storage.mutateDiskOnly((disk) => ({
577
+ ...disk,
578
+ accounts: disk.accounts.map((account) => ({
579
+ ...account,
580
+ refreshToken: "oauth-ref-new",
581
+ access: "oauth-ref-access-new",
582
+ token_updated_at: Date.now() + 5_000,
583
+ })),
584
+ }));
585
+
586
+ await manager.syncActiveIndexFromDisk();
587
+
588
+ const activeAfterSync = manager.getCurrentAccount();
589
+ expect(activeAfterSync).toBe(currentAccount);
590
+ expect(currentAccount?.refreshToken).toBe("oauth-ref-new");
591
+ });
675
592
 
676
- const { manager, storage } = await loadManager({ initialStorage });
677
-
678
- storage.mutateDiskOnly((disk) => ({
679
- ...disk,
680
- accounts: [
681
- makeStoredAccount({
682
- id: "oauth-prepended-disk-only",
683
- email: "prepended@example.com",
684
- refreshToken: "oauth-prepended-disk-only",
685
- source: "oauth",
686
- addedAt: 777,
687
- stats: makeStats(777),
688
- }),
689
- ...disk.accounts,
690
- ],
691
- }));
593
+ it("unions disk-only accounts during save instead of dropping them", async () => {
594
+ const initialStorage = makeAccountsData([
595
+ {
596
+ id: "oauth-save-primary",
597
+ email: "primary@example.com",
598
+ refreshToken: "oauth-save-primary",
599
+ source: "oauth",
600
+ },
601
+ ]);
602
+
603
+ const { manager, storage } = await loadManager({ initialStorage });
604
+
605
+ storage.mutateDiskOnly((disk) => ({
606
+ ...disk,
607
+ accounts: [
608
+ ...disk.accounts,
609
+ makeStoredAccount({
610
+ id: "oauth-disk-only",
611
+ email: "disk-only@example.com",
612
+ refreshToken: "oauth-disk-only",
613
+ source: "oauth",
614
+ addedAt: 9_999,
615
+ stats: makeStats(9_999),
616
+ }),
617
+ ],
618
+ }));
619
+
620
+ await manager.saveToDisk();
621
+
622
+ const saved = lastSavedStorage(storage);
623
+ expect(saved.accounts.map((account) => account.id).sort()).toEqual(["oauth-disk-only", "oauth-save-primary"]);
624
+ });
692
625
 
693
- await manager.saveToDisk();
626
+ it("does not lose disk-only accounts on repeated saves", async () => {
627
+ const initialStorage = makeAccountsData([
628
+ {
629
+ id: "oauth-repeat-primary",
630
+ email: "repeat-primary@example.com",
631
+ refreshToken: "oauth-repeat-primary",
632
+ source: "oauth",
633
+ },
634
+ ]);
635
+
636
+ const { manager, storage } = await loadManager({ initialStorage });
637
+
638
+ storage.mutateDiskOnly((disk) => ({
639
+ ...disk,
640
+ accounts: [
641
+ ...disk.accounts,
642
+ makeStoredAccount({
643
+ id: "oauth-repeat-disk-only",
644
+ email: "repeat-disk-only@example.com",
645
+ refreshToken: "oauth-repeat-disk-only",
646
+ source: "oauth",
647
+ addedAt: 8_888,
648
+ stats: makeStats(8_888),
649
+ }),
650
+ ],
651
+ }));
652
+
653
+ await manager.saveToDisk();
654
+ await manager.saveToDisk();
655
+
656
+ const saved = lastSavedStorage(storage);
657
+ expect(saved.accounts.map((account) => account.id).sort()).toEqual([
658
+ "oauth-repeat-disk-only",
659
+ "oauth-repeat-primary",
660
+ ]);
661
+ });
694
662
 
695
- const saved = lastSavedStorage(storage);
696
- expect(saved.accounts[saved.activeIndex]?.id).toBe("oauth-active-keep");
697
- });
663
+ it("keeps the same active account when disk-only unions shift array positions", async () => {
664
+ const initialStorage = makeAccountsData(
665
+ [
666
+ {
667
+ id: "oauth-active-keep",
668
+ email: "active@example.com",
669
+ refreshToken: "oauth-active-keep",
670
+ source: "oauth",
671
+ },
672
+ ],
673
+ { activeIndex: 0 },
674
+ );
675
+
676
+ const { manager, storage } = await loadManager({ initialStorage });
677
+
678
+ storage.mutateDiskOnly((disk) => ({
679
+ ...disk,
680
+ accounts: [
681
+ makeStoredAccount({
682
+ id: "oauth-prepended-disk-only",
683
+ email: "prepended@example.com",
684
+ refreshToken: "oauth-prepended-disk-only",
685
+ source: "oauth",
686
+ addedAt: 777,
687
+ stats: makeStats(777),
688
+ }),
689
+ ...disk.accounts,
690
+ ],
691
+ }));
692
+
693
+ await manager.saveToDisk();
694
+
695
+ const saved = lastSavedStorage(storage);
696
+ expect(saved.accounts[saved.activeIndex]?.id).toBe("oauth-active-keep");
697
+ });
698
698
  });