opencode-anthropic-fix 0.0.26 → 0.0.28
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 +30 -2
- package/index.mjs +4126 -3550
- package/lib/accounts.mjs +51 -11
- package/lib/accounts.test.mjs +22 -22
- package/lib/config.mjs +15 -1
- package/lib/oauth.mjs +256 -74
- package/lib/oauth.test.mjs +120 -3
- package/lib/rotation.mjs +2 -1
- package/lib/rotation.test.mjs +3 -3
- package/lib/storage.mjs +34 -6
- package/lib/storage.test.mjs +14 -7
- package/package.json +2 -2
package/lib/oauth.test.mjs
CHANGED
|
@@ -23,7 +23,7 @@ describe("oauth headers", () => {
|
|
|
23
23
|
await exchange("code#state", "verifier");
|
|
24
24
|
|
|
25
25
|
const [, init] = mockFetch.mock.calls[0];
|
|
26
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
26
|
+
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
it("sends Claude Code user-agent on token refresh", async () => {
|
|
@@ -35,7 +35,7 @@ describe("oauth headers", () => {
|
|
|
35
35
|
await refreshToken("refresh-token");
|
|
36
36
|
|
|
37
37
|
const [, init] = mockFetch.mock.calls[0];
|
|
38
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
38
|
+
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it("sends Claude Code user-agent on token revoke", async () => {
|
|
@@ -44,6 +44,123 @@ describe("oauth headers", () => {
|
|
|
44
44
|
await revoke("refresh-token");
|
|
45
45
|
|
|
46
46
|
const [, init] = mockFetch.mock.calls[0];
|
|
47
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
47
|
+
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("parses Anthropic nested error payloads on exchange failure", async () => {
|
|
51
|
+
mockFetch.mockResolvedValueOnce({
|
|
52
|
+
ok: false,
|
|
53
|
+
status: 400,
|
|
54
|
+
headers: { get: () => null },
|
|
55
|
+
text: async () => JSON.stringify({ error: { type: "invalid_grant", message: "Bad auth code." } }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await exchange("code#state", "verifier");
|
|
59
|
+
expect(result.type).toBe("failed");
|
|
60
|
+
expect(result.status).toBe(400);
|
|
61
|
+
expect(result.code).toBe("invalid_grant");
|
|
62
|
+
expect(result.reason).toBe("Bad auth code.");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("retries token exchange on 429 before succeeding", async () => {
|
|
66
|
+
mockFetch
|
|
67
|
+
.mockResolvedValueOnce({
|
|
68
|
+
ok: false,
|
|
69
|
+
status: 429,
|
|
70
|
+
headers: { get: (key) => (key === "retry-after-ms" ? "1" : null) },
|
|
71
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "retry" } }),
|
|
72
|
+
})
|
|
73
|
+
.mockResolvedValueOnce({
|
|
74
|
+
ok: true,
|
|
75
|
+
json: async () => ({
|
|
76
|
+
access_token: "access",
|
|
77
|
+
refresh_token: "refresh",
|
|
78
|
+
expires_in: 3600,
|
|
79
|
+
account: { email_address: "retry@example.com" },
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result = await exchange("code#state", "verifier");
|
|
84
|
+
expect(result.type).toBe("success");
|
|
85
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("retries token refresh on 429 before succeeding", async () => {
|
|
89
|
+
mockFetch
|
|
90
|
+
.mockResolvedValueOnce({
|
|
91
|
+
ok: false,
|
|
92
|
+
status: 429,
|
|
93
|
+
headers: { get: (key) => (key === "retry-after-ms" ? "1" : null) },
|
|
94
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "retry" } }),
|
|
95
|
+
})
|
|
96
|
+
.mockResolvedValueOnce({
|
|
97
|
+
ok: true,
|
|
98
|
+
json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = await refreshToken("refresh-token");
|
|
102
|
+
expect(result.access_token).toBe("a");
|
|
103
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("uses nested error type as refresh error code", async () => {
|
|
107
|
+
mockFetch.mockResolvedValueOnce({
|
|
108
|
+
ok: false,
|
|
109
|
+
status: 400,
|
|
110
|
+
headers: { get: () => null },
|
|
111
|
+
text: async () => JSON.stringify({ error: { type: "invalid_grant", message: "Bad refresh token." } }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(refreshToken("refresh-token")).rejects.toMatchObject({
|
|
115
|
+
status: 400,
|
|
116
|
+
code: "invalid_grant",
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns cooldown hint on exchange 429 without Retry-After headers", async () => {
|
|
121
|
+
vi.useFakeTimers();
|
|
122
|
+
try {
|
|
123
|
+
mockFetch.mockResolvedValue({
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 429,
|
|
126
|
+
headers: { get: () => null },
|
|
127
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limited" } }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const promise = exchange("code#state", "verifier");
|
|
131
|
+
await vi.runAllTimersAsync();
|
|
132
|
+
const result = await promise;
|
|
133
|
+
|
|
134
|
+
expect(result).toMatchObject({
|
|
135
|
+
type: "failed",
|
|
136
|
+
status: 429,
|
|
137
|
+
code: "rate_limit_error",
|
|
138
|
+
retryAfterMs: 30_000,
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
vi.useRealTimers();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("throws 30s cooldown hint on refresh 429 without Retry-After headers", async () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
try {
|
|
148
|
+
mockFetch.mockResolvedValue({
|
|
149
|
+
ok: false,
|
|
150
|
+
status: 429,
|
|
151
|
+
headers: { get: () => null },
|
|
152
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limited" } }),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const assertion = expect(refreshToken("refresh-token")).rejects.toMatchObject({
|
|
156
|
+
status: 429,
|
|
157
|
+
code: "rate_limit_error",
|
|
158
|
+
retryAfterMs: 30_000,
|
|
159
|
+
});
|
|
160
|
+
await vi.runAllTimersAsync();
|
|
161
|
+
await assertion;
|
|
162
|
+
} finally {
|
|
163
|
+
vi.useRealTimers();
|
|
164
|
+
}
|
|
48
165
|
});
|
|
49
166
|
});
|
package/lib/rotation.mjs
CHANGED
|
@@ -216,7 +216,8 @@ const SWITCH_THRESHOLD = 100;
|
|
|
216
216
|
function calculateHybridScore(account, maxTokens) {
|
|
217
217
|
const healthComponent = account.healthScore * 2;
|
|
218
218
|
const tokenComponent = (account.tokens / maxTokens) * 100 * 5;
|
|
219
|
-
|
|
219
|
+
// QA fix M17: lastUsed=0 means never-used; treat as 0 freshness bonus instead of max
|
|
220
|
+
const secondsSinceUsed = account.lastUsed > 0 ? (Date.now() - account.lastUsed) / 1000 : 0;
|
|
220
221
|
const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1;
|
|
221
222
|
|
|
222
223
|
return Math.max(0, healthComponent + tokenComponent + freshnessComponent);
|
package/lib/rotation.test.mjs
CHANGED
|
@@ -135,7 +135,7 @@ describe("TokenBucketTracker", () => {
|
|
|
135
135
|
tracker.consume(0);
|
|
136
136
|
tracker.consume(0);
|
|
137
137
|
tracker.refund(0);
|
|
138
|
-
expect(tracker.getTokens(0)).
|
|
138
|
+
expect(tracker.getTokens(0)).toBeCloseTo(49, 0);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
it("refund does not exceed max", () => {
|
|
@@ -290,9 +290,9 @@ describe("selectAccount", () => {
|
|
|
290
290
|
it("selects the account with highest composite score", () => {
|
|
291
291
|
const candidates = [
|
|
292
292
|
makeCandidate({ index: 0, lastUsed: Date.now() }),
|
|
293
|
-
makeCandidate({ index: 1, lastUsed:
|
|
293
|
+
makeCandidate({ index: 1, lastUsed: Date.now() - 120_000 }), // 2 min ago = more fresh
|
|
294
294
|
];
|
|
295
|
-
// Account 1 should have higher freshness score
|
|
295
|
+
// Account 1 should have higher freshness score (120s * 0.1 = 12pt bonus)
|
|
296
296
|
const result = selectAccount(candidates, "hybrid", null, healthTracker, tokenTracker, 0);
|
|
297
297
|
// Both have same health and tokens, but account 1 has more freshness
|
|
298
298
|
expect(result?.index).toBe(1);
|
package/lib/storage.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
5
5
|
import { getConfigDir } from "./config.mjs";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -153,7 +153,11 @@ function validateAccount(raw, now) {
|
|
|
153
153
|
|
|
154
154
|
const addedAt = typeof acc.addedAt === "number" && Number.isFinite(acc.addedAt) ? acc.addedAt : now;
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
// QA fix M12: hash token fragment instead of leaking raw prefix
|
|
157
|
+
const id =
|
|
158
|
+
typeof acc.id === "string" && acc.id
|
|
159
|
+
? acc.id
|
|
160
|
+
: `${addedAt}:${createHash("sha256").update(acc.refreshToken).digest("hex").slice(0, 12)}`;
|
|
157
161
|
|
|
158
162
|
return {
|
|
159
163
|
id,
|
|
@@ -198,8 +202,21 @@ export async function loadAccounts() {
|
|
|
198
202
|
}
|
|
199
203
|
|
|
200
204
|
if (data.version !== CURRENT_VERSION) {
|
|
201
|
-
//
|
|
202
|
-
|
|
205
|
+
// QA fix H12: warn about version mismatch but attempt best-effort load
|
|
206
|
+
// rather than silently discarding all accounts
|
|
207
|
+
if (typeof data.version === "number" && data.version > CURRENT_VERSION) {
|
|
208
|
+
// Forward-compat: newer versions likely only add fields
|
|
209
|
+
|
|
210
|
+
console.warn(
|
|
211
|
+
`[anthropic-auth] accounts file version ${data.version} is newer than expected ${CURRENT_VERSION}; loading with best effort`,
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
// Unknown/older version — still try to load
|
|
215
|
+
|
|
216
|
+
console.warn(
|
|
217
|
+
`[anthropic-auth] accounts file version mismatch (${data.version} vs ${CURRENT_VERSION}); attempting load`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
203
220
|
}
|
|
204
221
|
|
|
205
222
|
const now = Date.now();
|
|
@@ -225,7 +242,12 @@ export async function loadAccounts() {
|
|
|
225
242
|
} catch (error) {
|
|
226
243
|
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
227
244
|
if (code === "ENOENT") return null;
|
|
228
|
-
|
|
245
|
+
// QA fix H11: only swallow ENOENT and JSON parse errors; throw for real I/O failures
|
|
246
|
+
if (error instanceof SyntaxError) {
|
|
247
|
+
// Corrupted JSON file — treat as empty
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
229
251
|
}
|
|
230
252
|
}
|
|
231
253
|
|
|
@@ -244,11 +266,13 @@ export async function saveAccounts(storage) {
|
|
|
244
266
|
/** @type {AccountStorage} */
|
|
245
267
|
let storageToWrite = storage;
|
|
246
268
|
|
|
269
|
+
// QA fix M15: Accept pre-loaded disk data to avoid double-read (TOCTOU mitigation).
|
|
270
|
+
// Callers can pass diskData from their own loadAccounts() call.
|
|
247
271
|
// Merge auth fields against disk by freshness to avoid stale-process clobber.
|
|
248
272
|
// We do not resurrect removed accounts; only merge for accounts present in
|
|
249
273
|
// the incoming storage payload.
|
|
250
274
|
try {
|
|
251
|
-
const disk = await loadAccounts();
|
|
275
|
+
const disk = arguments[1] ?? (await loadAccounts());
|
|
252
276
|
if (disk && storage.accounts.length > 0) {
|
|
253
277
|
const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
|
|
254
278
|
/** @type {Map<number, AccountMetadata[]>} */
|
|
@@ -313,6 +337,10 @@ export async function saveAccounts(storage) {
|
|
|
313
337
|
const content = JSON.stringify(storageToWrite, null, 2);
|
|
314
338
|
|
|
315
339
|
try {
|
|
340
|
+
// QA note H9: mode 0o600 (owner-only read/write) is enforced on Linux/macOS.
|
|
341
|
+
// On Windows, POSIX mode is silently ignored by Node.js — ACLs govern permissions.
|
|
342
|
+
// Windows file inherits parent directory ACLs, which typically restrict access
|
|
343
|
+
// to the current user on per-user directories (%APPDATA%).
|
|
316
344
|
await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
317
345
|
await fs.rename(tempPath, storagePath);
|
|
318
346
|
} catch (error) {
|
package/lib/storage.test.mjs
CHANGED
|
@@ -27,11 +27,15 @@ vi.mock("node:fs", () => ({
|
|
|
27
27
|
},
|
|
28
28
|
}));
|
|
29
29
|
|
|
30
|
-
vi.mock("node:crypto", () =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
vi.mock("node:crypto", async (importOriginal) => {
|
|
31
|
+
const actual = await importOriginal();
|
|
32
|
+
return {
|
|
33
|
+
randomBytes: vi.fn(() => ({
|
|
34
|
+
toString: () => "abcdef123456",
|
|
35
|
+
})),
|
|
36
|
+
createHash: actual.createHash,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
35
39
|
|
|
36
40
|
// ---------------------------------------------------------------------------
|
|
37
41
|
// deduplicateByRefreshToken
|
|
@@ -206,10 +210,13 @@ describe("loadAccounts", () => {
|
|
|
206
210
|
expect(result).toBeNull();
|
|
207
211
|
});
|
|
208
212
|
|
|
209
|
-
it("returns
|
|
213
|
+
it("returns best-effort data for newer version (forward compat)", async () => {
|
|
210
214
|
fs.readFile.mockResolvedValue(JSON.stringify({ version: 99, accounts: [] }));
|
|
211
215
|
const result = await loadAccounts();
|
|
212
|
-
|
|
216
|
+
// H12 fix: no longer returns null; attempts best-effort load with a warning
|
|
217
|
+
expect(result).not.toBeNull();
|
|
218
|
+
expect(result.accounts).toEqual([]);
|
|
219
|
+
expect(result.version).toBe(1); // normalized to current version
|
|
213
220
|
});
|
|
214
221
|
|
|
215
222
|
it("returns null when accounts is not an array", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-anthropic-fix",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
4
4
|
"license": "GPL-3.0-or-later",
|
|
5
5
|
"main": "./index.mjs",
|
|
6
6
|
"files": [
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@eslint/js": "^10.0.1",
|
|
36
|
-
"@opencode-ai/plugin": "^
|
|
36
|
+
"@opencode-ai/plugin": "^1.2.27",
|
|
37
37
|
"esbuild": "^0.27.3",
|
|
38
38
|
"eslint": "^10.0.0",
|
|
39
39
|
"husky": "^9.1.7",
|