@huynle/hub 0.7.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 (185) hide show
  1. package/CHANGELOG.md +70 -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/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,190 @@
1
+ // SetupPreview: what `hubctl setup preview` outputs. It contains the plan, its digest and preview token, the
2
+ // blockers that stop apply, and the upstream evidence already checked for pinned harness downloads.
3
+ //
4
+ // The only blocker in v1 is a manual edit. When a config the plan would change is a symlink (for example
5
+ // ~/.config/opencode pointing into a dotfiles repository), hubctl refuses to write through it. Instead it emits the
6
+ // exact, structured, secret-free edit for the user to make in the repository the link points to. The edit
7
+ // replaces its action: the plan itself never contains an action that writes through the link. Apply refuses while
8
+ // any blocker remains (manual_edit_required, exit 10). Once the user has made the edit, a new preview sees the
9
+ // changed config, so its digest differs and that plan no longer carries the refused action.
10
+ //
11
+ // v1.1: a second reason, unmergeable-format: a config hubctl cannot edit losslessly (TOML, or a file it cannot
12
+ // parse) gets the same exact, structured edit, for the file itself (no link involved), instead of a bare conflict.
13
+ import { checkConfigSets, checkPrecondition, CONFIG_OWNERS, findExecKeys, type Action, type ConfigFormat, type ConfigOwner, type ConfigSet, type Precondition } from "./actions.ts"
14
+ import { EVIDENCE_KINDS, HARNESS_ORDER, type EvidenceKind, type HarnessID } from "./harness.ts"
15
+ import { checkPathRef, type PathRef } from "./paths.ts"
16
+ import { planDigest, validatePreviewToken, validateSetupPlan, type PreviewToken, type SetupPlan } from "./plan.ts"
17
+ import { findSecrets } from "./secrets.ts"
18
+ import { absPath, Checker, digest, fail, header, int, isRecord, list, ok, oneOf, pointer, shape, text, type Result } from "./validate.ts"
19
+
20
+ export type ManualChange =
21
+ | { readonly op: "merge"; readonly set: readonly ConfigSet[] }
22
+ /** Register this hubctl's `mcp` subcommand under `name` (the command is implied, as in the action). */
23
+ | { readonly op: "register-mcp-server"; readonly harness: HarnessID; readonly name: string }
24
+ | { readonly op: "write-file"; readonly asset: string; readonly sha256: string; readonly mode: number }
25
+
26
+ export const MANUAL_EDIT_REASONS = ["symlinked-config", "unmergeable-format"] as const
27
+ export type ManualEditReason = (typeof MANUAL_EDIT_REASONS)[number]
28
+
29
+ export interface ManualEdit {
30
+ readonly kind: "manual-edit"
31
+ readonly reason: ManualEditReason
32
+ /** The plan action it replaces. */
33
+ readonly actionID: string
34
+ readonly path: PathRef
35
+ /** The symlink found between the root and the target (symlinked-config only). */
36
+ readonly linkPath?: string
37
+ /** Where the edit belongs (the file the link resolves to). */
38
+ readonly resolvedPath: string
39
+ readonly format?: ConfigFormat
40
+ readonly owner?: ConfigOwner
41
+ /** What the file looked like when the edit was computed. */
42
+ readonly precondition?: Precondition
43
+ readonly change: ManualChange
44
+ }
45
+
46
+ /** Everything that stops apply. */
47
+ export type PlanBlocker = ManualEdit
48
+
49
+ const ACTION_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
50
+
51
+ function checkManualEdit(c: Checker, path: string, value: unknown): void {
52
+ for (const p of findExecKeys(value, path)) c.add(p, "exec_field", "a manual edit never carries a command")
53
+ const r = shape(c, path, value, ["kind", "reason", "actionID", "path", "resolvedPath", "change"], ["linkPath", "format", "owner", "precondition"])
54
+ if (!r) return
55
+ const at = (key: string) => pointer(path, key)
56
+ if (r.kind !== "manual-edit") c.add(at("kind"), "invalid_value", "expected manual-edit")
57
+ oneOf(c, at("reason"), r.reason, MANUAL_EDIT_REASONS)
58
+ text(c, at("actionID"), r.actionID, 64, ACTION_ID)
59
+ checkPathRef(c, at("path"), r.path)
60
+ if (r.reason === "symlinked-config" && r.linkPath === undefined) c.add(at("linkPath"), "missing_field", "a symlinked config names its link")
61
+ if (r.reason === "unmergeable-format" && r.linkPath !== undefined) c.add(at("linkPath"), "inconsistent", "an unmergeable config is edited in place; no link is involved")
62
+ if (r.linkPath !== undefined) absPath(c, at("linkPath"), r.linkPath)
63
+ absPath(c, at("resolvedPath"), r.resolvedPath)
64
+ if (r.format !== undefined) oneOf(c, at("format"), r.format, ["json", "jsonc", "toml"] as const)
65
+ if (r.owner !== undefined) oneOf(c, at("owner"), r.owner, CONFIG_OWNERS)
66
+ if (r.precondition !== undefined) checkPrecondition(c, at("precondition"), r.precondition)
67
+ const change = isRecord(r.change) ? r.change : undefined
68
+ if (!change) return void c.add(at("change"), "invalid_value", "expected a change")
69
+ const cp = at("change")
70
+ if (!oneOf(c, pointer(cp, "op"), change.op, ["merge", "register-mcp-server", "write-file"] as const)) return
71
+ if (change.op === "merge") {
72
+ shape(c, cp, change, ["op", "set"])
73
+ checkConfigSets(c, pointer(cp, "set"), change.set)
74
+ } else if (change.op === "register-mcp-server") {
75
+ shape(c, cp, change, ["op", "harness", "name"])
76
+ oneOf(c, pointer(cp, "harness"), change.harness, HARNESS_ORDER)
77
+ text(c, pointer(cp, "name"), change.name, 32, /^[a-z0-9][a-z0-9-]{0,31}$/)
78
+ } else {
79
+ shape(c, cp, change, ["op", "asset", "sha256", "mode"])
80
+ text(c, pointer(cp, "asset"), change.asset, 128, /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/)
81
+ digest(c, pointer(cp, "sha256"), change.sha256)
82
+ int(c, pointer(cp, "mode"), change.mode, 0, 0o7777)
83
+ }
84
+ for (const p of findSecrets(value, path)) c.add(p, "secret", "a manual edit never carries credentials")
85
+ }
86
+
87
+ export function validateManualEdit(value: unknown): Result<ManualEdit> {
88
+ const c = new Checker()
89
+ checkManualEdit(c, "", value)
90
+ return c.result(value)
91
+ }
92
+
93
+ /** The manual edit that replaces `action` when its target is behind a symlink; undefined if the action writes no config or file. */
94
+ export function manualEditFor(action: Action, link: { readonly linkPath: string; readonly resolvedPath: string }): ManualEdit | undefined {
95
+ const base = { kind: "manual-edit", reason: "symlinked-config", actionID: action.id, linkPath: link.linkPath, resolvedPath: link.resolvedPath } as const
96
+ switch (action.kind) {
97
+ case "merge-config":
98
+ return { ...base, path: action.path, format: action.format, owner: action.owner, precondition: action.precondition, change: { op: "merge", set: action.set } }
99
+ case "register-mcp-server":
100
+ return { ...base, path: action.path, precondition: action.precondition, change: { op: "register-mcp-server", harness: action.harness, name: action.name } }
101
+ case "write-file":
102
+ return { ...base, path: action.path, change: { op: "write-file", asset: action.asset, sha256: action.sha256, mode: action.mode } }
103
+ default:
104
+ return undefined
105
+ }
106
+ }
107
+
108
+ /**
109
+ * v1.1: the manual edit that replaces a config action hubctl cannot apply losslessly (TOML, or a file it cannot
110
+ * parse), for the file at `resolvedPath` itself; undefined if the action edits no config.
111
+ */
112
+ export function unmergeableEditFor(action: Action, resolvedPath: string): ManualEdit | undefined {
113
+ const base = { kind: "manual-edit", reason: "unmergeable-format", actionID: action.id, resolvedPath } as const
114
+ switch (action.kind) {
115
+ case "merge-config":
116
+ return { ...base, path: action.path, format: action.format, owner: action.owner, precondition: action.precondition, change: { op: "merge", set: action.set } }
117
+ case "register-mcp-server":
118
+ return { ...base, path: action.path, precondition: action.precondition, change: { op: "register-mcp-server", harness: action.harness, name: action.name } }
119
+ default:
120
+ return undefined
121
+ }
122
+ }
123
+
124
+ /** An upstream evidence check reported in the preview; "deferred" checks run on the downloaded bytes at apply. */
125
+ export interface PreviewEvidence {
126
+ readonly actionID: string
127
+ readonly kind: EvidenceKind
128
+ readonly status: "verified" | "unavailable" | "deferred" | "failed"
129
+ readonly detail?: string
130
+ }
131
+
132
+ export const SETUP_PREVIEW_SCHEMA = "hub.setup-preview"
133
+
134
+ export interface SetupPreview {
135
+ readonly schema: typeof SETUP_PREVIEW_SCHEMA
136
+ readonly version: 1
137
+ readonly plan: SetupPlan
138
+ readonly planDigest: string
139
+ readonly token: PreviewToken
140
+ readonly blockers: readonly PlanBlocker[]
141
+ readonly evidence: readonly PreviewEvidence[]
142
+ }
143
+
144
+ export function validateSetupPreview(value: unknown): Result<SetupPreview> {
145
+ const c = new Checker()
146
+ const r = shape(c, "", value, ["schema", "version", "plan", "planDigest", "token", "blockers", "evidence"])
147
+ if (!r) return c.result(value)
148
+ header(c, "", r, SETUP_PREVIEW_SCHEMA, 1)
149
+ const plan = validateSetupPlan(r.plan)
150
+ c.merge("/plan", plan)
151
+ digest(c, "/planDigest", r.planDigest)
152
+ const token = validatePreviewToken(r.token)
153
+ c.merge("/token", token)
154
+ const actions = plan.ok ? plan.value.actions : []
155
+ if (plan.ok) {
156
+ const d = planDigest(plan.value)
157
+ if (r.planDigest !== d) c.add("/planDigest", "inconsistent", "planDigest is not the digest of the plan")
158
+ if (token.ok && (token.value.planDigest !== d || token.value.hostFingerprint !== plan.value.hostFingerprint || token.value.snapshotDigest !== plan.value.snapshotDigest))
159
+ c.add("/token", "inconsistent", "the token is not for this plan")
160
+ }
161
+ list(c, "/blockers", r.blockers, 256, (p, b) => {
162
+ checkManualEdit(c, p, b)
163
+ // A manual edit replaces its action: the plan never keeps an action that would write through the symlink.
164
+ if (plan.ok && isRecord(b) && actions.some((a) => a.id === b.actionID)) c.add(pointer(p, "actionID"), "inconsistent", "the refused action is still in the plan")
165
+ })
166
+ list(c, "/evidence", r.evidence, 256, (p, e) => {
167
+ const ev = shape(c, p, e, ["actionID", "kind", "status"], ["detail"])
168
+ if (!ev) return
169
+ text(c, pointer(p, "actionID"), ev.actionID, 64, ACTION_ID)
170
+ oneOf(c, pointer(p, "kind"), ev.kind, EVIDENCE_KINDS)
171
+ oneOf(c, pointer(p, "status"), ev.status, ["verified", "unavailable", "deferred", "failed"] as const)
172
+ if (ev.detail !== undefined) text(c, pointer(p, "detail"), ev.detail)
173
+ const action = actions.find((a) => a.id === ev.actionID)
174
+ if (plan.ok && (action?.kind !== "install-harness" || !action.evidence.includes(ev.kind as EvidenceKind))) c.add(p, "inconsistent", "the evidence names no planned check")
175
+ })
176
+ // The preview token is not a credential (it grants nothing; apply re-plans and compares), so only its key is exempt.
177
+ for (const p of findSecrets(value, "", new Set(["/token"]))) c.add(p, "secret", "previews never carry credentials")
178
+ return c.result(value)
179
+ }
180
+
181
+ /** Whether apply may proceed past the preview's own findings (revalidatePreview still runs at apply). */
182
+ export function previewApplicable(preview: SetupPreview): Result<SetupPreview> {
183
+ if (preview.blockers.length > 0) {
184
+ const reasons = [...new Set(preview.blockers.map((b) => (b.reason === "unmergeable-format" ? "a format hubctl cannot edit losslessly" : "symlinked config")))].join(", ")
185
+ return fail("/blockers", "manual_edit_required", `${preview.blockers.length} change(s) must be made by hand (${reasons}); make them and preview again`)
186
+ }
187
+ const failed = preview.evidence.find((e) => e.status === "failed")
188
+ if (failed) return fail("/evidence", "evidence_failed", `${failed.kind} failed for ${failed.actionID}`)
189
+ return ok(preview)
190
+ }
@@ -0,0 +1,105 @@
1
+ // Setup profiles: how much of Hub a machine gets.
2
+ // session-only the harness integration only (follow and steer sessions); no services, no launches.
3
+ // launch-node plus the `hub node` service, so the hub can launch bounded sessions on this machine.
4
+ // full-harness plus every chosen harness's spoke service (and opt-in OpenShell checks).
5
+ // Profiles are versioned documents; an older document is upgraded by the migration hook, one version at a time,
6
+ // before it is validated. Escalation is never automatic and services are never activated without confirmation.
7
+ //
8
+ // v1.1 (profile version 2): `revision` records the catalog revision a profile was made from (profile rebases use
9
+ // it), and `enrollment` says whether the plan enrolls the machine ("inline", the default) or leaves it to a separate
10
+ // `hubctl machine enroll` ("separate", which the Ansible path always uses), so both paths plan the same digest.
11
+ import { SERVICE_IDS, type ServiceID } from "./exec.ts"
12
+ import { HARNESS_ORDER, RUNTIME_IDS, type HarnessID, type RuntimeID } from "./harness.ts"
13
+ import { bool, Checker, fail, header, int, isRecord, oneOf, shape, subset, type Result } from "./validate.ts"
14
+
15
+ export const PROFILE_SCHEMA = "hub.bootstrap-profile"
16
+ export const PROFILE_VERSION = 2
17
+ /** Profile versions a plan may record: plans made by a v1.0 hubctl (profile version 1) still validate and resume. */
18
+ export const PLAN_PROFILE_VERSIONS = [1, 2] as const
19
+ export type PlanProfileVersion = (typeof PLAN_PROFILE_VERSIONS)[number]
20
+ /** v1.1: inline: the plan enrolls the machine (enroll-machine). separate: enrollment is its own step; the plan never enrolls. */
21
+ export const ENROLLMENT_MODES = ["inline", "separate"] as const
22
+ export type EnrollmentMode = (typeof ENROLLMENT_MODES)[number]
23
+ export const PROFILE_IDS = ["session-only", "launch-node", "full-harness"] as const
24
+ export type ProfileID = (typeof PROFILE_IDS)[number]
25
+
26
+ export interface Profile {
27
+ readonly schema: typeof PROFILE_SCHEMA
28
+ readonly version: typeof PROFILE_VERSION
29
+ readonly id: ProfileID
30
+ /** In HARNESS_ORDER. */
31
+ readonly harnesses: readonly HarnessID[]
32
+ readonly runtimes: readonly RuntimeID[]
33
+ readonly services: readonly ServiceID[]
34
+ readonly launch: { readonly enabled: boolean; readonly maxDurationS?: number }
35
+ /** "explicit": the user may approve elevated actions at apply time; "never": the plan stays unprivileged. */
36
+ readonly escalation: "never" | "explicit"
37
+ /** v1.1: the built-in catalog revision this profile was made from (absent: unknown, e.g. written by hand). */
38
+ readonly revision?: number
39
+ /** v1.1: whether the plan enrolls the machine (default "inline"). */
40
+ readonly enrollment?: EnrollmentMode
41
+ }
42
+
43
+ /** The enrollment mode a profile plans with. */
44
+ export const enrollmentModeOf = (profile: Pick<Profile, "enrollment">): EnrollmentMode => profile.enrollment ?? "inline"
45
+
46
+ /** The service each harness's spoke runs as, when it has one (OpenCode's spoke lives in its plugin). */
47
+ export const HARNESS_SERVICE: Readonly<Partial<Record<HarnessID, ServiceID>>> = Object.freeze({ "claude-code": "claude-code", codex: "codex-spoke" })
48
+
49
+ export function validateProfile(value: unknown): Result<Profile> {
50
+ const c = new Checker()
51
+ const r = shape(c, "", value, ["schema", "version", "id", "harnesses", "runtimes", "services", "launch", "escalation"], ["revision", "enrollment"])
52
+ if (!r) return c.result(value)
53
+ header(c, "", r, PROFILE_SCHEMA, PROFILE_VERSION)
54
+ if (r.revision !== undefined) int(c, "/revision", r.revision, 1, 1_000_000)
55
+ if (r.enrollment !== undefined) oneOf(c, "/enrollment", r.enrollment, ENROLLMENT_MODES)
56
+ oneOf(c, "/id", r.id, PROFILE_IDS)
57
+ oneOf(c, "/escalation", r.escalation, ["never", "explicit"] as const)
58
+ const harnesses = subset(c, "/harnesses", r.harnesses, HARNESS_ORDER) ? r.harnesses : []
59
+ const services = subset(c, "/services", r.services, SERVICE_IDS) ? r.services : []
60
+ subset(c, "/runtimes", r.runtimes, RUNTIME_IDS)
61
+ if (Array.isArray(r.harnesses) && r.harnesses.length === 0) c.add("/harnesses", "invalid_value", "a profile sets up at least one harness")
62
+ const order = harnesses.map((h) => HARNESS_ORDER.indexOf(h))
63
+ if (order.some((v, i) => i > 0 && v < order[i - 1]!)) c.add("/harnesses", "inconsistent", `harnesses go in the order ${HARNESS_ORDER.join(", ")}`)
64
+ const launch = shape(c, "/launch", r.launch, ["enabled"], ["maxDurationS"])
65
+ if (launch) {
66
+ bool(c, "/launch/enabled", launch.enabled)
67
+ if (launch.maxDurationS !== undefined) int(c, "/launch/maxDurationS", launch.maxDurationS, 1, 86_400)
68
+ if (launch.enabled === true && launch.maxDurationS === undefined) c.add("/launch/maxDurationS", "inconsistent", "launches are always bounded")
69
+ if (launch.enabled === true && !services.includes("node")) c.add("/launch", "inconsistent", "launching needs the node service")
70
+ }
71
+ if (r.id === "session-only" && services.length > 0) c.add("/services", "inconsistent", "session-only installs no services")
72
+ if (r.id === "session-only" && launch?.enabled === true) c.add("/launch", "inconsistent", "session-only never launches")
73
+ if ((r.id === "launch-node" || r.id === "full-harness") && !services.includes("node")) c.add("/services", "inconsistent", `${String(r.id)} needs the node service`)
74
+ for (const [harness, service] of Object.entries(HARNESS_SERVICE))
75
+ if (services.includes(service!) && !harnesses.includes(harness as HarnessID)) c.add("/services", "inconsistent", `the ${service} service needs the ${harness} harness`)
76
+ return c.result(value)
77
+ }
78
+
79
+ /** One step: a document of version N (the key) to version N+1. */
80
+ export type ProfileMigration = (raw: Record<string, unknown>) => unknown
81
+ export type ProfileMigrations = Readonly<Record<number, ProfileMigration>>
82
+
83
+ /**
84
+ * Registered migrations to the current version. 1 → 2 (v1.1): the new fields are optional and mean what a version-1
85
+ * document meant (catalog revision unknown, enrollment inline), so only the version changes.
86
+ */
87
+ export const PROFILE_MIGRATIONS: ProfileMigrations = Object.freeze({
88
+ 1: (raw: Record<string, unknown>) => ({ ...raw, version: 2 }),
89
+ })
90
+
91
+ /** Upgrades an older profile document step by step, then validates it. */
92
+ export function migrateProfile(raw: unknown, migrations: ProfileMigrations = PROFILE_MIGRATIONS): Result<Profile> {
93
+ let doc = raw
94
+ for (;;) {
95
+ if (!isRecord(doc) || !Number.isInteger(doc.version)) return fail("/version", "invalid_value", "a profile needs an integer version")
96
+ const version = doc.version as number
97
+ if (version > PROFILE_VERSION) return fail("/version", "unsupported_version", `profile version ${version} is newer than this hubctl understands (${PROFILE_VERSION}); upgrade hubctl`)
98
+ if (version === PROFILE_VERSION) return validateProfile(doc)
99
+ const step = migrations[version]
100
+ if (!step) return fail("/version", "unmigratable", `no migration from profile version ${version}`)
101
+ const next = step(doc)
102
+ if (!isRecord(next) || !Number.isInteger(next.version) || (next.version as number) <= version) return fail("/version", "unmigratable", `the migration from version ${version} did not advance the version`)
103
+ doc = next
104
+ }
105
+ }
@@ -0,0 +1,52 @@
1
+ // Secret detection for everything the bootstrap writes outside the credential store: plans, previews, journals,
2
+ // discovery snapshots, status output, logs and generated Ansible. A document that findSecrets flags is refused,
3
+ // not redacted: the only place a machine token may live is the credential store (credentials.ts).
4
+ import { isRecord, pointer } from "./validate.ts"
5
+
6
+ /** Key names (case-insensitive, "-" and "_" ignored) that mean the value is a credential. */
7
+ const SECRET_KEY_PARTS = ["token", "secret", "password", "passwd", "passphrase", "apikey", "privatekey", "credential", "authorization", "cookie", "devicecode"] as const
8
+
9
+ /** Value shapes of known credentials (anywhere in a string, at a word start): hub app (hat_), session (hst1.), launch (hlt1) and machine test tokens, common vendor keys, PEM keys, bearer headers. */
10
+ const B = "(?:^|[^A-Za-z0-9_])"
11
+ const SECRET_VALUES: readonly RegExp[] = [
12
+ new RegExp(`${B}hat_[A-Za-z0-9_-]{8,}`),
13
+ new RegExp(`${B}hst1\\.[A-Za-z0-9_-]`),
14
+ new RegExp(`${B}hlt1[A-Za-z0-9._-]{8,}`),
15
+ new RegExp(`${B}mt_[A-Za-z0-9_-]+`),
16
+ new RegExp(`${B}sk-[A-Za-z0-9_-]{8,}`),
17
+ new RegExp(`${B}(?:ghp|gho|ghs|ghu)_[A-Za-z0-9]{8,}`),
18
+ new RegExp(`${B}github_pat_`),
19
+ new RegExp(`${B}xox[abprs]-`),
20
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
21
+ /\bBearer\s+\S/i,
22
+ ]
23
+
24
+ export function isSecretKey(key: string): boolean {
25
+ const k = key.toLowerCase().replaceAll(/[-_]/g, "")
26
+ return SECRET_KEY_PARTS.some((part) => k === part || k.endsWith(part) || k.startsWith(part))
27
+ }
28
+
29
+ export const isSecretValue = (value: string): boolean => SECRET_VALUES.some((re) => re.test(value))
30
+
31
+ /**
32
+ * JSON pointers of every credential-named key (with a value) and every credential-shaped string in `value`.
33
+ * `allowKeys` exempts exact pointers whose key name only looks like a credential (a preview's `/token`); their
34
+ * values are still scanned.
35
+ */
36
+ export function findSecrets(value: unknown, base = "", allowKeys: ReadonlySet<string> = new Set()): string[] {
37
+ const found: string[] = []
38
+ const walk = (v: unknown, path: string) => {
39
+ if (typeof v === "string") {
40
+ if (isSecretValue(v)) found.push(path)
41
+ } else if (Array.isArray(v)) v.forEach((item, i) => walk(item, pointer(path, i)))
42
+ else if (isRecord(v))
43
+ for (const [key, item] of Object.entries(v)) {
44
+ if (item === undefined) continue
45
+ const p = pointer(path, key)
46
+ if (isSecretKey(key) && !allowKeys.has(p)) found.push(p)
47
+ else walk(item, p)
48
+ }
49
+ }
50
+ walk(value, base)
51
+ return found
52
+ }
@@ -0,0 +1,212 @@
1
+ // A small, dependency-free validator kit for the bootstrap contracts. Every document crossing a trust boundary (a
2
+ // discovery snapshot, a plan read back from disk, a preview token, a manifest downloaded from a release, a journal
3
+ // left by a crashed apply) is parsed as `unknown` and checked here; nothing is cast blindly. Checks collect every
4
+ // issue (with a JSON pointer) instead of stopping at the first, so a report shows the whole problem.
5
+
6
+ /** Machine-readable reasons a check fails. Tests and callers match on `code`, never on `message`. */
7
+ export type IssueCode =
8
+ | "invalid_value"
9
+ | "missing_field"
10
+ | "unknown_field"
11
+ | "unsupported_version"
12
+ | "inconsistent"
13
+ | "duplicate"
14
+ | "invalid_path"
15
+ | "unmapped_root"
16
+ | "symlink"
17
+ | "exec_field"
18
+ | "secret"
19
+ | "unsupported_platform"
20
+ | "wrong_platform"
21
+ | "integrity_mismatch"
22
+ | "signature_invalid"
23
+ | "downgrade_rejected"
24
+ | "below_security_floor"
25
+ | "trust_root_rollback"
26
+ | "unmigratable"
27
+ | "preview_foreign"
28
+ | "preview_expired"
29
+ | "preview_not_yet_valid"
30
+ | "preview_replayed"
31
+ | "preview_stale"
32
+ | "unpinned_artifact"
33
+ | "evidence_failed"
34
+ | "manual_edit_required"
35
+ /** v1.1: an action needs escalation the profile never allows (was `inconsistent`). */
36
+ | "escalation_required"
37
+ /** v1.1: the profile installs services, but the host has no user service manager. */
38
+ | "no_service_manager"
39
+
40
+ export interface Issue {
41
+ /** JSON pointer of the offending value ("" for the document itself). */
42
+ readonly path: string
43
+ readonly code: IssueCode
44
+ readonly message: string
45
+ }
46
+
47
+ export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly issues: readonly Issue[] }
48
+
49
+ export const ok = <T>(value: T): Result<T> => ({ ok: true, value })
50
+ export const fail = <T = never>(path: string, code: IssueCode, message: string): Result<T> => ({ ok: false, issues: [{ path, code, message }] })
51
+
52
+ /** Collects issues while a document is walked. */
53
+ export class Checker {
54
+ readonly issues: Issue[] = []
55
+
56
+ add(path: string, code: IssueCode, message: string): void {
57
+ this.issues.push({ path, code, message })
58
+ }
59
+
60
+ merge(prefix: string, result: Result<unknown>): void {
61
+ if (!result.ok) for (const issue of result.issues) this.issues.push({ ...issue, path: `${prefix}${issue.path}` })
62
+ }
63
+
64
+ result<T>(value: unknown): Result<T> {
65
+ return this.issues.length === 0 ? ok(value as T) : { ok: false, issues: [...this.issues] }
66
+ }
67
+ }
68
+
69
+ export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }
70
+
71
+ export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
72
+
73
+ /** JSON pointer segment escaping (RFC 6901). */
74
+ export const pointer = (base: string, key: string | number): string => `${base}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`
75
+
76
+ /**
77
+ * Checks that `value` is an object whose keys are all in `allowed` (keys holding `undefined` count as absent) and
78
+ * that every key in `required` is present. Returns the object, or undefined (with issues added) when not an object.
79
+ */
80
+ export function shape(c: Checker, path: string, value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> | undefined {
81
+ if (!isRecord(value)) {
82
+ c.add(path, "invalid_value", "expected an object")
83
+ return undefined
84
+ }
85
+ const allowed = new Set([...required, ...optional])
86
+ for (const [key, v] of Object.entries(value)) if (v !== undefined && !allowed.has(key)) c.add(pointer(path, key), "unknown_field", `unknown field ${key}`)
87
+ for (const key of required) if (value[key] === undefined) c.add(pointer(path, key), "missing_field", `missing ${key}`)
88
+ return value
89
+ }
90
+
91
+ /** schema + version header shared by every versioned document. */
92
+ export function header(c: Checker, path: string, r: Record<string, unknown>, schema: string, version: number): void {
93
+ if (r.schema !== schema) c.add(pointer(path, "schema"), "invalid_value", `expected schema ${schema}`)
94
+ if (!Number.isInteger(r.version)) c.add(pointer(path, "version"), "invalid_value", "version must be an integer")
95
+ else if (r.version !== version) c.add(pointer(path, "version"), "unsupported_version", `version ${String(r.version)} is not supported (expected ${version})`)
96
+ }
97
+
98
+ export function oneOf<T extends string>(c: Checker, path: string, value: unknown, options: readonly T[]): value is T {
99
+ if (typeof value === "string" && (options as readonly string[]).includes(value)) return true
100
+ if (value !== undefined) c.add(path, "invalid_value", `expected one of ${options.join(", ")}`)
101
+ return false
102
+ }
103
+
104
+ export function bool(c: Checker, path: string, value: unknown): value is boolean {
105
+ if (typeof value === "boolean") return true
106
+ if (value !== undefined) c.add(path, "invalid_value", "expected true or false")
107
+ return false
108
+ }
109
+
110
+ export function int(c: Checker, path: string, value: unknown, min: number, max: number): value is number {
111
+ if (Number.isInteger(value) && (value as number) >= min && (value as number) <= max) return true
112
+ if (value !== undefined) c.add(path, "invalid_value", `expected an integer in ${min}..${max}`)
113
+ return false
114
+ }
115
+
116
+ /** A timestamp: integer milliseconds since the epoch. */
117
+ export const instant = (c: Checker, path: string, value: unknown): value is number => int(c, path, value, 0, 8.64e15)
118
+
119
+ // eslint-disable-next-line no-control-regex
120
+ const CONTROL = /[\u0000-\u001f\u007f]/
121
+
122
+ /** A one-line printable string of bounded length (no control characters, so it cannot fake output lines). */
123
+ export function text(c: Checker, path: string, value: unknown, max = 200, pattern?: RegExp): value is string {
124
+ if (typeof value === "string" && value.length >= 1 && value.length <= max && !CONTROL.test(value) && (!pattern || pattern.test(value))) return true
125
+ if (value !== undefined) c.add(path, "invalid_value", pattern ? `expected a string matching ${pattern}` : `expected a one-line string of 1..${max} characters`)
126
+ return false
127
+ }
128
+
129
+ export const DIGEST = /^sha256:[0-9a-f]{64}$/
130
+ export const digest = (c: Checker, path: string, value: unknown): value is string => text(c, path, value, 71, DIGEST)
131
+
132
+ export const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
133
+ export const semver = (c: Checker, path: string, value: unknown): value is string => text(c, path, value, 64, SEMVER)
134
+
135
+ /** An absolute, normalized POSIX path: no empty, "." or ".." segments, no trailing slash, no control characters. */
136
+ export function isAbsolutePath(value: unknown): value is string {
137
+ if (typeof value !== "string" || !value.startsWith("/") || value.length > 4096 || CONTROL.test(value) || value.includes("\\")) return false
138
+ if (value === "/") return true
139
+ return value
140
+ .slice(1)
141
+ .split("/")
142
+ .every((s) => s !== "" && s !== "." && s !== "..")
143
+ }
144
+
145
+ export function absPath(c: Checker, path: string, value: unknown): value is string {
146
+ if (isAbsolutePath(value)) return true
147
+ if (value !== undefined) c.add(path, "invalid_path", "expected an absolute, normalized path")
148
+ return false
149
+ }
150
+
151
+ /** An array whose items are checked by `item`; `unique` reports repeated keys as duplicates. */
152
+ export function list(c: Checker, path: string, value: unknown, max: number, item: (p: string, v: unknown) => void, unique?: (v: unknown) => string | undefined): value is unknown[] {
153
+ if (!Array.isArray(value) || value.length > max) {
154
+ if (value !== undefined) c.add(path, "invalid_value", `expected a list of at most ${max} items`)
155
+ return false
156
+ }
157
+ const seen = new Set<string>()
158
+ value.forEach((v, i) => {
159
+ item(pointer(path, i), v)
160
+ const key = unique?.(v)
161
+ if (key === undefined) return
162
+ if (seen.has(key)) c.add(pointer(path, i), "duplicate", `duplicate ${key}`)
163
+ seen.add(key)
164
+ })
165
+ return true
166
+ }
167
+
168
+ /** A list of distinct strings drawn from `options`. */
169
+ export function subset<T extends string>(c: Checker, path: string, value: unknown, options: readonly T[]): value is T[] {
170
+ return list(c, path, value, options.length, (p, v) => oneOf(c, p, v, options), (v) => (typeof v === "string" ? v : undefined))
171
+ }
172
+
173
+ /** A plain file name (no directories). */
174
+ export const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
175
+
176
+ /** An https URL without credentials (download sources: never plain http, not even for loopback). */
177
+ export function httpsURL(c: Checker, path: string, value: unknown): value is string {
178
+ if (typeof value === "string" && value.length <= 2048) {
179
+ try {
180
+ const url = new URL(value)
181
+ if (url.protocol === "https:" && !url.username && !url.password) return true
182
+ } catch {}
183
+ }
184
+ if (value !== undefined) c.add(path, "invalid_value", "expected an https URL without credentials")
185
+ return false
186
+ }
187
+
188
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"])
189
+
190
+ /** v1.1: whether `value` is a plain-http URL of a non-loopback host, without credentials (an insecure hub). */
191
+ export function isInsecureHubURL(value: unknown): value is string {
192
+ if (typeof value !== "string" || value.length > 2048) return false
193
+ try {
194
+ const url = new URL(value)
195
+ return url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname) && !url.username && !url.password
196
+ } catch {
197
+ return false
198
+ }
199
+ }
200
+
201
+ /** http(s) URL; plain http only for loopback hubs (a local hub on 127.0.0.1). */
202
+ export function hubURL(c: Checker, path: string, value: unknown): value is string {
203
+ if (typeof value === "string" && value.length <= 2048) {
204
+ try {
205
+ const url = new URL(value)
206
+ const loopback = LOOPBACK_HOSTS.has(url.hostname)
207
+ if ((url.protocol === "https:" || (url.protocol === "http:" && loopback)) && !url.username && !url.password) return true
208
+ } catch {}
209
+ }
210
+ if (value !== undefined) c.add(path, "invalid_value", "expected an https URL (http only for a loopback hub), without credentials")
211
+ return false
212
+ }