@bitkyc08/opencodex 2.58.0 → 2.60.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -0,0 +1,224 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Library, Symbols } from "bun:ffi";
3
+
4
+ const KERNEL_SYMBOLS = {
5
+ GetDriveTypeW: { args: ["ptr"], returns: "u32" },
6
+ GetVolumeNameForVolumeMountPointW: { args: ["ptr", "ptr", "u32"], returns: "i32" },
7
+ CreateFileW: { args: ["ptr", "u32", "u32", "ptr", "u32", "u32", "u64"], returns: "u64" },
8
+ GetFileInformationByHandleEx: { args: ["u64", "i32", "ptr", "u32"], returns: "i32" },
9
+ GetFileType: { args: ["u64"], returns: "u32" },
10
+ ReadFile: { args: ["u64", "ptr", "u32", "ptr", "ptr"], returns: "i32" },
11
+ CloseHandle: { args: ["u64"], returns: "i32" },
12
+ } as const satisfies Symbols;
13
+ const NT_SYMBOLS = {
14
+ NtCreateFile: { args: ["ptr", "u32", "ptr", "ptr", "ptr", "u32", "u32", "u32", "u32", "ptr", "u32"], returns: "i32" },
15
+ } as const satisfies Symbols;
16
+
17
+ export interface WindowsInstallationFileRequest {
18
+ readonly path: string;
19
+ readonly maxBytes: number;
20
+ readonly hashOnly?: boolean;
21
+ }
22
+ export interface WindowsInstallationFileIdentity {
23
+ readonly volumeSerial: string;
24
+ readonly fileId: string;
25
+ readonly size: number;
26
+ readonly lastWriteTime: string;
27
+ readonly changeTime: string;
28
+ }
29
+ export type WindowsInstallationFilesResult =
30
+ | { kind: "observed"; files: {
31
+ path: string; identity: WindowsInstallationFileIdentity; bytes: Uint8Array; digest: string;
32
+ }[] }
33
+ | { kind: "refused"; reason: "unsupported-platform" | "invalid-request" | "native-api-unavailable"
34
+ | "volume-unavailable" | "open-refused" | "reparse-point" | "not-regular-file"
35
+ | "size-limit" | "read-failed" | "identity-changed" | "inspection-failed" };
36
+
37
+ type Refusal = Extract<WindowsInstallationFilesResult, { kind: "refused" }>["reason"];
38
+ class InspectionRefusal extends Error {
39
+ constructor(readonly reason: Refusal) { super(reason); }
40
+ }
41
+ const MIB = 1024 * 1024;
42
+ const REPARSE = 0x400;
43
+ const DIRECTORY = 0x10;
44
+ const INVALID_HANDLE = 0xffffffffffffffffn;
45
+ let openedForTests: (() => void) | undefined;
46
+ /** Runs after handles are held, for real Windows sharing/rename regression fixtures. */
47
+ export function setWindowsInstallationFilesOpenedForTests(callback?: () => void): void {
48
+ openedForTests = callback;
49
+ }
50
+
51
+ function components(path: string): { drive: string; names: string[] } | null {
52
+ if (typeof path !== "string" || path.length > 8192 || !/^[A-Za-z]:[\\/]/.test(path)) return null;
53
+ const names = path.slice(3).split(/[\\/]/);
54
+ if (!names.length || names.length > 64 || names.some(name => !name || name === "." || name === ".."
55
+ || /[\x00-\x1f<>:"|?*]/.test(name) || /[. ]$/.test(name)
56
+ || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(name))) return null;
57
+ return { drive: `${path[0]!.toUpperCase()}:\\`, names };
58
+ }
59
+
60
+ /**
61
+ * An explicit, read-only snapshot; never installation authority or a runtime-selection proof.
62
+ * All component opens are relative to held directory handles, not checked-then-reopened paths.
63
+ * Windows x64 only: the UNICODE_STRING/OBJECT_ATTRIBUTES layouts below are the x64 ABI.
64
+ * No candidate runs and no shell/compiler is started. Disk/driver latency is not a hard deadline.
65
+ * API contracts: learn.microsoft.com/windows/win32/api/winternl/nf-winternl-ntcreatefile
66
+ * and /windows/win32/api/fileapi/nf-fileapi-getvolumenameforvolumemountpointw.
67
+ */
68
+ export async function inspectWindowsInstallationFiles(
69
+ requests: readonly WindowsInstallationFileRequest[],
70
+ ): Promise<WindowsInstallationFilesResult> {
71
+ if (!Array.isArray(requests) || requests.length === 0 || requests.length > 12) {
72
+ return { kind: "refused", reason: "invalid-request" };
73
+ }
74
+ let ceiling = 0;
75
+ const parsed = requests.map(request => {
76
+ const parsedPath = request && components(request.path);
77
+ if (!parsedPath || !Number.isSafeInteger(request.maxBytes) || request.maxBytes < 0
78
+ || (request.hashOnly !== undefined && typeof request.hashOnly !== "boolean")
79
+ || request.maxBytes > (request.hashOnly ? 256 * MIB : MIB)) return null;
80
+ ceiling += request.maxBytes;
81
+ return parsedPath;
82
+ });
83
+ if (parsed.some(path => path === null) || ceiling > 300 * MIB) {
84
+ return { kind: "refused", reason: "invalid-request" };
85
+ }
86
+ if (process.platform !== "win32" || process.arch !== "x64") {
87
+ return { kind: "refused", reason: "unsupported-platform" };
88
+ }
89
+ // Loading FFI is itself opt-in; importing this module performs no Windows inspection.
90
+ let ffi: typeof import("bun:ffi");
91
+ try { ffi = await import("bun:ffi"); } catch { return { kind: "refused", reason: "native-api-unavailable" }; }
92
+ let kernel: Library<typeof KERNEL_SYMBOLS> | undefined;
93
+ let nt: Library<typeof NT_SYMBOLS> | undefined;
94
+ const handles: bigint[] = [];
95
+ try {
96
+ try {
97
+ kernel = ffi.dlopen("kernel32.dll", KERNEL_SYMBOLS);
98
+ nt = ffi.dlopen("ntdll.dll", NT_SYMBOLS);
99
+ } catch { throw new InspectionRefusal("native-api-unavailable"); }
100
+ const k = kernel.symbols;
101
+ const wide = (text: string): Buffer => Buffer.from(`${text}\0`, "utf16le");
102
+ const keep = (handle: bigint): bigint => {
103
+ if (handle === 0n || handle === INVALID_HANDLE) throw new InspectionRefusal("open-refused");
104
+ handles.push(handle);
105
+ return handle;
106
+ };
107
+ const info = (handle: bigint, infoClass: number, length: number): Buffer => {
108
+ const buffer = Buffer.alloc(length);
109
+ if (!k.GetFileInformationByHandleEx!(handle, infoClass, ffi.ptr(buffer), length)) {
110
+ throw new InspectionRefusal("inspection-failed");
111
+ }
112
+ return buffer;
113
+ };
114
+ const inspect = (handle: bigint, directory: boolean): WindowsInstallationFileIdentity => {
115
+ if (k.GetFileType!(handle) !== 1) throw new InspectionRefusal("not-regular-file"); // FILE_TYPE_DISK
116
+ const basic = info(handle, 0, 40); // FILE_BASIC_INFO: 4 LARGE_INTEGERs, attributes.
117
+ const attributes = basic.readUInt32LE(32);
118
+ if (attributes & REPARSE) throw new InspectionRefusal("reparse-point");
119
+ if (Boolean(attributes & DIRECTORY) !== directory) throw new InspectionRefusal("not-regular-file");
120
+ const id = info(handle, 18, 24); // FILE_ID_INFO: volume serial and 128-bit file ID.
121
+ const standard = info(handle, 1, 24); // FILE_STANDARD_INFO.EndOfFile
122
+ const size = standard.readBigInt64LE(8);
123
+ if (size < 0n || size > BigInt(Number.MAX_SAFE_INTEGER)) throw new InspectionRefusal("size-limit");
124
+ return { volumeSerial: id.readBigUInt64LE(0).toString(16), fileId: id.subarray(8, 24).toString("hex"),
125
+ size: Number(size), lastWriteTime: basic.readBigInt64LE(16).toString(), changeTime: basic.readBigInt64LE(24).toString() };
126
+ };
127
+ const roots = new Map<string, bigint>();
128
+ const root = (drive: string): bigint => {
129
+ const held = roots.get(drive);
130
+ if (held !== undefined) return held;
131
+ const mount = wide(drive);
132
+ const name = Buffer.alloc(100 * 2);
133
+ if (![2, 3, 5, 6].includes(Number(k.GetDriveTypeW!(ffi.ptr(mount))))) {
134
+ throw new InspectionRefusal("volume-unavailable");
135
+ }
136
+ // Root-only volume management query, never a candidate-controlled ancestor traversal.
137
+ // This API does not support SMB; only a strict local volume GUID reaches CreateFileW.
138
+ if (!k.GetVolumeNameForVolumeMountPointW!(ffi.ptr(mount), ffi.ptr(name), 100)) {
139
+ throw new InspectionRefusal("volume-unavailable");
140
+ }
141
+ const guid = name.toString("utf16le").split("\0", 1)[0]!;
142
+ if (!/^\\\\\?\\Volume\{[0-9a-f-]{36}\}\\$/i.test(guid)) throw new InspectionRefusal("volume-unavailable");
143
+ const path = wide(guid);
144
+ const handle = keep(BigInt(k.CreateFileW!(ffi.ptr(path), 0x100081, 1, null, 3, 0x02200000, 0n)));
145
+ inspect(handle, true);
146
+ roots.set(drive, handle);
147
+ return handle;
148
+ };
149
+ const relativeOpen = (parent: bigint, component: string, directory: boolean): bigint => {
150
+ const name = wide(component);
151
+ const unicode = Buffer.alloc(16);
152
+ unicode.writeUInt16LE(name.length - 2, 0);
153
+ unicode.writeUInt16LE(name.length, 2);
154
+ unicode.writeBigUInt64LE(BigInt(ffi.ptr(name)), 8);
155
+ const attributes = Buffer.alloc(48);
156
+ attributes.writeUInt32LE(48, 0);
157
+ attributes.writeBigUInt64LE(parent, 8);
158
+ attributes.writeBigUInt64LE(BigInt(ffi.ptr(unicode)), 16);
159
+ attributes.writeUInt32LE(0x40, 24); // OBJ_CASE_INSENSITIVE; one component only.
160
+ const output = Buffer.alloc(8);
161
+ const status = Buffer.alloc(16);
162
+ // FILE_OPEN; FILE_SYNCHRONOUS_IO_NONALERT; FILE_OPEN_REPARSE_POINT.
163
+ // Share READ only: while held, writes/reparse edits and delete/rename opens are refused.
164
+ const result = nt!.symbols.NtCreateFile!(ffi.ptr(output), 0x100081, ffi.ptr(attributes),
165
+ ffi.ptr(status), null, 0, 1, 1, 0x200020 | (directory ? 1 : 0), null, 0);
166
+ if (result < 0) throw new InspectionRefusal("open-refused");
167
+ const handle = keep(output.readBigUInt64LE(0));
168
+ inspect(handle, directory);
169
+ return handle;
170
+ };
171
+ const directories = new Map<string, bigint>();
172
+ const files = requests.map((request, index) => {
173
+ const { drive, names } = parsed[index]!;
174
+ let parent = root(drive);
175
+ let key = drive;
176
+ for (const component of names.slice(0, -1)) {
177
+ key += `${component}\\`;
178
+ let handle = directories.get(key);
179
+ if (handle === undefined) {
180
+ handle = relativeOpen(parent, component, true);
181
+ directories.set(key, handle);
182
+ }
183
+ parent = handle;
184
+ }
185
+ const handle = relativeOpen(parent, names[names.length - 1]!, false);
186
+ const identity = inspect(handle, false);
187
+ if (identity.size > request.maxBytes) throw new InspectionRefusal("size-limit");
188
+ return { request, handle, identity };
189
+ });
190
+ openedForTests?.();
191
+ const observed = files.map(({ request, handle, identity }) => {
192
+ const hash = createHash("sha256");
193
+ const bytes = request.hashOnly ? new Uint8Array() : new Uint8Array(identity.size);
194
+ const chunk = Buffer.alloc(Math.min(MIB, Math.max(1, identity.size)));
195
+ const read = Buffer.alloc(4);
196
+ let offset = 0;
197
+ while (offset < identity.size) {
198
+ const length = Math.min(chunk.length, identity.size - offset);
199
+ if (!k.ReadFile!(handle, ffi.ptr(chunk), length, ffi.ptr(read), null)) throw new InspectionRefusal("read-failed");
200
+ const count = read.readUInt32LE(0);
201
+ if (!count || count > length) throw new InspectionRefusal("read-failed");
202
+ hash.update(chunk.subarray(0, count));
203
+ if (!request.hashOnly) bytes.set(chunk.subarray(0, count), offset);
204
+ offset += count;
205
+ }
206
+ if (!k.ReadFile!(handle, ffi.ptr(chunk), 1, ffi.ptr(read), null) || read.readUInt32LE(0) !== 0) {
207
+ throw new InspectionRefusal("identity-changed");
208
+ }
209
+ return { path: request.path, identity, bytes, digest: hash.digest("hex") };
210
+ });
211
+ for (const file of files) {
212
+ if (JSON.stringify(inspect(file.handle, false)) !== JSON.stringify(file.identity)) {
213
+ throw new InspectionRefusal("identity-changed");
214
+ }
215
+ }
216
+ return { kind: "observed", files: observed };
217
+ } catch (error) {
218
+ return { kind: "refused", reason: error instanceof InspectionRefusal ? error.reason : "inspection-failed" };
219
+ } finally {
220
+ for (const handle of handles.reverse()) kernel?.symbols.CloseHandle?.(handle);
221
+ nt?.close();
222
+ kernel?.close();
223
+ }
224
+ }
@@ -118,23 +118,29 @@ function parseHttpDate(value: string, now: number): number | undefined {
118
118
  export function parseRetryAfterMs(
119
119
  value: string | null | undefined,
120
120
  now = Date.now(),
121
- options?: { preserveImmediate?: boolean },
121
+ options?: { preserveImmediate?: boolean; preserveServerDelay?: boolean },
122
122
  ): number | undefined {
123
123
  const text = value?.trim();
124
124
  if (!text) return undefined;
125
+ // A local wait ceiling must not make an explicit upstream reset expire early.
126
+ // Keep legacy bounded parsing for other callers. The opt-in stores a timestamp;
127
+ // the combo picker still independently limits how long a live request waits.
128
+ const maximum = options?.preserveServerDelay === true
129
+ ? Number.MAX_SAFE_INTEGER - Math.max(0, now)
130
+ : MAX_COOLDOWN_MS;
125
131
  if (/^\d+(?:\.\d+)?$/.test(text)) {
126
132
  const seconds = Number(text);
127
133
  if (
128
134
  Number.isFinite(seconds)
129
135
  && (seconds > 0 || (options?.preserveImmediate && seconds === 0))
130
136
  ) {
131
- return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
137
+ return Math.min(Math.max(Math.ceil(seconds * 1000), 1), maximum);
132
138
  }
133
139
  }
134
140
  const timestamp = parseHttpDate(text, now);
135
141
  if (timestamp === undefined) return undefined;
136
142
  const delay = timestamp - now;
137
- if (delay > 0) return Math.min(delay, MAX_COOLDOWN_MS);
143
+ if (delay > 0) return Math.min(delay, maximum);
138
144
  return options?.preserveImmediate ? 1 : undefined;
139
145
  }
140
146
 
@@ -215,7 +221,11 @@ export function coolComboTarget(
215
221
  // A server-provided Retry-After is authoritative, including an immediate `0` directive.
216
222
  // A quota reset is the next-most-specific signal (#3256); configured and default cooldowns
217
223
  // are only fallbacks when upstream supplied neither usable value.
218
- const cooldownMs = parseRetryAfterMs(options?.retryAfter, now, { preserveImmediate: true })
224
+ const serverDelayMs = parseRetryAfterMs(options?.retryAfter, now, {
225
+ preserveImmediate: true,
226
+ preserveServerDelay: true,
227
+ });
228
+ const cooldownMs = serverDelayMs
219
229
  ?? parseResetCooldownMs(options?.resetAt, now)
220
230
  ?? options?.cooldownMs
221
231
  ?? (isTransientRequestRateLimit({
@@ -224,7 +234,9 @@ export function coolComboTarget(
224
234
  message: options?.message,
225
235
  }) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
226
236
  targetCooldowns.set(cooldownMapKey(comboId, target), {
227
- cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS),
237
+ // Only the locally chosen fallback is capped at ten minutes. An explicit
238
+ // server lower bound (including one hour) remains authoritative.
239
+ cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
228
240
  });
229
241
  sweepExpiredOnWrite(now);
230
242
  }
@@ -387,6 +399,9 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co
387
399
  if (errorCode === "unsupported_value"
388
400
  && (e.param === "reasoning.effort" || e.param === "reasoning_effort")
389
401
  && e.message.startsWith("Unsupported value:") && e.message.includes("not supported")) return true;
402
+ if (e.code === null
403
+ && e.param === "reasoning_effort"
404
+ && /^Function tools with reasoning_effort are not supported for gpt-6-astra(?:-\d{4}-\d{2}-\d{2})? in \/v1\/chat\/completions\. To use function tools, use \/v1\/responses or set reasoning_effort to 'none'\.$/.test(e.message)) return true;
390
405
  return e.param === "input"
391
406
  && (errorCode === "" || errorCode === "invalid_request_error")
392
407
  && /^Model '[^']{1,256}' does not support image inputs\./.test(e.message);
@@ -394,6 +409,103 @@ function isRequestLocalTargetIncompatibility(status: number, message: string, co
394
409
  return false;
395
410
  }
396
411
 
412
+ /**
413
+ * Codes a gateway uses when it declines a request field it cannot serve.
414
+ *
415
+ * Wider than the set {@link isRequestLocalTargetIncompatibility} accepts, by exactly one member:
416
+ * Alibaba's gateway reports `invalid_parameter_error`. It is kept in its own set rather than
417
+ * added to the shared one, because that set also governs the `user` parameter and image-input
418
+ * branches and widening it there would admit shapes those branches were reasoned about without.
419
+ */
420
+ const RESPONSE_FORMAT_REFUSAL_CODES = new Set([
421
+ "",
422
+ "invalid_request_error",
423
+ "invalid_parameter_error",
424
+ "unsupported_parameter",
425
+ "unsupported_value",
426
+ ]);
427
+
428
+ /**
429
+ * Does this message say the target cannot PROVIDE `response_format`, rather than that the
430
+ * request's `response_format` was malformed?
431
+ *
432
+ * That distinction is the whole point of #4903 and it is why neither obvious option was taken.
433
+ * Hopping on every 400 would replay a genuinely malformed request against every remaining
434
+ * target. Dropping `response_format` would silently change the output contract the caller
435
+ * asked for, on a path whose entire purpose is a structured result.
436
+ *
437
+ * So both halves are required: the message must name the field, AND it must say the field is
438
+ * unavailable or unsupported. "Invalid schema for response_format" names the field and claims
439
+ * nothing about capability, so it stays terminal.
440
+ *
441
+ * `param` may be absent or explicitly null -- the reported gateway sends `param: null` -- but a
442
+ * param naming a DIFFERENT field contradicts the message and fails closed.
443
+ */
444
+ function namesResponseFormatIncapability(message: string, param: unknown): boolean {
445
+ if (param !== undefined && param !== null && param !== "response_format") return false;
446
+ const text = message.toLowerCase();
447
+ if (!text.includes("response_format")) return false;
448
+ return /(unavailable|not available|unsupported|not supported|does not support|doesn't support|cannot be used|is not enabled)/u
449
+ .test(text);
450
+ }
451
+
452
+ /**
453
+ * A `response_format` capability gap is target-local: this model cannot produce the requested
454
+ * output shape, which says nothing about the next target in the combo.
455
+ *
456
+ * Reported against a shadow title-generation call, where a combo's first target rejects
457
+ * `response_format` and the chain stops instead of trying the target behind it (#4903).
458
+ *
459
+ * The next target receives the SAME request, `response_format` included, so a target that can
460
+ * honour the contract honours it and one that cannot is skipped in turn. Traversal stays finite
461
+ * because combo excludes each attempted target and policy tries each candidate once.
462
+ *
463
+ * The envelope is bounded exactly like {@link isRequestLocalTargetIncompatibility}: an intact
464
+ * provider JSON object, a depth budget, `type: "invalid_request_error"`, and a code from a
465
+ * closed set. Nothing is inferred from echoed prompt text and no field is removed.
466
+ */
467
+ function isResponseFormatCapabilityRefusal(
468
+ status: number,
469
+ message: string,
470
+ code?: string | null,
471
+ ): boolean {
472
+ if (status !== 400 || message.length > 16_384) return false;
473
+ if (!RESPONSE_FORMAT_REFUSAL_CODES.has(normalizedFailureCode(code))) return false;
474
+ let text = message.trim();
475
+ for (let depth = 0; depth < 3; depth += 1) {
476
+ if (text.startsWith("Provider error 400: ")) {
477
+ text = text.slice("Provider error 400: ".length).trim();
478
+ }
479
+ // A chat gateway can report the refusal inside a single SSE frame, and the combo consumer
480
+ // keeps the raw text when that frame stops the error object from being extracted -- which is
481
+ // why the reported classification text reads `data: {"error":...}` and why the structured
482
+ // code arrives undefined. Exactly one `data:` prefix is removed, and only when the body is
483
+ // one line: this unwraps a single frame rather than parsing a stream, so a multi-event body
484
+ // is left alone and still fails closed.
485
+ if (text.startsWith("data:") && !text.includes("\n")) {
486
+ text = text.slice("data:".length).trim();
487
+ }
488
+ let payload: unknown;
489
+ try { payload = JSON.parse(text); } catch { return false; }
490
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
491
+ const error = (payload as Record<string, unknown>).error;
492
+ if (!error || typeof error !== "object" || Array.isArray(error)) return false;
493
+ const e = error as Record<string, unknown>;
494
+ if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false;
495
+ if (typeof e.message !== "string") return false;
496
+ // Our own wrapper, re-wrapped by a downstream hop. Peel it and look again, within budget.
497
+ if (e.message.startsWith("Provider error 400: ") && e.param === undefined) {
498
+ text = e.message;
499
+ continue;
500
+ }
501
+ if (e.type !== "invalid_request_error") return false;
502
+ const errorCode = normalizedFailureCode(typeof e.code === "string" ? e.code : undefined);
503
+ if (!RESPONSE_FORMAT_REFUSAL_CODES.has(errorCode)) return false;
504
+ return namesResponseFormatIncapability(e.message, e.param);
505
+ }
506
+ return false;
507
+ }
508
+
397
509
  export function comboFailureCooldownScope(
398
510
  status: number,
399
511
  message: string,
@@ -411,6 +523,9 @@ export function comboFailureCooldownScope(
411
523
  || isProviderTargetContextOverflow(status, message, options?.code)
412
524
  || isDefiniteContextOverflow(status, message)
413
525
  || isRequestLocalTargetIncompatibility(status, message, options?.code)
526
+ // A capability gap says the target is healthy and the request did not fit it, which is the
527
+ // same reason every other entry here refuses to cool a target.
528
+ || isResponseFormatCapabilityRefusal(status, message, options?.code)
414
529
  ) return "none";
415
530
  if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
416
531
  // A rejected or unpaid credential is provider-wide evidence: every target that routes
@@ -595,6 +710,11 @@ export function comboFailureDecision(
595
710
  // per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
596
711
  if (failureCode === "free_rate_limited") return "hop";
597
712
  if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop";
713
+ // Must precede the generic `invalid_request_error` stop below, which is where this refusal
714
+ // ended the chain: the gateway reports `type: "invalid_request_error"`, so the classifier
715
+ // reaches that list and returns terminal before anything can ask whether the next target
716
+ // could have served the request (#4903).
717
+ if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
598
718
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
599
719
  return "stop";
600
720
  }
@@ -0,0 +1,222 @@
1
+ import { readConfigAdmissionSnapshot } from "./diagnostics";
2
+ import { getConfigPath } from "./paths";
3
+ import { canonicalPlainData, copyPlainData, isPlainObject, ownDataKeys } from "../lib/plain-data";
4
+ import type { OcxConfig } from "../types";
5
+
6
+ /**
7
+ * A configuration a roster may be built from, detached from the object the caller holds.
8
+ *
9
+ * The detachment is the point. A gather suspends, and while it is suspended a management route can
10
+ * edit the resident configuration in place; gathering from one state and projecting from another
11
+ * would produce rows that belong to neither. Every authoritative pass uses this copy from
12
+ * beginning to end, so what the resident object does in the meantime becomes a question about
13
+ * whether the result may be retained rather than a question about what the result is.
14
+ *
15
+ * What this deliberately does NOT do is require the resident configuration to equal the file on
16
+ * disk. The proxy routes by the configuration it is holding, so that is the configuration a
17
+ * preview and the mutation it authorizes must both describe. A file the operator has edited and
18
+ * the process has not adopted is a supported state rather than a fault: live reconciliation
19
+ * (src/config/live-reconcile.ts) merges persisted state while deliberately retaining live changes
20
+ * and the active listener binding, and can persist a binding the resident object does not have.
21
+ * Demanding equality would make preview permanently unavailable on exactly those configurations
22
+ * while proving nothing about the rows, which come from the resident object either way.
23
+ */
24
+ export interface ExportConfigAdmission {
25
+ readonly config: OcxConfig;
26
+ }
27
+
28
+ /**
29
+ * What was true when an admission was captured, kept here rather than on the admission object.
30
+ *
31
+ * None of it is data a consumer has any business reading: the file term is a digest of the
32
+ * operator's configuration file and the canonical form contains their credentials. Holding it in a
33
+ * module WeakMap means an admission can be passed around, and even serialized by a careless
34
+ * caller, without carrying any of it.
35
+ */
36
+ interface AdmissionEvidence {
37
+ readonly path: string;
38
+ readonly file: string;
39
+ readonly data: string;
40
+ readonly executors: ReadonlyMap<string, unknown>;
41
+ }
42
+
43
+ const evidence = new WeakMap<ExportConfigAdmission, AdmissionEvidence>();
44
+
45
+ /**
46
+ * Detach the configuration a roster is about to be built from, and record what it was.
47
+ *
48
+ * Two things are recorded because two things can move independently. The resident configuration is
49
+ * what the rows are derived from, so its complete structure is captured: structurally rather than
50
+ * as a list of the fields that seemed to matter, because such a list is only as complete as
51
+ * whoever last thought about it and this one had already missed export-affecting configuration.
52
+ * The configuration file is recorded beside it, so a roster does not outlive an operator editing
53
+ * the configuration under a process that has not adopted it yet.
54
+ *
55
+ * Null when the file cannot be read, when it is there but the loader would have had to salvage it,
56
+ * and when the configuration object cannot be copied as plain data. Every caller fails closed on
57
+ * it: refusing a preview costs an ordinary load, and serving one that describes a configuration
58
+ * nobody has costs a file the operator did not ask for.
59
+ */
60
+ export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmission | null {
61
+ const path = getConfigPath();
62
+ const file = admittedFileTerm();
63
+ if (file === null) return null;
64
+ const resident = detachConfig(live);
65
+ if (resident === null) return null;
66
+ const config = withExecutors(resident);
67
+ if (config === null) return null;
68
+ const admission: ExportConfigAdmission = { config };
69
+ evidence.set(admission, { path, file, data: canonicalPlainData(resident.data), executors: resident.executors });
70
+ return admission;
71
+ }
72
+
73
+ /**
74
+ * Whether an admission still describes the configuration in hand and the file it was taken beside.
75
+ *
76
+ * Three things are checked because three things can move: the file can be rewritten, the resident
77
+ * object can be edited in place, and a consumer of the detached copy can mutate what it was given.
78
+ * The last matters as much as the others, because a pass that edited its own input and then
79
+ * published would be retaining a roster under a state that no longer describes even that input.
80
+ *
81
+ * Passive: it reads the configuration file and nothing else. No credential is resolved, no
82
+ * provider is contacted, no path is hardened and nothing is written.
83
+ */
84
+ export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, live: OcxConfig): boolean {
85
+ const captured = evidence.get(admission);
86
+ if (captured === undefined) return false;
87
+ // A different configuration home is a different question, not a stale answer to this one.
88
+ if (getConfigPath() !== captured.path) return false;
89
+ const file = admittedFileTerm();
90
+ if (file === null || file !== captured.file) return false;
91
+ const resident = detachConfig(live);
92
+ if (resident === null || canonicalPlainData(resident.data) !== captured.data) return false;
93
+ if (!sameExecutors(resident.executors, captured.executors)) return false;
94
+ const working = detachConfig(admission.config);
95
+ return working !== null
96
+ && canonicalPlainData(working.data) === captured.data
97
+ && sameExecutors(working.executors, captured.executors);
98
+ }
99
+
100
+ /**
101
+ * A plain-data copy of a configuration for a consumer that must not observe later edits, or null.
102
+ *
103
+ * The integration writer is the case this exists for. It freezes every other resolution seam
104
+ * before its first await and then held the configuration by reference, so a plan checked under one
105
+ * configuration could be written from another: the check and the document it authorized were
106
+ * reading the same object at two different moments. One copy taken before the await gives both of
107
+ * them the same configuration.
108
+ *
109
+ * Null rather than the caller's object when the copy cannot be made. Handing back the reference
110
+ * would have been a copy in name only, and the caller would have gone on to describe it as the
111
+ * configuration it checked.
112
+ */
113
+ export function detachedConfigSnapshot(config: OcxConfig): OcxConfig | null {
114
+ const detached = detachConfig(config);
115
+ return detached === null ? null : withExecutors(detached);
116
+ }
117
+
118
+ /**
119
+ * The configuration file as an opaque term: its exact bytes, or the distinguished absence of one.
120
+ *
121
+ * This is a byte observation and nothing more. It says the operator's configuration file has not
122
+ * been rewritten since a roster was built; it is not a claim about whether the configuration the
123
+ * process is holding agrees with that file.
124
+ *
125
+ * Null for a file that cannot be read, because then a later read cannot tell whether it changed.
126
+ * Null too for one that is there and does not load cleanly, which is the existing contract for a
127
+ * derived roster rather than an inference about the resident configuration. Before this, a digest
128
+ * was accepted ahead of any look at what the parse produced.
129
+ *
130
+ * Absence is a configuration rather than the lack of one. No file means defaults, which is an
131
+ * ordinary fresh install and the ordinary state in CI.
132
+ */
133
+ function admittedFileTerm(): string | null {
134
+ const snapshot = readConfigAdmissionSnapshot();
135
+ const { source, error } = snapshot.diagnostics;
136
+ if (snapshot.kind === "read") return source === "file" && error === null ? snapshot.contentSha256 : null;
137
+ return source === "default" && error === null ? "absent" : null;
138
+ }
139
+
140
+ interface DetachedConfig {
141
+ readonly data: Record<string, unknown>;
142
+ readonly executors: ReadonlyMap<string, unknown>;
143
+ }
144
+
145
+ /**
146
+ * A configuration as plain data, with the transport executors kept out of it.
147
+ *
148
+ * The copier refuses everything JSON could not have produced, so the one thing that needs handling
149
+ * here is a provider's fetch executor: a caller owns it and the gather uses it instead of the
150
+ * global transport. It is held by reference for the detached copy and compared by reference
151
+ * afterwards, so replacing it invalidates the binding while it is never serialized.
152
+ */
153
+ function detachConfig(live: OcxConfig): DetachedConfig | null {
154
+ const executors = new Map<string, unknown>();
155
+ const root = live as unknown;
156
+ if (!isPlainObject(root)) return null;
157
+ const data: Record<string, unknown> = {};
158
+ for (const key of ownDataKeys(root)) {
159
+ if (key === null) return null;
160
+ const value = root[key];
161
+ if (value === undefined) continue;
162
+ if (key !== "providers") {
163
+ const copied = copyPlainData(value);
164
+ if (!copied.ok) return null;
165
+ data[key] = copied.value;
166
+ continue;
167
+ }
168
+ if (!isPlainObject(value)) return null;
169
+ const providers: Record<string, unknown> = {};
170
+ for (const name of ownDataKeys(value)) {
171
+ if (name === null) return null;
172
+ const provider = value[name];
173
+ if (provider === undefined) continue;
174
+ if (!isPlainObject(provider)) return null;
175
+ const copiedProvider: Record<string, unknown> = {};
176
+ for (const field of ownDataKeys(provider)) {
177
+ if (field === null) return null;
178
+ const fieldValue = provider[field];
179
+ if (fieldValue === undefined) continue;
180
+ // The executor exception applies to an executor. A provider entry keeps unknown
181
+ // configuration keys, so a fetch value that is not a function is something an operator
182
+ // wrote into the file, and it is copied and compared as the data it is. Discovery reads it
183
+ // the same way: the outbound transport takes its built-in path unless the value is
184
+ // callable.
185
+ if (field === "fetch" && typeof fieldValue === "function") {
186
+ executors.set(name, fieldValue);
187
+ continue;
188
+ }
189
+ const copied = copyPlainData(fieldValue);
190
+ if (!copied.ok) return null;
191
+ copiedProvider[field] = copied.value;
192
+ }
193
+ providers[name] = copiedProvider;
194
+ }
195
+ data[key] = providers;
196
+ }
197
+ return { data, executors };
198
+ }
199
+
200
+ /** The copy a consumer runs against, with the executors put back by reference. */
201
+ function withExecutors(detached: DetachedConfig): OcxConfig | null {
202
+ // A second copy, so what is compared later is never the object handed to a consumer.
203
+ const copied = copyPlainData(detached.data);
204
+ if (!copied.ok) return null;
205
+ const config = copied.value;
206
+ const providers = config.providers;
207
+ if (isPlainObject(providers)) {
208
+ for (const [name, executor] of detached.executors) {
209
+ const provider = providers[name];
210
+ if (isPlainObject(provider)) provider.fetch = executor;
211
+ }
212
+ }
213
+ return config as unknown as OcxConfig;
214
+ }
215
+
216
+ function sameExecutors(left: ReadonlyMap<string, unknown>, right: ReadonlyMap<string, unknown>): boolean {
217
+ if (left.size !== right.size) return false;
218
+ for (const [name, executor] of left) {
219
+ if (!right.has(name) || right.get(name) !== executor) return false;
220
+ }
221
+ return true;
222
+ }