@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { isRealBunBinary } from "./bun-binary-validator.mjs";
4
5
  import { isStandaloneBinary } from "./standalone";
5
6
 
6
7
  export interface PackageTreeObservation {
@@ -56,6 +57,14 @@ export interface PackageTreeIntegrityOptions {
56
57
  schedule?: (callback: () => void, delayMs: number) => (() => void) | void;
57
58
  /** Test seam for `installedVersion()`; production reads the package manifest. */
58
59
  readInstalledVersion?: () => string | undefined;
60
+ /**
61
+ * Whether the runtime a restart would spawn is complete. An in-place npm install writes
62
+ * package.json and extracts the `bun` package's small placeholder before its postinstall
63
+ * swaps the real binary in, and the replacement spawns `process.execPath`, that same path.
64
+ * While this answers false the replacement is treated like an unreadable tree: no restart,
65
+ * then a fresh full debounce once the runtime is ready.
66
+ */
67
+ runtimeReady?: () => boolean;
59
68
  }
60
69
 
61
70
  export type ObservePackageTree = () => PackageTreeObservation | null;
@@ -142,6 +151,12 @@ export function sameObservation(left: PackageTreeObservation, right: PackageTree
142
151
  */
143
152
  const PACKAGE_TREE_RECHECK_MS = 1_000;
144
153
 
154
+ /**
155
+ * Fences a live process whose package manifest was replaced, and once the replacement is readable,
156
+ * stable for the debounce interval and any configured `runtimeReady` check passes, hands it to
157
+ * `onReplaced`. If the callback throws, the guard rechecks the tree and may call it again after
158
+ * another debounce interval while the replacement persists.
159
+ */
145
160
  export function createPackageTreeIntegrityGuard(
146
161
  observe: ObservePackageTree = observePackageManifest,
147
162
  now: () => number = Date.now,
@@ -154,6 +169,8 @@ export function createPackageTreeIntegrityGuard(
154
169
  let timerScheduled = false;
155
170
  let cancelScheduled: (() => void) | null = null;
156
171
  let waitingForReadableTree = false;
172
+ /** Logged once per wait, so a postinstall that never runs is visible rather than silent. */
173
+ let waitingForRuntime = false;
157
174
  let replacementCandidate: PackageTreeObservation | null = null;
158
175
  /** The replacement identity that survived a full stability interval (see installedVersion). */
159
176
  let settledReplacement: PackageTreeObservation | null = null;
@@ -192,9 +209,24 @@ export function createPackageTreeIntegrityGuard(
192
209
  if (sameObservation(boot, current)) {
193
210
  resetRestartTimer();
194
211
  waitingForReadableTree = false;
212
+ waitingForRuntime = false;
195
213
  replacementCandidate = null;
196
214
  return;
197
215
  }
216
+ if (options.runtimeReady && !options.runtimeReady()) {
217
+ if (!waitingForRuntime) {
218
+ waitingForRuntime = true;
219
+ console.warn("Package tree replaced; waiting for its Bun runtime to finish installing before restarting");
220
+ }
221
+ // A replacement settled before a failed admission is no longer restart-ready: withdraw
222
+ // it so installedVersion() stays silent until the fresh debounce settles it again.
223
+ settledReplacement = null;
224
+ resetRestartTimer();
225
+ waitingForReadableTree = true;
226
+ armRestartTimer(PACKAGE_TREE_RECHECK_MS);
227
+ return;
228
+ }
229
+ waitingForRuntime = false;
198
230
  if (waitingForReadableTree) {
199
231
  waitingForReadableTree = false;
200
232
  replacementCandidate = current;
@@ -239,6 +271,7 @@ export function createPackageTreeIntegrityGuard(
239
271
  return {
240
272
  installedVersion: () => {
241
273
  if (settledReplacement === null) return undefined;
274
+ if (options.runtimeReady && !options.runtimeReady()) return undefined;
242
275
  const current = observe();
243
276
  if (current === null || !sameObservation(settledReplacement, current)) return undefined;
244
277
  return readInstalledVersion();
@@ -271,6 +304,7 @@ export function createPackageTreeIntegrityGuard(
271
304
  lastOkAt = at;
272
305
  resetRestartTimer();
273
306
  waitingForReadableTree = false;
307
+ waitingForRuntime = false;
274
308
  replacementCandidate = null;
275
309
  return { ok: true };
276
310
  },
@@ -292,5 +326,12 @@ export function createRuntimePackageTreeIntegrityGuard(
292
326
  if (installer === "source" || isStandaloneBinary()) {
293
327
  return { status: () => ({ ok: true }), dispose: () => {} };
294
328
  }
295
- return createPackageTreeIntegrityGuard(observe, now, options);
329
+ // The restart replacement spawns process.execPath, the bundled Bun in an npm install. Read it
330
+ // now: Linux Bun resolves it lazily, and after npm swaps the package directory a first read
331
+ // reports the retired binary as "(deleted)"; reading it once pins the boot path.
332
+ const runtimePath = process.execPath;
333
+ return createPackageTreeIntegrityGuard(observe, now, {
334
+ ...options,
335
+ runtimeReady: options.runtimeReady ?? (() => isRealBunBinary(runtimePath)),
336
+ });
296
337
  }
@@ -0,0 +1,62 @@
1
+ const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2
+ const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([
3
+ "authorization",
4
+ "chatgpt-account-id",
5
+ "connection",
6
+ "content-length",
7
+ "content-type",
8
+ "cookie",
9
+ "expect",
10
+ "host",
11
+ "keep-alive",
12
+ "proxy-authorization",
13
+ "proxy-connection",
14
+ "set-cookie",
15
+ "te",
16
+ "trailer",
17
+ "transfer-encoding",
18
+ "upgrade",
19
+ "x-amz-security-token",
20
+ "x-api-key",
21
+ "api-key",
22
+ "x-goog-api-key",
23
+ "x-oai-attestation",
24
+ ]);
25
+
26
+ const ALLOWED_FORWARDED_CLIENT_HEADERS = new Set([
27
+ "originator", "x-client-request-id", "x-codex-app-version", "user-agent",
28
+ ]);
29
+ const MAX_FORWARDED_CLIENT_HEADERS = 64;
30
+
31
+ /** Normalize one opt-in caller header while refusing credential and transport-owned fields. */
32
+ export function normalizeForwardedClientHeaderName(value: unknown): string | null {
33
+ if (typeof value !== "string") return null;
34
+ const name = value.trim().toLowerCase();
35
+ if (!name || !HEADER_NAME_PATTERN.test(name) || !ALLOWED_FORWARDED_CLIENT_HEADERS.has(name)) return null;
36
+ return name;
37
+ }
38
+
39
+ /** Validate provider.forwardClientHeaders without ever admitting credential-bearing headers. */
40
+ export function providerForwardClientHeadersConfigError(value: unknown): string | null {
41
+ if (value === undefined) return null;
42
+ if (!Array.isArray(value)) return "forwardClientHeaders must be an array of header names";
43
+ if (value.length > MAX_FORWARDED_CLIENT_HEADERS) {
44
+ return `forwardClientHeaders must contain at most ${MAX_FORWARDED_CLIENT_HEADERS} names`;
45
+ }
46
+ const seen = new Set<string>();
47
+ for (const raw of value) {
48
+ if (typeof raw !== "string" || !raw.trim() || !HEADER_NAME_PATTERN.test(raw.trim())) {
49
+ return "forwardClientHeaders must use valid HTTP header names";
50
+ }
51
+ const name = raw.trim().toLowerCase();
52
+ if (BLOCKED_FORWARDED_CLIENT_HEADERS.has(name)) {
53
+ return `forwardClientHeaders must not include credential or transport-owned header "${raw.trim()}"`;
54
+ }
55
+ if (!ALLOWED_FORWARDED_CLIENT_HEADERS.has(name)) {
56
+ return "forwardClientHeaders must use the supported client metadata allowlist";
57
+ }
58
+ if (seen.has(name)) return `forwardClientHeaders must not repeat header "${raw.trim()}"`;
59
+ seen.add(name);
60
+ }
61
+ return null;
62
+ }
@@ -96,6 +96,9 @@ export function outboundProxyConfigured(
96
96
  return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
97
97
  }
98
98
 
99
+ // Captured before config/proxy-env applies runtime changes; Bun retains startup proxy env.
100
+ export const startupOutboundProxyConfigured = outboundProxyConfigured();
101
+
99
102
  /**
100
103
  * The value when `raw` is a proxy URL Bun fetch can actually use, else null.
101
104
  * Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol),
@@ -150,7 +150,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
150
150
  if (canonicalize(dir) !== PROTECTED_CODEX_HOME) return;
151
151
  throw new Error(
152
152
  `refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. `
153
- + "Point CODEX_HOME at a temp directory for this test before writing native auth.json.",
153
+ + "Point CODEX_HOME at a temp directory for this test before writing Codex files "
154
+ + "(auth.json, the catalog, models_cache.json, the journal or config.toml).",
154
155
  );
155
156
  }
156
157
 
@@ -9,13 +9,13 @@ import type { OcxConfig } from "../types";
9
9
  import type { AnthropicRouteDecision } from "./anthropic-model-routes";
10
10
  import { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal, hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } from "./anthropic-routing";
11
11
 
12
- const responseCredentials = new WeakMap<Response, Pick<OAuthAccessSnapshot, "accountId" | "generation">>();
12
+ const responseCredentials = new WeakMap<Response, Pick<OAuthAccessSnapshot, "accountId" | "generation"> & { providerAccountUuid?: string; checkProviderUuid: boolean }>();
13
13
  const retryStates = new WeakMap<object, { firstAccountId: string; sameAccount: boolean; detour: boolean }>();
14
14
  const verdicts = new WeakMap<Response, Promise<boolean>>();
15
15
 
16
16
  /** Called only when the outgoing headers prove ownership of the selected stored bearer. */
17
- export function bindAnthropicRefusalCredential(response: Response, snapshot: OAuthAccessSnapshot): void {
18
- responseCredentials.set(response, { accountId: snapshot.accountId, generation: snapshot.generation });
17
+ export function bindAnthropicRefusalCredential(response: Response, snapshot: OAuthAccessSnapshot, providerAccountUuid?: string): void {
18
+ responseCredentials.set(response, { accountId: snapshot.accountId, generation: snapshot.generation, providerAccountUuid, checkProviderUuid: arguments.length >= 3 });
19
19
  }
20
20
 
21
21
  async function isAccountRefusal(response: Response, signal?: AbortSignal): Promise<boolean> {
@@ -55,6 +55,10 @@ export async function rotateAnthropicAccountOnResponse(
55
55
  requestKey?: object;
56
56
  allow429Recovery?: boolean;
57
57
  allowAccountRefusal?: boolean;
58
+ /** Applies to alternate ranking only; the shared same-account throttle remains allowed. */
59
+ excludedAccountIds?: ReadonlySet<string>;
60
+ /** Native dispatch re-reads operator policy after asynchronous classification/waits. */
61
+ currentDecision?: () => AnthropicRouteDecision | null;
58
62
  },
59
63
  ): Promise<string | null> {
60
64
  if (options.signal?.aborted || isNonReplayableResponse(response)) return null;
@@ -62,11 +66,13 @@ export async function rotateAnthropicAccountOnResponse(
62
66
  const sent = responseCredentials.get(response);
63
67
  const current = sent && getAccountCredentialWithStatus("anthropic", sent.accountId);
64
68
  if (!sent || sent.accountId !== options.accountId || !current || current.needsReauth
65
- || credentialGeneration(current.credential) !== sent.generation) return null;
69
+ || credentialGeneration(current.credential) !== sent.generation
70
+ || sent.checkProviderUuid && current.credential.accountId !== sent.providerAccountUuid) return null;
71
+ options.currentDecision?.();
66
72
  const kind = classifyAnthropic429(response.headers);
67
73
  if (kind === "family-quota") {
68
74
  if (anthropicModelFamily(options.model) !== "Fable" || !options.canRetry || options.allow429Recovery === false) return null;
69
- return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
75
+ return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), (options.currentDecision ? options.currentDecision() : options.decision ?? null), options.model, options.excludedAccountIds);
70
76
  }
71
77
  if (kind !== "shared-quota") {
72
78
  if (!isAnthropicAccountPoolEnabled(options.config) && !hasAnthropicFailoverQuorum() && !current.paused) return null;
@@ -87,16 +93,19 @@ export async function rotateAnthropicAccountOnResponse(
87
93
  const live = getAccountCredentialWithStatus("anthropic", sent.accountId);
88
94
  if (!live || live.paused || live.needsReauth || options.signal?.aborted
89
95
  || credentialGeneration(live.credential) !== sent.generation
96
+ || sent.checkProviderUuid && live.credential.accountId !== sent.providerAccountUuid
90
97
  || anthropicRatePauseUntil(sent.accountId, Math.max(Date.now(), retryAt))) return null;
98
+ options.currentDecision?.();
91
99
  return sent.accountId;
92
100
  }
93
101
  if (kind === "transient-rate" && !state.detour) {
94
102
  state.detour = true;
95
- return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
103
+ return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), (options.currentDecision ? options.currentDecision() : options.decision ?? null), options.model, options.excludedAccountIds);
96
104
  }
97
105
  return null;
98
106
  }
99
107
  if (options.allow429Recovery === false) {
108
+ options.currentDecision?.();
100
109
  recordAnthropicAccountRefusal(options.config, options.accountId, 429, response.headers.get("retry-after"), Date.now(), response.headers);
101
110
  return null;
102
111
  }
@@ -112,8 +121,10 @@ export async function rotateAnthropicAccountOnResponse(
112
121
  if (!await verdict || options.signal?.aborted) return null;
113
122
  // A late refusal must not cool a new credential stored while its body was being read.
114
123
  const current = getAccountCredentialWithStatus("anthropic", sent.accountId);
115
- if (!current || current.paused || current.needsReauth || credentialGeneration(current.credential) !== sent.generation) return null;
124
+ if (!current || current.paused || current.needsReauth || credentialGeneration(current.credential) !== sent.generation
125
+ || sent.checkProviderUuid && current.credential.accountId !== sent.providerAccountUuid) return null;
116
126
  }
127
+ const decision = options.currentDecision ? options.currentDecision() : options.decision;
117
128
  const status = response.status === 429 ? 429 : 403;
118
129
  if (!options.canRetry) {
119
130
  recordAnthropicAccountRefusal(options.config, options.accountId, status,
@@ -121,5 +132,5 @@ export async function rotateAnthropicAccountOnResponse(
121
132
  return null;
122
133
  }
123
134
  return rotateAnthropicAccountOnRefusal(options.config, options.accountId, status,
124
- response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, options.decision, options.model);
135
+ response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, decision, options.model, options.excludedAccountIds);
125
136
  }
@@ -0,0 +1,65 @@
1
+ import { detectClaudeCodeToken, hasClaudeCredentialContinuity } from "./local-token-detect";
2
+ import { normalizeAnthropicIdentity, resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
3
+ import { credentialGeneration, type AuthStore } from "./store";
4
+ import { REFRESH_SKEW_MS } from "./refresh-policy";
5
+ import type { OAuthCredentials, ProviderAccountSet } from "./types";
6
+
7
+ export type ClaudeCredentialObservation =
8
+ | { kind: "absent" }
9
+ | { kind: "unresolved" | "different"; diskGeneration: string }
10
+ | { kind: "adopt"; credential: OAuthCredentials; diskGeneration: string };
11
+
12
+ export async function newerClaudeCredential(
13
+ stored: OAuthCredentials, now: number, signal?: AbortSignal,
14
+ resolveIdentity: AnthropicIdentityResolver = resolveAnthropicAccountIdentity,
15
+ ): Promise<ClaudeCredentialObservation> {
16
+ if (stored.source !== "local-cli") return { kind: "absent" };
17
+ const disk = detectClaudeCodeToken();
18
+ if (!disk || !Number.isFinite(disk.expires) || disk.expires <= now + REFRESH_SKEW_MS
19
+ || credentialGeneration(disk) === credentialGeneration(stored)) return { kind: "absent" };
20
+ const diskGeneration = credentialGeneration(disk);
21
+ const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
22
+ if (hasClaudeCredentialContinuity(stored, disk)) {
23
+ return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
24
+ ...stored, ...disk, anthropicIdentity: disk.access === stored.access ? before : undefined,
25
+ } };
26
+ }
27
+ // Independent observations share a deadline through the caller signal; neither extends the other.
28
+ const [oldProof, newProof] = await Promise.all([
29
+ before ?? resolveIdentity(stored.access, signal), resolveIdentity(disk.access, signal),
30
+ ]);
31
+ const oldIdentity = normalizeAnthropicIdentity(oldProof, stored.access);
32
+ const newIdentity = normalizeAnthropicIdentity(newProof, disk.access);
33
+ if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration };
34
+ if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration };
35
+ return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
36
+ ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity,
37
+ } };
38
+ }
39
+
40
+ /** Capture primitives before network awaits; check them again inside the serialized writer. */
41
+ export function captureAnthropicCredentialOwner(set: ProviderAccountSet, accountId: string) {
42
+ const row = set.accounts.find(account => account.id === accountId)!;
43
+ const loginId = row.loginId;
44
+ const generation = credentialGeneration(row.credential);
45
+ const metadata = identityMetadata(row.credential);
46
+ const needsReauth = row.needsReauth;
47
+ const selection = { id: set.activeAccountId, revision: set.selectionRevision };
48
+ return (store: AuthStore, diskGeneration?: string, allowPaused = false): boolean => {
49
+ const current = store["anthropic"];
50
+ const account = current?.accounts.find(candidate => candidate.id === accountId);
51
+ if (!account || (!allowPaused && account.paused) || account.loginId !== loginId
52
+ || account.needsReauth !== needsReauth || credentialGeneration(account.credential) !== generation
53
+ || identityMetadata(account.credential) !== metadata) return false;
54
+ if (diskGeneration !== undefined) {
55
+ if (current?.activeAccountId !== selection.id || current?.selectionRevision !== selection.revision) return false;
56
+ const disk = detectClaudeCodeToken();
57
+ if (!disk || disk.expires <= Date.now() + REFRESH_SKEW_MS || credentialGeneration(disk) !== diskGeneration) return false;
58
+ }
59
+ return true;
60
+ };
61
+ }
62
+
63
+ function identityMetadata(credential: OAuthCredentials): string {
64
+ return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity]);
65
+ }
@@ -0,0 +1,75 @@
1
+ /** Private account proof from an authenticated Anthropic response, bound to its exact bearer. */
2
+ import { hasClaudeCredentialContinuity } from "./local-token-detect";
3
+ import { createHash } from "node:crypto";
4
+ import { readBoundedResponseBody } from "../lib/bounded-body";
5
+ import { CLAUDE_CLI_USER_AGENT } from "../providers/claude-cli-identity";
6
+ import type { OAuthCredentials } from "./types";
7
+
8
+ const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
9
+ const PROFILE_TIMEOUT_MS = 10_000;
10
+ const PROFILE_MAX_BYTES = 65_536;
11
+ export type AnthropicIdentity = NonNullable<OAuthCredentials["anthropicIdentity"]>;
12
+ export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<AnthropicIdentity | undefined>;
13
+
14
+ export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined {
15
+ if (!access || typeof accountUuid !== "string" || !accountUuid.length || accountUuid.length > 128
16
+ || accountUuid !== accountUuid.trim() || /[\x00-\x1f\x7f]/.test(accountUuid)) return undefined;
17
+ return { v: 1, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex") };
18
+ }
19
+
20
+ export function normalizeAnthropicIdentity(value: unknown, access: string): AnthropicIdentity | undefined {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
22
+ const proof = value as Partial<AnthropicIdentity>;
23
+ const bound = bindAnthropicIdentity(access, proof.accountUuid);
24
+ return proof.v === 1 && bound && proof.bearerSha256 === bound.bearerSha256 ? bound : undefined;
25
+ }
26
+
27
+ /** Fixed destination, no redirects, bounded body and deadline; failures never include response data. */
28
+ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async (access, signal) => {
29
+ if (!access || signal?.aborted) return undefined;
30
+ const deadline = AbortSignal.timeout(PROFILE_TIMEOUT_MS);
31
+ const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
32
+ try {
33
+ const response = await fetch(PROFILE_URL, {
34
+ method: "GET", redirect: "error", signal: combined,
35
+ headers: { Authorization: `Bearer ${access}`, Accept: "application/json",
36
+ "anthropic-beta": "oauth-2025-04-20",
37
+ "Cache-Control": "no-cache", "User-Agent": CLAUDE_CLI_USER_AGENT },
38
+ });
39
+ if (!response.ok || response.redirected) {
40
+ void response.body?.cancel().catch(() => {});
41
+ return undefined;
42
+ }
43
+ const body = await readBoundedResponseBody(response, {
44
+ signal: combined, maxBytes: PROFILE_MAX_BYTES, fatalUtf8: true,
45
+ totalTimeoutMs: PROFILE_TIMEOUT_MS, inactivityTimeoutMs: PROFILE_TIMEOUT_MS,
46
+ });
47
+ if (body.truncated || body.oversized || body.timedOut || combined.aborted) return undefined;
48
+ const data: unknown = JSON.parse(body.text);
49
+ if (!data || typeof data !== "object" || !("account" in data)) return undefined;
50
+ const account = data.account;
51
+ return account && typeof account === "object" && "uuid" in account
52
+ ? bindAnthropicIdentity(access, account.uuid) : undefined;
53
+ } catch {
54
+ // Unavailable identity is an explicit unresolved result, never an ownership guess.
55
+ return undefined;
56
+ }
57
+ };
58
+
59
+ /** A changed access bearer cannot inherit the previous bearer's proof. */
60
+ export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): Pick<OAuthCredentials, "anthropicIdentity"> {
61
+ const before = normalizeAnthropicIdentity(previous.anthropicIdentity, previous.access);
62
+ const after = normalizeAnthropicIdentity(fresh.anthropicIdentity, fresh.access);
63
+ if (before && after && before.accountUuid !== after.accountUuid) {
64
+ throw new Error("Anthropic credential identity changed; explicit account login required");
65
+ }
66
+ return { anthropicIdentity: after ?? (fresh.access === previous.access ? before : undefined) };
67
+ }
68
+
69
+ /** Explicit imports enrich only a proven slot; labels and paths never establish ownership. */
70
+ export function matchesAnthropicImport(stored: OAuthCredentials, imported: OAuthCredentials): boolean {
71
+ const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
72
+ const after = normalizeAnthropicIdentity(imported.anthropicIdentity, imported.access);
73
+ if (before && after) return before.accountUuid === after.accountUuid;
74
+ return hasClaudeCredentialContinuity(stored, imported);
75
+ }
@@ -18,6 +18,7 @@ import { anthropicFamilyRejected, anthropicFamilyRetryAt, anthropicModelExhauste
18
18
  * Token refresh failures retain the existing store needsReauth policy.
19
19
  */
20
20
  import { anthropicRatePauseUntil, clearAnthropicRatePauses, classifyAnthropic429, anthropicRetryAfterMs, pauseAnthropicRateAdmission } from "./anthropic-rate-limit-policy";
21
+ import { REFRESH_SKEW_MS } from "./refresh-policy";
21
22
  import { createHash } from "node:crypto";
22
23
  import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
23
24
  import type { OAuthAccessSnapshot } from "./index";
@@ -110,6 +111,9 @@ type OAuthAccountSelection = NonNullable<ReturnType<typeof captureOAuthAccountSe
110
111
  // Undefined means this runtime has not admitted a selection yet; null means consumed.
111
112
  // The startup baseline comes from the authoritative store, never a second persisted pin.
112
113
  let manualPreference: OAuthAccountSelection | null | undefined;
114
+ let manualSelectionGeneration = 0;
115
+ /** Automatic pointer moves preserve affinity; manual selection revokes older bindings. */
116
+ export function captureAnthropicManualSelectionGeneration(): number { return manualSelectionGeneration; }
113
117
 
114
118
  function normalizeAffinityComponent(value: string | null | undefined): string {
115
119
  const normalized = value?.trim() ?? "";
@@ -284,6 +288,7 @@ export function clearAnthropicAccountPoolState(): void {
284
288
  clearAnthropicCooldownGenerations();
285
289
  sessionAffinity.clear();
286
290
  manualPreference = undefined;
291
+ manualSelectionGeneration++;
287
292
  quorumCache = null;
288
293
  }
289
294
 
@@ -353,7 +358,6 @@ function usageScore(config: OcxConfig, accountId: string, model?: string): numbe
353
358
  }
354
359
  }
355
360
 
356
- const TOKEN_SKEW_MS = 60_000;
357
361
 
358
362
  /** Background `local-cli` slots with expired access are not pool-eligible (identity adoption risk). */
359
363
  function isPoolCredentialUsable(accountId: string, now: number): boolean {
@@ -361,7 +365,7 @@ function isPoolCredentialUsable(accountId: string, now: number): boolean {
361
365
  if (!cred) return false;
362
366
  if (cred.source !== "local-cli") return true;
363
367
  if (canRefreshAnthropicPoolAccount(accountId)) return true;
364
- return cred.expires > now + TOKEN_SKEW_MS;
368
+ return cred.expires > now + REFRESH_SKEW_MS;
365
369
  }
366
370
 
367
371
  export function getEligibleAnthropicAccounts(now = Date.now(), model?: string): string[] {
@@ -508,9 +512,9 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
508
512
  return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
509
513
  }
510
514
 
511
- function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string): string | null {
515
+ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string, excludedAccountIds?: ReadonlySet<string>): string | null {
512
516
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
513
- const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
517
+ const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
514
518
  const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
515
519
  || isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered;
516
520
  const modelAvailable = model ? available.filter(id => !anthropicModelExhausted(id, model)) : available;
@@ -589,16 +593,17 @@ export function pickAlternateAnthropicAccount(
589
593
  now: number,
590
594
  decision: AnthropicRouteDecision | null,
591
595
  model?: string,
596
+ excludedAccountIds?: ReadonlySet<string>,
592
597
  ): string | null {
593
598
  const strategy = anthropicPoolStrategy(config);
594
- const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
599
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
595
600
  if (strategy === "round-robin") {
596
601
  return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
597
602
  }
598
603
  if (strategy === "fill-first") {
599
604
  return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision, model);
600
605
  }
601
- return pickLowestUsage(config, excludeId, now, decision, model);
606
+ return pickLowestUsage(config, excludeId, now, decision, model, excludedAccountIds);
602
607
  }
603
608
 
604
609
  function pruneExpiredAffinity(now: number): void {
@@ -918,6 +923,7 @@ export function rotateAnthropicAccountOnRefusal(
918
923
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
919
924
  decision: AnthropicRouteDecision | null = null,
920
925
  model?: string,
926
+ excludedAccountIds?: ReadonlySet<string>,
921
927
  ): string | null {
922
928
  if (!recordAnthropicAccountRefusal(config, failedAccountId, status, retryAfterHeader, now, rateLimitHeaders)) return null;
923
929
 
@@ -926,8 +932,8 @@ export function rotateAnthropicAccountOnRefusal(
926
932
  // because those dormant values remain in config. The quota picker is the neutral
927
933
  // recovery policy already used by the default strategy.
928
934
  const next = isAnthropicAccountPoolEnabled(config)
929
- ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model)
930
- : pickLowestUsage(config, failedAccountId, now, null, model);
935
+ ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model, excludedAccountIds)
936
+ : pickLowestUsage(config, failedAccountId, now, null, model, excludedAccountIds);
931
937
  if (!next) {
932
938
  console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning ${status}`);
933
939
  return null;
@@ -1070,6 +1076,7 @@ export function commitAnthropicSelectionRouting(
1070
1076
  */
1071
1077
  export function resetAnthropicRoutingForManualSelection(accountId: string): void {
1072
1078
  sessionAffinity.clear();
1079
+ manualSelectionGeneration++;
1073
1080
  manualPreference = captureOAuthAccountSelection(PROVIDER);
1074
1081
  seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
1075
1082
  // A manual account selection is an operator statement about the roster; do not answer the
@@ -1093,7 +1100,7 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise<st
1093
1100
  const { OAuthLoginRequiredError } = await import("./index");
1094
1101
  throw new OAuthLoginRequiredError(PROVIDER);
1095
1102
  }
1096
- if (stored.expires > Date.now() + TOKEN_SKEW_MS) return stored.access;
1103
+ if (stored.expires > Date.now() + REFRESH_SKEW_MS) return stored.access;
1097
1104
  if (!canRefreshAnthropicPoolAccount(accountId)) {
1098
1105
  throw new Error("background local-cli token expired; refuse CLI-adopting refresh for pool");
1099
1106
  }
@@ -1172,12 +1179,10 @@ export function anthropicSessionKeyFromParts(input: {
1172
1179
  /** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
1173
1180
  promptCacheKeyIsSharedCohort?: boolean;
1174
1181
  }): string | null {
1175
- const preferred = (
1176
- input.clientThreadId
1177
- ?? input.sessionIdHeader
1178
- ?? input.threadIdHeader
1179
- ?? ""
1180
- ).trim();
1182
+ const preferred = input.clientThreadId?.trim()
1183
+ || input.sessionIdHeader?.trim()
1184
+ || input.threadIdHeader?.trim()
1185
+ || "";
1181
1186
  if (preferred) {
1182
1187
  return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
1183
1188
  }
@@ -1,7 +1,9 @@
1
1
  /** Anthropic OAuth flow (Claude Pro/Max). Ported from jawcode oauth/anthropic.ts. */
2
2
  import { OAuthCallbackFlow } from "./callback-server";
3
+ import { bindAnthropicIdentity, resolveAnthropicAccountIdentity } from "./anthropic-identity";
3
4
  import { generatePKCE } from "./pkce";
4
5
  import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
6
+ import { outboundProxyConfigured, startupOutboundProxyConfigured } from "../lib/proxy-env";
5
7
 
6
8
  const CLIENT_ID = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
7
9
  const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
@@ -39,6 +41,7 @@ export class AnthropicTokenError extends Error {
39
41
  message: string,
40
42
  readonly httpStatus: number | undefined,
41
43
  readonly oauthError: string | undefined,
44
+ readonly requestNotSent = false,
42
45
  ) {
43
46
  super(message);
44
47
  this.name = "AnthropicTokenError";
@@ -46,12 +49,35 @@ export class AnthropicTokenError extends Error {
46
49
  }
47
50
 
48
51
  async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
49
- const response = await fetch(url, {
52
+ const direct = !startupOutboundProxyConfigured && !outboundProxyConfigured();
53
+ const init: RequestInit & { protocol: "http1.1" } = {
50
54
  method: "POST",
51
55
  headers: { "Content-Type": "application/json", Accept: "application/json" },
52
56
  body: JSON.stringify(body),
53
57
  signal: AbortSignal.timeout(30_000),
54
- });
58
+ // Pinned Bun 1.4.0: one HTTP/1.1 attempt, no keep-alive reuse or redirect following.
59
+ // Only without an outbound proxy can token-host DNS failure prove no request was sent.
60
+ redirect: "manual",
61
+ keepalive: false,
62
+ protocol: "http1.1",
63
+ };
64
+ let response: Response;
65
+ try {
66
+ response = await fetch(url, init);
67
+ } catch (error) {
68
+ if (direct && typeof error === "object" && error !== null
69
+ && "code" in error && error.code === "ENOTFOUND"
70
+ && "syscall" in error && error.syscall === "getaddrinfo"
71
+ && "hostname" in error && error.hostname === new URL(url).hostname) {
72
+ throw new AnthropicTokenError("Anthropic OAuth DNS lookup failed before connection", undefined, undefined, true);
73
+ }
74
+ throw error;
75
+ }
76
+ if (response.status >= 300 && response.status < 400) {
77
+ // A redirect can follow a completed POST and token rotation; it is not a rejection.
78
+ await response.body?.cancel().catch(() => {});
79
+ throw new AnthropicTokenError(`Anthropic OAuth redirect HTTP ${response.status}: outcome unknown`, undefined, undefined);
80
+ }
55
81
  const responseBody = await response.text();
56
82
  if (!response.ok) {
57
83
  let oauthError: string | undefined;
@@ -96,6 +122,7 @@ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAut
96
122
  refresh: data.refresh_token || refreshFallback || "",
97
123
  access: data.access_token,
98
124
  expires,
125
+ anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid),
99
126
  accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined,
100
127
  email: typeof email === "string" && email.length > 0 ? email : undefined,
101
128
  };
@@ -159,7 +186,11 @@ export async function loginAnthropic(
159
186
  const local = detectClaudeCodeToken();
160
187
  if (local) {
161
188
  ctrl.onProgress?.("Found Claude Code token, importing automatically");
162
- if (local.expires >= Date.now() + 60_000) return local;
189
+ if (local.expires >= Date.now() + 60_000) {
190
+ const identity = await resolveAnthropicAccountIdentity(local.access, ctrl.signal);
191
+ ctrl.signal?.throwIfAborted();
192
+ return { ...local, ...(identity ? { accountId: identity.accountUuid, anthropicIdentity: identity } : {}) };
193
+ }
163
194
  try {
164
195
  return { ...(await refreshAnthropicToken(local.refresh)), source: "local-cli" };
165
196
  } catch (error) {
@@ -734,3 +734,24 @@ export function clearGenericFailoverHealth(providerName?: string): void {
734
734
  if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
735
735
  }
736
736
  }
737
+
738
+ /**
739
+ * Retire one account's superseded auth evidence after an explicit (re-)login. An
740
+ * auth cooldown recorded against the old grant must not hold out the fresh
741
+ * credential, so it leaves with the login rather than lingering until its
742
+ * deadline. Rate, quota, and suspension evidence is preserved: a re-login says
743
+ * nothing about those verdicts, and dropping them would make a throttled account
744
+ * eligible early. Entries bound to the still-current generation stay as well.
745
+ */
746
+ export function clearGenericFailoverHealthForAccount(providerName: string, accountId: string): void {
747
+ const live = getAccountSet(providerName)?.accounts.find(row => row.id === accountId);
748
+ const current = live ? credentialGeneration(live.credential) : undefined;
749
+ const bare = `${providerName}\u0000${accountId}`;
750
+ for (const key of [...health.keys()]) {
751
+ if (key !== bare && !key.startsWith(`${bare}\u0000`)) continue;
752
+ const entry = health.get(key);
753
+ if (!entry || entry.cooldownSource !== "auth") continue;
754
+ if (current !== undefined && entry.identity === current) continue;
755
+ health.delete(key);
756
+ }
757
+ }