@huynle/hub 0.7.1 → 0.7.3

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 (194) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/protocol/src/harness-events.ts +2 -2
  142. package/packages/protocol/src/index.ts +70 -7
  143. package/packages/runtime-openshell/package.json +1 -1
  144. package/packages/runtime-sdk/package.json +1 -1
  145. package/packages/sdk/package.json +1 -1
  146. package/packages/sdk/src/client.ts +55 -4
  147. package/packages/sdk/src/errors.ts +1 -0
  148. package/packages/sdk/src/sse.ts +4 -1
  149. package/packages/sdk/src/types.ts +59 -0
  150. package/packages/spoke-sdk/package.json +1 -1
  151. package/packages/spoke-sdk/src/enroll.ts +26 -4
  152. package/src/central/agent/app-tokens.ts +27 -17
  153. package/src/central/agent/archive-inactive.ts +66 -0
  154. package/src/central/agent/directory-routes.ts +155 -12
  155. package/src/central/agent/directory.ts +1 -1
  156. package/src/central/agent/errors.ts +2 -0
  157. package/src/central/agent/event-cursor.ts +49 -0
  158. package/src/central/agent/event-writers.ts +181 -0
  159. package/src/central/agent/events-routes.ts +148 -0
  160. package/src/central/agent/events.ts +277 -0
  161. package/src/central/agent/names.ts +1 -1
  162. package/src/central/agent/read-routes.ts +32 -36
  163. package/src/central/agent/routes.ts +14 -11
  164. package/src/central/agent/watch.ts +65 -174
  165. package/src/central/agent/webhook-routes.ts +10 -2
  166. package/src/central/agent/webhooks.ts +49 -5
  167. package/src/central/auth.ts +127 -1
  168. package/src/central/backend/index.ts +3 -0
  169. package/src/central/backend/types.ts +2 -0
  170. package/src/central/http.ts +116 -32
  171. package/src/central/mcp-http.ts +116 -0
  172. package/src/central/push.ts +14 -1
  173. package/src/central/registry.ts +34 -5
  174. package/src/central/store.ts +60 -0
  175. package/src/config.ts +70 -7
  176. package/src/index.ts +90 -91
  177. package/src/spoke/launchers.ts +109 -3
  178. package/src/spoke/spoke.ts +8 -4
  179. package/src/spoke/timeline.ts +59 -3
  180. package/src/spoke/tracker.ts +59 -6
  181. package/src/status.ts +97 -6
  182. package/web/dist/apple-touch-icon.png +0 -0
  183. package/web/dist/assets/index-Dm8OmkaO.css +1 -0
  184. package/web/dist/assets/index-FUJbj_Du.js +20 -0
  185. package/web/dist/favicon.ico +0 -0
  186. package/web/dist/icon-192.png +0 -0
  187. package/web/dist/icon-512.png +0 -0
  188. package/web/dist/icon-maskable-512.png +0 -0
  189. package/web/dist/icon.svg +5 -5
  190. package/web/dist/index.html +10 -4
  191. package/web/dist/manifest.webmanifest +2 -2
  192. package/web/dist/sw.js +4 -1
  193. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  194. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,456 @@
1
+ // The composition root of `hubctl setup` (HBS-13): one place that builds the harness adapters, the plan
2
+ // contributors and the apply executors from the independently tested bootstrap modules, so preview and apply use the
3
+ // SAME adapter registry, contributors and options (and so plan the same digest), and apply executes the fresh plan
4
+ // revalidateApply returns.
5
+ //
6
+ // adapters OpenCode, Claude Code, Codex (HARNESS_ORDER), each with its real probes, the machine-credential
7
+ // spoke probe (spoke-probe.ts) and its pinned-artifact source
8
+ // contributors hubctl itself (contributors.ts), the node services (node-services/plan.ts), a profile downgrade's
9
+ // typed service removal (profiles/upgrade.ts), inline enrollment
10
+ // executors builtinExecutors + the node-service executors + the per-harness dispatchers (dispatch.ts) + the
11
+ // enroll-machine and verify-runtime executors below
12
+ //
13
+ // Nothing here runs a shell: processes are started only by the adapters' fixed-argv runners and the service
14
+ // manager's fixed-call runner. Every capability that reaches outside is a port the tests replace.
15
+ import { realpathSync } from "node:fs"
16
+ import nodePath from "node:path"
17
+ import {
18
+ createAdapterRegistry,
19
+ executionContextOf,
20
+ HARNESS_ORDER,
21
+ ok,
22
+ resolvePathRef,
23
+ rootPathIn,
24
+ type AdapterContext,
25
+ type DiscoverySnapshot,
26
+ type HarnessAdapter,
27
+ type HarnessID,
28
+ type HarnessReadiness,
29
+ type HarnessVerification,
30
+ type PathRef,
31
+ type Profile,
32
+ type ProgramLayout,
33
+ type Result,
34
+ type RootMapping,
35
+ type ServiceManagerID,
36
+ type VerifiedManifest,
37
+ } from "../bootstrap/contracts/index.ts"
38
+ import { claudeVerifyExecutor, createClaudeAdapter, createClaudeHarnessSource, httpsDownloader as claudeDownloader, nodeRunner as claudeNodeRunner, tempScratch, type ProbeRunner as ClaudeProbeRunner } from "../bootstrap/adapters/claude/index.ts"
39
+ import { codexVerifyExecutor, createCodexAdapter, createCodexHarnessSource, httpsDownloader as codexDownloader, nodeProbeRunner as codexNodeRunner, stdioAppServerLauncher, type AppServerLauncher, type ProbeRunner as CodexProbeRunner } from "../bootstrap/adapters/codex/index.ts"
40
+ import {
41
+ createServiceControl,
42
+ nodeServiceExecutors,
43
+ planNodeServices,
44
+ serviceAssets,
45
+ spawnManagerRunner,
46
+ unitPathResolver,
47
+ type LaunchDriver,
48
+ type LaunchReport,
49
+ type ManagerRunner,
50
+ type NodeServiceControl,
51
+ type NodeServicesPlan,
52
+ } from "../bootstrap/adapters/node-services/index.ts"
53
+ import { createOpencodeHarness, type Download, type ProcessRunner } from "../bootstrap/adapters/opencode/index.ts"
54
+ import type { ReadinessGate } from "../bootstrap/adapters/readiness.ts"
55
+ import { discover } from "../bootstrap/discovery/discover.ts"
56
+ import { pathDirs, which } from "../bootstrap/discovery/executables.ts"
57
+ import type { DiscoveryProbe } from "../bootstrap/discovery/probe.ts"
58
+ import { machineEnroll, type EnrollPrompt } from "../bootstrap/enrollment/enroll.ts"
59
+ import type { ConfigCredentialStore } from "../bootstrap/enrollment/store.ts"
60
+ import type { PlanSources } from "../bootstrap/preview/plan.ts"
61
+ import { installedHarnesses, installedServiceUnits, rollbackHistory, type TransactionRecord } from "../bootstrap/profiles/history.ts"
62
+ import { planProfileChange } from "../bootstrap/profiles/upgrade.ts"
63
+ import { builtinExecutors, type HarnessSource, type HubctlSource } from "../bootstrap/transaction/builtin.ts"
64
+ import { createApplier, type Applier } from "../bootstrap/transaction/engine.ts"
65
+ import type { ActionExecutor, ExecutorMap } from "../bootstrap/transaction/executor.ts"
66
+ import { nodeFs, type TxFs } from "../bootstrap/transaction/fs.ts"
67
+ import { fileDigest } from "../bootstrap/transaction/stream.ts"
68
+ import { txFail, txOk } from "../bootstrap/transaction/result.ts"
69
+ import { TxStore } from "../bootstrap/transaction/store.ts"
70
+ import { checkOpenShell } from "../openshell.ts"
71
+ import { enrollmentContributor, harnessLinkContributor, HUBCTL_REF, hubctlContributor, refusing, type InstalledInspector, type PathInspector } from "./contributors.ts"
72
+ import { dispatchByHarness, dispatchHarnessSource } from "./dispatch.ts"
73
+ import { claudeSpokeProbes, codexSpokeStatus, opencodeSpokeProbe, type PresenceOptions } from "./spoke-probe.ts"
74
+ import type { BootstrapState, SetupChoices } from "./state.ts"
75
+
76
+ /** The capabilities that reach outside, replaced in tests. Production defaults are the adapters' own. */
77
+ export interface RuntimePorts {
78
+ readonly opencodeRunner?: ProcessRunner
79
+ readonly opencodeDownload?: Download
80
+ readonly claudeRunner?: ClaudeProbeRunner
81
+ readonly codexRunner?: CodexProbeRunner
82
+ readonly codexAppServer?: AppServerLauncher
83
+ /** The service manager's fixed-call runner (default: launchctl / systemctl --user, spawned without a shell). */
84
+ readonly serviceRunner?: ManagerRunner
85
+ /** The bounded verification launch (launch-session); without one a planned launch is refused at preflight. */
86
+ readonly launchDriver?: LaunchDriver
87
+ /** The bytes of this hubctl (install-artifact copies the running, digest-checked binary). */
88
+ readonly hubctlBytes?: () => Uint8Array | undefined
89
+ readonly fs?: TxFs
90
+ readonly uid?: number
91
+ }
92
+
93
+ export interface RuntimeDeps {
94
+ readonly env: Readonly<Record<string, string | undefined>>
95
+ readonly home: string
96
+ readonly now: () => number
97
+ readonly probe: DiscoveryProbe
98
+ /** How this process runs (programLayout of process.execPath and argv[1]). */
99
+ readonly layout: ProgramLayout
100
+ /** hubctl's own version (the app-server client version). */
101
+ readonly version: string
102
+ readonly manifest?: VerifiedManifest
103
+ readonly store: ConfigCredentialStore
104
+ readonly state: BootstrapState
105
+ readonly fetch?: typeof fetch
106
+ readonly signal?: AbortSignal
107
+ readonly stderr: (s: string) => void
108
+ readonly ports?: RuntimePorts
109
+ }
110
+
111
+ /** Everything one plan was made with: preview and apply build it from the same profile and choices. */
112
+ export interface PlanBundle {
113
+ readonly sources: PlanSources
114
+ /** The node-services plan of the last planning run (its units are what install-service writes). */
115
+ readonly node: () => NodeServicesPlan | undefined
116
+ readonly history: readonly TransactionRecord[]
117
+ }
118
+
119
+ /** The consent and readiness choices apply makes (never part of the plan, so never part of its digest). */
120
+ export interface ApplyChoices {
121
+ readonly allowDowngrade?: boolean
122
+ /**
123
+ * v1.2: fail (and roll back) unless every harness is ready (`--require-ready`). Without it a harness that only waits
124
+ * on the user (provider login, spoke) is committed as installed, not ready; an integrity failure still rolls back.
125
+ */
126
+ readonly requireReady?: boolean
127
+ /** Harnesses that must be ready regardless (the --verify-launch harness: the launch needs it). */
128
+ readonly strict?: readonly HarnessID[]
129
+ /** Sees each harness's classified readiness (the apply report). */
130
+ readonly onReadiness?: (readiness: HarnessReadiness) => void
131
+ }
132
+
133
+ /** The readiness gate of one harness's verify-harness step (contract v1.2): strict only when asked or launched. */
134
+ export function readinessGate(consent: ApplyChoices, harness: HarnessID): ReadinessGate {
135
+ return { requireReady: consent.requireReady === true || (consent.strict ?? []).includes(harness), ...(consent.onReadiness ? { onReadiness: consent.onReadiness } : {}) }
136
+ }
137
+
138
+ export interface SetupRuntime {
139
+ readonly deps: RuntimeDeps
140
+ discover(): Result<DiscoverySnapshot>
141
+ plan(profile: Profile, choices: SetupChoices): Promise<PlanBundle>
142
+ executors(bundle: PlanBundle, snapshot: DiscoverySnapshot, consent: ApplyChoices): ExecutorMap
143
+ applier(snapshot: DiscoverySnapshot, executors: ExecutorMap): Applier
144
+ verify(harnesses: readonly HarnessID[]): Promise<HarnessVerification[]>
145
+ /** This host's transactions (oldest first), from the journals and their plans. */
146
+ history(snapshot: DiscoverySnapshot): TransactionRecord[]
147
+ /** The hubctl the services and MCP clients run: user-bin:hubctl when the plan installs it, else this process. */
148
+ serviceLayout(snapshot: DiscoverySnapshot): ProgramLayout
149
+ }
150
+
151
+ /**
152
+ * What the files a plan would write hold now, read-only through the applier's filesystem: a regular file's digest
153
+ * (in chunks; hubctl is ~105 MB) and whether a path is a symlink to a target, in the snapshot's execution context.
154
+ */
155
+ export function installedInspector(snapshot: DiscoverySnapshot, fs: TxFs): InstalledInspector {
156
+ const where = (ref: PathRef) => {
157
+ const r = resolvePathRef(ref, snapshot.roots, executionContextOf(snapshot))
158
+ return r.ok ? r.value : undefined
159
+ }
160
+ return {
161
+ sha256: (ref) => {
162
+ const file = where(ref)
163
+ try {
164
+ return file && fs.lstat(file)?.kind === "file" ? fileDigest(fs, file) : undefined
165
+ } catch {
166
+ return undefined
167
+ }
168
+ },
169
+ linksTo: (ref, target) => {
170
+ const link = where(ref)
171
+ const to = where(target)
172
+ try {
173
+ return link !== undefined && to !== undefined && fs.lstat(link)?.kind === "symlink" && nodePath.resolve(nodePath.dirname(link), fs.readlink(link)) === to
174
+ } catch {
175
+ return false
176
+ }
177
+ },
178
+ }
179
+ }
180
+
181
+ /** Paths and PATH as the probe sees them (read-only), in the snapshot's execution context. */
182
+ export function pathInspector(snapshot: DiscoverySnapshot, probe: DiscoveryProbe): PathInspector {
183
+ const where = (ref: PathRef) => {
184
+ const r = resolvePathRef(ref, snapshot.roots, executionContextOf(snapshot))
185
+ return r.ok ? r.value : undefined
186
+ }
187
+ return {
188
+ where,
189
+ entry: (ref) => {
190
+ const p = where(ref)
191
+ return p ? probe.lstat(p) : "missing"
192
+ },
193
+ linkTarget: (ref) => {
194
+ const p = where(ref)
195
+ if (!p || probe.lstat(p) !== "symlink") return undefined
196
+ const text = probe.readlink(p)
197
+ return text === undefined ? undefined : nodePath.resolve(nodePath.dirname(p), text)
198
+ },
199
+ onPath: (name) => which(probe, pathDirs(probe), name),
200
+ }
201
+ }
202
+
203
+ /** A manifest that pins nothing (no --manifest): Codex is then only verified, never installed. */
204
+ const NO_PINS = { release: "0.0.0", harnesses: [], artifacts: [] } as unknown as VerifiedManifest
205
+
206
+ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
207
+ const ports = deps.ports ?? {}
208
+ const fs = ports.fs ?? nodeFs
209
+ let snapshot: DiscoverySnapshot | undefined
210
+ const context = (): AdapterContext => ({ env: deps.env, home: deps.home, platform: deps.probe.platform, arch: deps.probe.arch, now: deps.now, ...(deps.signal ? { signal: deps.signal } : {}) })
211
+ const presence: PresenceOptions = { credential: () => deps.store.read(), ...(deps.fetch ? { fetch: deps.fetch } : {}), ...(deps.signal ? { signal: deps.signal } : {}) }
212
+ const floors = deps.state.floors
213
+
214
+ const fresh = (): Result<DiscoverySnapshot> => {
215
+ const r = discover(deps.probe, { now: deps.now() })
216
+ if (r.ok) snapshot = r.value
217
+ return r
218
+ }
219
+
220
+ function history(snap: DiscoverySnapshot): TransactionRecord[] {
221
+ try {
222
+ const store = new TxStore(fs, snap.roots, executionContextOf(snap))
223
+ const journals = store.journals()
224
+ const plans = journals.flatMap((j) => store.readPlan(j) ?? [])
225
+ const r = rollbackHistory(journals, { hostFingerprint: snap.host.fingerprint, plans })
226
+ return r.ok ? r.value : []
227
+ } catch {
228
+ return []
229
+ }
230
+ }
231
+
232
+ function adapters(installed: ReturnType<typeof installedHarnesses>, allowDowngrade: boolean) {
233
+ const manifest = deps.manifest
234
+ const prior = (h: HarnessID) => (installed[h] ? { version: installed[h]!.version, sha256: installed[h]!.sha256 } : undefined)
235
+ const opencode = createOpencodeHarness({
236
+ probe: deps.probe,
237
+ ...(manifest ? { manifest } : {}),
238
+ ...(ports.opencodeRunner ? { runner: ports.opencodeRunner } : {}),
239
+ ...(ports.opencodeDownload ? { download: ports.opencodeDownload } : {}),
240
+ spoke: opencodeSpokeProbe(presence),
241
+ installed: () => prior("opencode"),
242
+ floor: () => floors.harnesses.opencode,
243
+ allowDowngrade,
244
+ })
245
+ const claudeRunner = ports.claudeRunner ?? claudeNodeRunner()
246
+ const claudePrior = prior("claude-code")
247
+ const claudeFloor = floors.harnesses["claude-code"]
248
+ const claude = createClaudeAdapter({
249
+ ...(manifest ? { manifest } : {}),
250
+ ...(claudePrior ? { installed: claudePrior } : {}),
251
+ ...(claudeFloor ? { floor: claudeFloor } : {}),
252
+ allowDowngrade,
253
+ probe: deps.probe,
254
+ probes: { runner: claudeRunner, ...claudeSpokeProbes(presence) },
255
+ })
256
+ const codexPrior = prior("codex")
257
+ const codex = createCodexAdapter({
258
+ manifest: manifest ?? NO_PINS,
259
+ probes: { runner: ports.codexRunner ?? codexNodeRunner, appServer: ports.codexAppServer ?? stdioAppServerLauncher(), spoke: codexSpokeStatus(presence), clientVersion: deps.version },
260
+ snapshot: () => snapshot,
261
+ ...(codexPrior ? { previous: codexPrior } : {}),
262
+ })
263
+ const sources: Partial<Record<HarnessID, HarnessSource>> = {}
264
+ if (manifest) {
265
+ if (opencode.source) sources.opencode = opencode.source
266
+ sources["claude-code"] = createClaudeHarnessSource({
267
+ manifest,
268
+ download: claudeDownloader(),
269
+ runner: claudeRunner,
270
+ scratch: tempScratch(),
271
+ platform: deps.probe.platform,
272
+ ...(claudePrior ? { installed: claudePrior } : {}),
273
+ ...(claudeFloor ? { floor: claudeFloor } : {}),
274
+ allowDowngrade,
275
+ })
276
+ sources.codex = createCodexHarnessSource({ manifest, download: codexDownloader(), installed: () => prior("codex"), floor: () => floors.harnesses.codex, allowDowngrade })
277
+ }
278
+ return { opencode, claude, codex, sources }
279
+ }
280
+
281
+ function serviceLayout(snap: DiscoverySnapshot): ProgramLayout {
282
+ const root = snap.roots.find((r) => r.id === "user-bin")
283
+ const installs = deps.manifest?.artifacts.some((a) => a.target === snap.host.target)
284
+ if (root && installs) return { kind: "standalone", executable: `${rootPathIn(root, executionContextOf(snap))}/${HUBCTL_REF.rel}` }
285
+ return deps.layout
286
+ }
287
+
288
+ async function plan(profile: Profile, choices: SetupChoices): Promise<PlanBundle> {
289
+ const snap = snapshot ?? (fresh().ok ? snapshot : undefined)
290
+ const records = snap ? history(snap) : []
291
+ const { opencode, claude, codex } = adapters(installedHarnesses(records), choices.allowDowngrade === true)
292
+ const registry = createAdapterRegistry()
293
+ for (const a of [opencode.adapter, claude, codex] as HarnessAdapter[]) registry.register(a)
294
+ let enrolledWith: string | undefined
295
+ try {
296
+ enrolledWith = (await deps.store.read())?.server
297
+ } catch {}
298
+ let nodePlan: NodeServicesPlan | undefined
299
+ const contributors = [
300
+ hubctlContributor({ ...(deps.manifest ? { manifest: deps.manifest } : {}), ...(snap ? { installed: installedInspector(snap, fs) } : {}) }),
301
+ refusing((input) => {
302
+ const planned = planNodeServices(input, {
303
+ layout: serviceLayout(input.snapshot),
304
+ ...(choices.node ? { node: choices.node } : {}),
305
+ ...(choices.activate ? { activate: true } : {}),
306
+ environment: deps.env,
307
+ ...(choices.launch ? { launch: choices.launch } : {}),
308
+ })
309
+ if (!planned.ok) return planned
310
+ nodePlan = planned.value
311
+ return ok(planned.value.actions)
312
+ }),
313
+ refusing((input) => {
314
+ const before = deps.state.profile
315
+ if (!before) return ok([])
316
+ const manager = input.snapshot.managers.service
317
+ const services = manager === "none" ? undefined : { manager: manager as ServiceManagerID, installed: installedServiceUnits(records, manager as ServiceManagerID) }
318
+ const change = planProfileChange(before, input.profile, { allowDowngrade: choices.allowDowngrade === true, ...(services ? { services } : {}) })
319
+ return change.ok ? ok(change.value.actions) : change
320
+ }),
321
+ ...(snap ? [harnessLinkContributor(pathInspector(snap, deps.probe))] : []),
322
+ enrollmentContributor({ ...(choices.server ? { server: choices.server } : {}), machineName: choices.machineName ?? "machine", enrolledWith }),
323
+ ]
324
+ return { sources: { adapters: registry, contributors, ...(choices.enrollment ? { enrollment: choices.enrollment } : {}) }, node: () => nodePlan, history: records }
325
+ }
326
+
327
+ function control(snap: DiscoverySnapshot): NodeServiceControl | undefined {
328
+ if (snap.managers.service === "none") return undefined
329
+ let run = ports.serviceRunner
330
+ if (!run) {
331
+ const systemctl = which(deps.probe, [...pathDirs(deps.probe), "/usr/bin", "/bin"], "systemctl") ?? "/usr/bin/systemctl"
332
+ run = spawnManagerRunner({ programs: { launchctl: "/bin/launchctl", systemctl }, env: deps.env })
333
+ }
334
+ return createServiceControl({ run, uid: ports.uid ?? deps.probe.uid, unitPath: unitPathResolver(snap.roots, executionContextOf(snap)) })
335
+ }
336
+
337
+ function hubctlSource(): HubctlSource | undefined {
338
+ const manifest = deps.manifest
339
+ if (!manifest) return undefined
340
+ return {
341
+ manifest,
342
+ fetch: async () => {
343
+ const bytes = ports.hubctlBytes?.()
344
+ if (bytes) return bytes
345
+ // The standalone executable itself (~105 MB): the executor digests it and the engine copies it in chunks,
346
+ // checking the digest again while it copies; it is never read whole.
347
+ if (deps.layout.kind === "standalone") return { path: realpathSync(deps.layout.executable) }
348
+ throw new Error("this hubctl is not the standalone release binary; run the hubctl you downloaded from the release to install it")
349
+ },
350
+ ...(floors.hubctl ? { floor: floors.hubctl } : {}),
351
+ }
352
+ }
353
+
354
+ function enrollExecutor(): ActionExecutor<"enroll-machine"> {
355
+ const ref: PathRef = { root: "config", rel: "hub/config.json" }
356
+ return {
357
+ kind: "enroll-machine",
358
+ plan: () => ({ class: "credential", path: ref }),
359
+ check: async (_action, ctx) =>
360
+ ctx.target === deps.store.location ? txOk(undefined) : txFail("usage", `the machine credential lives in ${deps.store.location} (HUB_SPOKE_CONFIG); enroll it separately with hubctl machine enroll`),
361
+ change: async (action) =>
362
+ txOk({
363
+ type: "effect",
364
+ perform: async () => {
365
+ const r = await machineEnroll({
366
+ server: action.server,
367
+ machineName: action.machineName,
368
+ store: deps.store,
369
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
370
+ ...(deps.signal ? { signal: deps.signal } : {}),
371
+ onPrompt: (p: EnrollPrompt) => deps.stderr(enrollPromptText(p)),
372
+ })
373
+ return r.envelope.ok ? txOk(undefined) : txFail(r.envelope.error!.code, r.envelope.error!.message)
374
+ },
375
+ }),
376
+ undo: async () => {
377
+ try {
378
+ await deps.store.clear()
379
+ return txOk(undefined)
380
+ } catch (error) {
381
+ return txFail("internal", (error as Error).message)
382
+ }
383
+ },
384
+ }
385
+ }
386
+
387
+ const verifyRuntime: ActionExecutor<"verify-runtime"> = {
388
+ kind: "verify-runtime",
389
+ plan: () => ({ class: "external" }),
390
+ change: async () =>
391
+ txOk({
392
+ type: "effect",
393
+ perform: async () => {
394
+ const report = await checkOpenShell({ env: deps.env as Record<string, string | undefined>, home: deps.home })
395
+ return report.ok ? txOk(undefined) : txFail("harness_not_ready", "OpenShell is not ready (`hubctl node openshell check` shows why)")
396
+ },
397
+ }),
398
+ }
399
+
400
+ function executors(bundle: PlanBundle, snap: DiscoverySnapshot, consent: ApplyChoices): ExecutorMap {
401
+ const allowDowngrade = consent.allowDowngrade === true
402
+ const gate = (h: HarnessID) => readinessGate(consent, h)
403
+ const { opencode, claude, codex, sources } = adapters(installedHarnesses(bundle.history), allowDowngrade)
404
+ const services = control(snap)
405
+ const hubctl = hubctlSource()
406
+ const builtin = builtinExecutors({
407
+ // Lazy: the node plan exists once apply's re-plan ran the contributor; only its exact units are written.
408
+ renderService: (action) => {
409
+ const node = bundle.node()
410
+ return node ? serviceAssets(node).renderService(action) : undefined
411
+ },
412
+ ...(services ? { services } : {}),
413
+ ...(hubctl ? { hubctl } : {}),
414
+ ...(deps.manifest ? { harness: dispatchHarnessSource({ manifest: deps.manifest, sources, allowDowngrade }) } : {}),
415
+ })
416
+ const oc = opencode.executors(context, gate("opencode"))
417
+ const launchProbe = ports.launchDriver ? { driver: ports.launchDriver, ...(deps.signal ? { signal: deps.signal } : {}), onReport: (r: LaunchReport) => deps.stderr(launchReportText(r)) } : undefined
418
+ return {
419
+ ...builtin,
420
+ ...nodeServiceExecutors({ ...(services ? { control: services } : {}), ...(launchProbe ? { probe: launchProbe } : {}) }),
421
+ "register-mcp-server": dispatchByHarness("register-mcp-server", { opencode: oc["register-mcp-server"]! }),
422
+ "verify-harness": dispatchByHarness("verify-harness", {
423
+ opencode: oc["verify-harness"]!,
424
+ "claude-code": claudeVerifyExecutor({ verify: () => claude.verify(context()), now: deps.now, ...gate("claude-code") }),
425
+ codex: codexVerifyExecutor({ verify: (ctx) => codex.verify(ctx), context, ...gate("codex") }),
426
+ }),
427
+ "enroll-machine": enrollExecutor(),
428
+ "verify-runtime": verifyRuntime,
429
+ }
430
+ }
431
+
432
+ function applier(snap: DiscoverySnapshot, map: ExecutorMap): Applier {
433
+ return createApplier({ roots: snap.roots, context: executionContextOf(snap), host: { fingerprint: snap.host.fingerprint, target: snap.host.target }, executors: map, fs, now: deps.now, keepBackups: true })
434
+ }
435
+
436
+ async function verify(harnesses: readonly HarnessID[]): Promise<HarnessVerification[]> {
437
+ const snap = fresh()
438
+ const { opencode, claude, codex } = adapters(snap.ok ? installedHarnesses(history(snap.value)) : {}, false)
439
+ const by: Record<HarnessID, HarnessAdapter> = { opencode: opencode.adapter, "claude-code": claude, codex }
440
+ const out: HarnessVerification[] = []
441
+ for (const id of HARNESS_ORDER) if (harnesses.includes(id)) out.push(await by[id].verify(context()))
442
+ return out
443
+ }
444
+
445
+ return { deps, discover: fresh, plan, executors, applier, verify, history, serviceLayout }
446
+ }
447
+
448
+ /** What `machine enroll` (and an inline enrollment) shows while it waits; never a credential. */
449
+ export function enrollPromptText(p: EnrollPrompt): string {
450
+ if (p.autoApproved) return "hubctl: the local hub approved this machine by itself\n"
451
+ return `hubctl: approve this machine on the hub: ${p.approveURL}\nhubctl: and check it shows the code ${p.userCode} (it expires at ${new Date(p.expiresAt).toISOString()})\n${p.warning ? `hubctl: warning: ${p.warning}\n` : ""}`
452
+ }
453
+
454
+ export function launchReportText(r: LaunchReport): string {
455
+ return `${r.stages.map((s) => `hubctl: verification launch, ${s.stage}: ${s.ok ? "ok" : "failed"}${s.detail ? ` (${s.detail})` : ""}`).join("\n")}\n`
456
+ }
@@ -0,0 +1,120 @@
1
+ // spoke-connected readiness (HBS-13 integration item 6): whether the hub sees a harness's spoke of this machine.
2
+ // One read-only GET /spoke/presence with the machine credential (src/central/http.ts spokePresence), under the
3
+ // enrollment policy: https, or plain http to a loopback hub only (checkServerURL); never across a redirect, never to
4
+ // an insecure stored hub, and nothing at all without a stored credential. The answer lists only the harness, the
5
+ // evidence level and the features of each connected spoke; details never carry the token.
6
+ import type { MachineCredential } from "../bootstrap/contracts/index.ts"
7
+ import type { SpokeHello } from "../bootstrap/adapters/claude/verify.ts"
8
+ import type { SpokeStatus } from "../bootstrap/adapters/codex/verify.ts"
9
+ import type { SpokeProbe } from "../bootstrap/adapters/opencode/verify.ts"
10
+ import { checkServerURL } from "../bootstrap/enrollment/policy.ts"
11
+
12
+ export interface ConnectedSpoke {
13
+ readonly harness: string
14
+ readonly evidence: string
15
+ readonly features: readonly string[]
16
+ }
17
+
18
+ export type Presence = { readonly ok: true; readonly spokes: readonly ConnectedSpoke[] } | { readonly ok: false; readonly detail: string }
19
+
20
+ export interface PresenceOptions {
21
+ /** The stored machine credential (CredentialStore.read). */
22
+ readonly credential: () => Promise<MachineCredential | undefined>
23
+ readonly fetch?: typeof fetch
24
+ readonly timeoutMs?: number
25
+ readonly signal?: AbortSignal
26
+ }
27
+
28
+ const TIMEOUT_MS = 10_000
29
+ const word = /^[A-Za-z0-9._-]{1,64}$/
30
+
31
+ function parse(body: unknown): readonly ConnectedSpoke[] | undefined {
32
+ const spokes = (body as { spokes?: unknown } | null)?.spokes
33
+ if (!Array.isArray(spokes)) return undefined
34
+ const out: ConnectedSpoke[] = []
35
+ for (const s of spokes) {
36
+ const { harness, evidence, features } = (s ?? {}) as Record<string, unknown>
37
+ if (typeof harness !== "string" || !word.test(harness) || typeof evidence !== "string" || !word.test(evidence)) return undefined
38
+ const list = Array.isArray(features) ? features.filter((f): f is string => typeof f === "string" && word.test(f)) : []
39
+ out.push({ harness, evidence, features: list })
40
+ }
41
+ return out
42
+ }
43
+
44
+ /** The hub's answer to "which spokes of this machine are connected?". */
45
+ export async function spokePresence(options: PresenceOptions): Promise<Presence> {
46
+ let credential: MachineCredential | undefined
47
+ try {
48
+ credential = await options.credential()
49
+ } catch {
50
+ return { ok: false, detail: "the machine credential store cannot be read" }
51
+ }
52
+ if (!credential) return { ok: false, detail: "this machine is not enrolled (run `hubctl machine enroll`)" }
53
+ const server = checkServerURL(credential.server)
54
+ if (!server) return { ok: false, detail: "the stored hub URL is not https (plain http only on loopback), so the hub was not asked" }
55
+ const signals = [AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
56
+ let response: Response
57
+ try {
58
+ response = await (options.fetch ?? fetch)(`${server}/spoke/presence`, { method: "GET", headers: { authorization: `Bearer ${credential.token}` }, redirect: "manual", signal: AbortSignal.any(signals) })
59
+ } catch (cause) {
60
+ return { ok: false, detail: `could not reach ${server} (${(cause as { name?: string })?.name ?? "error"})` }
61
+ }
62
+ if (response.status !== 200) {
63
+ void response.body?.cancel().catch(() => {})
64
+ if (response.status === 401 || response.status === 403) return { ok: false, detail: `${server} rejected the machine credential (revoked or unknown)` }
65
+ if (response.status === 404) return { ok: false, detail: `${server} does not answer spoke presence queries (an older hub)` }
66
+ return { ok: false, detail: `${server} could not answer (HTTP ${response.status})` }
67
+ }
68
+ let body: unknown
69
+ try {
70
+ body = await response.json()
71
+ } catch {
72
+ return { ok: false, detail: `${server} sent an invalid presence answer` }
73
+ }
74
+ const spokes = parse(body)
75
+ return spokes ? { ok: true, spokes } : { ok: false, detail: `${server} sent an invalid presence answer` }
76
+ }
77
+
78
+ const LABEL: Readonly<Record<string, string>> = { opencode: "OpenCode", "claude-code": "Claude Code", codex: "Codex" }
79
+
80
+ async function find(options: PresenceOptions, harness: string): Promise<{ readonly spoke?: ConnectedSpoke; readonly detail: string }> {
81
+ const p = await spokePresence(options)
82
+ if (!p.ok) return { detail: p.detail }
83
+ const spoke = p.spokes.find((s) => s.harness === harness)
84
+ return spoke ? { spoke, detail: `the hub sees this machine's ${LABEL[harness]} spoke (${spoke.evidence})` } : { detail: `the hub does not see this machine's ${LABEL[harness]} spoke connected` }
85
+ }
86
+
87
+ /** OpenCode's SpokeProbe. */
88
+ export function opencodeSpokeProbe(options: PresenceOptions): SpokeProbe {
89
+ return {
90
+ async connected() {
91
+ const r = await find(options, "opencode")
92
+ return { ok: r.spoke !== undefined, detail: r.detail }
93
+ },
94
+ }
95
+ }
96
+
97
+ /** The Claude Code adapter's spoke and conformance probes: the hooks spoke must be connected as a hooks spoke. */
98
+ export function claudeSpokeProbes(options: PresenceOptions): { spoke(): Promise<SpokeHello>; conformance(): Promise<{ readonly ok: boolean; readonly detail?: string }> } {
99
+ return {
100
+ async spoke() {
101
+ const r = await find(options, "claude-code")
102
+ return r.spoke ? { connected: true, harness: r.spoke.harness, evidence: r.spoke.evidence, features: r.spoke.features } : { connected: false }
103
+ },
104
+ async conformance() {
105
+ const r = await find(options, "claude-code")
106
+ if (!r.spoke) return { ok: false, detail: r.detail }
107
+ return r.spoke.evidence === "hooks" ? { ok: true, detail: "connected as a hooks spoke" } : { ok: false, detail: `the Claude Code spoke reports ${r.spoke.evidence} evidence, not hooks` }
108
+ },
109
+ }
110
+ }
111
+
112
+ /** The Codex adapter's SpokeStatus. */
113
+ export function codexSpokeStatus(options: PresenceOptions): SpokeStatus {
114
+ return {
115
+ async codex() {
116
+ const r = await find(options, "codex")
117
+ return { connected: r.spoke !== undefined, detail: r.detail }
118
+ },
119
+ }
120
+ }