@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -33,6 +33,7 @@ import { existsSync, statSync } from "node:fs";
33
33
  import { env, platform } from "node:process";
34
34
  import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
35
35
  import {
36
+ cachedCurrentWindowsIdentity,
36
37
  resolveCurrentWindowsPrincipal,
37
38
  resolveCurrentWindowsPrincipalAsync,
38
39
  setSyntheticWindowsPrincipalForTests,
@@ -500,6 +501,69 @@ function grantAce(user: string, directory: boolean): string {
500
501
  return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
501
502
  }
502
503
 
504
+ function existingAclIsCompliant(
505
+ targetPath: string,
506
+ directory: boolean,
507
+ stdout: string,
508
+ ownerName: string,
509
+ ): boolean {
510
+ const lines = stdout.replaceAll("\r", "").split("\n");
511
+ const first = lines.shift();
512
+ if (!first || first.slice(0, targetPath.length).toLowerCase() !== targetPath.toLowerCase()) {
513
+ return false;
514
+ }
515
+ const separator = first[targetPath.length];
516
+ if (separator !== undefined && !/\s/.test(separator)) return false;
517
+
518
+ const aceLines: string[] = [];
519
+ const firstAce = first.slice(targetPath.length).trim();
520
+ if (firstAce) aceLines.push(firstAce);
521
+ for (const line of lines) {
522
+ if (!line.trim()) break;
523
+ // Localized summary text is not indented like a continuation ACE.
524
+ if (!/^\s/.test(line)) break;
525
+ aceLines.push(line.trim());
526
+ }
527
+ if (aceLines.length !== 1) return false;
528
+
529
+ const match = /^([^:]+):((?:\([A-Z]+\))+)$/.exec(aceLines[0]!);
530
+ if (!match || match[1]!.trim().toLowerCase() !== ownerName.toLowerCase()) return false;
531
+ const rights = [...match[2]!.matchAll(/\(([A-Z]+)\)/g)].map(part => part[1]);
532
+ const expected = directory ? ["OI", "CI", "F"] : ["F"];
533
+ return rights.length === expected.length && rights.every((right, index) => right === expected[index]);
534
+ }
535
+
536
+ function shouldVerifyExistingAcl(): boolean {
537
+ return env["OPENCODEX_ACL_VERIFY_EXISTING"] === "1";
538
+ }
539
+
540
+ function existingAclAlreadyCompliant(targetPath: string, directory: boolean): boolean {
541
+ if (!shouldVerifyExistingAcl()) return false;
542
+ const identity = cachedCurrentWindowsIdentity();
543
+ if (!identity) return false;
544
+ try {
545
+ const result = icaclsRunner([targetPath], resolveHardenDeadlineMs());
546
+ return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
547
+ } catch {
548
+ return false;
549
+ }
550
+ }
551
+
552
+ async function existingAclAlreadyCompliantAsync(
553
+ targetPath: string,
554
+ directory: boolean,
555
+ ): Promise<boolean> {
556
+ if (!shouldVerifyExistingAcl()) return false;
557
+ const identity = cachedCurrentWindowsIdentity();
558
+ if (!identity) return false;
559
+ try {
560
+ const result = await asyncIcaclsRunner([targetPath], resolveHardenDeadlineMs());
561
+ return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
562
+ } catch {
563
+ return false;
564
+ }
565
+ }
566
+
503
567
  function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
504
568
  const principal = currentWindowsPrincipal(deadline);
505
569
 
@@ -724,6 +788,7 @@ function hardenEntry(
724
788
  if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
725
789
  if (effectivePlatform() !== "win32") return { ok: true };
726
790
  if (memoSatisfied(cache, targetPath)) return { ok: true };
791
+ if (existingAclAlreadyCompliant(targetPath, directory)) return { ok: true };
727
792
  const memoKey = timeoutMemoKey(targetPath, opts);
728
793
  const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
729
794
  if (timeoutMemoError) {
@@ -776,6 +841,7 @@ async function hardenEntryAsync(
776
841
  if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
777
842
  if (effectivePlatform() !== "win32") return { ok: true };
778
843
  if (memoSatisfied(cache, targetPath)) return { ok: true };
844
+ if (await existingAclAlreadyCompliantAsync(targetPath, directory)) return { ok: true };
779
845
  const memoKey = timeoutMemoKey(targetPath, opts);
780
846
  const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
781
847
  if (timeoutMemoError) {
@@ -53,14 +53,40 @@ function decodeUtf16Be(buffer: Uint8Array): string {
53
53
  * Western fallback deliberately narrow: treating CP932, CP1250, or CP1251
54
54
  * bytes as Windows-1252 can fabricate a different valid-looking filesystem
55
55
  * path, which is worse than the previous replacement-character refusal.
56
+ *
57
+ * The CJK double-byte pages are named for the same reason `euc-kr` is: they are
58
+ * the ANSI code page on their own hosts, they are unambiguous for that language
59
+ * tag, and `decodeStrict` rejects a mismatch instead of inventing a path. A
60
+ * zh-CN host's schtasks stderr is CP936 (`gbk`), which the UTF-8 attempt above
61
+ * fails on and which previously fell through to a lossy UTF-8 decode — the
62
+ * mojibake made every localized message unmatchable (#2914).
63
+ *
64
+ * zh-Hant is a separate page (`big5`), not a variant of the same one, so the
65
+ * region subtag decides: `zh-TW`/`zh-HK`/`zh-MO` are Big5, bare `zh` and
66
+ * `zh-CN`/`zh-SG` are GBK. Guessing wrong here is exactly the fabricated-path
67
+ * risk the Western note describes, so an unrecognized `zh-*` region keeps the
68
+ * mainland default rather than trying both.
56
69
  */
57
- function legacyEncodingForLocale(locale: string): "euc-kr" | "windows-1252" | null {
58
- const language = locale.trim().split(/[-_]/, 1)[0]?.toLowerCase();
70
+ function legacyEncodingForLocale(locale: string): LegacyWindowsEncoding | null {
71
+ const parts = locale.trim().split(/[-_]/);
72
+ const language = parts[0]?.toLowerCase();
59
73
  if (language === "ko") return "euc-kr";
74
+ if (language === "ja") return "shift_jis";
75
+ if (language === "zh") return traditionalChineseRegion(parts) ? "big5" : "gbk";
60
76
  if (language && WINDOWS_1252_LANGUAGES.has(language)) return "windows-1252";
61
77
  return null;
62
78
  }
63
79
 
80
+ type LegacyWindowsEncoding = "euc-kr" | "shift_jis" | "gbk" | "big5" | "windows-1252";
81
+
82
+ /** `zh-Hant`, or a region that ships Big5 as its ANSI code page. */
83
+ function traditionalChineseRegion(parts: readonly string[]): boolean {
84
+ return parts.slice(1).some(part => {
85
+ const tag = part.toLowerCase();
86
+ return tag === "hant" || tag === "tw" || tag === "hk" || tag === "mo";
87
+ });
88
+ }
89
+
64
90
  const WINDOWS_1252_LANGUAGES = new Set([
65
91
  "af", "br", "ca", "co", "cy", "da", "de", "en", "es", "eu", "fi", "fo", "fr",
66
92
  "ga", "gd", "gl", "id", "is", "it", "lb", "ms", "nl", "no", "oc", "pt", "sq",
@@ -29,8 +29,8 @@ import {
29
29
  } from "./windows-elevation";
30
30
 
31
31
  const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
32
- const SID_EXPRESSION =
33
- "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
32
+ const IDENTITY_EXPRESSION =
33
+ "$identity=[System.Security.Principal.WindowsIdentity]::GetCurrent();$identity.User.Value;$identity.Name";
34
34
  const DEFAULT_WINDOWS_ARM64_POWERSHELL = windowsPath.join(
35
35
  "C:\\Windows\\System32",
36
36
  "WindowsPowerShell",
@@ -106,7 +106,7 @@ const POWERSHELL_ARGS = [
106
106
  "-NoProfile",
107
107
  "-NonInteractive",
108
108
  "-Command",
109
- SID_EXPRESSION,
109
+ IDENTITY_EXPRESSION,
110
110
  ] as const;
111
111
 
112
112
  function windowsPrincipalPowerShellCommand(): string[] {
@@ -167,7 +167,12 @@ async function defaultAsyncWindowsPrincipalRunner(
167
167
 
168
168
  let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner;
169
169
  let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner;
170
- let cachedPrincipal: string | null = null;
170
+ export interface WindowsPrincipalIdentity {
171
+ readonly sid: string;
172
+ readonly name: string;
173
+ }
174
+
175
+ let cachedIdentity: WindowsPrincipalIdentity | null = null;
171
176
  let asyncLookupInFlight: Promise<string> | null = null;
172
177
 
173
178
  /**
@@ -191,7 +196,7 @@ let syntheticPrincipalForTests: string | null = null;
191
196
  */
192
197
  export function setSyntheticWindowsPrincipalForTests(principal: string | null): void {
193
198
  syntheticPrincipalForTests = principal;
194
- cachedPrincipal = null;
199
+ cachedIdentity = null;
195
200
  }
196
201
 
197
202
  /** True when an explicit runner override is installed and must take precedence. */
@@ -211,17 +216,27 @@ function identityError(reason: string): NodeJS.ErrnoException {
211
216
  return error;
212
217
  }
213
218
 
214
- function principalFromResult(result: WindowsPrincipalLookupResult): string {
219
+ function identityFromResult(result: WindowsPrincipalLookupResult): WindowsPrincipalIdentity {
215
220
  if (!result.success) {
216
221
  throw identityError(result.timedOut
217
222
  ? "timed out"
218
223
  : `exited ${result.exitCode ?? "null"}`);
219
224
  }
220
- const sid = result.stdout.trim();
225
+ const lines = result.stdout.trim().split(/\r?\n/);
226
+ const sid = lines[0]?.trim() ?? "";
227
+ const name = lines[1]?.trim() ?? "";
221
228
  if (!SID_PATTERN.test(sid)) {
222
229
  throw identityError(sid ? "returned an invalid SID" : "returned an empty SID");
223
230
  }
224
- return `*${sid.toUpperCase()}`;
231
+ if (!name || lines.length !== 2) {
232
+ throw identityError(name ? "returned an ambiguous account name" : "returned an empty account name");
233
+ }
234
+ return Object.freeze({ sid: sid.toUpperCase(), name });
235
+ }
236
+
237
+ /** Read the effective-token identity only when an earlier lookup already cached it. */
238
+ export function cachedCurrentWindowsIdentity(): WindowsPrincipalIdentity | null {
239
+ return cachedIdentity;
225
240
  }
226
241
 
227
242
  /** Resolve and process-cache the effective token SID for synchronous ACL paths. */
@@ -229,7 +244,7 @@ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
229
244
  // Order matters: an explicitly injected runner outranks the synthetic value,
230
245
  // so a test can inject a FAILURE on a POSIX host. See the seam comment above.
231
246
  if (hasSyncRunnerOverride()) {
232
- if (cachedPrincipal) return cachedPrincipal;
247
+ if (cachedIdentity) return `*${cachedIdentity.sid}`;
233
248
  if (timeoutMs <= 0) throw identityError("had no remaining deadline");
234
249
  let overridden: WindowsPrincipalLookupResult;
235
250
  try {
@@ -237,11 +252,10 @@ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
237
252
  } catch {
238
253
  throw identityError("could not start");
239
254
  }
240
- const principal = principalFromResult(overridden);
241
- cachedPrincipal = principal;
242
- return principal;
255
+ cachedIdentity = identityFromResult(overridden);
256
+ return `*${cachedIdentity.sid}`;
243
257
  }
244
- if (cachedPrincipal) return cachedPrincipal;
258
+ if (cachedIdentity) return `*${cachedIdentity.sid}`;
245
259
  if (syntheticPrincipalForTests) return syntheticPrincipalForTests;
246
260
  if (timeoutMs <= 0) throw identityError("had no remaining deadline");
247
261
  let result: WindowsPrincipalLookupResult;
@@ -250,9 +264,8 @@ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
250
264
  } catch {
251
265
  throw identityError("could not start");
252
266
  }
253
- const principal = principalFromResult(result);
254
- cachedPrincipal = principal;
255
- return principal;
267
+ cachedIdentity = identityFromResult(result);
268
+ return `*${cachedIdentity.sid}`;
256
269
  }
257
270
 
258
271
  async function waitForExistingLookup(
@@ -284,7 +297,7 @@ async function waitForExistingLookup(
284
297
  */
285
298
  export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise<string> {
286
299
  const overridden = hasAsyncRunnerOverride();
287
- if (cachedPrincipal) return cachedPrincipal;
300
+ if (cachedIdentity) return `*${cachedIdentity.sid}`;
288
301
  if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs);
289
302
  // Same precedence rule as the sync path: an injected runner beats the synthetic.
290
303
  if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests;
@@ -297,9 +310,8 @@ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Pr
297
310
  } catch {
298
311
  throw identityError("could not start");
299
312
  }
300
- const principal = principalFromResult(result);
301
- cachedPrincipal = principal;
302
- return principal;
313
+ cachedIdentity = identityFromResult(result);
314
+ return `*${cachedIdentity.sid}`;
303
315
  })();
304
316
  asyncLookupInFlight = lookup;
305
317
  try {
@@ -317,7 +329,7 @@ export function setWindowsPrincipalRunnerForTests(
317
329
  throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
318
330
  }
319
331
  principalRunner = runner ?? defaultWindowsPrincipalRunner;
320
- cachedPrincipal = null;
332
+ cachedIdentity = null;
321
333
  }
322
334
 
323
335
  /** Test seam: replace the async resolver process and clear its successful cache. */
@@ -328,7 +340,7 @@ export function setAsyncWindowsPrincipalRunnerForTests(
328
340
  throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
329
341
  }
330
342
  asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner;
331
- cachedPrincipal = null;
343
+ cachedIdentity = null;
332
344
  }
333
345
 
334
346
  /** Test seam: clear only process-local principal state. */
@@ -336,6 +348,6 @@ export function resetWindowsPrincipalForTests(): void {
336
348
  if (asyncLookupInFlight) {
337
349
  throw new Error("Cannot reset the Windows principal while a lookup is in flight.");
338
350
  }
339
- cachedPrincipal = null;
351
+ cachedIdentity = null;
340
352
  syntheticPrincipalForTests = null;
341
353
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Order failover candidates by what we know about their remaining allowance.
3
+ *
4
+ * Rotation without this walks the roster blind: the account right after the one that just
5
+ * 429'd may itself be spent, so the request burns a second rotation from a budget of three
6
+ * to learn what a cached quota row already knew.
7
+ *
8
+ * Deliberately NOT a scoring function. Percentages from different providers measure
9
+ * different things, and a weight would invite tuning a number nobody can validate. Three
10
+ * categories answer the only question rotation asks — "which of these is most likely to
11
+ * serve the retry" — and within the healthy group a simple headroom sort is enough.
12
+ */
13
+ import { getCachedProviderAccountQuota } from "../providers/quota";
14
+ import { getKiroAccountExhaustion } from "../providers/kiro-usage";
15
+
16
+ /** Lower sorts earlier. Unknown sits between measured-healthy and measured-empty. */
17
+ const RANK_HEALTHY = 0;
18
+ const RANK_UNKNOWN = 1;
19
+ const RANK_EXHAUSTED = 2;
20
+
21
+ interface Ranked {
22
+ id: string;
23
+ bucket: number;
24
+ /** Remaining percentage points, descending within the healthy bucket. */
25
+ headroom: number;
26
+ /** Preserves the caller's ring order for ties. */
27
+ index: number;
28
+ }
29
+
30
+ /**
31
+ * Remaining headroom across every window the provider reports.
32
+ *
33
+ * The minimum wins: an account at 5% of its five-hour window is unusable right now even if
34
+ * its monthly allowance is barely touched.
35
+ */
36
+ function headroomOf(provider: string, accountId: string): number | null {
37
+ const quota = getCachedProviderAccountQuota(provider, accountId);
38
+ if (!quota) return null;
39
+ const percents = [
40
+ quota.fiveHourPercent,
41
+ quota.weeklyPercent,
42
+ quota.monthlyPercent,
43
+ ...(quota.customWindows ?? []).map(window => window.percent),
44
+ ].filter((value): value is number => typeof value === "number");
45
+ if (percents.length === 0) return null;
46
+ return 100 - Math.max(...percents);
47
+ }
48
+
49
+ /**
50
+ * Order candidates best-first.
51
+ *
52
+ * Returns the input untouched when no candidate has quota evidence, which keeps every
53
+ * provider without per-account quota on exactly the behaviour it has today.
54
+ */
55
+ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]): string[] {
56
+ if (ring.length < 2) return [...ring];
57
+
58
+ let sawEvidence = false;
59
+ const ranked: Ranked[] = ring.map((id, index) => {
60
+ // A provider-declared exhaustion verdict outranks the percentage: an account may sit at
61
+ // 100% and still be servable when overage is enabled, and the verdict knows that.
62
+ const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${id}`) : null;
63
+ const headroom = headroomOf(provider, id);
64
+ if (exhaustion !== null || headroom !== null) sawEvidence = true;
65
+
66
+ if (exhaustion?.exhausted === true) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
67
+ if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
68
+ return { id, bucket: RANK_HEALTHY, headroom, index };
69
+ });
70
+
71
+ if (!sawEvidence) return [...ring];
72
+
73
+ return ranked
74
+ .sort((a, b) => (a.bucket - b.bucket) || (b.headroom - a.headroom) || (a.index - b.index))
75
+ .map(entry => entry.id);
76
+ }
77
+
78
+ /**
79
+ * Do we hold any measurement at all for these accounts?
80
+ *
81
+ * Ranking a single candidate is trivially the identity, which makes it useless as an
82
+ * evidence test: a caller that has already filtered its list down to one account would be
83
+ * told "ranked" when nothing was measured. Pre-dispatch selection asks this first so it
84
+ * can decline to act on a roster it knows nothing about.
85
+ */
86
+ export function hasHeadroomEvidence(provider: string, ids: readonly string[]): boolean {
87
+ return ids.some(id =>
88
+ headroomOf(provider, id) !== null
89
+ || (provider === "kiro" && getKiroAccountExhaustion(`${provider}\u0000${id}`) !== null));
90
+ }
91
+ /**
92
+ * How long to cool an account that just 429'd, when we know its allowance is spent.
93
+ *
94
+ * A monthly-exhausted account retried every minute is pure waste, but an upstream reset
95
+ * date is not something to trust unbounded — the clamp keeps a bogus far-future value from
96
+ * parking an account for weeks, and a near-instant one from being pointless.
97
+ */
98
+ const MIN_EXHAUSTED_COOLDOWN_MS = 5 * 60_000;
99
+ const MAX_EXHAUSTED_COOLDOWN_MS = 24 * 60 * 60_000;
100
+
101
+ export function exhaustedCooldownMs(provider: string, accountId: string, now = Date.now()): number | null {
102
+ if (provider !== "kiro") return null;
103
+ const exhaustion = getKiroAccountExhaustion(`${provider}\u0000${accountId}`, now);
104
+ if (!exhaustion?.exhausted) return null;
105
+ const untilReset = exhaustion.nextResetAt === undefined ? MIN_EXHAUSTED_COOLDOWN_MS : exhaustion.nextResetAt - now;
106
+ return Math.min(Math.max(untilReset, MIN_EXHAUSTED_COOLDOWN_MS), MAX_EXHAUSTED_COOLDOWN_MS);
107
+ }
@@ -27,7 +27,7 @@ import {
27
27
  POOL_KEY_ANTHROPIC,
28
28
  seedPoolRotationAccount,
29
29
  } from "../codex/pool-rotation";
30
- import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
30
+ import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
31
31
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
32
32
  import { retainedUtf8Bytes } from "../lib/admission";
33
33
 
@@ -39,6 +39,8 @@ const MAX_AFFINITY_ENTRIES = 2_000;
39
39
  const MAX_AFFINITY_COMPONENT_BYTES = 512;
40
40
  const UNKNOWN_USAGE_SCORE = 100;
41
41
  const DEFAULT_AUTO_SWITCH_THRESHOLD = 80;
42
+ const DEFAULT_QUOTA_WINDOW: OcxAccountPoolQuotaWindow = "five-hour";
43
+ const VALID_QUOTA_WINDOWS = new Set<OcxAccountPoolQuotaWindow>(["five-hour", "weekly", "max-utilization"]);
42
44
  /** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */
43
45
  export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3;
44
46
 
@@ -50,6 +52,8 @@ export interface AnthropicAccountPoolConfig {
50
52
  strategy?: OcxAccountPoolRotationStrategy;
51
53
  /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
52
54
  stickyLimit?: number;
55
+ /** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */
56
+ quotaWindow?: OcxAccountPoolQuotaWindow;
53
57
  }
54
58
 
55
59
  interface AccountHealth {
@@ -86,6 +90,22 @@ export function anthropicAutoSwitchThreshold(config: OcxConfig): number {
86
90
  return DEFAULT_AUTO_SWITCH_THRESHOLD;
87
91
  }
88
92
 
93
+ /** Strict parse for management APIs — returns null instead of defaulting. */
94
+ export function parseAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow | null {
95
+ if (typeof raw === "string" && VALID_QUOTA_WINDOWS.has(raw as OcxAccountPoolQuotaWindow)) {
96
+ return raw as OcxAccountPoolQuotaWindow;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ export function normalizeAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow {
102
+ return parseAccountPoolQuotaWindow(raw) ?? DEFAULT_QUOTA_WINDOW;
103
+ }
104
+
105
+ export function anthropicQuotaWindow(config: AnthropicAccountPoolConfig): OcxAccountPoolQuotaWindow {
106
+ return normalizeAccountPoolQuotaWindow(config.quotaWindow);
107
+ }
108
+
89
109
  function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined {
90
110
  const text = value?.trim();
91
111
  if (!text) return undefined;
@@ -142,17 +162,56 @@ function isCooled(accountId: string, now: number): boolean {
142
162
  return getAnthropicAccountHealthSnapshot(accountId, now) !== null;
143
163
  }
144
164
 
145
- function hasKnownUsage(accountId: string): boolean {
146
- const quota = getCachedProviderAccountQuota(PROVIDER, accountId);
147
- return typeof quota?.fiveHourPercent === "number" && Number.isFinite(quota.fiveHourPercent);
165
+ function fiveHourKnown(accountId: string): boolean {
166
+ const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.fiveHourPercent;
167
+ return typeof percent === "number" && Number.isFinite(percent);
168
+ }
169
+
170
+ function weeklyKnown(accountId: string): boolean {
171
+ const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.weeklyPercent;
172
+ return typeof percent === "number" && Number.isFinite(percent);
173
+ }
174
+
175
+ function fiveHourScore(accountId: string): number {
176
+ const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.fiveHourPercent;
177
+ return typeof percent === "number" && Number.isFinite(percent)
178
+ ? Math.max(0, Math.min(100, percent))
179
+ : UNKNOWN_USAGE_SCORE;
180
+ }
181
+
182
+ function weeklyScore(accountId: string): number {
183
+ const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.weeklyPercent;
184
+ return typeof percent === "number" && Number.isFinite(percent)
185
+ ? Math.max(0, Math.min(100, percent))
186
+ : UNKNOWN_USAGE_SCORE;
148
187
  }
149
188
 
150
- function usageScore(accountId: string): number {
151
- const quota = getCachedProviderAccountQuota(PROVIDER, accountId);
152
- if (!quota || typeof quota.fiveHourPercent !== "number" || !Number.isFinite(quota.fiveHourPercent)) {
153
- return UNKNOWN_USAGE_SCORE;
189
+ function exhausted5h(accountId: string): boolean {
190
+ return fiveHourKnown(accountId) && fiveHourScore(accountId) >= 100;
191
+ }
192
+
193
+ function hasKnownUsage(config: OcxConfig, accountId: string): boolean {
194
+ const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
195
+ switch (window) {
196
+ case "five-hour": return fiveHourKnown(accountId);
197
+ case "weekly": return weeklyKnown(accountId);
198
+ case "max-utilization": return fiveHourKnown(accountId) || weeklyKnown(accountId);
199
+ }
200
+ }
201
+
202
+ function usageScore(config: OcxConfig, accountId: string): number {
203
+ const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
204
+ switch (window) {
205
+ case "five-hour": return fiveHourScore(accountId);
206
+ case "weekly": return weeklyScore(accountId);
207
+ case "max-utilization": {
208
+ const scores = [
209
+ ...(fiveHourKnown(accountId) ? [fiveHourScore(accountId)] : []),
210
+ ...(weeklyKnown(accountId) ? [weeklyScore(accountId)] : []),
211
+ ];
212
+ return scores.length > 0 ? Math.max(...scores) : UNKNOWN_USAGE_SCORE;
213
+ }
154
214
  }
155
- return Math.max(0, Math.min(100, quota.fiveHourPercent));
156
215
  }
157
216
 
158
217
  const TOKEN_SKEW_MS = 60_000;
@@ -191,20 +250,48 @@ export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | nu
191
250
  return Math.max(1, Math.ceil((earliest - now) / 1000));
192
251
  }
193
252
 
194
- function pickLowestUsage(excludeId: string | undefined, now: number): string | null {
195
- const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
253
+ interface ScoredAccount {
254
+ accountId: string;
255
+ hasKnownUsage: boolean;
256
+ score: number;
257
+ fiveHourTieBreak: number;
258
+ /** True only under the opt-in weekly window; see compareScoredAccounts. */
259
+ knownFirst: boolean;
260
+ }
261
+
262
+ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
263
+ // known-before-unknown belongs to the OPT-IN windows (weekly, max-utilization), not the
264
+ // legacy five-hour default. Applying it unconditionally changed ordering for operators who
265
+ // never opted in: an account measured at 100% would sort ahead of an unmeasured one purely
266
+ // because it had a reading. The accepted scope preserves the five-hour default exactly.
267
+ if (a.knownFirst && b.knownFirst && a.hasKnownUsage !== b.hasKnownUsage) {
268
+ return a.hasKnownUsage ? -1 : 1;
269
+ }
270
+ return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
271
+ }
272
+
273
+ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number): string | null {
274
+ const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
275
+ const unfiltered = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
276
+ const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)) : unfiltered;
277
+ const eligible = available.length > 0 ? available : unfiltered;
196
278
  if (eligible.length === 0) return null;
197
- let best = eligible[0]!;
198
- let bestScore = usageScore(best);
199
- for (let i = 1; i < eligible.length; i++) {
200
- const id = eligible[i]!;
201
- const score = usageScore(id);
202
- if (score < bestScore) {
203
- best = id;
204
- bestScore = score;
205
- }
279
+ const scored: ScoredAccount[] = eligible.map(accountId => ({
280
+ accountId,
281
+ hasKnownUsage: hasKnownUsage(config, accountId),
282
+ score: usageScore(config, accountId),
283
+ fiveHourTieBreak: window === "five-hour" ? 0 : fiveHourScore(accountId),
284
+ // Every window EXCEPT the legacy five-hour default is an explicit opt-in, so
285
+ // known-before-unknown applies to all of them and to none of the default path.
286
+ knownFirst: window !== "five-hour",
287
+ }));
288
+ let best = scored[0]!;
289
+ for (let i = 1; i < scored.length; i++) {
290
+ const candidate = scored[i]!;
291
+ // Strict `< 0` keeps the earliest eligible account on an exact tie.
292
+ if (compareScoredAccounts(candidate, best) < 0) best = candidate;
206
293
  }
207
- return best;
294
+ return best.accountId;
208
295
  }
209
296
 
210
297
  /** Next eligible Anthropic account in stable order after `afterId` (wrapping). */
@@ -213,8 +300,11 @@ function pickNextFillFirstAnthropicAccount(
213
300
  afterId: string,
214
301
  eligible: string[],
215
302
  ): string | null {
216
- if (eligible.length === 0) return null;
217
- const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
303
+ const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
304
+ const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)) : eligible;
305
+ const candidates = available.length > 0 ? available : eligible;
306
+ if (candidates.length === 0) return null;
307
+ const ordered = [...candidates].sort((a, b) => a.localeCompare(b));
218
308
  const set = getAccountSet(PROVIDER);
219
309
  const stableAll = set
220
310
  ? [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b))
@@ -230,7 +320,7 @@ function pickNextFillFirstAnthropicAccount(
230
320
  let fallback: string | null = null;
231
321
  for (let step = 1; step <= stableAll.length; step++) {
232
322
  const candidate = stableAll[(startIdx + step) % stableAll.length]!;
233
- if (!eligible.includes(candidate)) continue;
323
+ if (!candidates.includes(candidate)) continue;
234
324
  if (!fallback) fallback = candidate;
235
325
  if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
236
326
  }
@@ -250,7 +340,7 @@ function pickAlternateAnthropicAccount(
250
340
  if (strategy === "fill-first") {
251
341
  return pickNextFillFirstAnthropicAccount(config, excludeId, eligible);
252
342
  }
253
- return pickLowestUsage(excludeId, now);
343
+ return pickLowestUsage(config, excludeId, now);
254
344
  }
255
345
 
256
346
  function pruneExpiredAffinity(now: number): void {
@@ -290,9 +380,11 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg
290
380
  function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
291
381
  const threshold = anthropicAutoSwitchThreshold(config);
292
382
  if (threshold <= 0) return true;
383
+ const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
384
+ if (window === "weekly" && exhausted5h(accountId)) return false;
293
385
  // Unknown usage must not force fill-first to abandon the active account.
294
- if (!hasKnownUsage(accountId)) return true;
295
- return usageScore(accountId) < threshold;
386
+ if (!hasKnownUsage(config, accountId)) return true;
387
+ return usageScore(config, accountId) < threshold;
296
388
  }
297
389
 
298
390
  /**
@@ -412,12 +504,15 @@ export function resolveAnthropicAccountForSession(
412
504
  let reason: AnthropicAccountSelectionReason = "none";
413
505
 
414
506
  if (threshold > 0) {
507
+ const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
415
508
  // Unknown usage must NOT force a switch away from the healthy active account.
416
- if (activeOk && (!hasKnownUsage(set.activeAccountId) || usageScore(set.activeAccountId) < threshold)) {
509
+ if (activeOk
510
+ && !(window === "weekly" && exhausted5h(set.activeAccountId))
511
+ && (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) {
417
512
  accountId = set.activeAccountId;
418
513
  reason = "active";
419
514
  } else {
420
- const picked = pickLowestUsage(undefined, now);
515
+ const picked = pickLowestUsage(config, undefined, now);
421
516
  if (picked) {
422
517
  accountId = picked;
423
518
  reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage";
@@ -430,7 +525,7 @@ export function resolveAnthropicAccountForSession(
430
525
  accountId = set.activeAccountId;
431
526
  reason = "active";
432
527
  } else {
433
- const picked = pickLowestUsage(set.activeAccountId, now);
528
+ const picked = pickLowestUsage(config, set.activeAccountId, now);
434
529
  if (picked) {
435
530
  accountId = picked;
436
531
  reason = "only-eligible";
@@ -143,7 +143,10 @@ export async function loginChatGPT(ctrl: OAuthController, opts?: { forceLogin?:
143
143
 
144
144
  // Note: uses form-urlencoded per OAuth 2.0 spec (RFC 6749 §6).
145
145
  // Codex-rs uses JSON for refresh — intentional divergence; both accepted by auth.openai.com.
146
- export async function refreshChatGPTToken(refreshToken: string): Promise<OAuthCredentials> {
146
+ export async function refreshChatGPTToken(
147
+ refreshToken: string,
148
+ options: { signal?: AbortSignal } = {},
149
+ ): Promise<OAuthCredentials> {
147
150
  const resp = await fetch(TOKEN_URL, {
148
151
  method: "POST",
149
152
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -152,6 +155,7 @@ export async function refreshChatGPTToken(refreshToken: string): Promise<OAuthCr
152
155
  client_id: CLIENT_ID,
153
156
  refresh_token: refreshToken,
154
157
  }).toString(),
158
+ signal: options.signal,
155
159
  });
156
160
  if (!resp.ok) {
157
161
  const errDesc = await safeErrorDescription(resp);