codex-multi-auth 2.1.13-beta.1 → 2.2.0

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 (214) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +415 -415
  3. package/assets/codex-multi-auth-icon.svg +39 -39
  4. package/assets/readme-hero.svg +31 -31
  5. package/config/README.md +29 -29
  6. package/config/codex-legacy.json +571 -571
  7. package/config/codex-modern.json +258 -258
  8. package/config/minimal-codex.json +15 -15
  9. package/config/schema/config.schema.json +20 -20
  10. package/dist/lib/accounts.d.ts.map +1 -1
  11. package/dist/lib/accounts.js +30 -1
  12. package/dist/lib/accounts.js.map +1 -1
  13. package/dist/lib/auth/org-override.d.ts +19 -0
  14. package/dist/lib/auth/org-override.d.ts.map +1 -0
  15. package/dist/lib/auth/org-override.js +23 -0
  16. package/dist/lib/auth/org-override.js.map +1 -0
  17. package/dist/lib/circuit-breaker.d.ts +6 -0
  18. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  19. package/dist/lib/circuit-breaker.js +8 -0
  20. package/dist/lib/circuit-breaker.js.map +1 -1
  21. package/dist/lib/codex-manager/account-manager-commands.d.ts +14 -0
  22. package/dist/lib/codex-manager/account-manager-commands.d.ts.map +1 -0
  23. package/dist/lib/codex-manager/account-manager-commands.js +43 -0
  24. package/dist/lib/codex-manager/account-manager-commands.js.map +1 -0
  25. package/dist/lib/codex-manager/commands/best.d.ts +1 -1
  26. package/dist/lib/codex-manager/commands/best.d.ts.map +1 -1
  27. package/dist/lib/codex-manager/commands/best.js +2 -1
  28. package/dist/lib/codex-manager/commands/best.js.map +1 -1
  29. package/dist/lib/codex-manager/commands/debug-bundle.d.ts +16 -0
  30. package/dist/lib/codex-manager/commands/debug-bundle.d.ts.map +1 -1
  31. package/dist/lib/codex-manager/commands/debug-bundle.js +80 -5
  32. package/dist/lib/codex-manager/commands/debug-bundle.js.map +1 -1
  33. package/dist/lib/codex-manager/commands/forecast.d.ts +1 -1
  34. package/dist/lib/codex-manager/commands/forecast.d.ts.map +1 -1
  35. package/dist/lib/codex-manager/commands/forecast.js +2 -1
  36. package/dist/lib/codex-manager/commands/forecast.js.map +1 -1
  37. package/dist/lib/codex-manager/commands/report.d.ts.map +1 -1
  38. package/dist/lib/codex-manager/commands/report.js +2 -2
  39. package/dist/lib/codex-manager/commands/report.js.map +1 -1
  40. package/dist/lib/codex-manager/commands/status.d.ts +2 -0
  41. package/dist/lib/codex-manager/commands/status.d.ts.map +1 -1
  42. package/dist/lib/codex-manager/commands/status.js +84 -25
  43. package/dist/lib/codex-manager/commands/status.js.map +1 -1
  44. package/dist/lib/codex-manager/commands/switch.d.ts.map +1 -1
  45. package/dist/lib/codex-manager/commands/switch.js +7 -0
  46. package/dist/lib/codex-manager/commands/switch.js.map +1 -1
  47. package/dist/lib/codex-manager/experimental-settings-prompt.d.ts.map +1 -1
  48. package/dist/lib/codex-manager/experimental-settings-prompt.js +13 -3
  49. package/dist/lib/codex-manager/experimental-settings-prompt.js.map +1 -1
  50. package/dist/lib/codex-manager/forecast-report-commands.d.ts.map +1 -1
  51. package/dist/lib/codex-manager/forecast-report-commands.js +3 -3
  52. package/dist/lib/codex-manager/forecast-report-commands.js.map +1 -1
  53. package/dist/lib/codex-manager/repair-commands.d.ts.map +1 -1
  54. package/dist/lib/codex-manager/repair-commands.js +11 -1
  55. package/dist/lib/codex-manager/repair-commands.js.map +1 -1
  56. package/dist/lib/codex-manager/settings-hub/experimental.d.ts.map +1 -1
  57. package/dist/lib/codex-manager/settings-hub/experimental.js +8 -0
  58. package/dist/lib/codex-manager/settings-hub/experimental.js.map +1 -1
  59. package/dist/lib/codex-manager.d.ts +3 -0
  60. package/dist/lib/codex-manager.d.ts.map +1 -1
  61. package/dist/lib/codex-manager.js +53 -65
  62. package/dist/lib/codex-manager.js.map +1 -1
  63. package/dist/lib/config.d.ts +26 -0
  64. package/dist/lib/config.d.ts.map +1 -1
  65. package/dist/lib/config.js +152 -5
  66. package/dist/lib/config.js.map +1 -1
  67. package/dist/lib/context-overflow.d.ts +7 -2
  68. package/dist/lib/context-overflow.d.ts.map +1 -1
  69. package/dist/lib/context-overflow.js +65 -47
  70. package/dist/lib/context-overflow.js.map +1 -1
  71. package/dist/lib/errors.d.ts +16 -0
  72. package/dist/lib/errors.d.ts.map +1 -1
  73. package/dist/lib/errors.js +23 -0
  74. package/dist/lib/errors.js.map +1 -1
  75. package/dist/lib/local-bridge.d.ts +8 -0
  76. package/dist/lib/local-bridge.d.ts.map +1 -1
  77. package/dist/lib/local-bridge.js +81 -7
  78. package/dist/lib/local-bridge.js.map +1 -1
  79. package/dist/lib/logger.d.ts +9 -1
  80. package/dist/lib/logger.d.ts.map +1 -1
  81. package/dist/lib/logger.js +104 -19
  82. package/dist/lib/logger.js.map +1 -1
  83. package/dist/lib/oauth-success.html +125 -125
  84. package/dist/lib/oc-chatgpt-orchestrator.d.ts +7 -1
  85. package/dist/lib/oc-chatgpt-orchestrator.d.ts.map +1 -1
  86. package/dist/lib/oc-chatgpt-orchestrator.js +68 -9
  87. package/dist/lib/oc-chatgpt-orchestrator.js.map +1 -1
  88. package/dist/lib/policy/runtime-policy.d.ts.map +1 -1
  89. package/dist/lib/policy/runtime-policy.js +15 -1
  90. package/dist/lib/policy/runtime-policy.js.map +1 -1
  91. package/dist/lib/prompts/codex-host-bridge.js +138 -138
  92. package/dist/lib/prompts/codex.d.ts.map +1 -1
  93. package/dist/lib/prompts/codex.js +203 -108
  94. package/dist/lib/prompts/codex.js.map +1 -1
  95. package/dist/lib/prompts/fetch-utils.d.ts +72 -0
  96. package/dist/lib/prompts/fetch-utils.d.ts.map +1 -0
  97. package/dist/lib/prompts/fetch-utils.js +165 -0
  98. package/dist/lib/prompts/fetch-utils.js.map +1 -0
  99. package/dist/lib/prompts/host-codex-prompt.d.ts.map +1 -1
  100. package/dist/lib/prompts/host-codex-prompt.js +24 -10
  101. package/dist/lib/prompts/host-codex-prompt.js.map +1 -1
  102. package/dist/lib/quota-cache.d.ts.map +1 -1
  103. package/dist/lib/quota-cache.js +10 -1
  104. package/dist/lib/quota-cache.js.map +1 -1
  105. package/dist/lib/quota-probe.d.ts +21 -0
  106. package/dist/lib/quota-probe.d.ts.map +1 -1
  107. package/dist/lib/quota-probe.js +39 -0
  108. package/dist/lib/quota-probe.js.map +1 -1
  109. package/dist/lib/quota-readiness.d.ts +3 -1
  110. package/dist/lib/quota-readiness.d.ts.map +1 -1
  111. package/dist/lib/quota-readiness.js +16 -3
  112. package/dist/lib/quota-readiness.js.map +1 -1
  113. package/dist/lib/recovery/storage.d.ts +7 -0
  114. package/dist/lib/recovery/storage.d.ts.map +1 -1
  115. package/dist/lib/recovery/storage.js +178 -10
  116. package/dist/lib/recovery/storage.js.map +1 -1
  117. package/dist/lib/refresh-lease.d.ts +1 -1
  118. package/dist/lib/refresh-lease.d.ts.map +1 -1
  119. package/dist/lib/refresh-lease.js +25 -3
  120. package/dist/lib/refresh-lease.js.map +1 -1
  121. package/dist/lib/refresh-queue.d.ts.map +1 -1
  122. package/dist/lib/refresh-queue.js +31 -17
  123. package/dist/lib/refresh-queue.js.map +1 -1
  124. package/dist/lib/request/fetch-helpers.d.ts +0 -5
  125. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  126. package/dist/lib/request/fetch-helpers.js +36 -7
  127. package/dist/lib/request/fetch-helpers.js.map +1 -1
  128. package/dist/lib/rotation.d.ts +12 -0
  129. package/dist/lib/rotation.d.ts.map +1 -1
  130. package/dist/lib/rotation.js +36 -0
  131. package/dist/lib/rotation.js.map +1 -1
  132. package/dist/lib/runtime/account-check-types.d.ts +1 -0
  133. package/dist/lib/runtime/account-check-types.d.ts.map +1 -1
  134. package/dist/lib/runtime/account-check-types.js +1 -0
  135. package/dist/lib/runtime/account-check-types.js.map +1 -1
  136. package/dist/lib/runtime/account-check.d.ts.map +1 -1
  137. package/dist/lib/runtime/account-check.js +15 -4
  138. package/dist/lib/runtime/account-check.js.map +1 -1
  139. package/dist/lib/runtime/runtime-observability.d.ts.map +1 -1
  140. package/dist/lib/runtime/runtime-observability.js +24 -2
  141. package/dist/lib/runtime/runtime-observability.js.map +1 -1
  142. package/dist/lib/runtime-rotation-proxy.d.ts +3 -0
  143. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  144. package/dist/lib/runtime-rotation-proxy.js +296 -21
  145. package/dist/lib/runtime-rotation-proxy.js.map +1 -1
  146. package/dist/lib/schemas.d.ts +2 -0
  147. package/dist/lib/schemas.d.ts.map +1 -1
  148. package/dist/lib/schemas.js +2 -0
  149. package/dist/lib/schemas.js.map +1 -1
  150. package/dist/lib/storage/account-clear.d.ts.map +1 -1
  151. package/dist/lib/storage/account-clear.js +4 -2
  152. package/dist/lib/storage/account-clear.js.map +1 -1
  153. package/dist/lib/storage/flagged-storage-io.d.ts.map +1 -1
  154. package/dist/lib/storage/flagged-storage-io.js +4 -1
  155. package/dist/lib/storage/flagged-storage-io.js.map +1 -1
  156. package/dist/lib/storage/import-export.d.ts.map +1 -1
  157. package/dist/lib/storage/import-export.js +29 -9
  158. package/dist/lib/storage/import-export.js.map +1 -1
  159. package/dist/lib/storage/paths.d.ts.map +1 -1
  160. package/dist/lib/storage/paths.js +93 -0
  161. package/dist/lib/storage/paths.js.map +1 -1
  162. package/dist/lib/storage/storage-parser.d.ts.map +1 -1
  163. package/dist/lib/storage/storage-parser.js +7 -1
  164. package/dist/lib/storage/storage-parser.js.map +1 -1
  165. package/dist/lib/storage.d.ts.map +1 -1
  166. package/dist/lib/storage.js +13 -9
  167. package/dist/lib/storage.js.map +1 -1
  168. package/dist/lib/table-formatter.d.ts.map +1 -1
  169. package/dist/lib/table-formatter.js +20 -2
  170. package/dist/lib/table-formatter.js.map +1 -1
  171. package/dist/lib/types.d.ts +7 -0
  172. package/dist/lib/types.d.ts.map +1 -1
  173. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  174. package/dist/lib/ui/auth-menu.js +8 -2
  175. package/dist/lib/ui/auth-menu.js.map +1 -1
  176. package/dist/lib/ui/display-width.d.ts +27 -0
  177. package/dist/lib/ui/display-width.d.ts.map +1 -0
  178. package/dist/lib/ui/display-width.js +194 -0
  179. package/dist/lib/ui/display-width.js.map +1 -0
  180. package/dist/lib/ui/select.d.ts +23 -0
  181. package/dist/lib/ui/select.d.ts.map +1 -1
  182. package/dist/lib/ui/select.js +64 -16
  183. package/dist/lib/ui/select.js.map +1 -1
  184. package/dist/lib/ui/theme.d.ts +13 -0
  185. package/dist/lib/ui/theme.d.ts.map +1 -1
  186. package/dist/lib/ui/theme.js +34 -1
  187. package/dist/lib/ui/theme.js.map +1 -1
  188. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
  189. package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
  190. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
  191. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
  192. package/node_modules/@codex-ai/plugin/package.json +9 -9
  193. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -0
  194. package/node_modules/@codex-ai/sdk/dist/index.js +2 -0
  195. package/node_modules/@codex-ai/sdk/package.json +8 -0
  196. package/package.json +196 -193
  197. package/scripts/codex-app-launcher.js +653 -653
  198. package/scripts/codex-app-router.js +322 -322
  199. package/scripts/codex-bin-resolver.js +254 -254
  200. package/scripts/codex-multi-auth.js +59 -59
  201. package/scripts/codex-routing.js +59 -56
  202. package/scripts/codex.js +4782 -4275
  203. package/scripts/install-codex-auth-utils.js +206 -206
  204. package/scripts/mcodex +117 -0
  205. package/scripts/postinstall.js +343 -343
  206. package/scripts/preuninstall.js +254 -254
  207. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
  208. package/vendor/codex-ai-plugin/dist/index.js +2 -2
  209. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
  210. package/vendor/codex-ai-plugin/dist/tool.js +29 -29
  211. package/vendor/codex-ai-plugin/package.json +9 -9
  212. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
  213. package/vendor/codex-ai-sdk/dist/index.js +2 -2
  214. package/vendor/codex-ai-sdk/package.json +8 -8
@@ -1,21 +1,74 @@
1
- import { createHash, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { AccountManager, extractAccountId, } from "./accounts.js";
5
5
  import { getStoragePath } from "./storage.js";
6
- import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getTokenRefreshSkewMs, loadPluginConfig, } from "./config.js";
6
+ import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, getPidOffsetEnabled, getRoutingMutexMode, loadPluginConfig, } from "./config.js";
7
7
  import { CODEX_BASE_URL, HTTP_STATUS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js";
8
8
  import { getModelFamily } from "./prompts/codex.js";
9
9
  import { queuedRefresh } from "./refresh-queue.js";
10
10
  import { mutateRuntimeObservabilitySnapshot, recordRuntimePoolExhaustion, recordRuntimeReload, recordRuntimeReset, } from "./runtime/runtime-observability.js";
11
11
  import { createRuntimeUsageRecorder, evaluateRuntimePolicy, loadRuntimePolicyState, } from "./policy/runtime-policy.js";
12
12
  import { isWorkspaceDisabledError } from "./request/fetch-helpers.js";
13
+ import { createLogger, maskString, runWithCorrelationId } from "./logger.js";
13
14
  import { SessionAffinityStore } from "./session-affinity.js";
14
15
  import { isRecord } from "./utils.js";
15
16
  const DEFAULT_HOST = "127.0.0.1";
17
+ function isLoopbackHost(host) {
18
+ const normalized = host.trim().toLowerCase();
19
+ return (normalized === "127.0.0.1" ||
20
+ normalized === "localhost" ||
21
+ normalized === "::1" ||
22
+ normalized === "[::1]");
23
+ }
24
+ // IPv6 literals must be presented in two distinct forms and the proxy
25
+ // previously conflated them (runtime-proxy IPv6 bug). Node's
26
+ // net.Server.listen(port, host) requires the RAW literal ("::1"); a bracketed
27
+ // literal ("[::1]") makes the bind fail or behave wrong. Conversely a URL
28
+ // authority requires the BRACKETED literal ("[::1]") so "http://[::1]:port"
29
+ // parses unambiguously — the raw form yields the unparseable "http://::1:port".
30
+ // Normalize each form ONCE at startup so concurrent rotation paths never race
31
+ // on inconsistent host string representations.
32
+ function stripIpv6Brackets(host) {
33
+ const trimmed = host.trim();
34
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
35
+ return trimmed.slice(1, -1);
36
+ }
37
+ return trimmed;
38
+ }
39
+ // Raw literal suitable for server.listen: "[::1]" -> "::1", others unchanged.
40
+ function toBindHost(host) {
41
+ return stripIpv6Brackets(host);
42
+ }
43
+ // URL authority host: IPv6 literals are bracketed ("::1" -> "[::1]") while
44
+ // IPv4 addresses and hostnames (no embedded colon) pass through unchanged.
45
+ function toUrlHost(host) {
46
+ const bare = stripIpv6Brackets(host);
47
+ return bare.includes(":") ? `[${bare}]` : bare;
48
+ }
49
+ // Structured logger for the default-on runtime proxy (errors-logging-01,
50
+ // runtime-proxy-04). Previously the 1900-LOC proxy had zero logger integration;
51
+ // failures surfaced only as a last-write-wins status.lastError string. Logs are
52
+ // level-gated and carry the per-request correlation id set in handleRequest.
53
+ const proxyLog = createLogger("runtime-proxy");
16
54
  const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10;
17
55
  const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30_000;
18
56
  const DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS = 4;
57
+ // Phrases observed in upstream 401 response bodies when OpenAI/Microsoft has
58
+ // explicitly revoked an OAuth token (as opposed to a generic expired-token 401
59
+ // that can be retried after a refresh). Matching is case-insensitive substring.
60
+ // If anti-abuse detection triggers different wording in production, add the new
61
+ // phrase here and record the source provider and date. See issue #495.
62
+ const TOKEN_INVALIDATION_PHRASES = [
63
+ "invalidated oauth token",
64
+ "authentication token has been invalidated",
65
+ "oauth token has been invalidated",
66
+ "token has been invalidated",
67
+ ];
68
+ function isTokenInvalidationError(bodyText) {
69
+ const lower = bodyText.toLowerCase();
70
+ return TOKEN_INVALIDATION_PHRASES.some((phrase) => lower.includes(phrase));
71
+ }
19
72
  const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
20
73
  const MAX_THREAD_GOAL_FALLBACKS = 512;
21
74
  const HOP_BY_HOP_HEADERS = new Set([
@@ -90,6 +143,10 @@ function createOutboundHeaders(incoming, account, accessToken, accountId) {
90
143
  }
91
144
  headers.delete("host");
92
145
  headers.delete("x-api-key");
146
+ // Never forward inbound client credentials upstream: a Cookie / proxy-auth
147
+ // header would ride along with the managed OAuth Bearer to OpenAI.
148
+ headers.delete("cookie");
149
+ headers.delete("proxy-authorization");
93
150
  headers.set("authorization", `Bearer ${accessToken}`);
94
151
  headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
95
152
  headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
@@ -249,7 +306,12 @@ async function persistRuntimeActiveAccount(accountManager, account, family, isPi
249
306
  return;
250
307
  }
251
308
  try {
252
- accountManager.markSwitched(account, "rotation", family);
309
+ // accounts-01/08: serialize the cursor mutation through the routing mutex
310
+ // (when routingMutex="enabled") because this commit spans an await
311
+ // (syncCodexCliActiveSelectionForIndex), which is the lost-update window the
312
+ // mutex exists to close. In legacy mode markSwitchedLocked runs inline, so
313
+ // behavior is unchanged by default.
314
+ await accountManager.markSwitchedLocked(account, "rotation", family);
253
315
  accountManager.saveToDiskDebounced();
254
316
  await accountManager.syncCodexCliActiveSelectionForIndex(account.index);
255
317
  }
@@ -424,6 +486,21 @@ function buildUpstreamUrl(req, upstreamBaseUrl, upstreamPath) {
424
486
  upstream.search = incomingUrl.search;
425
487
  return upstream.toString();
426
488
  }
489
+ // Monotonic auth-failure cooldown: only extend, never shorten. Two concurrent
490
+ // requests on the same account can race so that an invalidation path sets a
491
+ // long cooldown (5 min) and a subsequent generic 401 truncates it (30 s).
492
+ // Reading the live coolingDownUntil before writing prevents that race.
493
+ function applyMonotonicAuthCooldown(accountManager, account, cooldownMs) {
494
+ const existing = accountManager.getAccountByIndex(account.index)?.coolingDownUntil ?? 0;
495
+ // Intentionally Date.now(), not the proxy's injected now(): coolingDownUntil is
496
+ // written by markAccountCoolingDown via nowMs() (== Date.now()), so both sides of
497
+ // this comparison must live in the same real-wall-clock domain. Switching to the
498
+ // injected now() would mis-compare an injected-clock value against a real-clock
499
+ // deadline and silently defeat the monotonic guard.
500
+ if (Date.now() + cooldownMs > existing) {
501
+ accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure");
502
+ }
503
+ }
427
504
  function hasUsableAccessToken(account, now, skewMs) {
428
505
  return (typeof account.access === "string" &&
429
506
  account.access.trim().length > 0 &&
@@ -465,7 +542,7 @@ async function commitRefreshedAuthOnce(accountManager, account, auth) {
465
542
  return pending;
466
543
  }
467
544
  async function ensureFreshAccessToken(params) {
468
- const { accountManager, account, family, model, now, tokenRefreshSkewMs } = params;
545
+ const { accountManager, account, family, model, now, tokenRefreshSkewMs, tokenInvalidationCooldownMs } = params;
469
546
  if (hasUsableAccessToken(account, now, tokenRefreshSkewMs)) {
470
547
  return { ok: true, accessToken: account.access ?? "", account };
471
548
  }
@@ -473,9 +550,14 @@ async function ensureFreshAccessToken(params) {
473
550
  if (refreshResult.type === "failed") {
474
551
  accountManager.recordFailure(account, family, model);
475
552
  accountManager.incrementAuthFailures(account);
476
- accountManager.markAccountCoolingDown(account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
553
+ // If the refresh endpoint itself returns an explicit invalidation message
554
+ // (e.g. Microsoft/Outlook SSO revokes the refresh token server-side), apply
555
+ // the long cooldown and signal to the caller to stop rotating rather than
556
+ // presenting other accounts' tokens from the same IP.
557
+ const invalidated = isTokenInvalidationError(refreshResult.message ?? "");
558
+ applyMonotonicAuthCooldown(accountManager, account, invalidated ? tokenInvalidationCooldownMs : DEFAULT_AUTH_FAILURE_COOLDOWN_MS);
477
559
  accountManager.saveToDiskDebounced();
478
- return { ok: false, retryable: isTokenRefreshRetryable(refreshResult) };
560
+ return { ok: false, retryable: isTokenRefreshRetryable(refreshResult), invalidated };
479
561
  }
480
562
  const auth = {
481
563
  type: "oauth",
@@ -548,14 +630,96 @@ function parseRetryAfterBodyMs(bodyText, now) {
548
630
  }
549
631
  return null;
550
632
  }
551
- async function readErrorBody(response) {
633
+ async function readErrorBody(response, timeoutMs, maxBytes = 1024 * 1024) {
634
+ // The outbound fetch's abort timer is cleared once headers arrive, so a
635
+ // stalled error body would otherwise hang this handler forever (the success
636
+ // path is per-chunk stall-bounded; the error path was not). Read the body via
637
+ // a reader, bound it by an idle timeout AND a size cap, and cancel the stream
638
+ // on timeout/overflow so the socket is released.
639
+ const body = response.body;
640
+ if (!body || typeof body.getReader !== "function") {
641
+ // Fallback for impls without a streamable body: race text() against a timer.
642
+ try {
643
+ return await withTimeout(response.text(), timeoutMs, () => undefined, "error body stalled");
644
+ }
645
+ catch {
646
+ return "";
647
+ }
648
+ }
649
+ const reader = body.getReader();
650
+ const chunks = [];
651
+ let total = 0;
552
652
  try {
553
- return await response.text();
653
+ for (;;) {
654
+ let idleTimer;
655
+ const idle = new Promise((_resolve, reject) => {
656
+ idleTimer = setTimeout(() => reject(new Error("error body stalled")), Math.max(1, timeoutMs));
657
+ });
658
+ let result;
659
+ try {
660
+ result = await Promise.race([reader.read(), idle]);
661
+ }
662
+ finally {
663
+ if (idleTimer)
664
+ clearTimeout(idleTimer);
665
+ }
666
+ if (result.done)
667
+ break;
668
+ if (result.value) {
669
+ total += result.value.byteLength;
670
+ if (total > maxBytes)
671
+ break; // cap: enough for diagnostics, no OOM
672
+ chunks.push(result.value);
673
+ }
674
+ }
675
+ }
676
+ catch {
677
+ // stalled or errored — fall through with whatever we have
678
+ }
679
+ finally {
680
+ await reader.cancel().catch(() => undefined);
681
+ }
682
+ try {
683
+ return Buffer.concat(chunks).toString("utf8");
554
684
  }
555
685
  catch {
556
686
  return "";
557
687
  }
558
688
  }
689
+ const TOKEN_INVALIDATED_CODE = "token_invalidated";
690
+ const TOKEN_INVALIDATED_FALLBACK_MESSAGE = "OAuth token has been invalidated. Please re-login.";
691
+ // Both invalidation exit paths (refresh-failure and upstream-401) must hand the
692
+ // client the same machine-readable shape — { error: { message, code:
693
+ // "token_invalidated" } } — so a consumer keying off error.code behaves
694
+ // identically regardless of which vector fired. The upstream forwards a raw body
695
+ // with no guaranteed code, so we wrap it here while preserving its human-readable
696
+ // message when one is present.
697
+ export function buildTokenInvalidationBody(upstreamBodyText) {
698
+ let message = TOKEN_INVALIDATED_FALLBACK_MESSAGE;
699
+ const trimmed = upstreamBodyText.trim();
700
+ if (trimmed) {
701
+ try {
702
+ const parsed = JSON.parse(trimmed);
703
+ if (isRecord(parsed)) {
704
+ const direct = parsed.message;
705
+ if (typeof direct === "string" && direct.trim()) {
706
+ message = direct.trim();
707
+ }
708
+ else if (isRecord(parsed.error)) {
709
+ const nested = parsed.error.message;
710
+ if (typeof nested === "string" && nested.trim()) {
711
+ message = nested.trim();
712
+ }
713
+ }
714
+ }
715
+ }
716
+ catch {
717
+ // Non-JSON upstream body (e.g. HTML error page): keep the stable fallback
718
+ // message rather than echoing markup back to the client.
719
+ }
720
+ }
721
+ return JSON.stringify({ error: { message, code: TOKEN_INVALIDATED_CODE } });
722
+ }
559
723
  function extractErrorCodeFromBody(bodyText) {
560
724
  if (!bodyText.trim())
561
725
  return null;
@@ -616,7 +780,7 @@ function getQuotaNearExhaustionWaitMs(headers, remainingThreshold, now) {
616
780
  return candidates.length > 0 ? Math.max(...candidates) : 0;
617
781
  }
618
782
  export function chooseAccount(params) {
619
- const { accountManager, sessionAffinityStore, sessionKey, family, model, attemptedIndexes, now, policy, pinnedIndex, skipReasons, } = params;
783
+ const { accountManager, sessionAffinityStore, sessionKey, family, model, attemptedIndexes, now, policy, pinnedIndex, skipReasons, stickyBoostByAccount, pidOffsetEnabled, } = params;
620
784
  // Manual pin (from `codex-multi-auth switch <n>`) overrides every other
621
785
  // selection signal. We do NOT call markSwitched here — the proxy must not
622
786
  // clobber the pin set by the CLI. See issue #474.
@@ -662,7 +826,14 @@ export function chooseAccount(params) {
662
826
  }
663
827
  }
664
828
  const selected = accountManager.getCurrentOrNextForFamilyHybrid(family, model, {
665
- scoreBoostByAccount: policy?.scoreBoostByAccount,
829
+ scoreBoostByAccount: {
830
+ ...(policy?.scoreBoostByAccount ?? {}),
831
+ ...(stickyBoostByAccount ?? {}),
832
+ },
833
+ // accounts-05: carry the PID-offset distribution into the default-on proxy
834
+ // path too (index.ts already does). Without it, parallel proxy processes can
835
+ // stampede the same account instead of spreading across the pool.
836
+ pidOffsetEnabled,
666
837
  });
667
838
  if (selected &&
668
839
  !attemptedIndexes.has(selected.index) &&
@@ -816,8 +987,27 @@ export async function startRuntimeRotationProxy(options) {
816
987
  const pluginConfig = loadPluginConfig();
817
988
  let activeAccountManager = options.accountManager ?? (await AccountManager.loadFromDisk());
818
989
  const knownAccountManagers = new Set([activeAccountManager]);
990
+ // accounts-01/08: apply the configured routing-mutex mode so the proxy's
991
+ // async select->commit path (persistRuntimeActiveAccount) can serialize cursor
992
+ // mutations when routingMutex="enabled". Legacy mode keeps the inline fast path.
993
+ const routingMutexMode = getRoutingMutexMode(pluginConfig);
994
+ activeAccountManager.setRoutingMutexMode(routingMutexMode);
819
995
  const fetchImpl = options.fetchImpl ?? fetch;
820
996
  const host = options.host ?? DEFAULT_HOST;
997
+ // Defense in depth (runtime-proxy-01): the proxy presents managed OAuth tokens
998
+ // and must never be reachable off-box. It is loopback-only with NO opt-out —
999
+ // binding a non-loopback host would expose every managed account to the
1000
+ // network, so it is refused unconditionally.
1001
+ if (!isLoopbackHost(host)) {
1002
+ throw new Error(`Runtime rotation proxy refuses to bind non-loopback host "${host}". ` +
1003
+ "It forwards managed OAuth tokens and is loopback-only.");
1004
+ }
1005
+ // Normalize the validated host into its two representations exactly once so the
1006
+ // listen() bind and the emitted baseUrl can never disagree under concurrent
1007
+ // rotation: bindHost is the raw literal Node's listen() expects ("[::1]"->"::1"),
1008
+ // urlHost is the bracketed form a URL authority requires ("::1"->"[::1]").
1009
+ const bindHost = toBindHost(host);
1010
+ const urlHost = toUrlHost(host);
821
1011
  const port = options.port ?? 0;
822
1012
  const upstreamBaseUrl = options.upstreamBaseUrl ?? CODEX_BASE_URL;
823
1013
  const clientApiKey = typeof options.clientApiKey === "string" &&
@@ -831,6 +1021,11 @@ export async function startRuntimeRotationProxy(options) {
831
1021
  const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
832
1022
  const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig);
833
1023
  const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig);
1024
+ const tokenInvalidationCooldownMs = getTokenInvalidationCooldownMs(pluginConfig);
1025
+ const minRotationIntervalMs = getMinRotationIntervalMs(pluginConfig);
1026
+ const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
1027
+ let lastGlobalAccountIndex = null;
1028
+ let lastGlobalSwitchAt = 0;
834
1029
  const fetchTimeoutMs = options.fetchTimeoutMs ?? getFetchTimeoutMs(pluginConfig);
835
1030
  const streamStallTimeoutMs = options.streamStallTimeoutMs ?? getStreamStallTimeoutMs(pluginConfig);
836
1031
  const configuredMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
@@ -877,6 +1072,7 @@ export async function startRuntimeRotationProxy(options) {
877
1072
  AccountManager.resetVolatileRuntimeState();
878
1073
  recordRuntimeReset("pool-exhausted-no-account");
879
1074
  const reloaded = await AccountManager.loadFromDisk();
1075
+ reloaded.setRoutingMutexMode(routingMutexMode);
880
1076
  activeAccountManager = reloaded;
881
1077
  knownAccountManagers.add(reloaded);
882
1078
  lastStaleRuntimeReloadAt = Date.now();
@@ -893,6 +1089,13 @@ export async function startRuntimeRotationProxy(options) {
893
1089
  return staleRuntimeReloadPromise;
894
1090
  };
895
1091
  const handleRequest = async (req, res) => {
1092
+ // Per-request trace id (errors-logging-03): distinct from sessionKey, which
1093
+ // is shared across a thread's requests. Bound to this request's async context
1094
+ // so every proxyLog line and usage row can be correlated to one request.
1095
+ const traceId = randomUUID();
1096
+ return runWithCorrelationId(traceId, () => handleRequestInner(req, res, traceId));
1097
+ };
1098
+ const handleRequestInner = async (req, res, traceId) => {
896
1099
  let usageRecorder = null;
897
1100
  let accountManager = activeAccountManager;
898
1101
  try {
@@ -952,7 +1155,7 @@ export async function startRuntimeRotationProxy(options) {
952
1155
  : "responses",
953
1156
  model: context.model,
954
1157
  projectKey,
955
- requestId: context.sessionKey,
1158
+ requestId: traceId,
956
1159
  startedAt: requestStartedAt,
957
1160
  });
958
1161
  if (policyError) {
@@ -1010,6 +1213,11 @@ export async function startRuntimeRotationProxy(options) {
1010
1213
  }
1011
1214
  while (attemptedIndexes.size < accountCount &&
1012
1215
  transientAttempts < transientAttemptLimit) {
1216
+ const rotationStickyBoost = minRotationIntervalMs > 0 &&
1217
+ lastGlobalAccountIndex !== null &&
1218
+ now() - lastGlobalSwitchAt < minRotationIntervalMs
1219
+ ? { [lastGlobalAccountIndex]: 1000 }
1220
+ : {};
1013
1221
  const selected = chooseAccount({
1014
1222
  accountManager,
1015
1223
  sessionAffinityStore,
@@ -1021,6 +1229,8 @@ export async function startRuntimeRotationProxy(options) {
1021
1229
  policy: policyDecision,
1022
1230
  pinnedIndex,
1023
1231
  skipReasons: accountSkipReasons,
1232
+ stickyBoostByAccount: rotationStickyBoost,
1233
+ pidOffsetEnabled,
1024
1234
  });
1025
1235
  if (!selected) {
1026
1236
  if (!reloadedAfterNoAccount &&
@@ -1057,10 +1267,28 @@ export async function startRuntimeRotationProxy(options) {
1057
1267
  model: context.model,
1058
1268
  now: now(),
1059
1269
  tokenRefreshSkewMs,
1270
+ tokenInvalidationCooldownMs,
1060
1271
  });
1061
1272
  if (!refreshed.ok) {
1062
1273
  accountManager.refundToken(selected, context.family, context.model);
1063
1274
  exhaustionReason = "auth-failure";
1275
+ if (refreshed.invalidated) {
1276
+ // Refresh endpoint explicitly revoked the token. Stop cascade:
1277
+ // return auth error to client instead of rotating to the next account.
1278
+ sessionAffinityStore?.forgetSession(context.sessionKey);
1279
+ res.writeHead(HTTP_STATUS.UNAUTHORIZED, { "content-type": "application/json" });
1280
+ // Route through the shared builder so both invalidation exit paths stay
1281
+ // in lockstep — empty input yields { error: { message: <fallback>,
1282
+ // code: "token_invalidated" } }.
1283
+ res.end(buildTokenInvalidationBody(""));
1284
+ await usageRecorder.record({
1285
+ outcome: "failure",
1286
+ statusCode: HTTP_STATUS.UNAUTHORIZED,
1287
+ errorCode: "token_invalidated",
1288
+ account: selected,
1289
+ });
1290
+ return;
1291
+ }
1064
1292
  if (!refreshed.retryable)
1065
1293
  continue;
1066
1294
  transientAttempts += 1;
@@ -1112,7 +1340,7 @@ export async function startRuntimeRotationProxy(options) {
1112
1340
  continue;
1113
1341
  }
1114
1342
  if (upstream.status === HTTP_STATUS.TOO_MANY_REQUESTS) {
1115
- const bodyText = await readErrorBody(upstream);
1343
+ const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
1116
1344
  const retryAfterMs = parseRetryAfterHeaderMs(upstream.headers, now()) ??
1117
1345
  parseRetryAfterBodyMs(bodyText, now()) ??
1118
1346
  60_000;
@@ -1129,7 +1357,7 @@ export async function startRuntimeRotationProxy(options) {
1129
1357
  continue;
1130
1358
  }
1131
1359
  if (upstream.status === 402 || upstream.status === HTTP_STATUS.FORBIDDEN) {
1132
- const bodyText = await readErrorBody(upstream);
1360
+ const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
1133
1361
  const errorCode = extractErrorCodeFromBody(bodyText);
1134
1362
  if (isWorkspaceDisabledError(upstream.status, errorCode, bodyText)) {
1135
1363
  const accountWasEnabled = accountManager.getAccountByIndex(refreshed.account.index)?.enabled !==
@@ -1212,10 +1440,34 @@ export async function startRuntimeRotationProxy(options) {
1212
1440
  return;
1213
1441
  }
1214
1442
  if (upstream.status === HTTP_STATUS.UNAUTHORIZED) {
1215
- await readErrorBody(upstream);
1443
+ const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
1216
1444
  accountManager.refundToken(refreshed.account, context.family, context.model);
1217
1445
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1218
- accountManager.markAccountCoolingDown(refreshed.account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
1446
+ if (isTokenInvalidationError(bodyText)) {
1447
+ // The upstream explicitly revoked this OAuth token. Applying a long
1448
+ // cooldown prevents cascade invalidation: rapidly presenting each
1449
+ // account's token from the same IP triggers OpenAI's anti-abuse
1450
+ // detection and invalidates them in sequence. Return the 401 directly
1451
+ // rather than rotating so the client can prompt for re-login.
1452
+ applyMonotonicAuthCooldown(accountManager, refreshed.account, tokenInvalidationCooldownMs);
1453
+ sessionAffinityStore?.forgetSession(context.sessionKey);
1454
+ accountManager.saveToDiskDebounced();
1455
+ // Emit the same machine-readable shape as the refresh-failure path
1456
+ // (code: "token_invalidated") instead of forwarding the raw upstream
1457
+ // body, so the client contract is consistent across both vectors.
1458
+ const clientHeaders = responseHeadersForClient(upstream.headers);
1459
+ clientHeaders["content-type"] = "application/json";
1460
+ res.writeHead(upstream.status, clientHeaders);
1461
+ res.end(buildTokenInvalidationBody(bodyText));
1462
+ await usageRecorder.record({
1463
+ outcome: "failure",
1464
+ statusCode: upstream.status,
1465
+ errorCode: "token_invalidated",
1466
+ account: refreshed.account,
1467
+ });
1468
+ return;
1469
+ }
1470
+ applyMonotonicAuthCooldown(accountManager, refreshed.account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS);
1219
1471
  accountManager.saveToDiskDebounced();
1220
1472
  exhaustionReason = "auth-failure";
1221
1473
  transientAttempts += 1;
@@ -1225,7 +1477,7 @@ export async function startRuntimeRotationProxy(options) {
1225
1477
  continue;
1226
1478
  }
1227
1479
  if (upstream.status >= 500) {
1228
- await readErrorBody(upstream);
1480
+ await readErrorBody(upstream, streamStallTimeoutMs);
1229
1481
  accountManager.refundToken(refreshed.account, context.family, context.model);
1230
1482
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1231
1483
  accountManager.markAccountCoolingDown(refreshed.account, serverErrorCooldownMs, "server-error");
@@ -1266,6 +1518,10 @@ export async function startRuntimeRotationProxy(options) {
1266
1518
  }
1267
1519
  else {
1268
1520
  sessionAffinityStore?.remember(context.sessionKey, refreshed.account.index, now());
1521
+ if (refreshed.account.index !== lastGlobalAccountIndex) {
1522
+ lastGlobalAccountIndex = refreshed.account.index;
1523
+ }
1524
+ lastGlobalSwitchAt = now();
1269
1525
  }
1270
1526
  await persistRuntimeActiveAccount(accountManager, refreshed.account, context.family, isPinned && refreshed.account.index === pinnedIndex);
1271
1527
  const forwarded = await forwardStreamingResponse(upstream, res, status, () => {
@@ -1334,7 +1590,20 @@ export async function startRuntimeRotationProxy(options) {
1334
1590
  }
1335
1591
  }
1336
1592
  catch (error) {
1337
- status.lastError = error instanceof Error ? error.message : String(error);
1593
+ const rawErrorMessage = error instanceof Error ? error.message : String(error);
1594
+ // errors-logging-08: redact any email/token material that leaked into a
1595
+ // raw upstream or refresh error string before it reaches status consumers
1596
+ // or the structured log. maskString is a no-op for clean diagnostic text.
1597
+ const maskedErrorMessage = maskString(rawErrorMessage);
1598
+ status.lastError = maskedErrorMessage;
1599
+ // errors-logging-01: surface the failure through the structured logger
1600
+ // (redaction-safe) with the request trace id, instead of only stashing a
1601
+ // last-write-wins status string.
1602
+ proxyLog.error("runtime proxy request failed", {
1603
+ traceId,
1604
+ code: isRuntimeProxyHttpError(error) ? error.code : "codex_runtime_rotation_proxy_error",
1605
+ error: maskedErrorMessage,
1606
+ });
1338
1607
  if (!res.headersSent) {
1339
1608
  if (isRuntimeProxyHttpError(error)) {
1340
1609
  await usageRecorder?.record({
@@ -1391,20 +1660,26 @@ export async function startRuntimeRotationProxy(options) {
1391
1660
  };
1392
1661
  server.once("error", onError);
1393
1662
  server.once("listening", onListening);
1394
- server.listen(port, host);
1663
+ server.listen(port, bindHost);
1395
1664
  });
1396
1665
  server.on("error", onPostStartupServerError);
1397
1666
  const address = server.address();
1398
1667
  const resolvedPort = typeof address === "object" && address ? address.port : port;
1399
1668
  return {
1400
- host,
1669
+ host: bindHost,
1401
1670
  port: resolvedPort,
1402
- baseUrl: `http://${host}:${resolvedPort}`,
1671
+ baseUrl: `http://${urlHost}:${resolvedPort}`,
1403
1672
  close: async () => {
1404
1673
  await closeServer(server, sockets);
1405
1674
  await activeAccountManager.flushPendingSave();
1406
1675
  },
1407
- getStatus: () => ({ ...status }),
1676
+ getStatus: () => ({
1677
+ ...status,
1678
+ // Redact any email/token material that leaked into a raw upstream or
1679
+ // refresh error string before exposing it to status/report consumers
1680
+ // (errors-logging-08). maskString is a no-op for clean diagnostic text.
1681
+ lastError: status.lastError === null ? null : maskString(status.lastError),
1682
+ }),
1408
1683
  };
1409
1684
  }
1410
1685
  async function closeServer(server, sockets) {