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.
@@ -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("claude-code/2.1.80");
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("claude-code/2.1.80");
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("claude-code/2.1.80");
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
- const secondsSinceUsed = (Date.now() - account.lastUsed) / 1000;
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);
@@ -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)).toBe(49);
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: 0 }), // Older = more fresh
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
- const id = typeof acc.id === "string" && acc.id ? acc.id : `${addedAt}:${acc.refreshToken.slice(0, 12)}`;
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
- // Future: handle migrations here
202
- return null;
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
- return null;
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) {
@@ -27,11 +27,15 @@ vi.mock("node:fs", () => ({
27
27
  },
28
28
  }));
29
29
 
30
- vi.mock("node:crypto", () => ({
31
- randomBytes: vi.fn(() => ({
32
- toString: () => "abcdef123456",
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 null for wrong version", async () => {
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
- expect(result).toBeNull();
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.26",
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": "^0.4.45",
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",