codex-multi-auth 2.1.13-beta.2 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/.codex-plugin/plugin.json +9 -0
  2. package/README.md +5 -4
  3. package/dist/lib/accounts.d.ts.map +1 -1
  4. package/dist/lib/accounts.js +30 -1
  5. package/dist/lib/accounts.js.map +1 -1
  6. package/dist/lib/auth/device-auth.d.ts.map +1 -1
  7. package/dist/lib/auth/device-auth.js +15 -0
  8. package/dist/lib/auth/device-auth.js.map +1 -1
  9. package/dist/lib/auth/org-override.d.ts +19 -0
  10. package/dist/lib/auth/org-override.d.ts.map +1 -0
  11. package/dist/lib/auth/org-override.js +23 -0
  12. package/dist/lib/auth/org-override.js.map +1 -0
  13. package/dist/lib/auth/server.d.ts.map +1 -1
  14. package/dist/lib/auth/server.js +12 -7
  15. package/dist/lib/auth/server.js.map +1 -1
  16. package/dist/lib/capability-policy.d.ts.map +1 -1
  17. package/dist/lib/capability-policy.js +5 -0
  18. package/dist/lib/capability-policy.js.map +1 -1
  19. package/dist/lib/circuit-breaker.d.ts +6 -0
  20. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  21. package/dist/lib/circuit-breaker.js +8 -0
  22. package/dist/lib/circuit-breaker.js.map +1 -1
  23. package/dist/lib/codex-cli/state.d.ts.map +1 -1
  24. package/dist/lib/codex-cli/state.js +28 -7
  25. package/dist/lib/codex-cli/state.js.map +1 -1
  26. package/dist/lib/codex-manager/account-manager-commands.d.ts +14 -0
  27. package/dist/lib/codex-manager/account-manager-commands.d.ts.map +1 -0
  28. package/dist/lib/codex-manager/account-manager-commands.js +43 -0
  29. package/dist/lib/codex-manager/account-manager-commands.js.map +1 -0
  30. package/dist/lib/codex-manager/commands/best.d.ts +1 -1
  31. package/dist/lib/codex-manager/commands/best.d.ts.map +1 -1
  32. package/dist/lib/codex-manager/commands/best.js +4 -3
  33. package/dist/lib/codex-manager/commands/best.js.map +1 -1
  34. package/dist/lib/codex-manager/commands/budget.d.ts.map +1 -1
  35. package/dist/lib/codex-manager/commands/budget.js +6 -0
  36. package/dist/lib/codex-manager/commands/budget.js.map +1 -1
  37. package/dist/lib/codex-manager/commands/debug-bundle.d.ts +16 -0
  38. package/dist/lib/codex-manager/commands/debug-bundle.d.ts.map +1 -1
  39. package/dist/lib/codex-manager/commands/debug-bundle.js +80 -5
  40. package/dist/lib/codex-manager/commands/debug-bundle.js.map +1 -1
  41. package/dist/lib/codex-manager/commands/forecast.d.ts +1 -1
  42. package/dist/lib/codex-manager/commands/forecast.d.ts.map +1 -1
  43. package/dist/lib/codex-manager/commands/forecast.js +11 -8
  44. package/dist/lib/codex-manager/commands/forecast.js.map +1 -1
  45. package/dist/lib/codex-manager/commands/integrations.js +2 -2
  46. package/dist/lib/codex-manager/commands/integrations.js.map +1 -1
  47. package/dist/lib/codex-manager/commands/models.js +3 -3
  48. package/dist/lib/codex-manager/commands/models.js.map +1 -1
  49. package/dist/lib/codex-manager/commands/report.d.ts.map +1 -1
  50. package/dist/lib/codex-manager/commands/report.js +11 -9
  51. package/dist/lib/codex-manager/commands/report.js.map +1 -1
  52. package/dist/lib/codex-manager/commands/status.d.ts +2 -0
  53. package/dist/lib/codex-manager/commands/status.d.ts.map +1 -1
  54. package/dist/lib/codex-manager/commands/status.js +84 -25
  55. package/dist/lib/codex-manager/commands/status.js.map +1 -1
  56. package/dist/lib/codex-manager/commands/switch.d.ts.map +1 -1
  57. package/dist/lib/codex-manager/commands/switch.js +7 -0
  58. package/dist/lib/codex-manager/commands/switch.js.map +1 -1
  59. package/dist/lib/codex-manager/commands/workspace.d.ts.map +1 -1
  60. package/dist/lib/codex-manager/commands/workspace.js +12 -1
  61. package/dist/lib/codex-manager/commands/workspace.js.map +1 -1
  62. package/dist/lib/codex-manager/experimental-settings-prompt.d.ts.map +1 -1
  63. package/dist/lib/codex-manager/experimental-settings-prompt.js +13 -3
  64. package/dist/lib/codex-manager/experimental-settings-prompt.js.map +1 -1
  65. package/dist/lib/codex-manager/forecast-report-commands.d.ts.map +1 -1
  66. package/dist/lib/codex-manager/forecast-report-commands.js +14 -13
  67. package/dist/lib/codex-manager/forecast-report-commands.js.map +1 -1
  68. package/dist/lib/codex-manager/help.d.ts.map +1 -1
  69. package/dist/lib/codex-manager/help.js +7 -5
  70. package/dist/lib/codex-manager/help.js.map +1 -1
  71. package/dist/lib/codex-manager/repair-commands.d.ts.map +1 -1
  72. package/dist/lib/codex-manager/repair-commands.js +18 -8
  73. package/dist/lib/codex-manager/repair-commands.js.map +1 -1
  74. package/dist/lib/codex-manager/settings-hub/experimental.d.ts.map +1 -1
  75. package/dist/lib/codex-manager/settings-hub/experimental.js +8 -0
  76. package/dist/lib/codex-manager/settings-hub/experimental.js.map +1 -1
  77. package/dist/lib/codex-manager.d.ts +3 -0
  78. package/dist/lib/codex-manager.d.ts.map +1 -1
  79. package/dist/lib/codex-manager.js +121 -93
  80. package/dist/lib/codex-manager.js.map +1 -1
  81. package/dist/lib/config.d.ts.map +1 -1
  82. package/dist/lib/config.js +327 -18
  83. package/dist/lib/config.js.map +1 -1
  84. package/dist/lib/context-overflow.d.ts +7 -2
  85. package/dist/lib/context-overflow.d.ts.map +1 -1
  86. package/dist/lib/context-overflow.js +55 -37
  87. package/dist/lib/context-overflow.js.map +1 -1
  88. package/dist/lib/errors.d.ts +16 -0
  89. package/dist/lib/errors.d.ts.map +1 -1
  90. package/dist/lib/errors.js +23 -0
  91. package/dist/lib/errors.js.map +1 -1
  92. package/dist/lib/forecast.d.ts +1 -0
  93. package/dist/lib/forecast.d.ts.map +1 -1
  94. package/dist/lib/forecast.js +25 -2
  95. package/dist/lib/forecast.js.map +1 -1
  96. package/dist/lib/integration-generators.d.ts.map +1 -1
  97. package/dist/lib/integration-generators.js +1 -1
  98. package/dist/lib/integration-generators.js.map +1 -1
  99. package/dist/lib/local-bridge.d.ts +8 -0
  100. package/dist/lib/local-bridge.d.ts.map +1 -1
  101. package/dist/lib/local-bridge.js +81 -7
  102. package/dist/lib/local-bridge.js.map +1 -1
  103. package/dist/lib/local-client-tokens.d.ts.map +1 -1
  104. package/dist/lib/local-client-tokens.js +117 -64
  105. package/dist/lib/local-client-tokens.js.map +1 -1
  106. package/dist/lib/logger.d.ts +9 -1
  107. package/dist/lib/logger.d.ts.map +1 -1
  108. package/dist/lib/logger.js +104 -19
  109. package/dist/lib/logger.js.map +1 -1
  110. package/dist/lib/model-capability-matrix.d.ts.map +1 -1
  111. package/dist/lib/model-capability-matrix.js +13 -10
  112. package/dist/lib/model-capability-matrix.js.map +1 -1
  113. package/dist/lib/oc-chatgpt-orchestrator.d.ts +7 -1
  114. package/dist/lib/oc-chatgpt-orchestrator.d.ts.map +1 -1
  115. package/dist/lib/oc-chatgpt-orchestrator.js +68 -9
  116. package/dist/lib/oc-chatgpt-orchestrator.js.map +1 -1
  117. package/dist/lib/policy/runtime-policy.d.ts.map +1 -1
  118. package/dist/lib/policy/runtime-policy.js +23 -1
  119. package/dist/lib/policy/runtime-policy.js.map +1 -1
  120. package/dist/lib/prompts/codex.d.ts +1 -1
  121. package/dist/lib/prompts/codex.d.ts.map +1 -1
  122. package/dist/lib/prompts/codex.js +128 -33
  123. package/dist/lib/prompts/codex.js.map +1 -1
  124. package/dist/lib/prompts/fetch-utils.d.ts +72 -0
  125. package/dist/lib/prompts/fetch-utils.d.ts.map +1 -0
  126. package/dist/lib/prompts/fetch-utils.js +165 -0
  127. package/dist/lib/prompts/fetch-utils.js.map +1 -0
  128. package/dist/lib/prompts/host-codex-prompt.d.ts.map +1 -1
  129. package/dist/lib/prompts/host-codex-prompt.js +24 -10
  130. package/dist/lib/prompts/host-codex-prompt.js.map +1 -1
  131. package/dist/lib/quota-cache.d.ts.map +1 -1
  132. package/dist/lib/quota-cache.js +26 -2
  133. package/dist/lib/quota-cache.js.map +1 -1
  134. package/dist/lib/quota-probe.d.ts +21 -0
  135. package/dist/lib/quota-probe.d.ts.map +1 -1
  136. package/dist/lib/quota-probe.js +41 -1
  137. package/dist/lib/quota-probe.js.map +1 -1
  138. package/dist/lib/quota-readiness.d.ts +3 -1
  139. package/dist/lib/quota-readiness.d.ts.map +1 -1
  140. package/dist/lib/quota-readiness.js +16 -3
  141. package/dist/lib/quota-readiness.js.map +1 -1
  142. package/dist/lib/recovery/storage.d.ts +7 -0
  143. package/dist/lib/recovery/storage.d.ts.map +1 -1
  144. package/dist/lib/recovery/storage.js +178 -10
  145. package/dist/lib/recovery/storage.js.map +1 -1
  146. package/dist/lib/refresh-lease.d.ts +1 -1
  147. package/dist/lib/refresh-lease.d.ts.map +1 -1
  148. package/dist/lib/refresh-lease.js +25 -3
  149. package/dist/lib/refresh-lease.js.map +1 -1
  150. package/dist/lib/refresh-queue.d.ts.map +1 -1
  151. package/dist/lib/refresh-queue.js +31 -17
  152. package/dist/lib/refresh-queue.js.map +1 -1
  153. package/dist/lib/request/fetch-helpers.d.ts +0 -5
  154. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  155. package/dist/lib/request/fetch-helpers.js +39 -7
  156. package/dist/lib/request/fetch-helpers.js.map +1 -1
  157. package/dist/lib/request/helpers/model-map.d.ts +1 -0
  158. package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
  159. package/dist/lib/request/helpers/model-map.js +10 -0
  160. package/dist/lib/request/helpers/model-map.js.map +1 -1
  161. package/dist/lib/rotation.d.ts +12 -0
  162. package/dist/lib/rotation.d.ts.map +1 -1
  163. package/dist/lib/rotation.js +36 -0
  164. package/dist/lib/rotation.js.map +1 -1
  165. package/dist/lib/routing-mutex.d.ts +12 -0
  166. package/dist/lib/routing-mutex.d.ts.map +1 -1
  167. package/dist/lib/routing-mutex.js +38 -1
  168. package/dist/lib/routing-mutex.js.map +1 -1
  169. package/dist/lib/runtime/account-check-types.d.ts +1 -0
  170. package/dist/lib/runtime/account-check-types.d.ts.map +1 -1
  171. package/dist/lib/runtime/account-check-types.js +1 -0
  172. package/dist/lib/runtime/account-check-types.js.map +1 -1
  173. package/dist/lib/runtime/account-check.d.ts.map +1 -1
  174. package/dist/lib/runtime/account-check.js +15 -4
  175. package/dist/lib/runtime/account-check.js.map +1 -1
  176. package/dist/lib/runtime/quota-probe.d.ts.map +1 -1
  177. package/dist/lib/runtime/quota-probe.js +2 -5
  178. package/dist/lib/runtime/quota-probe.js.map +1 -1
  179. package/dist/lib/runtime/runtime-observability.d.ts.map +1 -1
  180. package/dist/lib/runtime/runtime-observability.js +24 -2
  181. package/dist/lib/runtime/runtime-observability.js.map +1 -1
  182. package/dist/lib/runtime-rotation-proxy.d.ts +24 -1
  183. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  184. package/dist/lib/runtime-rotation-proxy.js +289 -26
  185. package/dist/lib/runtime-rotation-proxy.js.map +1 -1
  186. package/dist/lib/storage/account-clear.d.ts.map +1 -1
  187. package/dist/lib/storage/account-clear.js +4 -2
  188. package/dist/lib/storage/account-clear.js.map +1 -1
  189. package/dist/lib/storage/account-persistence.d.ts.map +1 -1
  190. package/dist/lib/storage/account-persistence.js +11 -1
  191. package/dist/lib/storage/account-persistence.js.map +1 -1
  192. package/dist/lib/storage/flagged-storage-io.d.ts.map +1 -1
  193. package/dist/lib/storage/flagged-storage-io.js +4 -1
  194. package/dist/lib/storage/flagged-storage-io.js.map +1 -1
  195. package/dist/lib/storage/import-export.d.ts.map +1 -1
  196. package/dist/lib/storage/import-export.js +29 -9
  197. package/dist/lib/storage/import-export.js.map +1 -1
  198. package/dist/lib/storage/paths.d.ts.map +1 -1
  199. package/dist/lib/storage/paths.js +93 -0
  200. package/dist/lib/storage/paths.js.map +1 -1
  201. package/dist/lib/storage/record-utils.d.ts.map +1 -1
  202. package/dist/lib/storage/record-utils.js +6 -1
  203. package/dist/lib/storage/record-utils.js.map +1 -1
  204. package/dist/lib/storage/storage-parser.d.ts.map +1 -1
  205. package/dist/lib/storage/storage-parser.js +7 -1
  206. package/dist/lib/storage/storage-parser.js.map +1 -1
  207. package/dist/lib/storage/transactions.d.ts +8 -0
  208. package/dist/lib/storage/transactions.d.ts.map +1 -1
  209. package/dist/lib/storage/transactions.js +14 -1
  210. package/dist/lib/storage/transactions.js.map +1 -1
  211. package/dist/lib/storage.d.ts.map +1 -1
  212. package/dist/lib/storage.js +59 -18
  213. package/dist/lib/storage.js.map +1 -1
  214. package/dist/lib/table-formatter.d.ts.map +1 -1
  215. package/dist/lib/table-formatter.js +20 -2
  216. package/dist/lib/table-formatter.js.map +1 -1
  217. package/dist/lib/types.d.ts +7 -0
  218. package/dist/lib/types.d.ts.map +1 -1
  219. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  220. package/dist/lib/ui/auth-menu.js +8 -2
  221. package/dist/lib/ui/auth-menu.js.map +1 -1
  222. package/dist/lib/ui/display-width.d.ts +27 -0
  223. package/dist/lib/ui/display-width.d.ts.map +1 -0
  224. package/dist/lib/ui/display-width.js +194 -0
  225. package/dist/lib/ui/display-width.js.map +1 -0
  226. package/dist/lib/ui/select.d.ts +23 -0
  227. package/dist/lib/ui/select.d.ts.map +1 -1
  228. package/dist/lib/ui/select.js +64 -16
  229. package/dist/lib/ui/select.js.map +1 -1
  230. package/dist/lib/ui/theme.d.ts +13 -0
  231. package/dist/lib/ui/theme.d.ts.map +1 -1
  232. package/dist/lib/ui/theme.js +34 -1
  233. package/dist/lib/ui/theme.js.map +1 -1
  234. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -0
  235. package/node_modules/@codex-ai/sdk/dist/index.js +2 -0
  236. package/node_modules/@codex-ai/sdk/package.json +8 -0
  237. package/package.json +10 -6
  238. package/scripts/codex-bin-resolver.js +28 -1
  239. package/scripts/codex-routing.js +3 -0
  240. package/scripts/codex.js +508 -1
  241. package/scripts/mcodex.js +367 -0
@@ -1,18 +1,58 @@
1
- import { createHash, timingSafeEqual } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
1
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { AccountManager, extractAccountId, } from "./accounts.js";
5
+ import { withRoutingMutex } from "./routing-mutex.js";
5
6
  import { getStoragePath } from "./storage.js";
6
- import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, loadPluginConfig, } from "./config.js";
7
+ import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, getPidOffsetEnabled, getRoutingMutexMode, loadPluginConfig, } from "./config.js";
7
8
  import { CODEX_BASE_URL, HTTP_STATUS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js";
8
9
  import { getModelFamily } from "./prompts/codex.js";
10
+ import { CURRENT_CODEX_MODEL } from "./request/helpers/model-map.js";
9
11
  import { queuedRefresh } from "./refresh-queue.js";
10
12
  import { mutateRuntimeObservabilitySnapshot, recordRuntimePoolExhaustion, recordRuntimeReload, recordRuntimeReset, } from "./runtime/runtime-observability.js";
11
13
  import { createRuntimeUsageRecorder, evaluateRuntimePolicy, loadRuntimePolicyState, } from "./policy/runtime-policy.js";
12
14
  import { isWorkspaceDisabledError } from "./request/fetch-helpers.js";
15
+ import { createLogger, maskString, runWithCorrelationId } from "./logger.js";
13
16
  import { SessionAffinityStore } from "./session-affinity.js";
14
17
  import { isRecord } from "./utils.js";
15
18
  const DEFAULT_HOST = "127.0.0.1";
19
+ function isLoopbackHost(host) {
20
+ const normalized = host.trim().toLowerCase();
21
+ return (normalized === "127.0.0.1" ||
22
+ normalized === "localhost" ||
23
+ normalized === "::1" ||
24
+ normalized === "[::1]");
25
+ }
26
+ // IPv6 literals must be presented in two distinct forms and the proxy
27
+ // previously conflated them (runtime-proxy IPv6 bug). Node's
28
+ // net.Server.listen(port, host) requires the RAW literal ("::1"); a bracketed
29
+ // literal ("[::1]") makes the bind fail or behave wrong. Conversely a URL
30
+ // authority requires the BRACKETED literal ("[::1]") so "http://[::1]:port"
31
+ // parses unambiguously — the raw form yields the unparseable "http://::1:port".
32
+ // Normalize each form ONCE at startup so concurrent rotation paths never race
33
+ // on inconsistent host string representations.
34
+ function stripIpv6Brackets(host) {
35
+ const trimmed = host.trim();
36
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
37
+ return trimmed.slice(1, -1);
38
+ }
39
+ return trimmed;
40
+ }
41
+ // Raw literal suitable for server.listen: "[::1]" -> "::1", others unchanged.
42
+ function toBindHost(host) {
43
+ return stripIpv6Brackets(host);
44
+ }
45
+ // URL authority host: IPv6 literals are bracketed ("::1" -> "[::1]") while
46
+ // IPv4 addresses and hostnames (no embedded colon) pass through unchanged.
47
+ function toUrlHost(host) {
48
+ const bare = stripIpv6Brackets(host);
49
+ return bare.includes(":") ? `[${bare}]` : bare;
50
+ }
51
+ // Structured logger for the default-on runtime proxy (errors-logging-01,
52
+ // runtime-proxy-04). Previously the 1900-LOC proxy had zero logger integration;
53
+ // failures surfaced only as a last-write-wins status.lastError string. Logs are
54
+ // level-gated and carry the per-request correlation id set in handleRequest.
55
+ const proxyLog = createLogger("runtime-proxy");
16
56
  const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10;
17
57
  const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30_000;
18
58
  const DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS = 4;
@@ -105,6 +145,10 @@ function createOutboundHeaders(incoming, account, accessToken, accountId) {
105
145
  }
106
146
  headers.delete("host");
107
147
  headers.delete("x-api-key");
148
+ // Never forward inbound client credentials upstream: a Cookie / proxy-auth
149
+ // header would ride along with the managed OAuth Bearer to OpenAI.
150
+ headers.delete("cookie");
151
+ headers.delete("proxy-authorization");
108
152
  headers.set("authorization", `Bearer ${accessToken}`);
109
153
  headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
110
154
  headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
@@ -161,6 +205,17 @@ function recordLastRuntimeAccount(status, identity) {
161
205
  // vitest workers (each pointing at their own temp storage file) cannot
162
206
  // corrupt each other's snapshots. See issue #474.
163
207
  const STORAGE_META_CACHE = new Map();
208
+ // The mtime+size short-circuit (L3) may only be trusted once the cached
209
+ // mtime is far enough in the past that no *subsequent* write could share the
210
+ // same coarse mtime tick. Filesystems report mtime at wildly different
211
+ // granularities (ext4 ns, FAT 2s, some network/Windows volumes ~1s, and CI
212
+ // containers occasionally coarser), and our writers use atomic rename, so two
213
+ // rapid CLI bumps can land on an identical mtimeMs. Within this settle window
214
+ // we therefore ignore mtime equality and fall back to the read + sha1 path
215
+ // (the real source of truth). Outside it, the file has been quiescent long
216
+ // enough that mtime equality provably means "unchanged", so we skip the read.
217
+ // 2s comfortably exceeds the coarsest mtime granularity we expect in practice.
218
+ const MTIME_SHORTCIRCUIT_SETTLE_MS = 2_000;
164
219
  function hashStorageBytes(bytes) {
165
220
  return createHash("sha1").update(bytes).digest("hex");
166
221
  }
@@ -171,7 +226,11 @@ function metaFromSnapshot(snapshot) {
171
226
  };
172
227
  }
173
228
  /**
174
- * Cheap, hot-path-safe single read with mtime-cache short-circuit. Transient
229
+ * Cheap, hot-path-safe single read with mtime-cache short-circuit. When the
230
+ * file's `mtimeMs` and `size` match the cached snapshot for this path we
231
+ * return the cached value WITHOUT reading or hashing the file. Only when
232
+ * mtime/size differ (or were never cached) do we `readFileSync` + sha1 and,
233
+ * if the content hash still matches, skip the `JSON.parse`. Transient
175
234
  * failures (EBUSY/EPERM/EACCES/EAGAIN, partial-write SyntaxError) fall through
176
235
  * to the last cached value for this path; defaults are only returned when the
177
236
  * file has never been successfully read.
@@ -187,11 +246,36 @@ export function readStorageMetaFromDisk(storagePath = getStoragePath()) {
187
246
  return { pinnedAccountIndex: null, affinityGeneration: 0 };
188
247
  }
189
248
  try {
249
+ // mtime+size short-circuit (L3): when neither has changed since the last
250
+ // successful read AND the cached mtime has settled (see
251
+ // MTIME_SHORTCIRCUIT_SETTLE_MS) we return the cached snapshot without
252
+ // reading or hashing the file. During the settle window we deliberately
253
+ // fall through to the read + sha1 path below, which stays the source of
254
+ // truth and protects against the coarse-mtime collision described on
255
+ // StorageMetaSnapshot. So this is a pure fast path for the common
256
+ // "file quiescent, proxy polling every request" case.
257
+ const stats = statSync(storagePath);
258
+ const cachedByStat = STORAGE_META_CACHE.get(storagePath);
259
+ if (cachedByStat &&
260
+ cachedByStat.mtimeMs === stats.mtimeMs &&
261
+ cachedByStat.size === stats.size &&
262
+ Date.now() - stats.mtimeMs > MTIME_SHORTCIRCUIT_SETTLE_MS) {
263
+ return metaFromSnapshot(cachedByStat);
264
+ }
190
265
  const bytes = readFileSync(storagePath);
191
266
  const contentHash = hashStorageBytes(bytes);
192
267
  const cached = STORAGE_META_CACHE.get(storagePath);
193
268
  if (cached && cached.contentHash === contentHash) {
194
- return metaFromSnapshot(cached);
269
+ // Content is byte-identical despite the mtime/size change (e.g. an
270
+ // atomic-rename rewrite of the same bytes). Refresh the stat fields so
271
+ // the next request takes the fast path, but skip the JSON.parse.
272
+ const refreshed = {
273
+ ...cached,
274
+ mtimeMs: stats.mtimeMs,
275
+ size: stats.size,
276
+ };
277
+ STORAGE_META_CACHE.set(storagePath, refreshed);
278
+ return metaFromSnapshot(refreshed);
195
279
  }
196
280
  const parsed = JSON.parse(bytes.toString("utf8"));
197
281
  const pinnedAccountIndex = typeof parsed.pinnedAccountIndex === "number" &&
@@ -205,6 +289,8 @@ export function readStorageMetaFromDisk(storagePath = getStoragePath()) {
205
289
  ? parsed.affinityGeneration
206
290
  : 0;
207
291
  const snapshot = {
292
+ mtimeMs: stats.mtimeMs,
293
+ size: stats.size,
208
294
  contentHash,
209
295
  pinnedAccountIndex,
210
296
  affinityGeneration,
@@ -264,7 +350,26 @@ async function persistRuntimeActiveAccount(accountManager, account, family, isPi
264
350
  return;
265
351
  }
266
352
  try {
267
- accountManager.markSwitched(account, "rotation", family);
353
+ // accounts-01/08: serialize the cursor mutation through the routing mutex
354
+ // (when routingMutex="enabled") because this commit spans an await
355
+ // (syncCodexCliActiveSelectionForIndex), which is the lost-update window the
356
+ // mutex exists to close. In legacy mode markSwitchedLocked runs inline, so
357
+ // behavior is unchanged by default.
358
+ //
359
+ // L4 fix: in "enabled" mode the SELECTION path already committed the cursor
360
+ // for this account inside the routing mutex (atomic select+commit, see the
361
+ // hot-path caller). Re-running markSwitchedLocked here — after the upstream
362
+ // fetch, in a *separate* critical section — would redundantly re-advance the
363
+ // cursor and could clobber a concurrent request's atomic advance that landed
364
+ // while this request was awaiting upstream. So only do the locked cursor
365
+ // commit in legacy mode, where selection does NOT commit under a lock and
366
+ // this remains the sole commit site (preserving legacy behavior exactly).
367
+ // `saveToDiskDebounced` + CLI sync still run in both modes: they snapshot the
368
+ // current in-memory state / mirror the CLI selection and do not advance the
369
+ // in-memory rotation cursor.
370
+ if (accountManager.getRoutingMutexMode() !== "enabled") {
371
+ await accountManager.markSwitchedLocked(account, "rotation", family);
372
+ }
268
373
  accountManager.saveToDiskDebounced();
269
374
  await accountManager.syncCodexCliActiveSelectionForIndex(account.index);
270
375
  }
@@ -392,7 +497,11 @@ function buildResponsesRequestContext(req, body) {
392
497
  method: "POST",
393
498
  upstreamPath: URL_PATHS.CODEX_RESPONSES,
394
499
  model,
395
- family: getModelFamily(model ?? "gpt-5.3-codex"),
500
+ // The /codex/responses path is codex-family. A model-less request must
501
+ // bucket into the codex family (rotation/cooldown/budget), so fall back to
502
+ // the current codex model — NOT the general DEFAULT_MODEL (gpt-5.5), whose
503
+ // family is gpt-5.2 and would mis-account a pass-through codex request.
504
+ family: getModelFamily(model ?? CURRENT_CODEX_MODEL),
396
505
  stream: parsedBody?.stream === true,
397
506
  sessionKey: resolveSessionKey(headers, parsedBody),
398
507
  };
@@ -583,9 +692,57 @@ function parseRetryAfterBodyMs(bodyText, now) {
583
692
  }
584
693
  return null;
585
694
  }
586
- async function readErrorBody(response) {
695
+ async function readErrorBody(response, timeoutMs, maxBytes = 1024 * 1024) {
696
+ // The outbound fetch's abort timer is cleared once headers arrive, so a
697
+ // stalled error body would otherwise hang this handler forever (the success
698
+ // path is per-chunk stall-bounded; the error path was not). Read the body via
699
+ // a reader, bound it by an idle timeout AND a size cap, and cancel the stream
700
+ // on timeout/overflow so the socket is released.
701
+ const body = response.body;
702
+ if (!body || typeof body.getReader !== "function") {
703
+ // Fallback for impls without a streamable body: race text() against a timer.
704
+ try {
705
+ return await withTimeout(response.text(), timeoutMs, () => undefined, "error body stalled");
706
+ }
707
+ catch {
708
+ return "";
709
+ }
710
+ }
711
+ const reader = body.getReader();
712
+ const chunks = [];
713
+ let total = 0;
587
714
  try {
588
- return await response.text();
715
+ for (;;) {
716
+ let idleTimer;
717
+ const idle = new Promise((_resolve, reject) => {
718
+ idleTimer = setTimeout(() => reject(new Error("error body stalled")), Math.max(1, timeoutMs));
719
+ });
720
+ let result;
721
+ try {
722
+ result = await Promise.race([reader.read(), idle]);
723
+ }
724
+ finally {
725
+ if (idleTimer)
726
+ clearTimeout(idleTimer);
727
+ }
728
+ if (result.done)
729
+ break;
730
+ if (result.value) {
731
+ total += result.value.byteLength;
732
+ if (total > maxBytes)
733
+ break; // cap: enough for diagnostics, no OOM
734
+ chunks.push(result.value);
735
+ }
736
+ }
737
+ }
738
+ catch {
739
+ // stalled or errored — fall through with whatever we have
740
+ }
741
+ finally {
742
+ await reader.cancel().catch(() => undefined);
743
+ }
744
+ try {
745
+ return Buffer.concat(chunks).toString("utf8");
589
746
  }
590
747
  catch {
591
748
  return "";
@@ -684,8 +841,26 @@ function getQuotaNearExhaustionWaitMs(headers, remainingThreshold, now) {
684
841
  }
685
842
  return candidates.length > 0 ? Math.max(...candidates) : 0;
686
843
  }
844
+ /**
845
+ * `chooseAccount` is a SYNC selector that internally advances the rotation
846
+ * cursor (the session-affinity-preferred branch and the round-robin fallback
847
+ * both call `accountManager.markSwitched(...)`, and the hybrid selector advances
848
+ * its own cursor). It does NOT acquire the routing mutex itself.
849
+ *
850
+ * Concurrency (L4): when `routingMutex === "enabled"`, the proxy hot path runs
851
+ * this whole call AND the subsequent `markSwitchedLocked` commit inside a single
852
+ * `withRoutingMutex` acquisition, so concurrent requests serialize selection +
853
+ * cursor advance and cannot stampede the same account. `withRoutingMutex` is
854
+ * reentrant (AsyncLocalStorage), so the nested `markSwitchedLocked` — and the
855
+ * later one in `persistRuntimeActiveAccount` — run inline without re-acquiring
856
+ * the non-reentrant FIFO queue (no deadlock). In legacy mode the inline
857
+ * `markSwitched` calls below are used unchanged and no lock is taken, so default
858
+ * behavior and perf are identical. See the hot-path caller in
859
+ * `startRuntimeRotationProxy` and the regression in
860
+ * `test/runtime-rotation-proxy.test.ts`.
861
+ */
687
862
  export function chooseAccount(params) {
688
- const { accountManager, sessionAffinityStore, sessionKey, family, model, attemptedIndexes, now, policy, pinnedIndex, skipReasons, stickyBoostByAccount, } = params;
863
+ const { accountManager, sessionAffinityStore, sessionKey, family, model, attemptedIndexes, now, policy, pinnedIndex, skipReasons, stickyBoostByAccount, pidOffsetEnabled, } = params;
689
864
  // Manual pin (from `codex-multi-auth switch <n>`) overrides every other
690
865
  // selection signal. We do NOT call markSwitched here — the proxy must not
691
866
  // clobber the pin set by the CLI. See issue #474.
@@ -725,6 +900,7 @@ export function chooseAccount(params) {
725
900
  skipReasons?.set(preferred.index, reason);
726
901
  }
727
902
  else {
903
+ // L4 (deferred): unlocked cursor mutation — see chooseAccount header.
728
904
  accountManager.markSwitched(preferred, "rotation", family);
729
905
  return preferred;
730
906
  }
@@ -735,6 +911,10 @@ export function chooseAccount(params) {
735
911
  ...(policy?.scoreBoostByAccount ?? {}),
736
912
  ...(stickyBoostByAccount ?? {}),
737
913
  },
914
+ // accounts-05: carry the PID-offset distribution into the default-on proxy
915
+ // path too (index.ts already does). Without it, parallel proxy processes can
916
+ // stampede the same account instead of spreading across the pool.
917
+ pidOffsetEnabled,
738
918
  });
739
919
  if (selected &&
740
920
  !attemptedIndexes.has(selected.index) &&
@@ -758,6 +938,7 @@ export function chooseAccount(params) {
758
938
  const live = accountManager.getAccountByIndex(account.index);
759
939
  if (!live)
760
940
  continue;
941
+ // L4 (deferred): unlocked cursor mutation — see chooseAccount header.
761
942
  accountManager.markSwitched(live, "rotation", family);
762
943
  return live;
763
944
  }
@@ -888,8 +1069,27 @@ export async function startRuntimeRotationProxy(options) {
888
1069
  const pluginConfig = loadPluginConfig();
889
1070
  let activeAccountManager = options.accountManager ?? (await AccountManager.loadFromDisk());
890
1071
  const knownAccountManagers = new Set([activeAccountManager]);
1072
+ // accounts-01/08: apply the configured routing-mutex mode so the proxy's
1073
+ // async select->commit path (persistRuntimeActiveAccount) can serialize cursor
1074
+ // mutations when routingMutex="enabled". Legacy mode keeps the inline fast path.
1075
+ const routingMutexMode = getRoutingMutexMode(pluginConfig);
1076
+ activeAccountManager.setRoutingMutexMode(routingMutexMode);
891
1077
  const fetchImpl = options.fetchImpl ?? fetch;
892
1078
  const host = options.host ?? DEFAULT_HOST;
1079
+ // Defense in depth (runtime-proxy-01): the proxy presents managed OAuth tokens
1080
+ // and must never be reachable off-box. It is loopback-only with NO opt-out —
1081
+ // binding a non-loopback host would expose every managed account to the
1082
+ // network, so it is refused unconditionally.
1083
+ if (!isLoopbackHost(host)) {
1084
+ throw new Error(`Runtime rotation proxy refuses to bind non-loopback host "${host}". ` +
1085
+ "It forwards managed OAuth tokens and is loopback-only.");
1086
+ }
1087
+ // Normalize the validated host into its two representations exactly once so the
1088
+ // listen() bind and the emitted baseUrl can never disagree under concurrent
1089
+ // rotation: bindHost is the raw literal Node's listen() expects ("[::1]"->"::1"),
1090
+ // urlHost is the bracketed form a URL authority requires ("::1"->"[::1]").
1091
+ const bindHost = toBindHost(host);
1092
+ const urlHost = toUrlHost(host);
893
1093
  const port = options.port ?? 0;
894
1094
  const upstreamBaseUrl = options.upstreamBaseUrl ?? CODEX_BASE_URL;
895
1095
  const clientApiKey = typeof options.clientApiKey === "string" &&
@@ -905,6 +1105,7 @@ export async function startRuntimeRotationProxy(options) {
905
1105
  const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig);
906
1106
  const tokenInvalidationCooldownMs = getTokenInvalidationCooldownMs(pluginConfig);
907
1107
  const minRotationIntervalMs = getMinRotationIntervalMs(pluginConfig);
1108
+ const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
908
1109
  let lastGlobalAccountIndex = null;
909
1110
  let lastGlobalSwitchAt = 0;
910
1111
  const fetchTimeoutMs = options.fetchTimeoutMs ?? getFetchTimeoutMs(pluginConfig);
@@ -953,6 +1154,7 @@ export async function startRuntimeRotationProxy(options) {
953
1154
  AccountManager.resetVolatileRuntimeState();
954
1155
  recordRuntimeReset("pool-exhausted-no-account");
955
1156
  const reloaded = await AccountManager.loadFromDisk();
1157
+ reloaded.setRoutingMutexMode(routingMutexMode);
956
1158
  activeAccountManager = reloaded;
957
1159
  knownAccountManagers.add(reloaded);
958
1160
  lastStaleRuntimeReloadAt = Date.now();
@@ -969,10 +1171,27 @@ export async function startRuntimeRotationProxy(options) {
969
1171
  return staleRuntimeReloadPromise;
970
1172
  };
971
1173
  const handleRequest = async (req, res) => {
1174
+ // Per-request trace id (errors-logging-03): distinct from sessionKey, which
1175
+ // is shared across a thread's requests. Bound to this request's async context
1176
+ // so every proxyLog line and usage row can be correlated to one request.
1177
+ const traceId = randomUUID();
1178
+ return runWithCorrelationId(traceId, () => handleRequestInner(req, res, traceId));
1179
+ };
1180
+ const handleRequestInner = async (req, res, traceId) => {
972
1181
  let usageRecorder = null;
973
1182
  let accountManager = activeAccountManager;
974
1183
  try {
975
1184
  const incomingUrl = new URL(req.url ?? "/", "http://127.0.0.1");
1185
+ // Authenticate before discriminating path/method so an unauthenticated
1186
+ // caller cannot enumerate which endpoints exist: an unknown caller always
1187
+ // gets 401, never a 404 that would confirm a path is invalid (vs. just
1188
+ // unauthorized). Authorized callers still fall through to the 404 below
1189
+ // when they hit an unsupported path/method.
1190
+ const incomingHeaders = headersFromIncoming(req);
1191
+ if (!isAuthorizedClient(incomingHeaders, clientApiKey)) {
1192
+ writeUnauthorized(res);
1193
+ return;
1194
+ }
976
1195
  const isResponsesRequest = req.method === "POST" && isResponsesPath(incomingUrl.pathname);
977
1196
  const isModelsRequest = req.method === "GET" && isModelsPath(incomingUrl.pathname);
978
1197
  const isThreadGoalRequest = (req.method === "GET" || req.method === "POST") &&
@@ -981,11 +1200,6 @@ export async function startRuntimeRotationProxy(options) {
981
1200
  writeMethodOrPathError(res);
982
1201
  return;
983
1202
  }
984
- const incomingHeaders = headersFromIncoming(req);
985
- if (!isAuthorizedClient(incomingHeaders, clientApiKey)) {
986
- writeUnauthorized(res);
987
- return;
988
- }
989
1203
  status.totalRequests += 1;
990
1204
  const requestBody = isResponsesRequest || (isThreadGoalRequest && req.method === "POST")
991
1205
  ? await readRequestBody(req, maxRequestBodyBytes)
@@ -1028,7 +1242,7 @@ export async function startRuntimeRotationProxy(options) {
1028
1242
  : "responses",
1029
1243
  model: context.model,
1030
1244
  projectKey,
1031
- requestId: context.sessionKey,
1245
+ requestId: traceId,
1032
1246
  startedAt: requestStartedAt,
1033
1247
  });
1034
1248
  if (policyError) {
@@ -1091,7 +1305,22 @@ export async function startRuntimeRotationProxy(options) {
1091
1305
  now() - lastGlobalSwitchAt < minRotationIntervalMs
1092
1306
  ? { [lastGlobalAccountIndex]: 1000 }
1093
1307
  : {};
1094
- const selected = chooseAccount({
1308
+ // L4 fix (routing mutex): when `routingMutex === "enabled"`, run the
1309
+ // selection AND the cursor commit inside ONE mutex acquisition so two
1310
+ // concurrent requests cannot read the same cursor and stampede before
1311
+ // the locked commit lands. `chooseAccount` is sync and mutates the
1312
+ // cursor internally (session-affinity `markSwitched`, the hybrid
1313
+ // selector's own advance, and the round-robin fallback `markSwitched`);
1314
+ // holding the mutex across the whole call serializes all of those.
1315
+ // We then `markSwitchedLocked` the winner to (a) re-commit the cursor
1316
+ // under the lock across the await boundary and (b) hand
1317
+ // `persistRuntimeActiveAccount` a cursor that is already correct. That
1318
+ // later `markSwitchedLocked` runs INLINE (reentrant) within this held
1319
+ // section, so there is no double-acquire and no deadlock on the
1320
+ // non-reentrant FIFO queue. In legacy mode the inline `markSwitched`
1321
+ // calls inside `chooseAccount` are used unchanged and no lock is taken,
1322
+ // so default behavior and perf are identical to before.
1323
+ const selectAccount = () => chooseAccount({
1095
1324
  accountManager,
1096
1325
  sessionAffinityStore,
1097
1326
  sessionKey: context.sessionKey,
@@ -1103,7 +1332,22 @@ export async function startRuntimeRotationProxy(options) {
1103
1332
  pinnedIndex,
1104
1333
  skipReasons: accountSkipReasons,
1105
1334
  stickyBoostByAccount: rotationStickyBoost,
1335
+ pidOffsetEnabled,
1106
1336
  });
1337
+ const selected = routingMutexMode === "enabled"
1338
+ ? await withRoutingMutex(routingMutexMode, async () => {
1339
+ const candidate = selectAccount();
1340
+ if (candidate && pinnedIndex === null) {
1341
+ // Re-commit the cursor under the held mutex. Skipped when a
1342
+ // manual pin is active so the proxy never clobbers the pin
1343
+ // (see #474); pinned selections are deterministic and need no
1344
+ // cursor advance. Runs inline via reentrancy — see comment
1345
+ // above.
1346
+ await accountManager.markSwitchedLocked(candidate, "rotation", context.family);
1347
+ }
1348
+ return candidate;
1349
+ })
1350
+ : selectAccount();
1107
1351
  if (!selected) {
1108
1352
  if (!reloadedAfterNoAccount &&
1109
1353
  !isPinned &&
@@ -1212,7 +1456,7 @@ export async function startRuntimeRotationProxy(options) {
1212
1456
  continue;
1213
1457
  }
1214
1458
  if (upstream.status === HTTP_STATUS.TOO_MANY_REQUESTS) {
1215
- const bodyText = await readErrorBody(upstream);
1459
+ const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
1216
1460
  const retryAfterMs = parseRetryAfterHeaderMs(upstream.headers, now()) ??
1217
1461
  parseRetryAfterBodyMs(bodyText, now()) ??
1218
1462
  60_000;
@@ -1229,7 +1473,7 @@ export async function startRuntimeRotationProxy(options) {
1229
1473
  continue;
1230
1474
  }
1231
1475
  if (upstream.status === 402 || upstream.status === HTTP_STATUS.FORBIDDEN) {
1232
- const bodyText = await readErrorBody(upstream);
1476
+ const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
1233
1477
  const errorCode = extractErrorCodeFromBody(bodyText);
1234
1478
  if (isWorkspaceDisabledError(upstream.status, errorCode, bodyText)) {
1235
1479
  const accountWasEnabled = accountManager.getAccountByIndex(refreshed.account.index)?.enabled !==
@@ -1312,7 +1556,7 @@ export async function startRuntimeRotationProxy(options) {
1312
1556
  return;
1313
1557
  }
1314
1558
  if (upstream.status === HTTP_STATUS.UNAUTHORIZED) {
1315
- const bodyText = await readErrorBody(upstream);
1559
+ const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
1316
1560
  accountManager.refundToken(refreshed.account, context.family, context.model);
1317
1561
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1318
1562
  if (isTokenInvalidationError(bodyText)) {
@@ -1349,7 +1593,7 @@ export async function startRuntimeRotationProxy(options) {
1349
1593
  continue;
1350
1594
  }
1351
1595
  if (upstream.status >= 500) {
1352
- await readErrorBody(upstream);
1596
+ await readErrorBody(upstream, streamStallTimeoutMs);
1353
1597
  accountManager.refundToken(refreshed.account, context.family, context.model);
1354
1598
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1355
1599
  accountManager.markAccountCoolingDown(refreshed.account, serverErrorCooldownMs, "server-error");
@@ -1462,7 +1706,20 @@ export async function startRuntimeRotationProxy(options) {
1462
1706
  }
1463
1707
  }
1464
1708
  catch (error) {
1465
- status.lastError = error instanceof Error ? error.message : String(error);
1709
+ const rawErrorMessage = error instanceof Error ? error.message : String(error);
1710
+ // errors-logging-08: redact any email/token material that leaked into a
1711
+ // raw upstream or refresh error string before it reaches status consumers
1712
+ // or the structured log. maskString is a no-op for clean diagnostic text.
1713
+ const maskedErrorMessage = maskString(rawErrorMessage);
1714
+ status.lastError = maskedErrorMessage;
1715
+ // errors-logging-01: surface the failure through the structured logger
1716
+ // (redaction-safe) with the request trace id, instead of only stashing a
1717
+ // last-write-wins status string.
1718
+ proxyLog.error("runtime proxy request failed", {
1719
+ traceId,
1720
+ code: isRuntimeProxyHttpError(error) ? error.code : "codex_runtime_rotation_proxy_error",
1721
+ error: maskedErrorMessage,
1722
+ });
1466
1723
  if (!res.headersSent) {
1467
1724
  if (isRuntimeProxyHttpError(error)) {
1468
1725
  await usageRecorder?.record({
@@ -1519,20 +1776,26 @@ export async function startRuntimeRotationProxy(options) {
1519
1776
  };
1520
1777
  server.once("error", onError);
1521
1778
  server.once("listening", onListening);
1522
- server.listen(port, host);
1779
+ server.listen(port, bindHost);
1523
1780
  });
1524
1781
  server.on("error", onPostStartupServerError);
1525
1782
  const address = server.address();
1526
1783
  const resolvedPort = typeof address === "object" && address ? address.port : port;
1527
1784
  return {
1528
- host,
1785
+ host: bindHost,
1529
1786
  port: resolvedPort,
1530
- baseUrl: `http://${host}:${resolvedPort}`,
1787
+ baseUrl: `http://${urlHost}:${resolvedPort}`,
1531
1788
  close: async () => {
1532
1789
  await closeServer(server, sockets);
1533
1790
  await activeAccountManager.flushPendingSave();
1534
1791
  },
1535
- getStatus: () => ({ ...status }),
1792
+ getStatus: () => ({
1793
+ ...status,
1794
+ // Redact any email/token material that leaked into a raw upstream or
1795
+ // refresh error string before exposing it to status/report consumers
1796
+ // (errors-logging-08). maskString is a no-op for clean diagnostic text.
1797
+ lastError: status.lastError === null ? null : maskString(status.lastError),
1798
+ }),
1536
1799
  };
1537
1800
  }
1538
1801
  async function closeServer(server, sockets) {