opencode-anthropic-fix 0.0.16 → 0.0.18

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.
@@ -0,0 +1,123 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ import { getStoragePath } from "./storage.mjs";
5
+
6
+ const DEFAULT_LOCK_TIMEOUT_MS = 2_000;
7
+ const DEFAULT_LOCK_BACKOFF_MS = 50;
8
+ const DEFAULT_STALE_LOCK_MS = 20_000;
9
+
10
+ /**
11
+ * @param {number} ms
12
+ * @returns {Promise<void>}
13
+ */
14
+ function delay(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+
18
+ /**
19
+ * @param {string} accountId
20
+ * @returns {string}
21
+ */
22
+ function getLockPath(accountId) {
23
+ const hash = createHash("sha1").update(accountId).digest("hex").slice(0, 24);
24
+ return join(dirname(getStoragePath()), "locks", `refresh-${hash}.lock`);
25
+ }
26
+
27
+ /**
28
+ * Try to acquire a per-account cross-process lock.
29
+ * @param {string} accountId
30
+ * @param {{ timeoutMs?: number, backoffMs?: number, staleMs?: number }} [options]
31
+ * @returns {Promise<{ acquired: boolean, lockPath: string | null, owner: string | null, lockInode: number | null }>}
32
+ */
33
+ export async function acquireRefreshLock(accountId, options = {}) {
34
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
35
+ const backoffMs = options.backoffMs ?? DEFAULT_LOCK_BACKOFF_MS;
36
+ const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
37
+ const lockPath = getLockPath(accountId);
38
+ const lockDir = dirname(lockPath);
39
+ const deadline = Date.now() + Math.max(0, timeoutMs);
40
+ const owner = randomBytes(12).toString("hex");
41
+
42
+ await fs.mkdir(lockDir, { recursive: true });
43
+
44
+ while (Date.now() <= deadline) {
45
+ try {
46
+ const handle = await fs.open(lockPath, "wx", 0o600);
47
+ try {
48
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now(), owner }), "utf-8");
49
+ const stat = await handle.stat();
50
+ return { acquired: true, lockPath, owner, lockInode: stat.ino };
51
+ } finally {
52
+ await handle.close();
53
+ }
54
+ } catch (error) {
55
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
56
+ if (code !== "EEXIST") {
57
+ throw error;
58
+ }
59
+
60
+ try {
61
+ const stat = await fs.stat(lockPath);
62
+ if (Date.now() - stat.mtimeMs > staleMs) {
63
+ await fs.unlink(lockPath);
64
+ continue;
65
+ }
66
+ } catch {
67
+ // Lock may have been released concurrently; retry.
68
+ }
69
+
70
+ const remaining = deadline - Date.now();
71
+ if (remaining <= 0) break;
72
+ const jitter = Math.floor(Math.random() * 25);
73
+ await delay(Math.min(remaining, backoffMs + jitter));
74
+ }
75
+ }
76
+
77
+ return { acquired: false, lockPath: null, owner: null, lockInode: null };
78
+ }
79
+
80
+ /**
81
+ * Release a lock acquired by acquireRefreshLock.
82
+ * @param {{ lockPath: string | null, owner?: string | null, lockInode?: number | null } | string | null} lock
83
+ * @returns {Promise<void>}
84
+ */
85
+ export async function releaseRefreshLock(lock) {
86
+ const lockPath = typeof lock === "string" || lock === null ? lock : lock.lockPath;
87
+ const owner = typeof lock === "object" && lock ? lock.owner || null : null;
88
+ const lockInode = typeof lock === "object" && lock ? lock.lockInode || null : null;
89
+
90
+ if (!lockPath) return;
91
+
92
+ // Ownership-safe release: avoid deleting a lock that another process
93
+ // acquired after ours became stale.
94
+ if (owner) {
95
+ try {
96
+ const content = await fs.readFile(lockPath, "utf-8");
97
+ const parsed = JSON.parse(content);
98
+ if (!parsed || typeof parsed !== "object" || parsed.owner !== owner) {
99
+ return;
100
+ }
101
+
102
+ if (lockInode) {
103
+ const stat = await fs.stat(lockPath);
104
+ if (stat.ino !== lockInode) {
105
+ return;
106
+ }
107
+ }
108
+ } catch (error) {
109
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
110
+ if (code === "ENOENT") return;
111
+ // If unreadable/corrupt, fail closed to avoid deleting another
112
+ // process's lock when ownership cannot be verified.
113
+ return;
114
+ }
115
+ }
116
+
117
+ try {
118
+ await fs.unlink(lockPath);
119
+ } catch (error) {
120
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
121
+ if (code !== "ENOENT") throw error;
122
+ }
123
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { promises as fs } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ const baseDir = join(tmpdir(), `opencode-refresh-lock-test-${process.pid}`);
7
+ const storagePath = join(baseDir, "anthropic-accounts.json");
8
+
9
+ vi.mock("./storage.mjs", () => ({
10
+ getStoragePath: () => storagePath,
11
+ }));
12
+
13
+ import { acquireRefreshLock, releaseRefreshLock } from "./refresh-lock.mjs";
14
+
15
+ describe("refresh-lock", () => {
16
+ beforeEach(async () => {
17
+ await fs.rm(baseDir, { recursive: true, force: true });
18
+ await fs.mkdir(baseDir, { recursive: true });
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await fs.rm(baseDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("does not release lock with mismatched owner", async () => {
26
+ const lock = await acquireRefreshLock("acc-1");
27
+ expect(lock.acquired).toBe(true);
28
+ expect(lock.lockPath).toBeTruthy();
29
+
30
+ await releaseRefreshLock({ lockPath: lock.lockPath, owner: "wrong-owner" });
31
+
32
+ await expect(fs.stat(lock.lockPath)).resolves.toBeTruthy();
33
+
34
+ await releaseRefreshLock(lock);
35
+ await expect(fs.stat(lock.lockPath)).rejects.toMatchObject({ code: "ENOENT" });
36
+ });
37
+
38
+ it("acquires a new lock after stale lock timeout", async () => {
39
+ const first = await acquireRefreshLock("acc-2", { timeoutMs: 50, staleMs: 10_000 });
40
+ expect(first.acquired).toBe(true);
41
+
42
+ const old = Date.now() / 1000 - 120;
43
+ await fs.utimes(first.lockPath, old, old);
44
+
45
+ const second = await acquireRefreshLock("acc-2", { timeoutMs: 200, backoffMs: 5, staleMs: 20 });
46
+ expect(second.acquired).toBe(true);
47
+ expect(second.owner).not.toBe(first.owner);
48
+
49
+ await releaseRefreshLock(second);
50
+ });
51
+
52
+ it("returns not acquired when lock remains busy", async () => {
53
+ const first = await acquireRefreshLock("acc-3", { timeoutMs: 50 });
54
+ expect(first.acquired).toBe(true);
55
+
56
+ const second = await acquireRefreshLock("acc-3", { timeoutMs: 30, backoffMs: 5, staleMs: 60_000 });
57
+ expect(second.acquired).toBe(false);
58
+
59
+ await releaseRefreshLock(first);
60
+ });
61
+
62
+ it("does not release when inode changed even if owner matches", async () => {
63
+ const first = await acquireRefreshLock("acc-4");
64
+ expect(first.acquired).toBe(true);
65
+
66
+ // Replace lock file with a new inode that reuses owner text.
67
+ await fs.unlink(first.lockPath);
68
+ await fs.writeFile(first.lockPath, JSON.stringify({ owner: first.owner, createdAt: Date.now() }), {
69
+ encoding: "utf-8",
70
+ mode: 0o600,
71
+ });
72
+
73
+ await releaseRefreshLock(first);
74
+
75
+ await expect(fs.stat(first.lockPath)).resolves.toBeTruthy();
76
+
77
+ await fs.unlink(first.lockPath);
78
+ });
79
+ });
package/lib/storage.mjs CHANGED
@@ -21,6 +21,7 @@ import { getConfigDir } from "./config.mjs";
21
21
  * @property {string} refreshToken
22
22
  * @property {string} [access]
23
23
  * @property {number} [expires]
24
+ * @property {number} token_updated_at
24
25
  * @property {number} addedAt
25
26
  * @property {number} lastUsed
26
27
  * @property {boolean} enabled
@@ -160,6 +161,12 @@ function validateAccount(raw, now) {
160
161
  refreshToken: acc.refreshToken,
161
162
  access: typeof acc.access === "string" ? acc.access : undefined,
162
163
  expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : undefined,
164
+ token_updated_at:
165
+ typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at)
166
+ ? acc.token_updated_at
167
+ : typeof acc.tokenUpdatedAt === "number" && Number.isFinite(acc.tokenUpdatedAt)
168
+ ? acc.tokenUpdatedAt
169
+ : addedAt,
163
170
  addedAt,
164
171
  lastUsed: typeof acc.lastUsed === "number" && Number.isFinite(acc.lastUsed) ? acc.lastUsed : 0,
165
172
  enabled: acc.enabled !== false,
@@ -234,8 +241,76 @@ export async function saveAccounts(storage) {
234
241
  await fs.mkdir(configDir, { recursive: true });
235
242
  ensureGitignore(configDir);
236
243
 
244
+ /** @type {AccountStorage} */
245
+ let storageToWrite = storage;
246
+
247
+ // Merge auth fields against disk by freshness to avoid stale-process clobber.
248
+ // We do not resurrect removed accounts; only merge for accounts present in
249
+ // the incoming storage payload.
250
+ try {
251
+ const disk = await loadAccounts();
252
+ if (disk && storage.accounts.length > 0) {
253
+ const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
254
+ /** @type {Map<number, AccountMetadata[]>} */
255
+ const diskByAddedAt = new Map();
256
+ const diskByToken = new Map(disk.accounts.map((a) => [a.refreshToken, a]));
257
+ for (const d of disk.accounts) {
258
+ const bucket = diskByAddedAt.get(d.addedAt) || [];
259
+ bucket.push(d);
260
+ diskByAddedAt.set(d.addedAt, bucket);
261
+ }
262
+
263
+ const findDiskMatch = (/** @type {AccountMetadata} */ acc) => {
264
+ const byId = diskById.get(acc.id);
265
+ if (byId) return byId;
266
+
267
+ const byAddedAt = diskByAddedAt.get(acc.addedAt);
268
+ if (byAddedAt?.length === 1) return byAddedAt[0];
269
+
270
+ const byToken = diskByToken.get(acc.refreshToken);
271
+ if (byToken) return byToken;
272
+
273
+ if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
274
+ return null;
275
+ };
276
+
277
+ const mergedAccounts = storage.accounts.map((acc) => {
278
+ const diskAcc = findDiskMatch(acc);
279
+ const memTs =
280
+ typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at)
281
+ ? acc.token_updated_at
282
+ : acc.addedAt;
283
+ const diskTs = diskAcc?.token_updated_at || 0;
284
+ const useDiskAuth = !!diskAcc && diskTs > memTs;
285
+
286
+ return {
287
+ ...acc,
288
+ refreshToken: useDiskAuth ? diskAcc.refreshToken : acc.refreshToken,
289
+ access: useDiskAuth ? diskAcc.access : acc.access,
290
+ expires: useDiskAuth ? diskAcc.expires : acc.expires,
291
+ token_updated_at: useDiskAuth ? diskTs : memTs,
292
+ };
293
+ });
294
+
295
+ let activeIndex = storage.activeIndex;
296
+ if (mergedAccounts.length > 0) {
297
+ activeIndex = Math.max(0, Math.min(activeIndex, mergedAccounts.length - 1));
298
+ } else {
299
+ activeIndex = 0;
300
+ }
301
+
302
+ storageToWrite = {
303
+ ...storage,
304
+ accounts: mergedAccounts,
305
+ activeIndex,
306
+ };
307
+ }
308
+ } catch {
309
+ // If merge read fails, continue with caller-provided storage payload.
310
+ }
311
+
237
312
  const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
238
- const content = JSON.stringify(storage, null, 2);
313
+ const content = JSON.stringify(storageToWrite, null, 2);
239
314
 
240
315
  try {
241
316
  await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
@@ -352,6 +352,139 @@ describe("saveAccounts", () => {
352
352
  await expect(saveAccounts(storage)).rejects.toThrow("disk full");
353
353
  expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".tmp"));
354
354
  });
355
+
356
+ it("merges auth fields from fresher disk state", async () => {
357
+ fs.readFile.mockResolvedValue(
358
+ JSON.stringify({
359
+ version: 1,
360
+ accounts: [
361
+ {
362
+ id: "a1",
363
+ refreshToken: "disk-refresh",
364
+ access: "disk-access",
365
+ expires: 999999,
366
+ token_updated_at: 2000,
367
+ addedAt: 1000,
368
+ lastUsed: 0,
369
+ enabled: true,
370
+ rateLimitResetTimes: {},
371
+ consecutiveFailures: 0,
372
+ lastFailureTime: null,
373
+ stats: createDefaultStats(1000),
374
+ },
375
+ ],
376
+ activeIndex: 0,
377
+ }),
378
+ );
379
+
380
+ const storage = {
381
+ version: 1,
382
+ accounts: [
383
+ {
384
+ id: "a1",
385
+ refreshToken: "mem-refresh",
386
+ access: "mem-access",
387
+ expires: 123,
388
+ token_updated_at: 1000,
389
+ addedAt: 1000,
390
+ lastUsed: 0,
391
+ enabled: true,
392
+ rateLimitResetTimes: {},
393
+ consecutiveFailures: 0,
394
+ lastFailureTime: null,
395
+ stats: createDefaultStats(1000),
396
+ },
397
+ ],
398
+ activeIndex: 0,
399
+ };
400
+
401
+ await saveAccounts(storage);
402
+
403
+ const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
404
+ expect(written.accounts[0].refreshToken).toBe("disk-refresh");
405
+ expect(written.accounts[0].access).toBe("disk-access");
406
+ expect(written.accounts[0].expires).toBe(999999);
407
+ expect(written.accounts[0].token_updated_at).toBe(2000);
408
+ });
409
+
410
+ it("matches id-less disk accounts by addedAt during freshness merge", async () => {
411
+ fs.readFile.mockResolvedValue(
412
+ JSON.stringify({
413
+ version: 1,
414
+ accounts: [
415
+ {
416
+ refreshToken: "disk-refresh-rotated",
417
+ access: "disk-access",
418
+ expires: 888888,
419
+ token_updated_at: 3000,
420
+ addedAt: 1111,
421
+ lastUsed: 0,
422
+ enabled: true,
423
+ rateLimitResetTimes: {},
424
+ consecutiveFailures: 0,
425
+ lastFailureTime: null,
426
+ stats: createDefaultStats(1111),
427
+ },
428
+ ],
429
+ activeIndex: 0,
430
+ }),
431
+ );
432
+
433
+ const storage = {
434
+ version: 1,
435
+ accounts: [
436
+ {
437
+ id: "legacy-a1",
438
+ refreshToken: "old-refresh",
439
+ access: "old-access",
440
+ expires: 111,
441
+ token_updated_at: 1000,
442
+ addedAt: 1111,
443
+ lastUsed: 0,
444
+ enabled: true,
445
+ rateLimitResetTimes: {},
446
+ consecutiveFailures: 0,
447
+ lastFailureTime: null,
448
+ stats: createDefaultStats(1111),
449
+ },
450
+ ],
451
+ activeIndex: 0,
452
+ };
453
+
454
+ await saveAccounts(storage);
455
+
456
+ const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
457
+ expect(written.accounts[0].refreshToken).toBe("disk-refresh-rotated");
458
+ expect(written.accounts[0].token_updated_at).toBe(3000);
459
+ });
460
+
461
+ it("does not resurrect accounts removed by caller", async () => {
462
+ fs.readFile.mockResolvedValue(
463
+ JSON.stringify({
464
+ version: 1,
465
+ accounts: [
466
+ {
467
+ id: "a1",
468
+ refreshToken: "disk-refresh",
469
+ token_updated_at: 2000,
470
+ addedAt: 1000,
471
+ lastUsed: 0,
472
+ enabled: true,
473
+ rateLimitResetTimes: {},
474
+ consecutiveFailures: 0,
475
+ lastFailureTime: null,
476
+ stats: createDefaultStats(1000),
477
+ },
478
+ ],
479
+ activeIndex: 0,
480
+ }),
481
+ );
482
+
483
+ await saveAccounts({ version: 1, accounts: [], activeIndex: 0 });
484
+
485
+ const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
486
+ expect(written.accounts).toEqual([]);
487
+ });
355
488
  });
356
489
 
357
490
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "main": "./index.mjs",
5
+ "files": [
6
+ "index.mjs",
7
+ "lib/**/*.mjs",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
5
11
  "scripts": {
6
12
  "build": "node scripts/build.mjs",
7
13
  "test": "vitest run",
@@ -1,31 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- workflow_dispatch:
5
- inputs:
6
- bump:
7
- description: "Version bump type"
8
- required: false
9
- default: "patch"
10
-
11
- permissions:
12
- id-token: write
13
- contents: read
14
-
15
- jobs:
16
- publish:
17
- runs-on: ubuntu-latest
18
- steps:
19
- - uses: actions/checkout@v4
20
-
21
- - uses: actions/setup-node@v4
22
- with:
23
- node-version: "24"
24
- registry-url: "https://registry.npmjs.org"
25
-
26
- - run: npm install
27
-
28
- - run: npm publish --access public
29
- env:
30
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
31
- NPM_CONFIG_PROVENANCE: false
package/.husky/pre-commit DELETED
@@ -1,2 +0,0 @@
1
- npm test
2
- npx lint-staged
package/.husky/pre-push DELETED
@@ -1,3 +0,0 @@
1
- npm test
2
- npx prettier --check .
3
- npx eslint .