@bitkyc08/opencodex 2.83.0 → 2.86.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 (405) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/Tray-DyR9yBee.js +1 -0
  5. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  6. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  7. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +2 -3
  10. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  11. package/src/adapters/anthropic/client-identity.ts +1 -1
  12. package/src/adapters/anthropic/passthrough.ts +33 -4
  13. package/src/adapters/azure.ts +19 -1
  14. package/src/adapters/client-fingerprint.ts +24 -0
  15. package/src/adapters/coding-agent/protocol.ts +15 -8
  16. package/src/adapters/coding-agent/turn.ts +22 -9
  17. package/src/adapters/cursor/arg-codec.ts +17 -5
  18. package/src/adapters/cursor/catalog.ts +8 -0
  19. package/src/adapters/cursor/effort-map.ts +3 -0
  20. package/src/adapters/cursor/freeform-args.ts +75 -0
  21. package/src/adapters/cursor/protobuf-events.ts +36 -23
  22. package/src/adapters/google-antigravity-wire.ts +22 -1
  23. package/src/adapters/google.ts +2 -2
  24. package/src/adapters/ollama-native.ts +29 -1
  25. package/src/adapters/openai-chat/messages.ts +8 -4
  26. package/src/adapters/openai-chat/response-events.ts +59 -1
  27. package/src/adapters/openai-chat.ts +8 -5
  28. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  29. package/src/adapters/openai-responses/passthrough.ts +3 -0
  30. package/src/adapters/openai-responses/request-strips.ts +10 -2
  31. package/src/adapters/qoder/adapter.ts +56 -15
  32. package/src/advisor/consult.ts +220 -0
  33. package/src/advisor/context.ts +226 -0
  34. package/src/advisor/disclosure.ts +20 -0
  35. package/src/advisor/runtime.ts +305 -0
  36. package/src/advisor/settings.ts +135 -0
  37. package/src/advisor/state.ts +369 -0
  38. package/src/advisor/synthetic-tool.ts +33 -0
  39. package/src/bridge/errors.ts +24 -0
  40. package/src/bridge.ts +1 -1
  41. package/src/chat/inbound.ts +30 -6
  42. package/src/chat/outbound.ts +5 -0
  43. package/src/claude/context-windows.ts +54 -1
  44. package/src/claude/desktop-3p-library.ts +43 -3
  45. package/src/claude/desktop-3p.ts +22 -8
  46. package/src/claude/desktop-picker-profile.ts +11 -4
  47. package/src/claude/desktop-profile.ts +10 -1
  48. package/src/claude/desktop-remote-store-io.ts +5 -3
  49. package/src/claude/inbound.ts +121 -50
  50. package/src/claude/intercept/connect-proxy.ts +10 -3
  51. package/src/claude/intercept/local-ca.ts +8 -1
  52. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  53. package/src/claude/intercept/picker-listener.ts +49 -1
  54. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  55. package/src/claude/intercept/settings.ts +101 -9
  56. package/src/claude/model-default-settings.ts +154 -0
  57. package/src/claude/model-info.ts +7 -7
  58. package/src/claude/outbound.ts +13 -6
  59. package/src/cli/account-api.ts +65 -0
  60. package/src/cli/account-auth.ts +26 -2
  61. package/src/cli/account.ts +41 -5
  62. package/src/cli/advisor.ts +151 -0
  63. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  64. package/src/cli/capabilities-access-remote.ts +8 -0
  65. package/src/cli/capabilities-agents-routing.ts +18 -0
  66. package/src/cli/capabilities-base.ts +14 -2
  67. package/src/cli/catalog-command-result.ts +2 -1
  68. package/src/cli/chatgpt-command.ts +35 -14
  69. package/src/cli/claude.ts +60 -11
  70. package/src/cli/config-command.ts +8 -2
  71. package/src/cli/debug.ts +8 -0
  72. package/src/cli/dispatch.ts +21 -4
  73. package/src/cli/doctor.ts +9 -0
  74. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  75. package/src/cli/export-command.ts +15 -2
  76. package/src/cli/help.ts +2 -1
  77. package/src/cli/hub-disable.ts +99 -0
  78. package/src/cli/hub.ts +4 -2
  79. package/src/cli/index.ts +26 -8
  80. package/src/cli/integration-plan-dto.ts +2 -0
  81. package/src/cli/link-child.ts +104 -0
  82. package/src/cli/link-enrollment.ts +96 -0
  83. package/src/cli/link.ts +39 -24
  84. package/src/cli/observe-stream.ts +6 -1
  85. package/src/cli/observe.ts +10 -2
  86. package/src/cli/provider.ts +12 -0
  87. package/src/cli/registry.ts +20 -7
  88. package/src/cli/restart-scope.ts +3 -1
  89. package/src/cli/runtime-api.ts +27 -2
  90. package/src/cli/status.ts +104 -20
  91. package/src/client/connect.ts +39 -4
  92. package/src/client/hub-client.ts +14 -7
  93. package/src/client/hub-state.ts +25 -14
  94. package/src/client/link-home.ts +248 -0
  95. package/src/client/link-management-relay.ts +61 -0
  96. package/src/client/link-relay.ts +6 -1
  97. package/src/client/link-status.ts +14 -0
  98. package/src/client/machine-api.ts +14 -7
  99. package/src/client/machine-listener.ts +29 -4
  100. package/src/clients/config-export/contracts.ts +1 -0
  101. package/src/clients/config-export/model-metadata.ts +3 -2
  102. package/src/clients/config-export/qoder.ts +93 -0
  103. package/src/clients/config-export.ts +59 -16
  104. package/src/clients/omo-role-models.ts +6 -3
  105. package/src/codex/agent-role-models.ts +26 -8
  106. package/src/codex/auth-api/account-list.ts +17 -2
  107. package/src/codex/auth-api/account-selection.ts +17 -7
  108. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  109. package/src/codex/auth-api/routes.ts +5 -1
  110. package/src/codex/auth-context.ts +79 -5
  111. package/src/codex/autostart-health.ts +7 -1
  112. package/src/codex/catalog/build-entries.ts +11 -2
  113. package/src/codex/catalog/bundled.ts +5 -1
  114. package/src/codex/catalog/configured-tiers.ts +50 -0
  115. package/src/codex/catalog/effort.ts +6 -3
  116. package/src/codex/catalog/metadata.ts +4 -2
  117. package/src/codex/catalog/model-hints.ts +21 -3
  118. package/src/codex/catalog/native-models.ts +16 -2
  119. package/src/codex/catalog/native-ultrafast.ts +106 -0
  120. package/src/codex/catalog/parsing.ts +5 -0
  121. package/src/codex/catalog/provider-models.ts +4 -0
  122. package/src/codex/catalog/retained-sync.ts +2 -0
  123. package/src/codex/catalog/routed-gather.ts +22 -4
  124. package/src/codex/catalog/vendor-context.ts +82 -0
  125. package/src/codex/config-write-lock.ts +40 -2
  126. package/src/codex/convergence.ts +2 -0
  127. package/src/codex/desktop-app/types.ts +2 -0
  128. package/src/codex/desktop-app/windows.ts +29 -16
  129. package/src/codex/desktop-app-restart.ts +4 -2
  130. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  131. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  132. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  133. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  134. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  135. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  136. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  137. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  138. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  139. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  140. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  141. package/src/codex/desktop-compatibility/service.ts +30 -0
  142. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  143. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  144. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  145. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  146. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  147. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  148. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  149. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  150. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  151. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  152. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  153. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  154. package/src/codex/history-provider.ts +1 -1
  155. package/src/codex/inject/config-write-section.ts +4 -1
  156. package/src/codex/inject/remove.ts +18 -6
  157. package/src/codex/journal.ts +16 -1
  158. package/src/codex/main-account.ts +99 -15
  159. package/src/codex/native-main-refresh-mode.ts +217 -0
  160. package/src/codex/native-profile-startup.ts +4 -1
  161. package/src/codex/prompt-journal.ts +20 -2
  162. package/src/codex/prompt-layers/encoding.ts +49 -0
  163. package/src/codex/prompt-layers/import-source.ts +30 -0
  164. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  165. package/src/codex/prompt-layers/toml-read.ts +52 -4
  166. package/src/codex/prompt-layers.ts +383 -20
  167. package/src/codex/prompt-lock-claim.ts +13 -3
  168. package/src/codex/prompt-lock-evidence.ts +3 -3
  169. package/src/codex/prompt-lock-owner.ts +14 -6
  170. package/src/codex/prompt-text-probe.ts +34 -5
  171. package/src/codex/quota.ts +13 -6
  172. package/src/codex/role-auto-assign.ts +2 -5
  173. package/src/codex/routing/active-account.ts +21 -0
  174. package/src/codex/routing/health-store.ts +1 -1
  175. package/src/codex/routing/pool-recovery.ts +79 -0
  176. package/src/codex/routing.ts +1 -1
  177. package/src/codex/runtime.ts +196 -8
  178. package/src/codex/shim-diagnostics.ts +6 -0
  179. package/src/codex/shim-overlay.ts +15 -6
  180. package/src/codex/shim.ts +1 -1
  181. package/src/combos/failover.ts +3 -0
  182. package/src/config/api-key-reconcile.ts +34 -0
  183. package/src/config/diagnostics.ts +18 -1
  184. package/src/config/feature-flags.ts +5 -0
  185. package/src/config/live-reconcile.ts +56 -29
  186. package/src/config/live-topology.ts +29 -0
  187. package/src/config/load-degrade.ts +8 -2
  188. package/src/config/paths.ts +13 -3
  189. package/src/config/salvage.ts +3 -1
  190. package/src/config/schema/config-schema.ts +19 -0
  191. package/src/config/schema/desktop-compatibility.ts +9 -0
  192. package/src/config/schema/leaf-validators.ts +1 -0
  193. package/src/generated/compatibility-version.json +713 -285
  194. package/src/generated/model-metadata.ts +1 -1
  195. package/src/grok/grpc-web.ts +23 -1
  196. package/src/grok/reset-coupon-ledger.ts +75 -5
  197. package/src/grok/reset-coupons.ts +4 -2
  198. package/src/integrations/catalog-refresh.ts +1 -1
  199. package/src/integrations/config-io.ts +20 -2
  200. package/src/integrations/journal.ts +15 -1
  201. package/src/integrations/mutation-plan.ts +14 -7
  202. package/src/integrations/registry.ts +52 -11
  203. package/src/integrations/state.ts +19 -7
  204. package/src/integrations/store.ts +3 -0
  205. package/src/integrations/target.ts +42 -5
  206. package/src/integrations/writer.ts +24 -7
  207. package/src/lib/advisor-activation.ts +16 -0
  208. package/src/lib/app-owned-memory-stores.ts +4 -0
  209. package/src/lib/app-owned-memory.ts +11 -0
  210. package/src/lib/background-work.ts +65 -0
  211. package/src/lib/bounded-file-read.ts +58 -0
  212. package/src/lib/bun-path-runtime.d.mts +10 -1
  213. package/src/lib/bun-path-runtime.mjs +62 -3
  214. package/src/lib/desktop-proxy-route.ts +30 -0
  215. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  216. package/src/lib/errors.ts +7 -0
  217. package/src/lib/json-byte-size.ts +31 -0
  218. package/src/lib/local-internal-call-capability.ts +55 -0
  219. package/src/lib/optional-shutdown-hooks.ts +35 -0
  220. package/src/lib/outbound-credential-registry.ts +210 -0
  221. package/src/lib/package-tree-integrity.ts +2 -2
  222. package/src/lib/passive-read.ts +35 -0
  223. package/src/lib/shadow-call.ts +44 -5
  224. package/src/lib/socks5-fetch.ts +48 -71
  225. package/src/lib/socks5-handshake.ts +112 -0
  226. package/src/lib/translator-budget.ts +7 -1
  227. package/src/link/codex-settings.ts +155 -0
  228. package/src/link/management-routes.ts +89 -0
  229. package/src/link/remote-probe.ts +38 -0
  230. package/src/link/ssh-argv.ts +26 -5
  231. package/src/link/ssh-runner.ts +58 -27
  232. package/src/link/status-projection.ts +22 -4
  233. package/src/link/supervisor.ts +144 -49
  234. package/src/link/tunnel-state.ts +11 -4
  235. package/src/oauth/anthropic-continuity.ts +4 -3
  236. package/src/oauth/anthropic-identity.ts +13 -2
  237. package/src/oauth/anthropic-model-quota.ts +13 -1
  238. package/src/oauth/anthropic-routing.ts +230 -23
  239. package/src/oauth/anthropic.ts +18 -4
  240. package/src/oauth/generic-account-failover.ts +29 -5
  241. package/src/oauth/google-antigravity.ts +24 -7
  242. package/src/oauth/index.ts +28 -17
  243. package/src/oauth/kiro-account-identity.ts +106 -0
  244. package/src/oauth/kiro-device-login.ts +2 -1
  245. package/src/oauth/kiro.ts +55 -6
  246. package/src/oauth/plan.ts +29 -0
  247. package/src/oauth/store.ts +40 -3
  248. package/src/oauth/types.ts +2 -0
  249. package/src/plugins/loader.ts +11 -0
  250. package/src/protocols/codecs/chat-ir.ts +467 -0
  251. package/src/protocols/codecs/chat.ts +10 -1
  252. package/src/protocols/codecs/messages-ir.ts +500 -0
  253. package/src/protocols/features.ts +10 -2
  254. package/src/protocols/path.ts +11 -1
  255. package/src/protocols/plan-snapshot.ts +39 -16
  256. package/src/protocols/plan.ts +9 -1
  257. package/src/protocols/settings.ts +8 -0
  258. package/src/providers/anthropic-billing-ownership.ts +74 -0
  259. package/src/providers/anthropic-billing-recovery.ts +31 -0
  260. package/src/providers/anthropic-billing-refusal.ts +55 -0
  261. package/src/providers/anthropic-reset-admission.ts +62 -0
  262. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  263. package/src/providers/antigravity-account-roster.ts +102 -0
  264. package/src/providers/antigravity-models.ts +85 -7
  265. package/src/providers/api-key-resolve.ts +17 -2
  266. package/src/providers/key-failover.ts +52 -5
  267. package/src/providers/model-rename-fields.ts +2 -0
  268. package/src/providers/model-rename-migration.ts +5 -3
  269. package/src/providers/model-rename-startup.ts +5 -3
  270. package/src/providers/quota/account-cache.ts +11 -0
  271. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  272. package/src/providers/quota/report-cache.ts +14 -0
  273. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  274. package/src/providers/quota.ts +4 -1
  275. package/src/providers/registry/entries-core.ts +21 -20
  276. package/src/providers/registry/entries-extended.ts +19 -1
  277. package/src/providers/registry/model-seeds.ts +17 -0
  278. package/src/providers/request-pacing.ts +9 -0
  279. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  280. package/src/responses/compaction.ts +14 -0
  281. package/src/responses/parser.ts +37 -20
  282. package/src/responses/state.ts +32 -29
  283. package/src/routing/history/build-file.ts +92 -0
  284. package/src/routing/history/indexer.ts +248 -216
  285. package/src/routing/history/ingest.ts +238 -0
  286. package/src/routing/history/rebuild-job.ts +111 -0
  287. package/src/routing/history/rebuild-protocol.ts +15 -0
  288. package/src/routing/history/rebuild-worker.ts +34 -0
  289. package/src/server/admission-model-scope.ts +4 -1
  290. package/src/server/antigravity-direct-eligibility.ts +208 -0
  291. package/src/server/auth-cors.ts +46 -0
  292. package/src/server/chat-completions.ts +75 -9
  293. package/src/server/claude-messages.ts +110 -20
  294. package/src/server/configured-credentials.ts +194 -0
  295. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  296. package/src/server/index/link-management.ts +58 -0
  297. package/src/server/index/optional-listeners.ts +5 -1
  298. package/src/server/index/serve-options.ts +15 -2
  299. package/src/server/index/startup-warnings.ts +9 -0
  300. package/src/server/index/websocket-handler.ts +9 -0
  301. package/src/server/index.ts +13 -10
  302. package/src/server/inference/client-encoder-delivery.ts +6 -5
  303. package/src/server/lifecycle.ts +2 -1
  304. package/src/server/login-shell-path.ts +146 -0
  305. package/src/server/management/advisor-routes.ts +191 -0
  306. package/src/server/management/agent-settings-routes.ts +50 -14
  307. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  308. package/src/server/management/codex-agent-role-routes.ts +19 -6
  309. package/src/server/management/codex-prompt-routes.ts +136 -5
  310. package/src/server/management/codex-role-auto-assign.ts +1 -1
  311. package/src/server/management/companion-routes.ts +4 -0
  312. package/src/server/management/config-routes.ts +2 -2
  313. package/src/server/management/context.ts +4 -0
  314. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  315. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  316. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  317. package/src/server/management/grok-coupon-routes.ts +78 -27
  318. package/src/server/management/link-enrollment.ts +192 -0
  319. package/src/server/management/link-route-state.ts +185 -0
  320. package/src/server/management/link-routes.ts +82 -402
  321. package/src/server/management/model-routes.ts +2 -2
  322. package/src/server/management/model-rows.ts +3 -1
  323. package/src/server/management/protocol-settings-patch.ts +1 -0
  324. package/src/server/management/provider-overwrite-carry.ts +10 -0
  325. package/src/server/management/provider-routes.ts +1 -0
  326. package/src/server/management/route-registry.ts +16 -3
  327. package/src/server/management/shared.ts +3 -0
  328. package/src/server/management/sibling-guard.ts +1 -0
  329. package/src/server/management-api.ts +9 -1
  330. package/src/server/messages-antigravity-direct.ts +195 -0
  331. package/src/server/messages-native.ts +73 -43
  332. package/src/server/relay-eager.ts +2 -0
  333. package/src/server/request-log-filter.ts +6 -1
  334. package/src/server/request-log-terminal-status.ts +63 -6
  335. package/src/server/request-log.ts +7 -0
  336. package/src/server/responses/adapter-continuation.ts +35 -18
  337. package/src/server/responses/adapter-delivery.ts +24 -2
  338. package/src/server/responses/adapter-dispatch.ts +261 -81
  339. package/src/server/responses/advisor-plan-slot.ts +27 -0
  340. package/src/server/responses/advisor-slot.ts +336 -0
  341. package/src/server/responses/code-mode-collab-surface.ts +313 -0
  342. package/src/server/responses/codex-ws-exchange.ts +147 -6
  343. package/src/server/responses/codex-ws-wire.ts +203 -0
  344. package/src/server/responses/collaboration.ts +36 -7
  345. package/src/server/responses/compact.ts +126 -48
  346. package/src/server/responses/context-overflow.ts +13 -1
  347. package/src/server/responses/core-codex-account.ts +105 -1
  348. package/src/server/responses/core-combo-failure.ts +24 -1
  349. package/src/server/responses/core-combo.ts +28 -2
  350. package/src/server/responses/core-errors.ts +29 -1
  351. package/src/server/responses/core-lifetime.ts +2 -0
  352. package/src/server/responses/core-normalize.ts +11 -0
  353. package/src/server/responses/core-options.ts +48 -0
  354. package/src/server/responses/core-replay.ts +7 -4
  355. package/src/server/responses/core.ts +1 -1
  356. package/src/server/responses/deferred-collab-surface.ts +158 -0
  357. package/src/server/responses/deferred-send-reporter.ts +25 -0
  358. package/src/server/responses/fetch-helpers.ts +12 -1
  359. package/src/server/responses/passthrough-delivery.ts +20 -11
  360. package/src/server/responses/passthrough-dispatch.ts +29 -7
  361. package/src/server/responses/request-prepare.ts +74 -9
  362. package/src/server/responses/request-transport.ts +22 -8
  363. package/src/server/responses/run-turn-execution.ts +70 -8
  364. package/src/server/responses/sidecar-execution.ts +89 -5
  365. package/src/server/responses/terminal-guard.ts +2 -1
  366. package/src/server/responses/ws-upstream.ts +9 -1
  367. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  368. package/src/server/startup-action-control.ts +38 -4
  369. package/src/server/startup-health-cache.ts +5 -0
  370. package/src/server/system-env-shell.ts +7 -2
  371. package/src/server/system-env.ts +11 -1
  372. package/src/service/cli.ts +5 -1
  373. package/src/service/desktop-startup.ts +161 -34
  374. package/src/service/desktop-supervision.d.mts +27 -1
  375. package/src/service/desktop-supervision.mjs +111 -5
  376. package/src/service/health-sentinel-launch.ts +176 -0
  377. package/src/service/health-sentinel-main.ts +466 -0
  378. package/src/service/health-sentinel-policy.ts +241 -0
  379. package/src/service/health-sentinel-probe.ts +71 -0
  380. package/src/service/health-sentinel-windows.ts +87 -0
  381. package/src/service/linux-linger.ts +20 -0
  382. package/src/service/logging.ts +72 -0
  383. package/src/tray/windows-tray.ps1 +43 -24
  384. package/src/types/config.ts +68 -6
  385. package/src/types/provider.ts +7 -0
  386. package/src/types/request.ts +15 -0
  387. package/src/types/tools.ts +2 -0
  388. package/src/update/async-check.ts +1 -1
  389. package/src/update/badge.ts +2 -2
  390. package/src/update/index.ts +88 -15
  391. package/src/update/job-supervised-restart.ts +107 -0
  392. package/src/update/job.ts +32 -32
  393. package/src/update/notify.ts +1 -1
  394. package/src/update/refresh-scheduler.ts +2 -2
  395. package/src/update/supervised-recovery.d.mts +27 -0
  396. package/src/update/supervised-recovery.mjs +157 -0
  397. package/src/usage/cache-diagnostic.ts +2 -0
  398. package/src/usage/expected-prices.ts +16 -0
  399. package/src/usage/log.ts +19 -1
  400. package/src/web-search/gemini-executor.ts +2 -2
  401. package/assets/sponsors/packycode.png +0 -0
  402. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  403. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  404. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
  405. package/gui/dist/assets/quota-summary-DiJO6sYY.js +0 -1
@@ -0,0 +1,192 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED, remoteOcxArgv } from "../../link/ssh-argv";
5
+ import { parseFingerprintLine } from "../../link/fingerprint";
6
+ import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe";
7
+ import { newLinkId, type LinkStore } from "../../link/store";
8
+ import { SshRunnerError, boundHint, sshFailureHint, sshRunnerErrorHint, type SshRunResult } from "../../link/ssh-runner";
9
+ import { withConfigMutationLockSync } from "../../config/mutation-lock";
10
+ import type { ManagementContext } from "./context";
11
+ import { readManagementJsonBodyOr } from "./body";
12
+ import { fail, exactBody, port, isRecord, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, putKnownHost, issueKey, compensateNewLink, type LinkRouteState } from "./link-route-state";
13
+
14
+ const PROBE_TTL_MS = 5 * 60_000;
15
+ const MIN_REMOTE_OCX = [2, 66, 0] as const;
16
+ const REMOTE_OCX_VERSION = /^opencodex (\d{1,9})\.(\d{1,9})\.(\d{1,9})(-[0-9A-Za-z.-]{1,64})?(\+[0-9A-Za-z.-]{1,64})?(?=\s|$)/;
17
+
18
+ /** Same-alias operations refuse overlap rather than overwrite a confirmed generation. */
19
+ async function serialized(state: LinkRouteState, alias: string, action: () => Promise<Response>): Promise<Response> {
20
+ const operations = state.operations ??= new Set();
21
+ if (operations.has(alias)) return fail("link_operation_in_progress", "A link operation for this alias is already running.", 409);
22
+ operations.add(alias);
23
+ try { return await action(); } finally { operations.delete(alias); }
24
+ }
25
+
26
+ function remoteFailure(result: SshRunResult, code: string, message: string): Response | null {
27
+ if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return fail("remote_platform_unsupported", "Remote Link Children require macOS or Linux; Windows is unsupported.", 409);
28
+ if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
29
+ return result.code === 0 ? null : fail(code, message, 502, sshFailureHint(result.stderr));
30
+ }
31
+
32
+ export async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
33
+ const value = await readManagementJsonBodyOr(ctx.req, null);
34
+ const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]);
35
+ if (!body || typeof body.alias !== "string" || (body.includeVersion !== undefined && body.includeVersion !== true)) return fail("invalid_body", "alias is required.", 400);
36
+ const alias = body.alias;
37
+ try { assertSshAlias(alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
38
+ return serialized(state, alias, async () => {
39
+ const dir = mkdtempSync(join(tmpdir(), "ocx-link-probe-"));
40
+ const path = join(dir, "known_hosts");
41
+ writeFileSync(path, "", { mode: 0o600 });
42
+ const runner = runnerFor(ctx), now = ctx.deps.now ?? Date.now;
43
+ try {
44
+ const started = now();
45
+ const result = await runner.run(buildProbeArgv({ alias, tempKnownHostsFile: path }), { timeoutMs: 30_000 });
46
+ const latency = Math.max(0, now() - started);
47
+ if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
48
+ const fp = await runner.run(buildFingerprintArgv(path), { timeoutMs: 10_000 });
49
+ if (fp.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fp.stderr));
50
+ const parsed = parseFingerprintLine(fp.stdout.trim());
51
+ const line = readFileSync(path, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
52
+ if (!line) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
53
+ let ocxVersion: string | undefined;
54
+ if (body.includeVersion === true) {
55
+ const versionResult = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
56
+ const denied = remoteFailure(versionResult, "version_probe_failed", "The remote ocx version could not be read.");
57
+ if (denied) return denied;
58
+ const parsedVersion = parseVersion(versionResult.stdout);
59
+ if (!parsedVersion) return fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502);
60
+ ocxVersion = parsedVersion.version;
61
+ }
62
+ state.pendingHosts.set(alias, { alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine: line, probedAt: now(), sshLatencyMs: latency });
63
+ state.confirmedHosts?.delete(alias);
64
+ return Response.json({ alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, ...(ocxVersion === undefined ? {} : { ocxVersion }) });
65
+ } catch (error) { return fail("probe_failed", "SSH host probing failed.", 502, sshRunnerErrorHint(error)); }
66
+ finally { rmSync(dir, { recursive: true, force: true }); }
67
+ });
68
+ }
69
+
70
+ function parseVersion(stdout: string): { version: string; parts: number[] } | null {
71
+ const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? "");
72
+ return match ? { version: match.slice(1, 4).join(".") + (match[4] ?? "") + (match[5] ?? ""), parts: match.slice(1, 4).map(Number) } : null;
73
+ }
74
+
75
+ export async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
76
+ const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "fingerprint"]);
77
+ if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string") return fail("invalid_body", "alias and fingerprint are required.", 400);
78
+ const alias = body.alias;
79
+ return serialized(state, alias, async () => {
80
+ const pending = state.pendingHosts.get(alias), now = ctx.deps.now ?? Date.now;
81
+ if (!pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe has expired.", 409);
82
+ if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
83
+ const dir = mkdtempSync(join(tmpdir(), "ocx-link-confirm-")), path = join(dir, "known_hosts");
84
+ writeFileSync(path, pending.knownHostLine + "\n", { mode: 0o600 });
85
+ try {
86
+ const started = now();
87
+ const result = await runnerFor(ctx).run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
88
+ const denied = remoteFailure(result, "version_probe_failed", "The remote ocx version could not be confirmed.");
89
+ if (denied) return denied;
90
+ const version = parseVersion(result.stdout);
91
+ if (!version) return fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502);
92
+ const cmp = version.parts.findIndex((part, i) => part !== MIN_REMOTE_OCX[i]);
93
+ if (cmp >= 0 && version.parts[cmp]! < MIN_REMOTE_OCX[cmp]!) return fail("remote_ocx_outdated", "The remote OpenCodex is older than 2.66.0.", 409, boundHint("opencodex " + version.version));
94
+ if (state.pendingHosts.get(alias) !== pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe changed or expired.", 409);
95
+ withConfigMutationLockSync(() => putKnownHost(knownHostsFile(ctx), alias, pending.knownHostLine));
96
+ const confirmed = { ...pending, sshLatencyMs: Math.max(pending.sshLatencyMs ?? 0, now() - started), ocxVersion: version.version };
97
+ (state.confirmedHosts ??= new Map()).set(alias, confirmed);
98
+ state.pendingHosts.delete(alias);
99
+ return Response.json({ alias, fingerprint: pending.fingerprint, ocxVersion: version.version });
100
+ } catch (error) { return fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error)); }
101
+ finally { rmSync(dir, { recursive: true, force: true }); }
102
+ });
103
+ }
104
+
105
+ function parsePort(stdout: string): number | null {
106
+ try { const value: unknown = JSON.parse(stdout.trim()); return isRecord(value) && Object.keys(value).length === 1 && port(value.port) ? value.port : null; }
107
+ catch { return null; }
108
+ }
109
+
110
+ export async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
111
+ const value = await readManagementJsonBodyOr(ctx.req, null);
112
+ const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "fingerprint"]);
113
+ if (!body || typeof body.alias !== "string" || (body.fingerprint !== undefined && typeof body.fingerprint !== "string")) return fail("invalid_body", "alias and optional fingerprint are required.", 400);
114
+ const alias = body.alias;
115
+ return serialized(state, alias, async () => {
116
+ const confirmed = state.confirmedHosts?.get(alias), now = ctx.deps.now ?? Date.now;
117
+ if (!confirmed) return fail("host_not_confirmed", "Confirm the SSH host before applying the link.", 409);
118
+ if (now() - confirmed.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409);
119
+ if (body.fingerprint !== undefined && body.fingerprint !== confirmed.fingerprint) return fail("host_fingerprint_mismatch", "The apply fingerprint does not match the confirmed host.", 409);
120
+ if (readStoreFor(ctx).links.some(link => link.alias === alias && link.direction === "hub-initiated")) return fail("link_exists", "A link for this alias already exists.", 409);
121
+ const dir = mkdtempSync(join(tmpdir(), "ocx-link-apply-")), knownHosts = join(dir, "known_hosts");
122
+ writeFileSync(knownHosts, confirmed.knownHostLine + "\n", { mode: 0o600 });
123
+ const runner = runnerFor(ctx), budget = linkCommandBudget(confirmed.sshLatencyMs);
124
+ const run = (argv: readonly string[], timeoutMs = budget, maxOutputBytes?: number) => runner.run(buildExecArgv({ alias, argv, knownHostsFile: knownHosts }), { timeoutMs, ...(maxOutputBytes === undefined ? {} : { maxOutputBytes }) });
125
+ let record: LinkStore["links"][number] | undefined, input: Uint8Array | undefined;
126
+ let remoteMayBeConnected = false, phase = "remote_port";
127
+ const failed = async (response: Response): Promise<Response> => {
128
+ const compensation = record ? await compensateNewLink(ctx, state, record) : null;
129
+ const outcome = compensation ?? response;
130
+ if (!remoteMayBeConnected) return outcome;
131
+ const payload = await outcome.json() as Record<string, unknown>;
132
+ return Response.json({ ...payload, residual: true, recovery: "Run ocx disconnect and ocx restart on the Child before retrying." }, { status: outcome.status });
133
+ };
134
+ try {
135
+ const chosen = await run(remoteOcxArgv(["link", "port"]));
136
+ const denied = remoteFailure(chosen, "remote_port_failed", "The remote link port could not be determined.");
137
+ if (denied) return denied;
138
+ const remotePort = parsePort(chosen.stdout);
139
+ if (!remotePort) return fail("remote_port_failed", "The remote link port could not be determined.", 502);
140
+ phase = "child_preflight";
141
+ const configured = await run(remoteOcxArgv(["config", "show", "--json"]));
142
+ let childConfig: unknown = null;
143
+ try { if (configured.code === 0) childConfig = JSON.parse(configured.stdout); } catch { /* fail closed */ }
144
+ if (!isRecord(childConfig) || !port(childConfig.port)) return fail("child_preflight_failed", "The Child effective configuration could not be read.", 502);
145
+ if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409);
146
+ const childPort = childConfig.port;
147
+ if ((await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409);
148
+ const before = await run(remoteChildHttpArgv(childPort, "healthz"));
149
+ const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null;
150
+ if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409);
151
+ state.confirmedHosts?.delete(alias);
152
+ phase = "home_setup";
153
+ const id = newLinkId(), issued = issueKey(ctx, "link:" + alias);
154
+ record = { id, alias, direction: "hub-initiated", hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date(now()).toISOString() };
155
+ withConfigMutationLockSync(() => {
156
+ const current = readStoreFor(ctx);
157
+ if (current.links.some(link => link.alias === alias && link.direction === "hub-initiated")) throw new Error("link exists");
158
+ writeStoreFor(ctx, { ...current, links: [...current.links, record!] });
159
+ });
160
+ await state.listener.ensureStarted();
161
+ if (state.listener.status().state !== "listening") return await failed(fail("listener_unavailable", "The link listener is unavailable.", 503));
162
+ await state.supervisor.ensureStarted();
163
+ await state.supervisor.reload();
164
+ phase = "remote_connect";
165
+ input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
166
+ remoteMayBeConnected = true;
167
+ const connected = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id]), knownHostsFile: knownHosts }), { stdin: input, timeoutMs: budget });
168
+ const connectDenied = remoteFailure(connected, "remote_connect_failed", "The Child enrollment command failed.");
169
+ if (connectDenied) return await failed(connectDenied);
170
+ input.fill(0);
171
+ phase = "child_restart";
172
+ const restarted = await run(remoteOcxArgv(["restart"]), linkRestartBudget(confirmed.sshLatencyMs));
173
+ if (restarted.code !== 0) return await failed(fail("child_restart_failed", "The Child restart could not be verified.", 502, sshFailureHint(restarted.stderr)));
174
+ phase = "child_relay";
175
+ const deadline = now() + LINK_RELAY_VERIFY_MS, remaining = () => Math.max(1, deadline - now());
176
+ const health = await run(remoteChildHttpArgv(childPort, "healthz", remaining()), remaining());
177
+ if (!isChildHealth(health.code === 0 ? parseChildHttp(health.stdout) : null, "client", childPort)) return await failed(fail("child_runtime_not_client", "The Child is not serving the client runtime after restart.", 502));
178
+ let admitted = false;
179
+ const unsubscribe = state.listener.onAuthenticatedCatalog(keyId => { if (keyId === issued.id) admitted = true; });
180
+ try {
181
+ if (now() >= deadline) return await failed(fail("child_relay_timeout", "The Child relay verification deadline expired.", 502));
182
+ const catalog = await run(remoteChildHttpArgv(childPort, "v1/catalog", remaining()), remaining(), LINK_CATALOG_MAX_BYTES);
183
+ let data: unknown = null;
184
+ try { if (catalog.code === 0) data = parseChildHttp(catalog.stdout); } catch { /* malformed is not a completed catalog */ }
185
+ if (!isRecord(data) || !Array.isArray(data.models) || !admitted || now() > deadline) return await failed(fail("child_relay_failed", "The Child did not relay a complete authenticated catalog through this Home.", 502));
186
+ return Response.json({ linkId: id }, { status: 202 });
187
+ } finally { unsubscribe(); }
188
+ } catch (error) {
189
+ return await failed(fail(phase + (error instanceof SshRunnerError && error.code === "timeout" ? "_timeout" : "_failed"), "Remote Link failed during " + phase.replaceAll("_", " ") + ".", 502, sshRunnerErrorHint(error)));
190
+ } finally { input?.fill(0); rmSync(dir, { recursive: true, force: true }); }
191
+ });
192
+ }
@@ -0,0 +1,185 @@
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
+ import { atomicWriteFile } from "../../config/atomic-write";
3
+ import { dirname } from "node:path";
4
+ import { linkKnownHostsPath, linkStorePath } from "../../link/paths";
5
+ import { clearCompensationFailed, compensationPath, markCompensationFailed } from "../../link/compensation";
6
+ import { readLinkStore, writeLinkStore, type LinkStore } from "../../link/store";
7
+ import { boundHint, createSshRunner, type SshRunner } from "../../link/ssh-runner";
8
+ import { withConfigMutationLockSync } from "../../config/mutation-lock";
9
+ import type { ManagementContext } from "./context";
10
+ import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes";
11
+
12
+ export interface PendingLinkHost {
13
+ alias: string;
14
+ fingerprint: string;
15
+ keyType: string;
16
+ knownHostLine: string;
17
+ probedAt: number;
18
+ sshLatencyMs?: number;
19
+ }
20
+
21
+ export interface ConfirmedLinkHost extends PendingLinkHost {
22
+ ocxVersion: string;
23
+ }
24
+
25
+ export interface LinkRouteListener {
26
+ ensureStarted(): Promise<void>;
27
+ status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
28
+ close(): Promise<void>;
29
+ onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
30
+ }
31
+
32
+ export interface LinkRouteState {
33
+ pendingHosts: Map<string, PendingLinkHost>;
34
+ operations?: Set<string>;
35
+ confirmedHosts?: Map<string, ConfirmedLinkHost>;
36
+ compensationFailures?: Map<string, { since: string; reason: "compensation_failed" }>;
37
+ supervisor: import("../../link/supervisor").LinkSupervisor;
38
+ listener: LinkRouteListener;
39
+ }
40
+
41
+ export function fail(code: string, message: string, status: number, hint?: string): Response {
42
+ return Response.json({ error: { code, message, ...(hint ? { hint } : {}) } }, { status, headers: { "cache-control": "no-store" } });
43
+ }
44
+
45
+ export function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return value !== null && typeof value === "object" && !Array.isArray(value);
47
+ }
48
+
49
+ export function exactBody(body: unknown, keys: readonly string[]): Record<string, unknown> | null {
50
+ if (!isRecord(body)) return null;
51
+ const actual = Object.keys(body).sort();
52
+ const expected = [...keys].sort();
53
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]) ? body : null;
54
+ }
55
+
56
+ export function port(value: unknown): value is number {
57
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
58
+ }
59
+
60
+ /** A GUI session redeemed from an operator-created, one-use pairing grant. */
61
+ export function pairedSession(ctx: ManagementContext): boolean {
62
+ return ctx.principal === "gui-session"
63
+ && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
64
+ }
65
+
66
+ /**
67
+ * A paired session, or on a standalone runtime the current loopback-issued session that reached
68
+ * the public listener bound to a loopback hostname. The loopback bootstrap mints that session
69
+ * without a credential, so this is casual-path protection like POST /api/github/star, not a
70
+ * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Durable operations
71
+ * that require operator approval, such as joining this machine as a Child, use pairedSession.
72
+ */
73
+ export function dashboardSession(ctx: ManagementContext): boolean {
74
+ if (pairedSession(ctx)) return true;
75
+ return ctx.principal === "gui-session"
76
+ && (ctx.config.runtimeRole ?? "standalone") === "standalone"
77
+ && ctx.guiSessionIssuance === "loopback"
78
+ && ctx.trustedLoopbackIngress
79
+ && ctx.sessionControl?.isCurrent(ctx.req, ctx.config) === true;
80
+ }
81
+
82
+ export function adminLoopback(ctx: ManagementContext): boolean {
83
+ return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
84
+ }
85
+
86
+ export function auth(ctx: ManagementContext, kind: "dashboard" | "paired" | "admin" | "either"): Response | null {
87
+ if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
88
+ const allowed = kind === "dashboard" ? dashboardSession(ctx)
89
+ : kind === "paired" ? pairedSession(ctx)
90
+ : kind === "admin" ? adminLoopback(ctx)
91
+ : dashboardSession(ctx) || adminLoopback(ctx);
92
+ return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403);
93
+ }
94
+
95
+ export function runnerFor(ctx: ManagementContext): SshRunner {
96
+ return ctx.deps.sshRunner ?? createSshRunner();
97
+ }
98
+
99
+ export function readStoreFor(ctx: ManagementContext): LinkStore {
100
+ return ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath());
101
+ }
102
+
103
+ export function writeStoreFor(ctx: ManagementContext, store: LinkStore): void {
104
+ (ctx.deps.writeLinkStore ?? (value => writeLinkStore(linkStorePath(), value)))(store);
105
+ }
106
+
107
+ export function knownHostsFile(ctx: ManagementContext): string {
108
+ return ctx.deps.linkKnownHostsPath?.() ?? linkKnownHostsPath();
109
+ }
110
+
111
+ export function putKnownHost(path: string, alias: string, line: string): string {
112
+ const before = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
113
+ const lines = before.split(/\r?\n/).filter(value => value && !value.startsWith(`${alias} `) && !value.startsWith(`${alias},`));
114
+ lines.push(line.trim());
115
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
116
+ atomicWriteFile(path, `${lines.join("\n")}\n`);
117
+ return before;
118
+ }
119
+
120
+ export function issueKey(ctx: ManagementContext, name: string): IssuedApiKey {
121
+ return (ctx.deps.issueApiKey ?? issueApiKeyInProcess)(ctx.config, name);
122
+ }
123
+
124
+ export function revokeKey(ctx: ManagementContext, id: string): boolean {
125
+ return (ctx.deps.revokeApiKey ?? revokeApiKeyInProcess)(ctx.config, id);
126
+ }
127
+
128
+ /**
129
+ * Revocation that a retry can repeat. A key that is no longer in the live config is already
130
+ * revoked, which is exactly the state a partially completed cleanup leaves behind; without this
131
+ * a retried DELETE would fail forever on the key it removed the first time.
132
+ */
133
+ export function revokeKeyIdempotent(ctx: ManagementContext, id: string): boolean {
134
+ let revoked = false;
135
+ try { revoked = revokeKey(ctx, id); } catch { revoked = false; }
136
+ if (revoked) return true;
137
+ return !(ctx.config.apiKeys ?? []).some(entry => entry.id === id);
138
+ }
139
+
140
+ export function compensationFailure(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number], phase: string): Response {
141
+ const since = new Date((ctx.deps.now ?? Date.now)()).toISOString();
142
+ (state.compensationFailures ??= new Map()).set(record.id, { since, reason: "compensation_failed" });
143
+ try { markCompensationFailed(record.id, since, compensationPath()); } catch (error) {
144
+ console.warn(`[link] compensation marker persistence failed linkId=${record.id}: ${error instanceof Error ? error.message : String(error)}`);
145
+ }
146
+ console.warn(`[link] compensation_failed linkId=${record.id} phase=${phase}`);
147
+ return fail("compensation_failed", "The link compensation could not be completed.", 500);
148
+ }
149
+
150
+ export async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number]): Promise<Response | null> {
151
+ const revoked = revokeKeyIdempotent(ctx, record.apiKeyId);
152
+ if (!revoked) {
153
+ try { await state.supervisor.stopLink(record.id); } catch { /* retain the record and failure marker */ }
154
+ try {
155
+ withConfigMutationLockSync(() => {
156
+ const current = readStoreFor(ctx);
157
+ if (!current.links.some(link => link.id === record.id)) {
158
+ writeStoreFor(ctx, { ...current, links: [...current.links, record] });
159
+ }
160
+ });
161
+ } catch {
162
+ // The in-memory marker still makes the residual visible when persistence is unavailable.
163
+ }
164
+ return compensationFailure(ctx, state, record, "revoke");
165
+ }
166
+ try { await state.supervisor.stopLink(record.id); } catch { return compensationFailure(ctx, state, record, "stop"); }
167
+ try {
168
+ // Read→filter→write stays one section of the shared mutation lock: a sibling
169
+ // route's create must not land between the read and the rename and vanish.
170
+ const next = withConfigMutationLockSync(() => {
171
+ const current = readStoreFor(ctx);
172
+ const filtered = { ...current, links: current.links.filter(link => link.id !== record.id) };
173
+ if (filtered.links.length !== current.links.length) {
174
+ writeStoreFor(ctx, filtered);
175
+ }
176
+ return filtered;
177
+ });
178
+ clearCompensationFailed(record.id, compensationPath());
179
+ state.compensationFailures?.delete(record.id);
180
+ if (next.links.length === 0) await state.listener.close();
181
+ return null;
182
+ } catch {
183
+ return compensationFailure(ctx, state, record, "store");
184
+ }
185
+ }