@bitkyc08/opencodex 2.64.0 → 2.65.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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-DGLte6IR.js +50 -0
  5. package/gui/dist/assets/Tray-D4bA1vff.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-Bi1K37Wl.js} +12 -12
  8. package/gui/dist/assets/tray-data-BZXhB3qz.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -8,6 +8,7 @@ import {
8
8
  REALTIME_WS_BASE_URL_KEY,
9
9
  isRootOpenaiBaseUrlLine,
10
10
  isRootRealtimeWsBaseUrlLine,
11
+ rootTomlString,
11
12
  tomlStringPattern,
12
13
  } from "../injected-marker";
13
14
  import {
@@ -303,6 +304,142 @@ export function setRootRealtimeWsBaseUrl(
303
304
  return { content, keptUserRealtimeWsBaseUrl: false };
304
305
  }
305
306
 
307
+ /**
308
+ * Root key codex-rs reads for its web-search mode. The value opencodex writes is the only one that
309
+ * takes the native hosted tool away; the other modes keep it, so they are never written here.
310
+ */
311
+ export const ROOT_WEB_SEARCH_KEY = "web_search";
312
+
313
+ /** The one value opencodex writes for {@link ROOT_WEB_SEARCH_KEY}. */
314
+ export const ROOT_WEB_SEARCH_DISABLED_LINE = 'web_search = "disabled"';
315
+
316
+ /** The value {@link ROOT_WEB_SEARCH_DISABLED_LINE} carries, as the journal records it. */
317
+ export const ROOT_WEB_SEARCH_DISABLED_VALUE = "disabled";
318
+
319
+ export function isRootWebSearchLine(line: string): boolean {
320
+ // The quoted spellings are the same key to TOML, and `tomlStringPattern` -- which the value
321
+ // evidence below goes through -- already reads them. A predicate that matched only the bare
322
+ // spelling would leave `"web_search" = "live"` in place while inserting our own line, and two
323
+ // root keys of the same name stop Codex from loading the file at all.
324
+ return /^\s*(?:"web_search"|'web_search'|web_search)\s*=/.test(line);
325
+ }
326
+
327
+ /** What an earlier injection recorded about this key, read back from the journal. */
328
+ export interface RootWebSearchJournal {
329
+ /** The value that injection wrote, or null when it wrote none. */
330
+ injectedValue?: string | null;
331
+ /** The user-owned line that injection had to remove, or null when there was none. */
332
+ replacedUserLine?: string | null;
333
+ }
334
+
335
+ /** What one pass of {@link ensureRootWebSearchDisabled} did, for the journal to record. */
336
+ export interface RootWebSearchOutcome {
337
+ content: string;
338
+ /** The user-owned root line this pass removed, or null when there was none. */
339
+ replacedUserLine: string | null;
340
+ /** The value this pass wrote, or null when it wrote none. */
341
+ wroteValue: string | null;
342
+ }
343
+
344
+ /** Insert root-level lines ahead of the first table; TOML root keys may not follow one. */
345
+ function insertRootLines(lines: string[], text: string): string {
346
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
347
+ if (firstTable === -1) return `${lines.join("\n").replace(/\n+$/, "")}\n${text}\n`;
348
+ let at = firstTable;
349
+ while (at > 0 && lines[at - 1].trim() === "") at -= 1;
350
+ lines.splice(at, 0, ...text.split("\n"));
351
+ return lines.join("\n");
352
+ }
353
+
354
+ /**
355
+ * Ensure the root `web_search` key follows the web-search sidecar's master switch.
356
+ *
357
+ * Codex reads this key (modes `disabled`/`cached`/`indexed`/`live`) to decide whether its native
358
+ * Responses `web_search` tool is offered at all; `disabled` is the only mode that removes the tool
359
+ * from the model's tool list. An operator who runs an MCP search server instead needs exactly that,
360
+ * because a native tool the client still advertises wins the model's attention away from the MCP
361
+ * one. So the switch is not advisory: while `webSearchSidecar.enabled` is false we own the value.
362
+ *
363
+ * Ownership, both directions:
364
+ * - `disabled` removes EVERY root `web_search` line (ours or the user's) and writes the marker-owned
365
+ * pair. Two root keys of the same name are invalid TOML, so keeping a user line alongside ours
366
+ * would stop Codex from loading the file at all — and the operator has just asked for this exact
367
+ * value. A value the user owned is reported back as `replacedUserLine` so the journal can carry
368
+ * it, and `ocx restore` additionally replays the snapshot, like every other line this injection
369
+ * rewrites.
370
+ * - enabled (or unset) removes only the marker-owned pair, so re-enabling the sidecar cannot leave
371
+ * the native tool switched off — which would silently leave the sidecar with nothing to intercept.
372
+ * Two further sources of ownership come from the journal: the exact value a recorded injection
373
+ * wrote, so a line whose marker comment a Codex app reserialize dropped is still ours (#1798), and
374
+ * the user line that injection removed, which goes back now that nothing owns the key.
375
+ */
376
+ export function ensureRootWebSearchDisabled(
377
+ content: string,
378
+ disabled: boolean,
379
+ journal: RootWebSearchJournal = {},
380
+ ): RootWebSearchOutcome {
381
+ const lines = stripInjectedRootWebSearch(content, journal.injectedValue).split("\n");
382
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
383
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
384
+ if (!disabled) {
385
+ const restore = journal.replacedUserLine?.trim();
386
+ const owned = lines.slice(0, rootEnd).some(isRootWebSearchLine);
387
+ return {
388
+ content: restore && !owned ? insertRootLines(lines, restore) : lines.join("\n"),
389
+ replacedUserLine: null,
390
+ wroteValue: null,
391
+ };
392
+ }
393
+ // Whatever root line is left here is not marker-owned and not the value we recorded writing, so
394
+ // it is the operator's own mode: keep its exact text for the pass that switches the sidecar back
395
+ // on. Only one can be valid TOML, and the first is the one Codex reads. A pass that finds no such
396
+ // line keeps the one an earlier off pass recorded — the ordinary way to reach that state is a
397
+ // second injection while the switch is still off (a model change), and the operator's mode must
398
+ // not evaporate because the line it came from is already gone.
399
+ const replacedUserLine = lines
400
+ .slice(0, rootEnd)
401
+ .find((line) => isRootWebSearchLine(line))
402
+ ?.replace(/\r$/, "") ?? journal.replacedUserLine?.trim() ?? null;
403
+ return {
404
+ content: insertRootLines(
405
+ lines.filter((line, index) => index >= rootEnd || !isRootWebSearchLine(line)),
406
+ `${OCX_ROUTING_MARKER_LINE}\n${ROOT_WEB_SEARCH_DISABLED_LINE}`,
407
+ ),
408
+ replacedUserLine,
409
+ wroteValue: ROOT_WEB_SEARCH_DISABLED_VALUE,
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Remove the marker-owned root `web_search` pair (marker line + the key line right after it).
415
+ * Same ownership rule as `stripInjectedOpenaiBaseUrl`: a user's own line has no marker above it and
416
+ * survives, so a hand-set mode is never reinterpreted as ours after an injection cycle. `injectedValue`
417
+ * adds the #1798 value evidence — the exact value a recorded injection wrote — so a line whose marker
418
+ * comment a Codex app reserialize dropped is still recognized as ours.
419
+ */
420
+ export function stripInjectedRootWebSearch(content: string, injectedValue?: string | null): string {
421
+ const lines = content.split("\n");
422
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
423
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
424
+ const drop = new Set<number>();
425
+ for (let i = 0; i + 1 < rootEnd; i += 1) {
426
+ if (lines[i].includes(OCX_SECTION_MARKER) && isRootWebSearchLine(lines[i + 1])) {
427
+ drop.add(i);
428
+ drop.add(i + 1);
429
+ }
430
+ }
431
+ if (injectedValue) {
432
+ for (let i = 0; i < rootEnd; i += 1) {
433
+ if (!isRootWebSearchLine(lines[i])) continue;
434
+ if (rootTomlString(lines[i], ROOT_WEB_SEARCH_KEY) !== injectedValue) continue;
435
+ drop.add(i);
436
+ if (i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER)) drop.add(i - 1);
437
+ }
438
+ }
439
+ if (drop.size === 0) return content;
440
+ return lines.filter((_, index) => !drop.has(index)).join("\n");
441
+ }
442
+
306
443
  /**
307
444
  * Remove the marker-owned root `openai_base_url` (marker line + the key line right after it).
308
445
  * A user's own root override (no marker) survives; an orphaned marker with no key line after
@@ -17,6 +17,8 @@ import {
17
17
  import {
18
18
  journaledInjectedOpenaiBaseUrl,
19
19
  journaledInjectedRealtimeWsBaseUrl,
20
+ journaledInjectedRootWebSearch,
21
+ journaledReplacedRootWebSearch,
20
22
  } from "../journal";
21
23
  import { stripJournaledOpenaiBaseUrl } from "../injected-marker";
22
24
  import { CODEX_CONFIG_PATH, resolveCodexStateDbPath } from "../paths";
@@ -30,6 +32,7 @@ import {
30
32
  chooseCatalogPathForInjection,
31
33
  dominantEol,
32
34
  ensureFastModeFeature,
35
+ ensureRootWebSearchDisabled,
33
36
  normalizeServiceTier,
34
37
  removeProfileSection,
35
38
  setRootModelCatalogPath,
@@ -74,6 +77,10 @@ export interface CodexInjectionPlanOk {
74
77
  keepRootOverrideAlongsideTable: boolean;
75
78
  keptUserBaseUrl: boolean;
76
79
  keptUserRealtimeWsBaseUrl: boolean;
80
+ /** The root `web_search` value this plan writes, or null when it writes none. */
81
+ injectedRootWebSearch: string | null;
82
+ /** The user-owned root `web_search` line this plan removed, for the journal to carry. */
83
+ replacedRootWebSearch: string | null;
77
84
  nativeSubagentDefaultsWarning: string | undefined;
78
85
  managedDefaultsMessage: string;
79
86
  /**
@@ -169,6 +176,20 @@ export function deriveCodexInjectionPlan(
169
176
  content = stripRootContextWindowOverrides(content);
170
177
  content = normalizeServiceTier(content);
171
178
  content = ensureFastModeFeature(content, config?.fastMode);
179
+ // Codex's own web-search switch follows the sidecar's master switch. While the sidecar is off,
180
+ // the client must not keep offering a native `web_search` tool that an MCP search server is
181
+ // meant to replace. The journal carries both halves of an earlier pass — the value we wrote,
182
+ // because the marker comment does not survive a Codex app reserialize, and the operator line we
183
+ // had to remove, because the sidecar coming back on is what returns it.
184
+ const webSearch = ensureRootWebSearchDisabled(
185
+ content,
186
+ config?.webSearchSidecar?.enabled === false,
187
+ {
188
+ injectedValue: journaledInjectedRootWebSearch({ readOnly: ctx.journalReadOnly }),
189
+ replacedUserLine: journaledReplacedRootWebSearch({ readOnly: ctx.journalReadOnly }),
190
+ },
191
+ );
192
+ content = webSearch.content;
172
193
 
173
194
  const catalogPath = chooseCatalogPathForInjection(
174
195
  content,
@@ -352,6 +373,8 @@ export function deriveCodexInjectionPlan(
352
373
  keepRootOverrideAlongsideTable,
353
374
  keptUserBaseUrl,
354
375
  keptUserRealtimeWsBaseUrl,
376
+ injectedRootWebSearch: webSearch.wroteValue,
377
+ replacedRootWebSearch: webSearch.replacedUserLine,
355
378
  nativeSubagentDefaultsWarning,
356
379
  managedDefaultsMessage,
357
380
  historyRelabelRefusal: observedHistoryRefusal,
@@ -10,12 +10,15 @@ import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "../
10
10
  import {
11
11
  journaledInjectedOpenaiBaseUrl,
12
12
  journaledInjectedRealtimeWsBaseUrl,
13
+ journaledInjectedRootWebSearch,
14
+ journaledReplacedRootWebSearch,
13
15
  } from "../journal";
14
16
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, readRootTomlString } from "../paths";
15
17
  import { transformManagedSubagentDefaults } from "../subagent-defaults";
16
18
  import {
17
19
  applyEol,
18
20
  dominantEol,
21
+ ensureRootWebSearchDisabled,
19
22
  removeProfileSection,
20
23
  stripInjectedOpenaiBaseUrl,
21
24
  stripOpencodexCatalogPath,
@@ -65,6 +68,8 @@ function stripOpencodexConfigResult(
65
68
  content: string,
66
69
  journaledBaseUrl: string | null = null,
67
70
  journaledRealtimeWsBaseUrl: string | null = null,
71
+ journaledRootWebSearch: string | null = null,
72
+ journaledReplacedWebSearch: string | null = null,
68
73
  ): StripOpencodexConfigResult {
69
74
  let out = content;
70
75
  const hadRootOcxProvider =
@@ -75,6 +80,13 @@ function stripOpencodexConfigResult(
75
80
  const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out)
76
81
  || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
77
82
  out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
83
+ // The enabled direction of the web-search transform is also the purge: it drops our pair, drops a
84
+ // marker-less `disabled` the journal proves we wrote, and puts back the operator line we had to
85
+ // remove for as long as the switch was off. Nothing of ours is written here.
86
+ out = ensureRootWebSearchDisabled(out, false, {
87
+ injectedValue: journaledRootWebSearch,
88
+ replacedUserLine: journaledReplacedWebSearch,
89
+ }).content;
78
90
  out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl, journaledRealtimeWsBaseUrl);
79
91
  if (hasOcxProviderTable(out)) {
80
92
  out = removeOcxSection(out);
@@ -174,11 +186,19 @@ export function removeCodexConfig(
174
186
  // ownership verdict, which must agree with what was actually removed.
175
187
  const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
176
188
  const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl();
189
+ const journaledRootWebSearch = journaledInjectedRootWebSearch();
190
+ const journaledReplaced = journaledReplacedRootWebSearch();
177
191
  const had = hasOpencodexRouting(content)
178
192
  || (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl)
179
193
  || (journaledRealtimeWsBaseUrl !== null
180
194
  && rootTomlString(content, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
181
- const stripped = stripOpencodexConfigResult(content, journaledBaseUrl, journaledRealtimeWsBaseUrl);
195
+ const stripped = stripOpencodexConfigResult(
196
+ content,
197
+ journaledBaseUrl,
198
+ journaledRealtimeWsBaseUrl,
199
+ journaledRootWebSearch,
200
+ journaledReplaced,
201
+ );
182
202
  // Captured from the pre-strip bytes: the strip is what removes the table, so reading it
183
203
  // afterwards would find nothing.
184
204
  const retainedBlock = historyDisposition === "stand-down-retain"
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import {
3
4
  atomicWriteFile,
4
5
  loadConfig,
@@ -211,14 +212,15 @@ async function injectCodexConfigImpl(
211
212
  } catch (error) {
212
213
  return { success: false, message: error instanceof Error ? error.message : "Invalid Codex routing target" };
213
214
  }
214
- if (!existsSync(CODEX_CONFIG_PATH)) {
215
- return {
216
- success: false,
217
- message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?`,
218
- };
219
- }
215
+ const missingConfig = !existsSync(CODEX_CONFIG_PATH)
216
+ ? missingCodexConfigAdmission()
217
+ : null;
218
+ if (missingConfig && !missingConfig.ok) return { success: false, message: missingConfig.message };
220
219
 
221
- const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
220
+ // An absent config.toml in an existing home is planned as an empty file. The file itself
221
+ // is created only inside the write boundary, after the pre-images are captured, so any
222
+ // later refusal or failure rolls it back to absent (issue 5422).
223
+ const rawContent = missingConfig ? "" : readFileSync(CODEX_CONFIG_PATH, "utf-8");
222
224
  const activeProvider = externalCodexModelProvider(rawContent);
223
225
  if (activeProvider) {
224
226
  // A launcher may have journaled before the provider manager took ownership. Never let shutdown
@@ -402,6 +404,7 @@ async function injectCodexConfigImpl(
402
404
  * flip included — before the result is reported.
403
405
  */
404
406
  const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => {
407
+ if (missingConfig) createEmptyCodexConfigInBoundary();
405
408
  let nativeInput = rawContent;
406
409
  let plan = admittedPlan;
407
410
  if (v1Reconcile) {
@@ -465,6 +468,12 @@ async function injectCodexConfigImpl(
465
468
  injectedRealtimeWsBaseUrl: plan.providerTableMode || plan.keptUserBaseUrl || plan.keptUserRealtimeWsBaseUrl
466
469
  ? null
467
470
  : rootTomlString(plan.content, REALTIME_WS_BASE_URL_KEY),
471
+ // The web-search pair follows the sidecar's master switch, and it is the one root value we
472
+ // REPLACE rather than only add: the operator's own mode has to leave the file while the
473
+ // switch is off. Both halves are recorded here — the value we wrote (the marker comment is
474
+ // not durable) and the line we removed (so re-enabling the sidecar can return it).
475
+ injectedRootWebSearch: plan.injectedRootWebSearch,
476
+ replacedRootWebSearch: plan.replacedRootWebSearch,
468
477
  // This is the catalog artifact selected for this injection, even when config.toml
469
478
  // already points at that path and therefore needs no textual rewrite.
470
479
  injectedCatalogPath: plan.catalogPath,
@@ -860,3 +869,49 @@ export {
860
869
  setBeforeRestoreConfigForTests,
861
870
  skippedRestoreEnvelope,
862
871
  } from "./inject/restore";
872
+
873
+ type MissingCodexConfig = { ok: true } | { ok: false; message: string };
874
+
875
+ /**
876
+ * A fresh Codex install can have its home directory but no config.toml yet: Codex writes
877
+ * that file lazily, and a user who never signed in to OpenAI (authless Desktop with a
878
+ * third-party provider, issue 5422) may never get one. A missing optional file is not
879
+ * evidence that Codex is absent, so injection plans against an empty config.toml and
880
+ * creates it inside the write boundary. A missing home DIRECTORY is different: that is
881
+ * either an uninitialized install or the wrong home, and guessing would write provider
882
+ * state where Codex is not looking.
883
+ */
884
+ function missingCodexConfigAdmission(): MissingCodexConfig {
885
+ const home = dirname(CODEX_CONFIG_PATH);
886
+ let homeIsDirectory = false;
887
+ try {
888
+ homeIsDirectory = statSync(home).isDirectory();
889
+ } catch {
890
+ homeIsDirectory = false;
891
+ }
892
+ if (homeIsDirectory) return { ok: true };
893
+ return {
894
+ ok: false,
895
+ message: `Codex home ${home} does not exist yet, so there is no config.toml to route. Start Codex once so it creates its home, then rerun 'ocx sync'. If Codex uses a different home, set CODEX_HOME to it.`,
896
+ };
897
+ }
898
+
899
+ /**
900
+ * Create the planned empty config.toml under the write boundary. It runs after the
901
+ * pre-images were captured (config absent), so compensation removes it again. The create is
902
+ * exclusive: a file that appeared since admission belongs to another writer, and this plan,
903
+ * derived from an absent file, must not replace it.
904
+ */
905
+ function createEmptyCodexConfigInBoundary(): void {
906
+ try {
907
+ closeSync(openSync(CODEX_CONFIG_PATH, "wx", 0o600));
908
+ } catch (error) {
909
+ const appeared = (error as NodeJS.ErrnoException | null)?.code === "EEXIST";
910
+ throw new CodexInjectRefusal({
911
+ success: false,
912
+ message: appeared
913
+ ? `Codex config ${CODEX_CONFIG_PATH} appeared while injection was planned against its absence; nothing was changed. Rerun 'ocx sync'.`
914
+ : `Codex config not found at ${CODEX_CONFIG_PATH}, and creating it failed: ${error instanceof Error ? error.message : String(error)}`,
915
+ });
916
+ }
917
+ }
@@ -42,6 +42,26 @@ interface Journal {
42
42
  * that as ours. Null when the key was preserved or not injected.
43
43
  */
44
44
  injectedRealtimeWsBaseUrl?: string | null;
45
+ /**
46
+ * The root `web_search` value this injection wrote, when it wrote one.
47
+ *
48
+ * Same reasoning as {@link injectedOpenaiBaseUrl}, for the one other root key whose value is a
49
+ * mode Codex reads: the marker comment above `web_search = "disabled"` does not survive a Codex
50
+ * app reserialize, and without value evidence the next injection cannot tell our line from the
51
+ * operator's. The failure that buys is quiet: a re-enabled sidecar whose client still has the
52
+ * native tool switched off has nothing to intercept, which is the empty state the switch exists
53
+ * to avoid.
54
+ */
55
+ injectedRootWebSearch?: string | null;
56
+ /**
57
+ * The user-owned root `web_search` line this injection REMOVED while the sidecar was off.
58
+ *
59
+ * Two root keys of the same name are invalid TOML, so the operator's mode has to leave the file
60
+ * for as long as the switch is off. Recording the exact line is what puts it back when the
61
+ * sidecar is switched on again — including for a line the snapshot predates, which is the case
62
+ * `ocx restore` alone cannot cover.
63
+ */
64
+ replacedRootWebSearch?: string | null;
45
65
  /**
46
66
  * The catalog path this injection actually wrote to.
47
67
  *
@@ -156,6 +176,10 @@ export interface InjectedJournalOwnership {
156
176
  injectedOpenaiBaseUrl: string | null;
157
177
  injectedRealtimeWsBaseUrl: string | null;
158
178
  injectedCatalogPath: string | null;
179
+ /** Omitted by callers that inject no `web_search` line, which is the value it then records. */
180
+ injectedRootWebSearch?: string | null;
181
+ /** Omitted by callers that removed no user `web_search` line. */
182
+ replacedRootWebSearch?: string | null;
159
183
  }
160
184
 
161
185
  export function markJournalInjectedState(
@@ -177,6 +201,8 @@ export function markJournalInjectedState(
177
201
  // would mistake a preserved user override for injected routing.
178
202
  journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
179
203
  journal.injectedRealtimeWsBaseUrl = ownership.injectedRealtimeWsBaseUrl;
204
+ journal.injectedRootWebSearch = ownership.injectedRootWebSearch ?? null;
205
+ journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null;
180
206
  journal.injectedCatalogPath = ownership.injectedCatalogPath;
181
207
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
182
208
  }
@@ -198,6 +224,16 @@ export function journaledInjectedRealtimeWsBaseUrl(options: { readOnly?: boolean
198
224
  return readJournal(options.readOnly !== true)?.injectedRealtimeWsBaseUrl ?? null;
199
225
  }
200
226
 
227
+ /** The root `web_search` value the last injection wrote, or null when it wrote none. */
228
+ export function journaledInjectedRootWebSearch(options: { readOnly?: boolean } = {}): string | null {
229
+ return readJournal(options.readOnly !== true)?.injectedRootWebSearch ?? null;
230
+ }
231
+
232
+ /** The user-owned root `web_search` line the last injection removed, or null when there was none. */
233
+ export function journaledReplacedRootWebSearch(options: { readOnly?: boolean } = {}): string | null {
234
+ return readJournal(options.readOnly !== true)?.replacedRootWebSearch ?? null;
235
+ }
236
+
201
237
  /** The catalog path the last injection wrote to, or null when none was recorded. */
202
238
  export function journaledInjectedCatalogPath(): string | null {
203
239
  return readJournal()?.injectedCatalogPath ?? null;
@@ -13,6 +13,19 @@ export interface MainAccountHardLockStatus {
13
13
 
14
14
  type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock">;
15
15
 
16
+ /**
17
+ * Whether the main-account hard lock applies to this config (#5694).
18
+ *
19
+ * Absent key or `true` means on; only the persisted `false` opt-out turns it off. The
20
+ * `undefined` branch is the trap this resolver cannot close on its own: no config object is
21
+ * "the caller supplied no policy", not "the operator opted out", so a call site holding an
22
+ * optional config must test for one before asking. Sites that hold a config always (a loaded
23
+ * config, a resolved policy) call this directly.
24
+ */
25
+ export function isMainAccountHardLockEnabled(config: PolicyConfig | undefined): boolean {
26
+ return config?.codexMainAccountHardLock !== false;
27
+ }
28
+
16
29
  function resetTimestamp(value: number | undefined): number | undefined {
17
30
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
18
31
  return value < 10_000_000_000 ? value * 1000 : value;
@@ -23,7 +36,7 @@ export function getMainAccountHardLockStatus(
23
36
  config: PolicyConfig,
24
37
  now = Date.now(),
25
38
  ): MainAccountHardLockStatus {
26
- if (config.codexMainAccountHardLock !== true) return { enabled: false, state: "off" };
39
+ if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off" };
27
40
  const quota = getMainPolicyQuota();
28
41
  if (!quota) return { enabled: true, state: "unknown" };
29
42
  // Account window priority is deliberate: a 5h account uses that window, even if
@@ -0,0 +1,109 @@
1
+ import {
2
+ isMainAccountPolicyBindingPending,
3
+ waitForNativeMainStartupGate,
4
+ } from "./native-profile-startup";
5
+
6
+ /**
7
+ * How long a request waits for an owned startup to finish binding the main-account policy
8
+ * before it is refused as draining.
9
+ *
10
+ * `codexMainAccountHardLock` is on by default (#5694), so the admission fence beside this
11
+ * constant is no longer an opt-in rarity: every request that arrives while a process-owned
12
+ * startup is still recovering, sweeping stages, and binding the pinned home lands on it. That
13
+ * window is milliseconds on a warm Linux host and seconds on Windows, which is why the fence
14
+ * waits for the gate instead of failing the request the moment it arrives.
15
+ *
16
+ * 15 s is deliberately just above one startup claim wait: both exclusive claims
17
+ * (`withNativeMainExclusiveClaim` in `convergeOwnedStartup`) allow 10 s before they give up, so a
18
+ * request that arrived during the last claim is not refused in the instant before the gate would
19
+ * have opened. Anything longer stops being a courtesy to the client and starts being a hung
20
+ * request, and a gate still blocked after this leaves a real answer -- retained recovery or a
21
+ * manual-recovery requirement -- where waiting cannot help, which is what the draining error says.
22
+ */
23
+ export const MAIN_ACCOUNT_POLICY_BINDING_WAIT_MS = 15_000;
24
+
25
+ /**
26
+ * Repoll interval for the one window where the settle promise is already resolved and the gate
27
+ * still reads pending: a manual-recovery fence publishes a resolved promise while an in-flight
28
+ * convergence is still marked pending, and re-awaiting that same resolved promise would spin the
29
+ * microtask queue -- which never lets the deadline timer fire.
30
+ */
31
+ const MAIN_ACCOUNT_POLICY_BINDING_REPOLL_MS = 25;
32
+
33
+ export interface MainAccountPolicyBindingWait {
34
+ /** Ends the wait with the signal's reason, as an aborted request must. */
35
+ signal?: AbortSignal;
36
+ /** Overrides {@link MAIN_ACCOUNT_POLICY_BINDING_WAIT_MS} for a bounded focused test. */
37
+ timeoutMs?: number;
38
+ }
39
+
40
+ /**
41
+ * Wait, bounded, for an owned startup's main-account policy binding to settle.
42
+ *
43
+ * Returns `true` once the binding is no longer pending, `false` when the deadline passed with it
44
+ * still in flight. Callers fail closed on `false`; the gate itself is re-read every iteration
45
+ * because a rearm replaces the settle promise rather than resolving the one already held.
46
+ */
47
+ export async function waitForMainAccountPolicyBinding(
48
+ wait: MainAccountPolicyBindingWait = {},
49
+ ): Promise<boolean> {
50
+ const signal = wait.signal;
51
+ const timeoutMs = Math.max(0, wait.timeoutMs ?? MAIN_ACCOUNT_POLICY_BINDING_WAIT_MS);
52
+ const deadline = Date.now() + timeoutMs;
53
+ for (;;) {
54
+ if (!isMainAccountPolicyBindingPending()) return true;
55
+ const remaining = deadline - Date.now();
56
+ if (remaining <= 0) return false;
57
+ const settledNow = await raceSettle(waitForNativeMainStartupGate(), remaining, signal);
58
+ // Settling does not always clear the flag: the gate may have been rearmed, or the resolved
59
+ // promise above may belong to a fence that never released the entry. Yield to the event loop
60
+ // before asking again so this stays a poll rather than a busy-wait.
61
+ if (settledNow && isMainAccountPolicyBindingPending()) {
62
+ await pause(Math.min(remaining, MAIN_ACCOUNT_POLICY_BINDING_REPOLL_MS), signal);
63
+ }
64
+ }
65
+ }
66
+
67
+ /** `true` when the settle promise resolved first, `false` on the deadline. */
68
+ async function raceSettle(
69
+ settle: Promise<unknown>,
70
+ timeoutMs: number,
71
+ signal?: AbortSignal,
72
+ ): Promise<boolean> {
73
+ if (signal?.aborted) throw signal.reason;
74
+ let timer: ReturnType<typeof setTimeout> | undefined;
75
+ let onAbort: (() => void) | undefined;
76
+ const deadline = new Promise<boolean>(resolve => {
77
+ timer = setTimeout(() => resolve(false), timeoutMs);
78
+ // A request waiting on a startup gate must never be the reason the process stays alive.
79
+ timer.unref?.();
80
+ });
81
+ const aborted = new Promise<never>((_resolve, reject) => {
82
+ if (!signal) return;
83
+ onAbort = () => reject(signal.reason);
84
+ signal.addEventListener("abort", onAbort);
85
+ });
86
+ try {
87
+ // A rejected settle is not an error here: the loop re-reads the gate and decides.
88
+ return await Promise.race([settle.then(() => true, () => true), deadline, aborted]);
89
+ } finally {
90
+ if (timer !== undefined) clearTimeout(timer);
91
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
92
+ }
93
+ }
94
+
95
+ /** One short, abortable, unref'd yield so the loop cannot monopolize the microtask queue. */
96
+ function pause(ms: number, signal?: AbortSignal): Promise<void> {
97
+ if (signal?.aborted) return Promise.reject(signal.reason);
98
+ return new Promise<void>((resolve, reject) => {
99
+ const finish = (settle: () => void) => {
100
+ clearTimeout(timer);
101
+ signal?.removeEventListener("abort", onAbort);
102
+ settle();
103
+ };
104
+ const timer = setTimeout(() => finish(resolve), ms);
105
+ timer.unref?.();
106
+ const onAbort = () => finish(() => reject(signal?.reason));
107
+ signal?.addEventListener("abort", onAbort);
108
+ });
109
+ }
@@ -1,6 +1,7 @@
1
1
  import { NativeProfileManager } from "./native-profile-manager";
2
2
  import { loadConfig } from "../config";
3
3
  import { initializeMainAccountPolicyBinding } from "./account-lifecycle";
4
+ import { isMainAccountHardLockEnabled } from "./main-account-hard-lock";
4
5
  import { clearAccountNeedsReauth } from "./account-runtime-state";
5
6
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
6
7
  import {
@@ -188,7 +189,7 @@ function scheduleStageSweep(entry: StartupEntry): void {
188
189
  || entry.epoch !== sweepEpoch || entry.policyBindingPending) return;
189
190
  if (!safe) snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" };
190
191
  else if (snapshot.homeId === entry.homeId && snapshot.status === "blocked" && snapshot.reason === "stage-cleanup-required") {
191
- if (loadConfig().codexMainAccountHardLock === true) rearmOwnedMainPolicyBinding(entry);
192
+ if (isMainAccountHardLockEnabled(loadConfig())) rearmOwnedMainPolicyBinding(entry);
192
193
  else snapshot = ready(entry.homeId);
193
194
  }
194
195
  })().finally(() => {
@@ -200,7 +201,9 @@ function scheduleStageSweep(entry: StartupEntry): void {
200
201
  }
201
202
 
202
203
  function convergeOwnedStartup(entry: StartupEntry): void {
203
- if (entry.recoveryStarted) return;
204
+ // The map entry is the gate's owner of record. A released one has been deleted, and every
205
+ // write below belongs to a generation nothing is waiting for any more.
206
+ if (entry.recoveryStarted || startupEntries.get(entry.homeId) !== entry) return;
204
207
  entry.recoveryStarted = true;
205
208
  const currentEpoch = entry.epoch;
206
209
  snapshot = { status: "blocked", homeId: entry.homeId, reason: "recovery-pending" };
@@ -225,7 +228,7 @@ function convergeOwnedStartup(entry: StartupEntry): void {
225
228
  ));
226
229
  const stageSweepSafe = recoveryState === "none" ? await runOwnedStageSweep(entry) : false;
227
230
  if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none" && stageSweepSafe) {
228
- if (loadConfig().codexMainAccountHardLock === true) {
231
+ if (isMainAccountHardLockEnabled(loadConfig())) {
229
232
  await withNativeMainOwnerOperation(entry.manager.context, () => withNativeMainExclusiveClaim(
230
233
  entry.manager.context,
231
234
  async () => {
@@ -236,7 +239,7 @@ function convergeOwnedStartup(entry: StartupEntry): void {
236
239
  }
237
240
  // The HMAC is deliberately not persisted. Bind only the pinned owned home,
238
241
  // after recovery/cleanup, and before caller-owned admission can observe ready.
239
- if (loadConfig().codexMainAccountHardLock === true) {
242
+ if (isMainAccountHardLockEnabled(loadConfig())) {
240
243
  initializeMainAccountPolicyBinding(entry.manager.context.authPath);
241
244
  }
242
245
  clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
@@ -386,7 +389,7 @@ export function startNativeMainStartupLifecycle(
386
389
  entry.unsubscribe = owner.subscribe(ownerState => observeOwner(entry!, ownerState));
387
390
  } else if (!entry.policyBindingPending
388
391
  && snapshot.status === "ready" && snapshot.homeId === homeId
389
- && loadConfig().codexMainAccountHardLock === true) {
392
+ && isMainAccountHardLockEnabled(loadConfig())) {
390
393
  // A new same-process listener can enable protection or follow a credential replacement.
391
394
  // Re-read its pinned home through the held owner before admitting caller-owned main.
392
395
  rearmOwnedMainPolicyBinding(entry);
@@ -404,6 +407,21 @@ export function startNativeMainStartupLifecycle(
404
407
  entry!.sweepTimer = undefined;
405
408
  entry!.unsubscribe();
406
409
  startupEntries.delete(homeId);
410
+ // A released owner cannot leave the process fenced. Convergence runs in the background, and
411
+ // its only guard is this entry, so a server that stops mid-convergence used to keep the
412
+ // "recovery-pending" snapshot the entry armed: every later native request answered 503 until
413
+ // the process exited, because a server whose config does not sync Codex installs a no-op
414
+ // lifecycle that never touches the gate.
415
+ //
416
+ // The gate state belonged only to this entry, so reset it to the process-initial state here,
417
+ // synchronously and before the first await: a NEW entry created for the same home afterwards
418
+ // re-arms its own gate and cannot be clobbered by this release. The epoch bump retires any
419
+ // in-flight `initializeNativeMainStartupGate`/convergence write from the released generation.
420
+ if (snapshot.homeId === homeId && !startupEntries.has(homeId)) {
421
+ epoch += 1;
422
+ snapshot = ready(null);
423
+ settled = Promise.resolve(snapshot);
424
+ }
407
425
  entry!.resolveAcquisition?.(snapshot);
408
426
  entry!.resolveAcquisition = undefined;
409
427
  // Startup convergence can transition from the exclusive recovery claim
@@ -705,7 +723,7 @@ export function blockNativeMainRecovery(
705
723
  export function completeNativeMainRecovery(homeId: string): boolean {
706
724
  if (snapshot.status !== "blocked" || snapshot.homeId !== homeId) return false;
707
725
  const entry = startupEntries.get(homeId);
708
- if (entry && loadConfig().codexMainAccountHardLock === true) return rearmOwnedMainPolicyBinding(entry);
726
+ if (entry && isMainAccountHardLockEnabled(loadConfig())) return rearmOwnedMainPolicyBinding(entry);
709
727
  epoch += 1;
710
728
  clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
711
729
  snapshot = ready(homeId);