@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
3
+ import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
+ import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy";
4
5
  import { COMBO_NAMESPACE } from "../combos";
5
6
  import { getAuthStorePath } from "../oauth/store";
6
7
  import type { OcxConfig } from "../types";
@@ -41,6 +42,7 @@ import {
41
42
  import {
42
43
  buildCatalogEntriesFromObservedState,
43
44
  CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
45
+ finalizeAutoReviewModelOverride,
44
46
  mergeCatalogEntriesFromObservedState,
45
47
  mergeCatalogModelsWithNativeRecovery,
46
48
  orderForSubagents,
@@ -54,6 +56,7 @@ import {
54
56
  disabledNativeSlugs,
55
57
  desktopAllowlistSuppressedNativeSlugs,
56
58
  NATIVE_OPENAI_MODELS,
59
+ nativeContextLimits,
57
60
  shouldIncludeAccountBoundNativeOpenAi,
58
61
  shouldIncludeNativeOpenAi,
59
62
  } from "./catalog/metadata";
@@ -134,6 +137,7 @@ interface CandidateState {
134
137
  readonly changed: boolean;
135
138
  readonly notices: readonly CatalogNotice[];
136
139
  readonly modelEntitlements: CodexModelEntitlementSnapshot;
140
+ readonly discoveryConfig?: OcxConfig;
137
141
  }
138
142
 
139
143
  const candidateStates = new WeakMap<object, CandidateState>();
@@ -286,6 +290,7 @@ function prepareCatalog(
286
290
  // selector-qualified rows when a live selector is configured.
287
291
  const observedNativeSlugs: string[] = [];
288
292
  const disabledNative = disabledNativeSlugs(config);
293
+ const openaiContextCap = nativeContextLimits(config);
289
294
  const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery(
290
295
  active?.models ?? catalog.models ?? [],
291
296
  [catalog.models ?? [], ...nativeRecoverySources],
@@ -304,6 +309,7 @@ function prepareCatalog(
304
309
  suppressedBareNativeSlugs,
305
310
  disabledNativeAccountSlugs: new Set(),
306
311
  multiAgentV2Enabled,
312
+ openaiContextCap,
307
313
  });
308
314
  const accountBoundEntries = accountSelectors.length === 0
309
315
  ? []
@@ -320,6 +326,7 @@ function prepareCatalog(
320
326
  disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))),
321
327
  multiAgentV2Enabled,
322
328
  keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
329
+ openaiContextCap,
323
330
  accountNativeSlugs,
324
331
  accountNativeSlugsBySelector,
325
332
  }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined);
@@ -352,6 +359,7 @@ function prepareCatalog(
352
359
  includeNativeOpenAi,
353
360
  accountBoundEntries,
354
361
  suppressedBareNativeSlugs,
362
+ openaiContextCap,
355
363
  policy: {
356
364
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
357
365
  nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
@@ -364,6 +372,7 @@ function prepareCatalog(
364
372
  ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog)
365
373
  : null,
366
374
  );
375
+ finalizeAutoReviewModelOverride(mergedModels, catalogModels);
367
376
  catalog.models = mergedModels;
368
377
  return catalog;
369
378
  }
@@ -431,8 +440,17 @@ export async function gatherCodexCatalogCandidate(
431
440
  ? active
432
441
  : !hasRoutedEntries(source.catalog) ? source.catalog : null)
433
442
  : null);
443
+ const discoveryConfig = structuredClone(snapshot.config) as OcxConfig;
444
+ const discoveryChanged = reconcileSuccessfulModelDiscoveries({
445
+ config: discoveryConfig,
446
+ models: routedModels,
447
+ authoritativeProviders: providerModelOutcomes
448
+ .filter(outcome => outcome.state === "authoritative")
449
+ .map(outcome => outcome.provider),
450
+ now: new Date().toISOString(),
451
+ });
434
452
  const preparedCatalog = prepareCatalog(
435
- snapshot.config,
453
+ discoveryConfig,
436
454
  source,
437
455
  active,
438
456
  routedModels,
@@ -497,6 +515,7 @@ export async function gatherCodexCatalogCandidate(
497
515
  || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
498
516
  notices: Object.freeze([...notices]),
499
517
  modelEntitlements,
518
+ ...(discoveryChanged ? { discoveryConfig } : {}),
500
519
  });
501
520
  return { kind: "candidate", candidate };
502
521
  } catch (error) {
@@ -644,6 +663,12 @@ export async function convergeCodexCatalog(
644
663
  const state = candidateStates.get(gathered.candidate as object)!;
645
664
  lifecycle.onCommitBegin?.();
646
665
  const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
666
+ if (committed.kind === "committed" && state.discoveryConfig) {
667
+ const mutable = snapshot.config as OcxConfig;
668
+ mutable.modelDiscovery = state.discoveryConfig.modelDiscovery;
669
+ mutable.disabledModels = state.discoveryConfig.disabledModels;
670
+ saveConfigPreservingClaudeCode(mutable);
671
+ }
647
672
  return {
648
673
  changed: committed.kind === "committed" ? committed.changed : false,
649
674
  catalogRefresh: projectCommit(committed, state.notices),
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Design record: devlog/_fin/260803_codex_desktop_toggle/030_desired_state.md.
22
22
  */
23
- import { loadConfig, mutatePersistedConfig } from "../config";
23
+ import { deleteConfigTopLevelKey, loadConfig, mutatePersistedConfig } from "../config";
24
24
  import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
25
25
  import { runStartupReadinessSync, type ReadinessGate, type SyncOutcomeLike } from "../server/readiness";
26
26
 
@@ -114,7 +114,7 @@ export function setIntegrationEnabled(
114
114
  }
115
115
  // Drop the key entirely once nothing is left in it, so enabling twice does
116
116
  // not leave `"clientIntegrations": {}` behind in the user's file.
117
- if (Object.keys(integrations).length === 0) delete config.clientIntegrations;
117
+ if (Object.keys(integrations).length === 0) deleteConfigTopLevelKey(config, "clientIntegrations");
118
118
  else config.clientIntegrations = integrations;
119
119
  return { changed: true, value: enabled };
120
120
  });
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Full restart of the Codex desktop app (the Electron shell), Windows only.
3
+ *
4
+ * `--restart-codex` deliberately signals only `codex app-server` /
5
+ * `codex-code-mode-host` processes: `isCodexAppServerCommandLine` requires a
6
+ * `codex` executable token, so the shell that owns the model picker is never a
7
+ * match. On macOS that is enough, because the respawned app-server re-emits
8
+ * `codex-app-server-initialized` and the renderer drops its cached
9
+ * `model/list`. On Windows MSIX it is not: externally terminating the child
10
+ * does not reliably re-emit that event in the surviving shell, so the picker
11
+ * keeps showing the old catalog until the app itself is restarted (#2292).
12
+ *
13
+ * This is therefore a SEPARATE opt-in flag rather than a widening of
14
+ * `--restart-codex`. Quitting the desktop app ends live conversations, which is
15
+ * a different consent from restarting a background helper, and the CLI contract
16
+ * for `--restart-codex` promises the narrow behavior.
17
+ *
18
+ * Everything here fails CLOSED: if the package cannot be identified, if a
19
+ * target is part of our own ancestry, or if any target survives termination,
20
+ * nothing is relaunched and the caller is told to restart manually. A stale
21
+ * picker is a much smaller problem than a wrongly killed process.
22
+ */
23
+ import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation";
24
+ import { execFileSync } from "node:child_process";
25
+
26
+ /** Bounded subprocess options. A hung Appx/CIM probe must never wedge `ocx sync`. */
27
+ export interface DesktopAppExecOptions {
28
+ timeout?: number;
29
+ windowsHide?: boolean;
30
+ }
31
+
32
+ export interface DesktopAppRestartIo {
33
+ platform?: NodeJS.Platform;
34
+ /** Returns stdout. Options are part of the seam so the timeout is testable. */
35
+ execFile?: (file: string, args: readonly string[], options?: DesktopAppExecOptions) => string;
36
+ /** Process ancestry of the current process, innermost first. Used for the self-kill guard. */
37
+ ancestryPids?: () => number[];
38
+ isAlive?: (pid: number) => boolean;
39
+ sleep?: (ms: number) => void;
40
+ now?: () => number;
41
+ }
42
+
43
+ export type DesktopAppRestartReason =
44
+ | "windows_only"
45
+ | "package_discovery_failed"
46
+ | "process_probe_failed"
47
+ | "no_targets"
48
+ | "self_ancestry"
49
+ | "targets_survived";
50
+
51
+ export interface DesktopAppRestartResult {
52
+ attempted: boolean;
53
+ stopped: number[];
54
+ surviving: number[];
55
+ relaunch: "started" | "skipped";
56
+ reason?: DesktopAppRestartReason;
57
+ }
58
+
59
+ /** How long a graceful close is given before the forced pass. */
60
+ const GRACEFUL_EXIT_TIMEOUT_MS = 15_000;
61
+ /** How long a forced kill is given before the target counts as surviving. */
62
+ const FORCED_EXIT_TIMEOUT_MS = 5_000;
63
+ /** Every probe is bounded; PowerShell module loading is the slow part. */
64
+ const PROBE_TIMEOUT_MS = 10_000;
65
+
66
+ interface DesktopPackage {
67
+ family: string;
68
+ installLocation: string;
69
+ aumid: string;
70
+ }
71
+
72
+ /**
73
+ * Runtime discovery, never a hardcoded identifier. The beta MSIX package family
74
+ * changes between builds, so a literal AUMID would silently stop matching and
75
+ * then either do nothing or — worse — match a package we did not mean.
76
+ */
77
+ function discoverPackage(exec: NonNullable<DesktopAppRestartIo["execFile"]>): DesktopPackage | null {
78
+ const script = [
79
+ "$ErrorActionPreference='SilentlyContinue'",
80
+ "Import-Module Appx -ErrorAction SilentlyContinue",
81
+ "$p = Get-AppxPackage -Name OpenAI.Codex",
82
+ "if (-not $p) { $p = Get-AppxPackage -Name OpenAI.CodexBeta }",
83
+ "if (-not $p -or -not $p.InstallLocation) { 'MISS' } else {",
84
+ " $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"",
85
+ "}",
86
+ ].join("; ");
87
+ let stdout: string;
88
+ try {
89
+ stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
90
+ timeout: PROBE_TIMEOUT_MS,
91
+ windowsHide: true,
92
+ });
93
+ } catch {
94
+ return null;
95
+ }
96
+ const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0);
97
+ if (lines.length < 3 || lines[0] === "MISS") return null;
98
+ const [family, installLocation, aumid] = lines;
99
+ if (!family || !installLocation || !aumid) return null;
100
+ return { family, installLocation, aumid };
101
+ }
102
+
103
+ interface DesktopProcess {
104
+ pid: number;
105
+ parentPid: number;
106
+ /** Win32_Process CreationDate. Guards against PID reuse across the wait window. */
107
+ createdAt: string;
108
+ }
109
+
110
+ /**
111
+ * Only `ChatGPT.exe` processes whose image lives under the discovered install
112
+ * location AND owned by the current user. The install location alone is not
113
+ * enough: an MSIX package under `WindowsApps` is shared, so on a multi-user
114
+ * machine another account's Codex desktop matches the same path. The app-server
115
+ * collector already pays for `GetOwner` for exactly this reason.
116
+ *
117
+ * `CreationDate` is captured so a PID can be re-verified before it is signalled;
118
+ * a graceful-close window is long enough for Windows to recycle a PID.
119
+ */
120
+ function listPackageProcesses(
121
+ exec: NonNullable<DesktopAppRestartIo["execFile"]>,
122
+ installLocation: string,
123
+ ): DesktopProcess[] | null {
124
+ const literal = installLocation.replace(/'/g, "''");
125
+ const script = [
126
+ "$ErrorActionPreference='SilentlyContinue'",
127
+ `$root = '${literal}'`,
128
+ "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
129
+ "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
130
+ " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
131
+ " ForEach-Object {",
132
+ " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
133
+ " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",
134
+ " $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }",
135
+ " if ($owner -ieq $me) {",
136
+ " \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))\"",
137
+ " }",
138
+ " }",
139
+ " }",
140
+ // Statements must be newline-separated. Joining with a space concatenates
141
+ // `$ErrorActionPreference='SilentlyContinue' $root = '...'` into one malformed statement,
142
+ // which PowerShell rejects — so the probe threw and every caller read "not running" (#2557).
143
+ ].join("\n");
144
+ let stdout: string;
145
+ try {
146
+ stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
147
+ timeout: PROBE_TIMEOUT_MS,
148
+ windowsHide: true,
149
+ });
150
+ } catch {
151
+ // A probe that could not run is NOT proof the app is absent. Returning [] here made a
152
+ // failed enumeration indistinguishable from "no targets", so the CLI reported the app as
153
+ // not running and skipped a restart the user had explicitly asked for.
154
+ return null;
155
+ }
156
+ const processes: DesktopProcess[] = [];
157
+ for (const line of stdout.split(/\r?\n/)) {
158
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
159
+ if (!match) continue;
160
+ const pid = Number(match[1]);
161
+ const parentPid = Number(match[2]);
162
+ const createdAt = match[3]!;
163
+ if (Number.isSafeInteger(pid) && Number.isSafeInteger(parentPid)) {
164
+ processes.push({ pid, parentPid, createdAt });
165
+ }
166
+ }
167
+ return processes;
168
+ }
169
+
170
+ /**
171
+ * True when the PID still names the same process we verified. Between listing
172
+ * and signalling there is a graceful-close window, and a `taskkill /T /F` on a
173
+ * recycled PID would tear down an unrelated process tree.
174
+ */
175
+ function stillSameProcess(
176
+ exec: NonNullable<DesktopAppRestartIo["execFile"]>,
177
+ installLocation: string,
178
+ target: DesktopProcess,
179
+ ): boolean {
180
+ const processes = listPackageProcesses(exec, installLocation);
181
+ // Fail CLOSED on a failed re-probe: this guards a kill, and "we could not look" must not be
182
+ // read as "the pid was recycled and is now someone else's process".
183
+ if (processes === null) return false;
184
+ const current = processes.find(p => p.pid === target.pid);
185
+ return current !== undefined && current.createdAt === target.createdAt;
186
+ }
187
+
188
+ /** Roots are the package processes whose parent is not itself in the package tree. */
189
+ function rootProcesses(processes: readonly DesktopProcess[]): DesktopProcess[] {
190
+ const inTree = new Set(processes.map(p => p.pid));
191
+ return processes.filter(p => !inTree.has(p.parentPid));
192
+ }
193
+
194
+ /**
195
+ * Full Windows parent chain for this process, innermost first.
196
+ *
197
+ * `process.ppid` is one level, which is not enough: a terminal hosted inside the
198
+ * desktop app sits several hops below `ChatGPT.exe`, so a one-level check would
199
+ * miss the exact case the guard exists for and we would terminate our own host.
200
+ * The chain therefore comes from CIM, with a bound so a corrupted parent cycle
201
+ * cannot spin.
202
+ */
203
+ function windowsAncestryPids(exec: NonNullable<DesktopAppRestartIo["execFile"]>): number[] {
204
+ const chain: number[] = [process.pid];
205
+ let current = process.pid;
206
+ for (let hop = 0; hop < 16; hop++) {
207
+ let stdout: string;
208
+ try {
209
+ stdout = exec(resolveTrustedWindowsPowerShellExe(), [
210
+ "-NoProfile", "-NonInteractive", "-Command",
211
+ `$ErrorActionPreference='SilentlyContinue'; (Get-CimInstance Win32_Process -Filter "ProcessId=${current}").ParentProcessId`,
212
+ ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
213
+ } catch {
214
+ // An unreadable chain must not be read as "not our ancestor".
215
+ return [];
216
+ }
217
+ const parent = Number(stdout.trim());
218
+ if (!Number.isSafeInteger(parent) || parent <= 0 || chain.includes(parent)) break;
219
+ chain.push(parent);
220
+ current = parent;
221
+ }
222
+ return chain;
223
+ }
224
+
225
+ function defaultExecFile(file: string, args: readonly string[], options?: DesktopAppExecOptions): string {
226
+ return execFileSync(file, [...args], {
227
+ encoding: "utf-8",
228
+ timeout: options?.timeout ?? PROBE_TIMEOUT_MS,
229
+ windowsHide: options?.windowsHide ?? true,
230
+ });
231
+ }
232
+
233
+ function defaultIsAlive(pid: number): boolean {
234
+ try {
235
+ process.kill(pid, 0);
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
242
+ function defaultSleep(ms: number): void {
243
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
244
+ }
245
+
246
+ function waitForExit(
247
+ pid: number,
248
+ timeoutMs: number,
249
+ isAlive: (pid: number) => boolean,
250
+ sleep: (ms: number) => void,
251
+ now: () => number,
252
+ ): boolean {
253
+ const deadline = now() + timeoutMs;
254
+ while (now() < deadline) {
255
+ if (!isAlive(pid)) return true;
256
+ sleep(250);
257
+ }
258
+ return !isAlive(pid);
259
+ }
260
+
261
+ /**
262
+ * Stop every package-tree root gracefully, force the stragglers, then relaunch
263
+ * through the discovered AUMID. Returns without relaunching if anything
264
+ * survived, because launching a second shell beside a stuck one is worse than
265
+ * leaving the user to restart it.
266
+ */
267
+ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult {
268
+ const platform = io.platform ?? process.platform;
269
+ const skipped = (reason: DesktopAppRestartReason): DesktopAppRestartResult => ({
270
+ attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason,
271
+ });
272
+ if (platform !== "win32") return skipped("windows_only");
273
+
274
+ const exec = io.execFile ?? defaultExecFile;
275
+ const pkg = discoverPackage(exec);
276
+ if (!pkg) return skipped("package_discovery_failed");
277
+
278
+ const processes = listPackageProcesses(exec, pkg.installLocation);
279
+ // A probe that could not run is not evidence of absence. Reporting it as `no_targets` told
280
+ // the user the app was not running and silently skipped the restart they asked for (#2557).
281
+ if (processes === null) return skipped("process_probe_failed");
282
+ const roots = rootProcesses(processes);
283
+ if (roots.length === 0) return skipped("no_targets");
284
+
285
+ const ancestryPids = io.ancestryPids ? io.ancestryPids() : windowsAncestryPids(exec);
286
+ if (ancestryPids.length === 0) {
287
+ // Fail closed: an unreadable ancestry chain cannot prove we are outside the
288
+ // tree we are about to terminate.
289
+ return skipped("self_ancestry");
290
+ }
291
+ const ancestry = new Set(ancestryPids);
292
+ if (processes.some(p => ancestry.has(p.pid))) {
293
+ // Terminating our own tree would kill this command mid-flight and leave the
294
+ // user with neither a restarted app nor an explanation.
295
+ return skipped("self_ancestry");
296
+ }
297
+
298
+ const isAlive = io.isAlive ?? defaultIsAlive;
299
+ const sleep = io.sleep ?? defaultSleep;
300
+ const now = io.now ?? (() => Date.now());
301
+ const stopped: number[] = [];
302
+ const surviving: number[] = [];
303
+
304
+ for (const root of roots) {
305
+ const pid = root.pid;
306
+ // Re-verify immediately before the graceful close: the listing is already
307
+ // one probe old.
308
+ if (!stillSameProcess(exec, pkg.installLocation, root)) {
309
+ stopped.push(pid);
310
+ continue;
311
+ }
312
+ try {
313
+ exec(resolveTrustedWindowsPowerShellExe(), [
314
+ "-NoProfile", "-NonInteractive", "-Command",
315
+ `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { [void]$p.CloseMainWindow() }`,
316
+ ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
317
+ } catch {
318
+ /* a refused graceful close still gets the forced pass below */
319
+ }
320
+ if (waitForExit(pid, GRACEFUL_EXIT_TIMEOUT_MS, isAlive, sleep, now)) {
321
+ stopped.push(pid);
322
+ continue;
323
+ }
324
+ // The wait window is long enough for Windows to recycle a PID, and the next
325
+ // step is `/T /F` against a whole tree. Confirm the PID is still the process
326
+ // we verified, or leave it alone.
327
+ if (!stillSameProcess(exec, pkg.installLocation, root)) {
328
+ stopped.push(pid);
329
+ continue;
330
+ }
331
+ try {
332
+ exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"], {
333
+ timeout: PROBE_TIMEOUT_MS, windowsHide: true,
334
+ });
335
+ } catch {
336
+ /* fall through to the liveness check: the process state decides, not the exit code */
337
+ }
338
+ if (waitForExit(pid, FORCED_EXIT_TIMEOUT_MS, isAlive, sleep, now)) stopped.push(pid);
339
+ else surviving.push(pid);
340
+ }
341
+
342
+ if (surviving.length > 0) {
343
+ return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
344
+ }
345
+
346
+ try {
347
+ exec(resolveTrustedWindowsPowerShellExe(), [
348
+ "-NoProfile", "-NonInteractive", "-Command",
349
+ `Start-Process 'shell:AppsFolder\\${pkg.aumid}'`,
350
+ ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
351
+ } catch {
352
+ return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
353
+ }
354
+ return { attempted: true, stopped, surviving, relaunch: "started" };
355
+ }
@@ -109,7 +109,8 @@ export type CodexHistoryJobOutcome =
109
109
  | { readonly kind: "skipped" }
110
110
  | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
111
111
  | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
112
- readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason };
112
+ readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason;
113
+ readonly rows?: number; readonly files?: number };
113
114
 
114
115
  /**
115
116
  * Derive the durable history operation from admitted intent.
@@ -174,7 +175,14 @@ function isPlausibleWorkerResult(
174
175
  || message.reason === "desired_disabled" || message.reason === "desired_enabled";
175
176
  case "error":
176
177
  return typeof message.message === "string"
177
- && (message.reason === undefined || message.reason === "busy" || message.reason === "permission");
178
+ && (message.rows === undefined || (Number.isSafeInteger(message.rows) && Number(message.rows) >= 0))
179
+ && (message.files === undefined || (Number.isSafeInteger(message.files) && Number(message.files) >= 0))
180
+ && ((message.rows === undefined && message.files === undefined)
181
+ || (message.rows !== undefined && message.files !== undefined))
182
+ && (message.reason === undefined
183
+ || message.reason === "busy"
184
+ || message.reason === "permission"
185
+ || message.reason === "integrity");
178
186
  default:
179
187
  return false;
180
188
  }
@@ -224,6 +232,7 @@ export function describeHistoryJobFailure(
224
232
  : surface === "recover-legacy"
225
233
  ? "the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command."
226
234
  : "the Codex app appears to be holding the history database. Close Codex and run `ocx restore` again.";
235
+ const busyStateText = "Codex history state is busy (database, backup manifest, or rollout file); this is not enough evidence to blame the Codex app. It is retried automatically while the proxy runs; run 'ocx doctor' before forcing another attempt.";
227
236
  if (outcome.kind === "blocked") {
228
237
  if (outcome.reason === "busy") return busyText;
229
238
  switch (outcome.reason) {
@@ -237,10 +246,22 @@ export function describeHistoryJobFailure(
237
246
  return "Codex integration is enabled, so the history operation was skipped.";
238
247
  }
239
248
  }
240
- if (outcome.historyFailureReason === "busy") return busyText;
249
+ const partiallyChanged = (outcome.rows ?? 0) > 0 || (outcome.files ?? 0) > 0;
250
+ if (partiallyChanged && outcome.historyFailureReason === "busy") {
251
+ return "Codex history metadata changed but did not converge because manifest finalization remained busy; the manifest was retained for review and safe retry. Run 'ocx doctor'.";
252
+ }
253
+ if (partiallyChanged && outcome.historyFailureReason === "permission") {
254
+ return "Codex history metadata changed but did not converge because permission was denied while finalizing the manifest; the manifest was retained for review and safe retry. Run 'ocx doctor'.";
255
+ }
256
+ if (outcome.historyFailureReason === "busy") return busyStateText;
241
257
  if (outcome.historyFailureReason === "permission") {
242
258
  return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'.";
243
259
  }
260
+ if (outcome.historyFailureReason === "integrity") {
261
+ return partiallyChanged
262
+ ? "the history backup or its restore target changed after a partial restore; the manifest was retained for review and safe retry. Run 'ocx doctor'."
263
+ : "the history backup or its restore target failed integrity checks; no unverified provider metadata was applied. Run 'ocx doctor'.";
264
+ }
244
265
  switch (outcome.reason) {
245
266
  case "worker-error":
246
267
  return `the history worker failed (${outcome.message}). Run 'ocx doctor'.`;
@@ -277,6 +298,9 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
277
298
  reason: "worker-error",
278
299
  message: redactWorkerMessage(result.message),
279
300
  ...(result.reason ? { historyFailureReason: result.reason } : {}),
301
+ ...(result.rows !== undefined && result.files !== undefined
302
+ ? { rows: result.rows, files: result.files }
303
+ : {}),
280
304
  };
281
305
  }
282
306
  return result.outcome === "skipped"
@@ -284,6 +308,11 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
284
308
  : { kind: "converged", rows: result.rows, files: result.files, ...(result.proof ? { proof: result.proof } : {}) };
285
309
  }
286
310
 
311
+ /** Test seam for the parent-side Worker result classification contract. */
312
+ export function classifyWorkerResultForTests(result: HistoryWorkerResult): CodexHistoryJobOutcome {
313
+ return classifyWorkerResult(result);
314
+ }
315
+
287
316
  /**
288
317
  * Run one history unit in a Worker and join it before returning.
289
318
  *
@@ -0,0 +1,112 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, resolve } from "node:path";
3
+
4
+ /** Sources whose native OpenAI provenance can be restored exactly. */
5
+ export const CODEX_HISTORY_RESUMABLE_SOURCES = ["cli", "vscode"] as const;
6
+
7
+ export interface CodexHistoryBackupEntry {
8
+ id: string;
9
+ rolloutPath: string;
10
+ modelProvider: string;
11
+ source: string;
12
+ hasUserEvent: 0 | 1;
13
+ }
14
+
15
+ export interface CodexHistoryBackupManifest {
16
+ version: 1;
17
+ stateDbPath: string;
18
+ entries: Record<string, CodexHistoryBackupEntry>;
19
+ }
20
+
21
+ export type CodexHistoryManifestValidation =
22
+ | { readonly ok: true; readonly manifest: CodexHistoryBackupManifest }
23
+ | { readonly ok: false; readonly reason: "foreign-database" }
24
+ | {
25
+ readonly ok: false;
26
+ readonly reason: "schema";
27
+ readonly scope: "manifest" | "entry-shape" | "entry-provenance";
28
+ };
29
+
30
+ function codexHistoryPathIdentity(path: string): string {
31
+ const canonical = resolve(path);
32
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
33
+ }
34
+
35
+ /**
36
+ * Canonical identity used by both backup naming and manifest ownership checks.
37
+ * Windows paths are case-insensitive for this contract; other platforms keep
38
+ * the resolved path's case.
39
+ */
40
+ export function codexHistoryStateDbIdentity(path: string): string {
41
+ return codexHistoryPathIdentity(path);
42
+ }
43
+
44
+ /** Stable, non-secret file-name component for one state database. */
45
+ export function codexHistoryBackupId(stateDbPath: string): string {
46
+ return createHash("sha256")
47
+ .update(codexHistoryStateDbIdentity(stateDbPath))
48
+ .digest("hex")
49
+ .slice(0, 16);
50
+ }
51
+
52
+ /** Platform-aware identity comparison shared by database and rollout checks. */
53
+ export function sameCodexHistoryPath(left: string, right: string): boolean {
54
+ return codexHistoryPathIdentity(left) === codexHistoryPathIdentity(right);
55
+ }
56
+
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ function hasAllowedProvenance(entry: Record<string, unknown>): boolean {
62
+ return (entry.modelProvider === "openai"
63
+ && CODEX_HISTORY_RESUMABLE_SOURCES.includes(
64
+ entry.source as (typeof CODEX_HISTORY_RESUMABLE_SOURCES)[number],
65
+ ))
66
+ || (entry.modelProvider === "opencodex" && entry.source === "exec");
67
+ }
68
+
69
+ /**
70
+ * Validate only the versioned data contract and database ownership identity.
71
+ * Filesystem type checks, reads, fingerprints, rollout inspection, SQLite, and
72
+ * mutation deliberately remain with the callers.
73
+ */
74
+ export function validateCodexHistoryBackupManifest(
75
+ raw: unknown,
76
+ expectedStateDbPath: string,
77
+ ): CodexHistoryManifestValidation {
78
+ if (!isRecord(raw)
79
+ || raw.version !== 1
80
+ || typeof raw.stateDbPath !== "string"
81
+ || !raw.stateDbPath.trim()
82
+ || !isAbsolute(raw.stateDbPath)
83
+ || !isRecord(raw.entries)) {
84
+ return { ok: false, reason: "schema", scope: "manifest" };
85
+ }
86
+ if (!sameCodexHistoryPath(raw.stateDbPath, expectedStateDbPath)) {
87
+ return { ok: false, reason: "foreign-database" };
88
+ }
89
+
90
+ for (const [id, value] of Object.entries(raw.entries)) {
91
+ if (!isRecord(value)) {
92
+ return { ok: false, reason: "schema", scope: "entry-shape" };
93
+ }
94
+ if (!id
95
+ || value.id !== id
96
+ || typeof value.rolloutPath !== "string"
97
+ || !value.rolloutPath.trim()
98
+ || !isAbsolute(value.rolloutPath)
99
+ || typeof value.modelProvider !== "string"
100
+ || !value.modelProvider.trim()
101
+ || typeof value.source !== "string"
102
+ || !value.source.trim()
103
+ || typeof value.hasUserEvent !== "number"
104
+ || !Number.isSafeInteger(value.hasUserEvent)
105
+ || (value.hasUserEvent !== 0 && value.hasUserEvent !== 1)
106
+ || !hasAllowedProvenance(value)) {
107
+ return { ok: false, reason: "schema", scope: "entry-provenance" };
108
+ }
109
+ }
110
+
111
+ return { ok: true, manifest: raw as unknown as CodexHistoryBackupManifest };
112
+ }