@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,222 @@
1
+ // Plans a persistent launch node: the typed actions that configure `hub node` (a lossless merge of node.json), write
2
+ // its service unit and the chosen harness spokes' units (install-service, from the embedded templates, pinned by the
3
+ // rendered unit's digest), load them only when the user explicitly asked to (activate-service, confirm: "explicit";
4
+ // apply also needs --confirm-activation), and optionally prove the setup with one bounded launch inside a configured
5
+ // workspace root (launch-session). Linger is never planned: it is an opt-in note, since keeping a user's services
6
+ // running after logout is a login setting hubctl does not change.
7
+ //
8
+ // Pure and deterministic: the same snapshot, profile and options always plan the same actions and units, so a
9
+ // re-plan at apply gives the same digest. No action carries a command, and no unit carries a credential.
10
+ import {
11
+ HARNESS_SERVICE,
12
+ resolvePathRef,
13
+ validateAction,
14
+ type Action,
15
+ type ConfigSet,
16
+ type HarnessID,
17
+ type InstallService,
18
+ type Issue,
19
+ type PathRef,
20
+ type PlanInput,
21
+ type ProgramLayout,
22
+ type Result,
23
+ type ServiceID,
24
+ } from "../../contracts/index.ts"
25
+ import type { PlanContributor } from "../../preview/plan.ts"
26
+ import { renderServiceUnit, SERVICE_ENVIRONMENT, type RenderedUnit } from "./render.ts"
27
+ import { checkNodeSettings, inside, serviceContextOf, type NodeSettings, type ServiceContext } from "./validate.ts"
28
+
29
+ export interface NodeServicesOptions {
30
+ /** The hubctl the services start (programLayout of this process). */
31
+ readonly layout: ProgramLayout
32
+ /** node.json settings ({workspaceRoots, slots?, labels?}); required when the profile has the node service. */
33
+ readonly node?: unknown
34
+ /** Explicit consent, given at preview: plan loading the services. Never a default. */
35
+ readonly activate?: boolean
36
+ /** Opt-in: the user wants the services kept running while logged out (a note; hubctl changes no login setting). */
37
+ readonly linger?: boolean
38
+ /** The user's environment; only each service's own settings (SERVICE_ENVIRONMENT) are carried, others ignored. */
39
+ readonly environment?: Readonly<Record<string, string | undefined>>
40
+ /** One bounded verification launch, inside a workspace root (needs `activate` and a launch-enabled profile). */
41
+ readonly launch?: { readonly harness: HarnessID; readonly workspace: PathRef }
42
+ }
43
+
44
+ export interface NodeServicesPlan {
45
+ readonly actions: readonly Action[]
46
+ /** The rendered units, in the order of their install-service actions. */
47
+ readonly units: readonly RenderedUnit[]
48
+ readonly settings?: NodeSettings
49
+ readonly context?: ServiceContext
50
+ /** Secret-free guidance for the preview (activation not requested, linger). */
51
+ readonly notes: readonly string[]
52
+ }
53
+
54
+ export const NODE_CONFIG_REF: PathRef = { root: "config", rel: "hub/node.json" }
55
+ const CONFIG_DIR: PathRef = { root: "config", rel: "hub" }
56
+
57
+ const LABEL: Readonly<Record<ServiceID, string>> = { node: "hub node", "claude-code": "Claude Code spoke", "codex-spoke": "Codex spoke" }
58
+ const SERVICE_HARNESS: Readonly<Partial<Record<ServiceID, HarnessID>>> = Object.fromEntries(Object.entries(HARNESS_SERVICE).map(([h, s]) => [s, h]))
59
+ const BIN_SETTING: Readonly<Partial<Record<HarnessID, string>>> = { "claude-code": "HUB_CLAUDE_BIN", codex: "HUB_CODEX_BIN" }
60
+
61
+ const failed = <T>(issues: readonly Issue[]): Result<T> => ({ ok: false, issues: [...issues] })
62
+ const prefixed = (prefix: string, r: Result<unknown>): Issue[] => (r.ok ? [] : r.issues.map((i) => ({ ...i, path: `${prefix}${i.path}` })))
63
+
64
+ /** The harnesses a service serves: the node all chosen ones, a spoke its own. */
65
+ const servedBy = (service: ServiceID, harnesses: readonly HarnessID[]): HarnessID[] => (service === "node" ? [...harnesses] : [SERVICE_HARNESS[service]!])
66
+
67
+ function environmentFor(service: ServiceID, served: readonly HarnessID[], ctx: ServiceContext, environment: NodeServicesOptions["environment"]): Record<string, string> {
68
+ const env: Record<string, string> = {}
69
+ for (const key of SERVICE_ENVIRONMENT[service]) {
70
+ const v = environment?.[key]
71
+ if (v) env[key] = v
72
+ }
73
+ // The harness binary the service starts is the one discovery found in this context, not whatever its PATH holds.
74
+ for (const harness of served) {
75
+ const key = BIN_SETTING[harness]
76
+ const bin = ctx.harnessBins[harness]
77
+ if (key && bin && SERVICE_ENVIRONMENT[service].includes(key)) env[key] = bin
78
+ }
79
+ return env
80
+ }
81
+
82
+ function nodeConfigAction(raw: Record<string, unknown>, settings: NodeSettings, input: PlanInput, ctx: ServiceContext): Action[] {
83
+ const set: ConfigSet[] = [{ pointer: "/workspaceRoots", value: [...settings.workspaceRoots] }]
84
+ if (raw.slots !== undefined) set.push({ pointer: "/slots", value: settings.slots })
85
+ if (raw.labels !== undefined) set.push({ pointer: "/labels", value: { ...settings.labels } })
86
+ const where = resolvePathRef(NODE_CONFIG_REF, input.snapshot.roots, ctx.context)
87
+ const hostWhere = resolvePathRef(NODE_CONFIG_REF, input.snapshot.roots, "host")
88
+ const known = input.snapshot.configs.find((f) => (where.ok && f.path === where.value) || (hostWhere.ok && f.path === hostWhere.value))
89
+ const precondition = known?.sha256 ? { exists: true, sha256: known.sha256 } : { exists: false }
90
+ return [
91
+ { id: "hub-config-dir", kind: "ensure-directory", escalation: "user", summary: "Create the hub config directory", path: CONFIG_DIR, mode: 0o700 },
92
+ {
93
+ id: "node-config",
94
+ kind: "merge-config",
95
+ escalation: "user",
96
+ summary: `Set the launch node's workspace roots (${settings.workspaceRoots.join(", ")}) in node.json`,
97
+ path: NODE_CONFIG_REF,
98
+ format: "json",
99
+ owner: "hub",
100
+ precondition,
101
+ set,
102
+ preserveUnknown: true,
103
+ },
104
+ ]
105
+ }
106
+
107
+ /** The node-services actions for (snapshot, profile); refuses settings or contexts it cannot serve. */
108
+ export function planNodeServices(input: PlanInput, options: NodeServicesOptions): Result<NodeServicesPlan> {
109
+ const { profile } = input
110
+ if (profile.services.length === 0) {
111
+ if (options.launch) return failed([{ path: "/launch", code: "inconsistent", message: `the ${profile.id} profile runs no node to launch on` }])
112
+ return { ok: true, value: { actions: [], units: [], notes: [] } }
113
+ }
114
+ const harnesses = [...new Set(profile.services.flatMap((s) => servedBy(s, profile.harnesses)))]
115
+ const context = serviceContextOf({ snapshot: input.snapshot, harnesses, layout: options.layout })
116
+ if (!context.ok) return context as Result<never>
117
+ const ctx = context.value
118
+
119
+ const actions: Action[] = []
120
+ const issues: Issue[] = []
121
+ let settings: NodeSettings | undefined
122
+ if (profile.services.includes("node")) {
123
+ if (options.node === undefined) issues.push({ path: "/node", code: "missing_field", message: "a launch node needs its workspace roots: pass --workspace-root <dir> (repeat it for more than one), or list them in node.json workspaceRoots" })
124
+ else {
125
+ const checked = checkNodeSettings(options.node, { snapshot: input.snapshot })
126
+ if (!checked.ok) issues.push(...prefixed("/node", checked))
127
+ else {
128
+ settings = checked.value
129
+ actions.push(...nodeConfigAction(options.node as Record<string, unknown>, settings, input, ctx))
130
+ }
131
+ }
132
+ }
133
+
134
+ const units: RenderedUnit[] = []
135
+ for (const service of profile.services) {
136
+ const environment = environmentFor(service, servedBy(service, profile.harnesses), ctx, options.environment)
137
+ const unit = renderServiceUnit({ service, manager: ctx.manager, layout: options.layout, home: ctx.home, environment })
138
+ if (!unit.ok) {
139
+ issues.push(...prefixed(`/services/${service}`, unit))
140
+ continue
141
+ }
142
+ units.push(unit.value)
143
+ actions.push({
144
+ id: `install-${service}-service`,
145
+ kind: "install-service",
146
+ escalation: "user",
147
+ summary: `Write the ${LABEL[service]} ${ctx.manager === "launchd" ? "launchd agent" : "systemd --user unit"} (${unit.value.name})`,
148
+ service,
149
+ manager: ctx.manager,
150
+ path: unit.value.path,
151
+ asset: unit.value.asset,
152
+ sha256: unit.value.sha256,
153
+ })
154
+ }
155
+
156
+ if (options.activate)
157
+ for (const service of profile.services)
158
+ actions.push({ id: `activate-${service}-service`, kind: "activate-service", escalation: "user", summary: `Load the ${LABEL[service]} service (explicitly confirmed)`, service, manager: ctx.manager, confirm: "explicit" })
159
+
160
+ if (options.launch) issues.push(...checkLaunch(input, options, ctx, settings))
161
+ if (issues.length > 0) return failed(issues)
162
+ if (options.launch)
163
+ actions.push({
164
+ id: `verify-launch-${options.launch.harness}`,
165
+ kind: "launch-session",
166
+ escalation: "user",
167
+ summary: `Launch one bounded ${options.launch.harness} session (at most ${profile.launch.maxDurationS} s) to prove the node end to end`,
168
+ harness: options.launch.harness,
169
+ workspace: options.launch.workspace,
170
+ maxDurationS: profile.launch.maxDurationS!,
171
+ })
172
+
173
+ for (const action of actions) {
174
+ const valid = validateAction(action)
175
+ if (!valid.ok) issues.push(...prefixed(`/actions/${action.id}`, valid))
176
+ }
177
+ if (issues.length > 0) return failed(issues)
178
+ return { ok: true, value: { actions, units, ...(settings ? { settings } : {}), context: ctx, notes: notesFor(options, ctx) } }
179
+ }
180
+
181
+ function checkLaunch(input: PlanInput, options: NodeServicesOptions, ctx: ServiceContext, settings: NodeSettings | undefined): Issue[] {
182
+ const launch = options.launch!
183
+ const { profile } = input
184
+ if (!profile.launch.enabled || profile.launch.maxDurationS === undefined) return [{ path: "/launch", code: "inconsistent", message: `the ${profile.id} profile does not launch sessions` }]
185
+ if (!options.activate) return [{ path: "/launch", code: "inconsistent", message: "a verification launch needs the node running: confirm activation too" }]
186
+ if (!profile.harnesses.includes(launch.harness)) return [{ path: "/launch/harness", code: "inconsistent", message: `the ${profile.id} profile does not set up ${launch.harness}` }]
187
+ const where = resolvePathRef(launch.workspace, input.snapshot.roots, ctx.context)
188
+ if (!where.ok) return prefixed("/launch/workspace", where)
189
+ if (settings && !settings.workspaceRoots.some((root) => inside(where.value, root)))
190
+ return [{ path: "/launch/workspace", code: "invalid_path", message: `${where.value} is outside the node's workspace roots` }]
191
+ return []
192
+ }
193
+
194
+ function notesFor(options: NodeServicesOptions, ctx: ServiceContext): string[] {
195
+ const notes: string[] = []
196
+ if (!options.activate) notes.push("The services are installed but not loaded: pass --confirm-activation to load them (hubctl never loads a service without it).")
197
+ if (ctx.manager === "systemd-user")
198
+ notes.push(
199
+ options.linger
200
+ ? "To keep the services running while you are logged out, run `loginctl enable-linger` yourself; hubctl does not change login settings."
201
+ : "systemd --user services stop when you log out; keeping them running (linger) is opt-in.",
202
+ )
203
+ else notes.push("launchd user agents run while you are logged in.")
204
+ return notes
205
+ }
206
+
207
+ /** A planner contributor (preview/plan.ts). Validate with planNodeServices first: a contributor cannot refuse. */
208
+ export function nodeServicesContributor(options: NodeServicesOptions): PlanContributor {
209
+ return (input) => {
210
+ const planned = planNodeServices(input, options)
211
+ if (!planned.ok) throw new Error(`node services cannot be planned: ${planned.issues.map((i) => `${i.code} ${i.path}: ${i.message}`).join("; ")}`)
212
+ return planned.value.actions
213
+ }
214
+ }
215
+
216
+ /** The applier's renderService (BuiltinDeps): exactly the planned units, nothing else. */
217
+ export function serviceAssets(planned: NodeServicesPlan): { readonly renderService: (action: InstallService) => Uint8Array | undefined } {
218
+ return {
219
+ renderService: (action) =>
220
+ planned.units.find((u) => u.asset === action.asset && u.service === action.service && u.manager === action.manager && u.path.root === action.path.root && u.path.rel === action.path.rel)?.bytes,
221
+ }
222
+ }
@@ -0,0 +1,183 @@
1
+ // Renders the per-user service units of `hub node` and the harness spokes (`hub claude-code daemon`, `hub
2
+ // codex-spoke serve`) from the templates embedded in hubctl (EMBEDDED_ASSETS, release/assets.ts), never from
3
+ // scripts/ on disk, with the executable contract's argv (serviceArgv): a word list of absolute, single-line paths,
4
+ // one launchd <string> per word, or one systemd ExecStart= word per word (quoted and escaped). A unit carries only
5
+ // the service's own settings (where its config, state and harness binary are), each single-line, absolute and
6
+ // never a credential: the machine token stays in the CredentialStore, which the service reads at run time.
7
+ //
8
+ // BUN_BE_BUN=1 (in every template) makes a Bun-compiled executable act as the bun CLI: it is what lets an OpenCode
9
+ // binary run hub-cli.js, so only the script layout keeps it. A standalone hubctl with it would run `bun node run`
10
+ // (a missing module) and crash-loop, so its units never mention it.
11
+ //
12
+ // Rendering is pure: the same input always gives the same bytes, so a plan can pin the unit's digest
13
+ // (install-service.sha256) and apply can render it again and compare.
14
+ import {
15
+ Checker,
16
+ EMBEDDED_ASSETS,
17
+ isAbsolutePath,
18
+ isSecretValue,
19
+ pointer,
20
+ serviceArgv,
21
+ SERVICE_COMMANDS,
22
+ sha256Hex,
23
+ type PathRef,
24
+ type ProgramLayout,
25
+ type Result,
26
+ type ServiceID,
27
+ type ServiceManagerID,
28
+ } from "../../contracts/index.ts"
29
+ import { EMBEDDED_TEXT } from "../../release/assets.ts"
30
+
31
+ /** The settings each service may carry into its unit (as `hub <service> install` carries them today). */
32
+ export const SERVICE_ENVIRONMENT: Readonly<Record<ServiceID, readonly string[]>> = Object.freeze({
33
+ node: ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_CLAUDE_BIN", "HUB_CODEX_BIN"],
34
+ "claude-code": ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "HUB_CLAUDE_SOCKET", "HUB_CLAUDE_BIN"],
35
+ "codex-spoke": ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "CODEX_HOME", "HUB_CODEX_BIN", "HUB_CODEX_DAEMON_SOCKET"],
36
+ })
37
+
38
+ /** The named root each manager's units live in. */
39
+ export const UNIT_ROOT: Readonly<Record<ServiceManagerID, "launch-agents" | "systemd-user">> = Object.freeze({ launchd: "launch-agents", "systemd-user": "systemd-user" })
40
+
41
+ export interface RenderInput {
42
+ readonly service: ServiceID
43
+ readonly manager: ServiceManagerID
44
+ /** How the service starts hubctl (programLayout of the hubctl that plans and applies). */
45
+ readonly layout: ProgramLayout
46
+ /** The home directory as the service sees it (the execution context's view). */
47
+ readonly home: string
48
+ /** Settings to carry, from SERVICE_ENVIRONMENT[service] only. */
49
+ readonly environment?: Readonly<Record<string, string>>
50
+ /** The command that writes the unit by hand ("hub node"): its header then names its install/uninstall (default: hubctl setup). */
51
+ readonly writtenBy?: string
52
+ }
53
+
54
+ export interface RenderedUnit {
55
+ /** The EMBEDDED_ASSETS id it was rendered from. */
56
+ readonly asset: string
57
+ readonly service: ServiceID
58
+ readonly manager: ServiceManagerID
59
+ /** Where it is installed: the manager's root and the asset's file name. */
60
+ readonly path: PathRef
61
+ /** The launchd label or the systemd unit name (what the service manager calls it). */
62
+ readonly name: string
63
+ readonly argv: readonly string[]
64
+ readonly text: string
65
+ readonly bytes: Uint8Array
66
+ readonly sha256: string
67
+ }
68
+
69
+ export const assetIDFor = (service: ServiceID, manager: ServiceManagerID): string => `service/${manager}/${service}`
70
+
71
+ /** The launchd label or systemd unit name of a service under a manager. */
72
+ export function serviceName(service: ServiceID, manager: ServiceManagerID): string {
73
+ const file = EMBEDDED_ASSETS[assetIDFor(service, manager)]!.installAs
74
+ return manager === "launchd" ? file.replace(/\.plist$/, "") : file
75
+ }
76
+
77
+ const xml = (text: string) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;")
78
+
79
+ /** A word for a systemd command line or assignment: % and $ escaped, quoted when not plain (as node.ts does). */
80
+ const sd = (word: string) => {
81
+ const escaped = word.replaceAll("%", "%%").replaceAll("$", "$$$$")
82
+ return /^[A-Za-z0-9_./:@+=,-]+$/.test(escaped) ? escaped : `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
83
+ }
84
+
85
+ /** A home usable inside a systemd assignment without quoting (Environment=PATH=<home>/...). */
86
+ const SYSTEMD_HOME = /^\/[A-Za-z0-9_./@+,:-]*$/
87
+ const PLACEHOLDER = /__[A-Z]+__/
88
+
89
+ /** The template's BUN_BE_BUN=1 setting, per manager (removed for the standalone layout). */
90
+ const BUN_BE_BUN: Readonly<Record<ServiceManagerID, RegExp>> = Object.freeze({
91
+ "systemd-user": /^Environment=BUN_BE_BUN=1\n/m,
92
+ launchd: /\n[ \t]*<key>BUN_BE_BUN<\/key>\s*<string>1<\/string>/,
93
+ })
94
+
95
+ const header = (asset: string, by?: string) =>
96
+ by ? `Written by ${by} install from the embedded asset ${asset}; ${by} uninstall removes it.` : `Written by hubctl setup from the embedded asset ${asset}; hubctl removes it only while it is unchanged.`
97
+
98
+ function checkEnvironment(c: Checker, service: ServiceID, environment: Readonly<Record<string, string>>): [string, string][] {
99
+ const allowed = SERVICE_ENVIRONMENT[service]
100
+ const out: [string, string][] = []
101
+ for (const key of Object.keys(environment).sort()) {
102
+ const value = environment[key]!
103
+ const at = pointer("/environment", key)
104
+ if (!allowed.includes(key)) c.add(at, "invalid_value", `the ${service} service does not carry ${key}`)
105
+ else if (typeof value === "string" && isSecretValue(value)) c.add(at, "secret", "service units never carry credentials")
106
+ else if (!isAbsolutePath(value)) c.add(at, "invalid_value", `${key} must be an absolute, single-line path`)
107
+ else out.push([key, value])
108
+ }
109
+ return out
110
+ }
111
+
112
+ function renderSystemd(c: Checker, asset: string, template: string, argv: readonly string[], command: readonly string[], home: string, env: [string, string][], by?: string): string {
113
+ const execLine = /^ExecStart=(.*)$/m
114
+ const found = template.match(execLine)?.[1]
115
+ if (found !== "__ARGV__" && found !== `__RUNTIME__ __ENTRY__ ${command.join(" ")}`) c.add("/asset", "inconsistent", `${asset} does not start the ${command.join(" ")} command`)
116
+ const body = template.slice(Math.max(0, template.indexOf("[Unit]")))
117
+ const extra = env.map(([key, value]) => `\nEnvironment=${sd(`${key}=${value}`)}`).join("")
118
+ return `# ${header(asset, by)}\n${body}`
119
+ .replace(execLine, () => `ExecStart=${argv.map(sd).join(" ")}`)
120
+ .replaceAll("__HOME__", home.replaceAll("%", "%%"))
121
+ .replace(/(Environment=PATH=[^\n]*)/, (line) => `${line}${extra}`)
122
+ }
123
+
124
+ function renderLaunchd(c: Checker, asset: string, template: string, argv: readonly string[], command: readonly string[], home: string, env: [string, string][], by?: string): string {
125
+ const programArguments = /(<key>ProgramArguments<\/key>\s*<array>)([\s\S]*?)(\n\s*<\/array>)/
126
+ const match = template.match(programArguments)
127
+ const words = [...(match?.[2] ?? "").matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1])
128
+ const expected = [["__ARGV__"], ["__RUNTIME__", "__ENTRY__", ...command]]
129
+ if (!expected.some((e) => e.length === words.length && e.every((w, i) => w === words[i]))) c.add("/asset", "inconsistent", `${asset} does not start the ${command.join(" ")} command`)
130
+ const indent = (match?.[2] ?? "").match(/\n(\s*)<string>/)?.[1] ?? " "
131
+ const extra = env.map(([key, value]) => `\n${indent}<key>${key}</key>\n${indent}<string>${xml(value)}</string>`).join("")
132
+ return template
133
+ .replace(/<!--[\s\S]*?-->/, () => `<!-- ${header(asset, by)} -->`)
134
+ .replace(programArguments, (_all, open: string, _words: string, close: string) => `${open}${argv.map((w) => `\n${indent}<string>${xml(w)}</string>`).join("")}${close}`)
135
+ .replaceAll("__HOME__", xml(home))
136
+ .replace(/(<key>PATH<\/key>\s*<string>[^<]*<\/string>)/, (line) => `${line}${extra}`)
137
+ }
138
+
139
+ /** The unit for `service` under `manager`, rendered from its embedded template. */
140
+ export function renderServiceUnit(input: RenderInput): Result<RenderedUnit> {
141
+ const c = new Checker()
142
+ const asset = assetIDFor(input.service, input.manager)
143
+ const entry = EMBEDDED_ASSETS[asset]
144
+ const template = EMBEDDED_TEXT[asset]
145
+ if (!entry || template === undefined) {
146
+ c.add("/service", "invalid_value", `no embedded template for ${input.service} under ${input.manager}`)
147
+ return c.result(undefined)
148
+ }
149
+ let argv: string[] = []
150
+ try {
151
+ argv = serviceArgv(input.layout, input.service)
152
+ } catch (error) {
153
+ c.add("/layout", "invalid_path", (error as Error).message)
154
+ }
155
+ const homeOK = isAbsolutePath(input.home) && input.home !== "/" && (input.manager !== "systemd-user" || SYSTEMD_HOME.test(input.home))
156
+ if (!homeOK) c.add("/home", "invalid_path", "the service's home must be an absolute, normalized directory other than /")
157
+ const env = checkEnvironment(c, input.service, input.environment ?? {})
158
+ if (c.issues.length > 0) return c.result(undefined)
159
+
160
+ const command = SERVICE_COMMANDS[input.service]
161
+ const raw =
162
+ input.manager === "systemd-user"
163
+ ? renderSystemd(c, asset, template, argv, command, input.home, env, input.writtenBy)
164
+ : renderLaunchd(c, asset, template, argv, command, input.home, env, input.writtenBy)
165
+ const asBun = input.layout.kind === "script"
166
+ const text = asBun ? raw : raw.replace(BUN_BE_BUN[input.manager], "")
167
+ if (!asBun && text.includes("BUN_BE_BUN")) c.add("/asset", "inconsistent", `${asset} sets BUN_BE_BUN, which a standalone hubctl must not get`)
168
+ if (PLACEHOLDER.test(text)) c.add("/asset", "inconsistent", `${asset} has a placeholder this hubctl does not fill`)
169
+ if (isSecretValue(text)) c.add("/environment", "secret", "service units never carry credentials")
170
+ if (c.issues.length > 0) return c.result(undefined)
171
+ const bytes = new TextEncoder().encode(text)
172
+ return c.result<RenderedUnit>({
173
+ asset,
174
+ service: input.service,
175
+ manager: input.manager,
176
+ path: { root: UNIT_ROOT[input.manager], rel: entry.installAs },
177
+ name: serviceName(input.service, input.manager),
178
+ argv,
179
+ text,
180
+ bytes,
181
+ sha256: `sha256:${sha256Hex(bytes)}`,
182
+ })
183
+ }
@@ -0,0 +1,197 @@
1
+ // What a persistent launch node may be configured with, and where its services may run.
2
+ //
3
+ // Node settings (node.json): workspace roots must be narrow project directories, never /, a top-level directory,
4
+ // the home itself, a parent of it, another user's home, or hub's own config/state/unit directories; roots are
5
+ // distinct and never nested; slots are bounded; labels are plain and never a credential or the node's own os/arch.
6
+ //
7
+ // Same runtime context: a service runs where the service manager that loads it runs. It must be the same runtime
8
+ // and filesystem context as the harness it serves and the paths in its unit. So the adapter refuses
9
+ // - a harness reached through a container shim (the harness and its repositories live in a container, while this
10
+ // host has none: a host service would find no harness and the wrong paths);
11
+ // - inside a container, a unit directory that is a host directory mounted in (the host's manager would run the
12
+ // unit with the container's paths);
13
+ // - any program, harness or workspace path that is the other side's view of a mounted root.
14
+ import { labelsProblem, NODE_ROOTS_MAX } from "../../../../../protocol/src/index.ts"
15
+ import {
16
+ Checker,
17
+ checkTarget,
18
+ executionContextOf,
19
+ fail,
20
+ findSecrets,
21
+ isAbsolutePath,
22
+ isRecord,
23
+ pointer,
24
+ rootPathIn,
25
+ sha256Hex,
26
+ shape,
27
+ type DiscoverySnapshot,
28
+ type ExecutionContext,
29
+ type HarnessID,
30
+ type ProgramLayout,
31
+ type Result,
32
+ type RootMapping,
33
+ type ServiceManagerID,
34
+ } from "../../contracts/index.ts"
35
+ import { UNIT_ROOT } from "./render.ts"
36
+
37
+ /** Concurrent launches when the settings name none (the node's own default, packages/node DEFAULT_SLOTS). */
38
+ export const DEFAULT_SLOTS = 4
39
+ /** The bootstrap configures at most this many slots; a larger node is configured by hand. */
40
+ export const BOOTSTRAP_SLOTS_MAX = 64
41
+ /** Labels the node sets itself. */
42
+ export const RESERVED_LABELS = ["os", "arch"] as const
43
+
44
+ export interface NodeSettings {
45
+ readonly workspaceRoots: readonly string[]
46
+ readonly slots: number
47
+ readonly labels: Readonly<Record<string, string>>
48
+ }
49
+
50
+ export interface NodePlaces {
51
+ /** The discovery the plan is made from: its roots and execution context. */
52
+ readonly snapshot: Pick<DiscoverySnapshot, "roots" | "topology">
53
+ }
54
+
55
+ /** Whether `path` is `dir` or inside it. */
56
+ export const inside = (path: string, dir: string) => path === dir || path.startsWith(dir === "/" ? "/" : `${dir}/`)
57
+ const parentOf = (path: string) => path.slice(0, path.lastIndexOf("/")) || "/"
58
+
59
+ /** Mount prefixes as the *other* context sees them (a path under one is not valid where the service runs). */
60
+ function otherViews(roots: readonly RootMapping[], context: ExecutionContext): string[] {
61
+ return roots.flatMap((r) => (r.containerPath !== undefined && r.containerPath !== r.path ? [context === "container" ? r.path : r.containerPath] : []))
62
+ }
63
+
64
+ function thisViews(roots: readonly RootMapping[], context: ExecutionContext): string[] {
65
+ return roots.map((r) => rootPathIn(r, context))
66
+ }
67
+
68
+ /** Whether `path` is the other context's view of a mounted root (and not also valid in this context). */
69
+ const foreignPath = (path: string, roots: readonly RootMapping[], context: ExecutionContext) =>
70
+ otherViews(roots, context).some((v) => inside(path, v)) && !thisViews(roots, context).some((v) => inside(path, v))
71
+
72
+ function rootProblem(root: unknown, roots: readonly RootMapping[], context: ExecutionContext): { code: "invalid_path" | "inconsistent"; message: string } | undefined {
73
+ if (!isAbsolutePath(root)) return { code: "invalid_path", message: "a workspace root is an absolute, normalized, single-line path" }
74
+ if (foreignPath(root, roots, context)) return { code: "inconsistent", message: `${root} is how the other side of the container mount sees it; give the path the service sees` }
75
+ if (root === "/" || root.split("/").length < 3) return { code: "invalid_path", message: `${root} is too broad for a workspace root` }
76
+ const home = roots.find((r) => r.id === "home")
77
+ const homePath = home ? rootPathIn(home, context) : undefined
78
+ if (homePath && inside(homePath, root)) return { code: "invalid_path", message: `${root} is the whole home or a parent of it; name a project directory inside it` }
79
+ if (homePath && parentOf(root) === parentOf(homePath)) return { code: "invalid_path", message: `${root} is another user's home` }
80
+ for (const r of roots) {
81
+ if (r.id === "home") continue
82
+ const dir = rootPathIn(r, context)
83
+ if (inside(root, dir) || inside(dir, root)) return { code: "invalid_path", message: `${root} holds hub's own ${r.id} directory` }
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ /** Validates and normalizes the launch node's settings for this host. */
89
+ export function checkNodeSettings(value: unknown, places: NodePlaces): Result<NodeSettings> {
90
+ const c = new Checker()
91
+ const r = shape(c, "", value, ["workspaceRoots"], ["slots", "labels"])
92
+ if (!r) return c.result(undefined)
93
+ const context = executionContextOf(places.snapshot)
94
+ const roots = places.snapshot.roots
95
+ const workspaceRoots: string[] = []
96
+ if (r.workspaceRoots !== undefined) {
97
+ if (!Array.isArray(r.workspaceRoots) || r.workspaceRoots.length === 0) c.add("/workspaceRoots", "invalid_value", "a launch node needs at least one workspace root")
98
+ else if (r.workspaceRoots.length > NODE_ROOTS_MAX) c.add("/workspaceRoots", "invalid_value", `at most ${NODE_ROOTS_MAX} workspace roots`)
99
+ else
100
+ r.workspaceRoots.forEach((root: unknown, i: number) => {
101
+ const at = pointer("/workspaceRoots", i)
102
+ const problem = rootProblem(root, roots, context)
103
+ if (problem) return c.add(at, problem.code, problem.message)
104
+ const path = root as string
105
+ if (workspaceRoots.some((w) => inside(path, w) || inside(w, path))) return c.add(at, "duplicate", `${path} repeats or nests another workspace root`)
106
+ workspaceRoots.push(path)
107
+ })
108
+ }
109
+ let slots = DEFAULT_SLOTS
110
+ if (r.slots !== undefined) {
111
+ if (Number.isInteger(r.slots) && (r.slots as number) >= 1 && (r.slots as number) <= BOOTSTRAP_SLOTS_MAX) slots = r.slots as number
112
+ else c.add("/slots", "invalid_value", `slots must be a whole number from 1 to ${BOOTSTRAP_SLOTS_MAX}`)
113
+ }
114
+ let labels: Record<string, string> = {}
115
+ if (r.labels !== undefined) {
116
+ const problem = labelsProblem(r.labels)
117
+ if (findSecrets(r.labels).length > 0) c.add("/labels", "secret", "labels never carry credentials")
118
+ else if (problem) c.add("/labels", "invalid_value", problem)
119
+ else if (isRecord(r.labels) && RESERVED_LABELS.some((k) => k in (r.labels as object))) c.add("/labels", "invalid_value", `the node sets ${RESERVED_LABELS.join(" and ")} itself`)
120
+ else labels = Object.fromEntries(Object.entries(r.labels as Record<string, string>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
121
+ }
122
+ return c.result<NodeSettings>({ workspaceRoots, slots, labels })
123
+ }
124
+
125
+ export interface RenderedConfig {
126
+ readonly text: string
127
+ readonly bytes: Uint8Array
128
+ readonly sha256: string
129
+ }
130
+
131
+ /** node.json for validated settings: the same settings always give the same bytes. */
132
+ export function renderNodeConfig(settings: NodeSettings): RenderedConfig {
133
+ const labels = Object.fromEntries(Object.entries(settings.labels).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
134
+ const text = `${JSON.stringify({ workspaceRoots: settings.workspaceRoots, slots: settings.slots, labels }, null, 2)}\n`
135
+ const bytes = new TextEncoder().encode(text)
136
+ return { text, bytes, sha256: `sha256:${sha256Hex(bytes)}` }
137
+ }
138
+
139
+ export interface ServiceContextInput {
140
+ readonly snapshot: DiscoverySnapshot
141
+ /** The harnesses the services serve (the node: every chosen harness; a spoke: its own). */
142
+ readonly harnesses: readonly HarnessID[]
143
+ /** The hubctl the services start. */
144
+ readonly layout: ProgramLayout
145
+ }
146
+
147
+ export interface ServiceContext {
148
+ readonly context: ExecutionContext
149
+ readonly manager: ServiceManagerID
150
+ /** Home as the service sees it. */
151
+ readonly home: string
152
+ /** Each installed harness's real binary in this context (a wrapper resolved), carried into the unit. */
153
+ readonly harnessBins: Partial<Record<HarnessID, string>>
154
+ }
155
+
156
+ /** Where this host's services run, refusing any mismatch with the harness's runtime and filesystem context. */
157
+ export function serviceContextOf(input: ServiceContextInput): Result<ServiceContext> {
158
+ const { snapshot } = input
159
+ const context = executionContextOf(snapshot)
160
+ const manager = snapshot.managers.service
161
+ if (manager === "none") return fail("/managers/service", "no_service_manager", "this host has no user service manager (launchd or systemd --user)")
162
+ const target = checkTarget(snapshot.host.target)
163
+ if (!target.ok) return target as Result<never>
164
+ if (target.value.serviceManager !== manager) return fail("/managers/service", "inconsistent", `${snapshot.host.target} services run under ${target.value.serviceManager}, not ${manager}`)
165
+
166
+ const c = new Checker()
167
+ const unitRoot = snapshot.roots.find((r) => r.id === UNIT_ROOT[manager])
168
+ const home = snapshot.roots.find((r) => r.id === "home")
169
+ if (!unitRoot) c.add("/roots", "unmapped_root", `this host has no ${UNIT_ROOT[manager]} root`)
170
+ if (!home) c.add("/roots", "unmapped_root", "this host has no home root")
171
+ if (unitRoot && context === "container" && unitRoot.containerPath !== undefined)
172
+ c.add("/roots", "inconsistent", `the ${unitRoot.id} directory is the host's ${unitRoot.path}, mounted into this container: the host's ${manager} would run the unit outside the container`)
173
+
174
+ const program = input.layout.kind === "standalone" ? [input.layout.executable] : [input.layout.runtime, input.layout.entry]
175
+ for (const word of program)
176
+ if (isAbsolutePath(word) && foreignPath(word, snapshot.roots, context)) c.add("/layout", "inconsistent", `${word} is the other side's view of a container mount; the service could not start it`)
177
+
178
+ const harnessBins: Partial<Record<HarnessID, string>> = {}
179
+ for (const id of input.harnesses) {
180
+ const presence = snapshot.harnesses.find((h) => h.id === id)
181
+ if (!presence?.installed || !presence.path) continue
182
+ const shim = snapshot.topology.shims.find((s) => s.path === presence.path)
183
+ if (shim?.kind === "container-shim") {
184
+ c.add(
185
+ `/harnesses/${id}`,
186
+ "inconsistent",
187
+ `${id} at ${presence.path} is a shim into a container${shim.resolvesTo ? ` (${shim.resolvesTo})` : ""}: the harness and its repositories live there, so set up the service inside that container`,
188
+ )
189
+ continue
190
+ }
191
+ const bin = shim?.kind === "wrapper" && shim.resolvesTo ? shim.resolvesTo : presence.path
192
+ if (foreignPath(bin, snapshot.roots, context)) c.add(`/harnesses/${id}`, "inconsistent", `${id} at ${bin} is the other side's view of a container mount`)
193
+ else harnessBins[id] = bin
194
+ }
195
+ if (c.issues.length > 0) return c.result(undefined)
196
+ return c.result<ServiceContext>({ context, manager, home: rootPathIn(home!, context), harnessBins })
197
+ }