@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
@@ -4,30 +4,30 @@ import { createDeferred, nextTick } from "./__tests__/helpers/deferred.js";
4
4
  import { createRefreshHelpers } from "./refresh-helpers.js";
5
5
 
6
6
  vi.mock("node:child_process", () => ({
7
- execSync: vi.fn(),
7
+ execSync: vi.fn(),
8
8
  }));
9
9
 
10
10
  vi.mock("./cc-credentials.js", () => ({
11
- readCCCredentials: vi.fn(),
12
- readCCCredentialsFromFile: vi.fn(),
11
+ readCCCredentials: vi.fn(),
12
+ readCCCredentialsFromFile: vi.fn(),
13
13
  }));
14
14
 
15
15
  vi.mock("./oauth.js", () => ({
16
- refreshToken: vi.fn(),
16
+ refreshToken: vi.fn(),
17
17
  }));
18
18
 
19
19
  vi.mock("./refresh-lock.js", () => ({
20
- acquireRefreshLock: vi.fn().mockResolvedValue({
21
- acquired: true,
22
- lockPath: null,
23
- owner: null,
24
- lockInode: null,
25
- }),
26
- releaseRefreshLock: vi.fn().mockResolvedValue(undefined),
20
+ acquireRefreshLock: vi.fn().mockResolvedValue({
21
+ acquired: true,
22
+ lockPath: null,
23
+ owner: null,
24
+ lockInode: null,
25
+ }),
26
+ releaseRefreshLock: vi.fn().mockResolvedValue(undefined),
27
27
  }));
28
28
 
29
29
  vi.mock("./storage.js", () => ({
30
- loadAccounts: vi.fn().mockResolvedValue(null),
30
+ loadAccounts: vi.fn().mockResolvedValue(null),
31
31
  }));
32
32
 
33
33
  import type { ManagedAccount } from "./accounts.js";
@@ -42,276 +42,276 @@ const mockReadCCCredentialsFromFile = readCCCredentialsFromFile as Mock;
42
42
  const mockRefreshToken = refreshToken as Mock;
43
43
 
44
44
  function makeAccount(overrides: Partial<ManagedAccount> = {}): ManagedAccount {
45
- return {
46
- id: "acct-1",
47
- index: 0,
48
- email: "user@example.com",
49
- refreshToken: "refresh-old",
50
- access: "access-old",
51
- expires: Date.now() - 60_000,
52
- tokenUpdatedAt: 0,
53
- addedAt: Date.now() - 120_000,
54
- lastUsed: 0,
55
- enabled: true,
56
- rateLimitResetTimes: {},
57
- consecutiveFailures: 0,
58
- lastFailureTime: null,
59
- stats: {
60
- requests: 0,
61
- inputTokens: 0,
62
- outputTokens: 0,
63
- cacheReadTokens: 0,
64
- cacheWriteTokens: 0,
65
- lastReset: 0,
66
- },
67
- source: "oauth",
68
- ...overrides,
69
- };
45
+ return {
46
+ id: "acct-1",
47
+ index: 0,
48
+ email: "user@example.com",
49
+ refreshToken: "refresh-old",
50
+ access: "access-old",
51
+ expires: Date.now() - 60_000,
52
+ tokenUpdatedAt: 0,
53
+ addedAt: Date.now() - 120_000,
54
+ lastUsed: 0,
55
+ enabled: true,
56
+ rateLimitResetTimes: {},
57
+ consecutiveFailures: 0,
58
+ lastFailureTime: null,
59
+ stats: {
60
+ requests: 0,
61
+ inputTokens: 0,
62
+ outputTokens: 0,
63
+ cacheReadTokens: 0,
64
+ cacheWriteTokens: 0,
65
+ lastReset: 0,
66
+ },
67
+ source: "oauth",
68
+ ...overrides,
69
+ };
70
70
  }
71
71
 
72
72
  function makeCredential(overrides: Partial<CCCredential> = {}): CCCredential {
73
- return {
74
- accessToken: "access-fresh",
75
- refreshToken: "refresh-fresh",
76
- expiresAt: Date.now() + 3600_000,
77
- subscriptionType: "max",
78
- source: "cc-file",
79
- label: "/mock-home/.claude/.credentials.json",
80
- ...overrides,
81
- };
73
+ return {
74
+ accessToken: "access-fresh",
75
+ refreshToken: "refresh-fresh",
76
+ expiresAt: Date.now() + 3600_000,
77
+ subscriptionType: "max",
78
+ source: "cc-file",
79
+ label: "/mock-home/.claude/.credentials.json",
80
+ ...overrides,
81
+ };
82
82
  }
83
83
 
84
84
  describe("refreshAccountToken", () => {
85
- beforeEach(() => {
86
- vi.resetAllMocks();
87
- vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-25T12:00:00Z").getTime());
88
- });
89
-
90
- it("re-reads keychain-backed CC accounts without calling OAuth HTTP refresh", async () => {
91
- const account = makeAccount({
92
- source: "cc-keychain",
93
- refreshToken: "refresh-old",
94
- access: "access-old",
95
- expires: Date.now() - 1_000,
85
+ beforeEach(() => {
86
+ vi.resetAllMocks();
87
+ vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-25T12:00:00Z").getTime());
96
88
  });
97
89
 
98
- mockReadCCCredentials.mockReturnValue([
99
- makeCredential({
100
- source: "cc-keychain",
101
- label: "Claude Code-credentials",
102
- refreshToken: "refresh-old",
103
- }),
104
- ]);
105
-
106
- const client = {
107
- auth: {
108
- set: vi.fn().mockResolvedValue(undefined),
109
- },
110
- };
111
-
112
- await expect(refreshAccountToken(account, client)).resolves.toBe("access-fresh");
113
-
114
- expect(mockReadCCCredentials).toHaveBeenCalledTimes(1);
115
- expect(mockReadCCCredentialsFromFile).not.toHaveBeenCalled();
116
- expect(mockRefreshToken).not.toHaveBeenCalled();
117
- expect(account.access).toBe("access-fresh");
118
- expect(account.refreshToken).toBe("refresh-old");
119
- expect(account.expires).toBe(Date.now() + 3600_000);
120
- expect(client.auth.set).toHaveBeenCalledWith({
121
- path: { id: "anthropic" },
122
- body: {
123
- type: "oauth",
124
- refresh: "refresh-old",
125
- access: "access-fresh",
126
- expires: Date.now() + 3600_000,
127
- },
128
- });
129
- });
130
-
131
- it("invokes the claude CLI for expired file-backed CC accounts before re-reading", async () => {
132
- const account = makeAccount({
133
- source: "cc-file",
134
- refreshToken: "refresh-old",
135
- access: "access-old",
136
- expires: Date.now() - 1_000,
90
+ it("re-reads keychain-backed CC accounts without calling OAuth HTTP refresh", async () => {
91
+ const account = makeAccount({
92
+ source: "cc-keychain",
93
+ refreshToken: "refresh-old",
94
+ access: "access-old",
95
+ expires: Date.now() - 1_000,
96
+ });
97
+
98
+ mockReadCCCredentials.mockReturnValue([
99
+ makeCredential({
100
+ source: "cc-keychain",
101
+ label: "Claude Code-credentials",
102
+ refreshToken: "refresh-old",
103
+ }),
104
+ ]);
105
+
106
+ const client = {
107
+ auth: {
108
+ set: vi.fn().mockResolvedValue(undefined),
109
+ },
110
+ };
111
+
112
+ await expect(refreshAccountToken(account, client)).resolves.toBe("access-fresh");
113
+
114
+ expect(mockReadCCCredentials).toHaveBeenCalledTimes(1);
115
+ expect(mockReadCCCredentialsFromFile).not.toHaveBeenCalled();
116
+ expect(mockRefreshToken).not.toHaveBeenCalled();
117
+ expect(account.access).toBe("access-fresh");
118
+ expect(account.refreshToken).toBe("refresh-old");
119
+ expect(account.expires).toBe(Date.now() + 3600_000);
120
+ expect(client.auth.set).toHaveBeenCalledWith({
121
+ path: { id: "anthropic" },
122
+ body: {
123
+ type: "oauth",
124
+ refresh: "refresh-old",
125
+ access: "access-fresh",
126
+ expires: Date.now() + 3600_000,
127
+ },
128
+ });
137
129
  });
138
130
 
139
- mockReadCCCredentialsFromFile
140
- .mockReturnValueOnce(
141
- makeCredential({
142
- source: "cc-file",
143
- refreshToken: "refresh-old",
144
- accessToken: "access-stale",
145
- expiresAt: Date.now() - 5_000,
146
- }),
147
- )
148
- .mockReturnValueOnce(
149
- makeCredential({
150
- source: "cc-file",
151
- refreshToken: "refresh-new",
152
- accessToken: "access-new",
153
- expiresAt: Date.now() + 7_200_000,
154
- }),
155
- );
156
-
157
- mockExecSync.mockImplementation((command: string) => {
158
- if (command === "which claude") return "/usr/local/bin/claude\n";
159
- if (command === "/usr/local/bin/claude -p . --model haiku") return "";
160
- throw new Error(`unexpected command: ${command}`);
131
+ it("invokes the claude CLI for expired file-backed CC accounts before re-reading", async () => {
132
+ const account = makeAccount({
133
+ source: "cc-file",
134
+ refreshToken: "refresh-old",
135
+ access: "access-old",
136
+ expires: Date.now() - 1_000,
137
+ });
138
+
139
+ mockReadCCCredentialsFromFile
140
+ .mockReturnValueOnce(
141
+ makeCredential({
142
+ source: "cc-file",
143
+ refreshToken: "refresh-old",
144
+ accessToken: "access-stale",
145
+ expiresAt: Date.now() - 5_000,
146
+ }),
147
+ )
148
+ .mockReturnValueOnce(
149
+ makeCredential({
150
+ source: "cc-file",
151
+ refreshToken: "refresh-new",
152
+ accessToken: "access-new",
153
+ expiresAt: Date.now() + 7_200_000,
154
+ }),
155
+ );
156
+
157
+ mockExecSync.mockImplementation((command: string) => {
158
+ if (command === "which claude") return "/usr/local/bin/claude\n";
159
+ if (command === "/usr/local/bin/claude -p . --model haiku") return "";
160
+ throw new Error(`unexpected command: ${command}`);
161
+ });
162
+
163
+ await expect(refreshAccountToken(account, {})).resolves.toBe("access-new");
164
+
165
+ expect(mockReadCCCredentialsFromFile).toHaveBeenCalledTimes(2);
166
+ expect(mockReadCCCredentials).not.toHaveBeenCalled();
167
+ expect(mockRefreshToken).not.toHaveBeenCalled();
168
+ expect(mockExecSync).toHaveBeenNthCalledWith(1, "which claude", {
169
+ encoding: "utf-8",
170
+ timeout: 5000,
171
+ });
172
+ expect(mockExecSync).toHaveBeenNthCalledWith(2, "/usr/local/bin/claude -p . --model haiku", {
173
+ encoding: "utf-8",
174
+ timeout: 60000,
175
+ });
176
+ expect(account.access).toBe("access-new");
177
+ expect(account.refreshToken).toBe("refresh-new");
161
178
  });
162
179
 
163
- await expect(refreshAccountToken(account, {})).resolves.toBe("access-new");
164
-
165
- expect(mockReadCCCredentialsFromFile).toHaveBeenCalledTimes(2);
166
- expect(mockReadCCCredentials).not.toHaveBeenCalled();
167
- expect(mockRefreshToken).not.toHaveBeenCalled();
168
- expect(mockExecSync).toHaveBeenNthCalledWith(1, "which claude", {
169
- encoding: "utf-8",
170
- timeout: 5000,
171
- });
172
- expect(mockExecSync).toHaveBeenNthCalledWith(2, "/usr/local/bin/claude -p . --model haiku", {
173
- encoding: "utf-8",
174
- timeout: 60000,
175
- });
176
- expect(account.access).toBe("access-new");
177
- expect(account.refreshToken).toBe("refresh-new");
178
- });
179
-
180
- it("keeps OAuth-backed accounts on the existing HTTP refresh path", async () => {
181
- const account = makeAccount({
182
- source: "oauth",
183
- refreshToken: "oauth-refresh",
184
- access: "oauth-access",
185
- expires: Date.now() - 1_000,
180
+ it("keeps OAuth-backed accounts on the existing HTTP refresh path", async () => {
181
+ const account = makeAccount({
182
+ source: "oauth",
183
+ refreshToken: "oauth-refresh",
184
+ access: "oauth-access",
185
+ expires: Date.now() - 1_000,
186
+ });
187
+
188
+ mockRefreshToken.mockResolvedValue({
189
+ access_token: "oauth-access-new",
190
+ expires_in: 1800,
191
+ refresh_token: "oauth-refresh-new",
192
+ });
193
+
194
+ await expect(refreshAccountToken(account, {})).resolves.toBe("oauth-access-new");
195
+
196
+ expect(mockRefreshToken).toHaveBeenCalledWith("oauth-refresh", {
197
+ signal: expect.any(AbortSignal),
198
+ });
199
+ expect(mockReadCCCredentials).not.toHaveBeenCalled();
200
+ expect(mockReadCCCredentialsFromFile).not.toHaveBeenCalled();
201
+ expect(account.access).toBe("oauth-access-new");
202
+ expect(account.refreshToken).toBe("oauth-refresh-new");
186
203
  });
187
204
 
188
- mockRefreshToken.mockResolvedValue({
189
- access_token: "oauth-access-new",
190
- expires_in: 1800,
191
- refresh_token: "oauth-refresh-new",
205
+ it("fails cleanly when the claude binary is unavailable for CC refresh", async () => {
206
+ const account = makeAccount({
207
+ source: "cc-file",
208
+ refreshToken: "refresh-old",
209
+ expires: Date.now() - 1_000,
210
+ });
211
+
212
+ mockReadCCCredentialsFromFile.mockReturnValue(
213
+ makeCredential({
214
+ source: "cc-file",
215
+ refreshToken: "refresh-old",
216
+ accessToken: "access-stale",
217
+ expiresAt: Date.now() - 5_000,
218
+ }),
219
+ );
220
+ mockExecSync.mockImplementation((command: string) => {
221
+ if (command === "which claude") {
222
+ throw new Error("not found");
223
+ }
224
+ throw new Error(`unexpected command: ${command}`);
225
+ });
226
+
227
+ await expect(refreshAccountToken(account, {})).rejects.toThrow("CC credential refresh failed");
228
+
229
+ expect(mockRefreshToken).not.toHaveBeenCalled();
230
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
231
+ expect(mockExecSync).toHaveBeenCalledWith("which claude", {
232
+ encoding: "utf-8",
233
+ timeout: 5000,
234
+ });
192
235
  });
193
236
 
194
- await expect(refreshAccountToken(account, {})).resolves.toBe("oauth-access-new");
195
-
196
- expect(mockRefreshToken).toHaveBeenCalledWith("oauth-refresh", {
197
- signal: expect.any(AbortSignal),
198
- });
199
- expect(mockReadCCCredentials).not.toHaveBeenCalled();
200
- expect(mockReadCCCredentialsFromFile).not.toHaveBeenCalled();
201
- expect(account.access).toBe("oauth-access-new");
202
- expect(account.refreshToken).toBe("oauth-refresh-new");
203
- });
204
-
205
- it("fails cleanly when the claude binary is unavailable for CC refresh", async () => {
206
- const account = makeAccount({
207
- source: "cc-file",
208
- refreshToken: "refresh-old",
209
- expires: Date.now() - 1_000,
237
+ it("reuses the first foreground retry after an idle refresh rejection", async () => {
238
+ const idleRefresh = createDeferred<{
239
+ access_token: string;
240
+ expires_in: number;
241
+ refresh_token?: string;
242
+ }>();
243
+ const foregroundRefresh = createDeferred<{
244
+ access_token: string;
245
+ expires_in: number;
246
+ refresh_token?: string;
247
+ }>();
248
+ const idleFailure = new Error("idle refresh failed");
249
+ const foregroundFailure = new Error("foreground refresh failed");
250
+ mockRefreshToken
251
+ .mockImplementationOnce(() => idleRefresh.promise)
252
+ .mockImplementationOnce(() => foregroundRefresh.promise)
253
+ .mockRejectedValueOnce(new Error("duplicate foreground refresh"));
254
+ const accountManager = {
255
+ saveToDisk: vi.fn().mockResolvedValue(undefined),
256
+ requestSaveToDisk: vi.fn(),
257
+ getEnabledAccounts: vi.fn().mockReturnValue([]),
258
+ };
259
+ const account = makeAccount();
260
+ const helpers = createRefreshHelpers({
261
+ client: {},
262
+ config: {
263
+ idle_refresh: {
264
+ enabled: true,
265
+ window_minutes: 10,
266
+ min_interval_minutes: 1,
267
+ },
268
+ } as never,
269
+ getAccountManager: () => accountManager as never,
270
+ debugLog: vi.fn(),
271
+ });
272
+ const idleCall = helpers.refreshAccountTokenSingleFlight(account, "idle").catch((error) => error);
273
+ await nextTick();
274
+ await nextTick();
275
+
276
+ const foregroundCallA = helpers.refreshAccountTokenSingleFlight(account, "foreground").catch((error) => error);
277
+ const foregroundCallB = helpers.refreshAccountTokenSingleFlight(account, "foreground").catch((error) => error);
278
+ await nextTick();
279
+
280
+ idleRefresh.reject(idleFailure);
281
+ await expect(idleCall).resolves.toBe(idleFailure);
282
+ await nextTick();
283
+
284
+ expect(mockRefreshToken).toHaveBeenCalledTimes(2);
285
+
286
+ foregroundRefresh.reject(foregroundFailure);
287
+
288
+ await expect(foregroundCallA).resolves.toBe(foregroundFailure);
289
+ await expect(foregroundCallB).resolves.toBe(foregroundFailure);
210
290
  });
211
291
 
212
- mockReadCCCredentialsFromFile.mockReturnValue(
213
- makeCredential({
214
- source: "cc-file",
215
- refreshToken: "refresh-old",
216
- accessToken: "access-stale",
217
- expiresAt: Date.now() - 5_000,
218
- }),
219
- );
220
- mockExecSync.mockImplementation((command: string) => {
221
- if (command === "which claude") {
222
- throw new Error("not found");
223
- }
224
- throw new Error(`unexpected command: ${command}`);
225
- });
226
-
227
- await expect(refreshAccountToken(account, {})).rejects.toThrow("CC credential refresh failed");
228
-
229
- expect(mockRefreshToken).not.toHaveBeenCalled();
230
- expect(mockExecSync).toHaveBeenCalledTimes(1);
231
- expect(mockExecSync).toHaveBeenCalledWith("which claude", {
232
- encoding: "utf-8",
233
- timeout: 5000,
292
+ it("does not adopt older expired-fallback disk auth when only access differs", () => {
293
+ const currentTime = Date.now();
294
+ const account = makeAccount({
295
+ refreshToken: "refresh-current",
296
+ access: "access-current",
297
+ expires: currentTime - 1_000,
298
+ tokenUpdatedAt: currentTime,
299
+ });
300
+
301
+ const adopted = applyDiskAuthIfFresher(
302
+ account,
303
+ {
304
+ refreshToken: "refresh-current",
305
+ access: "access-stale",
306
+ expires: currentTime + 60_000,
307
+ tokenUpdatedAt: currentTime - 60_000,
308
+ },
309
+ { allowExpiredFallback: true },
310
+ );
311
+
312
+ expect(adopted).toBe(false);
313
+ expect(account.refreshToken).toBe("refresh-current");
314
+ expect(account.access).toBe("access-current");
315
+ expect(account.tokenUpdatedAt).toBe(currentTime);
234
316
  });
235
- });
236
-
237
- it("reuses the first foreground retry after an idle refresh rejection", async () => {
238
- const idleRefresh = createDeferred<{
239
- access_token: string;
240
- expires_in: number;
241
- refresh_token?: string;
242
- }>();
243
- const foregroundRefresh = createDeferred<{
244
- access_token: string;
245
- expires_in: number;
246
- refresh_token?: string;
247
- }>();
248
- const idleFailure = new Error("idle refresh failed");
249
- const foregroundFailure = new Error("foreground refresh failed");
250
- mockRefreshToken
251
- .mockImplementationOnce(() => idleRefresh.promise)
252
- .mockImplementationOnce(() => foregroundRefresh.promise)
253
- .mockRejectedValueOnce(new Error("duplicate foreground refresh"));
254
- const accountManager = {
255
- saveToDisk: vi.fn().mockResolvedValue(undefined),
256
- requestSaveToDisk: vi.fn(),
257
- getEnabledAccounts: vi.fn().mockReturnValue([]),
258
- };
259
- const account = makeAccount();
260
- const helpers = createRefreshHelpers({
261
- client: {},
262
- config: {
263
- idle_refresh: {
264
- enabled: true,
265
- window_minutes: 10,
266
- min_interval_minutes: 1,
267
- },
268
- } as never,
269
- getAccountManager: () => accountManager as never,
270
- debugLog: vi.fn(),
271
- });
272
- const idleCall = helpers.refreshAccountTokenSingleFlight(account, "idle").catch((error) => error);
273
- await nextTick();
274
- await nextTick();
275
-
276
- const foregroundCallA = helpers.refreshAccountTokenSingleFlight(account, "foreground").catch((error) => error);
277
- const foregroundCallB = helpers.refreshAccountTokenSingleFlight(account, "foreground").catch((error) => error);
278
- await nextTick();
279
-
280
- idleRefresh.reject(idleFailure);
281
- await expect(idleCall).resolves.toBe(idleFailure);
282
- await nextTick();
283
-
284
- expect(mockRefreshToken).toHaveBeenCalledTimes(2);
285
-
286
- foregroundRefresh.reject(foregroundFailure);
287
-
288
- await expect(foregroundCallA).resolves.toBe(foregroundFailure);
289
- await expect(foregroundCallB).resolves.toBe(foregroundFailure);
290
- });
291
-
292
- it("does not adopt older expired-fallback disk auth when only access differs", () => {
293
- const currentTime = Date.now();
294
- const account = makeAccount({
295
- refreshToken: "refresh-current",
296
- access: "access-current",
297
- expires: currentTime - 1_000,
298
- tokenUpdatedAt: currentTime,
299
- });
300
-
301
- const adopted = applyDiskAuthIfFresher(
302
- account,
303
- {
304
- refreshToken: "refresh-current",
305
- access: "access-stale",
306
- expires: currentTime + 60_000,
307
- tokenUpdatedAt: currentTime - 60_000,
308
- },
309
- { allowExpiredFallback: true },
310
- );
311
-
312
- expect(adopted).toBe(false);
313
- expect(account.refreshToken).toBe("refresh-current");
314
- expect(account.access).toBe("access-current");
315
- expect(account.tokenUpdatedAt).toBe(currentTime);
316
- });
317
317
  });