@bitkyc08/opencodex 2.57.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 (86) hide show
  1. package/gui/dist/assets/{index-Cz7CLdif.js → index-BbrHOIY0.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  5. package/src/adapters/command-code.ts +13 -4
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +65 -1
  8. package/src/adapters/cursor/live-transport.ts +5 -1
  9. package/src/adapters/cursor/protobuf-events.ts +110 -11
  10. package/src/adapters/cursor/protobuf-request.ts +19 -1
  11. package/src/adapters/cursor/text-toolcall.ts +230 -0
  12. package/src/adapters/cursor/thread-continuity.ts +67 -0
  13. package/src/adapters/cursor/types.ts +5 -0
  14. package/src/adapters/cursor.ts +55 -5
  15. package/src/adapters/google-http.ts +38 -13
  16. package/src/adapters/mimo-free.ts +32 -17
  17. package/src/adapters/ollama-native.ts +42 -8
  18. package/src/adapters/openai-responses/passthrough.ts +30 -4
  19. package/src/adapters/openai-responses/request-strips.ts +43 -0
  20. package/src/adapters/physical-send.ts +50 -0
  21. package/src/bridge/response-json.ts +1 -1
  22. package/src/bridge/sse.ts +1 -1
  23. package/src/claude/outbound.ts +14 -4
  24. package/src/cli/config-command.ts +35 -18
  25. package/src/cli/dispatch.ts +17 -4
  26. package/src/cli/index.ts +44 -2
  27. package/src/cli/system-command.ts +70 -1
  28. package/src/cli/uninstall-client-state.ts +12 -0
  29. package/src/codex/auth-context.ts +42 -8
  30. package/src/codex/desktop-switches.ts +145 -0
  31. package/src/codex/history-job.ts +5 -1
  32. package/src/codex/history-provider.ts +33 -4
  33. package/src/codex/history-worker.ts +14 -1
  34. package/src/codex/inject/remove.ts +145 -7
  35. package/src/codex/inject/restore.ts +204 -32
  36. package/src/codex/inject.ts +3 -7
  37. package/src/codex/loopback-target.ts +9 -0
  38. package/src/codex/native-profile-startup.ts +64 -20
  39. package/src/config/atomic-write.ts +83 -8
  40. package/src/config/schema/config-schema.ts +2 -0
  41. package/src/config/schema/leaf-validators.ts +1 -0
  42. package/src/generated/compatibility-version.json +132 -68
  43. package/src/lib/bounded-subprocess.ts +62 -10
  44. package/src/lib/windows-secret-acl.ts +151 -15
  45. package/src/lib/windows-user-principal.ts +5 -1
  46. package/src/providers/derive.ts +6 -0
  47. package/src/providers/model-discovery.ts +19 -7
  48. package/src/providers/registry/entries-core.ts +11 -0
  49. package/src/providers/registry/entries-extended.ts +50 -28
  50. package/src/providers/registry/model-seeds.ts +67 -17
  51. package/src/providers/registry/types.ts +9 -0
  52. package/src/responses/spill-store.ts +17 -0
  53. package/src/responses/state/body-policy.ts +25 -0
  54. package/src/responses/state/spill-queue.ts +8 -6
  55. package/src/responses/state.ts +3 -22
  56. package/src/router.ts +4 -0
  57. package/src/server/auth-cors.ts +1 -0
  58. package/src/server/index/websocket-handler.ts +48 -1
  59. package/src/server/management/config-routes.ts +27 -5
  60. package/src/server/models-capabilities.ts +24 -3
  61. package/src/server/responses/codex-ws-exchange.ts +65 -4
  62. package/src/server/responses/combo-stream-preflight.ts +68 -5
  63. package/src/server/responses/core-combo.ts +26 -0
  64. package/src/server/responses/core-options.ts +3 -0
  65. package/src/server/responses/fetch-helpers.ts +4 -1
  66. package/src/server/responses/native-injection-protocol.ts +42 -0
  67. package/src/server/responses/native-injection-replay.ts +105 -0
  68. package/src/server/responses/native-injection.ts +242 -0
  69. package/src/server/responses/native-response-control.ts +56 -0
  70. package/src/server/responses/native-response-json.ts +14 -0
  71. package/src/server/responses/native-response-output.ts +37 -0
  72. package/src/server/responses/native-steering-log.ts +44 -0
  73. package/src/server/responses/native-steering-policy.ts +49 -0
  74. package/src/server/responses/native-steering-replay.ts +126 -0
  75. package/src/server/responses/native-steering-settings.ts +76 -0
  76. package/src/server/responses/native-steering.ts +400 -0
  77. package/src/server/responses/native-tool-results.ts +130 -0
  78. package/src/server/responses/passthrough-delivery.ts +11 -0
  79. package/src/server/responses/passthrough-dispatch.ts +33 -1
  80. package/src/server/responses/request-prepare.ts +41 -0
  81. package/src/server/responses/ws-upstream.ts +21 -1
  82. package/src/server/stop-teardown.ts +8 -1
  83. package/src/server/ws-bridge.ts +16 -1
  84. package/src/service/cli.ts +13 -1
  85. package/src/types/config.ts +4 -0
  86. package/src/types/provider.ts +13 -0
@@ -4,6 +4,7 @@ import { readClientConnectionState, sameClientConnectionOwner } from "../client/
4
4
  import { assertClientLifecycleHeld, withClientLifecycle } from "../client/lifecycle-lock";
5
5
  import { inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store";
6
6
  import { removeOwnedConfigState, type ConfigRemovalResult } from "../lib/config-ownership";
7
+ import { windowsSecretAclReapPendingAtOrBelow } from "../lib/windows-secret-acl";
7
8
  import { sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
8
9
 
9
10
  export interface UninstallClientStateDeps {
@@ -13,6 +14,8 @@ export interface UninstallClientStateDeps {
13
14
  disconnect: (options?: Parameters<typeof disconnectClient>[0]) => Promise<unknown>;
14
15
  withLifecycle: typeof withClientLifecycle;
15
16
  remove: () => ConfigRemovalResult;
17
+ /** True while a timed-out icacls child still owns a path at or below the config directory. */
18
+ aclReapPending: (rootPath: string) => boolean;
16
19
  }
17
20
 
18
21
  const defaults: UninstallClientStateDeps = {
@@ -22,6 +25,7 @@ const defaults: UninstallClientStateDeps = {
22
25
  disconnect: options => disconnectClient(options),
23
26
  withLifecycle: withClientLifecycle,
24
27
  remove: () => removeOwnedConfigState(getConfigDir()),
28
+ aclReapPending: rootPath => windowsSecretAclReapPendingAtOrBelow(rootPath),
25
29
  };
26
30
 
27
31
  /** Restore connection-owned client artifacts before removing their ownership/recovery records. */
@@ -73,6 +77,14 @@ export async function removeOwnedConfigAfterDesktopCleanup(
73
77
  || (latestReceipt.kind === "valid" && latestReceipt.value.phase !== "complete")) {
74
78
  throw new Error("Client cleanup refused: connection or Desktop state changed before removal.");
75
79
  }
80
+ // The async ACL belt releases its caller on a stalled `icacls.exe`, which is what keeps
81
+ // startup and shutdown bounded. It is not evidence that the child released the directory,
82
+ // and on Windows a live handle makes this removal fail partway instead of cleanly. Refuse
83
+ // promptly and let the operator retry: waiting here would hand a stuck child the power to
84
+ // hang `ocx uninstall`, which is the bound the belt exists to preserve.
85
+ if (deps.aclReapPending(getConfigDir())) {
86
+ throw new Error("Client cleanup refused: ACL hardening still owns a path under the config directory.");
87
+ }
76
88
  return deps.remove();
77
89
  });
78
90
  }
@@ -96,6 +96,44 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
96
96
  return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
97
97
  }
98
98
 
99
+ /**
100
+ * Whether a request carrying its OWN main credential still serves on main because the operator
101
+ * manually pinned it (#3166), split from the surrounding resolution so request preview can ask
102
+ * the identical question (#4850).
103
+ *
104
+ * Exported for exactly one reason: two copies of this fence is how #4850 happened. Final
105
+ * authentication honoured the ownership boundary while request preview, computing its fence from
106
+ * recovery and drain state alone, still handed pool eligibility the default liveness probe and
107
+ * opened the physical `auth.json` twice per spawn. A predicate one caller can forget is a
108
+ * predicate the other caller will eventually disagree with.
109
+ *
110
+ * Read-free by construction, which is what makes it usable on the fenced side. Every input is
111
+ * config, policy, or in-memory runtime state: the pin fields, the paused list, the cached quota
112
+ * score, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
113
+ * credential record in `main-account-cache.ts`. Nothing here opens a file.
114
+ *
115
+ * `candidate` is the pin before the hard-lock question, because the caller still owes the
116
+ * pending-binding check that only final authentication can fail closed on.
117
+ */
118
+ export function requestOwnedMainPinState(
119
+ headers: Headers,
120
+ config: OcxConfig,
121
+ policy: CodexAuthPolicyConfig,
122
+ requestScopedMainCredential: boolean,
123
+ fixedAccountId: string | undefined,
124
+ ): { candidate: boolean; preserve: boolean } {
125
+ const candidate = requestScopedMainCredential
126
+ && fixedAccountId === undefined
127
+ && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
128
+ && isEffectiveCodexAccountPinned(config)
129
+ && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
130
+ && requestOwnedMainPinHasQuotaHeadroom(config);
131
+ return {
132
+ candidate,
133
+ preserve: candidate && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)),
134
+ };
135
+ }
136
+
99
137
  /**
100
138
  * Every thread keys as ITSELF, never as its parent (#4546, wp8).
101
139
  *
@@ -816,19 +854,15 @@ export async function resolveCodexAuthContext(
816
854
  throw new CodexReserveUnavailableError();
817
855
  }
818
856
  const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
819
- const requestOwnedMainPinCandidate = requestScopedMainCredential
820
- && fixedAccountId === undefined
821
- && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
822
- && isEffectiveCodexAccountPinned(config)
823
- && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
824
- && requestOwnedMainPinHasQuotaHeadroom(config);
857
+ const {
858
+ candidate: requestOwnedMainPinCandidate,
859
+ preserve: preserveRequestOwnedMainPin,
860
+ } = requestOwnedMainPinState(headers, config, policy, requestScopedMainCredential, fixedAccountId);
825
861
  // During an owned startup, equality cannot be established until recovery and the
826
862
  // memory-only policy binding finish. This read-only fence never probes a foreign home.
827
863
  if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
828
864
  throw new CodexMainProfileDrainingError();
829
865
  }
830
- const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
831
- && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
832
866
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
833
867
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
834
868
  }
@@ -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
  }