@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -1,11 +1,9 @@
1
- import { contextCompatibleBaseLine } from "./context-compat";
2
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
3
2
  import {
4
3
  atomicWriteFile,
5
4
  loadConfig,
6
5
  observeConfigGeneration,
7
6
  readConfigAdmissionSnapshot,
8
- subagentDefaultSyncEffective,
9
7
  websocketsEnabled,
10
8
  withConfigMutationLockSync,
11
9
  } from "../config";
@@ -30,7 +28,6 @@ import {
30
28
  } from "./inject-coordination";
31
29
  import { readIntegrationRecord } from "./integration-record";
32
30
  import { classifyNativeRoutedResidue } from "./native-residue";
33
- import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight";
34
31
  import {
35
32
  resolveCodexCoordinatorDatabasePath,
36
33
  resolveEffectiveUserIdentity,
@@ -40,14 +37,10 @@ import {
40
37
  markJournalInjectedState,
41
38
  journaledInjectedOpenaiBaseUrl,
42
39
  journaledInjectedRealtimeWsBaseUrl,
43
- journaledInjectedCatalogPath,
44
40
  removeJournal,
45
- restoreJournalState,
46
41
  writeJournal,
47
42
  } from "./journal";
48
- import { withCatalogWriteSerialization } from "./catalog-write-serialization";
49
- import { restoreCodexCatalogWithPermit } from "./catalog/sync";
50
- import { preflightCodexHistoryInjection, syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider";
43
+ import { preflightCodexHistoryInjection } from "./history-provider";
51
44
  import {
52
45
  describeHistoryJobFailure,
53
46
  deriveCodexHistoryOperation,
@@ -56,36 +49,49 @@ import {
56
49
  type CodexHistoryJobOutcome,
57
50
  } from "./history-job";
58
51
  import {
59
- OCX_SECTION_MARKER,
60
52
  REALTIME_WS_BASE_URL_KEY,
61
53
  hasInjectedCodexRouting,
62
54
  hasInjectedOpenaiBaseUrl,
63
- isRootOpenaiBaseUrlLine,
64
- isRootRealtimeWsBaseUrlLine,
65
- providerTableStart,
66
- providerTableString,
67
55
  rootTomlString,
68
56
  stripJournaledOpenaiBaseUrl,
69
- tomlStringPattern,
70
57
  } from "./injected-marker";
71
58
  import {
72
59
  CODEX_CONFIG_PATH,
73
60
  CODEX_PROFILE_PATH,
74
- DEFAULT_CATALOG_PATH,
75
61
  getCodexHome,
76
- parseTomlString,
77
- readRootTomlString,
78
- resolveCodexConfigPath,
79
62
  resolveCodexStateDbPath,
80
63
  tomlString,
81
64
  } from "./paths";
82
- import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
83
- import {
84
- transformManagedSubagentDefaults,
85
- type ManagedSubagentDefaults,
86
- } from "./subagent-defaults";
65
+ import { transformManagedSubagentDefaults } from "./subagent-defaults";
87
66
  import type { OcxConfig } from "../types";
88
- import { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
67
+ import {
68
+ configuredManagedSubagentDefaults,
69
+ standaloneCodexRoutingTarget,
70
+ usesProviderTable,
71
+ validateCodexRoutingTarget,
72
+ type CodexRoutingTarget,
73
+ } from "./inject/routing-target";
74
+ import {
75
+ applyEol,
76
+ buildProfileFileForTarget,
77
+ buildProviderTableBlockForTarget,
78
+ chooseCatalogPathForInjection,
79
+ dominantEol,
80
+ ensureFastModeFeature,
81
+ externalCodexModelProvider,
82
+ normalizeServiceTier,
83
+ removeProfileSection,
84
+ setRootModelCatalogPath,
85
+ setRootModelProvider,
86
+ setRootOpenaiBaseUrlForTarget,
87
+ setRootRealtimeWsBaseUrl,
88
+ stripExistingModelProvider,
89
+ stripInjectedOpenaiBaseUrl,
90
+ stripOpencodexCatalogPath,
91
+ stripRootContextWindowOverrides,
92
+ } from "./inject/config-toml";
93
+ import { hasOcxProviderTable, removeOcxSection } from "./inject/remove";
94
+
89
95
 
90
96
  export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
91
97
 
@@ -93,37 +99,6 @@ export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthH
93
99
  // without importing this module back. Re-exported for existing external callers.
94
100
  export { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl };
95
101
 
96
- export function externalCodexModelProvider(content: string): string | null {
97
- const provider = resolveEffectiveProjectModelProvider(content).provider;
98
- return provider && provider !== "openai" && provider !== "opencodex"
99
- ? provider
100
- : null;
101
- }
102
-
103
- export function currentExternalCodexModelProvider(): string | null {
104
- if (!existsSync(CODEX_CONFIG_PATH)) return null;
105
- return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8"));
106
- }
107
-
108
- /**
109
- * Detect the file's dominant line ending. Every transform in this module is LF-pure
110
- * (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are
111
- * normalized to LF at the pipeline boundary and converted back on write — otherwise a
112
- * single inject would leave a mixed-EOL file.
113
- */
114
- export function dominantEol(content: string): "\r\n" | "\n" {
115
- const crlf = (content.match(/\r\n/g) ?? []).length;
116
- if (crlf === 0) return "\n";
117
- const bareLf = (content.match(/\n/g) ?? []).length - crlf;
118
- return crlf >= bareLf ? "\r\n" : "\n";
119
- }
120
-
121
- /** Normalize all line endings to `eol` (CRLF first collapsed to LF, then expanded). */
122
- export function applyEol(content: string, eol: "\r\n" | "\n"): string {
123
- const lf = content.replace(/\r\n/g, "\n");
124
- return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
125
- }
126
-
127
102
  /**
128
103
  * Design B (2026-07-06): loopback installs no longer re-tag the provider. Instead of
129
104
  * `model_provider = "opencodex"` + a `[model_providers.opencodex]` table, we set the official
@@ -170,727 +145,6 @@ function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): vo
170
145
  }
171
146
  }
172
147
 
173
- export interface CodexRoutingTarget {
174
- baseUrl: string;
175
- requiresAdmissionToken: boolean;
176
- tokenEnv: "OPENCODEX_API_AUTH_TOKEN";
177
- /**
178
- * Opt-in authless Codex Desktop mode (#1107): inject the dedicated provider table with
179
- * `requires_openai_auth = false` so Desktop skips the ChatGPT login gate. Only ever true for
180
- * loopback targets that need no admission token; non-loopback admission is a separate layer
181
- * and is never weakened by this flag.
182
- */
183
- desktopAuthless?: boolean;
184
- /** Select the dedicated provider identity so Codex owns compaction locally. */
185
- clientCompaction?: boolean;
186
- }
187
-
188
- function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTarget {
189
- let parsed: URL;
190
- try {
191
- parsed = new URL(target.baseUrl);
192
- } catch {
193
- throw new TypeError("Codex routing target must be an absolute HTTP(S) /v1 URL");
194
- }
195
- if (
196
- (parsed.protocol !== "http:" && parsed.protocol !== "https:")
197
- || parsed.username
198
- || parsed.password
199
- || parsed.pathname !== "/v1"
200
- || parsed.search
201
- || parsed.hash
202
- || target.tokenEnv !== "OPENCODEX_API_AUTH_TOKEN"
203
- ) {
204
- throw new TypeError("Codex routing target must be a canonical HTTP(S) /v1 URL without credentials, query, or fragment");
205
- }
206
- return { ...target, baseUrl: `${parsed.origin}/v1` };
207
- }
208
-
209
- /** Provider-table form is used when auth, admission, or compaction policy needs a dedicated provider. */
210
- function usesProviderTable(target: CodexRoutingTarget): boolean {
211
- return target.requiresAdmissionToken
212
- || target.desktopAuthless === true
213
- || target.clientCompaction === true;
214
- }
215
-
216
- export function standaloneCodexRoutingTarget(
217
- port: number,
218
- config?: Pick<
219
- OcxConfig,
220
- "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction"
221
- >,
222
- ): CodexRoutingTarget {
223
- // An enabled listener with no `port` is the companion form: it answers on `port` itself,
224
- // bound to 127.0.0.1 (#4236). Resolving it through the shared helper is what makes the
225
- // one-port hub work without every writer repeating `?? port`.
226
- const loopback = config?.unauthenticatedLoopbackListener;
227
- const effectivePort = effectiveLoopbackListenerPort(config, port) ?? port;
228
- const hostname = loopback?.enabled ? undefined : config?.hostname;
229
- const requiresAdmissionToken = loopback?.enabled ? false : shouldInjectApiAuthHeader(config);
230
- return {
231
- baseUrl: `http://${providerBaseHost(hostname)}:${effectivePort}/v1`,
232
- requiresAdmissionToken,
233
- tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
234
- ...(config?.codexDesktopAuthless === true && !requiresAdmissionToken
235
- ? { desktopAuthless: true }
236
- : {}),
237
- ...(config?.codexClientCompaction === true && !requiresAdmissionToken
238
- ? { clientCompaction: true }
239
- : {}),
240
- };
241
- }
242
-
243
- function routingTargetOrigin(target: CodexRoutingTarget): string {
244
- return target.baseUrl.slice(0, -3);
245
- }
246
-
247
- function configuredManagedSubagentDefaults(
248
- config:
249
- | Pick<
250
- OcxConfig,
251
- "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"
252
- >
253
- | undefined,
254
- ): ManagedSubagentDefaults | null {
255
- if (!subagentDefaultSyncEffective(config ?? {})) return null;
256
- return {
257
- model: config!.injectionModel!.trim(),
258
- ...(config!.injectionEffort?.trim()
259
- ? { reasoningEffort: config!.injectionEffort.trim() }
260
- : {}),
261
- };
262
- }
263
-
264
- /**
265
- * The `[model_providers.opencodex]` TABLE only. A table is position-independent in TOML, so it is
266
- * safe to append at EOF. The bare root key `model_provider = "opencodex"` is NOT included here —
267
- * it must live at the document root (before any table header) and is set separately by
268
- * setRootModelProvider(). Appending the bare key at EOF was the original bug: it nested under
269
- * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
270
- * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
271
- */
272
- export function providerBaseHost(hostname: string | undefined): string {
273
- const trimmed = (hostname ?? "127.0.0.1").trim();
274
- const lower = trimmed.toLowerCase();
275
- // Match what the server actually binds. Writing "localhost" while binding IPv4-only
276
- // 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first.
277
- if (lower === "::1" || lower === "[::1]") return "[::1]";
278
- if (
279
- isLoopbackHostname(trimmed) ||
280
- trimmed === "0.0.0.0" ||
281
- trimmed === "::" ||
282
- trimmed === "[::]"
283
- )
284
- return "127.0.0.1";
285
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
286
- return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
287
- }
288
-
289
- export function buildProviderTableBlock(
290
- port: number,
291
- supportsWebsockets?: boolean,
292
- includeApiAuthHeader?: boolean,
293
- hostname?: string,
294
- ): string;
295
- export function buildProviderTableBlock(
296
- target: CodexRoutingTarget,
297
- supportsWebsockets?: boolean,
298
- ): string;
299
- export function buildProviderTableBlock(
300
- portOrTarget: number | CodexRoutingTarget,
301
- supportsWebsockets = false,
302
- includeApiAuthHeader = false,
303
- hostname?: string,
304
- ): string {
305
- const target = typeof portOrTarget === "number"
306
- ? validateCodexRoutingTarget({
307
- baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`,
308
- requiresAdmissionToken: includeApiAuthHeader,
309
- tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
310
- })
311
- : validateCodexRoutingTarget(portOrTarget);
312
- return buildProviderTableBlockForTarget(target, supportsWebsockets);
313
- }
314
-
315
- function buildProviderTableBlockForTarget(
316
- target: CodexRoutingTarget,
317
- supportsWebsockets = false,
318
- ): string {
319
- const lines = [
320
- "",
321
- OCX_SECTION_MARKER,
322
- "[model_providers.opencodex]",
323
- 'name = "OpenCodex Proxy"',
324
- `base_url = ${tomlString(target.baseUrl)}`,
325
- 'wire_api = "responses"',
326
- // false only in the authless Desktop opt-in (#1107); true keeps the App/TUI account gate.
327
- `requires_openai_auth = ${target.desktopAuthless === true ? "false" : "true"}`,
328
- ];
329
- if (target.requiresAdmissionToken) {
330
- // codex-cli 0.146+ contract (#2073): env_key sends Authorization: Bearer $VAR and
331
- // hard-errors on a missing/empty variable instead of silently omitting auth. It
332
- // coexists with requires_openai_auth (env_key wins wire auth; the flag keeps the
333
- // login/account UX), and the server substitutes stored main auth for our admission
334
- // bearer (#1686), so the modern form is strictly better than the legacy
335
- // env_http_headers table this line used to emit.
336
- lines.push(`env_key = ${tomlString(target.tokenEnv)}`);
337
- }
338
- if (supportsWebsockets) lines.push("supports_websockets = true");
339
- return lines.join("\n") + "\n";
340
- }
341
-
342
- export function buildOpenaiBaseUrlLine(
343
- port: number,
344
- hostname?: string,
345
- ): string;
346
- export function buildOpenaiBaseUrlLine(target: CodexRoutingTarget): string;
347
- export function buildOpenaiBaseUrlLine(
348
- portOrTarget: number | CodexRoutingTarget,
349
- hostname?: string,
350
- ): string {
351
- return typeof portOrTarget === "number"
352
- ? `openai_base_url = "http://${providerBaseHost(hostname)}:${portOrTarget}/v1"`
353
- : buildOpenaiBaseUrlLineForTarget(validateCodexRoutingTarget(portOrTarget));
354
- }
355
-
356
- function buildOpenaiBaseUrlLineForTarget(target: CodexRoutingTarget): string {
357
- return `openai_base_url = ${tomlString(target.baseUrl)}`;
358
- }
359
-
360
- /**
361
- * Realtime sideband override (codex-rs `experimental_realtime_ws_base_url`), written with the
362
- * SAME value as `openai_base_url`. Desktop voice creates its WebRTC call through the proxy
363
- * (`POST /v1/live`, answered under the Pool account the proxy selects) but, since openai/codex
364
- * 438c9e98d (#35830), joins the sideband at `wss://api.openai.com/v1/live/{callId}` with the
365
- * app's own login unless this key redirects it. Two accounts, one call: the join 404s. Pointing
366
- * the key at the proxy sends the join through `GET /v1/live/{callId}` (src/server/live.ts),
367
- * where the same Pool account is reused. codex-rs turns `http` into `ws` and appends
368
- * `/live/{callId}` itself; the value must stay the canonical `/v1` root.
369
- */
370
- export function buildRealtimeWsBaseUrlLine(target: CodexRoutingTarget): string {
371
- return `${REALTIME_WS_BASE_URL_KEY} = ${tomlString(target.baseUrl)}`;
372
- }
373
-
374
- /**
375
- * Design B root-key injection: place `OCX_SECTION_MARKER` + `openai_base_url` at the document
376
- * ROOT (before the first table header). Idempotent: an existing marker-owned line is rewritten
377
- * in place. A user's OWN root `openai_base_url` (no marker above it) is respected — we keep it
378
- * and inject nothing, reporting `keptUserBaseUrl` so the caller can surface it.
379
- */
380
- export function setRootOpenaiBaseUrl(
381
- content: string,
382
- port: number,
383
- hostname?: string,
384
- ): { content: string; keptUserBaseUrl: boolean };
385
- export function setRootOpenaiBaseUrl(
386
- content: string,
387
- target: CodexRoutingTarget,
388
- ): { content: string; keptUserBaseUrl: boolean };
389
- export function setRootOpenaiBaseUrl(
390
- content: string,
391
- portOrTarget: number | CodexRoutingTarget,
392
- hostname?: string,
393
- ): { content: string; keptUserBaseUrl: boolean } {
394
- if (typeof portOrTarget !== "number") {
395
- return setRootOpenaiBaseUrlForTarget(content, validateCodexRoutingTarget(portOrTarget));
396
- }
397
- const lines = content.split("\n");
398
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
399
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
400
- const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLine(portOrTarget, hostname));
401
-
402
- for (let i = 0; i < rootEnd; i++) {
403
- if (!isRootOpenaiBaseUrlLine(lines[i])) continue;
404
- const markerOwned = i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER);
405
- if (!markerOwned) return { content, keptUserBaseUrl: true };
406
- lines[i] = key;
407
- return { content: lines.join("\n"), keptUserBaseUrl: false };
408
- }
409
-
410
- if (firstTable === -1) {
411
- return {
412
- content:
413
- content.replace(/\n+$/, "") +
414
- "\n" +
415
- OCX_SECTION_MARKER +
416
- "\n" +
417
- key +
418
- "\n",
419
- keptUserBaseUrl: false,
420
- };
421
- }
422
- let insertAt = firstTable;
423
- while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
424
- lines.splice(insertAt, 0, OCX_SECTION_MARKER, key);
425
- return { content: lines.join("\n"), keptUserBaseUrl: false };
426
- }
427
-
428
- function setRootOpenaiBaseUrlForTarget(
429
- content: string,
430
- target: CodexRoutingTarget,
431
- ): { content: string; keptUserBaseUrl: boolean } {
432
- const lines = content.split("\n");
433
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
434
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
435
- const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLineForTarget(target));
436
- for (let index = 0; index < rootEnd; index += 1) {
437
- if (!isRootOpenaiBaseUrlLine(lines[index])) continue;
438
- const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER);
439
- if (!markerOwned) return { content, keptUserBaseUrl: true };
440
- lines[index] = key;
441
- return { content: lines.join("\n"), keptUserBaseUrl: false };
442
- }
443
- if (firstTable === -1) {
444
- return {
445
- content: `${content.replace(/\n+$/, "")}\n${OCX_SECTION_MARKER}\n${key}\n`,
446
- keptUserBaseUrl: false,
447
- };
448
- }
449
- let insertAt = firstTable;
450
- while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt -= 1;
451
- lines.splice(insertAt, 0, OCX_SECTION_MARKER, key);
452
- return { content: lines.join("\n"), keptUserBaseUrl: false };
453
- }
454
-
455
- /**
456
- * Companion to `setRootOpenaiBaseUrlForTarget` for the realtime sideband override. Same
457
- * ownership rule, applied per key: the line is ours only when the marker sits directly
458
- * above it; a user's own line (no marker above it) is kept and nothing is injected. The
459
- * key gets its OWN marker line rather than sharing the routing override's, so a user line
460
- * that happens to sit right under our `openai_base_url` is never mistaken for ours.
461
- * Placement: directly after the marker-owned `openai_base_url` pair. Only ever called on
462
- * the Design B (loopback) path right after the routing override was written — the legacy
463
- * provider-table form needs the admission-token header, which the sideband cannot carry.
464
- */
465
- export function setRootRealtimeWsBaseUrl(
466
- content: string,
467
- target: CodexRoutingTarget,
468
- ): { content: string; keptUserRealtimeWsBaseUrl: boolean } {
469
- const lines = content.split("\n");
470
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
471
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
472
- const key = buildRealtimeWsBaseUrlLine(validateCodexRoutingTarget(target));
473
- for (let index = 0; index < rootEnd; index += 1) {
474
- if (!isRootRealtimeWsBaseUrlLine(lines[index])) continue;
475
- const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER);
476
- if (!markerOwned) return { content, keptUserRealtimeWsBaseUrl: true };
477
- lines[index] = key;
478
- return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false };
479
- }
480
- for (let index = 0; index < rootEnd; index += 1) {
481
- if (!isRootOpenaiBaseUrlLine(lines[index])) continue;
482
- if (!(index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER))) continue;
483
- lines.splice(index + 1, 0, OCX_SECTION_MARKER, key);
484
- return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false };
485
- }
486
- // No marker-owned routing override to attach to: the override has no owner, so inject nothing.
487
- return { content, keptUserRealtimeWsBaseUrl: false };
488
- }
489
-
490
- /**
491
- * Remove the marker-owned root `openai_base_url` (marker line + the key line right after it).
492
- * A user's own root override (no marker) survives; an orphaned marker with no key line after
493
- * it is dropped too so repeated strip/inject cycles cannot accumulate marker comments.
494
- * A marker-owned `experimental_realtime_ws_base_url` pair is removed by the same rule.
495
- */
496
- export function stripInjectedOpenaiBaseUrl(content: string): string {
497
- const lines = content.split("\n");
498
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
499
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
500
- const drop = new Set<number>();
501
- for (let i = 0; i < rootEnd; i++) {
502
- if (!lines[i].includes(OCX_SECTION_MARKER)) continue;
503
- if (i + 1 < rootEnd && (isRootOpenaiBaseUrlLine(lines[i + 1]) || isRootRealtimeWsBaseUrlLine(lines[i + 1]))) {
504
- drop.add(i);
505
- drop.add(i + 1);
506
- } else if (i + 1 >= rootEnd || lines[i + 1].trim() === "") {
507
- drop.add(i); // orphaned marker at root
508
- }
509
- }
510
- if (drop.size === 0) return content;
511
- return lines.filter((_, i) => !drop.has(i)).join("\n");
512
- }
513
-
514
- export type CodexRoutingKind =
515
- "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
516
-
517
- type RoutingEndpointKind = "local" | "remote" | "unknown";
518
-
519
- function ipv4Octets(hostname: string): number[] | null {
520
- const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
521
- if (dotted) {
522
- const octets = dotted.slice(1).map(Number);
523
- return octets.some((octet) => octet > 255) ? null : octets;
524
- }
525
- const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname);
526
- if (!mapped) return null;
527
- const high = Number.parseInt(mapped[1], 16);
528
- const low = Number.parseInt(mapped[2], 16);
529
- return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
530
- }
531
-
532
- function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
533
- try {
534
- const url = new URL(value);
535
- if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown";
536
- const hostname = url.hostname
537
- .toLowerCase()
538
- .replace(/^\[|\]$/g, "")
539
- .replace(/\.$/, "");
540
- if (!hostname) return "unknown";
541
- if (hostname === "localhost" || hostname.endsWith(".localhost"))
542
- return "local";
543
- if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0")
544
- return "local";
545
- const octets = ipv4Octets(hostname);
546
- if (octets) {
547
- if (octets.every((octet) => octet === 0)) return "local";
548
- if (octets[0] === 127) return "local";
549
- return "remote";
550
- }
551
- if (/^::ffff:/i.test(hostname)) return "unknown";
552
- return "remote";
553
- } catch {
554
- return "unknown";
555
- }
556
- }
557
-
558
- /** Classify actual routing dependency separately from opencodex ownership. */
559
- export function classifyCodexRouting(content: string): CodexRoutingKind {
560
- const rootBaseUrl = rootTomlString(content, "openai_base_url");
561
- if (rootBaseUrl) {
562
- const endpoint = classifyRoutingEndpoint(rootBaseUrl);
563
- if (endpoint === "unknown") return "unknown";
564
- if (hasInjectedOpenaiBaseUrl(content)) return "opencodex-local";
565
- return endpoint === "local" ? "custom-local" : "custom-remote";
566
- }
567
- const rootProvider = rootTomlString(content, "model_provider");
568
- if (rootProvider) {
569
- const providerTableExists =
570
- providerTableStart(content.split("\n"), rootProvider) !== -1;
571
- const providerBaseUrl = providerTableString(
572
- content,
573
- rootProvider,
574
- "base_url",
575
- );
576
- if (providerBaseUrl) {
577
- const endpoint = classifyRoutingEndpoint(providerBaseUrl);
578
- if (endpoint === "unknown") return "unknown";
579
- if (rootProvider === "opencodex") return "opencodex-local";
580
- return endpoint === "local" ? "custom-local" : "custom-remote";
581
- }
582
- if (
583
- rootProvider === "opencodex" ||
584
- providerTableExists ||
585
- rootProvider !== "openai"
586
- )
587
- return "unknown";
588
- }
589
- return "native";
590
- }
591
-
592
- /** Read-only probe used by status, doctor, and the dashboard. */
593
- export function isCodexRoutingInjected(): boolean {
594
- const path = CODEX_CONFIG_PATH;
595
- if (!existsSync(path)) return false;
596
- try {
597
- return hasInjectedCodexRouting(readFileSync(path, "utf8"));
598
- } catch {
599
- return false;
600
- }
601
- }
602
-
603
- export function getCodexRoutingKind(): CodexRoutingKind {
604
- const path = CODEX_CONFIG_PATH;
605
- if (!existsSync(path)) return "native";
606
- try {
607
- return classifyCodexRouting(readFileSync(path, "utf8"));
608
- } catch {
609
- return "unknown";
610
- }
611
- }
612
-
613
- /**
614
- * Strip every existing `model_provider` line that we must not duplicate: any line set to
615
- * "opencodex" (wherever it sits — including a previously mis-nested one under a table), plus any
616
- * ROOT-level model_provider (before the first table) of any value, since we override the global.
617
- * A `model_provider` legitimately inside a user table/profile with a non-opencodex value is left
618
- * untouched.
619
- */
620
- function stripExistingModelProvider(content: string): string {
621
- const lines = content.split("\n");
622
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
623
- const out: string[] = [];
624
- lines.forEach((line, i) => {
625
- if (/^\s*model_provider\s*=/.test(line)) {
626
- const isOurs = /^\s*model_provider\s*=\s*"opencodex"\s*$/.test(line);
627
- const isRoot = firstTable === -1 || i < firstTable;
628
- if (isOurs || isRoot) return; // drop it
629
- }
630
- out.push(line);
631
- });
632
- return out.join("\n");
633
- }
634
-
635
- /**
636
- * Drop ROOT-level `model_context_window` overrides (keys before the first table header). Codex
637
- * treats this root key as a global override that wins over the per-model catalog values, so a stale
638
- * `model_context_window = 1000000` makes every model (e.g. gpt-5.5) report a 1M window. User-owned
639
- * compaction limits do not alter the advertised context window and must survive reinjection.
640
- */
641
- export function stripRootContextWindowOverrides(content: string): string {
642
- const lines = content.split("\n");
643
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
644
- return lines
645
- .filter((line, i) => {
646
- const isRoot = firstTable === -1 || i < firstTable;
647
- return !isRoot || !/^\s*model_context_window\s*=/.test(line);
648
- })
649
- .join("\n");
650
- }
651
-
652
- function stripRootRoutedModel(content: string): string {
653
- const lines = content.split("\n");
654
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
655
- return lines
656
- .filter((line, i) => {
657
- const isRoot = firstTable === -1 || i < firstTable;
658
- if (!isRoot) return true;
659
- const m = line.match(/^\s*model\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/);
660
- if (!m) return true;
661
- const model = parseTomlString(m[1]);
662
- return !model?.includes("/");
663
- })
664
- .join("\n");
665
- }
666
-
667
- /**
668
- * Insert `model_provider = "opencodex"` at the document ROOT — immediately before the first table
669
- * header (TOML root keys must precede all tables). If there are no tables, append it to the root body.
670
- */
671
- function setRootModelProvider(content: string): string {
672
- const lines = content.split("\n");
673
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
674
- const key = 'model_provider = "opencodex"';
675
- if (firstTable === -1) {
676
- return content.replace(/\n+$/, "") + "\n" + key + "\n";
677
- }
678
- let insertAt = firstTable;
679
- while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
680
- lines.splice(insertAt, 0, key);
681
- return lines.join("\n");
682
- }
683
-
684
- function readRootModelCatalogPath(content: string): string | null {
685
- const lines = content.split("\n");
686
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
687
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
688
- const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
689
- let ownedCatalogPath: string | null = null;
690
- for (let index = 0; index < rootEnd; index += 1) {
691
- const match = modelCatalogAssignment.exec(lines[index]);
692
- if (!match) continue;
693
- const catalogPath = parseTomlString(match[1]);
694
- if (!isOpencodexCatalogPath(catalogPath)) return catalogPath;
695
- ownedCatalogPath ??= catalogPath;
696
- }
697
- return ownedCatalogPath;
698
- }
699
-
700
- function setRootModelCatalogPath(content: string, catalogPath: string): string {
701
- const lines = content.split("\n");
702
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
703
- const key = `model_catalog_json = ${tomlString(catalogPath)}`;
704
- const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
705
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
706
- const ownedAssignments: number[] = [];
707
- let hasUserAssignment = false;
708
- for (let i = 0; i < rootEnd; i++) {
709
- const m = modelCatalogAssignment.exec(lines[i]);
710
- if (!m) continue;
711
- const existing = parseTomlString(m[1]);
712
- if (isOpencodexCatalogPath(existing)) {
713
- ownedAssignments.push(i);
714
- } else {
715
- hasUserAssignment = true;
716
- }
717
- }
718
- if (hasUserAssignment) {
719
- const owned = new Set(ownedAssignments);
720
- return lines.filter((_, index) => !owned.has(index)).join("\n");
721
- }
722
- if (ownedAssignments.length > 0) {
723
- lines[ownedAssignments[0]] = key;
724
- const duplicates = new Set(ownedAssignments.slice(1));
725
- return lines.filter((_, index) => !duplicates.has(index)).join("\n");
726
- }
727
- if (firstTable === -1) {
728
- return content.replace(/\n+$/, "") + "\n" + key + "\n";
729
- }
730
- let insertAt = firstTable;
731
- while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
732
- lines.splice(insertAt, 0, key);
733
- return lines.join("\n");
734
- }
735
-
736
- function removeProfileSection(content: string): string {
737
- const lines = content.split("\n");
738
- const filtered: string[] = [];
739
- let inProfile = false;
740
- for (const line of lines) {
741
- if (line.trim() === "[profiles.opencodex]") {
742
- inProfile = true;
743
- continue;
744
- }
745
- if (inProfile) {
746
- if (/^\s*\[/.test(line) && line.trim() !== "[profiles.opencodex]") {
747
- inProfile = false;
748
- filtered.push(line);
749
- }
750
- continue;
751
- }
752
- filtered.push(line);
753
- }
754
- return (
755
- filtered
756
- .join("\n")
757
- .replace(/\n{3,}/g, "\n\n")
758
- .trimEnd() + "\n"
759
- );
760
- }
761
-
762
- function normalizeServiceTier(content: string): string {
763
- return content.replace(
764
- /^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm,
765
- '$1"fast"',
766
- );
767
- }
768
-
769
- function ensureFastModeFeature(content: string, fastMode?: boolean): string {
770
- // Tri-state fast mode (see OcxConfig.fastMode): true forces `fast_mode = true`,
771
- // false forces `fast_mode = false`, and undefined leaves the user's config
772
- // untouched (no [features] table is added and an existing fast_mode line is
773
- // preserved as-is). Table and key matching accept the valid TOML spellings
774
- // `[features] # comment`, `["features"]` / `['features']`, and quoted keys.
775
- const lines = content.split("\n");
776
- const featuresHeader = /^\s*\[(["']?)\s*features\s*\1\]\s*(?:#.*)?$/;
777
- const fastModeKey = /^\s*(?:"fast_mode"|'fast_mode'|fast_mode)\s*=/;
778
- const featuresStart = lines.findIndex(line => featuresHeader.test(line));
779
- if (featuresStart === -1) {
780
- if (fastMode === undefined) return content;
781
- return content.trimEnd() + "\n\n[features]\nfast_mode = " + (fastMode ? "true" : "false") + "\n";
782
- }
783
-
784
- const nextTable = lines.findIndex(
785
- (line, index) => index > featuresStart && /^\s*\[/.test(line),
786
- );
787
- const featuresEnd = nextTable === -1 ? lines.length : nextTable;
788
- for (let i = featuresStart + 1; i < featuresEnd; i++) {
789
- if (fastModeKey.test(lines[i])) {
790
- if (fastMode === undefined) return lines.join("\n");
791
- lines[i] = lines[i].replace(/^(\s*)(?:"fast_mode"|'fast_mode'|fast_mode)\s*=.*$/, `$1fast_mode = ${fastMode ? "true" : "false"}`);
792
- return lines.join("\n");
793
- }
794
- }
795
-
796
- if (fastMode === undefined) return lines.join("\n");
797
- let insertAt = featuresEnd;
798
- while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--;
799
- lines.splice(insertAt, 0, `fast_mode = ${fastMode ? "true" : "false"}`);
800
- return lines.join("\n");
801
- }
802
-
803
- function isOpencodexCatalogPath(path: string): boolean {
804
- return path.replace(/\\/g, "/").split("/").pop() === "opencodex-catalog.json";
805
- }
806
-
807
- function stripOpencodexCatalogPath(content: string): string {
808
- const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
809
- const lines = content.split("\n");
810
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
811
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
812
- return lines
813
- .filter((line, index) => {
814
- if (index >= rootEnd) return true;
815
- const m = modelCatalogAssignment.exec(line);
816
- return !m || !isOpencodexCatalogPath(parseTomlString(m[1]));
817
- })
818
- .join("\n");
819
- }
820
-
821
- export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, fastMode?: boolean): string;
822
- export function buildProfileFile(target: CodexRoutingTarget, catalogPath?: string | null, supportsWebsockets?: boolean, fastMode?: boolean): string;
823
- export function buildProfileFile(
824
- portOrTarget: number | CodexRoutingTarget,
825
- catalogPath?: string | null,
826
- supportsWebsockets = false,
827
- includeApiAuthHeaderOrFastMode?: boolean,
828
- hostname?: string,
829
- fastMode?: boolean,
830
- ): string {
831
- const target = typeof portOrTarget === "number"
832
- ? validateCodexRoutingTarget({
833
- baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`,
834
- requiresAdmissionToken: includeApiAuthHeaderOrFastMode === true,
835
- tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
836
- })
837
- : validateCodexRoutingTarget(portOrTarget);
838
- return buildProfileFileForTarget(
839
- target,
840
- catalogPath,
841
- supportsWebsockets,
842
- typeof portOrTarget === "number" ? fastMode : includeApiAuthHeaderOrFastMode,
843
- );
844
- }
845
-
846
- function buildProfileFileForTarget(
847
- target: CodexRoutingTarget,
848
- catalogPath?: string | null,
849
- supportsWebsockets = false,
850
- fastMode?: boolean,
851
- ): string {
852
- const origin = routingTargetOrigin(target);
853
- const host = new URL(origin).host;
854
- // Design B (loopback): the reference/fallback file documents the root override form.
855
- // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry
856
- // the x-opencodex-api-key env header); explicit Desktop policies share that shape.
857
- if (!usesProviderTable(target)) {
858
- const lines = [
859
- "# OpenCodex proxy fallback config (Design B)",
860
- `# Root override that points Codex's built-in openai provider at the proxy on ${host}.`,
861
- "# Merge these root keys into ~/.codex/config.toml manually if auto-injection was removed.",
862
- buildOpenaiBaseUrlLineForTarget(target),
863
- ];
864
- if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
865
- if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`, "");
866
- return lines.join("\n");
867
- }
868
- const lines = [
869
- "# OpenCodex proxy profile — use with: codex --profile opencodex",
870
- `# Routes all model requests through the opencodex proxy at ${host}`,
871
- 'model_provider = "opencodex"',
872
- ];
873
- if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
874
- if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`);
875
- lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets).trimEnd(), "");
876
- return lines.join("\n");
877
- }
878
-
879
- export function chooseCatalogPathForInjection(
880
- content: string,
881
- requested?: string | null,
882
- ): string | null {
883
- if (requested !== undefined) return requested;
884
-
885
- const existing = readRootModelCatalogPath(content);
886
- if (existing) {
887
- const resolved = resolveCodexConfigPath(existing);
888
- if (!isOpencodexCatalogPath(resolved) || existsSync(resolved))
889
- return existing;
890
- }
891
-
892
- return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null;
893
- }
894
148
 
895
149
  export interface CodexInjectResult {
896
150
  success: boolean;
@@ -916,19 +170,10 @@ export interface CodexInjectResult {
916
170
  const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer";
917
171
 
918
172
  class CodexHistoryPreflightRefusal extends Error {}
919
- class CodexRestoreRefusal extends Error {
920
- constructor(readonly config: CodexRestoreConfigResult) {
921
- super(config.message);
922
- }
923
- }
924
173
  let historyArtifactStageForTests: ((stage: string) => void) | undefined;
925
174
  export function setHistoryArtifactStageForTests(hook: typeof historyArtifactStageForTests): void {
926
175
  historyArtifactStageForTests = hook;
927
176
  }
928
- let beforeRestoreConfigForTests: ((kind: string) => void) | undefined;
929
- export function setBeforeRestoreConfigForTests(hook: typeof beforeRestoreConfigForTests): void {
930
- beforeRestoreConfigForTests = hook;
931
- }
932
177
  let beforeHistoryArtifactCommitForTests: ((kind: string) => void) | undefined;
933
178
  export function setBeforeHistoryArtifactCommitForTests(hook: typeof beforeHistoryArtifactCommitForTests): void {
934
179
  beforeHistoryArtifactCommitForTests = hook;
@@ -1220,9 +465,9 @@ async function injectCodexConfigImpl(
1220
465
  */
1221
466
  /*
1222
467
  * Re-observed inside the artifact transaction. A store that migrates to paginated history
1223
- * mid-write retires the relabel unit, because the config half writes no history and rolling
1224
- * it back is what left every paginated home with no OpenCodex models. Any other reason is
1225
- * still treated as a failed transition so compensation can restore the pre-images.
468
+ * mid-write can retire the relabel unit while its already-admitted candidate leaves
469
+ * existing provider references resolvable. Existing provider definitions are retained
470
+ * before the witness; no post-commit compensation may overwrite a newer native write.
1226
471
  */
1227
472
  const observeHistoryRefusalOrThrow = (known: string | null): string | null => {
1228
473
  if (known) return known;
@@ -1244,12 +489,12 @@ async function injectCodexConfigImpl(
1244
489
 
1245
490
  /*
1246
491
  * Rows this home may have tagged `opencodex` resolve only through a provider table. Design B
1247
- * normally retires that table because the relabel migrates those rows back to `openai` in
1248
- * the same pass; with the relabel stood down, stripping it anyway would leave every such
1249
- * conversation pointing at a provider id that no longer exists. Keep what was already
1250
- * published, and keep it BEFORE the witness so the lock admits the bytes actually written.
492
+ * selects built-in `openai` for new work, but background relabel and native publication are
493
+ * not atomic. Codex can paginate after the final check or when the worker starts. Retain
494
+ * an existing definition BEFORE the witness regardless of preflight, so worker failure
495
+ * cannot orphan old references. Explicit restoration keeps its removal and history guards.
1251
496
  */
1252
- if (historyRelabelRefusal && hadOcxProviderTableOnDisk && !providerTableMode) {
497
+ if (hadOcxProviderTableOnDisk && !providerTableMode) {
1253
498
  content = applyEol(
1254
499
  content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})),
1255
500
  eol,
@@ -1542,6 +787,7 @@ async function injectCodexConfigImpl(
1542
787
  // handed down fixed; the Worker never takes a direction from its caller.
1543
788
  // A stood-down relabel unit spawns no Worker: the preflight it would run first has
1544
789
  // already refused, and the config half is committed either way.
790
+ historyArtifactStageForTests?.("before-history-worker");
1545
791
  const historyOutcome: CodexHistoryJobOutcome = historyRelabelRefusal
1546
792
  ? { kind: "skipped" }
1547
793
  : await runCodexHistoryJob({
@@ -1661,656 +907,6 @@ async function injectCodexConfigImpl(
1661
907
  };
1662
908
  }
1663
909
 
1664
- /**
1665
- * Sub-table headers like `[model_providers.opencodex.env_http_headers]` appear when a Codex app
1666
- * config rewrite re-serializes the provider's inline `env_http_headers` table. They define the
1667
- * same `model_providers.opencodex` provider, so cleanup must remove them too — otherwise the
1668
- * provider survives with no `name` and Codex rejects the whole config
1669
- * ("provider name must not be empty"). The dot terminator keeps a user's
1670
- * `[model_providers.opencodex_backup]`-style tables out of scope.
1671
- */
1672
- function isOcxProviderHeaderLine(trimmedLine: string): boolean {
1673
- // Root form matched by regex, not equality: TOML v1.0 allows a trailing comment
1674
- // (`[model_providers.opencodex] # comment`), and an exact compare would miss that form.
1675
- // The sub-table prefix check already tolerates trailing comments by construction.
1676
- return (
1677
- /^\[model_providers\.opencodex\]\s*(?:#.*)?$/.test(trimmedLine) ||
1678
- trimmedLine.startsWith("[model_providers.opencodex.")
1679
- );
1680
- }
1681
-
1682
- function hasOcxProviderTable(content: string): boolean {
1683
- return content
1684
- .split("\n")
1685
- .some((line) => isOcxProviderHeaderLine(line.trim()));
1686
- }
1687
-
1688
- function removeOcxSection(content: string): string {
1689
- const lines = content.split("\n");
1690
- const filtered: string[] = [];
1691
- let inOcxSection = false;
1692
- for (const line of lines) {
1693
- if (
1694
- line.includes(OCX_SECTION_MARKER) ||
1695
- isOcxProviderHeaderLine(line.trim())
1696
- ) {
1697
- inOcxSection = true;
1698
- continue;
1699
- }
1700
- if (inOcxSection) {
1701
- // End the injected section at the next table header that ISN'T our own. Exact match on the
1702
- // provider name (plus our own sub-tables) so a user's
1703
- // "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
1704
- if (/^\s*\[/.test(line) && !isOcxProviderHeaderLine(line.trim())) {
1705
- inOcxSection = false;
1706
- filtered.push(line);
1707
- }
1708
- continue;
1709
- }
1710
- filtered.push(line);
1711
- }
1712
- return (
1713
- filtered
1714
- .join("\n")
1715
- .replace(/\n{3,}/g, "\n\n")
1716
- .trimEnd() + "\n"
1717
- );
1718
- }
1719
-
1720
- interface StripOpencodexConfigResult {
1721
- content: string;
1722
- managedDefaultsError: string | null;
1723
- }
1724
-
1725
- /**
1726
- * Detailed form used by the on-disk restore path. A damaged ownership marker is
1727
- * ambiguous: keep the associated value, but return the transform error so the
1728
- * caller cannot report a complete restore.
1729
- */
1730
- function stripOpencodexConfigResult(
1731
- content: string,
1732
- journaledBaseUrl: string | null = null,
1733
- journaledRealtimeWsBaseUrl: string | null = null,
1734
- ): StripOpencodexConfigResult {
1735
- let out = content;
1736
- const hadRootOcxProvider =
1737
- readRootTomlString(out, "model_provider") === "opencodex";
1738
- // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values
1739
- // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded
1740
- // writing -- so an app-rewritten config is still recognized as ours.
1741
- const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out)
1742
- || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
1743
- out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
1744
- out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl, journaledRealtimeWsBaseUrl);
1745
- if (hasOcxProviderTable(out)) {
1746
- out = removeOcxSection(out);
1747
- }
1748
- out = removeProfileSection(out);
1749
- // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
1750
- // must match the detection regex above, or a detected line could survive un-removed.
1751
- out = out
1752
- .split("\n")
1753
- .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l))
1754
- .join("\n");
1755
- // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves
1756
- // them — strip on both the legacy re-tag form and the Design B injected-base-url form.
1757
- if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out);
1758
- const managedDefaults = transformManagedSubagentDefaults(out, null);
1759
- if (managedDefaults.ok) out = managedDefaults.content;
1760
- out = stripOpencodexCatalogPath(out);
1761
- return {
1762
- content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n",
1763
- managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null,
1764
- };
1765
- }
1766
-
1767
- /** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */
1768
- export function stripOpencodexConfig(content: string): string {
1769
- return stripOpencodexConfigResult(content).content;
1770
- }
1771
-
1772
- function hasOpencodexRouting(content: string): boolean {
1773
- return (
1774
- hasOcxProviderTable(content) ||
1775
- /^\s*model_provider\s*=\s*"opencodex"/m.test(content) ||
1776
- hasInjectedOpenaiBaseUrl(content)
1777
- );
1778
- }
1779
-
1780
- export function removeCodexConfig(
1781
- options: { preserveProfile?: boolean } = {},
1782
- ): { success: boolean; message: string } {
1783
- const historyError = preflightCodexHistoryInjection(false, false);
1784
- if (historyError) return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` };
1785
- if (!existsSync(CODEX_CONFIG_PATH)) {
1786
- if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
1787
- unlinkSync(CODEX_PROFILE_PATH);
1788
- return {
1789
- success: true,
1790
- message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
1791
- };
1792
- }
1793
- const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
1794
- // Same EOL boundary as inject: strip in LF space, write back in the file's own ending.
1795
- // The unchanged fast path compares in LF space so an untouched file is never rewritten.
1796
- const eol = dominantEol(rawContent);
1797
- const content = applyEol(rawContent, "\n");
1798
- // Read the recorded injection once: the strip below consumes it, and so does the
1799
- // ownership verdict, which must agree with what was actually removed.
1800
- const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
1801
- const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl();
1802
- const had = hasOpencodexRouting(content)
1803
- || (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl)
1804
- || (journaledRealtimeWsBaseUrl !== null
1805
- && rootTomlString(content, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
1806
- const stripped = stripOpencodexConfigResult(content, journaledBaseUrl, journaledRealtimeWsBaseUrl);
1807
- if (had || stripped.content !== content) {
1808
- atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
1809
- }
1810
- if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
1811
- unlinkSync(CODEX_PROFILE_PATH);
1812
- const removedMessage = had
1813
- ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
1814
- : "opencodex not present in Codex config.";
1815
- if (stripped.managedDefaultsError) {
1816
- const routingMessage = had
1817
- ? removedMessage
1818
- : "No opencodex routing was present in Codex config.";
1819
- return {
1820
- success: false,
1821
- message:
1822
- `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` +
1823
- "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.",
1824
- };
1825
- }
1826
- return {
1827
- success: true,
1828
- message: removedMessage,
1829
- };
1830
- }
1831
-
1832
- export type CodexRestoreArtifactState = "ok" | "skipped" | "failed";
1833
-
1834
- export interface CodexRestoreConfigResult {
1835
- state: CodexRestoreArtifactState;
1836
- changed: boolean;
1837
- action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed";
1838
- message: string;
1839
- }
1840
-
1841
- export interface CodexRestoreCatalogResult {
1842
- state: CodexRestoreArtifactState;
1843
- changed: boolean;
1844
- removed: number;
1845
- kept: number;
1846
- path: string | null;
1847
- message: string;
1848
- }
1849
-
1850
- export interface CodexRestoreHistoryResult {
1851
- state: CodexRestoreArtifactState;
1852
- changed: boolean;
1853
- reason?: CodexHistoryFailureReason;
1854
- rows: number;
1855
- files: number;
1856
- ejectedRows: number;
1857
- message: string;
1858
- }
1859
-
1860
- export interface CodexNativeRestoreResult {
1861
- success: boolean;
1862
- message: string;
1863
- externalProvider?: string;
1864
- artifacts: {
1865
- config: CodexRestoreConfigResult;
1866
- catalog: CodexRestoreCatalogResult;
1867
- history: CodexRestoreHistoryResult;
1868
- };
1869
- }
1870
-
1871
- function failedHistoryRestore(
1872
- reason?: CodexHistoryFailureReason,
1873
- detail?: string,
1874
- progress: { rows?: number; files?: number } = {},
1875
- ): CodexRestoreHistoryResult {
1876
- const rows = progress.rows ?? 0;
1877
- const files = progress.files ?? 0;
1878
- const changed = rows > 0 || files > 0;
1879
- return {
1880
- state: "failed",
1881
- changed,
1882
- ...(reason ? { reason } : {}),
1883
- rows,
1884
- files,
1885
- ejectedRows: 0,
1886
- message: reason === "permission"
1887
- ? changed
1888
- ? "Codex resume history changed but did NOT converge because permission was denied while finalizing the backup manifest; the manifest was retained for review and safe retry."
1889
- : "Codex resume history could NOT be restored because permission was denied."
1890
- : reason === "busy"
1891
- ? changed
1892
- ? "Codex resume history changed but did NOT converge because backup-manifest finalization remained busy; the manifest was retained for review and safe retry."
1893
- : detail ?? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database."
1894
- : reason === "integrity"
1895
- ? changed
1896
- ? "Codex resume history changed but did NOT converge because the backup or target changed; the manifest was retained for review and safe retry."
1897
- : "Codex resume history could NOT be restored because the backup or restore target failed integrity checks; unverified provider metadata was left unchanged."
1898
- : detail
1899
- ? `Codex resume history could NOT be restored: ${detail}`
1900
- : "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.",
1901
- };
1902
- }
1903
-
1904
- /**
1905
- * Restore failure wording for a Worker outcome.
1906
- *
1907
- * Only a genuine busy result blames the Codex app. An unsafe-path refusal, an
1908
- * unavailable coordinator database, a permission denial, or a dead/timed-out
1909
- * worker is a different problem; the old collapse made every one of those read
1910
- * as "the Codex app is holding the database" (issue #1191). `busy` and
1911
- * `permission` keep the restore-specific sentence built by
1912
- * `failedHistoryRestore`; every other reason reuses the single formatter so
1913
- * the two modules cannot drift apart.
1914
- */
1915
- export function failedHistoryRestoreFromOutcome(
1916
- outcome: Extract<CodexHistoryJobOutcome, { kind: "blocked" | "failed" }>,
1917
- ): CodexRestoreHistoryResult {
1918
- if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy");
1919
- if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") {
1920
- return failedHistoryRestore(
1921
- "busy",
1922
- describeHistoryJobFailure(outcome, "restore"),
1923
- { rows: outcome.rows, files: outcome.files },
1924
- );
1925
- }
1926
- if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") {
1927
- return failedHistoryRestore("permission", undefined, { rows: outcome.rows, files: outcome.files });
1928
- }
1929
- if (outcome.kind === "failed" && outcome.historyFailureReason === "integrity") {
1930
- return failedHistoryRestore("integrity", undefined, { rows: outcome.rows, files: outcome.files });
1931
- }
1932
- return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore"));
1933
- }
1934
-
1935
- function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult {
1936
- const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`;
1937
- return {
1938
- success: true,
1939
- message,
1940
- externalProvider: activeProvider,
1941
- artifacts: {
1942
- config: { state: "skipped", changed: false, action: "external-provider-preserved", message },
1943
- catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1944
- history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1945
- },
1946
- };
1947
- }
1948
-
1949
- /** A foreign service claim is an authority boundary, including explicit CLI restore. */
1950
- function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResult {
1951
- return {
1952
- success: false,
1953
- message: `Codex native restore refused: ${message}`,
1954
- artifacts: {
1955
- config: { state: "skipped", changed: false, action: "failed", message },
1956
- catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1957
- history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1958
- },
1959
- };
1960
- }
1961
-
1962
- function desiredEnabledRestoreSkip(): CodexNativeRestoreResult {
1963
- const message = "Codex integration was re-enabled; native restore was skipped.";
1964
- return skippedRestoreEnvelope(true, message);
1965
- }
1966
-
1967
- /**
1968
- * A schema-complete all-skipped envelope for outcomes decided before any
1969
- * restore machinery runs. Every `restore --json` path must stay shape-stable
1970
- * with `CodexNativeRestoreResult`; consumers never special-case early exits.
1971
- */
1972
- export function skippedRestoreEnvelope(success: boolean, message: string): CodexNativeRestoreResult {
1973
- return {
1974
- success,
1975
- message,
1976
- artifacts: {
1977
- config: { state: "skipped", changed: false, action: "owned-fields-stripped", message },
1978
- catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1979
- history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1980
- },
1981
- };
1982
- }
1983
-
1984
- /** Config was attempted and failed; downstream artifacts were never attempted. */
1985
- function failedConfigRestoreEnvelope(config: CodexRestoreConfigResult): CodexNativeRestoreResult {
1986
- const result = skippedRestoreEnvelope(false, config.message);
1987
- result.artifacts.config = config;
1988
- return result;
1989
- }
1990
-
1991
- /** The config/profile half of a native restore, reported as one artifact. */
1992
- function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult {
1993
- const preImages = captureCodexPreImages();
1994
- const result = restoreCodexConfigInlineImpl(kind);
1995
- if (result.state === "failed") {
1996
- const compensated = restoreCodexPreImages(preImages);
1997
- if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
1998
- }
1999
- return result;
2000
- }
2001
-
2002
- function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult {
2003
- try {
2004
- beforeRestoreConfigForTests?.(kind);
2005
- const historyError = preflightCodexHistoryInjection(false, false);
2006
- if (historyError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${historyError}.` };
2007
- const journal = restoreJournalState();
2008
- if (journal.unverified) {
2009
- return {
2010
- state: "failed", changed: false, action: "failed",
2011
- message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.",
2012
- };
2013
- }
2014
- const restored = journal.configRestored
2015
- ? { success: true, message: "Codex config restored from opencodex journal." }
2016
- : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
2017
- if (restored.success) {
2018
- // A successful journal/fallback write can race native history migration too.
2019
- // Refuse here while preimage compensation and the remove transaction can roll back.
2020
- const finalHistoryError = preflightCodexHistoryInjection(false, false);
2021
- if (finalHistoryError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${finalHistoryError}.` };
2022
- }
2023
- return restored.success
2024
- ? {
2025
- state: "ok",
2026
- changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"),
2027
- action: journal.configRestored ? "journal-restored" : "owned-fields-stripped",
2028
- message: restored.message,
2029
- }
2030
- : { state: "failed", changed: false, action: "failed", message: restored.message };
2031
- } catch (error) {
2032
- return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) };
2033
- }
2034
- }
2035
-
2036
- /** The catalog half, always inside its own K acquisition. */
2037
- /**
2038
- * The catalog half, always inside its own K acquisition.
2039
- *
2040
- * `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a
2041
- * successful journal restore deletes the journal, and a config restore can remove
2042
- * `model_catalog_json`. Reading it here would be too late in both cases (#1798).
2043
- */
2044
- function restoreCodexCatalogArtifact(
2045
- revalidateDesiredState: boolean,
2046
- journaledCatalogPath: string | null,
2047
- ): CodexRestoreCatalogResult {
2048
- const owningCodexHome = getCodexHome();
2049
- try {
2050
- const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
2051
- revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
2052
- ? null
2053
- : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath));
2054
- return restored.kind === "completed" && restored.value !== null
2055
- ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
2056
- : restored.kind === "completed"
2057
- ? {
2058
- state: "skipped", changed: false, removed: 0, kept: 0, path: null,
2059
- message: "Codex integration was re-enabled; native catalog restoration was skipped.",
2060
- }
2061
- : {
2062
- state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
2063
- message: `Codex catalog could not be restored: ${restored.reason}.`,
2064
- };
2065
- } catch (error) {
2066
- return {
2067
- state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
2068
- message: error instanceof Error ? error.message : String(error),
2069
- };
2070
- }
2071
- }
2072
-
2073
- /**
2074
- * Restore native Codex, running history in a Worker under H.
2075
- *
2076
- * On a coordinated home the config/profile restore happens INSIDE the Codex
2077
- * write lock, publishing a `remove` transition — the same serialization inject
2078
- * uses. Without it, an older restore could overwrite a config a concurrent
2079
- * enable had just written under the lock, and then honestly report success
2080
- * while desired intent said ON. The desired-state re-read under the lock turns
2081
- * that lost race into the discriminated `desired_enabled` skip.
2082
- */
2083
- export async function restoreNativeCodexAsync(
2084
- options: { revalidateDesiredState?: boolean } = {},
2085
- ): Promise<CodexNativeRestoreResult> {
2086
- try {
2087
- return await restoreNativeCodexAsyncImpl(options);
2088
- } catch (error) {
2089
- if (!(error instanceof CodexRestoreRefusal)) throw error;
2090
- return failedConfigRestoreEnvelope(error.config);
2091
- }
2092
- }
2093
-
2094
- async function restoreNativeCodexAsyncImpl(
2095
- options: { revalidateDesiredState?: boolean },
2096
- ): Promise<CodexNativeRestoreResult> {
2097
- const activeProvider = currentExternalCodexModelProvider();
2098
- if (activeProvider) {
2099
- // External-provider courtesy: only the stale journal is removed. The
2100
- // history worker must not launch — it would turn a read-mostly courtesy
2101
- // result into a history mutation on a home we do not own.
2102
- removeJournal();
2103
- return externalProviderRestoreResult(activeProvider);
2104
- }
2105
-
2106
- // `restore` normally honours a human request even when an unrelated
2107
- // service-manager probe is unavailable. A recorded FOREIGN home is not an
2108
- // unrelated probe: it is positive evidence another installation owns these
2109
- // native artifacts, so do not create profile/claim locks before refusing.
2110
- if (options.revalidateDesiredState) {
2111
- const ownership = inspectNativeCodexOwnership();
2112
- if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason);
2113
- if (shouldSyncCodexOnStart(loadConfig())) return desiredEnabledRestoreSkip();
2114
- }
2115
-
2116
- const historyError = preflightCodexHistoryInjection(false, false);
2117
- if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`);
2118
-
2119
- const eligibility = codexWriteCoordinationEligibility({
2120
- coordinatorPath: () =>
2121
- resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()),
2122
- residue: () => classifyNativeRoutedResidue(),
2123
- integrationRecord: () => readIntegrationRecord(),
2124
- });
2125
-
2126
- // Captured before the config half: a successful journal restore DELETES the journal, and
2127
- // restoring the config can drop `model_catalog_json`. Either one would hide the routed
2128
- // catalog we actually wrote (#1798).
2129
- const journaledCatalogPath = journaledInjectedCatalogPath();
2130
- let config: CodexRestoreConfigResult;
2131
- let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
2132
-
2133
- if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") {
2134
- // The restore has no candidate bytes to witness; freshness comes from the
2135
- // filesystem reads and the desired-state re-read performed under the lock.
2136
- const witness = { authoritySnapshotId: "codex-native-restore" };
2137
- const coordinated = await withCodexWriteLock(
2138
- {
2139
- timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS,
2140
- ...(eligibility.kind === "adopt" ? { adoption: { direction: "remove" as const } } : {}),
2141
- admitted: witness,
2142
- readAdmissionUnderLock: () => witness,
2143
- },
2144
- (ctx) => {
2145
- if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
2146
- throw new CodexWriteLockSkipped("desired_enabled");
2147
- }
2148
- const published = ctx.coordinator.beginTransition(
2149
- {
2150
- nativeGeneration: ctx.expectation.nativeBefore,
2151
- currentTxId: ctx.currentTxId,
2152
- },
2153
- {
2154
- txId: ctx.expectation.txId,
2155
- direction: "remove",
2156
- authoritySnapshotId: ctx.admission.authoritySnapshotId,
2157
- nextRetryAt: new Date().toISOString(),
2158
- },
2159
- );
2160
- if (published.kind !== "updated") {
2161
- throw new CodexWriteConflictError(
2162
- `The Codex transition could not be published: ${published.kind}.`,
2163
- );
2164
- }
2165
- const preImages = captureCodexPreImages();
2166
- let restored: CodexRestoreConfigResult;
2167
- try {
2168
- restored = restoreCodexConfigInline(eligibility.kind);
2169
- // Throw inside N so the published remove transition rolls back too.
2170
- if (restored.state === "failed") throw new CodexRestoreRefusal(restored);
2171
- } catch (error) {
2172
- const compensated = restoreCodexPreImages(preImages);
2173
- if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
2174
- throw error;
2175
- }
2176
- return {
2177
- config: restored,
2178
- preImages,
2179
- receipt: {
2180
- nativeGeneration: ctx.expectation.nativeAfter,
2181
- currentTxId: ctx.expectation.txId,
2182
- },
2183
- };
2184
- },
2185
- );
2186
- if (coordinated.status === "skipped") return desiredEnabledRestoreSkip();
2187
- if (coordinated.status !== "acquired") {
2188
- config = {
2189
- state: "failed",
2190
- changed: false,
2191
- action: "failed",
2192
- message: coordinated.status === "busy"
2193
- ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.`
2194
- : `Codex configuration was not restored: ${coordinated.message}`,
2195
- };
2196
- } else {
2197
- recordCodexNativeTransactionProvenance(
2198
- coordinated.value.preImages,
2199
- coordinated.value.receipt.currentTxId,
2200
- );
2201
- config = coordinated.value.config;
2202
- transitionReceipt = coordinated.value.receipt;
2203
- }
2204
- } else {
2205
- // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path
2206
- // they have always had; restore is the escape hatch and must not strand
2207
- // them. The plain re-read still honors an intervening re-enable.
2208
- if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
2209
- return desiredEnabledRestoreSkip();
2210
- }
2211
- config = restoreCodexConfigInline(eligibility.kind);
2212
- }
2213
-
2214
- if (config.state === "failed") return failedConfigRestoreEnvelope(config);
2215
- const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
2216
- const outcome = await runCodexHistoryJob({
2217
- ...resolveCodexHistoryJobTarget(),
2218
- ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
2219
- operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }),
2220
- });
2221
- if (transitionReceipt) {
2222
- resolveCodexHistoryTransition(transitionReceipt, outcome);
2223
- }
2224
- const history: CodexRestoreHistoryResult = outcome.kind === "converged"
2225
- ? {
2226
- state: "ok", changed: outcome.rows > 0 || outcome.files > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0,
2227
- message: outcome.rows > 0
2228
- ? `Resume history metadata restored from opencodex backup (${outcome.rows} thread(s)); original providers preserved.`
2229
- : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.",
2230
- }
2231
- : outcome.kind === "skipped"
2232
- ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." }
2233
- : outcome.kind === "blocked" && (outcome.reason === "desired_disabled" || outcome.reason === "desired_enabled")
2234
- ? {
2235
- state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0,
2236
- message: outcome.reason === "desired_disabled"
2237
- ? "Codex integration was disabled; history restoration was skipped."
2238
- : "Codex integration was enabled; history restoration was skipped.",
2239
- }
2240
- : outcome.kind === "blocked" || outcome.kind === "failed"
2241
- ? failedHistoryRestoreFromOutcome(outcome)
2242
- : failedHistoryRestore();
2243
- const base = catalog.removed > 0
2244
- ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
2245
- : config.message;
2246
- const success = catalog.state !== "failed"
2247
- && history.state !== "failed";
2248
- return {
2249
- success,
2250
- message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`,
2251
- artifacts: { config, catalog, history },
2252
- };
2253
- }
2254
-
2255
- export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult {
2256
- const activeProvider = currentExternalCodexModelProvider();
2257
- if (activeProvider) {
2258
- removeJournal();
2259
- return externalProviderRestoreResult(activeProvider);
2260
- }
2261
- if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
2262
- return desiredEnabledRestoreSkip();
2263
- }
2264
- const historyError = preflightCodexHistoryInjection(false, false);
2265
- if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`);
2266
- // Captured before the config half: a successful journal restore DELETES the journal, and
2267
- // restoring the config can drop `model_catalog_json`. Either one would hide the routed
2268
- // catalog we actually wrote (#1798).
2269
- const journaledCatalogPath = journaledInjectedCatalogPath();
2270
- const config = restoreCodexConfigInline();
2271
- if (config.state === "failed") return failedConfigRestoreEnvelope(config);
2272
- const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
2273
- // Design B (loopback) steady state: threads are already tagged openai, so prove the
2274
- // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
2275
- // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
2276
- // Legacy (non-loopback) installs keep the unconditional write-open restore.
2277
- let skipWhenProvablyNoop = false;
2278
- try {
2279
- skipWhenProvablyNoop = !shouldInjectApiAuthHeader(loadConfig());
2280
- } catch {
2281
- /* unreadable config: keep the conservative write-open restore */
2282
- }
2283
- // `skipHistory` is how the async wrapper takes this work for itself: the
2284
- // native files come down here, and history runs in the Worker under H.
2285
- const rawHistory = options.skipHistory
2286
- ? { rows: 0, files: 0 }
2287
- : syncCodexHistoryProvider("openai", undefined, undefined, {
2288
- skipWhenProvablyNoop,
2289
- });
2290
- const history: CodexRestoreHistoryResult = options.skipHistory
2291
- ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." }
2292
- : rawHistory.failed
2293
- ? failedHistoryRestore(rawHistory.failureReason, undefined, rawHistory)
2294
- : {
2295
- state: "ok",
2296
- changed: rawHistory.rows > 0 || rawHistory.files > 0 || (rawHistory.ejectedRows ?? 0) > 0,
2297
- rows: rawHistory.rows,
2298
- files: rawHistory.files,
2299
- ejectedRows: rawHistory.ejectedRows ?? 0,
2300
- message: rawHistory.rows > 0
2301
- ? `Resume history metadata restored from opencodex backup (${rawHistory.rows} thread(s)); original providers preserved.`
2302
- : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.",
2303
- };
2304
- const message = catalog.removed > 0
2305
- ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
2306
- : config.message;
2307
- return {
2308
- success: catalog.state !== "failed" && history.state !== "failed",
2309
- message,
2310
- artifacts: { config, catalog, history },
2311
- };
2312
- }
2313
-
2314
910
  export function getCodexConfigPath(): string {
2315
911
  return CODEX_CONFIG_PATH;
2316
912
  }
@@ -2340,3 +936,52 @@ export function formatApplyHistoryFailure(outcome: CodexHistoryJobOutcome, legac
2340
936
  : "Codex resume history NOT changed";
2341
937
  return ` ⚠️ ${headline}: ${describeHistoryJobFailure(outcome, "apply", legacyMode)}\n`;
2342
938
  }
939
+
940
+ export {
941
+ providerBaseHost,
942
+ standaloneCodexRoutingTarget,
943
+ } from "./inject/routing-target";
944
+ export type { CodexRoutingTarget } from "./inject/routing-target";
945
+
946
+ export {
947
+ applyEol,
948
+ buildOpenaiBaseUrlLine,
949
+ buildProfileFile,
950
+ buildProviderTableBlock,
951
+ buildRealtimeWsBaseUrlLine,
952
+ chooseCatalogPathForInjection,
953
+ currentExternalCodexModelProvider,
954
+ dominantEol,
955
+ externalCodexModelProvider,
956
+ setRootOpenaiBaseUrl,
957
+ setRootRealtimeWsBaseUrl,
958
+ stripInjectedOpenaiBaseUrl,
959
+ stripRootContextWindowOverrides,
960
+ } from "./inject/config-toml";
961
+
962
+ export {
963
+ classifyCodexRouting,
964
+ getCodexRoutingKind,
965
+ isCodexRoutingInjected,
966
+ } from "./inject/routing-classify";
967
+ export type { CodexRoutingKind } from "./inject/routing-classify";
968
+
969
+ export {
970
+ removeCodexConfig,
971
+ stripOpencodexConfig,
972
+ } from "./inject/remove";
973
+
974
+ export type {
975
+ CodexNativeRestoreResult,
976
+ CodexRestoreArtifactState,
977
+ CodexRestoreCatalogResult,
978
+ CodexRestoreConfigResult,
979
+ CodexRestoreHistoryResult,
980
+ } from "./inject/restore";
981
+ export {
982
+ failedHistoryRestoreFromOutcome,
983
+ restoreNativeCodex,
984
+ restoreNativeCodexAsync,
985
+ setBeforeRestoreConfigForTests,
986
+ skippedRestoreEnvelope,
987
+ } from "./inject/restore";