@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -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 } : {}),
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { zstdDecompressSync } from "node:zlib";
5
5
  import { Database } from "bun:sqlite";
6
6
  import { resolveCodexStateDbPath } from "./paths";
7
+ import { openCodexStateForPreflight } from "./history-state-open";
7
8
  import { atomicWriteFile, getConfigDir } from "../config";
8
9
  import {
9
10
  CODEX_HISTORY_RESUMABLE_SOURCES,
@@ -76,6 +77,22 @@ export function setHistoryDbBusyTimeoutForTests(ms: number): void {
76
77
  historyDbBusyTimeoutMs = ms;
77
78
  }
78
79
 
80
+ /**
81
+ * Carry that timeout across a realm boundary. A Worker starts from the default above and cannot
82
+ * observe a parent that shortened the window — the same reason its run message carries the homes
83
+ * explicitly — so `history-job.ts` sends this value and `history-worker.ts` adopts it. In
84
+ * production both sides already hold the codex-rs-matching 5s. A non-finite or negative value is
85
+ * refused rather than allowed to disable the wait the app expects.
86
+ */
87
+ export function currentHistoryDbBusyTimeoutMs(): number {
88
+ return historyDbBusyTimeoutMs;
89
+ }
90
+
91
+ export function adoptHistoryDbBusyTimeout(ms: number): void {
92
+ if (!Number.isFinite(ms) || ms < 0) return;
93
+ historyDbBusyTimeoutMs = Math.floor(ms);
94
+ }
95
+
79
96
  function openStateDb(stateDbPath: string): Database {
80
97
  const db = new Database(stateDbPath);
81
98
  try {
@@ -311,6 +328,19 @@ class CodexHistoryIntegrityError extends Error {
311
328
  * O_APPEND does not allocate an ordinal or update that writer's in-memory cursor.
312
329
  * Refuse before changing the DB, manifest, or first-line provider; never guess N+1.
313
330
  */
331
+ /**
332
+ * The one refusal reason that means "the native writer owns this history", as opposed
333
+ * to "something is wrong". It is a stand-down for the relabel unit on apply
334
+ * (`src/codex/inject.ts`) and for the history half of a restore; every other reason is
335
+ * a hard refusal in both directions.
336
+ *
337
+ * Exported as a constant rather than repeated as a literal because the apply and restore
338
+ * directions have to agree on it exactly. They drifted once already: apply learned to
339
+ * stand down while restore kept refusing, which is how #4812's uninstall deadlock
340
+ * survived the fix that was supposed to end it.
341
+ */
342
+ export const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer";
343
+
314
344
  function assertLegacyHistoryRecord(line: string): void {
315
345
  let value: unknown;
316
346
  try { value = JSON.parse(line); } catch { throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); }
@@ -320,7 +350,7 @@ function assertLegacyHistoryRecord(line: string): void {
320
350
  const record = value as Record<string, unknown>;
321
351
  const payload = record.payload;
322
352
  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");
353
+ throw new CodexHistoryIntegrityError(HISTORY_RELABEL_STANDS_DOWN);
324
354
  }
325
355
  }
326
356
 
@@ -381,7 +411,7 @@ function assertLegacyHistoryWritable(path: string, heldFd?: number): void {
381
411
  function assertLegacyHistoryStore(db: Database): void {
382
412
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all();
383
413
  if (columns.some(column => column.name === "history_mode")) {
384
- throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer");
414
+ throw new CodexHistoryIntegrityError(HISTORY_RELABEL_STANDS_DOWN);
385
415
  }
386
416
  }
387
417
 
@@ -404,10 +434,12 @@ export function preflightCodexHistoryInjection(
404
434
  if (!existsSync(resolvedPath)) {
405
435
  return restoreEntries.length > 0 ? "history_state_database_missing" : null;
406
436
  }
407
- db = new Database(resolvedPath, { readonly: true });
437
+ // Read-only, and narrowed so a cleanly-closed WAL store is inspected rather than refused
438
+ // (#4943). The open order is the safety property; see history-state-open.ts.
439
+ db = openCodexStateForPreflight(resolvedPath);
408
440
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all();
409
441
  const paginatedColumn = columns.some(column => column.name === "history_mode");
410
- if (paginatedColumn && restoreEntries.length > 0) return "history_paginated_requires_native_writer";
442
+ if (paginatedColumn && restoreEntries.length > 0) return HISTORY_RELABEL_STANDS_DOWN;
411
443
  for (const entry of restoreEntries) assertLegacyHistoryWritable(entry.rolloutPath);
412
444
  const rows = db.query<{ rollout_path: string; history_mode: string | null }, []>(`
413
445
  SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}
@@ -417,7 +449,7 @@ export function preflightCodexHistoryInjection(
417
449
  : "model_provider = 'opencodex'"}
418
450
  `).all();
419
451
  for (const row of rows) {
420
- if (paginatedColumn || row.history_mode === "paginated") return "history_paginated_requires_native_writer";
452
+ if (paginatedColumn || row.history_mode === "paginated") return HISTORY_RELABEL_STANDS_DOWN;
421
453
  assertLegacyHistoryWritable(row.rollout_path);
422
454
  }
423
455
  return null;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * How the read-only Codex history preflight opens the state store.
3
+ *
4
+ * Split out of `history-provider.ts` deliberately. That file is the single largest module in
5
+ * `src/codex/` and sits just under the repository's 2000-line ratchet; the reasoning below is
6
+ * load-bearing and long, and appending it there would have pushed the file over. Keeping the
7
+ * open policy in one small module also puts the decision somewhere it can be read on its own,
8
+ * which matters because getting it wrong is silent in both directions.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { pathToFileURL } from "node:url";
12
+ import { Database, constants as sqliteConstants } from "bun:sqlite";
13
+
14
+ /**
15
+ * Read-only open flags that skip WAL shared memory entirely.
16
+ *
17
+ * `immutable=1` has to arrive as a `file:` URI, and a URI filename needs SQLITE_OPEN_URI.
18
+ * Same idiom as the storage scanner, the log-guard inspector and the coordinator doctor, for
19
+ * the same reason: never touch a foreign store's WAL protocol.
20
+ */
21
+ const IMMUTABLE_READONLY_FLAGS = sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI;
22
+
23
+ /** Is this the "there is no shared memory and I may not create it" open failure? */
24
+ export function isStateDbCantOpenError(error: unknown): boolean {
25
+ const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
26
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
27
+ // Matched on the code with a message fallback, exactly like classifyRecoverableHistoryError:
28
+ // the same SQLite condition reaches us as a code on some platforms and as bare text on others.
29
+ return code === "SQLITE_CANTOPEN" || message.includes("unable to open database file");
30
+ }
31
+
32
+ /** Which step of the primary attempt a test wants to fail. */
33
+ export type StateDbPreflightOpenPhase = "open" | "first-read";
34
+
35
+ /**
36
+ * Test-only knob: force the primary attempt to fail with a supplied error.
37
+ *
38
+ * The fallback below turns on a condition this repository cannot reproduce deterministically
39
+ * from a test: whether a plain read-only open of a cleanly-closed WAL store fails or quietly
40
+ * creates the `-shm` depends on the platform VFS and on the directory the store sits in.
41
+ * Pinning the NARROWING — sidecars absent admits the immutable read, either sidecar present
42
+ * still refuses — therefore needs the failure supplied rather than provoked, or the test would
43
+ * assert the host's SQLite build instead of this decision (#4943).
44
+ *
45
+ * The phase exists because the platforms disagree about WHEN the condition is raised, not only
46
+ * about whether it is: see the first-read note on `openCodexStateForPreflight`.
47
+ */
48
+ let openFailureForTests: ((path: string, phase: StateDbPreflightOpenPhase) => unknown) | undefined;
49
+ export function setStateDbPreflightOpenFailureForTests(hook: typeof openFailureForTests): void {
50
+ openFailureForTests = hook;
51
+ }
52
+
53
+ /**
54
+ * Open the Codex state store for the read-only preflight.
55
+ *
56
+ * `{ readonly: true }` is tried FIRST and stays the primary path, because it is the only mode
57
+ * that can see a live WAL: it joins the writer's shared memory, so a thread another process
58
+ * just migrated to paginated history is visible here and the preflight refuses on it. An
59
+ * immutable open reads the last checkpointed main database instead. Making that the primary
60
+ * path would trade a refusal for a stale snapshot, and a refusal this preflight fails to
61
+ * observe is a config transition that proceeds over history Codex owns — the exact outcome the
62
+ * whole guard exists to prevent.
63
+ *
64
+ * A WAL store closed cleanly is the case that has no shared memory to join. SQLite cannot
65
+ * create the `-shm` from a read-only connection, so the open fails SQLITE_CANTOPEN on a store
66
+ * that is perfectly healthy, the catch-all in the preflight folds that into
67
+ * `history_injection_preflight_unavailable`, and `ocx sync` refuses on every attempt with no
68
+ * way forward (#4943).
69
+ *
70
+ * So the fallback is admitted only in the state where the absent sidecars are what make an
71
+ * immutable read exact rather than stale: no `-wal` and no `-shm` on disk means no writer is
72
+ * attached and no committed content sits outside the main database, so the main file IS the
73
+ * whole store and the two modes cannot disagree. Either sidecar present keeps the original
74
+ * error and the refusal that follows from it — a `-wal` holds content this connection would
75
+ * not read, and a `-shm` means a writer is attached, and neither is a store this preflight
76
+ * may inspect from a snapshot.
77
+ *
78
+ * The first read belongs INSIDE this attempt. `sqlite3_open_v2` does not touch page 1, so a
79
+ * store whose header says WAL is not inspected until the first prepare — which is where the
80
+ * missing shared memory is discovered on macOS, one caller frame above this function. Opening
81
+ * here and reading there put the classification and the failure in different scopes: the
82
+ * fallback was never reached, and the operator got the catch-all refusal the fix was supposed
83
+ * to remove. Linux hides this because its SQLite materializes the sidecars on that first read
84
+ * and never fails at all (#4943, macOS CI).
85
+ */
86
+ export function openCodexStateForPreflight(resolvedPath: string): Database {
87
+ let db: Database | undefined;
88
+ try {
89
+ const forced = openFailureForTests?.(resolvedPath, "open");
90
+ if (forced) throw forced;
91
+ db = new Database(resolvedPath, { readonly: true });
92
+ const forcedRead = openFailureForTests?.(resolvedPath, "first-read");
93
+ if (forcedRead) throw forcedRead;
94
+ // Page 1, read while the failure is still this function's to classify.
95
+ db.query<{ tables: number }, []>("SELECT count(*) AS tables FROM sqlite_master").get();
96
+ return db;
97
+ } catch (error) {
98
+ db?.close();
99
+ if (!isStateDbCantOpenError(error)) throw error;
100
+ if (existsSync(`${resolvedPath}-wal`) || existsSync(`${resolvedPath}-shm`)) throw error;
101
+ // pathToFileURL percent-encodes the reserved characters a naive `file:${path}` would
102
+ // misparse as a query or fragment.
103
+ return new Database(`${pathToFileURL(resolvedPath).href}?immutable=1`, IMMUTABLE_READONLY_FLAGS);
104
+ }
105
+ }
@@ -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({
@@ -55,6 +55,46 @@ export function applyEol(content: string, eol: "\r\n" | "\n"): string {
55
55
  return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
56
56
  }
57
57
 
58
+ /** Label Codex shows for the injected provider when the operator has not chosen one. */
59
+ export const DEFAULT_CODEX_PROVIDER_DISPLAY_NAME = "OpenCodex Proxy";
60
+
61
+ /** Longest label accepted, matching the display-label policy used for provider names. */
62
+ const MAX_CODEX_PROVIDER_DISPLAY_NAME_LENGTH = 128;
63
+
64
+ /** Would this label put a control character into config.toml? */
65
+ function hasControlCharacter(value: string): boolean {
66
+ // Checked by code point rather than by a control-character regex, which needs a lint
67
+ // suppression this repository's hygiene gate rejects — and which reads no more clearly.
68
+ for (const character of value) {
69
+ const code = character.codePointAt(0) ?? 0;
70
+ if (code < 0x20 || code === 0x7f) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Which label to write, given whatever the config holds.
77
+ *
78
+ * Presentation only, and deliberately separate from identity: routing resolves through the
79
+ * provider id `opencodex` in the root `model_provider` line and the `[model_providers.opencodex]`
80
+ * header, neither of which is derived from this value. So a rename cannot reroute a thread or
81
+ * orphan a row that already names that id (#4810).
82
+ *
83
+ * Every rejected value falls back to the default rather than being emitted or omitted. Codex
84
+ * refuses to load a provider with no name, so writing a blank one would break the whole config
85
+ * file rather than one thread — strictly worse than the branding it was meant to remove. That is
86
+ * also why there is no way to suppress the field: suppression here means choosing a neutral
87
+ * label. A control character or an over-long value is rejected for the same reason, because
88
+ * `tomlString` would faithfully encode something Codex may still reject.
89
+ */
90
+ export function resolveCodexProviderDisplayName(configured?: string): string {
91
+ const trimmed = (configured ?? "").trim();
92
+ if (!trimmed) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
93
+ if (trimmed.length > MAX_CODEX_PROVIDER_DISPLAY_NAME_LENGTH) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
94
+ if (hasControlCharacter(trimmed)) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
95
+ return trimmed;
96
+ }
97
+
58
98
  export function buildProviderTableBlock(
59
99
  port: number,
60
100
  supportsWebsockets?: boolean,
@@ -84,12 +124,13 @@ export function buildProviderTableBlock(
84
124
  export function buildProviderTableBlockForTarget(
85
125
  target: CodexRoutingTarget,
86
126
  supportsWebsockets = false,
127
+ displayName?: string,
87
128
  ): string {
88
129
  const lines = [
89
130
  "",
90
131
  OCX_SECTION_MARKER,
91
132
  "[model_providers.opencodex]",
92
- 'name = "OpenCodex Proxy"',
133
+ `name = ${tomlString(resolveCodexProviderDisplayName(displayName))}`,
93
134
  `base_url = ${tomlString(target.baseUrl)}`,
94
135
  'wire_api = "responses"',
95
136
  // false only in the authless Desktop opt-in (#1107); true keeps the App/TUI account gate.
@@ -518,6 +559,7 @@ export function buildProfileFileForTarget(
518
559
  catalogPath?: string | null,
519
560
  supportsWebsockets = false,
520
561
  fastMode?: boolean,
562
+ displayName?: string,
521
563
  ): string {
522
564
  const origin = routingTargetOrigin(target);
523
565
  const host = new URL(origin).host;
@@ -542,7 +584,7 @@ export function buildProfileFileForTarget(
542
584
  ];
543
585
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
544
586
  if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`);
545
- lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets).trimEnd(), "");
587
+ lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets, displayName).trimEnd(), "");
546
588
  return lines.join("\n");
547
589
  }
548
590
 
@@ -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
  }