@bitkyc08/opencodex 2.55.0-preview.20260914 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,540 @@
1
+ import { loadConfig } from "../../config";
2
+ import { shouldSyncCodexOnStart } from "../desired-state";
3
+ import { withCatalogWriteSerialization } from "../catalog-write-serialization";
4
+ import { restoreCodexCatalogWithPermit } from "../catalog/sync";
5
+ import { withCodexWriteLock, CodexWriteLockSkipped } from "../codex-write-lock";
6
+ import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight";
7
+ import { resolveCodexHistoryTransition } from "../history-transition";
8
+ import {
9
+ captureCodexPreImages,
10
+ codexWriteCoordinationEligibility,
11
+ CodexPartialWriteError,
12
+ CodexWriteConflictError,
13
+ DEFAULT_INJECT_LOCK_TIMEOUT_MS,
14
+ recordCodexNativeTransactionProvenance,
15
+ restoreCodexPreImages,
16
+ } from "../inject-coordination";
17
+ import { readIntegrationRecord } from "../integration-record";
18
+ import { classifyNativeRoutedResidue } from "../native-residue";
19
+ import {
20
+ resolveCodexCoordinatorDatabasePath,
21
+ resolveEffectiveUserIdentity,
22
+ } from "../user-identity";
23
+ import {
24
+ journaledInjectedCatalogPath,
25
+ removeJournal,
26
+ restoreJournalState,
27
+ } from "../journal";
28
+ import {
29
+ preflightCodexHistoryInjection,
30
+ syncCodexHistoryProvider,
31
+ type CodexHistoryFailureReason,
32
+ } from "../history-provider";
33
+ import {
34
+ describeHistoryJobFailure,
35
+ deriveCodexHistoryOperation,
36
+ resolveCodexHistoryJobTarget,
37
+ runCodexHistoryJob,
38
+ type CodexHistoryJobOutcome,
39
+ } from "../history-job";
40
+ import {
41
+ DEFAULT_CATALOG_PATH,
42
+ getCodexHome,
43
+ tomlString,
44
+ } from "../paths";
45
+ import { shouldInjectApiAuthHeader } from "../loopback-target";
46
+ import { currentExternalCodexModelProvider } from "./config-toml";
47
+ import { removeCodexConfig } from "./remove";
48
+
49
+ class CodexRestoreRefusal extends Error {
50
+ constructor(readonly config: CodexRestoreConfigResult) {
51
+ super(config.message);
52
+ }
53
+ }
54
+
55
+ let beforeRestoreConfigForTests: ((kind: string) => void) | undefined;
56
+ export function setBeforeRestoreConfigForTests(hook: typeof beforeRestoreConfigForTests): void {
57
+ beforeRestoreConfigForTests = hook;
58
+ }
59
+
60
+ export type CodexRestoreArtifactState = "ok" | "skipped" | "failed";
61
+
62
+ export interface CodexRestoreConfigResult {
63
+ state: CodexRestoreArtifactState;
64
+ changed: boolean;
65
+ action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed";
66
+ message: string;
67
+ }
68
+
69
+ export interface CodexRestoreCatalogResult {
70
+ state: CodexRestoreArtifactState;
71
+ changed: boolean;
72
+ removed: number;
73
+ kept: number;
74
+ path: string | null;
75
+ message: string;
76
+ }
77
+
78
+ export interface CodexRestoreHistoryResult {
79
+ state: CodexRestoreArtifactState;
80
+ changed: boolean;
81
+ reason?: CodexHistoryFailureReason;
82
+ rows: number;
83
+ files: number;
84
+ ejectedRows: number;
85
+ message: string;
86
+ }
87
+
88
+ export interface CodexNativeRestoreResult {
89
+ success: boolean;
90
+ message: string;
91
+ externalProvider?: string;
92
+ artifacts: {
93
+ config: CodexRestoreConfigResult;
94
+ catalog: CodexRestoreCatalogResult;
95
+ history: CodexRestoreHistoryResult;
96
+ };
97
+ }
98
+
99
+ function failedHistoryRestore(
100
+ reason?: CodexHistoryFailureReason,
101
+ detail?: string,
102
+ progress: { rows?: number; files?: number } = {},
103
+ ): CodexRestoreHistoryResult {
104
+ const rows = progress.rows ?? 0;
105
+ const files = progress.files ?? 0;
106
+ const changed = rows > 0 || files > 0;
107
+ return {
108
+ state: "failed",
109
+ changed,
110
+ ...(reason ? { reason } : {}),
111
+ rows,
112
+ files,
113
+ ejectedRows: 0,
114
+ message: reason === "permission"
115
+ ? changed
116
+ ? "Codex resume history changed but did NOT converge because permission was denied while finalizing the backup manifest; the manifest was retained for review and safe retry."
117
+ : "Codex resume history could NOT be restored because permission was denied."
118
+ : reason === "busy"
119
+ ? changed
120
+ ? "Codex resume history changed but did NOT converge because backup-manifest finalization remained busy; the manifest was retained for review and safe retry."
121
+ : detail ?? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database."
122
+ : reason === "integrity"
123
+ ? changed
124
+ ? "Codex resume history changed but did NOT converge because the backup or target changed; the manifest was retained for review and safe retry."
125
+ : "Codex resume history could NOT be restored because the backup or restore target failed integrity checks; unverified provider metadata was left unchanged."
126
+ : detail
127
+ ? `Codex resume history could NOT be restored: ${detail}`
128
+ : "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.",
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Restore failure wording for a Worker outcome.
134
+ *
135
+ * Only a genuine busy result blames the Codex app. An unsafe-path refusal, an
136
+ * unavailable coordinator database, a permission denial, or a dead/timed-out
137
+ * worker is a different problem; the old collapse made every one of those read
138
+ * as "the Codex app is holding the database" (issue #1191). `busy` and
139
+ * `permission` keep the restore-specific sentence built by
140
+ * `failedHistoryRestore`; every other reason reuses the single formatter so
141
+ * the two modules cannot drift apart.
142
+ */
143
+ export function failedHistoryRestoreFromOutcome(
144
+ outcome: Extract<CodexHistoryJobOutcome, { kind: "blocked" | "failed" }>,
145
+ ): CodexRestoreHistoryResult {
146
+ if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy");
147
+ if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") {
148
+ return failedHistoryRestore(
149
+ "busy",
150
+ describeHistoryJobFailure(outcome, "restore"),
151
+ { rows: outcome.rows, files: outcome.files },
152
+ );
153
+ }
154
+ if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") {
155
+ return failedHistoryRestore("permission", undefined, { rows: outcome.rows, files: outcome.files });
156
+ }
157
+ if (outcome.kind === "failed" && outcome.historyFailureReason === "integrity") {
158
+ return failedHistoryRestore("integrity", undefined, { rows: outcome.rows, files: outcome.files });
159
+ }
160
+ return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore"));
161
+ }
162
+
163
+ function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult {
164
+ const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`;
165
+ return {
166
+ success: true,
167
+ message,
168
+ externalProvider: activeProvider,
169
+ artifacts: {
170
+ config: { state: "skipped", changed: false, action: "external-provider-preserved", message },
171
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
172
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
173
+ },
174
+ };
175
+ }
176
+
177
+ /** A foreign service claim is an authority boundary, including explicit CLI restore. */
178
+ function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResult {
179
+ return {
180
+ success: false,
181
+ message: `Codex native restore refused: ${message}`,
182
+ artifacts: {
183
+ config: { state: "skipped", changed: false, action: "failed", message },
184
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
185
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
186
+ },
187
+ };
188
+ }
189
+
190
+ function desiredEnabledRestoreSkip(): CodexNativeRestoreResult {
191
+ const message = "Codex integration was re-enabled; native restore was skipped.";
192
+ return skippedRestoreEnvelope(true, message);
193
+ }
194
+
195
+ /**
196
+ * A schema-complete all-skipped envelope for outcomes decided before any
197
+ * restore machinery runs. Every `restore --json` path must stay shape-stable
198
+ * with `CodexNativeRestoreResult`; consumers never special-case early exits.
199
+ */
200
+ export function skippedRestoreEnvelope(success: boolean, message: string): CodexNativeRestoreResult {
201
+ return {
202
+ success,
203
+ message,
204
+ artifacts: {
205
+ config: { state: "skipped", changed: false, action: "owned-fields-stripped", message },
206
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
207
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
208
+ },
209
+ };
210
+ }
211
+
212
+ /** Config was attempted and failed; downstream artifacts were never attempted. */
213
+ function failedConfigRestoreEnvelope(config: CodexRestoreConfigResult): CodexNativeRestoreResult {
214
+ const result = skippedRestoreEnvelope(false, config.message);
215
+ result.artifacts.config = config;
216
+ return result;
217
+ }
218
+
219
+ /** The config/profile half of a native restore, reported as one artifact. */
220
+ function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult {
221
+ const preImages = captureCodexPreImages();
222
+ const result = restoreCodexConfigInlineImpl(kind);
223
+ if (result.state === "failed") {
224
+ const compensated = restoreCodexPreImages(preImages);
225
+ if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
226
+ }
227
+ return result;
228
+ }
229
+
230
+ function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult {
231
+ try {
232
+ beforeRestoreConfigForTests?.(kind);
233
+ const historyError = preflightCodexHistoryInjection(false, false);
234
+ if (historyError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${historyError}.` };
235
+ const journal = restoreJournalState();
236
+ if (journal.unverified) {
237
+ return {
238
+ state: "failed", changed: false, action: "failed",
239
+ message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.",
240
+ };
241
+ }
242
+ const restored = journal.configRestored
243
+ ? { success: true, message: "Codex config restored from opencodex journal." }
244
+ : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
245
+ if (restored.success) {
246
+ // A successful journal/fallback write can race native history migration too.
247
+ // Refuse here while preimage compensation and the remove transaction can roll back.
248
+ const finalHistoryError = preflightCodexHistoryInjection(false, false);
249
+ if (finalHistoryError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${finalHistoryError}.` };
250
+ }
251
+ return restored.success
252
+ ? {
253
+ state: "ok",
254
+ changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"),
255
+ action: journal.configRestored ? "journal-restored" : "owned-fields-stripped",
256
+ message: restored.message,
257
+ }
258
+ : { state: "failed", changed: false, action: "failed", message: restored.message };
259
+ } catch (error) {
260
+ return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) };
261
+ }
262
+ }
263
+
264
+ /** The catalog half, always inside its own K acquisition. */
265
+ /**
266
+ * The catalog half, always inside its own K acquisition.
267
+ *
268
+ * `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a
269
+ * successful journal restore deletes the journal, and a config restore can remove
270
+ * `model_catalog_json`. Reading it here would be too late in both cases (#1798).
271
+ */
272
+ function restoreCodexCatalogArtifact(
273
+ revalidateDesiredState: boolean,
274
+ journaledCatalogPath: string | null,
275
+ ): CodexRestoreCatalogResult {
276
+ const owningCodexHome = getCodexHome();
277
+ try {
278
+ const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
279
+ revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
280
+ ? null
281
+ : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath));
282
+ return restored.kind === "completed" && restored.value !== null
283
+ ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
284
+ : restored.kind === "completed"
285
+ ? {
286
+ state: "skipped", changed: false, removed: 0, kept: 0, path: null,
287
+ message: "Codex integration was re-enabled; native catalog restoration was skipped.",
288
+ }
289
+ : {
290
+ state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
291
+ message: `Codex catalog could not be restored: ${restored.reason}.`,
292
+ };
293
+ } catch (error) {
294
+ return {
295
+ state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
296
+ message: error instanceof Error ? error.message : String(error),
297
+ };
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Restore native Codex, running history in a Worker under H.
303
+ *
304
+ * On a coordinated home the config/profile restore happens INSIDE the Codex
305
+ * write lock, publishing a `remove` transition — the same serialization inject
306
+ * uses. Without it, an older restore could overwrite a config a concurrent
307
+ * enable had just written under the lock, and then honestly report success
308
+ * while desired intent said ON. The desired-state re-read under the lock turns
309
+ * that lost race into the discriminated `desired_enabled` skip.
310
+ */
311
+ export async function restoreNativeCodexAsync(
312
+ options: { revalidateDesiredState?: boolean } = {},
313
+ ): Promise<CodexNativeRestoreResult> {
314
+ try {
315
+ return await restoreNativeCodexAsyncImpl(options);
316
+ } catch (error) {
317
+ if (!(error instanceof CodexRestoreRefusal)) throw error;
318
+ return failedConfigRestoreEnvelope(error.config);
319
+ }
320
+ }
321
+
322
+ async function restoreNativeCodexAsyncImpl(
323
+ options: { revalidateDesiredState?: boolean },
324
+ ): Promise<CodexNativeRestoreResult> {
325
+ const activeProvider = currentExternalCodexModelProvider();
326
+ if (activeProvider) {
327
+ // External-provider courtesy: only the stale journal is removed. The
328
+ // history worker must not launch — it would turn a read-mostly courtesy
329
+ // result into a history mutation on a home we do not own.
330
+ removeJournal();
331
+ return externalProviderRestoreResult(activeProvider);
332
+ }
333
+
334
+ // `restore` normally honours a human request even when an unrelated
335
+ // service-manager probe is unavailable. A recorded FOREIGN home is not an
336
+ // unrelated probe: it is positive evidence another installation owns these
337
+ // native artifacts, so do not create profile/claim locks before refusing.
338
+ if (options.revalidateDesiredState) {
339
+ const ownership = inspectNativeCodexOwnership();
340
+ if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason);
341
+ if (shouldSyncCodexOnStart(loadConfig())) return desiredEnabledRestoreSkip();
342
+ }
343
+
344
+ const historyError = preflightCodexHistoryInjection(false, false);
345
+ if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`);
346
+
347
+ const eligibility = codexWriteCoordinationEligibility({
348
+ coordinatorPath: () =>
349
+ resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()),
350
+ residue: () => classifyNativeRoutedResidue(),
351
+ integrationRecord: () => readIntegrationRecord(),
352
+ });
353
+
354
+ // Captured before the config half: a successful journal restore DELETES the journal, and
355
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
356
+ // catalog we actually wrote (#1798).
357
+ const journaledCatalogPath = journaledInjectedCatalogPath();
358
+ let config: CodexRestoreConfigResult;
359
+ let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
360
+
361
+ if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") {
362
+ // The restore has no candidate bytes to witness; freshness comes from the
363
+ // filesystem reads and the desired-state re-read performed under the lock.
364
+ const witness = { authoritySnapshotId: "codex-native-restore" };
365
+ const coordinated = await withCodexWriteLock(
366
+ {
367
+ timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS,
368
+ ...(eligibility.kind === "adopt" ? { adoption: { direction: "remove" as const } } : {}),
369
+ admitted: witness,
370
+ readAdmissionUnderLock: () => witness,
371
+ },
372
+ (ctx) => {
373
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
374
+ throw new CodexWriteLockSkipped("desired_enabled");
375
+ }
376
+ const published = ctx.coordinator.beginTransition(
377
+ {
378
+ nativeGeneration: ctx.expectation.nativeBefore,
379
+ currentTxId: ctx.currentTxId,
380
+ },
381
+ {
382
+ txId: ctx.expectation.txId,
383
+ direction: "remove",
384
+ authoritySnapshotId: ctx.admission.authoritySnapshotId,
385
+ nextRetryAt: new Date().toISOString(),
386
+ },
387
+ );
388
+ if (published.kind !== "updated") {
389
+ throw new CodexWriteConflictError(
390
+ `The Codex transition could not be published: ${published.kind}.`,
391
+ );
392
+ }
393
+ const preImages = captureCodexPreImages();
394
+ let restored: CodexRestoreConfigResult;
395
+ try {
396
+ restored = restoreCodexConfigInline(eligibility.kind);
397
+ // Throw inside N so the published remove transition rolls back too.
398
+ if (restored.state === "failed") throw new CodexRestoreRefusal(restored);
399
+ } catch (error) {
400
+ const compensated = restoreCodexPreImages(preImages);
401
+ if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
402
+ throw error;
403
+ }
404
+ return {
405
+ config: restored,
406
+ preImages,
407
+ receipt: {
408
+ nativeGeneration: ctx.expectation.nativeAfter,
409
+ currentTxId: ctx.expectation.txId,
410
+ },
411
+ };
412
+ },
413
+ );
414
+ if (coordinated.status === "skipped") return desiredEnabledRestoreSkip();
415
+ if (coordinated.status !== "acquired") {
416
+ config = {
417
+ state: "failed",
418
+ changed: false,
419
+ action: "failed",
420
+ message: coordinated.status === "busy"
421
+ ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.`
422
+ : `Codex configuration was not restored: ${coordinated.message}`,
423
+ };
424
+ } else {
425
+ recordCodexNativeTransactionProvenance(
426
+ coordinated.value.preImages,
427
+ coordinated.value.receipt.currentTxId,
428
+ );
429
+ config = coordinated.value.config;
430
+ transitionReceipt = coordinated.value.receipt;
431
+ }
432
+ } else {
433
+ // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path
434
+ // they have always had; restore is the escape hatch and must not strand
435
+ // them. The plain re-read still honors an intervening re-enable.
436
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
437
+ return desiredEnabledRestoreSkip();
438
+ }
439
+ config = restoreCodexConfigInline(eligibility.kind);
440
+ }
441
+
442
+ if (config.state === "failed") return failedConfigRestoreEnvelope(config);
443
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
444
+ const outcome = await runCodexHistoryJob({
445
+ ...resolveCodexHistoryJobTarget(),
446
+ ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
447
+ operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }),
448
+ });
449
+ if (transitionReceipt) {
450
+ resolveCodexHistoryTransition(transitionReceipt, outcome);
451
+ }
452
+ const history: CodexRestoreHistoryResult = outcome.kind === "converged"
453
+ ? {
454
+ state: "ok", changed: outcome.rows > 0 || outcome.files > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0,
455
+ message: outcome.rows > 0
456
+ ? `Resume history metadata restored from opencodex backup (${outcome.rows} thread(s)); original providers preserved.`
457
+ : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.",
458
+ }
459
+ : outcome.kind === "skipped"
460
+ ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." }
461
+ : outcome.kind === "blocked" && (outcome.reason === "desired_disabled" || outcome.reason === "desired_enabled")
462
+ ? {
463
+ state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0,
464
+ message: outcome.reason === "desired_disabled"
465
+ ? "Codex integration was disabled; history restoration was skipped."
466
+ : "Codex integration was enabled; history restoration was skipped.",
467
+ }
468
+ : outcome.kind === "blocked" || outcome.kind === "failed"
469
+ ? failedHistoryRestoreFromOutcome(outcome)
470
+ : failedHistoryRestore();
471
+ const base = catalog.removed > 0
472
+ ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
473
+ : config.message;
474
+ const success = catalog.state !== "failed"
475
+ && history.state !== "failed";
476
+ return {
477
+ success,
478
+ message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`,
479
+ artifacts: { config, catalog, history },
480
+ };
481
+ }
482
+
483
+ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult {
484
+ const activeProvider = currentExternalCodexModelProvider();
485
+ if (activeProvider) {
486
+ removeJournal();
487
+ return externalProviderRestoreResult(activeProvider);
488
+ }
489
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
490
+ return desiredEnabledRestoreSkip();
491
+ }
492
+ const historyError = preflightCodexHistoryInjection(false, false);
493
+ if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`);
494
+ // Captured before the config half: a successful journal restore DELETES the journal, and
495
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
496
+ // catalog we actually wrote (#1798).
497
+ const journaledCatalogPath = journaledInjectedCatalogPath();
498
+ const config = restoreCodexConfigInline();
499
+ if (config.state === "failed") return failedConfigRestoreEnvelope(config);
500
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
501
+ // Design B (loopback) steady state: threads are already tagged openai, so prove the
502
+ // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
503
+ // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
504
+ // Legacy (non-loopback) installs keep the unconditional write-open restore.
505
+ let skipWhenProvablyNoop = false;
506
+ try {
507
+ skipWhenProvablyNoop = !shouldInjectApiAuthHeader(loadConfig());
508
+ } catch {
509
+ /* unreadable config: keep the conservative write-open restore */
510
+ }
511
+ // `skipHistory` is how the async wrapper takes this work for itself: the
512
+ // native files come down here, and history runs in the Worker under H.
513
+ const rawHistory = options.skipHistory
514
+ ? { rows: 0, files: 0 }
515
+ : syncCodexHistoryProvider("openai", undefined, undefined, {
516
+ skipWhenProvablyNoop,
517
+ });
518
+ const history: CodexRestoreHistoryResult = options.skipHistory
519
+ ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." }
520
+ : rawHistory.failed
521
+ ? failedHistoryRestore(rawHistory.failureReason, undefined, rawHistory)
522
+ : {
523
+ state: "ok",
524
+ changed: rawHistory.rows > 0 || rawHistory.files > 0 || (rawHistory.ejectedRows ?? 0) > 0,
525
+ rows: rawHistory.rows,
526
+ files: rawHistory.files,
527
+ ejectedRows: rawHistory.ejectedRows ?? 0,
528
+ message: rawHistory.rows > 0
529
+ ? `Resume history metadata restored from opencodex backup (${rawHistory.rows} thread(s)); original providers preserved.`
530
+ : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.",
531
+ };
532
+ const message = catalog.removed > 0
533
+ ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
534
+ : config.message;
535
+ return {
536
+ success: catalog.state !== "failed" && history.state !== "failed",
537
+ message,
538
+ artifacts: { config, catalog, history },
539
+ };
540
+ }
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import {
3
+ hasInjectedCodexRouting,
4
+ hasInjectedOpenaiBaseUrl,
5
+ providerTableStart,
6
+ providerTableString,
7
+ rootTomlString,
8
+ } from "../injected-marker";
9
+ import { CODEX_CONFIG_PATH } from "../paths";
10
+
11
+ export type CodexRoutingKind =
12
+ "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
13
+
14
+ type RoutingEndpointKind = "local" | "remote" | "unknown";
15
+
16
+ function ipv4Octets(hostname: string): number[] | null {
17
+ const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
18
+ if (dotted) {
19
+ const octets = dotted.slice(1).map(Number);
20
+ return octets.some((octet) => octet > 255) ? null : octets;
21
+ }
22
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname);
23
+ if (!mapped) return null;
24
+ const high = Number.parseInt(mapped[1], 16);
25
+ const low = Number.parseInt(mapped[2], 16);
26
+ return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
27
+ }
28
+
29
+ function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
30
+ try {
31
+ const url = new URL(value);
32
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown";
33
+ const hostname = url.hostname
34
+ .toLowerCase()
35
+ .replace(/^\[|\]$/g, "")
36
+ .replace(/\.$/, "");
37
+ if (!hostname) return "unknown";
38
+ if (hostname === "localhost" || hostname.endsWith(".localhost"))
39
+ return "local";
40
+ if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0")
41
+ return "local";
42
+ const octets = ipv4Octets(hostname);
43
+ if (octets) {
44
+ if (octets.every((octet) => octet === 0)) return "local";
45
+ if (octets[0] === 127) return "local";
46
+ return "remote";
47
+ }
48
+ if (/^::ffff:/i.test(hostname)) return "unknown";
49
+ return "remote";
50
+ } catch {
51
+ return "unknown";
52
+ }
53
+ }
54
+
55
+ /** Classify actual routing dependency separately from opencodex ownership. */
56
+ export function classifyCodexRouting(content: string): CodexRoutingKind {
57
+ const rootBaseUrl = rootTomlString(content, "openai_base_url");
58
+ if (rootBaseUrl) {
59
+ const endpoint = classifyRoutingEndpoint(rootBaseUrl);
60
+ if (endpoint === "unknown") return "unknown";
61
+ if (hasInjectedOpenaiBaseUrl(content)) return "opencodex-local";
62
+ return endpoint === "local" ? "custom-local" : "custom-remote";
63
+ }
64
+ const rootProvider = rootTomlString(content, "model_provider");
65
+ if (rootProvider) {
66
+ const providerTableExists =
67
+ providerTableStart(content.split("\n"), rootProvider) !== -1;
68
+ const providerBaseUrl = providerTableString(
69
+ content,
70
+ rootProvider,
71
+ "base_url",
72
+ );
73
+ if (providerBaseUrl) {
74
+ const endpoint = classifyRoutingEndpoint(providerBaseUrl);
75
+ if (endpoint === "unknown") return "unknown";
76
+ if (rootProvider === "opencodex") return "opencodex-local";
77
+ return endpoint === "local" ? "custom-local" : "custom-remote";
78
+ }
79
+ if (
80
+ rootProvider === "opencodex" ||
81
+ providerTableExists ||
82
+ rootProvider !== "openai"
83
+ )
84
+ return "unknown";
85
+ }
86
+ return "native";
87
+ }
88
+
89
+ /** Read-only probe used by status, doctor, and the dashboard. */
90
+ export function isCodexRoutingInjected(): boolean {
91
+ const path = CODEX_CONFIG_PATH;
92
+ if (!existsSync(path)) return false;
93
+ try {
94
+ return hasInjectedCodexRouting(readFileSync(path, "utf8"));
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ export function getCodexRoutingKind(): CodexRoutingKind {
101
+ const path = CODEX_CONFIG_PATH;
102
+ if (!existsSync(path)) return "native";
103
+ try {
104
+ return classifyCodexRouting(readFileSync(path, "utf8"));
105
+ } catch {
106
+ return "unknown";
107
+ }
108
+ }
109
+