@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1
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 +19 -0
- package/dist/bun-proxy.mjs +282 -55
- package/dist/opencode-anthropic-auth-cli.mjs +194 -55
- package/dist/opencode-anthropic-auth-plugin.js +1816 -594
- package/package.json +1 -1
- package/src/__tests__/billing-edge-cases.test.ts +84 -0
- package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
- package/src/__tests__/debug-gating.test.ts +76 -0
- package/src/__tests__/decomposition-smoke.test.ts +92 -0
- package/src/__tests__/fingerprint-regression.test.ts +1 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
- package/src/__tests__/helpers/conversation-history.ts +376 -0
- package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
- package/src/__tests__/helpers/deferred.ts +122 -0
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
- package/src/__tests__/helpers/in-memory-storage.ts +152 -0
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
- package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
- package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
- package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
- package/src/__tests__/helpers/sse.ts +288 -0
- package/src/__tests__/index.parallel.test.ts +711 -0
- package/src/__tests__/sanitization-regex.test.ts +65 -0
- package/src/__tests__/state-bounds.test.ts +110 -0
- package/src/account-identity.test.ts +213 -0
- package/src/account-identity.ts +108 -0
- package/src/accounts.dedup.test.ts +696 -0
- package/src/accounts.test.ts +2 -1
- package/src/accounts.ts +485 -191
- package/src/bun-fetch.test.ts +379 -0
- package/src/bun-fetch.ts +447 -174
- package/src/bun-proxy.ts +289 -57
- package/src/circuit-breaker.test.ts +274 -0
- package/src/circuit-breaker.ts +235 -0
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +37 -18
- package/src/commands/router.ts +25 -5
- package/src/env.ts +1 -0
- package/src/headers/billing.ts +31 -13
- package/src/index.ts +224 -247
- package/src/oauth.ts +7 -1
- package/src/parent-pid-watcher.test.ts +219 -0
- package/src/parent-pid-watcher.ts +99 -0
- package/src/plugin-helpers.ts +112 -0
- package/src/refresh-helpers.ts +169 -0
- package/src/refresh-lock.test.ts +36 -9
- package/src/refresh-lock.ts +2 -2
- package/src/request/body.history.test.ts +398 -0
- package/src/request/body.ts +200 -13
- package/src/request/metadata.ts +6 -2
- package/src/response/index.ts +1 -1
- package/src/response/mcp.ts +60 -31
- package/src/response/streaming.test.ts +382 -0
- package/src/response/streaming.ts +403 -76
- package/src/storage.test.ts +127 -104
- package/src/storage.ts +152 -62
- package/src/system-prompt/builder.ts +33 -3
- package/src/system-prompt/sanitize.ts +12 -2
- package/src/token-refresh.test.ts +84 -1
- package/src/token-refresh.ts +14 -8
package/src/storage.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { appendFileSync, existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import type { AccountIdentity } from "./account-identity.js";
|
|
5
|
+
import { findByIdentity } from "./account-identity.js";
|
|
4
6
|
import { getConfigDir } from "./config.js";
|
|
5
7
|
|
|
6
8
|
export interface AccountStats {
|
|
@@ -15,6 +17,8 @@ export interface AccountStats {
|
|
|
15
17
|
export interface AccountMetadata {
|
|
16
18
|
id: string;
|
|
17
19
|
email?: string;
|
|
20
|
+
identity?: AccountIdentity;
|
|
21
|
+
label?: string;
|
|
18
22
|
refreshToken: string;
|
|
19
23
|
access?: string;
|
|
20
24
|
expires?: number;
|
|
@@ -36,6 +40,11 @@ export interface AccountStorage {
|
|
|
36
40
|
activeIndex: number;
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
export type StoredAccountMatchCandidate = Pick<
|
|
44
|
+
AccountMetadata,
|
|
45
|
+
"id" | "email" | "identity" | "label" | "refreshToken" | "addedAt" | "source"
|
|
46
|
+
>;
|
|
47
|
+
|
|
39
48
|
const CURRENT_VERSION = 1;
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -134,6 +143,8 @@ function validateAccount(raw: unknown, now: number): AccountMetadata | null {
|
|
|
134
143
|
return {
|
|
135
144
|
id,
|
|
136
145
|
email: typeof acc.email === "string" ? acc.email : undefined,
|
|
146
|
+
identity: isAccountIdentity(acc.identity) ? acc.identity : undefined,
|
|
147
|
+
label: typeof acc.label === "string" ? acc.label : undefined,
|
|
137
148
|
refreshToken: acc.refreshToken as string,
|
|
138
149
|
access: typeof acc.access === "string" ? acc.access : undefined,
|
|
139
150
|
expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : undefined,
|
|
@@ -155,10 +166,139 @@ function validateAccount(raw: unknown, now: number): AccountMetadata | null {
|
|
|
155
166
|
lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
|
|
156
167
|
lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
|
|
157
168
|
stats: validateStats(acc.stats, now),
|
|
158
|
-
source:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
source: acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth" ? acc.source : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isAccountIdentity(value: unknown): value is AccountIdentity {
|
|
174
|
+
if (!value || typeof value !== "object") return false;
|
|
175
|
+
|
|
176
|
+
const candidate = value as Record<string, unknown>;
|
|
177
|
+
switch (candidate.kind) {
|
|
178
|
+
case "oauth":
|
|
179
|
+
return typeof candidate.email === "string" && candidate.email.length > 0;
|
|
180
|
+
case "cc":
|
|
181
|
+
return (
|
|
182
|
+
(candidate.source === "cc-keychain" || candidate.source === "cc-file") && typeof candidate.label === "string"
|
|
183
|
+
);
|
|
184
|
+
case "legacy":
|
|
185
|
+
return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
|
|
186
|
+
default:
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveStoredIdentity(candidate: StoredAccountMatchCandidate): AccountIdentity {
|
|
192
|
+
if (isAccountIdentity(candidate.identity)) {
|
|
193
|
+
return candidate.identity;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (candidate.source === "oauth" && candidate.email) {
|
|
197
|
+
return { kind: "oauth", email: candidate.email };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if ((candidate.source === "cc-keychain" || candidate.source === "cc-file") && candidate.label) {
|
|
201
|
+
return {
|
|
202
|
+
kind: "cc",
|
|
203
|
+
source: candidate.source,
|
|
204
|
+
label: candidate.label,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { kind: "legacy", refreshToken: candidate.refreshToken };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveTokenUpdatedAt(account: Pick<AccountMetadata, "token_updated_at" | "addedAt">): number {
|
|
212
|
+
return typeof account.token_updated_at === "number" && Number.isFinite(account.token_updated_at)
|
|
213
|
+
? account.token_updated_at
|
|
214
|
+
: account.addedAt;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function clampActiveIndex(accounts: AccountMetadata[], activeIndex: number): number {
|
|
218
|
+
if (accounts.length === 0) {
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return Math.max(0, Math.min(activeIndex, accounts.length - 1));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function findStoredAccountMatch(
|
|
226
|
+
accounts: AccountMetadata[],
|
|
227
|
+
candidate: StoredAccountMatchCandidate,
|
|
228
|
+
): AccountMetadata | null {
|
|
229
|
+
const byId = accounts.find((account) => account.id === candidate.id);
|
|
230
|
+
if (byId) {
|
|
231
|
+
return byId;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const byIdentity = findByIdentity(accounts, resolveStoredIdentity(candidate));
|
|
235
|
+
if (byIdentity) {
|
|
236
|
+
return byIdentity;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const byAddedAt = accounts.filter((account) => account.addedAt === candidate.addedAt);
|
|
240
|
+
if (byAddedAt.length === 1) {
|
|
241
|
+
return byAddedAt[0]!;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const byRefreshToken = accounts.find((account) => account.refreshToken === candidate.refreshToken);
|
|
245
|
+
if (byRefreshToken) {
|
|
246
|
+
return byRefreshToken;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return byAddedAt[0] ?? null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function mergeAccountWithFresherAuth(
|
|
253
|
+
account: AccountMetadata,
|
|
254
|
+
diskMatch: AccountMetadata | null,
|
|
255
|
+
): AccountMetadata {
|
|
256
|
+
const memoryTokenUpdatedAt = resolveTokenUpdatedAt(account);
|
|
257
|
+
const diskTokenUpdatedAt = diskMatch ? resolveTokenUpdatedAt(diskMatch) : 0;
|
|
258
|
+
|
|
259
|
+
if (!diskMatch || diskTokenUpdatedAt <= memoryTokenUpdatedAt) {
|
|
260
|
+
return {
|
|
261
|
+
...account,
|
|
262
|
+
token_updated_at: memoryTokenUpdatedAt,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
...account,
|
|
268
|
+
refreshToken: diskMatch.refreshToken,
|
|
269
|
+
access: diskMatch.access,
|
|
270
|
+
expires: diskMatch.expires,
|
|
271
|
+
token_updated_at: diskTokenUpdatedAt,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function unionAccountsWithDisk(storage: AccountStorage, disk: AccountStorage | null): AccountStorage {
|
|
276
|
+
if (!disk || storage.accounts.length === 0) {
|
|
277
|
+
return {
|
|
278
|
+
...storage,
|
|
279
|
+
activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const activeAccountId = storage.accounts[storage.activeIndex]?.id ?? null;
|
|
284
|
+
const matchedDiskAccounts = new Set<AccountMetadata>();
|
|
285
|
+
const mergedAccounts = storage.accounts.map((account) => {
|
|
286
|
+
const diskMatch = findStoredAccountMatch(disk.accounts, account);
|
|
287
|
+
if (diskMatch) {
|
|
288
|
+
matchedDiskAccounts.add(diskMatch);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return mergeAccountWithFresherAuth(account, diskMatch);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const diskOnlyAccounts = disk.accounts.filter((account) => !matchedDiskAccounts.has(account));
|
|
295
|
+
const accounts = [...mergedAccounts, ...diskOnlyAccounts];
|
|
296
|
+
const activeIndex = activeAccountId ? accounts.findIndex((account) => account.id === activeAccountId) : -1;
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
...storage,
|
|
300
|
+
accounts,
|
|
301
|
+
activeIndex: activeIndex >= 0 ? activeIndex : clampActiveIndex(accounts, storage.activeIndex),
|
|
162
302
|
};
|
|
163
303
|
}
|
|
164
304
|
|
|
@@ -177,8 +317,9 @@ export async function loadAccounts(): Promise<AccountStorage | null> {
|
|
|
177
317
|
}
|
|
178
318
|
|
|
179
319
|
if (data.version !== CURRENT_VERSION) {
|
|
180
|
-
|
|
181
|
-
|
|
320
|
+
console.warn(
|
|
321
|
+
`Storage version mismatch: ${String(data.version)} vs ${CURRENT_VERSION}. Attempting best-effort migration.`,
|
|
322
|
+
);
|
|
182
323
|
}
|
|
183
324
|
|
|
184
325
|
const now = Date.now();
|
|
@@ -218,66 +359,15 @@ export async function saveAccounts(storage: AccountStorage): Promise<void> {
|
|
|
218
359
|
await fs.mkdir(configDir, { recursive: true });
|
|
219
360
|
ensureGitignore(configDir);
|
|
220
361
|
|
|
221
|
-
let storageToWrite =
|
|
362
|
+
let storageToWrite = {
|
|
363
|
+
...storage,
|
|
364
|
+
activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
|
|
365
|
+
};
|
|
222
366
|
|
|
223
367
|
// Merge auth fields against disk by freshness to avoid stale-process clobber.
|
|
224
368
|
try {
|
|
225
369
|
const disk = await loadAccounts();
|
|
226
|
-
|
|
227
|
-
const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
|
|
228
|
-
const diskByAddedAt = new Map<number, AccountMetadata[]>();
|
|
229
|
-
const diskByToken = new Map(disk.accounts.map((a) => [a.refreshToken, a]));
|
|
230
|
-
for (const d of disk.accounts) {
|
|
231
|
-
const bucket = diskByAddedAt.get(d.addedAt) || [];
|
|
232
|
-
bucket.push(d);
|
|
233
|
-
diskByAddedAt.set(d.addedAt, bucket);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const findDiskMatch = (acc: AccountMetadata): AccountMetadata | null => {
|
|
237
|
-
const byId = diskById.get(acc.id);
|
|
238
|
-
if (byId) return byId;
|
|
239
|
-
|
|
240
|
-
const byAddedAt = diskByAddedAt.get(acc.addedAt);
|
|
241
|
-
if (byAddedAt?.length === 1) return byAddedAt[0]!;
|
|
242
|
-
|
|
243
|
-
const byToken = diskByToken.get(acc.refreshToken);
|
|
244
|
-
if (byToken) return byToken;
|
|
245
|
-
|
|
246
|
-
if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0]!;
|
|
247
|
-
return null;
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
const mergedAccounts = storage.accounts.map((acc) => {
|
|
251
|
-
const diskAcc = findDiskMatch(acc);
|
|
252
|
-
const memTs =
|
|
253
|
-
typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at)
|
|
254
|
-
? acc.token_updated_at
|
|
255
|
-
: acc.addedAt;
|
|
256
|
-
const diskTs = diskAcc?.token_updated_at || 0;
|
|
257
|
-
const useDiskAuth = !!diskAcc && diskTs > memTs;
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
...acc,
|
|
261
|
-
refreshToken: useDiskAuth ? diskAcc!.refreshToken : acc.refreshToken,
|
|
262
|
-
access: useDiskAuth ? diskAcc!.access : acc.access,
|
|
263
|
-
expires: useDiskAuth ? diskAcc!.expires : acc.expires,
|
|
264
|
-
token_updated_at: useDiskAuth ? diskTs : memTs,
|
|
265
|
-
};
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
let activeIndex = storage.activeIndex;
|
|
269
|
-
if (mergedAccounts.length > 0) {
|
|
270
|
-
activeIndex = Math.max(0, Math.min(activeIndex, mergedAccounts.length - 1));
|
|
271
|
-
} else {
|
|
272
|
-
activeIndex = 0;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
storageToWrite = {
|
|
276
|
-
...storage,
|
|
277
|
-
accounts: mergedAccounts,
|
|
278
|
-
activeIndex,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
370
|
+
storageToWrite = unionAccountsWithDisk(storageToWrite, disk);
|
|
281
371
|
} catch {
|
|
282
372
|
// If merge read fails, continue with caller-provided storage payload.
|
|
283
373
|
}
|
|
@@ -1,6 +1,36 @@
|
|
|
1
|
-
//
|
|
2
|
-
// System
|
|
3
|
-
//
|
|
1
|
+
// ===========================================================================
|
|
2
|
+
// System Prompt Structure — CC Alignment Audit
|
|
3
|
+
// ===========================================================================
|
|
4
|
+
//
|
|
5
|
+
// Audit date: 2026-04-10
|
|
6
|
+
// Reference: .omc/research/cch-source-analysis.md,
|
|
7
|
+
// .omc/research/cc-vs-plugin-comparison.md
|
|
8
|
+
//
|
|
9
|
+
// VERIFIED ALIGNMENT:
|
|
10
|
+
// [x] Billing block placement: inserted first in the system array, with no
|
|
11
|
+
// cache_control on the emitted block.
|
|
12
|
+
// [x] Identity string text matches CC exactly:
|
|
13
|
+
// "You are Claude Code, Anthropic's official CLI for Claude."
|
|
14
|
+
// [x] Identity cache_control matches CC's request shape:
|
|
15
|
+
// { type: "ephemeral" }.
|
|
16
|
+
// [x] Block ordering remains billing → identity → remaining sanitized blocks.
|
|
17
|
+
// [x] Sanitization rewrites OpenCode-specific references toward Claude/Claude
|
|
18
|
+
// Code phrasing before final prompt assembly.
|
|
19
|
+
//
|
|
20
|
+
// FUTURE HARDENING NOTES:
|
|
21
|
+
// - CC's full system prompt is much larger (tool instructions, permissions,
|
|
22
|
+
// internal workflow text). This builder intentionally preserves only the
|
|
23
|
+
// routing-critical structure documented in the source analysis.
|
|
24
|
+
// - CC records billing cache behavior internally as cacheScope: null. The
|
|
25
|
+
// plugin emits no cache_control field on the billing block, which is the
|
|
26
|
+
// equivalent wire representation.
|
|
27
|
+
// - CC can append cc_workload when AsyncLocalStorage workload tracking is
|
|
28
|
+
// present. That field is not applicable to this plugin.
|
|
29
|
+
// - CC tool naming conventions can evolve independently from this file. Current
|
|
30
|
+
// plugin-specific tool prefix notes live in body/request docs, not here.
|
|
31
|
+
//
|
|
32
|
+
// See src/headers/billing.ts for billing-specific gaps and attestation notes.
|
|
33
|
+
// ===========================================================================
|
|
4
34
|
|
|
5
35
|
import {
|
|
6
36
|
CLAUDE_CODE_IDENTITY_STRING,
|
|
@@ -5,8 +5,18 @@
|
|
|
5
5
|
import { CLAUDE_CODE_IDENTITY_STRING } from "../constants.js";
|
|
6
6
|
import type { PromptCompactionMode } from "../types.js";
|
|
7
7
|
|
|
8
|
-
export function sanitizeSystemText(text: string): string {
|
|
9
|
-
|
|
8
|
+
export function sanitizeSystemText(text: string, enabled = true): string {
|
|
9
|
+
if (!enabled) return text;
|
|
10
|
+
return text
|
|
11
|
+
.replace(/\bOpenCode\b/g, "Claude Code")
|
|
12
|
+
.replace(/\bopencode\b/gi, "Claude")
|
|
13
|
+
.replace(/OhMyClaude\s*Code/gi, "Claude Code")
|
|
14
|
+
.replace(/OhMyClaudeCode/gi, "Claude Code")
|
|
15
|
+
.replace(/\bSisyphus\b/g, "Claude Code Agent")
|
|
16
|
+
.replace(/\bMorph\s+plugin\b/gi, "edit plugin")
|
|
17
|
+
.replace(/\bmorph_edit\b/g, "edit")
|
|
18
|
+
.replace(/\bmorph_/g, "")
|
|
19
|
+
.replace(/\bOhMyClaude\b/gi, "Claude");
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
export function compactSystemText(text: string, mode: PromptCompactionMode): string {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
3
|
+
import { createDeferred, nextTick } from "./__tests__/helpers/deferred.js";
|
|
4
|
+
import { createRefreshHelpers } from "./refresh-helpers.js";
|
|
3
5
|
|
|
4
6
|
vi.mock("node:child_process", () => ({
|
|
5
7
|
execSync: vi.fn(),
|
|
@@ -32,7 +34,7 @@ import type { ManagedAccount } from "./accounts.js";
|
|
|
32
34
|
import type { CCCredential } from "./cc-credentials.js";
|
|
33
35
|
import { readCCCredentials, readCCCredentialsFromFile } from "./cc-credentials.js";
|
|
34
36
|
import { refreshToken } from "./oauth.js";
|
|
35
|
-
import { refreshAccountToken } from "./token-refresh.js";
|
|
37
|
+
import { applyDiskAuthIfFresher, refreshAccountToken } from "./token-refresh.js";
|
|
36
38
|
|
|
37
39
|
const mockExecSync = execSync as Mock;
|
|
38
40
|
const mockReadCCCredentials = readCCCredentials as Mock;
|
|
@@ -231,4 +233,85 @@ describe("refreshAccountToken", () => {
|
|
|
231
233
|
timeout: 5000,
|
|
232
234
|
});
|
|
233
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
|
+
});
|
|
234
317
|
});
|
package/src/token-refresh.ts
CHANGED
|
@@ -61,21 +61,25 @@ export function applyDiskAuthIfFresher(
|
|
|
61
61
|
if (!diskAuth) return false;
|
|
62
62
|
const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
|
|
63
63
|
const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
|
|
64
|
-
const
|
|
64
|
+
const diskIsNewer = diskTokenUpdatedAt > memTokenUpdatedAt;
|
|
65
|
+
const diskHasDifferentRefreshToken = diskAuth.refreshToken !== account.refreshToken;
|
|
65
66
|
const memAuthExpired = !account.expires || account.expires <= Date.now();
|
|
66
67
|
const allowExpiredFallback = options.allowExpiredFallback === true;
|
|
67
|
-
if (
|
|
68
|
+
if (!diskIsNewer && !(allowExpiredFallback && diskHasDifferentRefreshToken && memAuthExpired)) {
|
|
68
69
|
return false;
|
|
69
70
|
}
|
|
70
71
|
account.refreshToken = diskAuth.refreshToken;
|
|
71
72
|
account.access = diskAuth.access;
|
|
72
73
|
account.expires = diskAuth.expires;
|
|
73
|
-
|
|
74
|
+
if (diskIsNewer) {
|
|
75
|
+
account.tokenUpdatedAt = diskTokenUpdatedAt;
|
|
76
|
+
}
|
|
74
77
|
return true;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
export interface RefreshAccountTokenOptions {
|
|
78
81
|
onTokensUpdated?: () => Promise<void>;
|
|
82
|
+
debugLog?: (...args: unknown[]) => void;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
export interface OpenCodeClient {
|
|
@@ -188,12 +192,10 @@ export async function refreshAccountToken(
|
|
|
188
192
|
account: ManagedAccount,
|
|
189
193
|
client: OpenCodeClient,
|
|
190
194
|
source: "foreground" | "idle" = "foreground",
|
|
191
|
-
{ onTokensUpdated }: RefreshAccountTokenOptions = {},
|
|
195
|
+
{ onTokensUpdated, debugLog }: RefreshAccountTokenOptions = {},
|
|
192
196
|
): Promise<string> {
|
|
193
197
|
const lockResult = await acquireRefreshLock(account.id, {
|
|
194
|
-
timeoutMs: 2_000,
|
|
195
198
|
backoffMs: 60,
|
|
196
|
-
staleMs: 20_000,
|
|
197
199
|
});
|
|
198
200
|
const lock =
|
|
199
201
|
lockResult && typeof lockResult === "object"
|
|
@@ -233,7 +235,9 @@ export async function refreshAccountToken(
|
|
|
233
235
|
const accessToken = await refreshCCAccount(account);
|
|
234
236
|
if (accessToken) {
|
|
235
237
|
if (onTokensUpdated) {
|
|
236
|
-
await onTokensUpdated().catch(() =>
|
|
238
|
+
await onTokensUpdated().catch((err) => {
|
|
239
|
+
debugLog?.("onTokensUpdated failed:", (err as Error).message);
|
|
240
|
+
});
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
await client.auth
|
|
@@ -246,7 +250,9 @@ export async function refreshAccountToken(
|
|
|
246
250
|
expires: account.expires,
|
|
247
251
|
},
|
|
248
252
|
})
|
|
249
|
-
.catch(() =>
|
|
253
|
+
.catch((err) => {
|
|
254
|
+
debugLog?.("auth.set failed:", (err as Error).message);
|
|
255
|
+
});
|
|
250
256
|
return accessToken;
|
|
251
257
|
}
|
|
252
258
|
throw new Error("CC credential refresh failed");
|