@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -116,11 +116,20 @@ export interface DesktopAppAdapter {
116
116
  *
117
117
  * `root` is expected to be `realpath`-resolved by discovery already.
118
118
  */
119
+ function isMembershipSeparator(character: string): boolean {
120
+ // `/` separates on every platform this runs on, and Windows accepts it wherever it
121
+ // accepts `\`. `\` is only a separator where the host says so: it is a legal
122
+ // FILENAME character on POSIX, so admitting it there would reopen the sibling hole
123
+ // this function exists to close.
124
+ return character === "/" || (sep === "\\" && character === "\\");
125
+ }
126
+
119
127
  export function isUnderRoot(executable: string, root: string): boolean {
120
128
  if (!executable || !root) return false;
121
129
  if (executable === root) return true;
122
- const prefix = root.endsWith(sep) ? root : root + sep;
123
- return executable.startsWith(prefix);
130
+ if (!executable.startsWith(root)) return false;
131
+ if (isMembershipSeparator(root[root.length - 1]!)) return true;
132
+ return isMembershipSeparator(executable[root.length] ?? "");
124
133
  }
125
134
 
126
135
  /**
@@ -29,16 +29,16 @@ const SHELL_BASENAME = "chatgpt.exe";
29
29
  const POWERSHELL_PROBE_OPTIONS = { timeout: PROBE_TIMEOUT_MS, windowsHide: true } as const;
30
30
 
31
31
  /**
32
- * isUnderRoot prefixes with the host path.sep and is case-sensitive. Windows
32
+ * isUnderRoot checks a lexical path boundary and is case-sensitive. Windows
33
33
  * membership is case-insensitive, and this file is executed by Unix CI against
34
- * backslash paths, so both sides are folded onto the host separator first.
34
+ * mixed slash paths, so both slash forms are folded onto the host separator first.
35
35
  * The boundary itself — sibling `OpenAI.Codex-evil` must not match root
36
36
  * `OpenAI.Codex` — is still isUnderRoot's, which is why the PowerShell
37
37
  * StartsWith is only a cheap pre-filter.
38
38
  */
39
39
  function toHostMembershipPath(windowsPath: string): string {
40
40
  const lowered = windowsPath.toLowerCase();
41
- return sep === "\\" ? lowered : lowered.replaceAll("\\", "/");
41
+ return lowered.replace(/[\\/]/g, sep);
42
42
  }
43
43
 
44
44
  function isMemberExecutable(executable: string, root: string): boolean {
@@ -91,10 +91,10 @@ function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): De
91
91
  const literal = install.root.replace(/'/g, "''");
92
92
  const script = [
93
93
  "$ErrorActionPreference='SilentlyContinue'",
94
- `$root = '${literal}'`,
94
+ `$root = '${literal}'.Replace('/', '\\')`,
95
95
  "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
96
96
  "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
97
- " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
97
+ " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.Replace('/', '\\').StartsWith($root, 'OrdinalIgnoreCase') } |",
98
98
  " ForEach-Object {",
99
99
  " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
100
100
  " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",
@@ -0,0 +1,145 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { shouldSyncCodexOnStart } from "./desired-state";
3
+ import {
4
+ isEffectiveCodexClientCompaction,
5
+ isEffectiveCodexDesktopAuthless,
6
+ } from "./loopback-target";
7
+
8
+ export type CodexDesktopSwitchInertReason =
9
+ | "client_role"
10
+ | "non_loopback_bind_requires_admission_token";
11
+
12
+ export interface CodexDesktopSwitchState {
13
+ stored: boolean;
14
+ effective: boolean;
15
+ inertReason?: CodexDesktopSwitchInertReason;
16
+ }
17
+
18
+ export type CodexDesktopSwitchApplyReason =
19
+ | "not_requested"
20
+ | "proxy_not_running"
21
+ | "integration_disabled"
22
+ | "write_lock_busy"
23
+ | "injection_refused";
24
+
25
+ export type CodexDesktopSwitchApply =
26
+ | { applied: true }
27
+ | {
28
+ applied: false;
29
+ reason: CodexDesktopSwitchApplyReason;
30
+ retryable: boolean;
31
+ detail?: string;
32
+ };
33
+
34
+ export interface CodexDesktopSwitchReport {
35
+ codexDesktopAuthless: CodexDesktopSwitchState;
36
+ codexClientCompaction: CodexDesktopSwitchState;
37
+ apply: CodexDesktopSwitchApply;
38
+ authSource: { presentsCodexAccount: boolean; summary: string };
39
+ }
40
+
41
+ type DesktopSwitchConfig = Pick<
42
+ OcxConfig,
43
+ | "clientIntegrations"
44
+ | "runtimeRole"
45
+ | "hostname"
46
+ | "unauthenticatedLoopbackListener"
47
+ | "codexDesktopAuthless"
48
+ | "codexClientCompaction"
49
+ >;
50
+
51
+ function describeSwitch(
52
+ stored: boolean,
53
+ effective: boolean,
54
+ config: Pick<OcxConfig, "runtimeRole">,
55
+ ): CodexDesktopSwitchState {
56
+ if (!stored || effective) return { stored, effective };
57
+ return {
58
+ stored,
59
+ effective,
60
+ inertReason: config.runtimeRole === "client"
61
+ ? "client_role"
62
+ : "non_loopback_bind_requires_admission_token",
63
+ };
64
+ }
65
+
66
+ export function describeCodexDesktopSwitches(
67
+ config: DesktopSwitchConfig,
68
+ apply: CodexDesktopSwitchApply,
69
+ ): CodexDesktopSwitchReport {
70
+ const authlessStored = config.codexDesktopAuthless === true;
71
+ const authlessEffective = isEffectiveCodexDesktopAuthless(config);
72
+ const compactionStored = config.codexClientCompaction === true;
73
+ const compactionEffective = isEffectiveCodexClientCompaction(config);
74
+
75
+ return {
76
+ codexDesktopAuthless: describeSwitch(authlessStored, authlessEffective, config),
77
+ codexClientCompaction: describeSwitch(compactionStored, compactionEffective, config),
78
+ apply,
79
+ authSource: authlessEffective
80
+ ? {
81
+ presentsCodexAccount: false,
82
+ summary: "The Codex app will not require its own account sign-in.",
83
+ }
84
+ : {
85
+ presentsCodexAccount: true,
86
+ summary: "The Codex app will require its own account sign-in.",
87
+ },
88
+ };
89
+ }
90
+
91
+ export async function applyCodexDesktopSwitches(
92
+ config: OcxConfig,
93
+ ): Promise<CodexDesktopSwitchApply> {
94
+ if (!shouldSyncCodexOnStart(config)) {
95
+ return { applied: false, reason: "integration_disabled", retryable: false };
96
+ }
97
+
98
+ const { readRuntimePort } = await import("../config/process-state");
99
+ const runtime = readRuntimePort(process.pid);
100
+ if (!runtime) {
101
+ return { applied: false, reason: "proxy_not_running", retryable: true };
102
+ }
103
+
104
+ try {
105
+ // Imported at call time, not module load. The settings route reaches this module on
106
+ // every GET, and pulling the whole injection graph in just to report stored-versus-
107
+ // effective state would put it on a read path that never writes anything.
108
+ const { injectCodexConfig } = await import("./inject");
109
+ const result = await injectCodexConfig(runtime.port, config);
110
+ if (result.status === "skipped") {
111
+ return {
112
+ applied: false,
113
+ reason: "integration_disabled",
114
+ retryable: false,
115
+ detail: result.message,
116
+ };
117
+ }
118
+ if (result.success) {
119
+ // history_paginated_requires_native_writer stands down only the legacy relabel;
120
+ // apply still writes the routing and catalog half for paginated Codex homes.
121
+ return { applied: true };
122
+ }
123
+ if (result.retryable === true) {
124
+ return {
125
+ applied: false,
126
+ reason: "write_lock_busy",
127
+ retryable: true,
128
+ detail: result.message,
129
+ };
130
+ }
131
+ return {
132
+ applied: false,
133
+ reason: "injection_refused",
134
+ retryable: false,
135
+ detail: result.message,
136
+ };
137
+ } catch (error) {
138
+ return {
139
+ applied: false,
140
+ reason: "injection_refused",
141
+ retryable: false,
142
+ detail: error instanceof Error ? error.message : "Codex config injection failed.",
143
+ };
144
+ }
145
+ }
@@ -23,7 +23,7 @@ import type {
23
23
  CodexHistoryWorkerOperation,
24
24
  HistoryWorkerResult,
25
25
  } from "./history-worker";
26
- import { historyBackupPathFor } from "./history-provider";
26
+ import { currentHistoryDbBusyTimeoutMs, historyBackupPathFor } from "./history-provider";
27
27
  import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
28
28
  import { getCodexHome, resolveCodexStateDbPath } from "./paths";
29
29
 
@@ -437,6 +437,10 @@ export async function runCodexHistoryJob(
437
437
  canonicalStateDbPath: request.canonicalStateDbPath,
438
438
  canonicalBackupPath: request.canonicalBackupPath,
439
439
  ...(request.expectedDesiredEnabled === undefined ? {} : { expectedDesiredEnabled: request.expectedDesiredEnabled }),
440
+ // A Worker is a fresh module realm: it would otherwise open state_5.sqlite with this
441
+ // module's default rather than the timeout this process resolved. Production sends the
442
+ // same codex-rs-matching 5s the Worker would have used on its own.
443
+ busyTimeoutMs: currentHistoryDbBusyTimeoutMs(),
440
444
  env: {
441
445
  ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}),
442
446
  ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
@@ -76,6 +76,22 @@ export function setHistoryDbBusyTimeoutForTests(ms: number): void {
76
76
  historyDbBusyTimeoutMs = ms;
77
77
  }
78
78
 
79
+ /**
80
+ * Carry that timeout across a realm boundary. A Worker starts from the default above and cannot
81
+ * observe a parent that shortened the window — the same reason its run message carries the homes
82
+ * explicitly — so `history-job.ts` sends this value and `history-worker.ts` adopts it. In
83
+ * production both sides already hold the codex-rs-matching 5s. A non-finite or negative value is
84
+ * refused rather than allowed to disable the wait the app expects.
85
+ */
86
+ export function currentHistoryDbBusyTimeoutMs(): number {
87
+ return historyDbBusyTimeoutMs;
88
+ }
89
+
90
+ export function adoptHistoryDbBusyTimeout(ms: number): void {
91
+ if (!Number.isFinite(ms) || ms < 0) return;
92
+ historyDbBusyTimeoutMs = Math.floor(ms);
93
+ }
94
+
79
95
  function openStateDb(stateDbPath: string): Database {
80
96
  const db = new Database(stateDbPath);
81
97
  try {
@@ -311,6 +327,19 @@ class CodexHistoryIntegrityError extends Error {
311
327
  * O_APPEND does not allocate an ordinal or update that writer's in-memory cursor.
312
328
  * Refuse before changing the DB, manifest, or first-line provider; never guess N+1.
313
329
  */
330
+ /**
331
+ * The one refusal reason that means "the native writer owns this history", as opposed
332
+ * to "something is wrong". It is a stand-down for the relabel unit on apply
333
+ * (`src/codex/inject.ts`) and for the history half of a restore; every other reason is
334
+ * a hard refusal in both directions.
335
+ *
336
+ * Exported as a constant rather than repeated as a literal because the apply and restore
337
+ * directions have to agree on it exactly. They drifted once already: apply learned to
338
+ * stand down while restore kept refusing, which is how #4812's uninstall deadlock
339
+ * survived the fix that was supposed to end it.
340
+ */
341
+ export const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer";
342
+
314
343
  function assertLegacyHistoryRecord(line: string): void {
315
344
  let value: unknown;
316
345
  try { value = JSON.parse(line); } catch { throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); }
@@ -320,7 +349,7 @@ function assertLegacyHistoryRecord(line: string): void {
320
349
  const record = value as Record<string, unknown>;
321
350
  const payload = record.payload;
322
351
  if (Object.hasOwn(record, "ordinal") || (payload !== null && typeof payload === "object" && (payload as Record<string, unknown>).history_mode === "paginated")) {
323
- throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer");
352
+ throw new CodexHistoryIntegrityError(HISTORY_RELABEL_STANDS_DOWN);
324
353
  }
325
354
  }
326
355
 
@@ -381,7 +410,7 @@ function assertLegacyHistoryWritable(path: string, heldFd?: number): void {
381
410
  function assertLegacyHistoryStore(db: Database): void {
382
411
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all();
383
412
  if (columns.some(column => column.name === "history_mode")) {
384
- throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer");
413
+ throw new CodexHistoryIntegrityError(HISTORY_RELABEL_STANDS_DOWN);
385
414
  }
386
415
  }
387
416
 
@@ -407,7 +436,7 @@ export function preflightCodexHistoryInjection(
407
436
  db = new Database(resolvedPath, { readonly: true });
408
437
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all();
409
438
  const paginatedColumn = columns.some(column => column.name === "history_mode");
410
- if (paginatedColumn && restoreEntries.length > 0) return "history_paginated_requires_native_writer";
439
+ if (paginatedColumn && restoreEntries.length > 0) return HISTORY_RELABEL_STANDS_DOWN;
411
440
  for (const entry of restoreEntries) assertLegacyHistoryWritable(entry.rolloutPath);
412
441
  const rows = db.query<{ rollout_path: string; history_mode: string | null }, []>(`
413
442
  SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}
@@ -417,7 +446,7 @@ export function preflightCodexHistoryInjection(
417
446
  : "model_provider = 'opencodex'"}
418
447
  `).all();
419
448
  for (const row of rows) {
420
- if (paginatedColumn || row.history_mode === "paginated") return "history_paginated_requires_native_writer";
449
+ if (paginatedColumn || row.history_mode === "paginated") return HISTORY_RELABEL_STANDS_DOWN;
421
450
  assertLegacyHistoryWritable(row.rollout_path);
422
451
  }
423
452
  return null;
@@ -33,6 +33,7 @@ import {
33
33
  } from "./internal/history-writer";
34
34
  import {
35
35
  snapshotCodexHistoryNoop,
36
+ adoptHistoryDbBusyTimeout,
36
37
  type CodexHistoryFailureReason,
37
38
  type CodexHistoryVerifiedNoopProof,
38
39
  } from "./history-provider";
@@ -62,6 +63,11 @@ export interface HistoryWorkerRunMessage {
62
63
  readonly canonicalBackupPath: string;
63
64
  /** When set, prove this transition's desired direction while H is held. */
64
65
  readonly expectedDesiredEnabled?: boolean;
66
+ /**
67
+ * The parent realm's `state_5.sqlite` busy timeout. A Worker cannot observe a parent that
68
+ * resolved a different window, for the same reason the homes below are explicit.
69
+ */
70
+ readonly busyTimeoutMs?: number;
65
71
  /** Env snapshot: a Worker may not observe parent mutations on every platform. */
66
72
  readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string };
67
73
  }
@@ -107,7 +113,11 @@ export function isHistoryWorkerRunMessage(data: unknown): data is HistoryWorkerR
107
113
  && nonEmpty(message.canonicalCodexHome)
108
114
  && nonEmpty(message.canonicalStateDbPath)
109
115
  && nonEmpty(message.canonicalBackupPath)
110
- && (message.expectedDesiredEnabled === undefined || typeof message.expectedDesiredEnabled === "boolean");
116
+ && (message.expectedDesiredEnabled === undefined || typeof message.expectedDesiredEnabled === "boolean")
117
+ && (message.busyTimeoutMs === undefined
118
+ || (typeof message.busyTimeoutMs === "number"
119
+ && Number.isFinite(message.busyTimeoutMs)
120
+ && message.busyTimeoutMs >= 0));
111
121
  }
112
122
 
113
123
  /**
@@ -207,6 +217,9 @@ if (typeof self !== "undefined" && typeof (self as { onmessage?: unknown }) ===
207
217
  try {
208
218
  if (message.env?.CODEX_HOME) process.env.CODEX_HOME = message.env.CODEX_HOME;
209
219
  if (message.env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = message.env.OPENCODEX_HOME;
220
+ // Before any DB open: the timeout has to be in force for the first `openStateDb`, not
221
+ // after the writer has already waited out this realm's default.
222
+ if (message.busyTimeoutMs !== undefined) adoptHistoryDbBusyTimeout(message.busyTimeoutMs);
210
223
  self.postMessage(runHistoryUnitUnderLock(message));
211
224
  } catch (error) {
212
225
  self.postMessage({
@@ -7,7 +7,7 @@ import {
7
7
  rootTomlString,
8
8
  stripJournaledOpenaiBaseUrl,
9
9
  } from "../injected-marker";
10
- import { preflightCodexHistoryInjection } from "../history-provider";
10
+ import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "../history-provider";
11
11
  import {
12
12
  journaledInjectedOpenaiBaseUrl,
13
13
  journaledInjectedRealtimeWsBaseUrl,
@@ -79,6 +79,90 @@ export function removeOcxSection(content: string): string {
79
79
  );
80
80
  }
81
81
 
82
+ /**
83
+ * Capture `[model_providers.opencodex]` verbatim so it can survive a restore that only
84
+ * takes routing down (#4812).
85
+ *
86
+ * This is deliberately NOT a mirror of `removeOcxSection`'s scan. That one opens a
87
+ * section on any line containing `OCX_SECTION_MARKER`, which is safe there only because
88
+ * `stripInjectedOpenaiBaseUrl` has already consumed the identical marker that annotates
89
+ * the root `openai_base_url`. Capture runs against the untouched file, so the same rule
90
+ * would collect that marker and the routing line under it — and re-appending the result
91
+ * would restore the exact base-url override the caller just removed.
92
+ *
93
+ * So the anchor is the provider header itself, via the shared `isOcxProviderHeaderLine`,
94
+ * with an immediately preceding marker line pulled in as its comment. Sharing that
95
+ * predicate is what keeps capture and removal from disagreeing about what our table is.
96
+ */
97
+ export function extractOcxProviderTableBlock(content: string): string | null {
98
+ const lines = content.split("\n");
99
+ const collected: string[] = [];
100
+ let capturing = false;
101
+ for (let index = 0; index < lines.length; index++) {
102
+ const line = lines[index]!;
103
+ if (isOcxProviderHeaderLine(line.trim())) {
104
+ if (!capturing) {
105
+ const previous = lines[index - 1];
106
+ if (previous !== undefined && previous.includes(OCX_SECTION_MARKER)) collected.push(previous);
107
+ capturing = true;
108
+ }
109
+ collected.push(line);
110
+ continue;
111
+ }
112
+ if (!capturing) continue;
113
+ // A foreign table header closes ours, exactly as in `removeOcxSection`. A later
114
+ // `[model_providers.opencodex.*]` sub-table reopens capture on the next iteration,
115
+ // which is why the two are separate passes over the same predicate.
116
+ if (/^\s*\[/.test(line)) {
117
+ capturing = false;
118
+ continue;
119
+ }
120
+ collected.push(line);
121
+ }
122
+ if (collected.length === 0) return null;
123
+ return collected.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
124
+ }
125
+
126
+ /**
127
+ * Append a captured provider table to stripped content, as one buffer.
128
+ *
129
+ * Pure on purpose. Upstream resolves `model_provider` against the merged provider map and
130
+ * fails the WHOLE config load on a miss — not the one thread — so a config carrying root
131
+ * `model_provider = "opencodex"` without this table breaks every `codex` invocation. The
132
+ * strip and the re-append therefore have to reach disk in a single write, which they can
133
+ * only do if the append is a transform rather than a second file operation.
134
+ */
135
+ export function appendOcxProviderTableBlock(content: string, block: string): string {
136
+ if (hasOcxProviderTable(content)) return content;
137
+ return `${content.replace(/\n+$/, "")}\n\n${block.replace(/\n+$/, "")}\n`;
138
+ }
139
+
140
+ /** Read the provider table straight off disk, before anything has transformed it. */
141
+ export function readOcxProviderTableBlock(): string | null {
142
+ if (!existsSync(CODEX_CONFIG_PATH)) return null;
143
+ return extractOcxProviderTableBlock(applyEol(readFileSync(CODEX_CONFIG_PATH, "utf-8"), "\n"));
144
+ }
145
+
146
+ /**
147
+ * Re-attach a captured provider table after an exact journal restore.
148
+ *
149
+ * This is the one place retention needs a second write, because the journal replays whole
150
+ * pre-injection bytes rather than transforming the current file. The intermediate state is
151
+ * the safe one: the journal's config is the user's own, so it carries no
152
+ * `model_provider = "opencodex"` for a missing table to strand. A crash between the two
153
+ * writes leaves a fully native config, which is the direction this whole change is trying
154
+ * to reach anyway.
155
+ */
156
+ export function retainOcxProviderTableOnDisk(block: string): string[] | null {
157
+ if (!existsSync(CODEX_CONFIG_PATH)) return null;
158
+ const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
159
+ const eol = dominantEol(rawContent);
160
+ const content = applyEol(rawContent, "\n");
161
+ const next = appendOcxProviderTableBlock(content, block);
162
+ if (next !== content) atomicWriteFile(CODEX_CONFIG_PATH, applyEol(next, eol));
163
+ return block.replace(/\n+$/, "").split("\n");
164
+ }
165
+
82
166
  interface StripOpencodexConfigResult {
83
167
  content: string;
84
168
  managedDefaultsError: string | null;
@@ -139,11 +223,52 @@ function hasOpencodexRouting(content: string): boolean {
139
223
  );
140
224
  }
141
225
 
226
+ /**
227
+ * What the caller already decided about conversation history before calling.
228
+ *
229
+ * - `refuse-on-any` — nothing was decided, so re-derive and refuse on any refusal reason.
230
+ * This is the default, and it is what a direct caller gets.
231
+ * - `stand-down-retain` — a stand-down was accepted and `[model_providers.opencodex]` must
232
+ * survive, because the rows this home tagged `opencodex` stay tagged and resolve only
233
+ * through that table. Those conversations still open; their requests fail against a
234
+ * stopped proxy, which is an ordinary connection error.
235
+ * - `stand-down-remove` — a stand-down was accepted and the user explicitly asked for the
236
+ * table to go too, accepting that those conversations stop opening.
237
+ *
238
+ * One option rather than two booleans: retention and the refusal are the same decision seen
239
+ * from two sides, and splitting them is how the explicit-removal path ended up refused by a
240
+ * preflight its caller had already answered.
241
+ */
242
+ export type RemoveCodexConfigHistoryDisposition =
243
+ | "refuse-on-any"
244
+ | "stand-down-retain"
245
+ | "stand-down-remove";
246
+
247
+ export interface RemoveCodexConfigOptions {
248
+ preserveProfile?: boolean;
249
+ historyDisposition?: RemoveCodexConfigHistoryDisposition;
250
+ }
251
+
252
+ export interface RemoveCodexConfigResult {
253
+ success: boolean;
254
+ message: string;
255
+ /** The exact lines left on disk when the disposition was `stand-down-retain`. */
256
+ retainedProviderTable?: string[];
257
+ }
258
+
142
259
  export function removeCodexConfig(
143
- options: { preserveProfile?: boolean } = {},
144
- ): { success: boolean; message: string } {
260
+ options: RemoveCodexConfigOptions = {},
261
+ ): RemoveCodexConfigResult {
262
+ const historyDisposition = options.historyDisposition ?? "refuse-on-any";
145
263
  const historyError = preflightCodexHistoryInjection(false, false);
146
- if (historyError) return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` };
264
+ // The preflight answers "may I rewrite conversation history?". Routing removal is a
265
+ // different question, and treating one answer as both is what left `ocx uninstall`
266
+ // pointing a live config at a port it had just removed (#4812). Only the stand-down
267
+ // reason is separable; every other reason still means something is wrong with the
268
+ // history state itself, and those keep the hard refusal even for a caller that decided.
269
+ if (historyError && !(historyDisposition !== "refuse-on-any" && historyError === HISTORY_RELABEL_STANDS_DOWN)) {
270
+ return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` };
271
+ }
147
272
  if (!existsSync(CODEX_CONFIG_PATH)) {
148
273
  if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
149
274
  unlinkSync(CODEX_PROFILE_PATH);
@@ -166,13 +291,25 @@ export function removeCodexConfig(
166
291
  || (journaledRealtimeWsBaseUrl !== null
167
292
  && rootTomlString(content, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
168
293
  const stripped = stripOpencodexConfigResult(content, journaledBaseUrl, journaledRealtimeWsBaseUrl);
169
- if (had || stripped.content !== content) {
170
- atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
294
+ // Captured from the pre-strip bytes: the strip is what removes the table, so reading it
295
+ // afterwards would find nothing.
296
+ const retainedBlock = historyDisposition === "stand-down-retain"
297
+ ? extractOcxProviderTableBlock(content)
298
+ : null;
299
+ const finalContent = retainedBlock === null
300
+ ? stripped.content
301
+ : appendOcxProviderTableBlock(stripped.content, retainedBlock);
302
+ if (had || finalContent !== content) {
303
+ atomicWriteFile(CODEX_CONFIG_PATH, applyEol(finalContent, eol));
171
304
  }
172
305
  if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
173
306
  unlinkSync(CODEX_PROFILE_PATH);
307
+ const retainedNote = retainedBlock === null
308
+ ? ""
309
+ : " Kept [model_providers.opencodex] so conversations already tagged opencodex still open;"
310
+ + " remove it with 'ocx restore --remove-codex-provider-table' (those conversations stop opening).";
174
311
  const removedMessage = had
175
- ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
312
+ ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}${retainedNote}`
176
313
  : "opencodex not present in Codex config.";
177
314
  if (stripped.managedDefaultsError) {
178
315
  const routingMessage = had
@@ -188,5 +325,6 @@ export function removeCodexConfig(
188
325
  return {
189
326
  success: true,
190
327
  message: removedMessage,
328
+ ...(retainedBlock === null ? {} : { retainedProviderTable: retainedBlock.replace(/\n+$/, "").split("\n") }),
191
329
  };
192
330
  }