opencode-anthropic-fix 0.2.1 → 0.3.0
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 +24 -0
- package/index.mjs +155 -149
- package/lib/config.mjs +47 -25
- package/lib/mimicry/adapter-input.mjs +671 -0
- package/lib/mimicry/context-hint-threshold.mjs +127 -0
- package/lib/mimicry/headers.mjs +71 -64
- package/lib/mimicry/request-body.mjs +23 -22
- package/lib/mimicry/system-prompt.mjs +54 -23
- package/lib/mimicry/wire-compat.mjs +305 -0
- package/package.json +3 -3
- package/lib/account-state.test.mjs +0 -109
- package/lib/accounts.test.mjs +0 -912
- package/lib/adaptive-thinking-models.test.mjs +0 -30
- package/lib/backoff.test.mjs +0 -411
- package/lib/cc-credentials.test.mjs +0 -391
- package/lib/config.test.mjs +0 -764
- package/lib/context-hint-persist.mjs +0 -68
- package/lib/diagnose.test.mjs +0 -122
- package/lib/env.test.mjs +0 -25
- package/lib/haiku-call.test.mjs +0 -72
- package/lib/message-transform.test.mjs +0 -173
- package/lib/mimicry/cache.test.mjs +0 -112
- package/lib/mimicry/headers.test.mjs +0 -50
- package/lib/mimicry/models.test.mjs +0 -132
- package/lib/mimicry/request-body.test.mjs +0 -34
- package/lib/mimicry/request-helpers.test.mjs +0 -155
- package/lib/mimicry/response-stream.test.mjs +0 -127
- package/lib/mimicry/system-prompt.test.mjs +0 -73
- package/lib/oauth.test.mjs +0 -343
- package/lib/redact.test.mjs +0 -154
- package/lib/refresh-lock.test.mjs +0 -131
- package/lib/request-headers.test.mjs +0 -164
- package/lib/retry/overload-loop.test.mjs +0 -107
- package/lib/rotation.test.mjs +0 -392
- package/lib/session-metrics.test.mjs +0 -67
- package/lib/storage.test.mjs +0 -696
- package/lib/token-economy/microcompact.test.mjs +0 -28
- package/lib/token-economy/transforms.test.mjs +0 -192
- package/lib/tuning.test.mjs +0 -89
package/lib/storage.test.mjs
DELETED
|
@@ -1,696 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
deduplicateByRefreshToken,
|
|
4
|
-
ensureGitignore,
|
|
5
|
-
getStoragePath,
|
|
6
|
-
loadAccounts,
|
|
7
|
-
saveAccounts,
|
|
8
|
-
clearAccounts,
|
|
9
|
-
createDefaultStats,
|
|
10
|
-
} from "./storage.mjs";
|
|
11
|
-
import { promises as fs } from "node:fs";
|
|
12
|
-
import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { randomBytes } from "node:crypto";
|
|
14
|
-
|
|
15
|
-
const itWindows = process.platform === "win32" ? it : it.skip;
|
|
16
|
-
|
|
17
|
-
// Mock fs modules
|
|
18
|
-
vi.mock("node:fs", () => ({
|
|
19
|
-
existsSync: vi.fn(),
|
|
20
|
-
readFileSync: vi.fn(),
|
|
21
|
-
appendFileSync: vi.fn(),
|
|
22
|
-
writeFileSync: vi.fn(),
|
|
23
|
-
promises: {
|
|
24
|
-
readFile: vi.fn(),
|
|
25
|
-
writeFile: vi.fn(),
|
|
26
|
-
rename: vi.fn(),
|
|
27
|
-
chmod: vi.fn(),
|
|
28
|
-
unlink: vi.fn(),
|
|
29
|
-
mkdir: vi.fn(),
|
|
30
|
-
},
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
vi.mock("node:crypto", async (importOriginal) => {
|
|
34
|
-
const actual = await importOriginal();
|
|
35
|
-
return {
|
|
36
|
-
randomBytes: vi.fn(() => ({
|
|
37
|
-
toString: () => "abcdef123456",
|
|
38
|
-
})),
|
|
39
|
-
createHash: actual.createHash,
|
|
40
|
-
};
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// deduplicateByRefreshToken
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
describe("deduplicateByRefreshToken", () => {
|
|
48
|
-
it("returns empty array for empty input", () => {
|
|
49
|
-
expect(deduplicateByRefreshToken([])).toEqual([]);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("returns single account unchanged", () => {
|
|
53
|
-
const accounts = [
|
|
54
|
-
{
|
|
55
|
-
refreshToken: "token1",
|
|
56
|
-
addedAt: 1000,
|
|
57
|
-
lastUsed: 2000,
|
|
58
|
-
enabled: true,
|
|
59
|
-
rateLimitResetTimes: {},
|
|
60
|
-
consecutiveFailures: 0,
|
|
61
|
-
lastFailureTime: null,
|
|
62
|
-
},
|
|
63
|
-
];
|
|
64
|
-
const result = deduplicateByRefreshToken(accounts);
|
|
65
|
-
expect(result).toHaveLength(1);
|
|
66
|
-
expect(result[0].refreshToken).toBe("token1");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("keeps most recently used when duplicates exist", () => {
|
|
70
|
-
const accounts = [
|
|
71
|
-
{
|
|
72
|
-
refreshToken: "token1",
|
|
73
|
-
addedAt: 1000,
|
|
74
|
-
lastUsed: 1000,
|
|
75
|
-
enabled: true,
|
|
76
|
-
rateLimitResetTimes: {},
|
|
77
|
-
consecutiveFailures: 0,
|
|
78
|
-
lastFailureTime: null,
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
refreshToken: "token1",
|
|
82
|
-
addedAt: 2000,
|
|
83
|
-
lastUsed: 5000,
|
|
84
|
-
enabled: true,
|
|
85
|
-
rateLimitResetTimes: {},
|
|
86
|
-
consecutiveFailures: 0,
|
|
87
|
-
lastFailureTime: null,
|
|
88
|
-
},
|
|
89
|
-
];
|
|
90
|
-
const result = deduplicateByRefreshToken(accounts);
|
|
91
|
-
expect(result).toHaveLength(1);
|
|
92
|
-
expect(result[0].lastUsed).toBe(5000);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("keeps different tokens as separate accounts", () => {
|
|
96
|
-
const accounts = [
|
|
97
|
-
{
|
|
98
|
-
refreshToken: "token1",
|
|
99
|
-
addedAt: 1000,
|
|
100
|
-
lastUsed: 1000,
|
|
101
|
-
enabled: true,
|
|
102
|
-
rateLimitResetTimes: {},
|
|
103
|
-
consecutiveFailures: 0,
|
|
104
|
-
lastFailureTime: null,
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
refreshToken: "token2",
|
|
108
|
-
addedAt: 2000,
|
|
109
|
-
lastUsed: 2000,
|
|
110
|
-
enabled: true,
|
|
111
|
-
rateLimitResetTimes: {},
|
|
112
|
-
consecutiveFailures: 0,
|
|
113
|
-
lastFailureTime: null,
|
|
114
|
-
},
|
|
115
|
-
];
|
|
116
|
-
const result = deduplicateByRefreshToken(accounts);
|
|
117
|
-
expect(result).toHaveLength(2);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("skips accounts without refreshToken", () => {
|
|
121
|
-
const accounts = [
|
|
122
|
-
{
|
|
123
|
-
refreshToken: "",
|
|
124
|
-
addedAt: 1000,
|
|
125
|
-
lastUsed: 1000,
|
|
126
|
-
enabled: true,
|
|
127
|
-
rateLimitResetTimes: {},
|
|
128
|
-
consecutiveFailures: 0,
|
|
129
|
-
lastFailureTime: null,
|
|
130
|
-
},
|
|
131
|
-
];
|
|
132
|
-
const result = deduplicateByRefreshToken(accounts);
|
|
133
|
-
expect(result).toHaveLength(0);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
// ---------------------------------------------------------------------------
|
|
138
|
-
// ensureGitignore
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
|
|
141
|
-
describe("ensureGitignore", () => {
|
|
142
|
-
beforeEach(() => {
|
|
143
|
-
vi.resetAllMocks();
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("creates new .gitignore when none exists", () => {
|
|
147
|
-
existsSync.mockReturnValue(false);
|
|
148
|
-
ensureGitignore("/config/dir");
|
|
149
|
-
expect(writeFileSync).toHaveBeenCalledWith(
|
|
150
|
-
expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
|
|
151
|
-
expect.stringContaining("anthropic-accounts.json"),
|
|
152
|
-
"utf-8",
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("appends missing entries to existing .gitignore", () => {
|
|
157
|
-
existsSync.mockReturnValue(true);
|
|
158
|
-
readFileSync.mockReturnValue("some-other-file\n");
|
|
159
|
-
ensureGitignore("/config/dir");
|
|
160
|
-
expect(appendFileSync).toHaveBeenCalledWith(
|
|
161
|
-
expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
|
|
162
|
-
expect.stringContaining("anthropic-accounts.json"),
|
|
163
|
-
"utf-8",
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("does nothing when all entries already present", () => {
|
|
168
|
-
existsSync.mockReturnValue(true);
|
|
169
|
-
readFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
|
|
170
|
-
ensureGitignore("/config/dir");
|
|
171
|
-
expect(appendFileSync).not.toHaveBeenCalled();
|
|
172
|
-
expect(writeFileSync).not.toHaveBeenCalled();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("handles errors gracefully", () => {
|
|
176
|
-
existsSync.mockImplementation(() => {
|
|
177
|
-
throw new Error("permission denied");
|
|
178
|
-
});
|
|
179
|
-
// Should not throw
|
|
180
|
-
expect(() => ensureGitignore("/config/dir")).not.toThrow();
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// ---------------------------------------------------------------------------
|
|
185
|
-
// getStoragePath
|
|
186
|
-
// ---------------------------------------------------------------------------
|
|
187
|
-
|
|
188
|
-
describe("getStoragePath", () => {
|
|
189
|
-
it("returns path ending with anthropic-accounts.json", () => {
|
|
190
|
-
const path = getStoragePath();
|
|
191
|
-
expect(path.endsWith("anthropic-accounts.json")).toBe(true);
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
// loadAccounts
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
describe("loadAccounts", () => {
|
|
200
|
-
beforeEach(() => {
|
|
201
|
-
vi.resetAllMocks();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("returns null when file does not exist", async () => {
|
|
205
|
-
fs.readFile.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
206
|
-
const result = await loadAccounts();
|
|
207
|
-
expect(result).toBeNull();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("returns null for invalid JSON", async () => {
|
|
211
|
-
fs.readFile.mockResolvedValue("not json");
|
|
212
|
-
const result = await loadAccounts();
|
|
213
|
-
expect(result).toBeNull();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("returns best-effort data for newer version (forward compat)", async () => {
|
|
217
|
-
// QA fix L-warnSpy: mock console.warn to prevent test output contamination
|
|
218
|
-
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
219
|
-
fs.readFile.mockResolvedValue(JSON.stringify({ version: 99, accounts: [] }));
|
|
220
|
-
const result = await loadAccounts();
|
|
221
|
-
// H12 fix: no longer returns null; attempts best-effort load with a warning
|
|
222
|
-
expect(result).not.toBeNull();
|
|
223
|
-
expect(result.accounts).toEqual([]);
|
|
224
|
-
expect(result.version).toBe(1); // normalized to current version
|
|
225
|
-
expect(warnSpy).toHaveBeenCalledOnce();
|
|
226
|
-
warnSpy.mockRestore();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("returns null when accounts is not an array", async () => {
|
|
230
|
-
fs.readFile.mockResolvedValue(JSON.stringify({ version: 1, accounts: "not-array" }));
|
|
231
|
-
const result = await loadAccounts();
|
|
232
|
-
expect(result).toBeNull();
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("loads valid accounts", async () => {
|
|
236
|
-
fs.readFile.mockResolvedValue(
|
|
237
|
-
JSON.stringify({
|
|
238
|
-
version: 1,
|
|
239
|
-
accounts: [
|
|
240
|
-
{
|
|
241
|
-
refreshToken: "token1",
|
|
242
|
-
access: "access1",
|
|
243
|
-
expires: 9999999999,
|
|
244
|
-
addedAt: 1000,
|
|
245
|
-
lastUsed: 2000,
|
|
246
|
-
enabled: true,
|
|
247
|
-
rateLimitResetTimes: {},
|
|
248
|
-
consecutiveFailures: 0,
|
|
249
|
-
lastFailureTime: null,
|
|
250
|
-
},
|
|
251
|
-
],
|
|
252
|
-
activeIndex: 0,
|
|
253
|
-
}),
|
|
254
|
-
);
|
|
255
|
-
const result = await loadAccounts();
|
|
256
|
-
expect(result).not.toBeNull();
|
|
257
|
-
expect(result.accounts).toHaveLength(1);
|
|
258
|
-
expect(result.accounts[0].refreshToken).toBe("token1");
|
|
259
|
-
expect(result.accounts[0].access).toBe("access1");
|
|
260
|
-
expect(result.accounts[0].expires).toBe(9999999999);
|
|
261
|
-
expect(result.activeIndex).toBe(0);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("filters out invalid accounts (missing refreshToken)", async () => {
|
|
265
|
-
fs.readFile.mockResolvedValue(
|
|
266
|
-
JSON.stringify({
|
|
267
|
-
version: 1,
|
|
268
|
-
accounts: [{ refreshToken: "valid", addedAt: 1000 }, { email: "no-token" }, null],
|
|
269
|
-
activeIndex: 0,
|
|
270
|
-
}),
|
|
271
|
-
);
|
|
272
|
-
const result = await loadAccounts();
|
|
273
|
-
expect(result.accounts).toHaveLength(1);
|
|
274
|
-
expect(result.accounts[0].refreshToken).toBe("valid");
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("clamps activeIndex to valid range", async () => {
|
|
278
|
-
fs.readFile.mockResolvedValue(
|
|
279
|
-
JSON.stringify({
|
|
280
|
-
version: 1,
|
|
281
|
-
accounts: [{ refreshToken: "token1" }],
|
|
282
|
-
activeIndex: 99,
|
|
283
|
-
}),
|
|
284
|
-
);
|
|
285
|
-
const result = await loadAccounts();
|
|
286
|
-
expect(result.activeIndex).toBe(0);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("deduplicates accounts by refresh token", async () => {
|
|
290
|
-
fs.readFile.mockResolvedValue(
|
|
291
|
-
JSON.stringify({
|
|
292
|
-
version: 1,
|
|
293
|
-
accounts: [
|
|
294
|
-
{ refreshToken: "token1", lastUsed: 1000 },
|
|
295
|
-
{ refreshToken: "token1", lastUsed: 5000 },
|
|
296
|
-
],
|
|
297
|
-
activeIndex: 0,
|
|
298
|
-
}),
|
|
299
|
-
);
|
|
300
|
-
const result = await loadAccounts();
|
|
301
|
-
expect(result.accounts).toHaveLength(1);
|
|
302
|
-
expect(result.accounts[0].lastUsed).toBe(5000);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("applies defaults for missing fields", async () => {
|
|
306
|
-
fs.readFile.mockResolvedValue(
|
|
307
|
-
JSON.stringify({
|
|
308
|
-
version: 1,
|
|
309
|
-
accounts: [{ refreshToken: "token1" }],
|
|
310
|
-
activeIndex: 0,
|
|
311
|
-
}),
|
|
312
|
-
);
|
|
313
|
-
const result = await loadAccounts();
|
|
314
|
-
const acc = result.accounts[0];
|
|
315
|
-
expect(acc.enabled).toBe(true);
|
|
316
|
-
expect(acc.consecutiveFailures).toBe(0);
|
|
317
|
-
expect(acc.lastFailureTime).toBeNull();
|
|
318
|
-
expect(acc.rateLimitResetTimes).toEqual({});
|
|
319
|
-
expect(acc.lastUsed).toBe(0);
|
|
320
|
-
});
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// ---------------------------------------------------------------------------
|
|
324
|
-
// saveAccounts
|
|
325
|
-
// ---------------------------------------------------------------------------
|
|
326
|
-
|
|
327
|
-
describe("saveAccounts", () => {
|
|
328
|
-
beforeEach(() => {
|
|
329
|
-
vi.resetAllMocks();
|
|
330
|
-
existsSync.mockReturnValue(true);
|
|
331
|
-
readFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
|
|
332
|
-
fs.mkdir.mockResolvedValue(undefined);
|
|
333
|
-
fs.writeFile.mockResolvedValue(undefined);
|
|
334
|
-
fs.rename.mockResolvedValue(undefined);
|
|
335
|
-
fs.chmod.mockResolvedValue(undefined);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it("writes atomically via temp file + rename", async () => {
|
|
339
|
-
const storage = {
|
|
340
|
-
version: 1,
|
|
341
|
-
accounts: [{ refreshToken: "token1" }],
|
|
342
|
-
activeIndex: 0,
|
|
343
|
-
};
|
|
344
|
-
await saveAccounts(storage);
|
|
345
|
-
|
|
346
|
-
expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining(".tmp"), expect.any(String), {
|
|
347
|
-
encoding: "utf-8",
|
|
348
|
-
mode: 0o600,
|
|
349
|
-
});
|
|
350
|
-
expect(fs.rename).toHaveBeenCalled();
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it("creates config directory if needed", async () => {
|
|
354
|
-
const storage = { version: 1, accounts: [], activeIndex: 0 };
|
|
355
|
-
await saveAccounts(storage);
|
|
356
|
-
expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), {
|
|
357
|
-
recursive: true,
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it("cleans up temp file on write error", async () => {
|
|
362
|
-
fs.writeFile.mockRejectedValue(new Error("disk full"));
|
|
363
|
-
fs.unlink.mockResolvedValue(undefined);
|
|
364
|
-
|
|
365
|
-
const storage = { version: 1, accounts: [], activeIndex: 0 };
|
|
366
|
-
await expect(saveAccounts(storage)).rejects.toThrow("disk full");
|
|
367
|
-
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".tmp"));
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it("keeps persisted JSON consistent when concurrent saves commit out of order", async () => {
|
|
371
|
-
randomBytes
|
|
372
|
-
.mockReturnValueOnce({ toString: () => "writer-one" })
|
|
373
|
-
.mockReturnValueOnce({ toString: () => "writer-two" });
|
|
374
|
-
|
|
375
|
-
const files = new Map();
|
|
376
|
-
let markFirstWriteStarted;
|
|
377
|
-
const firstWriteStarted = new Promise((resolve) => {
|
|
378
|
-
markFirstWriteStarted = resolve;
|
|
379
|
-
});
|
|
380
|
-
let releaseFirstWrite;
|
|
381
|
-
const firstWriteBlocked = new Promise((resolve) => {
|
|
382
|
-
releaseFirstWrite = resolve;
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
fs.writeFile.mockImplementation(async (path, content) => {
|
|
386
|
-
if (path.includes("writer-one")) {
|
|
387
|
-
markFirstWriteStarted();
|
|
388
|
-
await firstWriteBlocked;
|
|
389
|
-
}
|
|
390
|
-
files.set(path, content);
|
|
391
|
-
});
|
|
392
|
-
fs.rename.mockImplementation(async (tempPath, storagePath) => {
|
|
393
|
-
const content = files.get(tempPath);
|
|
394
|
-
if (content === undefined) throw new Error("temp file missing");
|
|
395
|
-
files.set(storagePath, content);
|
|
396
|
-
files.delete(tempPath);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
const firstStorage = {
|
|
400
|
-
version: 1,
|
|
401
|
-
accounts: [{ refreshToken: "first-writer" }],
|
|
402
|
-
activeIndex: 0,
|
|
403
|
-
};
|
|
404
|
-
const secondStorage = {
|
|
405
|
-
version: 1,
|
|
406
|
-
accounts: [{ refreshToken: "second-writer" }],
|
|
407
|
-
activeIndex: 0,
|
|
408
|
-
};
|
|
409
|
-
const emptyDisk = { version: 1, accounts: [], activeIndex: 0 };
|
|
410
|
-
|
|
411
|
-
const firstSave = saveAccounts(firstStorage, emptyDisk);
|
|
412
|
-
await firstWriteStarted;
|
|
413
|
-
await saveAccounts(secondStorage, emptyDisk);
|
|
414
|
-
releaseFirstWrite();
|
|
415
|
-
await firstSave;
|
|
416
|
-
|
|
417
|
-
const storagePath = fs.rename.mock.calls.at(-1)[1];
|
|
418
|
-
const persisted = JSON.parse(files.get(storagePath));
|
|
419
|
-
expect(persisted).toEqual(firstStorage);
|
|
420
|
-
expect([...files.keys()].filter((path) => path.endsWith(".tmp"))).toEqual([]);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
itWindows("propagates a Windows ACL-denied write and removes the temp file", async () => {
|
|
424
|
-
const accessDenied = Object.assign(new Error("access denied"), { code: "EACCES" });
|
|
425
|
-
fs.writeFile.mockRejectedValue(accessDenied);
|
|
426
|
-
fs.unlink.mockResolvedValue(undefined);
|
|
427
|
-
|
|
428
|
-
const storage = { version: 1, accounts: [], activeIndex: 0 };
|
|
429
|
-
await expect(saveAccounts(storage, storage)).rejects.toBe(accessDenied);
|
|
430
|
-
|
|
431
|
-
const tempPath = fs.writeFile.mock.calls[0][0];
|
|
432
|
-
expect(fs.rename).not.toHaveBeenCalled();
|
|
433
|
-
expect(fs.unlink).toHaveBeenCalledWith(tempPath);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it("cleans up the temp file when atomic rename fails", async () => {
|
|
437
|
-
const renameError = Object.assign(new Error("rename failed"), { code: "EPERM" });
|
|
438
|
-
fs.rename.mockRejectedValue(renameError);
|
|
439
|
-
fs.unlink.mockResolvedValue(undefined);
|
|
440
|
-
|
|
441
|
-
const storage = { version: 1, accounts: [], activeIndex: 0 };
|
|
442
|
-
await expect(saveAccounts(storage, storage)).rejects.toBe(renameError);
|
|
443
|
-
|
|
444
|
-
const tempPath = fs.writeFile.mock.calls[0][0];
|
|
445
|
-
expect(fs.rename).toHaveBeenCalledWith(tempPath, expect.stringMatching(/anthropic-accounts\.json$/));
|
|
446
|
-
expect(fs.unlink).toHaveBeenCalledWith(tempPath);
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it("merges auth fields from fresher disk state", async () => {
|
|
450
|
-
fs.readFile.mockResolvedValue(
|
|
451
|
-
JSON.stringify({
|
|
452
|
-
version: 1,
|
|
453
|
-
accounts: [
|
|
454
|
-
{
|
|
455
|
-
id: "a1",
|
|
456
|
-
refreshToken: "disk-refresh",
|
|
457
|
-
access: "disk-access",
|
|
458
|
-
expires: 999999,
|
|
459
|
-
token_updated_at: 2000,
|
|
460
|
-
addedAt: 1000,
|
|
461
|
-
lastUsed: 0,
|
|
462
|
-
enabled: true,
|
|
463
|
-
rateLimitResetTimes: {},
|
|
464
|
-
consecutiveFailures: 0,
|
|
465
|
-
lastFailureTime: null,
|
|
466
|
-
stats: createDefaultStats(1000),
|
|
467
|
-
},
|
|
468
|
-
],
|
|
469
|
-
activeIndex: 0,
|
|
470
|
-
}),
|
|
471
|
-
);
|
|
472
|
-
|
|
473
|
-
const storage = {
|
|
474
|
-
version: 1,
|
|
475
|
-
accounts: [
|
|
476
|
-
{
|
|
477
|
-
id: "a1",
|
|
478
|
-
refreshToken: "mem-refresh",
|
|
479
|
-
access: "mem-access",
|
|
480
|
-
expires: 123,
|
|
481
|
-
token_updated_at: 1000,
|
|
482
|
-
addedAt: 1000,
|
|
483
|
-
lastUsed: 0,
|
|
484
|
-
enabled: true,
|
|
485
|
-
rateLimitResetTimes: {},
|
|
486
|
-
consecutiveFailures: 0,
|
|
487
|
-
lastFailureTime: null,
|
|
488
|
-
stats: createDefaultStats(1000),
|
|
489
|
-
},
|
|
490
|
-
],
|
|
491
|
-
activeIndex: 0,
|
|
492
|
-
};
|
|
493
|
-
|
|
494
|
-
await saveAccounts(storage);
|
|
495
|
-
|
|
496
|
-
const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
|
|
497
|
-
expect(written.accounts[0].refreshToken).toBe("disk-refresh");
|
|
498
|
-
expect(written.accounts[0].access).toBe("disk-access");
|
|
499
|
-
expect(written.accounts[0].expires).toBe(999999);
|
|
500
|
-
expect(written.accounts[0].token_updated_at).toBe(2000);
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
it("matches id-less disk accounts by addedAt during freshness merge", async () => {
|
|
504
|
-
fs.readFile.mockResolvedValue(
|
|
505
|
-
JSON.stringify({
|
|
506
|
-
version: 1,
|
|
507
|
-
accounts: [
|
|
508
|
-
{
|
|
509
|
-
refreshToken: "disk-refresh-rotated",
|
|
510
|
-
access: "disk-access",
|
|
511
|
-
expires: 888888,
|
|
512
|
-
token_updated_at: 3000,
|
|
513
|
-
addedAt: 1111,
|
|
514
|
-
lastUsed: 0,
|
|
515
|
-
enabled: true,
|
|
516
|
-
rateLimitResetTimes: {},
|
|
517
|
-
consecutiveFailures: 0,
|
|
518
|
-
lastFailureTime: null,
|
|
519
|
-
stats: createDefaultStats(1111),
|
|
520
|
-
},
|
|
521
|
-
],
|
|
522
|
-
activeIndex: 0,
|
|
523
|
-
}),
|
|
524
|
-
);
|
|
525
|
-
|
|
526
|
-
const storage = {
|
|
527
|
-
version: 1,
|
|
528
|
-
accounts: [
|
|
529
|
-
{
|
|
530
|
-
id: "legacy-a1",
|
|
531
|
-
refreshToken: "old-refresh",
|
|
532
|
-
access: "old-access",
|
|
533
|
-
expires: 111,
|
|
534
|
-
token_updated_at: 1000,
|
|
535
|
-
addedAt: 1111,
|
|
536
|
-
lastUsed: 0,
|
|
537
|
-
enabled: true,
|
|
538
|
-
rateLimitResetTimes: {},
|
|
539
|
-
consecutiveFailures: 0,
|
|
540
|
-
lastFailureTime: null,
|
|
541
|
-
stats: createDefaultStats(1111),
|
|
542
|
-
},
|
|
543
|
-
],
|
|
544
|
-
activeIndex: 0,
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
await saveAccounts(storage);
|
|
548
|
-
|
|
549
|
-
const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
|
|
550
|
-
expect(written.accounts[0].refreshToken).toBe("disk-refresh-rotated");
|
|
551
|
-
expect(written.accounts[0].token_updated_at).toBe(3000);
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it("does not resurrect accounts removed by caller", async () => {
|
|
555
|
-
fs.readFile.mockResolvedValue(
|
|
556
|
-
JSON.stringify({
|
|
557
|
-
version: 1,
|
|
558
|
-
accounts: [
|
|
559
|
-
{
|
|
560
|
-
id: "a1",
|
|
561
|
-
refreshToken: "disk-refresh",
|
|
562
|
-
token_updated_at: 2000,
|
|
563
|
-
addedAt: 1000,
|
|
564
|
-
lastUsed: 0,
|
|
565
|
-
enabled: true,
|
|
566
|
-
rateLimitResetTimes: {},
|
|
567
|
-
consecutiveFailures: 0,
|
|
568
|
-
lastFailureTime: null,
|
|
569
|
-
stats: createDefaultStats(1000),
|
|
570
|
-
},
|
|
571
|
-
],
|
|
572
|
-
activeIndex: 0,
|
|
573
|
-
}),
|
|
574
|
-
);
|
|
575
|
-
|
|
576
|
-
await saveAccounts({ version: 1, accounts: [], activeIndex: 0 });
|
|
577
|
-
|
|
578
|
-
const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
|
|
579
|
-
expect(written.accounts).toEqual([]);
|
|
580
|
-
});
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
// ---------------------------------------------------------------------------
|
|
584
|
-
// clearAccounts
|
|
585
|
-
// ---------------------------------------------------------------------------
|
|
586
|
-
|
|
587
|
-
describe("clearAccounts", () => {
|
|
588
|
-
beforeEach(() => {
|
|
589
|
-
vi.resetAllMocks();
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it("deletes the storage file", async () => {
|
|
593
|
-
fs.unlink.mockResolvedValue(undefined);
|
|
594
|
-
await clearAccounts();
|
|
595
|
-
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("anthropic-accounts.json"));
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it("ignores ENOENT errors", async () => {
|
|
599
|
-
fs.unlink.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
|
|
600
|
-
await expect(clearAccounts()).resolves.toBeUndefined();
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
it("rethrows non-ENOENT errors", async () => {
|
|
604
|
-
fs.unlink.mockRejectedValue(Object.assign(new Error("permission denied"), { code: "EACCES" }));
|
|
605
|
-
await expect(clearAccounts()).rejects.toThrow("permission denied");
|
|
606
|
-
});
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
// ---------------------------------------------------------------------------
|
|
610
|
-
// Stats validation
|
|
611
|
-
// ---------------------------------------------------------------------------
|
|
612
|
-
|
|
613
|
-
describe("createDefaultStats", () => {
|
|
614
|
-
it("creates zeroed stats with given timestamp", () => {
|
|
615
|
-
const stats = createDefaultStats(1000);
|
|
616
|
-
expect(stats).toEqual({
|
|
617
|
-
requests: 0,
|
|
618
|
-
inputTokens: 0,
|
|
619
|
-
outputTokens: 0,
|
|
620
|
-
cacheReadTokens: 0,
|
|
621
|
-
cacheWriteTokens: 0,
|
|
622
|
-
lastReset: 1000,
|
|
623
|
-
});
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
it("uses Date.now() when no timestamp given", () => {
|
|
627
|
-
const before = Date.now();
|
|
628
|
-
const stats = createDefaultStats();
|
|
629
|
-
expect(stats.lastReset).toBeGreaterThanOrEqual(before);
|
|
630
|
-
expect(stats.lastReset).toBeLessThanOrEqual(Date.now());
|
|
631
|
-
});
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
describe("loadAccounts with stats", () => {
|
|
635
|
-
it("loads accounts with stats fields preserved", async () => {
|
|
636
|
-
const stored = {
|
|
637
|
-
version: 1,
|
|
638
|
-
accounts: [
|
|
639
|
-
{
|
|
640
|
-
refreshToken: "tok1",
|
|
641
|
-
stats: {
|
|
642
|
-
requests: 42,
|
|
643
|
-
inputTokens: 10000,
|
|
644
|
-
outputTokens: 5000,
|
|
645
|
-
cacheReadTokens: 2000,
|
|
646
|
-
cacheWriteTokens: 100,
|
|
647
|
-
lastReset: 1700000000000,
|
|
648
|
-
},
|
|
649
|
-
},
|
|
650
|
-
],
|
|
651
|
-
activeIndex: 0,
|
|
652
|
-
};
|
|
653
|
-
fs.readFile.mockResolvedValue(JSON.stringify(stored));
|
|
654
|
-
|
|
655
|
-
const result = await loadAccounts();
|
|
656
|
-
expect(result.accounts[0].stats.requests).toBe(42);
|
|
657
|
-
expect(result.accounts[0].stats.inputTokens).toBe(10000);
|
|
658
|
-
expect(result.accounts[0].stats.outputTokens).toBe(5000);
|
|
659
|
-
expect(result.accounts[0].stats.cacheReadTokens).toBe(2000);
|
|
660
|
-
expect(result.accounts[0].stats.cacheWriteTokens).toBe(100);
|
|
661
|
-
expect(result.accounts[0].stats.lastReset).toBe(1700000000000);
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
it("provides default stats when missing from stored data", async () => {
|
|
665
|
-
const stored = {
|
|
666
|
-
version: 1,
|
|
667
|
-
accounts: [{ refreshToken: "tok1" }],
|
|
668
|
-
activeIndex: 0,
|
|
669
|
-
};
|
|
670
|
-
fs.readFile.mockResolvedValue(JSON.stringify(stored));
|
|
671
|
-
|
|
672
|
-
const result = await loadAccounts();
|
|
673
|
-
expect(result.accounts[0].stats.requests).toBe(0);
|
|
674
|
-
expect(result.accounts[0].stats.inputTokens).toBe(0);
|
|
675
|
-
expect(result.accounts[0].stats.outputTokens).toBe(0);
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
it("clamps negative stats values to 0", async () => {
|
|
679
|
-
const stored = {
|
|
680
|
-
version: 1,
|
|
681
|
-
accounts: [
|
|
682
|
-
{
|
|
683
|
-
refreshToken: "tok1",
|
|
684
|
-
stats: { requests: -5, inputTokens: -100, outputTokens: NaN },
|
|
685
|
-
},
|
|
686
|
-
],
|
|
687
|
-
activeIndex: 0,
|
|
688
|
-
};
|
|
689
|
-
fs.readFile.mockResolvedValue(JSON.stringify(stored));
|
|
690
|
-
|
|
691
|
-
const result = await loadAccounts();
|
|
692
|
-
expect(result.accounts[0].stats.requests).toBe(0);
|
|
693
|
-
expect(result.accounts[0].stats.inputTokens).toBe(0);
|
|
694
|
-
expect(result.accounts[0].stats.outputTokens).toBe(0);
|
|
695
|
-
});
|
|
696
|
-
});
|