opencode-anthropic-fix 0.0.42 → 0.0.44
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/index.mjs +402 -174
- package/lib/account-state.mjs +3 -0
- package/lib/accounts.mjs +22 -5
- package/lib/backoff.mjs +5 -2
- package/lib/backoff.test.mjs +62 -13
- package/lib/cc-credentials.mjs +4 -0
- package/lib/config.mjs +160 -0
- package/lib/oauth.mjs +1 -4
- package/lib/refresh-lock.mjs +10 -1
- package/lib/rotation.mjs +3 -2
- package/lib/storage.mjs +3 -2
- package/lib/storage.test.mjs +4 -0
- package/package.json +1 -1
package/lib/account-state.mjs
CHANGED
|
@@ -15,6 +15,9 @@ export function resetAccountTracking(account) {
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Normalize active index after removing one account.
|
|
18
|
+
* NOTE: This operates on the disk-level AccountStorage shape (activeIndex >= 0).
|
|
19
|
+
* AccountManager.removeAccount has its own inline variant using #currentIndex = -1
|
|
20
|
+
* for empty accounts (runtime sentinel). The two are intentionally different.
|
|
18
21
|
* @param {AccountStorage} storage
|
|
19
22
|
* @param {number} removedIndex
|
|
20
23
|
*/
|
package/lib/accounts.mjs
CHANGED
|
@@ -154,7 +154,7 @@ export class AccountManager {
|
|
|
154
154
|
const now = Date.now();
|
|
155
155
|
manager.#accounts = [
|
|
156
156
|
{
|
|
157
|
-
id: `${now}:${authFallback.refresh
|
|
157
|
+
id: `${now}:${hashTokenFragment(authFallback.refresh)}`,
|
|
158
158
|
index: 0,
|
|
159
159
|
email: undefined,
|
|
160
160
|
refreshToken: authFallback.refresh,
|
|
@@ -433,6 +433,20 @@ export class AccountManager {
|
|
|
433
433
|
return backoffMs;
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Mark an account for preemptive switching (quota burn rate high, but request succeeded).
|
|
438
|
+
* Unlike markRateLimited, this does NOT increment consecutiveFailures or record a health penalty.
|
|
439
|
+
* It only applies a short cooldown to encourage the rotation strategy to pick a different account.
|
|
440
|
+
* @param {ManagedAccount} account
|
|
441
|
+
* @param {number|null} [cooldownMs] - Optional cooldown in ms; defaults to 60s
|
|
442
|
+
*/
|
|
443
|
+
markPreemptiveSwitch(account, cooldownMs = 60_000) {
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
account.rateLimitResetTimes[RATE_LIMIT_KEY] = now + (cooldownMs ?? 60_000);
|
|
446
|
+
// Do NOT increment consecutiveFailures or record health penalty — the request succeeded.
|
|
447
|
+
this.requestSaveToDisk();
|
|
448
|
+
}
|
|
449
|
+
|
|
436
450
|
/**
|
|
437
451
|
* Mark a successful request for an account.
|
|
438
452
|
* @param {ManagedAccount} account
|
|
@@ -559,7 +573,8 @@ export class AccountManager {
|
|
|
559
573
|
this.#currentIndex = this.#accounts.length - 1;
|
|
560
574
|
}
|
|
561
575
|
if (this.#cursor > 0) {
|
|
562
|
-
|
|
576
|
+
// QA fix L-cursor: clamp to valid range instead of allowing cursor === length
|
|
577
|
+
this.#cursor = this.#accounts.length > 0 ? this.#cursor % this.#accounts.length : 0;
|
|
563
578
|
}
|
|
564
579
|
}
|
|
565
580
|
|
|
@@ -744,9 +759,11 @@ export class AccountManager {
|
|
|
744
759
|
const capturedDeltaKeys = new Set(this.#statsDeltas.keys());
|
|
745
760
|
|
|
746
761
|
// QA fix M15: pass pre-loaded disk data to saveAccounts to avoid TOCTOU double-read
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
762
|
+
// QA fix L-preload: omit activeIndex from preloaded disk data since saveAccounts
|
|
763
|
+
// only uses disk.accounts for auth freshness merge (not disk.activeIndex).
|
|
764
|
+
// The in-memory currentIndex was misleading here — the real disk activeIndex is
|
|
765
|
+
// carried by the first arg (storage.activeIndex).
|
|
766
|
+
const preloadedDiskData = diskAccountsById ? { version: 1, accounts: [...diskAccountsById.values()] } : null;
|
|
750
767
|
await saveAccounts(storage, preloadedDiskData);
|
|
751
768
|
|
|
752
769
|
// Clear only the captured deltas; new ones added during await are preserved
|
package/lib/backoff.mjs
CHANGED
|
@@ -138,10 +138,13 @@ function bodyHasAccountError(body) {
|
|
|
138
138
|
"not authorized",
|
|
139
139
|
];
|
|
140
140
|
|
|
141
|
+
// QA fix: only match signals against error type and parsed message fields,
|
|
142
|
+
// not the full JSON body text — common words like "billing", "permission" in
|
|
143
|
+
// unrelated JSON fields (e.g. resource URLs) cause false account-specific
|
|
144
|
+
// classification, triggering unnecessary account rotation.
|
|
141
145
|
return (
|
|
142
146
|
typeSignals.some((signal) => errorType.includes(signal)) ||
|
|
143
|
-
messageSignals.some((signal) => message.includes(signal))
|
|
144
|
-
messageSignals.some((signal) => text.includes(signal))
|
|
147
|
+
messageSignals.some((signal) => message.includes(signal))
|
|
145
148
|
);
|
|
146
149
|
}
|
|
147
150
|
|
package/lib/backoff.test.mjs
CHANGED
|
@@ -18,24 +18,63 @@ describe("isAccountSpecificError", () => {
|
|
|
18
18
|
expect(isAccountSpecificError(401, null)).toBe(true);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it("returns true for 400 with rate limit language in
|
|
22
|
-
expect(
|
|
21
|
+
it("returns true for 400 with rate limit language in JSON error message", () => {
|
|
22
|
+
expect(
|
|
23
|
+
isAccountSpecificError(
|
|
24
|
+
400,
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
error: { type: "invalid_request_error", message: "This request would exceed your account's rate limit" },
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns true for 400 with quota language in JSON error message", () => {
|
|
33
|
+
expect(
|
|
34
|
+
isAccountSpecificError(
|
|
35
|
+
400,
|
|
36
|
+
JSON.stringify({ error: { type: "invalid_request_error", message: "Your quota has been exhausted" } }),
|
|
37
|
+
),
|
|
38
|
+
).toBe(true);
|
|
23
39
|
});
|
|
24
40
|
|
|
25
|
-
it("returns true for 400 with
|
|
26
|
-
expect(
|
|
41
|
+
it("returns true for 400 with credit balance language in JSON error message", () => {
|
|
42
|
+
expect(
|
|
43
|
+
isAccountSpecificError(
|
|
44
|
+
400,
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
error: { type: "invalid_request_error", message: "Your credit balance is too low to complete this request" },
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
).toBe(true);
|
|
27
50
|
});
|
|
28
51
|
|
|
29
|
-
it("returns true for 400 with
|
|
30
|
-
expect(
|
|
52
|
+
it("returns true for 400 with billing language in JSON error message", () => {
|
|
53
|
+
expect(
|
|
54
|
+
isAccountSpecificError(
|
|
55
|
+
400,
|
|
56
|
+
JSON.stringify({ error: { type: "billing_error", message: "Billing issue on your account" } }),
|
|
57
|
+
),
|
|
58
|
+
).toBe(true);
|
|
31
59
|
});
|
|
32
60
|
|
|
33
|
-
it("returns true for
|
|
34
|
-
expect(
|
|
61
|
+
it("returns true for 403 with permission language in JSON error message", () => {
|
|
62
|
+
expect(
|
|
63
|
+
isAccountSpecificError(
|
|
64
|
+
403,
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
error: { type: "permission_error", message: "You do not have permission to access this model" },
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
).toBe(true);
|
|
35
70
|
});
|
|
36
71
|
|
|
37
|
-
it("returns
|
|
38
|
-
|
|
72
|
+
it("returns false for plain text body without JSON structure (no false positives)", () => {
|
|
73
|
+
// Plain text bodies that happen to contain keywords should NOT match
|
|
74
|
+
// — only structured error.type and error.message fields are checked
|
|
75
|
+
expect(isAccountSpecificError(400, "This request would exceed your account's rate limit")).toBe(false);
|
|
76
|
+
expect(isAccountSpecificError(400, "Billing issue on your account")).toBe(false);
|
|
77
|
+
expect(isAccountSpecificError(403, "You do not have permission to access this model")).toBe(false);
|
|
39
78
|
});
|
|
40
79
|
|
|
41
80
|
it("returns true for 403 permission_error type in JSON body", () => {
|
|
@@ -86,9 +125,19 @@ describe("isAccountSpecificError", () => {
|
|
|
86
125
|
expect(isAccountSpecificError(200, null)).toBe(false);
|
|
87
126
|
});
|
|
88
127
|
|
|
89
|
-
it("is case-insensitive for
|
|
90
|
-
expect(
|
|
91
|
-
|
|
128
|
+
it("is case-insensitive for JSON error message matching", () => {
|
|
129
|
+
expect(
|
|
130
|
+
isAccountSpecificError(
|
|
131
|
+
400,
|
|
132
|
+
JSON.stringify({ error: { type: "invalid_request_error", message: "RATE LIMIT exceeded" } }),
|
|
133
|
+
),
|
|
134
|
+
).toBe(true);
|
|
135
|
+
expect(
|
|
136
|
+
isAccountSpecificError(
|
|
137
|
+
400,
|
|
138
|
+
JSON.stringify({ error: { type: "invalid_request_error", message: "QUOTA Exhausted" } }),
|
|
139
|
+
),
|
|
140
|
+
).toBe(true);
|
|
92
141
|
});
|
|
93
142
|
});
|
|
94
143
|
|
package/lib/cc-credentials.mjs
CHANGED
|
@@ -226,6 +226,10 @@ export function parseCCCredentialData(jsonStr, source, label) {
|
|
|
226
226
|
* @returns {CCCredential | null}
|
|
227
227
|
*/
|
|
228
228
|
function shapeToCCCredential(data, source, label) {
|
|
229
|
+
// QA fix: filter MCP-scoped credentials — they're not valid for API requests.
|
|
230
|
+
// The file reader already checks this, but keychain entries bypass that check.
|
|
231
|
+
if (data.scope === "mcp") return null;
|
|
232
|
+
|
|
229
233
|
const accessToken = typeof data.accessToken === "string" ? data.accessToken : null;
|
|
230
234
|
const refreshToken = typeof data.refreshToken === "string" ? data.refreshToken : null;
|
|
231
235
|
|
package/lib/config.mjs
CHANGED
|
@@ -67,6 +67,14 @@ import { randomBytes } from "node:crypto";
|
|
|
67
67
|
* @property {boolean} prefer_over_oauth - When true, CC credentials are tried before plugin-managed OAuth accounts. Default: false.
|
|
68
68
|
*/
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} HeaderConfig
|
|
72
|
+
* @property {string} emulation_profile - Claude Code version to emulate in headers
|
|
73
|
+
* @property {Record<string, string>} overrides - Custom header overrides
|
|
74
|
+
* @property {string[]} disable - Headers to disable/remove
|
|
75
|
+
* @property {boolean} billing_header - Whether to include billing header in system prompt
|
|
76
|
+
*/
|
|
77
|
+
|
|
70
78
|
/**
|
|
71
79
|
* @typedef {object} AnthropicAuthConfig
|
|
72
80
|
* @property {AccountSelectionStrategy} account_selection_strategy
|
|
@@ -515,6 +523,158 @@ function validateConfig(raw) {
|
|
|
515
523
|
}
|
|
516
524
|
}
|
|
517
525
|
|
|
526
|
+
// Willow mode sub-config
|
|
527
|
+
if (raw.willow_mode && typeof raw.willow_mode === "object") {
|
|
528
|
+
const wm = /** @type {Record<string, unknown>} */ (raw.willow_mode);
|
|
529
|
+
config.willow_mode = {
|
|
530
|
+
enabled: typeof wm.enabled === "boolean" ? wm.enabled : DEFAULT_CONFIG.willow_mode.enabled,
|
|
531
|
+
idle_threshold_minutes: clampNumber(
|
|
532
|
+
wm.idle_threshold_minutes,
|
|
533
|
+
1,
|
|
534
|
+
24 * 60,
|
|
535
|
+
DEFAULT_CONFIG.willow_mode.idle_threshold_minutes,
|
|
536
|
+
),
|
|
537
|
+
cooldown_minutes: clampNumber(wm.cooldown_minutes, 1, 24 * 60, DEFAULT_CONFIG.willow_mode.cooldown_minutes),
|
|
538
|
+
min_turns_before_suggest: clampNumber(
|
|
539
|
+
wm.min_turns_before_suggest,
|
|
540
|
+
0,
|
|
541
|
+
50,
|
|
542
|
+
DEFAULT_CONFIG.willow_mode.min_turns_before_suggest,
|
|
543
|
+
),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Token economy sub-config
|
|
548
|
+
if (raw.token_economy && typeof raw.token_economy === "object") {
|
|
549
|
+
const te = /** @type {Record<string, unknown>} */ (raw.token_economy);
|
|
550
|
+
config.token_economy = {
|
|
551
|
+
token_efficient_tools:
|
|
552
|
+
typeof te.token_efficient_tools === "boolean"
|
|
553
|
+
? te.token_efficient_tools
|
|
554
|
+
: DEFAULT_CONFIG.token_economy.token_efficient_tools,
|
|
555
|
+
redact_thinking:
|
|
556
|
+
typeof te.redact_thinking === "boolean" ? te.redact_thinking : DEFAULT_CONFIG.token_economy.redact_thinking,
|
|
557
|
+
connector_text_summarization:
|
|
558
|
+
typeof te.connector_text_summarization === "boolean"
|
|
559
|
+
? te.connector_text_summarization
|
|
560
|
+
: DEFAULT_CONFIG.token_economy.connector_text_summarization,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Output cap sub-config
|
|
565
|
+
if (raw.output_cap && typeof raw.output_cap === "object") {
|
|
566
|
+
const oc = /** @type {Record<string, unknown>} */ (raw.output_cap);
|
|
567
|
+
config.output_cap = {
|
|
568
|
+
enabled: typeof oc.enabled === "boolean" ? oc.enabled : DEFAULT_CONFIG.output_cap.enabled,
|
|
569
|
+
default_max_tokens: clampNumber(
|
|
570
|
+
oc.default_max_tokens,
|
|
571
|
+
256,
|
|
572
|
+
200_000,
|
|
573
|
+
DEFAULT_CONFIG.output_cap.default_max_tokens,
|
|
574
|
+
),
|
|
575
|
+
escalated_max_tokens: clampNumber(
|
|
576
|
+
oc.escalated_max_tokens,
|
|
577
|
+
1_000,
|
|
578
|
+
200_000,
|
|
579
|
+
DEFAULT_CONFIG.output_cap.escalated_max_tokens,
|
|
580
|
+
),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Preconnect sub-config
|
|
585
|
+
if (raw.preconnect && typeof raw.preconnect === "object") {
|
|
586
|
+
const pc = /** @type {Record<string, unknown>} */ (raw.preconnect);
|
|
587
|
+
config.preconnect = {
|
|
588
|
+
enabled: typeof pc.enabled === "boolean" ? pc.enabled : DEFAULT_CONFIG.preconnect.enabled,
|
|
589
|
+
timeout_ms: clampNumber(pc.timeout_ms, 100, 60_000, DEFAULT_CONFIG.preconnect.timeout_ms),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Overflow recovery sub-config
|
|
594
|
+
if (raw.overflow_recovery && typeof raw.overflow_recovery === "object") {
|
|
595
|
+
const ovf = /** @type {Record<string, unknown>} */ (raw.overflow_recovery);
|
|
596
|
+
config.overflow_recovery = {
|
|
597
|
+
enabled: typeof ovf.enabled === "boolean" ? ovf.enabled : DEFAULT_CONFIG.overflow_recovery.enabled,
|
|
598
|
+
safety_margin: clampNumber(ovf.safety_margin, 0, 10_000, DEFAULT_CONFIG.overflow_recovery.safety_margin),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Cache break detection sub-config
|
|
603
|
+
if (raw.cache_break_detection && typeof raw.cache_break_detection === "object") {
|
|
604
|
+
const cbd = /** @type {Record<string, unknown>} */ (raw.cache_break_detection);
|
|
605
|
+
config.cache_break_detection = {
|
|
606
|
+
enabled: typeof cbd.enabled === "boolean" ? cbd.enabled : DEFAULT_CONFIG.cache_break_detection.enabled,
|
|
607
|
+
alert_threshold: clampNumber(
|
|
608
|
+
cbd.alert_threshold,
|
|
609
|
+
0,
|
|
610
|
+
1_000_000,
|
|
611
|
+
DEFAULT_CONFIG.cache_break_detection.alert_threshold,
|
|
612
|
+
),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Request classification sub-config
|
|
617
|
+
if (raw.request_classification && typeof raw.request_classification === "object") {
|
|
618
|
+
const rc = /** @type {Record<string, unknown>} */ (raw.request_classification);
|
|
619
|
+
config.request_classification = {
|
|
620
|
+
enabled: typeof rc.enabled === "boolean" ? rc.enabled : DEFAULT_CONFIG.request_classification.enabled,
|
|
621
|
+
background_max_service_retries: clampNumber(
|
|
622
|
+
rc.background_max_service_retries,
|
|
623
|
+
0,
|
|
624
|
+
10,
|
|
625
|
+
DEFAULT_CONFIG.request_classification.background_max_service_retries,
|
|
626
|
+
),
|
|
627
|
+
background_max_should_retries: clampNumber(
|
|
628
|
+
rc.background_max_should_retries,
|
|
629
|
+
0,
|
|
630
|
+
10,
|
|
631
|
+
DEFAULT_CONFIG.request_classification.background_max_should_retries,
|
|
632
|
+
),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Token budget sub-config
|
|
637
|
+
if (raw.token_budget && typeof raw.token_budget === "object") {
|
|
638
|
+
const tbg = /** @type {Record<string, unknown>} */ (raw.token_budget);
|
|
639
|
+
config.token_budget = {
|
|
640
|
+
enabled: typeof tbg.enabled === "boolean" ? tbg.enabled : DEFAULT_CONFIG.token_budget.enabled,
|
|
641
|
+
default: clampNumber(tbg.default, 0, 1_000_000, DEFAULT_CONFIG.token_budget.default),
|
|
642
|
+
completion_threshold: clampNumber(
|
|
643
|
+
tbg.completion_threshold,
|
|
644
|
+
0,
|
|
645
|
+
1,
|
|
646
|
+
DEFAULT_CONFIG.token_budget.completion_threshold,
|
|
647
|
+
),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Microcompact sub-config
|
|
652
|
+
if (raw.microcompact && typeof raw.microcompact === "object") {
|
|
653
|
+
const mc = /** @type {Record<string, unknown>} */ (raw.microcompact);
|
|
654
|
+
config.microcompact = {
|
|
655
|
+
enabled: typeof mc.enabled === "boolean" ? mc.enabled : DEFAULT_CONFIG.microcompact.enabled,
|
|
656
|
+
threshold_percent: clampNumber(mc.threshold_percent, 0, 100, DEFAULT_CONFIG.microcompact.threshold_percent),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Overload recovery sub-config
|
|
661
|
+
if (raw.overload_recovery && typeof raw.overload_recovery === "object") {
|
|
662
|
+
const ovl = /** @type {Record<string, unknown>} */ (raw.overload_recovery);
|
|
663
|
+
config.overload_recovery = {
|
|
664
|
+
enabled: typeof ovl.enabled === "boolean" ? ovl.enabled : DEFAULT_CONFIG.overload_recovery.enabled,
|
|
665
|
+
default_cooldown_ms: clampNumber(
|
|
666
|
+
ovl.default_cooldown_ms,
|
|
667
|
+
1_000,
|
|
668
|
+
3_600_000,
|
|
669
|
+
DEFAULT_CONFIG.overload_recovery.default_cooldown_ms,
|
|
670
|
+
),
|
|
671
|
+
poll_quota_on_overload:
|
|
672
|
+
typeof ovl.poll_quota_on_overload === "boolean"
|
|
673
|
+
? ovl.poll_quota_on_overload
|
|
674
|
+
: DEFAULT_CONFIG.overload_recovery.poll_quota_on_overload,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
518
678
|
return config;
|
|
519
679
|
}
|
|
520
680
|
|
package/lib/oauth.mjs
CHANGED
|
@@ -269,6 +269,7 @@ export async function exchange(code, verifier) {
|
|
|
269
269
|
break;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// Defensive guard — control flow guarantees result is always set, but kept for safety
|
|
272
273
|
if (!result) {
|
|
273
274
|
return fail(undefined, "Token exchange request did not complete");
|
|
274
275
|
}
|
|
@@ -336,7 +337,6 @@ export async function revoke(refreshToken) {
|
|
|
336
337
|
export async function refreshToken(refreshTokenValue, options = {}) {
|
|
337
338
|
// QA fix M13: default 30s timeout to prevent hanging on unresponsive server
|
|
338
339
|
const signal = options.signal ?? AbortSignal.timeout(30_000);
|
|
339
|
-
let lastError = null;
|
|
340
340
|
|
|
341
341
|
for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
|
|
342
342
|
let resp;
|
|
@@ -357,7 +357,6 @@ export async function refreshToken(refreshTokenValue, options = {}) {
|
|
|
357
357
|
signal,
|
|
358
358
|
});
|
|
359
359
|
} catch (err) {
|
|
360
|
-
lastError = err;
|
|
361
360
|
if (attempt < OAUTH_MAX_RETRIES) {
|
|
362
361
|
await sleep(Math.min(500 * 2 ** attempt, 2000));
|
|
363
362
|
continue;
|
|
@@ -393,8 +392,6 @@ export async function refreshToken(refreshTokenValue, options = {}) {
|
|
|
393
392
|
|
|
394
393
|
return resp.json();
|
|
395
394
|
}
|
|
396
|
-
|
|
397
|
-
throw lastError instanceof Error ? lastError : new Error("Token refresh failed after retries");
|
|
398
395
|
}
|
|
399
396
|
|
|
400
397
|
/**
|
package/lib/refresh-lock.mjs
CHANGED
|
@@ -60,7 +60,16 @@ export async function acquireRefreshLock(accountId, options = {}) {
|
|
|
60
60
|
try {
|
|
61
61
|
const stat = await fs.stat(lockPath);
|
|
62
62
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
63
|
-
|
|
63
|
+
// QA fix L-toctou: re-stat after potential concurrent lock renewal
|
|
64
|
+
// to reduce the TOCTOU window. If mtime changed, skip the unlink.
|
|
65
|
+
try {
|
|
66
|
+
const reStat = await fs.stat(lockPath);
|
|
67
|
+
if (Date.now() - reStat.mtimeMs > staleMs) {
|
|
68
|
+
await fs.unlink(lockPath);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// File vanished between stat calls — another process released it
|
|
72
|
+
}
|
|
64
73
|
continue;
|
|
65
74
|
}
|
|
66
75
|
} catch {
|
package/lib/rotation.mjs
CHANGED
|
@@ -216,8 +216,9 @@ 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
|
-
// QA fix M17
|
|
220
|
-
|
|
219
|
+
// QA fix M17+: lastUsed=0 means never-used; treat as max freshness (3600s cap)
|
|
220
|
+
// so new accounts aren't perpetually ranked below recently-active ones.
|
|
221
|
+
const secondsSinceUsed = account.lastUsed > 0 ? (Date.now() - account.lastUsed) / 1000 : 3600;
|
|
221
222
|
const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1;
|
|
222
223
|
|
|
223
224
|
return Math.max(0, healthComponent + tokenComponent + freshnessComponent);
|
package/lib/storage.mjs
CHANGED
|
@@ -261,7 +261,7 @@ export async function loadAccounts() {
|
|
|
261
261
|
* @param {AccountStorage} storage
|
|
262
262
|
* @returns {Promise<void>}
|
|
263
263
|
*/
|
|
264
|
-
export async function saveAccounts(storage) {
|
|
264
|
+
export async function saveAccounts(storage, storageOrDiskData = undefined) {
|
|
265
265
|
const storagePath = getStoragePath();
|
|
266
266
|
const configDir = dirname(storagePath);
|
|
267
267
|
|
|
@@ -276,8 +276,9 @@ export async function saveAccounts(storage) {
|
|
|
276
276
|
// Merge auth fields against disk by freshness to avoid stale-process clobber.
|
|
277
277
|
// We do not resurrect removed accounts; only merge for accounts present in
|
|
278
278
|
// the incoming storage payload.
|
|
279
|
+
// QA fix: declare diskData param explicitly instead of hidden arguments[1].
|
|
279
280
|
try {
|
|
280
|
-
const disk =
|
|
281
|
+
const disk = storageOrDiskData ?? (await loadAccounts());
|
|
281
282
|
if (disk && storage.accounts.length > 0) {
|
|
282
283
|
const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
|
|
283
284
|
/** @type {Map<number, AccountMetadata[]>} */
|
package/lib/storage.test.mjs
CHANGED
|
@@ -211,12 +211,16 @@ describe("loadAccounts", () => {
|
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
it("returns best-effort data for newer version (forward compat)", async () => {
|
|
214
|
+
// QA fix L-warnSpy: mock console.warn to prevent test output contamination
|
|
215
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
214
216
|
fs.readFile.mockResolvedValue(JSON.stringify({ version: 99, accounts: [] }));
|
|
215
217
|
const result = await loadAccounts();
|
|
216
218
|
// H12 fix: no longer returns null; attempts best-effort load with a warning
|
|
217
219
|
expect(result).not.toBeNull();
|
|
218
220
|
expect(result.accounts).toEqual([]);
|
|
219
221
|
expect(result.version).toBe(1); // normalized to current version
|
|
222
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
223
|
+
warnSpy.mockRestore();
|
|
220
224
|
});
|
|
221
225
|
|
|
222
226
|
it("returns null when accounts is not an array", async () => {
|