@bitkyc08/opencodex 2.86.0 → 2.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (378) hide show
  1. package/gui/dist/assets/App-ZfYbLHkB.js +58 -0
  2. package/gui/dist/assets/App-wT3mXqFz.css +1 -0
  3. package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
  4. package/gui/dist/assets/index-CfYDMj34.js +94 -0
  5. package/gui/dist/assets/index-DPeB9HpV.css +1 -0
  6. package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic/beta-allowlist.ts +9 -8
  10. package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -23
  12. package/src/adapters/anthropic-progress.ts +84 -0
  13. package/src/adapters/anthropic.ts +108 -21
  14. package/src/adapters/base.ts +2 -0
  15. package/src/adapters/claude-cli/adapter.ts +111 -23
  16. package/src/adapters/claude-cli/profiles.ts +2 -1
  17. package/src/adapters/claude-cli/stable-replay.ts +108 -0
  18. package/src/adapters/claude-cli/usage-admission.ts +230 -0
  19. package/src/adapters/coding-agent/protocol.ts +1 -1
  20. package/src/adapters/coding-agent/turn.ts +18 -2
  21. package/src/adapters/dsh-account.ts +97 -0
  22. package/src/adapters/google.ts +35 -8
  23. package/src/adapters/openai-chat/messages.ts +4 -3
  24. package/src/adapters/openai-chat/passthrough.ts +8 -2
  25. package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
  26. package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
  27. package/src/adapters/openai-responses/passthrough.ts +57 -125
  28. package/src/adapters/openai-responses/request-strips.ts +25 -3
  29. package/src/adapters/openai-responses/transport.ts +81 -0
  30. package/src/adapters/qoder/adapter.ts +4 -2
  31. package/src/adapters/registry.ts +5 -0
  32. package/src/adapters/zed.ts +26 -1
  33. package/src/advisor/activity.ts +55 -0
  34. package/src/advisor/budget.ts +62 -0
  35. package/src/advisor/consult.ts +4 -1
  36. package/src/advisor/context.ts +16 -4
  37. package/src/advisor/evidence-tool.ts +21 -0
  38. package/src/advisor/evidence.ts +117 -0
  39. package/src/advisor/runtime.ts +79 -31
  40. package/src/advisor/settings.ts +11 -2
  41. package/src/bridge/errors.ts +19 -1
  42. package/src/bridge/internal.ts +3 -1
  43. package/src/bridge/response-json.ts +18 -3
  44. package/src/bridge/sse.ts +58 -8
  45. package/src/bridge/terminal-refusal.ts +59 -0
  46. package/src/claude/desktop-3p-library.ts +32 -7
  47. package/src/claude/desktop-3p.ts +55 -13
  48. package/src/claude/desktop-first-party.ts +1 -1
  49. package/src/claude/desktop-picker-platform.ts +4 -0
  50. package/src/claude/desktop-picker.ts +15 -8
  51. package/src/claude/desktop-system-proxy.ts +50 -4
  52. package/src/claude/inbound-content-options.ts +11 -0
  53. package/src/claude/inbound.ts +33 -0
  54. package/src/claude/intercept/picker-ca-startup.ts +2 -1
  55. package/src/claude/intercept/picker-runtime.ts +6 -5
  56. package/src/claude/intercept/picker-trust-windows.ts +338 -0
  57. package/src/claude/intercept/picker-trust.ts +19 -0
  58. package/src/claude/intercept/port-policy.ts +30 -0
  59. package/src/claude/intercept/runtime.ts +97 -94
  60. package/src/claude/outbound.ts +38 -13
  61. package/src/cli/account-api.ts +1 -0
  62. package/src/cli/account-policy-dto.ts +6 -0
  63. package/src/cli/account-policy.ts +34 -5
  64. package/src/cli/account-preservation.ts +44 -0
  65. package/src/cli/account.ts +8 -2
  66. package/src/cli/advisor.ts +17 -3
  67. package/src/cli/capabilities-accounts.ts +20 -1
  68. package/src/cli/capabilities-agents-routing.ts +5 -2
  69. package/src/cli/capabilities-base.ts +38 -10
  70. package/src/cli/capabilities-observe-system.ts +63 -3
  71. package/src/cli/capabilities-provider-models.ts +11 -0
  72. package/src/cli/codex-cli-update.ts +151 -41
  73. package/src/cli/codex-provider.ts +44 -0
  74. package/src/cli/combo-input.ts +10 -1
  75. package/src/cli/combo-result.ts +1 -1
  76. package/src/cli/combo.ts +2 -1
  77. package/src/cli/cross-home-owner.ts +17 -14
  78. package/src/cli/dispatch.ts +25 -4
  79. package/src/cli/doctor.ts +6 -1
  80. package/src/cli/ensure-desktop-cold-start.ts +60 -7
  81. package/src/cli/help.ts +3 -2
  82. package/src/cli/index.ts +1 -1
  83. package/src/cli/integration-plan-dto.ts +1 -0
  84. package/src/cli/log-history.ts +172 -0
  85. package/src/cli/models-runtime-subcommands.ts +1 -0
  86. package/src/cli/models-runtime.ts +72 -8
  87. package/src/cli/observe-stream.ts +2 -0
  88. package/src/cli/observe.ts +16 -4
  89. package/src/cli/provider.ts +27 -5
  90. package/src/cli/registry.ts +28 -8
  91. package/src/cli/runtime-api.ts +5 -3
  92. package/src/cli/system-command.ts +40 -8
  93. package/src/cli/uninstall-client-state.ts +32 -0
  94. package/src/cli/usage-compare.ts +309 -0
  95. package/src/cli/usage-model-search.ts +4 -4
  96. package/src/cli/usage-report.ts +2 -2
  97. package/src/cli/v2.ts +1 -1
  98. package/src/client/machine-listener.ts +1 -3
  99. package/src/client/runtime.ts +1 -0
  100. package/src/clients/client-path.ts +51 -0
  101. package/src/clients/config-export/contracts.ts +2 -1
  102. package/src/clients/config-export.ts +18 -44
  103. package/src/clients/gentle-shell-path.ts +87 -0
  104. package/src/codex/account-auto-switch.ts +2 -0
  105. package/src/codex/account-identity.ts +29 -0
  106. package/src/codex/account-label.ts +1 -1
  107. package/src/codex/account-lifecycle.ts +21 -5
  108. package/src/codex/account-preservation.ts +28 -0
  109. package/src/codex/account-use-remaining.ts +23 -0
  110. package/src/codex/app-server-processes.ts +97 -7
  111. package/src/codex/app-server-restart-service.ts +55 -4
  112. package/src/codex/auth-api/account-list.ts +4 -0
  113. package/src/codex/auth-api/account-pause-group.ts +6 -50
  114. package/src/codex/auth-api/account-preservation.ts +57 -0
  115. package/src/codex/auth-api/routes.ts +8 -0
  116. package/src/codex/auth-api/use-remaining-route.ts +36 -0
  117. package/src/codex/auth-api.ts +0 -1
  118. package/src/codex/auth-context.ts +2 -2
  119. package/src/codex/catalog/auto-review.ts +20 -3
  120. package/src/codex/catalog/build-entries.ts +9 -1
  121. package/src/codex/catalog/combo-member.ts +14 -5
  122. package/src/codex/catalog/configured-tiers.ts +1 -1
  123. package/src/codex/catalog/gather-capture.ts +2 -0
  124. package/src/codex/catalog/metadata.ts +11 -9
  125. package/src/codex/catalog/model-hints.ts +21 -16
  126. package/src/codex/catalog/model-visibility.ts +3 -1
  127. package/src/codex/catalog/parsing.ts +30 -86
  128. package/src/codex/catalog/provider-models.ts +36 -34
  129. package/src/codex/catalog/routed-gather.ts +12 -13
  130. package/src/codex/cli-install-provenance.ts +105 -17
  131. package/src/codex/cli-update-plan.ts +545 -0
  132. package/src/codex/config-write-lock.ts +23 -2
  133. package/src/codex/desktop-app/picker-freshness.ts +96 -0
  134. package/src/codex/desktop-app-restart.ts +21 -0
  135. package/src/codex/desktop-compatibility/installed-build.ts +24 -6
  136. package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
  137. package/src/codex/desktop-compatibility/runtime.ts +12 -3
  138. package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
  139. package/src/codex/features.ts +85 -45
  140. package/src/codex/inject/history-failure.ts +27 -0
  141. package/src/codex/inject/restore.ts +24 -2
  142. package/src/codex/inject/routing-classify.ts +4 -3
  143. package/src/codex/inject.ts +18 -27
  144. package/src/codex/main-account-hard-lock.ts +30 -1
  145. package/src/codex/native-profile-startup.ts +1 -1
  146. package/src/codex/ocx-compaction-history.ts +12 -10
  147. package/src/codex/paths.ts +86 -1
  148. package/src/codex/provider-routing.ts +174 -0
  149. package/src/codex/refresh.ts +2 -2
  150. package/src/codex/routing/health-store.ts +1 -1
  151. package/src/codex/routing/selection.ts +4 -3
  152. package/src/codex/routing/thread-affinity.ts +1 -1
  153. package/src/codex/signed-in-identity.ts +43 -0
  154. package/src/codex/subagent-model-fallback.ts +1 -1
  155. package/src/codex/sync.ts +6 -1
  156. package/src/codex/user-identity.ts +16 -5
  157. package/src/codex/windows-local-appdata.ts +84 -0
  158. package/src/combos/failover.ts +6 -1
  159. package/src/combos/index.ts +1 -0
  160. package/src/combos/resolve.ts +46 -10
  161. package/src/combos/types.ts +15 -0
  162. package/src/config/diagnostics.ts +33 -6
  163. package/src/config/live-reconcile.ts +38 -2
  164. package/src/config/load-degrade.ts +17 -0
  165. package/src/config/opencode-wire-upgrade.ts +54 -0
  166. package/src/config/opencode-zen-upgrade.ts +52 -0
  167. package/src/config/owner-registry.ts +22 -17
  168. package/src/config/provider-validation.ts +45 -2
  169. package/src/config/schema/config-schema.ts +22 -0
  170. package/src/config/schema/leaf-validators.ts +38 -9
  171. package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
  172. package/src/config/schema/native-reasoning-retention.ts +26 -0
  173. package/src/config.ts +2 -0
  174. package/src/generated/compatibility-version.json +608 -284
  175. package/src/grok/sync.ts +1 -1
  176. package/src/images/loop.ts +46 -8
  177. package/src/integrations/catalog-refresh.ts +1 -1
  178. package/src/integrations/mutation-plan.ts +1 -0
  179. package/src/integrations/registry.ts +8 -0
  180. package/src/lib/codex-restart-contract.ts +15 -0
  181. package/src/lib/desktop-upstream-tunnel.ts +18 -53
  182. package/src/lib/empty-tool-catalog.ts +62 -0
  183. package/src/lib/errors.ts +19 -3
  184. package/src/lib/gcp-adc.ts +96 -9
  185. package/src/lib/gcp-credential-input.ts +12 -0
  186. package/src/lib/lab-live-route-production.ts +5 -0
  187. package/src/lib/opaque-proxy-tunnel.ts +217 -0
  188. package/src/lib/redact.ts +2 -2
  189. package/src/oauth/account-quota-rank.ts +69 -2
  190. package/src/oauth/anthropic-account-refusal.ts +20 -2
  191. package/src/oauth/anthropic-oauth-definitions.ts +3 -1
  192. package/src/oauth/anthropic-routing.ts +1 -21
  193. package/src/oauth/anthropic.ts +38 -7
  194. package/src/oauth/callback-server.ts +27 -1
  195. package/src/oauth/chatgpt-device.ts +1 -1
  196. package/src/oauth/chatgpt-shared.ts +83 -0
  197. package/src/oauth/chatgpt.ts +8 -72
  198. package/src/oauth/dsh.ts +203 -0
  199. package/src/oauth/generic-account-failover.ts +33 -7
  200. package/src/oauth/generic-session-affinity.ts +125 -0
  201. package/src/oauth/index.ts +184 -109
  202. package/src/oauth/local-token-detect.ts +21 -1
  203. package/src/oauth/login-cli-errors.ts +44 -0
  204. package/src/oauth/login-cli.ts +69 -7
  205. package/src/oauth/login-flow-state.ts +371 -23
  206. package/src/oauth/meta-muse-errors.ts +41 -0
  207. package/src/oauth/meta-muse-keychain.ts +52 -0
  208. package/src/oauth/meta-muse.ts +51 -84
  209. package/src/oauth/model-discovery-auth.ts +3 -0
  210. package/src/oauth/pool-settings-capability.ts +4 -2
  211. package/src/oauth/refresh-flight-retirement.ts +48 -0
  212. package/src/oauth/session-affinity-key.ts +23 -0
  213. package/src/oauth/store-anthropic-instance.ts +9 -2
  214. package/src/oauth/store.ts +12 -5
  215. package/src/protocols/encoders/adapter-events.ts +5 -4
  216. package/src/protocols/encoders/messages.ts +9 -6
  217. package/src/protocols/provider-summary.ts +2 -1
  218. package/src/providers/api-key-cooldowns.ts +49 -0
  219. package/src/providers/api-key-resolve.ts +11 -6
  220. package/src/providers/api-key-response-ownership.ts +57 -0
  221. package/src/providers/api-key-selection.ts +31 -6
  222. package/src/providers/api-keys.ts +214 -3
  223. package/src/providers/claude-cli-usage.ts +96 -0
  224. package/src/providers/dsh-account-destination.ts +31 -0
  225. package/src/providers/dsh-account-models.ts +41 -0
  226. package/src/providers/gcp-credential-cleanup.ts +59 -0
  227. package/src/providers/key-failover.ts +54 -50
  228. package/src/providers/key-store.ts +7 -2
  229. package/src/providers/model-rename-fields.ts +3 -0
  230. package/src/providers/model-rename-migration.ts +2 -2
  231. package/src/providers/openai-tiers-destination.ts +11 -2
  232. package/src/providers/openai-tiers.ts +5 -12
  233. package/src/providers/opencode-go-transport.ts +3 -1
  234. package/src/providers/quota/account-cache.ts +6 -5
  235. package/src/providers/quota/account-row.ts +34 -0
  236. package/src/providers/quota/antigravity.ts +28 -7
  237. package/src/providers/quota/dsh-account.ts +70 -0
  238. package/src/providers/quota/vendor-probes-oauth.ts +1 -93
  239. package/src/providers/quota-types.ts +2 -0
  240. package/src/providers/quota.ts +14 -20
  241. package/src/providers/registry/entries-core.ts +37 -8
  242. package/src/providers/registry/types.ts +1 -1
  243. package/src/providers/request-pacing.ts +77 -19
  244. package/src/providers/routed-config.ts +339 -0
  245. package/src/providers/service-tier.ts +2 -1
  246. package/src/quota/window-mapping.ts +5 -2
  247. package/src/responses/anthropic-progress.ts +34 -0
  248. package/src/responses/assistant-replay.ts +35 -0
  249. package/src/responses/compaction-history-framing.ts +65 -0
  250. package/src/responses/compaction.ts +6 -1
  251. package/src/responses/incomplete-reason.ts +31 -0
  252. package/src/responses/parser.ts +50 -5
  253. package/src/responses/reasoning-envelope.ts +4 -0
  254. package/src/responses/reasoning-replay-cache.ts +24 -2
  255. package/src/responses/reasoning-retention.ts +173 -0
  256. package/src/responses/retained-compaction.ts +46 -0
  257. package/src/responses/schema.ts +23 -0
  258. package/src/router.ts +8 -321
  259. package/src/routing/health.ts +37 -22
  260. package/src/routing/history/indexer.ts +6 -5
  261. package/src/routing/history/ingest.ts +6 -2
  262. package/src/routing/profile.ts +1 -1
  263. package/src/server/admission-model-scope.ts +17 -0
  264. package/src/server/auth-cors.ts +11 -0
  265. package/src/server/chat-completions.ts +7 -1
  266. package/src/server/chat-native-reasoning.ts +123 -0
  267. package/src/server/chat-native-sse.ts +110 -7
  268. package/src/server/chat-native.ts +76 -17
  269. package/src/server/claude-messages.ts +55 -414
  270. package/src/server/effort-policy.ts +1 -1
  271. package/src/server/images.ts +15 -2
  272. package/src/server/index/model-alias-id.ts +36 -0
  273. package/src/server/index/optional-listeners.ts +2 -0
  274. package/src/server/index/serve-options.ts +9 -1
  275. package/src/server/index/startup-warnings.ts +2 -0
  276. package/src/server/index.ts +1 -1
  277. package/src/server/inference/advisor-diagnostics.ts +31 -0
  278. package/src/server/management/advisor-routes.ts +15 -4
  279. package/src/server/management/claude-desktop-picker-routes.ts +1 -1
  280. package/src/server/management/combo-routes.ts +176 -50
  281. package/src/server/management/companion-routes.ts +1 -1
  282. package/src/server/management/config-routes.ts +75 -4
  283. package/src/server/management/cursor-integration-routes.ts +1 -1
  284. package/src/server/management/model-routes.ts +11 -6
  285. package/src/server/management/model-rows.ts +14 -0
  286. package/src/server/management/native-integration-routes.ts +2 -7
  287. package/src/server/management/native-reasoning-retention-routes.ts +41 -0
  288. package/src/server/management/oauth-account-routes.ts +87 -14
  289. package/src/server/management/provider-gcp-credential-routes.ts +29 -0
  290. package/src/server/management/provider-key-pacing-routes.ts +31 -0
  291. package/src/server/management/provider-overwrite-carry.ts +7 -0
  292. package/src/server/management/provider-routes.ts +25 -30
  293. package/src/server/management/route-registry.ts +10 -1
  294. package/src/server/management-api.ts +3 -1
  295. package/src/server/messages-native-tool-guard.ts +185 -0
  296. package/src/server/messages-native.ts +101 -21
  297. package/src/server/messages-shared.ts +431 -0
  298. package/src/server/relay.ts +1 -1
  299. package/src/server/request-log-terminal-status.ts +1 -1
  300. package/src/server/request-log.ts +6 -1
  301. package/src/server/responses/account-change-state.ts +8 -2
  302. package/src/server/responses/adapter-continuation.ts +13 -1
  303. package/src/server/responses/adapter-delivery.ts +19 -6
  304. package/src/server/responses/adapter-dispatch.ts +19 -2
  305. package/src/server/responses/advisor-retention.ts +117 -0
  306. package/src/server/responses/advisor-slot.ts +73 -41
  307. package/src/server/responses/codex-ws-wire.ts +15 -8
  308. package/src/server/responses/compact.ts +303 -125
  309. package/src/server/responses/compaction-recovery.ts +18 -10
  310. package/src/server/responses/compaction-retention.ts +61 -0
  311. package/src/server/responses/context-overflow.ts +11 -6
  312. package/src/server/responses/core-auth.ts +6 -0
  313. package/src/server/responses/core-codex-account.ts +11 -1
  314. package/src/server/responses/core-combo-failure.ts +2 -0
  315. package/src/server/responses/core-combo.ts +120 -36
  316. package/src/server/responses/core-errors.ts +3 -3
  317. package/src/server/responses/core-lifetime.ts +5 -2
  318. package/src/server/responses/core-opaque-recovery.ts +35 -0
  319. package/src/server/responses/core-options.ts +5 -0
  320. package/src/server/responses/core-pipeline.ts +141 -0
  321. package/src/server/responses/core-replay.ts +18 -2
  322. package/src/server/responses/core.ts +2 -115
  323. package/src/server/responses/fetch-helpers.ts +73 -19
  324. package/src/server/responses/main-hard-lock-routing.ts +109 -0
  325. package/src/server/responses/native-response-control.ts +4 -2
  326. package/src/server/responses/passthrough-delivery.ts +3 -1
  327. package/src/server/responses/passthrough-dispatch.ts +15 -13
  328. package/src/server/responses/request-prepare.ts +76 -12
  329. package/src/server/responses/request-sidecar-auth.ts +2 -2
  330. package/src/server/responses/request-transport.ts +104 -27
  331. package/src/server/responses/response-effects.ts +1 -0
  332. package/src/server/responses/run-turn-execution.ts +96 -30
  333. package/src/server/responses/sidecar-execution.ts +20 -2
  334. package/src/server/responses/terminal-guard.ts +2 -16
  335. package/src/service/auth-preflight.ts +71 -0
  336. package/src/service/cli.ts +4 -2
  337. package/src/service/definition.ts +97 -0
  338. package/src/service/guards.ts +1 -61
  339. package/src/service/health-sentinel-linux.ts +112 -0
  340. package/src/service/health-sentinel-main.ts +76 -57
  341. package/src/service/health.ts +5 -89
  342. package/src/service/launchd.ts +27 -7
  343. package/src/service/logging.ts +72 -9
  344. package/src/service/repair.ts +2 -1
  345. package/src/service/state.ts +7 -8
  346. package/src/service/systemd.ts +1 -1
  347. package/src/service/windows-guarded-manager.ts +2 -1
  348. package/src/service/windows-ops.ts +1 -1
  349. package/src/service/windows-scheduler.ts +2 -7
  350. package/src/service/windows-taskxml.ts +2 -2
  351. package/src/service/windows-wscript.ts +14 -0
  352. package/src/service.ts +4 -2
  353. package/src/sidecar/auth.ts +2 -0
  354. package/src/types/config.ts +64 -3
  355. package/src/types/opencode-go.ts +61 -0
  356. package/src/types/opencode-zen.ts +29 -0
  357. package/src/types/provider.ts +32 -6
  358. package/src/types/request.ts +23 -2
  359. package/src/types/wire.ts +29 -37
  360. package/src/types.ts +1 -0
  361. package/src/update/npm-invocation.mjs +12 -2
  362. package/src/usage/cost.ts +48 -12
  363. package/src/usage/expected-prices.ts +3 -0
  364. package/src/usage/ledger-retention.ts +2 -1
  365. package/src/usage/ledger-scanner.ts +6 -5
  366. package/src/usage/log.ts +17 -8
  367. package/src/usage/summary.ts +22 -9
  368. package/src/usage/user-cost-overlays.ts +14 -19
  369. package/src/usage/user-cost-validation.ts +38 -0
  370. package/src/vision/eligibility.ts +13 -0
  371. package/src/web-search/loop.ts +55 -5
  372. package/src/web-search/progress-stream.ts +4 -1
  373. package/src/web-search/run-turn-loop.ts +9 -1
  374. package/gui/dist/assets/App-BjlttVLy.js +0 -58
  375. package/gui/dist/assets/App-CSJvmjNs.css +0 -1
  376. package/gui/dist/assets/index-BB6uK6tU.css +0 -1
  377. package/gui/dist/assets/index-BMssP4k3.js +0 -94
  378. package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { isApprovedCodexProviderUrl } from "../provider-routing";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import {
3
4
  hasInjectedCodexRouting,
@@ -54,7 +55,7 @@ export function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
54
55
  }
55
56
 
56
57
  /** Classify actual routing dependency separately from opencodex ownership. */
57
- export function classifyCodexRouting(content: string): CodexRoutingKind {
58
+ export function classifyCodexRouting(content: string, approvedProviderUrl?: (id: string, url: string) => boolean): CodexRoutingKind {
58
59
  const rootBaseUrl = rootTomlString(content, "openai_base_url");
59
60
  if (rootBaseUrl) {
60
61
  const endpoint = classifyRoutingEndpoint(rootBaseUrl);
@@ -74,7 +75,7 @@ export function classifyCodexRouting(content: string): CodexRoutingKind {
74
75
  if (providerBaseUrl) {
75
76
  const endpoint = classifyRoutingEndpoint(providerBaseUrl);
76
77
  if (endpoint === "unknown") return "unknown";
77
- if (rootProvider === "opencodex") return "opencodex-local";
78
+ if (rootProvider === "opencodex" || endpoint === "local" && approvedProviderUrl?.(rootProvider, providerBaseUrl)) return "opencodex-local";
78
79
  return endpoint === "local" ? "custom-local" : "custom-remote";
79
80
  }
80
81
  if (
@@ -102,7 +103,7 @@ export function getCodexRoutingKind(): CodexRoutingKind {
102
103
  const path = CODEX_CONFIG_PATH;
103
104
  if (!existsSync(path)) return "native";
104
105
  try {
105
- return classifyCodexRouting(readFileSync(path, "utf8"));
106
+ return classifyCodexRouting(readFileSync(path, "utf8"), isApprovedCodexProviderUrl);
106
107
  } catch {
107
108
  return "unknown";
108
109
  }
@@ -1,3 +1,4 @@
1
+ import { injectApprovedCodexProvider } from "./provider-routing";
1
2
  import { beginCodexWriteSection, cleanExternalProviderJournal, publishCodexArtifact } from "./inject/config-write-section";
2
3
  import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs";
3
4
  import { dirname } from "node:path";
@@ -46,8 +47,8 @@ import {
46
47
  writeJournal,
47
48
  } from "./journal";
48
49
  import { HISTORY_RELABEL_STANDS_DOWN } from "./history-provider";
50
+ import { formatApplyHistoryFailure } from "./inject/history-failure";
49
51
  import {
50
- describeHistoryJobFailure,
51
52
  deriveCodexHistoryOperation,
52
53
  resolveCodexHistoryJobTarget,
53
54
  runCodexHistoryJob,
@@ -142,6 +143,8 @@ export interface CodexInjectResult {
142
143
  message: string;
143
144
  /** False when injection intentionally preserves configuration owned by another provider. */
144
145
  configApplied?: false;
146
+ /** True only after a successful configuration transaction changes config.toml bytes. */
147
+ configChanged?: boolean;
145
148
  /**
146
149
  * Structured read-only history preflight refusal; never parsed from display text.
147
150
  *
@@ -240,6 +243,10 @@ async function injectCodexConfigImpl(
240
243
  const rawContent = missingConfig ? "" : readFileSync(CODEX_CONFIG_PATH, "utf-8");
241
244
  const activeProvider = externalCodexModelProvider(rawContent);
242
245
  if (activeProvider) {
246
+ const approved = injectApprovedCodexProvider(activeProvider, routingTarget, options.beforeClientWrite ? () => runClientWriteGuard(options.beforeClientWrite) : undefined, options.validateOnly);
247
+ if (approved) {
248
+ return { success: true, configChanged: approved.configChanged, ...(options.validateOnly ? { configApplied: false as const } : {}), message: `Approved Codex provider ${tomlString(activeProvider)} routes through OpenCodex; provider identity and transport settings preserved.` };
249
+ }
243
250
  // A launcher may have journaled before the provider manager took ownership. Never let shutdown
244
251
  // replay that stale snapshot over externally managed config.
245
252
  if (!options.validateOnly) {
@@ -258,7 +265,7 @@ async function injectCodexConfigImpl(
258
265
  : {}),
259
266
  message:
260
267
  `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
261
- ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` +
268
+ ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n To authorize URL-only management, run ocx codex-provider allow ${tomlString(activeProvider)} and confirm.\n` +
262
269
  ` Configure that provider for Responses passthrough at ${routingTarget.baseUrl}` +
263
270
  `${routingTarget.requiresAdmissionToken ? ` with x-opencodex-api-key from ${routingTarget.tokenEnv}` : ""}.\n` +
264
271
  ` For direct injection, switch to the built-in openai provider, remove any user-owned root openai_base_url, and rerun 'ocx start'.`,
@@ -717,6 +724,11 @@ async function injectCodexConfigImpl(
717
724
  releaseConfigWriteLock(configLock.handle);
718
725
  heldConfigWriteLock = undefined;
719
726
  }
727
+ // Both write paths have committed, including the coordinator's post-callback checks.
728
+ // Compare against the admitted original, not post-v1-reconcile input: the native
729
+ // feature transition is part of this transaction too. Refusals/rollbacks return
730
+ // above and must never publish this receipt. No filesystem reread after unlock.
731
+ const configChanged = Boolean(missingConfig) || effectivePlan.content !== rawContent;
720
732
  // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
721
733
  // the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
722
734
  // machinery; cheap no-op when there is nothing to migrate). The client-compaction opt-in keeps
@@ -799,6 +811,7 @@ async function injectCodexConfigImpl(
799
811
  if (effectivePlan.keptUserBaseUrl && effectivePlan.keepRootOverrideAlongsideTable) {
800
812
  return {
801
813
  success: true,
814
+ configChanged,
802
815
  ...(effectivePlan.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning } : {}),
803
816
  ...(effectivePlan.historyRelabelRefusal ? { historyPreflightFailureReason: effectivePlan.historyRelabelRefusal } : {}),
804
817
  message:
@@ -817,6 +830,7 @@ async function injectCodexConfigImpl(
817
830
  if (effectivePlan.keptUserBaseUrl) {
818
831
  return {
819
832
  success: true,
833
+ configChanged,
820
834
  ...(effectivePlan.nativeSubagentDefaultsWarning
821
835
  ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning }
822
836
  : {}),
@@ -839,6 +853,7 @@ async function injectCodexConfigImpl(
839
853
  : `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url + realtime sideband override).\n`;
840
854
  return {
841
855
  success: true,
856
+ configChanged,
842
857
  ...(effectivePlan.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning } : {}),
843
858
  ...(effectivePlan.historyRelabelRefusal ? { historyPreflightFailureReason: effectivePlan.historyRelabelRefusal } : {}),
844
859
  message:
@@ -860,31 +875,7 @@ export function getCodexConfigPath(): string {
860
875
  return CODEX_CONFIG_PATH;
861
876
  }
862
877
 
863
- /**
864
- * Frame one failed apply history job honestly.
865
- *
866
- * A genuine lock keeps the established deferred/SKIPPED wording; any other
867
- * reason names itself instead of blaming the Codex app/IDE.
868
- */
869
- export function formatApplyHistoryFailure(outcome: CodexHistoryJobOutcome, legacyMode: boolean): string {
870
- // A busy database is a deferral no matter which half observed it: the lock
871
- // contended (blocked/busy), or the worker acquired the lock and then found
872
- // SQLite busy (failed with a busy history reason). Only those keep the
873
- // deferred headline; every other failure is a real "NOT changed".
874
- const busy =
875
- (outcome.kind === "blocked" && outcome.reason === "busy") ||
876
- (outcome.kind === "failed" && outcome.historyFailureReason === "busy");
877
- const partiallyChanged = outcome.kind === "failed"
878
- && ((outcome.rows ?? 0) > 0 || (outcome.files ?? 0) > 0);
879
- const headline = partiallyChanged
880
- ? "Codex resume history changed but did not converge"
881
- : legacyMode
882
- ? "Codex resume history sync SKIPPED"
883
- : busy
884
- ? "Codex resume history metadata restore deferred"
885
- : "Codex resume history NOT changed";
886
- return ` ⚠️ ${headline}: ${describeHistoryJobFailure(outcome, "apply", legacyMode)}\n`;
887
- }
878
+ export { formatApplyHistoryFailure };
888
879
 
889
880
  export {
890
881
  providerBaseHost,
@@ -1,4 +1,7 @@
1
1
  import type { OcxConfig } from "../types";
2
+ import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
3
+ import { codexAccountUsesRemainingQuota } from "./account-use-remaining";
4
+ import { isDeclaredReasoningEffort } from "../reasoning-effort";
2
5
  import { getMainPolicyQuota } from "./quota";
3
6
  import { MAIN_ACCOUNT_HARD_LOCK_PERCENT, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "./quota-types";
4
7
 
@@ -13,9 +16,35 @@ export interface MainAccountHardLockStatus {
13
16
  window?: "short" | "long";
14
17
  }
15
18
 
16
- type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds">;
19
+ type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "codexMainAccountHardLockFallback" | "codexUseRemainingQuotaAccountIds">;
20
+
21
+ /** The reroute destination for requests the lock blocks, when one is configured and readable. */
22
+ export interface MainAccountHardLockFallback {
23
+ model: string;
24
+ reasoningEffort?: string;
25
+ }
26
+
27
+ /**
28
+ * The configured fallback, or `undefined` when nothing qualifies. The dashboard row keeps
29
+ * the configured model while its switch is off, so `enabled: false` pauses the reroute
30
+ * without forgetting the destination. A malformed hand edit — blank or padded model,
31
+ * undeclared effort — disables the reroute entirely rather than degrading into a
32
+ * half-applied one: the request path must never guess which part of a broken setting to
33
+ * keep. This is the same disable-don't-widen reading the compaction override takes, and
34
+ * the load/write boundaries report the malformation separately.
35
+ */
36
+ export function resolveMainAccountHardLockFallback(config: PolicyConfig | undefined): MainAccountHardLockFallback | undefined {
37
+ const raw = config?.codexMainAccountHardLockFallback;
38
+ if (!raw || raw.enabled === false) return undefined;
39
+ const model = typeof raw.model === "string" ? raw.model : "";
40
+ if (model.length === 0 || model !== model.trim() || /\s/.test(model)) return undefined;
41
+ if (raw.reasoningEffort !== undefined
42
+ && (typeof raw.reasoningEffort !== "string" || !isDeclaredReasoningEffort(raw.reasoningEffort))) return undefined;
43
+ return raw.reasoningEffort !== undefined ? { model, reasoningEffort: raw.reasoningEffort } : { model };
44
+ }
17
45
 
18
46
  export function resolveMainAccountHardLockThresholds(config: PolicyConfig | undefined): { short: number; long: number } {
47
+ if (codexAccountUsesRemainingQuota(config, MAIN_CODEX_ACCOUNT_ID)) return { short: 100, long: 100 };
19
48
  const valid = (value: unknown, fallback: number): number => typeof value === "number"
20
49
  && Number.isInteger(value) && value >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT && value <= 100 ? value : fallback;
21
50
  const long = valid(config?.codexMainAccountHardLockThresholds?.long, MAIN_ACCOUNT_HARD_LOCK_PERCENT);
@@ -3,7 +3,7 @@ import { loadConfig } from "../config";
3
3
  import { initializeMainAccountPolicyBinding } from "./account-lifecycle";
4
4
  import { isMainAccountHardLockEnabled } from "./main-account-hard-lock";
5
5
  import { clearAccountNeedsReauth } from "./account-runtime-state";
6
- import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
6
+ import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
7
7
  import {
8
8
  probeNativeProfileRecoveryState,
9
9
  resolveNativeProfileContext,
@@ -26,6 +26,7 @@ import {
26
26
  isCompactionItemType,
27
27
  SUMMARY_PREFIX,
28
28
  } from "../responses/compaction";
29
+ import { decodeRetainedCompaction } from "../responses/retained-compaction";
29
30
  import { resolveCodexHomeDir } from "./home";
30
31
  import { resolveCodexStateDbPath } from "./paths";
31
32
 
@@ -96,21 +97,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
96
97
  return value !== null && typeof value === "object" && !Array.isArray(value);
97
98
  }
98
99
 
99
- function lowerCompactionItem(item: unknown): { item: unknown; changed: boolean } {
100
+ function lowerCompactionItem(item: unknown): { items: unknown[]; changed: boolean } {
100
101
  if (!isRecord(item) || !isCompactionItemType(item.type)) {
101
- return { item, changed: false };
102
+ return { items: [item], changed: false };
102
103
  }
103
104
  if (typeof item.encrypted_content !== "string") {
104
- return { item, changed: false };
105
+ return { items: [item], changed: false };
105
106
  }
106
- const summary = decodeCompactionSummary(item.encrypted_content);
107
- if (summary === null) return { item, changed: false };
107
+ const retained = decodeRetainedCompaction(item.encrypted_content);
108
+ const summary = retained?.summary ?? decodeCompactionSummary(item.encrypted_content);
109
+ if (summary === null) return { items: [item], changed: false };
108
110
  return {
109
- item: {
111
+ items: [{
110
112
  type: "message",
111
113
  role: "user",
112
114
  content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n${summary}` }],
113
- },
115
+ }, ...(retained?.reasoning ?? []).map(text => ({ type: "message", role: "user", content: [{ type: "input_text", text }] }))],
114
116
  changed: true,
115
117
  };
116
118
  }
@@ -129,10 +131,10 @@ function rewriteJsonlLine(line: string): { line: string; replaced: number } {
129
131
  if (!Array.isArray(history)) return { line, replaced: 0 };
130
132
 
131
133
  let replaced = 0;
132
- const replacementHistory = history.map(item => {
134
+ const replacementHistory = history.flatMap(item => {
133
135
  const lowered = lowerCompactionItem(item);
134
136
  if (lowered.changed) replaced += 1;
135
- return lowered.item;
137
+ return lowered.items;
136
138
  });
137
139
  if (replaced === 0) return { line, replaced: 0 };
138
140
 
@@ -146,7 +148,7 @@ function rewriteJsonlLine(line: string): { line: string; replaced: number } {
146
148
  }
147
149
 
148
150
  /**
149
- * Convert OpenCodeX-owned `ocx1:` compaction items into ordinary replayable user messages.
151
+ * Convert OpenCodeX-owned `ocx1:`/`ocx2:` compaction items into ordinary replayable user messages.
150
152
  *
151
153
  * Only the authoritative `compacted.payload.replacement_history` snapshot is changed. Earlier
152
154
  * response-item events are historical output and are deliberately preserved byte-for-byte.
@@ -1,4 +1,4 @@
1
- import { readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { expandUserPath } from "../config";
4
4
  import { defaultCodexHome } from "./home";
@@ -144,3 +144,88 @@ export function readRootTomlString(content: string, key: string): string | null
144
144
  export function resolveCodexConfigPath(path: string): string {
145
145
  return isAbsolute(path) ? path : join(CODEX_HOME, path);
146
146
  }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Active Codex home resolution.
150
+ //
151
+ // These resolve `CODEX_HOME` at call time rather than at import time, which is
152
+ // what lets a test or a sibling-home probe point fixture state somewhere else
153
+ // after this module has already loaded. They live here, next to the constants
154
+ // they fall back to, so a caller that only needs a path never has to reach the
155
+ // catalog parser.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export function samePath(a: string, b: string): boolean {
159
+ const left = resolve(a);
160
+ const right = resolve(b);
161
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
162
+ }
163
+
164
+ export function activeCodexHome(): string | null {
165
+ const raw = process.env.CODEX_HOME?.trim();
166
+ if (!raw) return null;
167
+ const path = resolve(expandUserPath(raw));
168
+ try {
169
+ return realpathSync.native(path);
170
+ } catch {
171
+ return path;
172
+ }
173
+ }
174
+
175
+ export function activeCodexConfigPath(): string {
176
+ const home = activeCodexHome();
177
+ return home ? join(home, "config.toml") : CODEX_CONFIG_PATH;
178
+ }
179
+
180
+ export function activeDefaultCatalogPath(): string {
181
+ const home = activeCodexHome();
182
+ return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH;
183
+ }
184
+
185
+ export function activeCodexModelsCachePath(): string {
186
+ const home = activeCodexHome();
187
+ return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH;
188
+ }
189
+
190
+ export function resolveActiveCodexConfigPath(path: string): string {
191
+ const home = activeCodexHome();
192
+ return home ? resolve(home, path) : resolveCodexConfigPath(path);
193
+ }
194
+
195
+ export function isDefaultCatalogPath(path: string): boolean {
196
+ return samePath(path, activeDefaultCatalogPath());
197
+ }
198
+
199
+ export function readCodexCatalogPath(): string {
200
+ const home = activeCodexHome();
201
+ if (home) return readCodexCatalogPathForHome(home);
202
+ try {
203
+ const configPath = activeCodexConfigPath();
204
+ if (existsSync(configPath)) {
205
+ const toml = readFileSync(configPath, "utf-8");
206
+ const path = readRootTomlString(toml, "model_catalog_json");
207
+ if (path) return resolveActiveCodexConfigPath(path);
208
+ }
209
+ } catch { /* ignore */ }
210
+ return activeDefaultCatalogPath();
211
+ }
212
+
213
+ /**
214
+ * Resolve the configured catalog without consulting ambient CODEX_HOME again.
215
+ *
216
+ * `configText` is for a caller that has already read that same `config.toml` under
217
+ * its own constraints - the prompt-text probe reads it bounded, on the request
218
+ * thread - so resolving the catalog does not cost a second, unbounded read of the
219
+ * file the caller is holding. Omitting it keeps the original behaviour.
220
+ */
221
+ export function readCodexCatalogPathForHome(codexHome: string, configText?: string): string {
222
+ try {
223
+ const configPath = join(codexHome, "config.toml");
224
+ if (configText !== undefined || existsSync(configPath)) {
225
+ const toml = configText ?? readFileSync(configPath, "utf-8");
226
+ const path = readRootTomlString(toml, "model_catalog_json");
227
+ if (path) return resolve(codexHome, path);
228
+ }
229
+ } catch { /* ignore */ }
230
+ return join(codexHome, "opencodex-catalog.json");
231
+ }
@@ -0,0 +1,174 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { loadConfig } from "../config";
6
+ import { shouldSyncCodexOnStart } from "./desired-state";
7
+ import { atomicWriteFile } from "../config/atomic-write";
8
+ import { withConfigMutationLockSync } from "../config/mutation-lock";
9
+ import { getConfigDir } from "../config/paths";
10
+ import { assertCodexHomeOwner } from "./codex-home-owner";
11
+ import { ConfigWriteLockRefusal, publishConfigWriteTarget, watchConfigWriteTargets, withConfigWriteLockHeld, type LockHandle } from "./config-write-lock";
12
+ import { providerTableStart } from "./injected-marker";
13
+ import { CODEX_CONFIG_PATH, getCodexHome, tomlString } from "./paths";
14
+ import { rootSourceLines, sourceAssignment, sourceText } from "./toml-source-lines";
15
+ import { JOURNAL_PATH, removeJournal } from "./journal";
16
+ import { externalCodexModelProvider } from "./inject/config-toml";
17
+ import { readBoundedCodexConfig } from "./inject/bounded-config-reader";
18
+ import { isLoopbackHostname } from "./loopback-target";
19
+ import { validateCodexRoutingTarget, type CodexRoutingTarget } from "./inject/routing-target";
20
+
21
+ const approval = z.object({ original: z.string(), originalUrl: z.string().url(), managedUrls: z.array(z.string().url()).max(2) }).strict();
22
+ const recordSchema = z.object({ version: z.literal(1), codexHome: z.string(), providers: z.record(z.string(), approval) }).strict();
23
+ type RoutingRecord = z.infer<typeof recordSchema>;
24
+ type Approval = z.infer<typeof approval>;
25
+ let beforePublishForTests: (() => void) | undefined;
26
+ export function setBeforeProviderRoutingPublishForTests(hook: typeof beforePublishForTests): void { beforePublishForTests = hook; }
27
+
28
+ function recordPath(): string {
29
+ const homeId = createHash("sha256").update(getCodexHome()).digest("hex");
30
+ return join(getConfigDir(), `codex-provider-routing-${homeId}.json`);
31
+ }
32
+
33
+ function readRecord(): RoutingRecord {
34
+ const content = readBoundedCodexConfig(recordPath());
35
+ if (content === null) return { version: 1, codexHome: getCodexHome(), providers: {} };
36
+ const record = recordSchema.parse(JSON.parse(content));
37
+ if (record.codexHome !== getCodexHome()) throw new Error("Codex provider routing consent belongs to another home.");
38
+ for (const saved of Object.values(record.providers)) {
39
+ const parsed = Bun.TOML.parse(saved.original) as Record<string, unknown>;
40
+ if (Object.keys(parsed).length !== 1 || parsed.base_url !== saved.originalUrl
41
+ || saved.managedUrls.some(url => !isLoopbackHostname(new URL(url).hostname))) {
42
+ throw new Error("Codex provider routing recovery evidence is invalid; existing files were preserved.");
43
+ }
44
+ }
45
+ return record;
46
+ }
47
+
48
+ /** Only regular Responses provider tables with no separate credential transport are eligible. */
49
+ function providerUrl(content: string, id: string, checkCredentials = true) {
50
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id) || ["__proto__", "constructor", "prototype", "openai", "opencodex", "ollama", "lmstudio"].includes(id)) {
51
+ throw new Error("Select a custom provider ID using letters, digits, hyphens or underscores.");
52
+ }
53
+ const parsed = Bun.TOML.parse(content) as Record<string, unknown>;
54
+ const table = (parsed.model_providers as Record<string, Record<string, unknown>> | undefined)?.[id];
55
+ if (!table || checkCredentials && ((table.wire_api !== undefined && table.wire_api !== "responses")
56
+ || table.requires_openai_auth !== true
57
+ || ["env_key", "experimental_bearer_token", "auth", "http_headers", "env_http_headers"].some(key => Object.hasOwn(table, key)))) {
58
+ throw new Error("Routing consent requires a Responses provider using OpenAI authentication without custom credentials or headers.");
59
+ }
60
+ const { bom, lines } = rootSourceLines(content);
61
+ const start = providerTableStart(lines.map(line => line.structural ? line.text : ""), id);
62
+ if (start < 0) throw new Error("Routing consent requires an explicit provider table.");
63
+ let end = start + 1;
64
+ while (end < lines.length && !(lines[end]!.structural && /^\s*\[/.test(lines[end]!.text))) end++;
65
+ for (let i = start + 1; i < end; i++) {
66
+ const field = sourceAssignment(lines, i, end);
67
+ if (field?.key !== "base_url") continue;
68
+ if (typeof field.value !== "string") break;
69
+ const url = new URL(field.value);
70
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) break;
71
+ return { url: field.value, assignment: field.text, replace: (text: string) =>
72
+ bom + sourceText(lines.slice(0, field.start)) + text + lines[field.end - 1]!.eol + sourceText(lines.slice(field.end)) };
73
+ }
74
+ throw new Error("Routing consent requires a credential-free HTTP(S) base_url assignment.");
75
+ }
76
+
77
+ function inWriteSection<T>(run: (held: LockHandle, content: string, record: RoutingRecord) => T): T {
78
+ assertCodexHomeOwner(getCodexHome());
79
+ const result = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, held => {
80
+ watchConfigWriteTargets(held, [CODEX_CONFIG_PATH, recordPath(), JOURNAL_PATH]);
81
+ const content = readFileSync(CODEX_CONFIG_PATH, "utf8");
82
+ return run(held, content, readRecord());
83
+ });
84
+ if (!result.ok) throw new ConfigWriteLockRefusal(result);
85
+ return result.value;
86
+ }
87
+
88
+ function publish(path: string, content: string, held: LockHandle): void {
89
+ publishConfigWriteTarget(CODEX_CONFIG_PATH, held, path, (destination, hooks) => atomicWriteFile(destination, content, undefined, hooks));
90
+ }
91
+
92
+ function save(record: RoutingRecord, held: LockHandle): void {
93
+ publish(recordPath(), JSON.stringify(record, null, 2) + "\n", held);
94
+ }
95
+
96
+ /** Read-only ownership evidence: consent alone never claims a URL we did not publish. */
97
+ export function isApprovedCodexProviderUrl(id: string, url: string): boolean {
98
+ const record = readRecord();
99
+ return Object.hasOwn(record.providers, id) && record.providers[id]!.managedUrls.includes(url);
100
+ }
101
+
102
+ export function approvedCodexProviders(): string[] {
103
+ return Object.keys(readRecord().providers);
104
+ }
105
+
106
+ /** Capture the exact source before asking; commit refuses a changed provider after the prompt. */
107
+ export function previewCodexProviderConsent(id: string): string {
108
+ return providerUrl(readFileSync(CODEX_CONFIG_PATH, "utf8"), id).assignment;
109
+ }
110
+
111
+ export function allowCodexProviderRouting(id: string, expected: string): void {
112
+ inWriteSection((held, content, record) => {
113
+ const field = providerUrl(content, id);
114
+ if (field.assignment !== expected) throw new Error("Provider changed after confirmation; review it and retry.");
115
+ if (Object.hasOwn(record.providers, id)) throw new Error("Provider is already approved; revoke it before granting new consent.");
116
+ record.providers[id] = { original: field.assignment, originalUrl: field.url, managedUrls: [] };
117
+ save(record, held);
118
+ });
119
+ }
120
+
121
+ function accepts(fieldUrl: string, saved: Approval): boolean {
122
+ return fieldUrl === saved.originalUrl || saved.managedUrls.includes(fieldUrl);
123
+ }
124
+
125
+ /** Called by start/sync, never prompts and never broadens the approved provider set. */
126
+ export function injectApprovedCodexProvider(id: string, target: CodexRoutingTarget, beforeWrite?: () => void, validateOnly = false): false | { configChanged: boolean } {
127
+ return inWriteSection((held, content, record) => {
128
+ if (!Object.hasOwn(record.providers, id)) return false;
129
+ const apply = () => {
130
+ target = validateCodexRoutingTarget(target);
131
+ if (target.requiresAdmissionToken || target.desktopAuthless || target.clientCompaction || !isLoopbackHostname(new URL(target.baseUrl).hostname)) {
132
+ throw new Error("Approved custom providers support only authenticated Codex on a local loopback proxy without client compaction.");
133
+ }
134
+ if (externalCodexModelProvider(content) !== id) throw new Error("Active provider changed; routing was not applied.");
135
+ const field = providerUrl(content, id), saved = record.providers[id]!;
136
+ if (!accepts(field.url, saved)) throw new Error("Approved provider base_url changed outside OpenCodex; revoke and approve it again.");
137
+ if (validateOnly) return { configChanged: false };
138
+ beforeWrite?.();
139
+ if (!shouldSyncCodexOnStart(loadConfig())) return false;
140
+ if (readFileSync(CODEX_CONFIG_PATH, "utf8") !== content) throw new Error("Codex config changed before publication; retry sync.");
141
+ publishConfigWriteTarget(CODEX_CONFIG_PATH, held, JOURNAL_PATH, (_destination, hooks) => removeJournal(hooks));
142
+ if (field.url === target.baseUrl) return { configChanged: false };
143
+ // Write recovery evidence first. Both the previous and candidate URL remain restorable
144
+ // if publication fails or the process exits between these two atomic renames.
145
+ saved.managedUrls = [...new Set([field.url, target.baseUrl])].filter(url => url !== saved.originalUrl);
146
+ save(record, held);
147
+ beforePublishForTests?.();
148
+ publish(CODEX_CONFIG_PATH, field.replace(`base_url = ${tomlString(target.baseUrl)}`), held);
149
+ return { configChanged: true };
150
+ };
151
+ return validateOnly ? apply() : withConfigMutationLockSync(apply);
152
+ });
153
+ }
154
+
155
+ /** Restore only our URLs; unrelated edits and provider IDs/history are never rewritten. */
156
+ export function restoreApprovedCodexProviders(revokeId?: string): boolean {
157
+ const ids = approvedCodexProviders();
158
+ if (revokeId !== undefined && !ids.includes(revokeId)) throw new Error("Provider has no routing consent to revoke.");
159
+ if (!ids.length) return false;
160
+ return inWriteSection((held, content, record) => {
161
+ let next = content;
162
+ for (const id of revokeId === undefined ? Object.keys(record.providers) : [revokeId]) {
163
+ const saved = record.providers[id]!;
164
+ const parsed = Bun.TOML.parse(next) as Record<string, unknown>;
165
+ const table = (parsed.model_providers as Record<string, Record<string, unknown>> | undefined)?.[id];
166
+ if (!table || !saved.managedUrls.includes(table.base_url as string)) continue;
167
+ const field = providerUrl(next, id, false);
168
+ if (saved.managedUrls.includes(field.url)) next = field.replace(saved.original);
169
+ }
170
+ if (next !== content) publish(CODEX_CONFIG_PATH, next, held);
171
+ if (revokeId !== undefined) { delete record.providers[revokeId]; save(record, held); }
172
+ return next !== content;
173
+ });
174
+ }
@@ -1,12 +1,12 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog";
3
+ import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog/retained-sync";
4
4
  import type { ComboCatalogOmission } from "./catalog/aggregation";
5
5
  import { getCodexHome } from "./paths";
6
6
  import { withCatalogWriteSerialization, CatalogWritePermitRefusal } from "./catalog-write-serialization";
7
7
  import { replaceCodexModelsCache } from "./internal/catalog-writer";
8
8
  import type { OcxConfig } from "../types";
9
- import type { CodexCatalogSyncOptions } from "./catalog/sync";
9
+ import type { CodexCatalogSyncOptions } from "./catalog/retained-sync";
10
10
 
11
11
  export interface CodexCatalogRefreshResult {
12
12
  added: number;
@@ -1,6 +1,6 @@
1
1
  import { isCodexAccountGenerationLive } from "../account-store";
2
2
  import { NATIVE_RESERVE_MODEL } from "../catalog/native-models";
3
- import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
3
+ import { MAIN_CODEX_ACCOUNT_ID } from "../account-id";
4
4
  import { POOL_KEY_CODEX } from "../pool-rotation";
5
5
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
6
6
  import type { OcxConfig } from "../../types";
@@ -1,7 +1,8 @@
1
1
  import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch";
2
2
  import { codexAccountUsesCreditsAfterLimit, isCodexAccountHeldForCredits } from "../account-credit-use";
3
3
  import { isCodexAccountPaused } from "../account-pause";
4
- import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
4
+ import { pinnedCodexAccountId } from "../account-priority";
5
+ import { codexSelectionPriorityLookup } from "../account-preservation";
5
6
  import { isSelectableCodexPoolAccount } from "../account-id";
6
7
  import { isAccountNeedsReauth } from "../account-runtime-state";
7
8
  import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "../account-usability";
@@ -248,7 +249,7 @@ export function getEligiblePoolAccounts(
248
249
  const pinned = pinnedCodexAccountId(config);
249
250
  return selectPriorityTier(
250
251
  withoutModelDeniedAccounts(ids, selectionOptions?.deniedModelAccountIds, pinned),
251
- codexAccountPriorityLookup(config),
252
+ codexSelectionPriorityLookup(config),
252
253
  id => hasCodexQuotaHeadroom(config, id, selectionOptions, now),
253
254
  pinned,
254
255
  );
@@ -767,7 +768,7 @@ export function pickPriorityPreemption(
767
768
  && eligible.includes(pinned)
768
769
  && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now)
769
770
  ) return null;
770
- const priorityOf = codexAccountPriorityLookup(config);
771
+ const priorityOf = codexSelectionPriorityLookup(config);
771
772
  if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
772
773
  // Members without headroom are in the tier only because a sibling has some;
773
774
  // picking one would hand the request straight back to a drained account.
@@ -1,5 +1,5 @@
1
1
  import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
2
- import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
2
+ import { MAIN_CODEX_ACCOUNT_ID } from "../account-id";
3
3
  import { retainedUtf8Bytes } from "../../lib/admission";
4
4
  import { clearAllCodexPoolRefreshFailures } from "../pool-refresh-backoff";
5
5
  import type { CodexThreadLineage } from "../lineage";
@@ -0,0 +1,43 @@
1
+ import { extractEmail, inspectChatGptDomainClaim } from "../oauth/chatgpt";
2
+ import type { CodexTokens } from "./auth-collision";
3
+
4
+ export type CodexLoginIdentity = Readonly<{ accountId: string; email: string }>;
5
+ let observed: CodexLoginIdentity | undefined;
6
+
7
+ export function codexLoginIdentity(accountId: unknown, email: unknown): CodexLoginIdentity | undefined {
8
+ const member = typeof email === "string" ? email.trim().toLowerCase() : "";
9
+ const workspace = typeof accountId === "string" ? accountId.trim() : "";
10
+ return workspace && member ? { accountId: workspace, email: member } : undefined;
11
+ }
12
+
13
+ /** Undefined is insufficient evidence; null means conflicting or malformed token evidence. */
14
+ export function codexTokenIdentity(tokens: CodexTokens): CodexLoginIdentity | undefined | null {
15
+ if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string"
16
+ || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return null;
17
+ const claims = [tokens.id_token, tokens.access_token]
18
+ .filter((token): token is string => typeof token === "string")
19
+ .map(inspectChatGptDomainClaim);
20
+ if (claims.some(claim => claim.kind === "invalid")) return null;
21
+ const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []);
22
+ const accountId = tokens.account_id.trim() || claimIds[0];
23
+ if (claimIds.some(claim => claim !== accountId)) return null;
24
+ const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)]
25
+ .flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : []));
26
+ if (emails.size > 1) return null;
27
+ return codexLoginIdentity(accountId, [...emails][0]);
28
+ }
29
+
30
+ /** Publish only from an existing physical-login observation, never an incoming bearer. */
31
+ export function observeSignedInCodexIdentity(tokens?: CodexTokens): void {
32
+ if (!tokens) { observed = undefined; return; }
33
+ const next = codexTokenIdentity(tokens);
34
+ // Conflicting claims can be a partial external write, never proof of a new login.
35
+ if (next !== null) observed = next;
36
+ }
37
+
38
+ /** Routing reads memory only, including while the native profile is fenced. */
39
+ export function getSignedInCodexIdentity(): CodexLoginIdentity | undefined { return observed; }
40
+
41
+ export function sameCodexLogin(a: CodexLoginIdentity, b: CodexLoginIdentity | undefined): boolean {
42
+ return a.accountId === b?.accountId && a.email === b.email;
43
+ }
@@ -591,7 +591,7 @@ export function maybePrimeSubagentQuota(
591
591
  if (subagentQuotaPrimeForTests) {
592
592
  await subagentQuotaPrimeForTests(config, "subagent-spawn");
593
593
  } else {
594
- const { primeCodexPoolQuotas } = await import("./auth-api");
594
+ const { primeCodexPoolQuotas } = await import("./auth-api/pool-mode-gate");
595
595
  await primeCodexPoolQuotas(config, "subagent-spawn");
596
596
  }
597
597
  quotaPrimedAt.set("global", Date.now());