@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.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
package/src/oauth.ts CHANGED
@@ -107,6 +107,7 @@ export async function exchange(code: string, verifier: string): Promise<Exchange
107
107
  reason = parsed.message;
108
108
  }
109
109
  } catch {
110
+ // Body is not JSON — use raw text as the reason, trimmed to strip whitespace
110
111
  reason = rawText.trim() || undefined;
111
112
  }
112
113
  }
@@ -154,6 +155,8 @@ export async function exchange(code: string, verifier: string): Promise<Exchange
154
155
  ? await result
155
156
  .text()
156
157
  .then((value) => (typeof value === "string" ? value : ""))
158
+ // Body may be unreadable on 5xx responses; empty string is the correct fallback
159
+ // because we've already captured the HTTP status for the caller.
157
160
  .catch(() => "")
158
161
  : "";
159
162
  return fail(result.status, raw);
@@ -198,6 +201,8 @@ export async function revoke(refreshToken: string): Promise<boolean> {
198
201
  });
199
202
  return resp.ok;
200
203
  } catch {
204
+ // Best-effort revocation — network errors, DNS failures, or endpoint unavailability
205
+ // are all expected; callers proceed with local cleanup regardless.
201
206
  return false;
202
207
  }
203
208
  }
@@ -236,6 +241,7 @@ export async function refreshToken(
236
241
  });
237
242
 
238
243
  if (!resp.ok) {
244
+ // Empty string is the correct fallback — we already have resp.status for the error message.
239
245
  const text = await resp.text().catch(() => "");
240
246
  const error: RefreshError = new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
241
247
  error.status = resp.status;
@@ -243,7 +249,7 @@ export async function refreshToken(
243
249
  const parsed = JSON.parse(text);
244
250
  if (parsed.error) error.code = parsed.error;
245
251
  } catch {
246
- // Body may not be valid JSON
252
+ // Body may not be valid JSON — leave error.code unset, the HTTP status is still attached
247
253
  }
248
254
  throw error;
249
255
  }
@@ -0,0 +1,219 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // RED phase — will be GREEN after T18.
4
+ // @ts-expect-error T18 creates this module and makes these tests executable.
5
+ import { ParentPidWatcher, watchParentAndExit } from "./parent-pid-watcher.js";
6
+
7
+ const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
8
+ const originalPpidDescriptor = Object.getOwnPropertyDescriptor(process, "ppid");
9
+
10
+ function restorePlatform(): void {
11
+ if (originalPlatformDescriptor) {
12
+ Object.defineProperty(process, "platform", originalPlatformDescriptor);
13
+ }
14
+ }
15
+
16
+ function setPlatform(platform: NodeJS.Platform): void {
17
+ Object.defineProperty(process, "platform", {
18
+ value: platform,
19
+ configurable: true,
20
+ });
21
+ }
22
+
23
+ function restoreParentPid(): void {
24
+ if (originalPpidDescriptor) {
25
+ Object.defineProperty(process, "ppid", originalPpidDescriptor);
26
+ }
27
+ }
28
+
29
+ function setCurrentParentPid(ppid: number): void {
30
+ Object.defineProperty(process, "ppid", {
31
+ value: ppid,
32
+ configurable: true,
33
+ });
34
+ }
35
+
36
+ function makeProcessError(code: "EPERM" | "ESRCH"): NodeJS.ErrnoException {
37
+ const error = new Error(`process.kill failed with ${code}`) as NodeJS.ErrnoException;
38
+ error.code = code;
39
+ return error;
40
+ }
41
+
42
+ describe("ParentPidWatcher", () => {
43
+ beforeEach(() => {
44
+ vi.useFakeTimers();
45
+ restorePlatform();
46
+ restoreParentPid();
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.useRealTimers();
51
+ vi.restoreAllMocks();
52
+ restorePlatform();
53
+ restoreParentPid();
54
+ });
55
+
56
+ it("starts polling parent PID every 5 seconds by default", () => {
57
+ const onParentGone = vi.fn();
58
+ const intervalSpy = vi.spyOn(globalThis, "setInterval");
59
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
60
+
61
+ const watcher = new ParentPidWatcher({
62
+ parentPid: 4242,
63
+ onParentGone,
64
+ });
65
+
66
+ watcher.start();
67
+
68
+ expect(intervalSpy).toHaveBeenCalledTimes(1);
69
+ expect(intervalSpy).toHaveBeenLastCalledWith(expect.any(Function), 5000);
70
+
71
+ vi.advanceTimersByTime(5000);
72
+
73
+ expect(killSpy).toHaveBeenCalledWith(4242, 0);
74
+ expect(onParentGone).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("fires the callback when process.kill(parentPid, 0) reports ESRCH", () => {
78
+ const onParentGone = vi.fn();
79
+
80
+ vi.spyOn(process, "kill").mockImplementation(() => {
81
+ throw makeProcessError("ESRCH");
82
+ });
83
+
84
+ const watcher = new ParentPidWatcher({
85
+ parentPid: 4242,
86
+ onParentGone,
87
+ });
88
+
89
+ watcher.start();
90
+ vi.advanceTimersByTime(5000);
91
+
92
+ expect(onParentGone).toHaveBeenCalledTimes(1);
93
+ });
94
+
95
+ it("fires the callback when process.ppid changes away from the configured parent PID", () => {
96
+ const onParentGone = vi.fn();
97
+ setCurrentParentPid(4242);
98
+
99
+ vi.spyOn(process, "kill").mockImplementation(() => true);
100
+
101
+ const watcher = new ParentPidWatcher({
102
+ parentPid: 4242,
103
+ onParentGone,
104
+ });
105
+
106
+ watcher.start();
107
+ setCurrentParentPid(9001);
108
+ vi.advanceTimersByTime(5000);
109
+
110
+ expect(onParentGone).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it("stops polling after the callback fires", () => {
114
+ const onParentGone = vi.fn();
115
+ const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
116
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
117
+ throw makeProcessError("ESRCH");
118
+ });
119
+
120
+ const watcher = new ParentPidWatcher({
121
+ parentPid: 4242,
122
+ onParentGone,
123
+ });
124
+
125
+ watcher.start();
126
+ vi.advanceTimersByTime(5000);
127
+ vi.advanceTimersByTime(20_000);
128
+
129
+ expect(onParentGone).toHaveBeenCalledTimes(1);
130
+ expect(killSpy).toHaveBeenCalledTimes(1);
131
+ expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it("manual stop() halts polling", () => {
135
+ const onParentGone = vi.fn();
136
+ const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
137
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
138
+
139
+ const watcher = new ParentPidWatcher({
140
+ parentPid: 4242,
141
+ pollIntervalMs: 1000,
142
+ onParentGone,
143
+ });
144
+
145
+ watcher.start();
146
+ watcher.stop();
147
+ vi.advanceTimersByTime(10_000);
148
+
149
+ expect(killSpy).not.toHaveBeenCalled();
150
+ expect(onParentGone).not.toHaveBeenCalled();
151
+ expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
152
+ });
153
+
154
+ it("does not start polling for invalid parent PIDs", () => {
155
+ const intervalSpy = vi.spyOn(globalThis, "setInterval");
156
+ const onParentGone = vi.fn();
157
+
158
+ expect(() => new ParentPidWatcher({ parentPid: 0, onParentGone }).start()).toThrow(/parent pid/i);
159
+ expect(() => new ParentPidWatcher({ parentPid: -1, onParentGone }).start()).toThrow(/parent pid/i);
160
+ expect(() => new ParentPidWatcher({ parentPid: Number.NaN, onParentGone }).start()).toThrow(/parent pid/i);
161
+ expect(intervalSpy).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("treats EPERM as parent-still-alive on macOS", () => {
165
+ const onParentGone = vi.fn();
166
+ setPlatform("darwin");
167
+
168
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
169
+ throw makeProcessError("EPERM");
170
+ });
171
+
172
+ const watcher = new ParentPidWatcher({
173
+ parentPid: 4242,
174
+ onParentGone,
175
+ });
176
+
177
+ watcher.start();
178
+ vi.advanceTimersByTime(5000);
179
+
180
+ expect(killSpy).toHaveBeenCalledWith(4242, 0);
181
+ expect(onParentGone).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it("treats EPERM as parent-still-alive on Linux", () => {
185
+ const onParentGone = vi.fn();
186
+ setPlatform("linux");
187
+
188
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
189
+ throw makeProcessError("EPERM");
190
+ });
191
+
192
+ const watcher = new ParentPidWatcher({
193
+ parentPid: 4242,
194
+ onParentGone,
195
+ });
196
+
197
+ watcher.start();
198
+ vi.advanceTimersByTime(5000);
199
+
200
+ expect(killSpy).toHaveBeenCalledWith(4242, 0);
201
+ expect(onParentGone).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("uses the same parent-death contract on Windows via watchParentAndExit", () => {
205
+ setPlatform("win32");
206
+
207
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
208
+ vi.spyOn(process, "kill").mockImplementation(() => {
209
+ throw makeProcessError("ESRCH");
210
+ });
211
+
212
+ const watcher = watchParentAndExit(4242, 7);
213
+
214
+ watcher.start();
215
+ vi.advanceTimersByTime(5000);
216
+
217
+ expect(exitSpy).toHaveBeenCalledWith(7);
218
+ });
219
+ });
@@ -0,0 +1,99 @@
1
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
2
+ const DEFAULT_PARENT_EXIT_CODE = 1;
3
+
4
+ export interface ParentPidWatcherOptions {
5
+ parentPid: number;
6
+ pollIntervalMs?: number;
7
+ onParentGone: () => void;
8
+ }
9
+
10
+ function assertValidParentPid(parentPid: number): void {
11
+ if (!Number.isInteger(parentPid) || parentPid <= 0) {
12
+ throw new Error("Parent PID must be a positive integer.");
13
+ }
14
+ }
15
+
16
+ function assertValidPollInterval(pollIntervalMs: number): void {
17
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
18
+ throw new Error("Poll interval must be a positive number.");
19
+ }
20
+ }
21
+
22
+ function isParentAlive(parentPid: number): boolean {
23
+ try {
24
+ process.kill(parentPid, 0);
25
+ return true;
26
+ } catch (error) {
27
+ const code = (error as NodeJS.ErrnoException).code;
28
+ if (code === "ESRCH") {
29
+ return false;
30
+ }
31
+
32
+ if (code === "EPERM") {
33
+ return true;
34
+ }
35
+
36
+ return true;
37
+ }
38
+ }
39
+
40
+ export class ParentPidWatcher {
41
+ private readonly parentPid: number;
42
+ private readonly pollIntervalMs: number;
43
+ private readonly onParentGone: () => void;
44
+
45
+ private interval: ReturnType<typeof setInterval> | null = null;
46
+ private shouldMonitorPpidDrift = false;
47
+
48
+ constructor(options: ParentPidWatcherOptions) {
49
+ this.parentPid = options.parentPid;
50
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
51
+ this.onParentGone = options.onParentGone;
52
+ }
53
+
54
+ start(): void {
55
+ if (this.interval) {
56
+ return;
57
+ }
58
+
59
+ assertValidParentPid(this.parentPid);
60
+ assertValidPollInterval(this.pollIntervalMs);
61
+
62
+ this.shouldMonitorPpidDrift = process.ppid === this.parentPid;
63
+
64
+ this.interval = setInterval(() => {
65
+ if (this.shouldMonitorPpidDrift && process.ppid !== this.parentPid) {
66
+ this.handleParentGone();
67
+ return;
68
+ }
69
+
70
+ if (!isParentAlive(this.parentPid)) {
71
+ this.handleParentGone();
72
+ }
73
+ }, this.pollIntervalMs);
74
+ }
75
+
76
+ stop(): void {
77
+ if (!this.interval) {
78
+ return;
79
+ }
80
+
81
+ clearInterval(this.interval);
82
+ this.interval = null;
83
+ this.shouldMonitorPpidDrift = false;
84
+ }
85
+
86
+ private handleParentGone(): void {
87
+ this.stop();
88
+ this.onParentGone();
89
+ }
90
+ }
91
+
92
+ export function watchParentAndExit(parentPid: number, exitCode = DEFAULT_PARENT_EXIT_CODE): ParentPidWatcher {
93
+ return new ParentPidWatcher({
94
+ parentPid,
95
+ onParentGone: () => {
96
+ process.exit(exitCode);
97
+ },
98
+ });
99
+ }
@@ -0,0 +1,112 @@
1
+ import { AccountManager } from "./accounts.js";
2
+ import { stripAnsi } from "./commands/router.js";
3
+ import type { AnthropicAuthConfig } from "./config.js";
4
+ import type { OpenCodeClient } from "./token-refresh.js";
5
+
6
+ /**
7
+ * Cap on the toast-debounce timestamp map. Bounded because each distinct
8
+ * `debounceKey` creates a long-lived entry, and new debounce keys accumulate
9
+ * over a session (one per account switch reason, per account, etc.). Eviction
10
+ * is FIFO on the insertion order preserved by Map.
11
+ */
12
+ const DEBOUNCE_TOAST_MAP_MAX_SIZE = 50;
13
+
14
+ export interface PluginHelperDeps {
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin client API boundary; accepts arbitrary extension methods
16
+ client: OpenCodeClient & Record<string, any>;
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin config accepts forward-compatible arbitrary keys
18
+ config: AnthropicAuthConfig & Record<string, any>;
19
+ debugLog: (...args: unknown[]) => void;
20
+ getAccountManager: () => AccountManager | null;
21
+ setAccountManager: (accountManager: AccountManager | null) => void;
22
+ }
23
+
24
+ export function createPluginHelpers({
25
+ client,
26
+ config,
27
+ debugLog,
28
+ getAccountManager,
29
+ setAccountManager,
30
+ }: PluginHelperDeps) {
31
+ const debouncedToastTimestamps = new Map<string, number>();
32
+
33
+ async function sendCommandMessage(sessionID: string, text: string) {
34
+ await client.session?.prompt({
35
+ path: { id: sessionID },
36
+ body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
37
+ });
38
+ }
39
+
40
+ async function reloadAccountManagerFromDisk() {
41
+ if (!getAccountManager()) return;
42
+ setAccountManager(await AccountManager.load(config, null));
43
+ }
44
+
45
+ async function persistOpenCodeAuth(refresh: string, access: string | undefined, expires: number | undefined) {
46
+ await client.auth?.set({
47
+ path: { id: "anthropic" },
48
+ body: { type: "oauth", refresh, access, expires },
49
+ });
50
+ }
51
+
52
+ async function runCliCommand(argv: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
53
+ const logs: string[] = [];
54
+ const errors: string[] = [];
55
+ let code = 1;
56
+ try {
57
+ const { main: cliMain } = await import("./cli.js");
58
+ code = await cliMain(argv, {
59
+ io: {
60
+ log: (...args: unknown[]) => logs.push(args.map(String).join(" ")),
61
+ error: (...args: unknown[]) => errors.push(args.map(String).join(" ")),
62
+ },
63
+ });
64
+ } catch (err) {
65
+ errors.push(err instanceof Error ? err.message : String(err));
66
+ }
67
+ return {
68
+ code,
69
+ stdout: stripAnsi(logs.join("\n")).trim(),
70
+ stderr: stripAnsi(errors.join("\n")).trim(),
71
+ };
72
+ }
73
+
74
+ async function toast(
75
+ message: string,
76
+ variant: "info" | "success" | "warning" | "error" = "info",
77
+ options: { debounceKey?: string } = {},
78
+ ) {
79
+ if (config.toasts.quiet && variant !== "error") return;
80
+ if (variant !== "error" && options.debounceKey) {
81
+ const minGapMs = Math.max(0, config.toasts.debounce_seconds) * 1000;
82
+ if (minGapMs > 0) {
83
+ const now = Date.now();
84
+ const lastAt = debouncedToastTimestamps.get(options.debounceKey) ?? 0;
85
+ if (now - lastAt < minGapMs) return;
86
+ if (
87
+ !debouncedToastTimestamps.has(options.debounceKey) &&
88
+ debouncedToastTimestamps.size >= DEBOUNCE_TOAST_MAP_MAX_SIZE
89
+ ) {
90
+ const oldestKey = debouncedToastTimestamps.keys().next().value;
91
+ if (oldestKey !== undefined) debouncedToastTimestamps.delete(oldestKey);
92
+ }
93
+ debouncedToastTimestamps.set(options.debounceKey, now);
94
+ }
95
+ }
96
+ try {
97
+ await client.tui?.showToast({ body: { message, variant } });
98
+ } catch (err) {
99
+ if (!(err instanceof TypeError)) debugLog("toast failed:", err);
100
+ }
101
+ }
102
+
103
+ return {
104
+ toast,
105
+ sendCommandMessage,
106
+ runCliCommand,
107
+ reloadAccountManagerFromDisk,
108
+ persistOpenCodeAuth,
109
+ };
110
+ }
111
+
112
+ export type PluginHelpers = ReturnType<typeof createPluginHelpers>;
@@ -0,0 +1,169 @@
1
+ import type { AccountManager, ManagedAccount } from "./accounts.js";
2
+ import type { AnthropicAuthConfig } from "./config.js";
3
+ import type { OpenCodeClient } from "./token-refresh.js";
4
+ import { markTokenStateUpdated, readDiskAccountAuth, refreshAccountToken } from "./token-refresh.js";
5
+
6
+ type RefreshSource = "foreground" | "idle";
7
+
8
+ type RefreshInFlightEntry = {
9
+ promise: Promise<string>;
10
+ source: RefreshSource;
11
+ };
12
+
13
+ export interface RefreshDeps {
14
+ client: OpenCodeClient;
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin config accepts forward-compatible arbitrary keys
16
+ config: AnthropicAuthConfig & Record<string, any>;
17
+ getAccountManager: () => AccountManager | null;
18
+ debugLog: (...args: unknown[]) => void;
19
+ }
20
+
21
+ export function createRefreshHelpers({ client, config, getAccountManager, debugLog }: RefreshDeps) {
22
+ const refreshInFlight = new Map<string, RefreshInFlightEntry>();
23
+ const idleRefreshLastAttempt = new Map<string, number>();
24
+ const idleRefreshInFlight = new Set<string>();
25
+
26
+ const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
27
+ const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
28
+ const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
29
+
30
+ function parseRefreshFailure(refreshError: unknown) {
31
+ const message = refreshError instanceof Error ? refreshError.message : String(refreshError);
32
+ const status =
33
+ typeof refreshError === "object" && refreshError && "status" in refreshError
34
+ ? Number((refreshError as Record<string, unknown>).status)
35
+ : NaN;
36
+ const errorCode =
37
+ typeof refreshError === "object" && refreshError && ("errorCode" in refreshError || "code" in refreshError)
38
+ ? String(
39
+ (refreshError as Record<string, unknown>).errorCode || (refreshError as Record<string, unknown>).code || "",
40
+ )
41
+ : "";
42
+ const msgLower = message.toLowerCase();
43
+ const isInvalidGrant =
44
+ errorCode === "invalid_grant" || errorCode === "invalid_request" || msgLower.includes("invalid_grant");
45
+ const isTerminalStatus = status === 400 || status === 401 || status === 403;
46
+ return { message, status, errorCode, isInvalidGrant, isTerminalStatus };
47
+ }
48
+
49
+ async function refreshAccountTokenSingleFlight(
50
+ account: ManagedAccount,
51
+ source: RefreshSource = "foreground",
52
+ ): Promise<string> {
53
+ const key = account.id;
54
+ const existing = refreshInFlight.get(key);
55
+ if (existing) {
56
+ if (source === "foreground" && existing.source === "idle") {
57
+ try {
58
+ await existing.promise;
59
+ } catch (err) {
60
+ void err;
61
+ }
62
+ if (account.access && account.expires && account.expires > Date.now()) return account.access;
63
+ const retried = refreshInFlight.get(key);
64
+ if (retried && retried !== existing) {
65
+ return retried.promise;
66
+ }
67
+ } else {
68
+ return existing.promise;
69
+ }
70
+ }
71
+
72
+ const entry: RefreshInFlightEntry = {
73
+ source,
74
+ promise: Promise.resolve(""),
75
+ };
76
+ const p = (async () => {
77
+ try {
78
+ return await refreshAccountToken(account, client, source, {
79
+ onTokensUpdated: async () => {
80
+ try {
81
+ await getAccountManager()!.saveToDisk();
82
+ } catch {
83
+ getAccountManager()!.requestSaveToDisk();
84
+ throw new Error("save failed, debounced retry scheduled");
85
+ }
86
+ },
87
+ debugLog,
88
+ });
89
+ } finally {
90
+ if (refreshInFlight.get(key) === entry) refreshInFlight.delete(key);
91
+ }
92
+ })();
93
+ entry.promise = p;
94
+ refreshInFlight.set(key, entry);
95
+ return p;
96
+ }
97
+
98
+ async function refreshIdleAccount(account: ManagedAccount) {
99
+ if (!getAccountManager()) return;
100
+ if (idleRefreshInFlight.has(account.id)) return;
101
+ idleRefreshInFlight.add(account.id);
102
+ const attemptedRefreshToken = account.refreshToken;
103
+ try {
104
+ try {
105
+ await refreshAccountTokenSingleFlight(account, "idle");
106
+ return;
107
+ } catch (err) {
108
+ let details = parseRefreshFailure(err);
109
+ if (!(details.isInvalidGrant || details.isTerminalStatus)) {
110
+ debugLog("idle refresh skipped after transient failure", {
111
+ accountIndex: account.index,
112
+ status: details.status,
113
+ errorCode: details.errorCode,
114
+ message: details.message,
115
+ });
116
+ return;
117
+ }
118
+ const diskAuth = await readDiskAccountAuth(account.id);
119
+ const retryToken = diskAuth?.refreshToken;
120
+ if (retryToken && retryToken !== attemptedRefreshToken && account.refreshToken === attemptedRefreshToken) {
121
+ account.refreshToken = retryToken;
122
+ if (diskAuth?.tokenUpdatedAt) account.tokenUpdatedAt = diskAuth.tokenUpdatedAt;
123
+ else markTokenStateUpdated(account);
124
+ }
125
+ try {
126
+ await refreshAccountTokenSingleFlight(account, "idle");
127
+ } catch (retryErr) {
128
+ details = parseRefreshFailure(retryErr);
129
+ debugLog("idle refresh retry failed", {
130
+ accountIndex: account.index,
131
+ status: details.status,
132
+ errorCode: details.errorCode,
133
+ message: details.message,
134
+ });
135
+ }
136
+ }
137
+ } finally {
138
+ idleRefreshInFlight.delete(account.id);
139
+ }
140
+ }
141
+
142
+ function maybeRefreshIdleAccounts(activeAccount: ManagedAccount) {
143
+ const accountManager = getAccountManager();
144
+ if (!IDLE_REFRESH_ENABLED || !accountManager) return;
145
+ const now = Date.now();
146
+ const excluded = new Set([activeAccount.index]);
147
+ const candidates = accountManager
148
+ .getEnabledAccounts(excluded)
149
+ .filter((acc) => !acc.expires || acc.expires <= now + IDLE_REFRESH_WINDOW_MS)
150
+ .filter((acc) => {
151
+ const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
152
+ return now - last >= IDLE_REFRESH_MIN_INTERVAL_MS;
153
+ })
154
+ .sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
155
+ const target = candidates[0];
156
+ if (!target) return;
157
+ idleRefreshLastAttempt.set(target.id, now);
158
+ void refreshIdleAccount(target);
159
+ }
160
+
161
+ return {
162
+ parseRefreshFailure,
163
+ refreshAccountTokenSingleFlight,
164
+ refreshIdleAccount,
165
+ maybeRefreshIdleAccounts,
166
+ };
167
+ }
168
+
169
+ export type RefreshHelpers = ReturnType<typeof createRefreshHelpers>;