@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,192 @@
1
+ // The reviewed harness pin list: the checked-in input (PIN_LIST_FILE, schema `hub.harness-pin-list` v1) that the
2
+ // release job turns into the signed manifest's `harnesses` (contracts §3.6.1, I5).
3
+ //
4
+ // Format:
5
+ // { "schema": "hub.harness-pin-list", "version": 1,
6
+ // "pins": [ { "pin": <HarnessPin, exactly as the manifest lists it>,
7
+ // "review": { "by": "<GitHub handle>", "on": "YYYY-MM-DD",
8
+ // "checked": ["upstream-release", "digest-redownloaded", "evidence-listed"] } } ] }
9
+ //
10
+ // Review process (see README.md next to this file):
11
+ // - A pin changes only through a pull request that touches this file; the reviewer is someone other than the
12
+ // author, and the `review` record names who checked the pin, when, and that every required check was done:
13
+ // upstream-release the URL is the upstream's own release asset for exactly this version and target;
14
+ // digest-redownloaded the reviewer downloaded the file and computed the size and sha256 themselves;
15
+ // evidence-listed `evidence` lists exactly the upstream evidence published for this file.
16
+ // - Each pin is checked with the manifest's own rules (supported target, exact version, https, plain file name,
17
+ // one per harness and target, evidence the harness can offer) plus: the file is the URL's last path segment,
18
+ // and the URL has no credentials, query or fragment. Nothing secret-shaped may appear anywhere.
19
+ // - The release job re-downloads every pinned file and refuses to sign on any size or digest mismatch
20
+ // (verifyPinDownloads), so the signature never vouches for a file nobody fetched in that release.
21
+ import { createHash } from "node:crypto"
22
+ import {
23
+ Checker,
24
+ HARNESS_ORDER,
25
+ fail,
26
+ findSecrets,
27
+ header,
28
+ isRecord,
29
+ list,
30
+ ok,
31
+ pointer,
32
+ shape,
33
+ text,
34
+ validateArtifactManifest,
35
+ type HarnessPin,
36
+ type Result,
37
+ } from "../contracts/index.ts"
38
+
39
+ export const PIN_LIST_SCHEMA = "hub.harness-pin-list"
40
+ export const PIN_LIST_VERSION = 1
41
+ /** The checked-in pin list, relative to the repository root. */
42
+ export const PIN_LIST_FILE = "packages/cli/src/bootstrap/release/harness-pins.json"
43
+
44
+ /** What a reviewer must have checked for each pin (all of them, every time). */
45
+ export const PIN_CHECKS = ["upstream-release", "digest-redownloaded", "evidence-listed"] as const
46
+ export type PinCheck = (typeof PIN_CHECKS)[number]
47
+
48
+ export interface PinReview {
49
+ /** The reviewer's GitHub handle. */
50
+ readonly by: string
51
+ /** The review date, YYYY-MM-DD. */
52
+ readonly on: string
53
+ readonly checked: readonly PinCheck[]
54
+ }
55
+
56
+ export interface ReviewedPin {
57
+ readonly pin: HarnessPin
58
+ readonly review: PinReview
59
+ }
60
+
61
+ export interface PinList {
62
+ readonly schema: typeof PIN_LIST_SCHEMA
63
+ readonly version: typeof PIN_LIST_VERSION
64
+ readonly pins: readonly ReviewedPin[]
65
+ }
66
+
67
+ const HANDLE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/
68
+ const DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
69
+ const MAX_PINS = 64
70
+
71
+ /** A stand-in hubctl artifact, so the pins can be checked by the manifest validator itself. */
72
+ const SKELETON = {
73
+ schema: "hub.artifact-manifest",
74
+ version: 1,
75
+ product: "hubctl",
76
+ release: "0.0.0",
77
+ channel: "stable",
78
+ minimumVersion: "0.0.0",
79
+ issuedAt: 0,
80
+ artifacts: [{ target: "linux-x64", file: "hubctl-linux-x64", size: 1, sha256: `sha256:${"0".repeat(64)}` }],
81
+ signatures: [],
82
+ } as const
83
+
84
+ /** "/harnesses/3/url" → "/pins/3/pin/url"; "/harnesses/3" → "/pins/3". */
85
+ function pinPath(path: string): string | undefined {
86
+ const m = /^\/harnesses\/(\d+)(\/.*)?$/.exec(path)
87
+ if (!m) return undefined
88
+ return m[2] ? `/pins/${m[1]}/pin${m[2]}` : `/pins/${m[1]}`
89
+ }
90
+
91
+ function checkReview(c: Checker, p: string, v: unknown) {
92
+ const r = shape(c, p, v, ["by", "on", "checked"])
93
+ if (!r) return
94
+ text(c, pointer(p, "by"), r.by, 39, HANDLE)
95
+ text(c, pointer(p, "on"), r.on, 10, DATE)
96
+ if (!list(c, pointer(p, "checked"), r.checked, PIN_CHECKS.length, (q, item) => {
97
+ if (typeof item !== "string" || !(PIN_CHECKS as readonly string[]).includes(item)) c.add(q, "invalid_value", `expected one of ${PIN_CHECKS.join(", ")}`)
98
+ }, (item) => (typeof item === "string" ? item : undefined))) return
99
+ for (const check of PIN_CHECKS) if (!(r.checked as unknown[]).includes(check)) c.add(pointer(p, "checked"), "missing_field", `the review did not record ${check}`)
100
+ }
101
+
102
+ function checkSource(c: Checker, p: string, pin: unknown) {
103
+ if (!isRecord(pin) || typeof pin.url !== "string") return
104
+ let url: URL
105
+ try {
106
+ url = new URL(pin.url)
107
+ } catch {
108
+ return // the manifest rules report it
109
+ }
110
+ if (url.username || url.password || url.search || url.hash) c.add(pointer(p, "url"), "invalid_value", "a pinned URL carries no credentials, query or fragment")
111
+ const last = decodeURIComponent(url.pathname.split("/").pop() ?? "")
112
+ if (typeof pin.file === "string" && last !== pin.file) c.add(pointer(p, "file"), "inconsistent", `the file must be the URL's last path segment (${last})`)
113
+ }
114
+
115
+ export function validatePinList(value: unknown): Result<PinList> {
116
+ const c = new Checker()
117
+ const r = shape(c, "", value, ["schema", "version", "pins"])
118
+ if (!r) return c.result(value)
119
+ header(c, "", r, PIN_LIST_SCHEMA, PIN_LIST_VERSION)
120
+ const pins: unknown[] = []
121
+ const listed = list(c, "/pins", r.pins, MAX_PINS, (p, entry) => {
122
+ const e = shape(c, p, entry, ["pin", "review"])
123
+ pins.push(e?.pin)
124
+ if (!e) return
125
+ if (e.review !== undefined) checkReview(c, pointer(p, "review"), e.review)
126
+ checkSource(c, pointer(p, "pin"), e.pin)
127
+ })
128
+ if (listed) {
129
+ const m = validateArtifactManifest({ ...SKELETON, harnesses: pins.map((p) => p ?? {}) })
130
+ if (!m.ok) for (const issue of m.issues) {
131
+ const path = pinPath(issue.path)
132
+ if (path !== undefined) c.add(path, issue.code, issue.message)
133
+ }
134
+ }
135
+ for (const path of findSecrets(value)) c.add(path, "secret", "a pin list never carries a secret")
136
+ return c.result(value)
137
+ }
138
+
139
+ const targetOrder = (a: HarnessPin, b: HarnessPin) =>
140
+ HARNESS_ORDER.indexOf(a.harness) - HARNESS_ORDER.indexOf(b.harness) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0)
141
+
142
+ /** The manifest's `harnesses`: the pins in HARNESS_ORDER, then by target (deterministic). */
143
+ export function harnessPins(list: PinList): HarnessPin[] {
144
+ return list.pins.map((p) => p.pin).sort(targetOrder)
145
+ }
146
+
147
+ export type Downloader = (url: string) => Promise<{ readonly size: number; readonly sha256: string }>
148
+
149
+ /**
150
+ * Re-downloads every pinned file and compares its size and digest with the pin. Any mismatch or failed download is
151
+ * integrity_mismatch; every pin is checked and reported. Returns the manifest's `harnesses` when all match.
152
+ */
153
+ export async function verifyPinDownloads(value: unknown, download: Downloader): Promise<Result<HarnessPin[]>> {
154
+ const valid = validatePinList(value)
155
+ if (!valid.ok) return valid
156
+ const c = new Checker()
157
+ for (const [i, { pin }] of valid.value.pins.entries()) {
158
+ const at = `/pins/${i}/pin`
159
+ try {
160
+ const got = await download(pin.url)
161
+ if (got.size !== pin.size) c.add(`${at}/size`, "integrity_mismatch", `${pin.harness} ${pin.version} ${pin.target}: expected ${pin.size} bytes, downloaded ${got.size}`)
162
+ else if (got.sha256 !== pin.sha256) c.add(`${at}/sha256`, "integrity_mismatch", `${pin.harness} ${pin.version} ${pin.target}: the download does not match the pinned digest`)
163
+ } catch (error) {
164
+ c.add(`${at}/url`, "integrity_mismatch", `${pin.harness} ${pin.version} ${pin.target}: could not download (${error instanceof Error ? error.message : String(error)})`)
165
+ }
166
+ }
167
+ if (c.issues.length > 0) return { ok: false, issues: c.issues }
168
+ return ok(harnessPins(valid.value))
169
+ }
170
+
171
+ /** Streams a URL and returns its size and digest (redirects followed); throws on an HTTP error. */
172
+ export async function httpDigest(url: string, fetcher: typeof fetch = fetch): Promise<{ size: number; sha256: string }> {
173
+ const response = await fetcher(url, { redirect: "follow" })
174
+ if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`)
175
+ const hash = createHash("sha256")
176
+ let size = 0
177
+ const reader = response.body.getReader()
178
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
179
+ hash.update(next.value)
180
+ size += next.value.byteLength
181
+ }
182
+ return { size, sha256: `sha256:${hash.digest("hex")}` }
183
+ }
184
+
185
+ /** Reads a pin list document (JSON text). */
186
+ export function parsePinList(text: string): Result<PinList> {
187
+ try {
188
+ return validatePinList(JSON.parse(text))
189
+ } catch {
190
+ return fail("", "invalid_value", "the pin list is not JSON")
191
+ }
192
+ }
@@ -0,0 +1,9 @@
1
+ // Service templates imported `with { type: "text" }` (assets.ts): Bun gives their contents as a string.
2
+ declare module "*.plist" {
3
+ const text: string
4
+ export default text
5
+ }
6
+ declare module "*.service" {
7
+ const text: string
8
+ export default text
9
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schema": "hub.trust-root",
3
+ "version": 1,
4
+ "threshold": 1,
5
+ "keys": [
6
+ {
7
+ "keyID": "hub-release-1",
8
+ "algorithm": "ed25519",
9
+ "publicKey": "MCowBQYDK2VwAyEAedYGFamR6BeN2QG7eorgc1ilpboyFzEiwaOE+n8bzVQ=",
10
+ "status": "active"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,39 @@
1
+ // The trust root a standalone hubctl ships (contracts §3.6, I6): public keys only, embedded at build time with
2
+ // `bun build --compile --define HUBCTL_TRUST_ROOT=<JSON string>` (trustRootDefine). It is never read from the
3
+ // environment or the disk at run time, so nothing on the machine can swap it; replacing it goes through
4
+ // rotateTrustRoot. A build without it (a source run, the npm bundle, a dev build) has no trust root and refuses
5
+ // every manifest, rather than trusting one by default.
6
+ import { canonicalJSON, fail, findSecrets, validateTrustRoot, type Result, type TrustRoot } from "../contracts/index.ts"
7
+
8
+ /** The identifier `bun build --define` replaces with the trust root's canonical JSON (a string literal). */
9
+ export const TRUST_ROOT_DEFINE = "HUBCTL_TRUST_ROOT"
10
+
11
+ declare const HUBCTL_TRUST_ROOT: string | undefined
12
+
13
+ /** The embedded trust root text, or undefined when this build has none. */
14
+ export function embeddedTrustRootText(): string | undefined {
15
+ return typeof HUBCTL_TRUST_ROOT === "string" ? HUBCTL_TRUST_ROOT : undefined
16
+ }
17
+
18
+ /** The shipped trust root, validated; signature_invalid when the build has none or it is unusable. */
19
+ export function shippedTrustRoot(text: string | undefined = embeddedTrustRootText()): Result<TrustRoot> {
20
+ if (text === undefined) return fail("", "signature_invalid", "this hubctl build has no trust root, so it cannot verify releases (use a signed release build)")
21
+ let value: unknown
22
+ try {
23
+ value = JSON.parse(text)
24
+ } catch {
25
+ return fail("", "signature_invalid", "the embedded trust root is not JSON")
26
+ }
27
+ const r = validateTrustRoot(value)
28
+ if (!r.ok) return fail("", "signature_invalid", `the embedded trust root is unusable: ${r.issues.map((i) => `${i.path} ${i.message}`).join("; ")}`)
29
+ if (findSecrets(value).length > 0) return fail("", "signature_invalid", "the embedded trust root carries key material that is not a public key")
30
+ return r
31
+ }
32
+
33
+ /** The `bun build` words that embed `root`; throws on anything but a valid, public-keys-only trust root. */
34
+ export function trustRootDefine(root: TrustRoot): string[] {
35
+ const text = canonicalJSON(root)
36
+ const r = shippedTrustRoot(text)
37
+ if (!r.ok) throw new Error(r.issues.map((i) => i.message).join("; "))
38
+ return ["--define", `${TRUST_ROOT_DEFINE}=${JSON.stringify(text)}`]
39
+ }
@@ -0,0 +1,88 @@
1
+ // The hubctl release verification library: everything an installer checks about a downloaded hubctl before it
2
+ // touches the disk, in the order that keeps untrusted data inert:
3
+ // 1. the manifest is well formed and signed by `threshold` active keys of the shipped trust root (T1, T2);
4
+ // 2. it lists an artifact for exactly this host's target, and the host is a supported one (T3);
5
+ // 3. the downloaded bytes have exactly that size and digest (T1);
6
+ // 4. the install is not a downgrade below the security floor, nor an unconsented downgrade (T4).
7
+ // Only the contracts are used (node:crypto, no dependencies), so the release signing job can run it without any
8
+ // npm install.
9
+ import { createHash } from "node:crypto"
10
+ import {
11
+ checkArtifactBytes,
12
+ compareVersions,
13
+ decideInstall,
14
+ fail,
15
+ ok,
16
+ selectArtifact,
17
+ verifyManifest,
18
+ type ArtifactEntry,
19
+ type InstallKind,
20
+ type Result,
21
+ type SignatureVerifier,
22
+ type TrustRoot,
23
+ type VerifiedManifest,
24
+ } from "../contracts/index.ts"
25
+
26
+ export interface ReleaseCheckInput {
27
+ /** The parsed manifest document (untrusted). */
28
+ readonly manifest: unknown
29
+ /** The trust root this hubctl ships (or a rotated successor accepted through rotateTrustRoot). */
30
+ readonly root: TrustRoot
31
+ /** This host's target (targetOf); never a "close enough" one. */
32
+ readonly target: string
33
+ /** Size and digest of the downloaded hubctl (digestBytes). */
34
+ readonly bytes: { readonly sha256: string; readonly size: number }
35
+ /** The hubctl installed now, if any. */
36
+ readonly installed?: { readonly version: string; readonly sha256: string }
37
+ /** The highest security floor this machine has seen in a verified manifest. */
38
+ readonly floor?: string
39
+ /** Explicit --allow-downgrade (never below the floor). */
40
+ readonly allowDowngrade?: boolean
41
+ readonly verifier?: SignatureVerifier
42
+ }
43
+
44
+ export interface AcceptedRelease {
45
+ readonly manifest: VerifiedManifest
46
+ readonly artifact: ArtifactEntry
47
+ readonly kind: InstallKind
48
+ /** The security floor to persist after the install: max(seen floor, manifest floor). */
49
+ readonly floor: string
50
+ }
51
+
52
+ /** Largest manifest accepted (a real one is a few kilobytes). */
53
+ export const MAX_MANIFEST_BYTES = 1_048_576
54
+
55
+ const maxVersion = (a: string, b?: string) => (b !== undefined && compareVersions(b, a) > 0 ? b : a)
56
+
57
+ export function verifyHubctlRelease(input: ReleaseCheckInput): Result<AcceptedRelease> {
58
+ const verified = verifyManifest(input.manifest, input.root, input.verifier)
59
+ if (!verified.ok) return verified
60
+ const manifest = verified.value
61
+ const artifact = selectArtifact(manifest, input.target)
62
+ if (!artifact.ok) return artifact
63
+ const bytes = checkArtifactBytes(artifact.value, input.bytes)
64
+ if (!bytes.ok) return bytes
65
+ const kind = decideInstall({
66
+ candidate: { release: manifest.release, minimumVersion: manifest.minimumVersion, sha256: input.bytes.sha256 },
67
+ ...(input.installed ? { installed: input.installed } : {}),
68
+ ...(input.floor !== undefined ? { floor: input.floor } : {}),
69
+ ...(input.allowDowngrade !== undefined ? { allowDowngrade: input.allowDowngrade } : {}),
70
+ })
71
+ if (!kind.ok) return kind
72
+ return ok({ manifest, artifact: artifact.value, kind: kind.value, floor: maxVersion(manifest.minimumVersion, input.floor) })
73
+ }
74
+
75
+ /** Size and "sha256:<hex>" of some bytes, the form manifests list. */
76
+ export function digestBytes(bytes: Uint8Array): { size: number; sha256: string } {
77
+ return { size: bytes.byteLength, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` }
78
+ }
79
+
80
+ /** JSON text of a downloaded manifest, bounded; the document still needs verifyHubctlRelease. */
81
+ export function parseManifest(text: string): Result<unknown> {
82
+ if (text.length > MAX_MANIFEST_BYTES) return fail("", "invalid_value", `a manifest is at most ${MAX_MANIFEST_BYTES} bytes`)
83
+ try {
84
+ return ok(JSON.parse(text) as unknown)
85
+ } catch {
86
+ return fail("", "invalid_value", "the manifest is not JSON")
87
+ }
88
+ }
@@ -0,0 +1,312 @@
1
+ // The built-in executors: the local file, config and directory steps, plus the two artifact installs whose bytes
2
+ // must be authenticated before use. Each only computes what the target should become; the engine stages, journals
3
+ // and replaces it atomically.
4
+ //
5
+ // ensure-directory a directory (an existing directory is left as it is)
6
+ // write-file an embedded/rendered asset whose bytes must match the plan's digest; never overwrites
7
+ // merge-config a lossless JSON merge, only if the file is still as previewed (precondition digest)
8
+ // link-executable the hub ↔ hubctl alias symlink (an existing entry is backed up and replaced)
9
+ // select-harness-version v1.1: the harness's `current` link, switched atomically to an installed version
10
+ // directory (relative, so it reads the same inside and outside a container); the previous
11
+ // link is backed up, so a rollback makes the previous version current again
12
+ // install-service the rendered unit, whose bytes must match the plan's digest
13
+ // deactivate-service v1.1: unloads a hub service through the injected ServiceControl (an external effect; its
14
+ // undo loads it again)
15
+ // remove-service v1.1: removes the unit, only while it is still exactly the one hub installed (backed up, so
16
+ // a rollback restores it)
17
+ // install-artifact hubctl from a verified manifest: exact target, pinned digest, downgrade policy, bytes checked
18
+ // (hubctl's own executable is digested and copied in chunks, never read whole)
19
+ // install-harness a harness pinned by the verified manifest: checkHarnessPin, downgrade policy, the pinned
20
+ // digest of the download first, then the upstream evidence, then the adapter's unpacker
21
+ //
22
+ // Fetching and unpacking come from injected, typed sources (no shell, no generic exec); verification always
23
+ // happens here, so a source cannot skip it. Harness downloads stream into the step's staging area, which digests
24
+ // them as it writes: the pin is checked against that digest, never one the source reports, before anything is
25
+ // unpacked; the unpacker streams the archive's members into the staging area too (bounded memory, stream.ts).
26
+ import {
27
+ checkArtifactBytes,
28
+ checkHarnessEvidence,
29
+ checkHarnessPin,
30
+ decideHarnessInstall,
31
+ decideInstall,
32
+ isSafeRel,
33
+ selectArtifact,
34
+ type ArtifactEntry,
35
+ type EvidenceResult,
36
+ type HarnessID,
37
+ type InstallHarness,
38
+ type InstallService,
39
+ type ServiceID,
40
+ type ServiceManagerID,
41
+ type VerifiedManifest,
42
+ } from "../contracts/index.ts"
43
+ import type { ActionExecutor, ExecutorMap, StepContext, TreeFile } from "./executor.ts"
44
+ import { mergeConfigText } from "./json-merge.ts"
45
+ import { fromResult, txFail, txOk, type TxResult } from "./result.ts"
46
+ import { sha256Of } from "./state.ts"
47
+ import { fileDigest, type StagedFile, type StagingArea } from "./stream.ts"
48
+
49
+ export interface HubctlSource {
50
+ /** The release manifest, already verified against the shipped trust root. */
51
+ readonly manifest: VerifiedManifest
52
+ /**
53
+ * The release file for `entry`: its bytes, or the path of a local copy (hubctl's own standalone executable), which
54
+ * the executor digests and the engine copies in chunks. Either way the executor checks it against the pin.
55
+ */
56
+ fetch(entry: ArtifactEntry): Promise<Uint8Array | { readonly path: string }>
57
+ /** The hubctl currently installed, if any (downgrade policy). */
58
+ readonly installed?: { readonly version: string; readonly sha256: string }
59
+ /** The highest security floor this machine has seen. */
60
+ readonly floor?: string
61
+ /** Explicit --allow-downgrade. */
62
+ readonly allowDowngrade?: boolean
63
+ }
64
+
65
+ export interface HarnessSource {
66
+ readonly manifest: VerifiedManifest
67
+ /**
68
+ * Downloads the pinned file into `staging` (streamed, staging.write) and runs the upstream evidence checks the
69
+ * action lists (adapter-specific).
70
+ */
71
+ fetch(action: InstallHarness, staging: StagingArea): Promise<{ readonly file: StagedFile; readonly evidence: readonly EvidenceResult[] }>
72
+ /**
73
+ * Unpacks the verified download into the install tree (adapter-specific): large members are streamed into
74
+ * `staging` and named as staged files; nothing else is written.
75
+ */
76
+ unpack(action: InstallHarness, file: StagedFile, staging: StagingArea): Promise<TxResult<readonly TreeFile[]>>
77
+ installed?(harness: HarnessID): { readonly version: string; readonly sha256: string } | undefined
78
+ floor?(harness: HarnessID): string | undefined
79
+ readonly allowDowngrade?: boolean
80
+ }
81
+
82
+ /**
83
+ * v1.1: the typed client for the user's service manager (launchd / systemd --user), supplied by the integrator
84
+ * (HBS-13). It names a service and manager, never a command; how it reaches the manager is its own business.
85
+ */
86
+ export interface ServiceControl {
87
+ deactivate(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<void>>
88
+ /** Loads a service again (the undo of a deactivation). */
89
+ activate(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<void>>
90
+ }
91
+
92
+ export interface BuiltinDeps {
93
+ /** Embedded or rendered assets by id (write-file). */
94
+ readonly assets?: (asset: string) => Uint8Array | undefined
95
+ /** The rendered unit for an install-service action (HBS-13 renders EMBEDDED_ASSETS with serviceArgv). */
96
+ readonly renderService?: (action: InstallService) => Uint8Array | undefined
97
+ readonly hubctl?: HubctlSource
98
+ readonly harness?: HarnessSource
99
+ /** v1.1: deactivate-service (and its undo). Without it, deactivation is refused at preflight. */
100
+ readonly services?: ServiceControl
101
+ }
102
+
103
+ const ok = txOk(undefined)
104
+ const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
105
+
106
+ export const ensureDirectoryExecutor = (): ActionExecutor<"ensure-directory"> => ({
107
+ kind: "ensure-directory",
108
+ plan: (action) => ({ class: "directory", path: action.path }),
109
+ change: async (action) => txOk({ type: "directory", mode: action.mode }),
110
+ })
111
+
112
+ /** Bytes whose digest must equal the plan's pin. */
113
+ function pinned(bytes: Uint8Array | undefined, sha256: string, what: string): TxResult<Uint8Array> {
114
+ if (!bytes) return txFail("not_found", `${what} is not available in this hubctl`)
115
+ if (sha256Of(bytes) !== sha256) return txFail("integrity_mismatch", `${what} does not match the digest in the plan`)
116
+ return txOk(bytes)
117
+ }
118
+
119
+ export const writeFileExecutor = (assets: BuiltinDeps["assets"] = () => undefined): ActionExecutor<"write-file"> => ({
120
+ kind: "write-file",
121
+ plan: (action) => ({ class: "file", path: action.path }),
122
+ check: async (action) => {
123
+ const r = pinned(assets(action.asset), action.sha256, `asset ${action.asset}`)
124
+ return r.ok ? ok : r
125
+ },
126
+ change: async (action) => {
127
+ const r = pinned(assets(action.asset), action.sha256, `asset ${action.asset}`)
128
+ return r.ok ? txOk({ type: "file", bytes: r.value, mode: action.mode, replace: "refuse" }) : r
129
+ },
130
+ })
131
+
132
+ function preconditionHolds(ctx: StepContext, precondition: { readonly exists: boolean; readonly sha256?: string }): boolean {
133
+ const o = ctx.original
134
+ if (o.symlink) return false
135
+ return precondition.exists ? o.exists && o.sha256 === precondition.sha256 : !o.exists
136
+ }
137
+
138
+ export const mergeConfigExecutor = (): ActionExecutor<"merge-config"> => ({
139
+ kind: "merge-config",
140
+ plan: (action) => ({ class: "config", path: action.path }),
141
+ check: async (action, ctx) =>
142
+ preconditionHolds(ctx, action.precondition) ? ok : txFail("conflict", `${ctx.target} changed since the preview; nothing was overwritten (preview again)`),
143
+ change: async (action, ctx) => {
144
+ const text = ctx.current.exists ? decode(ctx.fs.readFile(ctx.target!)) : undefined
145
+ const merged = mergeConfigText(text, action.format, action.set)
146
+ if (!merged.ok) return merged
147
+ if (merged.value === text) return txOk({ type: "none", reason: "already merged" })
148
+ return txOk({ type: "file", bytes: new TextEncoder().encode(merged.value), mode: ctx.current.mode ?? 0o600, replace: "backup" })
149
+ },
150
+ })
151
+
152
+ export const linkExecutableExecutor = (): ActionExecutor<"link-executable"> => ({
153
+ kind: "link-executable",
154
+ plan: (action) => ({ class: "link", path: action.path, resolve: { finalMayBeSymlink: true } }),
155
+ change: async (action, ctx) => {
156
+ // The link's own path is symlink-checked (it is what gets written). Its target is only the link's text, never
157
+ // written through, and may lead through a harness's `current` link, which is a symlink by design (§3.6.1).
158
+ const target = ctx.resolve(action.target, { targetOnly: true })
159
+ return target.ok ? txOk({ type: "symlink", target: target.value, replace: "backup" }) : target
160
+ },
161
+ })
162
+
163
+ export const selectHarnessVersionExecutor = (): ActionExecutor<"select-harness-version"> => ({
164
+ kind: "select-harness-version",
165
+ plan: (action) => ({ class: "link", path: action.path, resolve: { finalMayBeSymlink: true } }),
166
+ change: async (action, ctx) => {
167
+ const dir = ctx.resolve(action.target)
168
+ if (!dir.ok) return dir
169
+ if (ctx.fs.lstat(dir.value)?.kind !== "directory") return txFail("not_found", `${action.harness} ${action.version} is not installed (${dir.value} is not a directory)`)
170
+ // `current` and the version directory are siblings (contract harnessCurrentRef / harnessVersionRef).
171
+ return txOk({ type: "symlink", target: action.version, replace: "backup" })
172
+ },
173
+ })
174
+
175
+ export const installServiceExecutor = (render: BuiltinDeps["renderService"] = () => undefined): ActionExecutor<"install-service"> => ({
176
+ kind: "install-service",
177
+ plan: (action) => ({ class: "service", path: action.path }),
178
+ check: async (action) => {
179
+ const r = pinned(render(action), action.sha256, `the ${action.service} ${action.manager} unit`)
180
+ return r.ok ? ok : r
181
+ },
182
+ change: async (action) => {
183
+ const r = pinned(render(action), action.sha256, `the ${action.service} ${action.manager} unit`)
184
+ return r.ok ? txOk({ type: "file", bytes: r.value, mode: 0o644, replace: "backup" }) : r
185
+ },
186
+ })
187
+
188
+ export const deactivateServiceExecutor = (services?: ServiceControl): ActionExecutor<"deactivate-service"> => ({
189
+ kind: "deactivate-service",
190
+ plan: () => ({ class: "external" }),
191
+ check: async () => (services ? ok : missing("service manager")),
192
+ change: async (action) => (services ? txOk({ type: "effect", perform: () => services.deactivate(action.service, action.manager) }) : missing("service manager")),
193
+ undo: async (action) => (services ? services.activate(action.service, action.manager) : missing("service manager")),
194
+ })
195
+
196
+ export const removeServiceExecutor = (): ActionExecutor<"remove-service"> => ({
197
+ kind: "remove-service",
198
+ plan: (action) => ({ class: "service", path: action.path }),
199
+ check: async (action, ctx) =>
200
+ !ctx.original.exists || (ctx.original.sha256 === action.sha256 && !ctx.original.symlink)
201
+ ? ok
202
+ : txFail("conflict", `${ctx.target} is not the ${action.service} unit hub installed (it changed since); nothing was removed`),
203
+ change: async (action, ctx) => (ctx.current.exists ? txOk({ type: "remove" }) : txOk({ type: "none", reason: `the ${action.service} unit is already gone` })),
204
+ })
205
+
206
+ const missing = (what: string) => txFail("internal", `this hubctl was built without a ${what} source`)
207
+
208
+ export const installArtifactExecutor = (source?: HubctlSource): ActionExecutor<"install-artifact"> => {
209
+ const entryFor = (action: { readonly target: string; readonly version: string; readonly sha256: string }, host: string): TxResult<ArtifactEntry> => {
210
+ if (!source) return missing("hubctl release")
211
+ if (action.target !== host) return txFail("wrong_platform", `the plan installs hubctl for ${action.target}, but this host is ${host}`)
212
+ const entry = fromResult(selectArtifact(source.manifest, action.target))
213
+ if (!entry.ok) return entry
214
+ if (source.manifest.release !== action.version || entry.value.sha256 !== action.sha256)
215
+ return txFail("integrity_mismatch", `the plan's hubctl ${action.version} is not the artifact the verified ${source.manifest.release} manifest pins`)
216
+ const decision = fromResult(
217
+ decideInstall({
218
+ candidate: { release: source.manifest.release, minimumVersion: source.manifest.minimumVersion, sha256: entry.value.sha256 },
219
+ installed: source.installed,
220
+ floor: source.floor,
221
+ allowDowngrade: source.allowDowngrade,
222
+ }),
223
+ )
224
+ return decision.ok ? entry : decision
225
+ }
226
+ return {
227
+ kind: "install-artifact",
228
+ plan: (action) => ({ class: "artifact", path: action.path }),
229
+ check: async (action, ctx) => {
230
+ const entry = entryFor(action, ctx.host.target)
231
+ return entry.ok ? ok : entry
232
+ },
233
+ change: async (action, ctx) => {
234
+ const entry = entryFor(action, ctx.host.target)
235
+ if (!entry.ok) return entry
236
+ const fetched = await source!.fetch(entry.value)
237
+ if (fetched instanceof Uint8Array) {
238
+ const checked = fromResult(checkArtifactBytes(entry.value, { sha256: sha256Of(fetched), size: fetched.byteLength }))
239
+ if (!checked.ok) return checked
240
+ return txOk({ type: "file", bytes: fetched, mode: 0o755, replace: "backup" })
241
+ }
242
+ const info = ctx.fs.lstat(fetched.path)
243
+ if (info?.kind !== "file") return txFail("not_found", `${fetched.path} is not a regular file`)
244
+ const staged = { path: fetched.path, sha256: fileDigest(ctx.fs, fetched.path), size: info.size }
245
+ const checked = fromResult(checkArtifactBytes(entry.value, { sha256: staged.sha256, size: staged.size }))
246
+ if (!checked.ok) return checked
247
+ // The engine copies it in chunks and checks this digest again while it copies.
248
+ return txOk({ type: "file", staged, mode: 0o755, replace: "backup" })
249
+ },
250
+ }
251
+ }
252
+
253
+ export const installHarnessExecutor = (source?: HarnessSource): ActionExecutor<"install-harness"> => {
254
+ const pinFor = (action: InstallHarness, host: string) => {
255
+ if (!source) return missing("harness")
256
+ if (action.target !== host) return txFail("wrong_platform", `the plan installs ${action.harness} for ${action.target}, but this host is ${host}`)
257
+ const pin = fromResult(checkHarnessPin(source.manifest, action))
258
+ if (!pin.ok) return pin
259
+ const decision = fromResult(
260
+ decideHarnessInstall(pin.value, { installed: source.installed?.(action.harness) ?? action.previous, floor: source.floor?.(action.harness), allowDowngrade: source.allowDowngrade }),
261
+ )
262
+ return decision.ok ? pin : decision
263
+ }
264
+ return {
265
+ kind: "install-harness",
266
+ plan: (action) => ({ class: "harness", path: action.path }),
267
+ check: async (action, ctx) => {
268
+ const pin = pinFor(action, ctx.host.target)
269
+ return pin.ok ? ok : pin
270
+ },
271
+ change: async (action, ctx) => {
272
+ const pin = pinFor(action, ctx.host.target)
273
+ if (!pin.ok) return pin
274
+ const download = await source!.fetch(action, ctx.staging)
275
+ // The digest the staging area recorded while it wrote the download (never one the source reports).
276
+ const written = ctx.staging.written(download.file)
277
+ if (!written) return txFail("internal", `the ${action.harness} source did not download into the transaction's staging area`)
278
+ // The pinned digest first (mandatory), then the upstream evidence; only verified bytes reach the unpacker.
279
+ const evidence = fromResult(checkHarnessEvidence(action, written, download.evidence))
280
+ if (!evidence.ok) return evidence
281
+ ctx.recordEvidence(evidence.value)
282
+ const files = await source!.unpack(action, { path: download.file.path, ...written }, ctx.staging)
283
+ if (!files.ok) return files
284
+ const seen = new Set<string>()
285
+ for (const f of files.value) {
286
+ if (!isSafeRel(f.rel) || seen.has(f.rel) || !Number.isInteger(f.mode) || f.mode < 0 || f.mode > 0o777) return txFail("invalid", `the ${action.harness} archive has an unsafe entry ${JSON.stringify(f.rel)}`)
287
+ // Unpacked members are files the staging area wrote, with the digest it recorded.
288
+ if ("staged" in f && ctx.staging.written(f.staged)?.sha256 !== f.staged.sha256) return txFail("invalid", `the ${action.harness} archive's ${JSON.stringify(f.rel)} was not unpacked into the staging area`)
289
+ seen.add(f.rel)
290
+ }
291
+ for (const rel of seen) if ([...seen].some((other) => other.startsWith(`${rel}/`))) return txFail("invalid", `the ${action.harness} archive uses ${rel} as both a file and a directory`)
292
+ if (seen.size === 0) return txFail("invalid", `the ${action.harness} archive is empty`)
293
+ return txOk({ type: "tree", files: files.value, mode: 0o755 })
294
+ },
295
+ }
296
+ }
297
+
298
+ /** The built-in executors for the local kinds; adapters add the rest (see executor.ts). */
299
+ export function builtinExecutors(deps: BuiltinDeps): ExecutorMap {
300
+ return {
301
+ "ensure-directory": ensureDirectoryExecutor(),
302
+ "write-file": writeFileExecutor(deps.assets),
303
+ "merge-config": mergeConfigExecutor(),
304
+ "link-executable": linkExecutableExecutor(),
305
+ "select-harness-version": selectHarnessVersionExecutor(),
306
+ "install-service": installServiceExecutor(deps.renderService),
307
+ "deactivate-service": deactivateServiceExecutor(deps.services),
308
+ "remove-service": removeServiceExecutor(),
309
+ "install-artifact": installArtifactExecutor(deps.hubctl),
310
+ "install-harness": installHarnessExecutor(deps.harness),
311
+ }
312
+ }