@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,160 @@
1
+ // The Claude Code HarnessSource (transaction/builtin.ts installHarnessExecutor): where the pinned binary comes from,
2
+ // which upstream evidence is attempted for it, and how it becomes the install tree. The executor checks the pin and
3
+ // the downloaded bytes' sha256 itself (mandatory, contracts §3.6.1); this source only adds the optional evidence:
4
+ //
5
+ // gpg-manifest upstream's per-release manifest.json and its detached signature, checked in-house
6
+ // (openpgp.ts) against the pinned release-signing key; the signed manifest must list exactly
7
+ // these bytes for this platform and version. No gpg binary is needed.
8
+ // macos-notarization on macOS, /usr/bin/codesign (fixed argv, through the injected runner) on a private scratch
9
+ // copy of the downloaded bytes: signed by Anthropic PBC's Developer ID, then notarized.
10
+ //
11
+ // The binary streams into the step's staging area (never held whole: HBS-12); the signed manifest is compared with
12
+ // the digest the staging area recorded, and codesign gets a scratch copy written in chunks.
13
+ //
14
+ // Each is recorded as verified, unavailable (could not be checked here: no codesign, offline, unsigned release,
15
+ // unknown key) or failed (checked and wrong); a failure rejects the artifact (checkHarnessEvidence). Nothing here
16
+ // runs a shell, reads a credential or touches ~/.claude.
17
+ import type { EvidenceResult, HarnessID, InstallHarness, VerifiedManifest } from "../../contracts/index.ts"
18
+ import type { HarnessSource } from "../../transaction/builtin.ts"
19
+ import type { TreeFile } from "../../transaction/executor.ts"
20
+ import { TxError, txFail, txOk, type TxResult } from "../../transaction/result.ts"
21
+ import { collect, TooLarge, type ByteReader, type StagedFile } from "../../transaction/stream.ts"
22
+ import { CLAUDE_SIGNING_FINGERPRINT, verifyDetachedSignature } from "./openpgp.ts"
23
+ import { DownloadTooLarge, type Downloader, type ProbeRunner, type Scratch } from "./ports.ts"
24
+
25
+ export const UPSTREAM_RELEASES = "https://downloads.claude.ai/claude-code-releases"
26
+ export const UPSTREAM_KEY_URL = "https://downloads.claude.ai/keys/claude-code.asc"
27
+ /** Anthropic PBC's Apple Developer ID team (codesign -dv: "Developer ID Application: Anthropic PBC (Q6L2SF6YDW)"). */
28
+ export const ANTHROPIC_TEAM_ID = "Q6L2SF6YDW"
29
+ const IDENTITY = `anchor apple generic and certificate leaf[subject.OU] = "${ANTHROPIC_TEAM_ID}"`
30
+
31
+ /** The fixed codesign invocations; the scratch file's path is the last argument. */
32
+ export const CODESIGN = Object.freeze({
33
+ program: "/usr/bin/codesign",
34
+ identity: Object.freeze(["--verify", "--strict", `-R=${IDENTITY}`]),
35
+ notarized: Object.freeze(["--verify", "--strict", `-R=notarized and ${IDENTITY}`]),
36
+ })
37
+
38
+ const MANIFEST_MAX = 1 << 20
39
+ const SIGNATURE_MAX = 64 << 10
40
+ const KEY_MAX = 64 << 10
41
+ const CODESIGN_TIMEOUT_MS = 60_000
42
+ const UPSTREAM_BINARY = /^https:\/\/downloads\.claude\.ai\/claude-code-releases\/([0-9A-Za-z.+-]+)\/([a-z0-9-]+)\/claude$/
43
+
44
+ /** Upstream's platform name for a hub target (the same names; upstream's musl builds are not hub targets). */
45
+ export function upstreamPlatform(target: string): string | undefined {
46
+ return ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"].includes(target) ? target : undefined
47
+ }
48
+
49
+ export interface ClaudeSourceOptions {
50
+ readonly manifest: VerifiedManifest
51
+ readonly download: Downloader
52
+ /** codesign for macos-notarization; without it (or the scratch) notarization is unavailable. */
53
+ readonly runner?: ProbeRunner
54
+ readonly scratch?: Scratch
55
+ /** process.platform of this host. */
56
+ readonly platform: string
57
+ /** The release-signing key's pinned fingerprint (default CLAUDE_SIGNING_FINGERPRINT). */
58
+ readonly signingKey?: { readonly fingerprint: string }
59
+ /** What hub installed before (the history), for the downgrade policy. */
60
+ readonly installed?: { readonly version: string; readonly sha256: string }
61
+ readonly floor?: string
62
+ readonly allowDowngrade?: boolean
63
+ }
64
+
65
+ const result = (kind: EvidenceResult["kind"], status: EvidenceResult["status"], detail: string): EvidenceResult => ({ kind, status, detail: detail.replace(/\s+/g, " ").slice(0, 200) })
66
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
67
+
68
+ async function gpgManifest(options: ClaudeSourceOptions, action: InstallHarness, file: StagedFile): Promise<EvidenceResult> {
69
+ const kind = "gpg-manifest"
70
+ const m = UPSTREAM_BINARY.exec(action.source.url)
71
+ const platform = upstreamPlatform(action.target)
72
+ if (!m || m[1] !== action.version || !platform || m[2] !== platform) return result(kind, "unavailable", "the pinned URL is not upstream's release bucket for this version and platform")
73
+ const base = `${UPSTREAM_RELEASES}/${action.version}`
74
+ let manifest: Uint8Array
75
+ let signature: Uint8Array
76
+ let key: Uint8Array
77
+ try {
78
+ const small = async (url: string, max: number) => collect(await options.download.get(url, max), max)
79
+ ;[manifest, signature, key] = await Promise.all([small(`${base}/manifest.json`, MANIFEST_MAX), small(`${base}/manifest.json.sig`, SIGNATURE_MAX), small(UPSTREAM_KEY_URL, KEY_MAX)])
80
+ } catch {
81
+ return result(kind, "unavailable", `the signed manifest of ${action.version} could not be downloaded (upstream signs releases from 2.1.89 on)`)
82
+ }
83
+ const fingerprint = options.signingKey?.fingerprint ?? CLAUDE_SIGNING_FINGERPRINT
84
+ const signed = verifyDetachedSignature({ data: manifest, signature, publicKey: key, fingerprint })
85
+ if (signed.status !== "verified") return result(kind, signed.status, `manifest.json: ${signed.detail}`)
86
+ let doc: unknown
87
+ try {
88
+ doc = JSON.parse(new TextDecoder().decode(manifest))
89
+ } catch {
90
+ return result(kind, "failed", "the signed manifest.json is not JSON")
91
+ }
92
+ const entry = isRecord(doc) && isRecord(doc.platforms) ? doc.platforms[platform] : undefined
93
+ if (!isRecord(doc) || doc.version !== action.version) return result(kind, "failed", `the signed manifest is not for ${action.version}`)
94
+ const expected = action.sha256.replace(/^sha256:/, "")
95
+ if (!isRecord(entry) || entry.checksum !== expected || entry.size !== action.size || file.sha256 !== `sha256:${entry.checksum}`)
96
+ return result(kind, "failed", `the signed manifest lists other bytes for ${platform} than hub's pin`)
97
+ return result(kind, "verified", `upstream manifest.json signed by ${fingerprint} lists this binary`)
98
+ }
99
+
100
+ async function notarization(options: ClaudeSourceOptions, action: InstallHarness, binary: ByteReader): Promise<EvidenceResult> {
101
+ const kind = "macos-notarization"
102
+ if (options.platform !== "darwin" || !action.target.startsWith("darwin-")) return result(kind, "unavailable", "notarization is checked on macOS only")
103
+ const { runner, scratch } = options
104
+ if (!runner || !scratch) return result(kind, "unavailable", "no codesign runner in this hubctl")
105
+ const run = (args: readonly string[], file: string) => runner.run(CODESIGN.program, [...args, file], { env: { PATH: "/usr/bin:/bin" }, timeoutMs: CODESIGN_TIMEOUT_MS, maxOutputBytes: 64 << 10 })
106
+ try {
107
+ return await scratch.withFile(binary.stream(), async (file) => {
108
+ const identity = await run(CODESIGN.identity, file)
109
+ if (identity.kind !== "exited") return result(kind, "unavailable", `codesign could not run (${identity.kind})`)
110
+ if (identity.code !== 0) return result(kind, "failed", `the binary is not validly signed by Anthropic PBC (${ANTHROPIC_TEAM_ID}); codesign exit ${identity.code}`)
111
+ const notarized = await run(CODESIGN.notarized, file)
112
+ if (notarized.kind === "exited" && notarized.code === 0) return result(kind, "verified", `signed by Anthropic PBC (${ANTHROPIC_TEAM_ID}) and notarized by Apple`)
113
+ return result(kind, "unavailable", `signed by Anthropic PBC (${ANTHROPIC_TEAM_ID}), but the notarization could not be confirmed (offline?)`)
114
+ })
115
+ } catch {
116
+ return result(kind, "unavailable", "no scratch copy for codesign")
117
+ }
118
+ }
119
+
120
+ /** Mach-O (64-bit, either byte order, or universal) for macOS; ELF for Linux. */
121
+ function nativeFor(target: string, bytes: Uint8Array): boolean {
122
+ const head = [...bytes.subarray(0, 4)]
123
+ const is = (magic: readonly number[]) => magic.every((b, i) => head[i] === b)
124
+ if (target.startsWith("darwin-")) return [[0xcf, 0xfa, 0xed, 0xfe], [0xfe, 0xed, 0xfa, 0xcf], [0xca, 0xfe, 0xba, 0xbe]].some(is)
125
+ if (target.startsWith("linux-")) return is([0x7f, 0x45, 0x4c, 0x46])
126
+ return false
127
+ }
128
+
129
+ export function createClaudeHarnessSource(options: ClaudeSourceOptions): HarnessSource {
130
+ return {
131
+ manifest: options.manifest,
132
+ async fetch(action, staging) {
133
+ if (action.harness !== "claude-code") throw new Error(`the Claude Code source cannot install ${action.harness}`)
134
+ // One byte more than pinned is still fetched, so a longer file is an integrity mismatch, not a download error.
135
+ // The body streams into the staging area; a longer one fails as soon as it runs past the limit.
136
+ let file: StagedFile
137
+ try {
138
+ file = await staging.write("claude", await options.download.get(action.source.url, action.size + 1), { maxBytes: action.size + 1 })
139
+ } catch (error) {
140
+ if (error instanceof DownloadTooLarge || error instanceof TooLarge) throw new TxError("integrity_mismatch", `${action.source.url} is larger than the pinned ${action.size} bytes`)
141
+ throw new TxError("offline", `${action.source.url} could not be downloaded`)
142
+ }
143
+ const evidence: EvidenceResult[] = []
144
+ for (const kind of action.evidence) {
145
+ if (kind === "gpg-manifest") evidence.push(await gpgManifest(options, action, file))
146
+ else if (kind === "macos-notarization") evidence.push(await notarization(options, action, staging.open(file)))
147
+ }
148
+ return { file, evidence }
149
+ },
150
+ async unpack(action, file, staging): Promise<TxResult<readonly TreeFile[]>> {
151
+ if (action.source.file !== "claude") return txFail("invalid", `the Claude Code pin must be upstream's raw binary "claude", not ${JSON.stringify(action.source.file)}`)
152
+ if (!nativeFor(action.target, staging.open(file).read(0, 4))) return txFail("invalid", `the downloaded file is not a native ${action.target} executable`)
153
+ // The tree is the staged download itself: the engine moves it into place (no copy).
154
+ return txOk([{ rel: "claude", staged: file, mode: 0o755 }])
155
+ },
156
+ installed: (harness: HarnessID) => (harness === "claude-code" ? options.installed : undefined),
157
+ floor: (harness: HarnessID) => (harness === "claude-code" ? options.floor : undefined),
158
+ allowDowngrade: options.allowDowngrade,
159
+ }
160
+ }
@@ -0,0 +1,165 @@
1
+ // Claude Code readiness (contracts §3.10): REQUIRED_PROBES["claude-code"], each real, passing and fresh.
2
+ //
3
+ // version-exec the binary hub resolved (hub's current version, or the program a wrapper or link resolves to;
4
+ // never just the shim on PATH) runs `claude --version` (fixed argv, minimal environment, updates
5
+ // switched off) and prints the expected version.
6
+ // provider-auth `claude auth status` (fixed argv) exits 0 with a login method: the user's own login, made by
7
+ // the user on this machine (`claude auth login`). hubctl never reads, copies or keeps a Claude
8
+ // credential: the probe's output is parsed for the login method only and then dropped, and the
9
+ // child gets the user's environment minus hub's own variables.
10
+ // spoke-connected the injected spoke probe sees the claude-code hooks spoke connected, and the injected
11
+ // conformance probe passes. Both are required: without either, the harness is not ready.
12
+ //
13
+ // Not ready means no capability: claudeReadiness offers "claude-code" only when every probe passes. The
14
+ // verify-harness step classifies the probes (contract v1.2): an integrity failure (the binary) fails it with
15
+ // harness_not_ready (so the transaction rolls back); a login or spoke that waits on the user fails it only with
16
+ // requireReady (the default), and otherwise passes as "installed, not ready".
17
+ import { harnessReadiness, isHarnessReady, type AdapterContext, type HarnessVerification, type Probe } from "../../contracts/index.ts"
18
+ import type { ActionExecutor } from "../../transaction/executor.ts"
19
+ import { txFail, txOk } from "../../transaction/result.ts"
20
+ import { passesAsPending, type ReadinessGate } from "../readiness.ts"
21
+ import type { ProbeRunner, RunResult } from "./ports.ts"
22
+
23
+ export const VERSION_ARGV: readonly string[] = Object.freeze(["--version"])
24
+ export const AUTH_STATUS_ARGV: readonly string[] = Object.freeze(["auth", "status"])
25
+ /** Upstream's documented login methods (`claude auth status` JSON `authMethod`), "none" excluded. */
26
+ export const AUTH_METHODS: readonly string[] = ["claude.ai", "oauth_token", "api_key", "api_key_helper", "third_party"]
27
+ /** What the spoke must be (packages/claude-code daemon: harness claude-code, evidence hooks). */
28
+ export const CLAUDE_SPOKE = { harness: "claude-code", evidence: "hooks" } as const
29
+
30
+ const VERSION_TIMEOUT_MS = 15_000
31
+ const AUTH_TIMEOUT_MS = 30_000
32
+ const OUTPUT_MAX = 64 << 10
33
+ const NO_UPDATES = { DISABLE_AUTOUPDATER: "1", DISABLE_UPDATES: "1" } as const
34
+
35
+ /** The binary to verify, or why there is none. */
36
+ export type ClaudeBinary = { readonly path: string; readonly version?: string } | { readonly problem: string }
37
+
38
+ export interface SpokeHello {
39
+ readonly connected: boolean
40
+ readonly harness?: string
41
+ readonly evidence?: string
42
+ readonly features?: readonly string[]
43
+ }
44
+
45
+ export interface ClaudeProbes {
46
+ readonly runner: ProbeRunner
47
+ readonly binary: () => ClaudeBinary
48
+ /** Is the claude-code spoke connected to the hub (HBS-13: the daemon socket or the hub's machine view)? */
49
+ readonly spoke?: () => Promise<SpokeHello>
50
+ /** Does the connected spoke pass conformance? */
51
+ readonly conformance?: () => Promise<{ readonly ok: boolean; readonly detail?: string }>
52
+ }
53
+
54
+ const line = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 200)
55
+ const why = (r: RunResult) => (r.kind === "exited" ? `exit ${r.code}` : r.kind === "error" ? "could not start" : r.kind)
56
+
57
+ /** The user's environment for a probe, minus hub's own variables, with updates switched off. */
58
+ function userEnv(env: AdapterContext["env"]): Record<string, string> {
59
+ const out: Record<string, string> = {}
60
+ for (const [k, v] of Object.entries(env)) if (v !== undefined && !k.startsWith("HUB_") && k !== "BUN_BE_BUN") out[k] = v
61
+ return { ...out, ...NO_UPDATES }
62
+ }
63
+
64
+ async function versionProbe(probes: ClaudeProbes, ctx: AdapterContext, bin: { path: string; version?: string }): Promise<Omit<Probe, "observedAt">> {
65
+ const env = { PATH: ctx.env.PATH ?? "/usr/bin:/bin", HOME: ctx.home, ...NO_UPDATES }
66
+ const r = await probes.runner.run(bin.path, VERSION_ARGV, { env, timeoutMs: VERSION_TIMEOUT_MS, maxOutputBytes: OUTPUT_MAX })
67
+ if (r.kind !== "exited" || r.code !== 0) return { kind: "version-exec", ok: false, detail: `${bin.path} --version did not run (${why(r)})` }
68
+ const version = /^\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(r.stdout)?.[1]
69
+ if (!version) return { kind: "version-exec", ok: false, detail: `${bin.path} --version printed no version` }
70
+ if (bin.version && version !== bin.version) return { kind: "version-exec", ok: false, detail: `${bin.path} is ${version}, not the expected ${bin.version}` }
71
+ return { kind: "version-exec", ok: true, detail: `${version} at ${bin.path}` }
72
+ }
73
+
74
+ async function authProbe(probes: ClaudeProbes, ctx: AdapterContext, bin: { path: string }): Promise<Omit<Probe, "observedAt">> {
75
+ const login = "not logged in: log in on this machine yourself (`claude auth login`, or run `claude`)"
76
+ const r = await probes.runner.run(bin.path, AUTH_STATUS_ARGV, { env: userEnv(ctx.env), timeoutMs: AUTH_TIMEOUT_MS, maxOutputBytes: OUTPUT_MAX })
77
+ if (r.kind !== "exited") return { kind: "provider-auth", ok: false, detail: `claude auth status did not run (${why(r)})` }
78
+ if (r.code !== 0) return { kind: "provider-auth", ok: false, detail: login }
79
+ // Only the login method is read; everything else the command printed is dropped here.
80
+ let method: unknown
81
+ try {
82
+ method = (JSON.parse(r.stdout) as { authMethod?: unknown }).authMethod
83
+ } catch {}
84
+ if (method === "none") return { kind: "provider-auth", ok: false, detail: login }
85
+ return { kind: "provider-auth", ok: true, detail: typeof method === "string" && AUTH_METHODS.includes(method) ? `logged in (${method})` : "logged in" }
86
+ }
87
+
88
+ async function spokeProbe(probes: ClaudeProbes): Promise<Omit<Probe, "observedAt">> {
89
+ const fail = (detail: string) => ({ kind: "spoke-connected" as const, ok: false, detail })
90
+ if (!probes.spoke) return fail("no spoke probe in this hubctl")
91
+ if (!probes.conformance) return fail("no conformance probe in this hubctl")
92
+ let hello: SpokeHello
93
+ try {
94
+ hello = await probes.spoke()
95
+ } catch {
96
+ return fail("the claude-code spoke could not be reached")
97
+ }
98
+ if (!hello.connected) return fail("the claude-code spoke is not connected to the hub (is the daemon running and the machine enrolled?)")
99
+ if (hello.harness !== CLAUDE_SPOKE.harness || hello.evidence !== CLAUDE_SPOKE.evidence) return fail(`the connected spoke is ${hello.harness ?? "?"}/${hello.evidence ?? "?"}, not ${CLAUDE_SPOKE.harness}/${CLAUDE_SPOKE.evidence}`)
100
+ try {
101
+ const c = await probes.conformance()
102
+ if (!c.ok) return fail(`spoke conformance failed${c.detail ? `: ${c.detail}` : ""}`)
103
+ } catch {
104
+ return fail("spoke conformance could not run")
105
+ }
106
+ return { kind: "spoke-connected", ok: true, detail: "claude-code hooks spoke connected and conformant" }
107
+ }
108
+
109
+ /** Runs every required probe (passing or not) and reports each. */
110
+ export async function verifyClaude(probes: ClaudeProbes, ctx: AdapterContext): Promise<HarnessVerification> {
111
+ const bin = probes.binary()
112
+ const found: Omit<Probe, "observedAt">[] = []
113
+ if ("problem" in bin) {
114
+ found.push({ kind: "version-exec", ok: false, detail: bin.problem }, { kind: "provider-auth", ok: false, detail: `not checked: ${bin.problem}` })
115
+ } else {
116
+ found.push(await versionProbe(probes, ctx, bin), await authProbe(probes, ctx, bin))
117
+ }
118
+ found.push(await spokeProbe(probes))
119
+ const observedAt = ctx.now()
120
+ return { harness: "claude-code", probes: found.map((p) => ({ ...p, detail: line(p.detail ?? ""), observedAt })) }
121
+ }
122
+
123
+ export interface ClaudeReadiness {
124
+ readonly ready: boolean
125
+ /** Offered only when ready. */
126
+ readonly capability?: "claude-code"
127
+ readonly problems: readonly string[]
128
+ }
129
+
130
+ export function claudeReadiness(verification: HarnessVerification, now: number): ClaudeReadiness {
131
+ const ready = verification.harness === "claude-code" && isHarnessReady(verification, now)
132
+ const problems = verification.probes.filter((p) => !p.ok).map((p) => `${p.kind}: ${p.detail ?? "failed"}`)
133
+ if (ready) return { ready, capability: "claude-code", problems: [] }
134
+ return { ready, problems: problems.length > 0 ? problems : ["the probes are missing or stale"] }
135
+ }
136
+
137
+ export interface ClaudeVerifyOptions extends ReadinessGate {
138
+ readonly verify: () => Promise<HarnessVerification>
139
+ readonly now: () => number
140
+ /** Sees every verification (ready or not), e.g. to record it or offer the capability. */
141
+ readonly onVerified?: (verification: HarnessVerification) => void
142
+ }
143
+
144
+ /** The verify-harness step for claude-code: an external effect that fails unless ready (or, v1.2, only pending). */
145
+ export function claudeVerifyExecutor(options: ClaudeVerifyOptions): ActionExecutor<"verify-harness"> {
146
+ return {
147
+ kind: "verify-harness",
148
+ plan: () => ({ class: "external" }),
149
+ check: async (action) => (action.harness === "claude-code" ? txOk(undefined) : txFail("internal", `the Claude Code verifier cannot verify ${action.harness}`)),
150
+ change: async (action) =>
151
+ txOk({
152
+ type: "effect",
153
+ perform: async () => {
154
+ const verification = await options.verify()
155
+ options.onVerified?.(verification)
156
+ const classified = harnessReadiness(verification, options.now(), action.probes)
157
+ options.onReadiness?.(structuredClone(classified))
158
+ const readiness = claudeReadiness(verification, options.now())
159
+ if (readiness.ready) return txOk(undefined)
160
+ if (verification.harness === "claude-code" && passesAsPending(classified, options)) return txOk(undefined)
161
+ return txFail("harness_not_ready", `Claude Code is not ready: ${readiness.problems.join("; ")}`)
162
+ },
163
+ }),
164
+ }
165
+ }
@@ -0,0 +1,103 @@
1
+ // Unpacks a Codex release archive (.tar.gz) into the immutable install tree, without a tar binary, streaming: the
2
+ // package inflates to ~0.5 GB, so the archive is read from the staging area in chunks (archive-stream.ts) and each
3
+ // member is streamed into its own staging file, hashed as it is written (HBS-12; nothing is held whole). It only
4
+ // ever sees a download the install-harness executor has already checked against hub's pinned digest
5
+ // (CodexHarnessSource.unpack), and it is still strict, because the tree is installed as is:
6
+ //
7
+ // - regular files and directories only: symlinks, hard links, devices and FIFOs are refused;
8
+ // - every name must be a safe relative path (no "..", no absolute path, no empty segment), after pax and GNU
9
+ // long-name records; duplicates are refused; header checksums must match;
10
+ // - modes keep only the permission bits (no setuid, setgid or sticky);
11
+ // - two layouts are accepted: the upstream package (bin/codex plus its helpers, codex-package-<triple>.tar.gz,
12
+ // what Codex's own install.sh installs) and the standalone binary (one codex-<triple> file, which becomes
13
+ // bin/codex). Either way the tree has bin/codex.
14
+ import { isSafeRel } from "../../contracts/index.ts"
15
+ import type { TreeFile } from "../../transaction/executor.ts"
16
+ import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
17
+ import { bytesReader, memoryStaging, type ByteReader, type StagedFile, type StagingArea } from "../../transaction/stream.ts"
18
+ import { ArchiveError, readTarGz } from "../archive-stream.ts"
19
+
20
+ /** The executable inside an installed Codex tree. */
21
+ export const CODEX_BINARY_REL = "bin/codex"
22
+ /** Upstream archives inflate to ~0.5 GB today; anything far beyond that is refused, as it inflates. */
23
+ export const MAX_UNPACKED_BYTES = 2 * 1024 ** 3 - 1
24
+ /** pax and GNU long-name records are small; a larger one is not a name. */
25
+ const MAX_NAME_RECORD = 64 * 1024
26
+ const STANDALONE = /^codex-[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+)?$/
27
+
28
+ const bad = (why: string) => txFail("invalid", `the Codex archive is not acceptable: ${why}`)
29
+
30
+ function field(bytes: Uint8Array): string {
31
+ const end = bytes.indexOf(0)
32
+ return new TextDecoder().decode(end < 0 ? bytes : bytes.subarray(0, end))
33
+ }
34
+
35
+ /** "<len> key=value\n" records of a pax extended header. */
36
+ function paxPath(body: Uint8Array): string | undefined {
37
+ const text = new TextDecoder().decode(body)
38
+ let path: string | undefined
39
+ let at = 0
40
+ while (at < text.length) {
41
+ const space = text.indexOf(" ", at)
42
+ const len = Number(text.slice(at, space))
43
+ if (space < 0 || !Number.isInteger(len) || len <= 0) break
44
+ const record = text.slice(space + 1, at + len - 1)
45
+ const eq = record.indexOf("=")
46
+ if (eq > 0 && record.slice(0, eq) === "path") path = record.slice(eq + 1)
47
+ at += len
48
+ }
49
+ return path
50
+ }
51
+
52
+ const normalize = (name: string) => name.replace(/^(?:\.\/)+/, "").replace(/\/+$/, "")
53
+
54
+ /**
55
+ * The install tree of a verified Codex .tar.gz read from `reader`: every file is streamed into `staging` (judged
56
+ * before its body is read). `maxBytes` (default MAX_UNPACKED_BYTES) bounds what the archive inflates to.
57
+ */
58
+ export async function unpackCodexFile(reader: ByteReader, staging: StagingArea, options: { readonly maxBytes?: number } = {}): Promise<TxResult<TreeFile[]>> {
59
+ const files: { rel: string; staged: StagedFile; mode: number }[] = []
60
+ const seen = new Set<string>()
61
+ let nextName: string | undefined
62
+ try {
63
+ await readTarGz(reader, { maxBytes: options.maxBytes ?? MAX_UNPACKED_BYTES }, async (member) => {
64
+ const { type } = member.header
65
+ if (type === "x") {
66
+ nextName = paxPath(await member.bytes(MAX_NAME_RECORD)) ?? nextName
67
+ return
68
+ }
69
+ if (type === "g") return
70
+ if (type === "L") {
71
+ nextName = field(await member.bytes(MAX_NAME_RECORD))
72
+ return
73
+ }
74
+ const raw = nextName ?? member.header.name
75
+ nextName = undefined
76
+ if (raw.startsWith("/")) throw new ArchiveError(`${JSON.stringify(raw)} is an absolute path`)
77
+ const name = normalize(raw)
78
+ if (type === "5") {
79
+ if (name !== "" && !isSafeRel(name)) throw new ArchiveError(`${JSON.stringify(raw)} leaves the install directory`)
80
+ return
81
+ }
82
+ if (type !== "0" && type !== "7") throw new ArchiveError(`${JSON.stringify(raw)} is not a regular file (links and devices are never installed)`)
83
+ if (!isSafeRel(name)) throw new ArchiveError(`${JSON.stringify(raw)} leaves the install directory`)
84
+ if (seen.has(name)) throw new ArchiveError(`${name} appears twice`)
85
+ seen.add(name)
86
+ const staged = await staging.write(name, member.body(), { maxBytes: member.header.size })
87
+ files.push({ rel: name, staged, mode: (member.header.mode ?? 0o644) & 0o777 })
88
+ })
89
+ } catch (error) {
90
+ if (error instanceof ArchiveError) return bad(error.message)
91
+ throw error
92
+ }
93
+ if (files.length === 1 && STANDALONE.test(files[0]!.rel)) return txOk([{ rel: CODEX_BINARY_REL, staged: files[0]!.staged, mode: 0o755 }])
94
+ if (!seen.has(CODEX_BINARY_REL)) return bad(`it has no ${CODEX_BINARY_REL}`)
95
+ return txOk(files)
96
+ }
97
+
98
+ /** unpackCodexFile over bytes in memory, the tree as bytes (small archives: tests, fixtures). */
99
+ export async function unpackCodexArchive(bytes: Uint8Array, options: { readonly maxBytes?: number } = {}): Promise<TxResult<(TreeFile & { readonly bytes: Uint8Array })[]>> {
100
+ const staging = memoryStaging()
101
+ const r = await unpackCodexFile(bytesReader(bytes), staging, options)
102
+ return r.ok ? txOk(r.value.map((f) => ({ rel: f.rel, mode: f.mode, bytes: "bytes" in f ? f.bytes : staging.bytes(f.staged) }))) : r
103
+ }
@@ -0,0 +1,60 @@
1
+ // The Codex bootstrap adapter (HBS-09). Codex is the third harness in HARNESS_ORDER.
2
+ //
3
+ // createCodexAdapter the HarnessAdapter: discover (presence only), plan (planCodex: pinned install-harness,
4
+ // select-harness-version, verify-harness with real probes; no ~/.codex edit unless the
5
+ // opt-in TOML edit, which becomes a manual edit), verify (verifyCodex)
6
+ // createCodexHarnessSource the HarnessSource for the built-in install-harness executor (pinned download,
7
+ // sigstore evidence, unpack)
8
+ // codexVerifyExecutor the verify-harness executor (harness_not_ready unless advertised ready)
9
+ // codexCapabilities the explicit, probe-backed capability advertisement
10
+ //
11
+ // Hub wiring reuses packages/codex: the app-server handshake (openAppServer) and stdio channel the Codex spoke uses
12
+ // probe the app-server, and the spoke itself (hub codex-spoke serve) is what spoke-connected checks for.
13
+ import type { DiscoverySnapshot, HarnessAdapter, HarnessPresence, VerifiedManifest } from "../../contracts/index.ts"
14
+ import { codexPinFor, planCodex } from "./plan.ts"
15
+ import { codexBinaryPath, verifyCodex, type CodexProbes } from "./verify.ts"
16
+
17
+ export * from "./archive.ts"
18
+ export * from "./sigstore.ts"
19
+ export * from "./source.ts"
20
+ export * from "./plan.ts"
21
+ export * from "./verify.ts"
22
+
23
+ export interface CodexAdapterOptions {
24
+ /** The verified hub release manifest (its codex pins). */
25
+ readonly manifest: VerifiedManifest
26
+ /** The injected probe capabilities; the binary and expected version come from the snapshot and the pin. */
27
+ readonly probes: Omit<CodexProbes, "binary" | "expectedVersion"> & Partial<Pick<CodexProbes, "binary">>
28
+ /** The discovery snapshot of this host (discover reads presence from it). */
29
+ readonly snapshot: () => DiscoverySnapshot | undefined
30
+ /** The hub-installed Codex recorded before (profile history), for rollback records. */
31
+ readonly previous?: { readonly version: string; readonly sha256: string }
32
+ /** Opt-in: ask for features.daemon_auto_start in ~/.codex/config.toml (a TOML manual edit). */
33
+ readonly ensureDaemonFeature?: boolean
34
+ }
35
+
36
+ export interface CodexAdapter extends HarnessAdapter {
37
+ readonly options: CodexAdapterOptions
38
+ }
39
+
40
+ const NOT_FOUND: HarnessPresence = { id: "codex", installed: false, health: { state: "unverified", probes: [] } }
41
+
42
+ export function createCodexAdapter(options: CodexAdapterOptions): CodexAdapter {
43
+ const pinFor = (snapshot: DiscoverySnapshot | undefined) => (snapshot ? codexPinFor(options.manifest, snapshot.host.target) : undefined)
44
+ return {
45
+ id: "codex",
46
+ options,
47
+ async discover() {
48
+ const found = options.snapshot()?.harnesses.find((h) => h.id === "codex")
49
+ // Presence only: health stays unverified until verify runs the real probes.
50
+ return found ? { ...found, health: { state: "unverified", probes: [] } } : NOT_FOUND
51
+ },
52
+ plan: (input) => planCodex(input, { manifest: options.manifest, ...(options.previous ? { previous: options.previous } : {}), ...(options.ensureDaemonFeature ? { ensureDaemonFeature: true } : {}) }),
53
+ async verify(context) {
54
+ const snapshot = options.snapshot()
55
+ const pin = pinFor(snapshot)
56
+ const binary = options.probes.binary ?? (() => (snapshot ? codexBinaryPath(snapshot, { preferHub: pin !== undefined }) : undefined))
57
+ return verifyCodex(context, { ...options.probes, binary, ...(pin ? { expectedVersion: pin.version } : {}) })
58
+ },
59
+ }
60
+ }
@@ -0,0 +1,129 @@
1
+ // The Codex adapter's planner (HarnessAdapter.plan): pure in (snapshot, profile) and the adapter's fixed options.
2
+ //
3
+ // install-harness the artifact hub's verified manifest pins for Codex on this host's exact target, into
4
+ // its own version directory (harnessVersionRef); evidence: exactly what the pin lists
5
+ // (sigstore on Linux, none on macOS). No pin for the target: no install is planned (an
6
+ // unpinned Codex is never installed), only verification.
7
+ // select-harness-version makes that version current (harnessCurrentRef); a rollback makes the previous current.
8
+ // verify-harness real probes only: the binary runs (version-exec), its app-server starts and answers
9
+ // (api-health), its provider account is ready (provider-auth), and the hub sees the Codex
10
+ // spoke (spoke-connected).
11
+ //
12
+ // When hub's own install already has the pinned version current, only verification is planned. A Codex the user
13
+ // installed another way (brew, npm, Codex's install.sh into ~/.codex/packages) is never touched: hub installs and
14
+ // runs its own pinned copy.
15
+ //
16
+ // ~/.codex is preserved: hub's Codex wiring needs no edit of it (the spoke passes `-c` overrides to the app-server it
17
+ // starts, the wrapper passes `--enable daemon_auto_start` per run, and Codex ≥ 0.165 enables that feature by
18
+ // default). The one optional edit, `ensureDaemonFeature` (for a user who turned the feature off in config.toml), is
19
+ // a TOML merge-config, which the setup planner turns into an unmergeable-format manual edit: hub never writes TOML.
20
+ import {
21
+ executionContextOf,
22
+ harnessCurrentRef,
23
+ harnessVersionRef,
24
+ resolvePathRef,
25
+ type Action,
26
+ type DiscoverySnapshot,
27
+ type HarnessPin,
28
+ type PathRef,
29
+ type PlanInput,
30
+ type Precondition,
31
+ type ProbeKind,
32
+ type VerifiedManifest,
33
+ } from "../../contracts/index.ts"
34
+
35
+ /** The probes verify-codex runs; each must pass, fresh, before Codex is advertised as ready. */
36
+ export const CODEX_VERIFY_PROBES: readonly Exclude<ProbeKind, "presence">[] = ["version-exec", "api-health", "provider-auth", "spoke-connected"]
37
+
38
+ /** Codex's config file, as discovery reads it (home:.codex/config.toml). */
39
+ export const CODEX_CONFIG_REF: PathRef = { root: "home", rel: ".codex/config.toml" }
40
+
41
+ export interface CodexPlanOptions {
42
+ /** The verified hub release manifest; its codex pins are the only Codex artifacts ever installed. */
43
+ readonly manifest: VerifiedManifest
44
+ /** The hub-installed version and digest recorded before (profile history); used only if discovery agrees. */
45
+ readonly previous?: { readonly version: string; readonly sha256: string }
46
+ /** Opt-in: ask for `[features] daemon_auto_start = true` in ~/.codex/config.toml (a manual edit). */
47
+ readonly ensureDaemonFeature?: boolean
48
+ }
49
+
50
+ const codexOf = (snapshot: DiscoverySnapshot) => snapshot.harnesses.find((h) => h.id === "codex")
51
+
52
+ /** The pin for Codex on exactly this target, if the manifest has one. */
53
+ export function codexPinFor(manifest: VerifiedManifest, target: string): HarnessPin | undefined {
54
+ return manifest.harnesses.find((h) => h.harness === "codex" && h.target === target)
55
+ }
56
+
57
+ function configPrecondition(snapshot: DiscoverySnapshot): Precondition | undefined {
58
+ const where = resolvePathRef(CODEX_CONFIG_REF, snapshot.roots, executionContextOf(snapshot))
59
+ if (!where.ok) return undefined
60
+ const found = snapshot.configs.find((c) => c.path === where.value)
61
+ return found?.sha256 ? { exists: true, sha256: found.sha256 } : { exists: false }
62
+ }
63
+
64
+ export function planCodex(input: PlanInput, options: CodexPlanOptions): Action[] {
65
+ const { snapshot, profile } = input
66
+ if (!profile.harnesses.includes("codex")) return []
67
+ const actions: Action[] = []
68
+ const pin = codexPinFor(options.manifest, snapshot.host.target)
69
+ const found = codexOf(snapshot)
70
+ const hubVersion = found?.installed && found.installMethod === "hub" ? found.version : undefined
71
+ if (pin && hubVersion !== pin.version) {
72
+ const previous = hubVersion !== undefined && options.previous?.version === hubVersion ? { previous: options.previous } : {}
73
+ actions.push(
74
+ {
75
+ id: "install-codex",
76
+ kind: "install-harness",
77
+ escalation: "user",
78
+ summary: `Install Codex ${pin.version} for ${pin.target} (pinned by hub ${options.manifest.release})`,
79
+ harness: "codex",
80
+ version: pin.version,
81
+ target: pin.target,
82
+ source: { manifest: options.manifest.release, url: pin.url, file: pin.file },
83
+ size: pin.size,
84
+ sha256: pin.sha256,
85
+ evidence: [...pin.evidence],
86
+ path: harnessVersionRef("codex", pin.version),
87
+ owner: "hub",
88
+ rollback: "restore-previous",
89
+ ...previous,
90
+ },
91
+ {
92
+ id: "select-codex",
93
+ kind: "select-harness-version",
94
+ escalation: "user",
95
+ summary: `Make Codex ${pin.version} the current version`,
96
+ harness: "codex",
97
+ version: pin.version,
98
+ path: harnessCurrentRef("codex"),
99
+ target: harnessVersionRef("codex", pin.version),
100
+ ...previous,
101
+ },
102
+ )
103
+ }
104
+ if (options.ensureDaemonFeature) {
105
+ const precondition = configPrecondition(snapshot)
106
+ if (precondition)
107
+ actions.push({
108
+ id: "codex-daemon-feature",
109
+ kind: "merge-config",
110
+ escalation: "user",
111
+ summary: "Turn on Codex's shared app-server daemon (features.daemon_auto_start) so the hub sees TUI threads",
112
+ path: CODEX_CONFIG_REF,
113
+ format: "toml",
114
+ owner: "harness:codex",
115
+ precondition,
116
+ set: [{ pointer: "/features/daemon_auto_start", value: true }],
117
+ preserveUnknown: true,
118
+ })
119
+ }
120
+ actions.push({
121
+ id: "verify-codex",
122
+ kind: "verify-harness",
123
+ escalation: "user",
124
+ summary: "Verify Codex really runs, its app-server answers, its provider account is ready and its spoke reaches the hub",
125
+ harness: "codex",
126
+ probes: [...CODEX_VERIFY_PROBES],
127
+ })
128
+ return actions
129
+ }