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