@huynle/hub 0.6.1 → 0.7.2

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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,470 @@
1
+ // The typed Action union: everything a setup plan can ask the applier to do. There is deliberately no "run a
2
+ // command" action and no field that could carry one: each kind names a local operation the applier implements
3
+ // itself (write this embedded asset there, merge these keys into that config, load this user service). A plan that
4
+ // smuggles a command-shaped key anywhere, at any depth, is refused (exec_field), as is any unknown field or kind.
5
+ import { EMBEDDED_ASSETS, SERVICE_IDS, type ServiceID } from "./exec.ts"
6
+ import { HARNESS_EVIDENCE, HARNESS_ORDER, REAL_PROBES, RUNTIME_IDS, type EvidenceKind, type HarnessID, type ProbeKind, type RuntimeID } from "./harness.ts"
7
+ import { checkPathRef, ELEVATED_ROOTS, type PathRef } from "./paths.ts"
8
+ import { supportedTarget } from "./platform.ts"
9
+ import { findSecrets, isSecretKey } from "./secrets.ts"
10
+ import { bool, Checker, digest, FILE_NAME, httpsURL, hubURL, int, isRecord, list, oneOf, pointer, semver, shape, subset, text, type Json, type Result } from "./validate.ts"
11
+
12
+ /** Keys that would let a document carry something to execute. No action field is named like these, at any depth. */
13
+ export const EXEC_KEYS = ["command", "cmd", "commands", "shell", "script", "scripts", "exec", "execute", "argv", "args", "arguments", "run", "eval", "sh", "bash", "program", "spawn", "hook", "hooks"] as const
14
+
15
+ export type Escalation = "user" | "elevated"
16
+ export type ConfigOwner = "user" | "hub" | `harness:${HarnessID}`
17
+ export const CONFIG_OWNERS: readonly ConfigOwner[] = ["user", "hub", ...HARNESS_ORDER.map((h) => `harness:${h}` as const)]
18
+ export type ConfigFormat = "json" | "jsonc" | "toml"
19
+ export type ServiceManagerID = "launchd" | "systemd-user"
20
+
21
+ /** What the target file must look like for an edit to apply; anything else is a conflicting user edit. */
22
+ export interface Precondition {
23
+ readonly exists: boolean
24
+ readonly sha256?: string
25
+ }
26
+
27
+ interface Base<K extends string> {
28
+ readonly id: string
29
+ readonly kind: K
30
+ readonly escalation: Escalation
31
+ /** One printable line shown in the preview. */
32
+ readonly summary: string
33
+ }
34
+
35
+ export interface EnsureDirectory extends Base<"ensure-directory"> {
36
+ readonly path: PathRef
37
+ readonly mode: number
38
+ }
39
+
40
+ export interface InstallArtifact extends Base<"install-artifact"> {
41
+ readonly artifact: "hubctl"
42
+ readonly version: string
43
+ readonly target: string
44
+ /** The manifest's digest for this target; the download must match it. */
45
+ readonly sha256: string
46
+ readonly path: PathRef
47
+ }
48
+
49
+ /**
50
+ * Installs a harness from an artifact pinned in hub's signed hubctl release manifest (transitive trust): exact
51
+ * version, target, source and digest, all of which must equal the manifest's pin (checkHarnessPin). Upstream
52
+ * evidence (GPG manifest, notarization, sigstore) is attempted and recorded, never a substitute for the pin. The
53
+ * install is owned by hub and rolls back by restoring the previous binary/version. Installing is not readiness:
54
+ * a plan verifies every harness it installs.
55
+ */
56
+ export interface InstallHarness extends Base<"install-harness"> {
57
+ readonly harness: HarnessID
58
+ readonly version: string
59
+ readonly target: string
60
+ readonly source: {
61
+ /** The hub release whose signed manifest pins this artifact. */
62
+ readonly manifest: string
63
+ readonly url: string
64
+ readonly file: string
65
+ }
66
+ readonly size: number
67
+ readonly sha256: string
68
+ /** Upstream evidence to attempt: exactly the kinds the pin lists as available. */
69
+ readonly evidence: readonly EvidenceKind[]
70
+ /** The install root: v1.1, exactly harnessVersionRef(harness, version), an immutable versioned directory. */
71
+ readonly path: PathRef
72
+ readonly owner: "hub"
73
+ readonly rollback: "restore-previous"
74
+ /** The version it replaces, as discovery found it (restored on rollback). */
75
+ readonly previous?: { readonly version: string; readonly sha256: string }
76
+ }
77
+
78
+ /**
79
+ * v1.1 harness layout. Every pinned version installs into its own immutable directory
80
+ * data:hub/harnesses/<harness>/<version> (install-harness), and data:hub/harnesses/<harness>/current is a symlink to
81
+ * the version in use. select-harness-version switches that link atomically (stage, rename); its rollback restores
82
+ * the previous link from its backup, so the previous version is current again, and a rolled-back install removes
83
+ * only its own fresh directory.
84
+ */
85
+ export const harnessVersionRef = (harness: HarnessID, version: string): PathRef => ({ root: "data", rel: `hub/harnesses/${harness}/${version}` })
86
+ export const harnessCurrentRef = (harness: HarnessID): PathRef => ({ root: "data", rel: `hub/harnesses/${harness}/current` })
87
+
88
+ /** Points harnessCurrentRef(harness) at harnessVersionRef(harness, version). */
89
+ export interface SelectHarnessVersion extends Base<"select-harness-version"> {
90
+ readonly harness: HarnessID
91
+ readonly version: string
92
+ /** The `current` link: harnessCurrentRef(harness). */
93
+ readonly path: PathRef
94
+ /** The version directory it points to: harnessVersionRef(harness, version). */
95
+ readonly target: PathRef
96
+ /** The version `current` pointed at before, as discovery found it (current again after a rollback). */
97
+ readonly previous?: { readonly version: string; readonly sha256: string }
98
+ }
99
+
100
+ /** A symlink (the hub ↔ hubctl alias); `target` is where it points. */
101
+ export interface LinkExecutable extends Base<"link-executable"> {
102
+ readonly path: PathRef
103
+ readonly target: PathRef
104
+ }
105
+
106
+ /** Writes an embedded or rendered asset whose content digest is pinned in the plan. */
107
+ export interface WriteFile extends Base<"write-file"> {
108
+ readonly path: PathRef
109
+ readonly asset: string
110
+ readonly sha256: string
111
+ readonly mode: number
112
+ /** An existing file that is not ours, or not as we left it, is never overwritten. */
113
+ readonly onConflict: "refuse"
114
+ }
115
+
116
+ export interface ConfigSet {
117
+ /** RFC 6901 pointer; "-" appends to an array. */
118
+ readonly pointer: string
119
+ readonly value: Json
120
+ }
121
+
122
+ /** A lossless merge: only the listed pointers change; every other key (known or not) is preserved byte for byte. */
123
+ export interface MergeConfig extends Base<"merge-config"> {
124
+ readonly path: PathRef
125
+ readonly format: ConfigFormat
126
+ readonly owner: ConfigOwner
127
+ readonly precondition: Precondition
128
+ readonly set: readonly ConfigSet[]
129
+ readonly preserveUnknown: true
130
+ }
131
+
132
+ /** Registers this hubctl's `mcp` subcommand with a harness. The command is implied, never carried. */
133
+ export interface RegisterMcpServer extends Base<"register-mcp-server"> {
134
+ readonly harness: HarnessID
135
+ readonly path: PathRef
136
+ readonly name: string
137
+ readonly precondition: Precondition
138
+ }
139
+
140
+ export interface InstallService extends Base<"install-service"> {
141
+ readonly service: ServiceID
142
+ readonly manager: ServiceManagerID
143
+ readonly path: PathRef
144
+ /** An EMBEDDED_ASSETS key for this service and manager. */
145
+ readonly asset: string
146
+ /** Digest of the rendered unit. */
147
+ readonly sha256: string
148
+ }
149
+
150
+ /** Loads a user service. Only ever explicit: the user confirmed this step. */
151
+ export interface ActivateService extends Base<"activate-service"> {
152
+ readonly service: ServiceID
153
+ readonly manager: ServiceManagerID
154
+ readonly confirm: "explicit"
155
+ }
156
+
157
+ /** v1.1: unloads a hub user service (a profile downgrade; needs --allow-downgrade). Rollback loads it again. */
158
+ export interface DeactivateService extends Base<"deactivate-service"> {
159
+ readonly service: ServiceID
160
+ readonly manager: ServiceManagerID
161
+ }
162
+
163
+ /**
164
+ * v1.1: removes a hub service unit (a profile downgrade; needs --allow-downgrade), only while it is still exactly
165
+ * the unit hub installed (`sha256`); a unit the user changed is a conflict, never removed. Rollback restores it.
166
+ */
167
+ export interface RemoveService extends Base<"remove-service"> {
168
+ readonly service: ServiceID
169
+ readonly manager: ServiceManagerID
170
+ readonly path: PathRef
171
+ readonly sha256: string
172
+ }
173
+
174
+ /** Device-code enrollment of this machine (the user approves it on the hub); carries no credential. */
175
+ export interface EnrollMachine extends Base<"enroll-machine"> {
176
+ readonly server: string
177
+ readonly machineName: string
178
+ }
179
+
180
+ export interface VerifyHarness extends Base<"verify-harness"> {
181
+ readonly harness: HarnessID
182
+ readonly probes: readonly Exclude<ProbeKind, "presence">[]
183
+ }
184
+
185
+ /** Opt-in check of a sandboxed runtime (OpenShell); never installs or configures it. */
186
+ export interface VerifyRuntime extends Base<"verify-runtime"> {
187
+ readonly runtime: RuntimeID
188
+ }
189
+
190
+ /** One bounded session launch to prove the setup end to end. */
191
+ export interface LaunchSession extends Base<"launch-session"> {
192
+ readonly harness: HarnessID
193
+ readonly workspace: PathRef
194
+ readonly maxDurationS: number
195
+ }
196
+
197
+ export type Action =
198
+ | EnsureDirectory
199
+ | InstallArtifact
200
+ | InstallHarness
201
+ | SelectHarnessVersion
202
+ | LinkExecutable
203
+ | WriteFile
204
+ | MergeConfig
205
+ | RegisterMcpServer
206
+ | InstallService
207
+ | ActivateService
208
+ | DeactivateService
209
+ | RemoveService
210
+ | EnrollMachine
211
+ | VerifyHarness
212
+ | VerifyRuntime
213
+ | LaunchSession
214
+
215
+ export type ActionKind = Action["kind"]
216
+
217
+ const COMMON = ["id", "kind", "escalation", "summary"] as const
218
+
219
+ /** Every field each kind accepts (all required unless listed in ACTION_OPTIONAL). */
220
+ export const ACTION_FIELDS: Readonly<Record<ActionKind, readonly string[]>> = Object.freeze({
221
+ "ensure-directory": [...COMMON, "path", "mode"],
222
+ "install-artifact": [...COMMON, "artifact", "version", "target", "sha256", "path"],
223
+ "install-harness": [...COMMON, "harness", "version", "target", "source", "size", "sha256", "evidence", "path", "owner", "rollback"],
224
+ "select-harness-version": [...COMMON, "harness", "version", "path", "target"],
225
+ "link-executable": [...COMMON, "path", "target"],
226
+ "write-file": [...COMMON, "path", "asset", "sha256", "mode", "onConflict"],
227
+ "merge-config": [...COMMON, "path", "format", "owner", "precondition", "set", "preserveUnknown"],
228
+ "register-mcp-server": [...COMMON, "harness", "path", "name", "precondition"],
229
+ "install-service": [...COMMON, "service", "manager", "path", "asset", "sha256"],
230
+ "activate-service": [...COMMON, "service", "manager", "confirm"],
231
+ "deactivate-service": [...COMMON, "service", "manager"],
232
+ "remove-service": [...COMMON, "service", "manager", "path", "sha256"],
233
+ "enroll-machine": [...COMMON, "server", "machineName"],
234
+ "verify-harness": [...COMMON, "harness", "probes"],
235
+ "verify-runtime": [...COMMON, "runtime"],
236
+ "launch-session": [...COMMON, "harness", "workspace", "maxDurationS"],
237
+ })
238
+
239
+ /** Optional fields per kind. */
240
+ export const ACTION_OPTIONAL: Readonly<Partial<Record<ActionKind, readonly string[]>>> = Object.freeze({ "install-harness": ["previous"], "select-harness-version": ["previous"] })
241
+
242
+ export const ACTION_KINDS = Object.keys(ACTION_FIELDS) as ActionKind[]
243
+
244
+ const ACTION_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
245
+ const ASSET_ID = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/
246
+ const MACHINE_NAME = /^[A-Za-z0-9._-]{1,64}$/
247
+ const MCP_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
248
+ const JSON_POINTER = /^(?:\/(?:[^~/]|~[01])*)+$/
249
+ const SERVICE_ROOT: Record<ServiceManagerID, string> = { launchd: "launch-agents", "systemd-user": "systemd-user" }
250
+ const EXEC = new Set<string>(EXEC_KEYS)
251
+
252
+ /** JSON pointers of every key named like EXEC_KEYS inside `value`. */
253
+ export function findExecKeys(value: unknown, base = ""): string[] {
254
+ const found: string[] = []
255
+ const walk = (v: unknown, path: string) => {
256
+ if (Array.isArray(v)) v.forEach((item, i) => walk(item, pointer(path, i)))
257
+ else if (isRecord(v))
258
+ for (const [key, item] of Object.entries(v)) {
259
+ const p = pointer(path, key)
260
+ if (EXEC.has(key.toLowerCase())) found.push(p)
261
+ walk(item, p)
262
+ }
263
+ }
264
+ walk(value, base)
265
+ return found
266
+ }
267
+
268
+ const mode = (c: Checker, path: string, v: unknown) => int(c, path, v, 0, 0o7777)
269
+ const sameRef = (v: unknown, ref: PathRef) => isRecord(v) && v.root === ref.root && v.rel === ref.rel
270
+
271
+ function checkPrevious(c: Checker, path: string, v: unknown) {
272
+ if (v === undefined) return
273
+ const prev = shape(c, path, v, ["version", "sha256"])
274
+ if (!prev) return
275
+ semver(c, pointer(path, "version"), prev.version)
276
+ digest(c, pointer(path, "sha256"), prev.sha256)
277
+ }
278
+
279
+ function checkServiceUnitPath(c: Checker, at: (key: string) => string, r: Record<string, unknown>) {
280
+ const p = r.path as PathRef | undefined
281
+ const root = typeof r.manager === "string" ? SERVICE_ROOT[r.manager as ServiceManagerID] : undefined
282
+ if (root && p && root !== p.root) c.add(at("path"), "inconsistent", `${String(r.manager)} units go in the ${root} root`)
283
+ }
284
+
285
+ export function checkPrecondition(c: Checker, path: string, v: unknown) {
286
+ const r = shape(c, path, v, ["exists"], ["sha256"])
287
+ if (!r) return
288
+ if (bool(c, pointer(path, "exists"), r.exists)) {
289
+ if (r.exists && r.sha256 === undefined) c.add(pointer(path, "sha256"), "missing_field", "an existing file is pinned by its digest")
290
+ if (!r.exists && r.sha256 !== undefined) c.add(pointer(path, "sha256"), "inconsistent", "a missing file has no digest")
291
+ }
292
+ if (r.sha256 !== undefined) digest(c, pointer(path, "sha256"), r.sha256)
293
+ }
294
+
295
+ function jsonValue(c: Checker, path: string, v: unknown, depth = 0): void {
296
+ if (depth > 32) return c.add(path, "invalid_value", "too deeply nested")
297
+ if (v === null || typeof v === "boolean" || typeof v === "string") return
298
+ if (typeof v === "number") return void (Number.isFinite(v) || c.add(path, "invalid_value", "not a finite number"))
299
+ if (Array.isArray(v)) return v.forEach((item, i) => jsonValue(c, pointer(path, i), item, depth + 1))
300
+ if (isRecord(v)) return Object.entries(v).forEach(([k, item]) => jsonValue(c, pointer(path, k), item, depth + 1))
301
+ c.add(path, "invalid_value", "not JSON")
302
+ }
303
+
304
+ /** A list of config sets: JSON pointers (never to a credential-named key) and JSON values. */
305
+ export function checkConfigSets(c: Checker, path: string, v: unknown) {
306
+ if (Array.isArray(v) && v.length === 0) c.add(path, "invalid_value", "a merge sets at least one key")
307
+ list(c, path, v, 64, (p, item) => {
308
+ const s = shape(c, p, item, ["pointer", "value"])
309
+ if (!s) return
310
+ if (text(c, pointer(p, "pointer"), s.pointer, 512, JSON_POINTER) && s.pointer.split("/").some((seg) => isSecretKey(seg)))
311
+ c.add(pointer(p, "pointer"), "secret", "credentials are never written by a config merge")
312
+ jsonValue(c, pointer(p, "value"), s.value)
313
+ })
314
+ }
315
+
316
+ /** Path-bearing fields of an action (for escalation and ownership checks). */
317
+ export function actionPaths(action: Action): PathRef[] {
318
+ const refs: PathRef[] = []
319
+ if ("path" in action) refs.push(action.path)
320
+ if (action.kind === "link-executable" || action.kind === "select-harness-version") refs.push(action.target)
321
+ if (action.kind === "launch-session") refs.push(action.workspace)
322
+ return refs
323
+ }
324
+
325
+ export function checkAction(c: Checker, path: string, value: unknown): void {
326
+ for (const p of findExecKeys(value, path)) c.add(p, "exec_field", "actions never carry commands")
327
+ if (!isRecord(value)) return c.add(path, "invalid_value", "expected an action object")
328
+ const kind = value.kind
329
+ if (!oneOf(c, pointer(path, "kind"), kind, ACTION_KINDS)) {
330
+ if (kind === undefined) c.add(pointer(path, "kind"), "missing_field", "missing kind")
331
+ return
332
+ }
333
+ const r = shape(c, path, value, ACTION_FIELDS[kind], ACTION_OPTIONAL[kind])!
334
+ text(c, pointer(path, "id"), r.id, 64, ACTION_ID)
335
+ oneOf(c, pointer(path, "escalation"), r.escalation, ["user", "elevated"] as const)
336
+ text(c, pointer(path, "summary"), r.summary)
337
+ const at = (key: string) => pointer(path, key)
338
+ const refs: unknown[] = []
339
+ const ref = (key: string) => {
340
+ if (r[key] === undefined) return
341
+ checkPathRef(c, at(key), r[key])
342
+ refs.push(r[key])
343
+ }
344
+ switch (kind) {
345
+ case "ensure-directory":
346
+ ref("path")
347
+ mode(c, at("mode"), r.mode)
348
+ break
349
+ case "install-artifact":
350
+ if (r.artifact !== "hubctl") c.add(at("artifact"), "invalid_value", "only the hubctl artifact is installed by the bootstrap")
351
+ semver(c, at("version"), r.version)
352
+ if (typeof r.target !== "string" || !supportedTarget(r.target)) c.add(at("target"), "unsupported_platform", "not a supported target")
353
+ digest(c, at("sha256"), r.sha256)
354
+ ref("path")
355
+ break
356
+ case "install-harness": {
357
+ const harnessOK = oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
358
+ semver(c, at("version"), r.version)
359
+ if (typeof r.target !== "string" || !supportedTarget(r.target)) c.add(at("target"), "unsupported_platform", "not a supported target")
360
+ const src = shape(c, at("source"), r.source, ["manifest", "url", "file"])
361
+ if (src) {
362
+ semver(c, pointer(at("source"), "manifest"), src.manifest)
363
+ httpsURL(c, pointer(at("source"), "url"), src.url)
364
+ if (typeof src.file !== "string" || !FILE_NAME.test(src.file)) c.add(pointer(at("source"), "file"), "invalid_path", "expected a plain file name")
365
+ }
366
+ int(c, at("size"), r.size, 1, 2 ** 40)
367
+ digest(c, at("sha256"), r.sha256)
368
+ if (harnessOK) subset(c, at("evidence"), r.evidence, HARNESS_EVIDENCE[r.harness as HarnessID])
369
+ ref("path")
370
+ if (r.owner !== "hub") c.add(at("owner"), "invalid_value", "harness installs are owned by hub (recorded in the journal)")
371
+ if (r.rollback !== "restore-previous") c.add(at("rollback"), "invalid_value", "harness installs roll back by restoring the previous version")
372
+ checkPrevious(c, at("previous"), r.previous)
373
+ if (harnessOK && typeof r.version === "string" && r.path !== undefined && !sameRef(r.path, harnessVersionRef(r.harness as HarnessID, r.version)))
374
+ c.add(at("path"), "inconsistent", `a harness version installs into its own directory data:${harnessVersionRef(r.harness as HarnessID, r.version).rel}`)
375
+ break
376
+ }
377
+ case "select-harness-version": {
378
+ const harnessOK = oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
379
+ const versionOK = semver(c, at("version"), r.version)
380
+ ref("path")
381
+ ref("target")
382
+ checkPrevious(c, at("previous"), r.previous)
383
+ if (harnessOK && r.path !== undefined && !sameRef(r.path, harnessCurrentRef(r.harness as HarnessID)))
384
+ c.add(at("path"), "inconsistent", `the current version is the link data:${harnessCurrentRef(r.harness as HarnessID).rel}`)
385
+ if (harnessOK && versionOK && r.target !== undefined && !sameRef(r.target, harnessVersionRef(r.harness as HarnessID, r.version as string)))
386
+ c.add(at("target"), "inconsistent", `the link points at the version directory data:${harnessVersionRef(r.harness as HarnessID, r.version as string).rel}`)
387
+ break
388
+ }
389
+ case "link-executable":
390
+ ref("path")
391
+ ref("target")
392
+ break
393
+ case "write-file":
394
+ ref("path")
395
+ text(c, at("asset"), r.asset, 128, ASSET_ID)
396
+ digest(c, at("sha256"), r.sha256)
397
+ mode(c, at("mode"), r.mode)
398
+ if (r.onConflict !== "refuse") c.add(at("onConflict"), "invalid_value", "conflicting files are always refused, never overwritten")
399
+ break
400
+ case "merge-config":
401
+ ref("path")
402
+ oneOf(c, at("format"), r.format, ["json", "jsonc", "toml"] as const)
403
+ oneOf(c, at("owner"), r.owner, CONFIG_OWNERS)
404
+ checkPrecondition(c, at("precondition"), r.precondition)
405
+ if (r.preserveUnknown !== true) c.add(at("preserveUnknown"), "invalid_value", "merges are always lossless")
406
+ checkConfigSets(c, at("set"), r.set)
407
+ break
408
+ case "register-mcp-server":
409
+ oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
410
+ ref("path")
411
+ text(c, at("name"), r.name, 32, MCP_NAME)
412
+ checkPrecondition(c, at("precondition"), r.precondition)
413
+ break
414
+ case "install-service": {
415
+ oneOf(c, at("service"), r.service, SERVICE_IDS)
416
+ oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
417
+ ref("path")
418
+ digest(c, at("sha256"), r.sha256)
419
+ const asset = typeof r.asset === "string" ? EMBEDDED_ASSETS[r.asset] : undefined
420
+ if (!asset) c.add(at("asset"), "invalid_value", "expected an embedded service template")
421
+ else if (asset.service !== r.service || asset.manager !== r.manager) c.add(at("asset"), "inconsistent", "the template is for another service or manager")
422
+ checkServiceUnitPath(c, at, r)
423
+ break
424
+ }
425
+ case "deactivate-service":
426
+ oneOf(c, at("service"), r.service, SERVICE_IDS)
427
+ oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
428
+ break
429
+ case "remove-service":
430
+ oneOf(c, at("service"), r.service, SERVICE_IDS)
431
+ oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
432
+ ref("path")
433
+ digest(c, at("sha256"), r.sha256)
434
+ checkServiceUnitPath(c, at, r)
435
+ break
436
+ case "activate-service":
437
+ oneOf(c, at("service"), r.service, SERVICE_IDS)
438
+ oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
439
+ if (r.confirm !== "explicit") c.add(at("confirm"), "invalid_value", "services are only activated on explicit confirmation")
440
+ break
441
+ case "enroll-machine":
442
+ hubURL(c, at("server"), r.server)
443
+ text(c, at("machineName"), r.machineName, 64, MACHINE_NAME)
444
+ break
445
+ case "verify-harness":
446
+ oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
447
+ if (Array.isArray(r.probes) && r.probes.length === 0) c.add(at("probes"), "invalid_value", "verification runs at least one real probe")
448
+ subset(c, at("probes"), r.probes, REAL_PROBES)
449
+ break
450
+ case "verify-runtime":
451
+ oneOf(c, at("runtime"), r.runtime, RUNTIME_IDS)
452
+ break
453
+ case "launch-session":
454
+ oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
455
+ ref("workspace")
456
+ int(c, at("maxDurationS"), r.maxDurationS, 1, 86_400)
457
+ break
458
+ }
459
+ // Escalation is explicit and exact: elevated iff the action touches an elevated root.
460
+ const touchesElevated = refs.some((p) => isRecord(p) && (ELEVATED_ROOTS as readonly unknown[]).includes(p.root))
461
+ if (r.escalation === "user" && touchesElevated) c.add(at("escalation"), "inconsistent", "writing a system location needs escalation: elevated")
462
+ if (r.escalation === "elevated" && !touchesElevated) c.add(at("escalation"), "inconsistent", "only actions on system locations are elevated")
463
+ for (const p of findSecrets(value, path)) c.add(p, "secret", "actions never carry credentials")
464
+ }
465
+
466
+ export function validateAction(value: unknown): Result<Action> {
467
+ const c = new Checker()
468
+ checkAction(c, "", value)
469
+ return c.result(value)
470
+ }