@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,250 @@
1
+ // The Claude Code HarnessAdapter (contracts §3.10): a pure planner over the discovery snapshot and the verified hub
2
+ // manifest, discovery of the claude binary through the read-only DiscoveryProbe, and verification through real
3
+ // probes (verify.ts). It never gets a shell.
4
+ //
5
+ // Plan (in planSetup's phase order):
6
+ // install-harness the artifact hub's signed manifest pins for this target, into its own immutable
7
+ // directory data:hub/harnesses/claude-code/<version> (downgrade policy: decideHarnessInstall)
8
+ // select-harness-version makes that version current (rollback: the previous version is current again)
9
+ // merge-config hub's own config:hub/claude-code.json gets {"binary": <current/claude>}, the one
10
+ // hub-owned entry that points the existing spoke (daemon, `hub claude`, the plugin it
11
+ // writes) at the hub-installed binary. Created by hub, so a rollback removes it; a
12
+ // symlinked hub config dir becomes a manual edit (planSetup).
13
+ // verify-harness version-exec, provider-auth, spoke-connected (install is never readiness)
14
+ //
15
+ // ~/.claude and ~/.claude.json are never planned: the existing integration loads the hub plugin with --plugin-dir
16
+ // and never edits the user's Claude Code files (docs/harnesses.md). A Claude Code the user installed themselves is
17
+ // kept and only verified, unless the caller prefers a hub-managed install.
18
+ import {
19
+ checkHarnessPin,
20
+ decideHarnessInstall,
21
+ executionContextOf,
22
+ fail,
23
+ harnessCurrentRef,
24
+ harnessVersionRef,
25
+ ok,
26
+ REQUIRED_PROBES,
27
+ resolvePathRef,
28
+ rootPathIn,
29
+ type Action,
30
+ type DiscoverySnapshot,
31
+ type ExecutionContext,
32
+ type HarnessAdapter,
33
+ type HarnessPresence,
34
+ type InstallHarness,
35
+ type MergeConfig,
36
+ type PathRef,
37
+ type PlanInput,
38
+ type Precondition,
39
+ type Result,
40
+ type RootMapping,
41
+ type SelectHarnessVersion,
42
+ type VerifiedManifest,
43
+ type VerifyHarness,
44
+ } from "../../contracts/index.ts"
45
+ import { classifyExecutable, HARNESS_BINARIES, installOf, pathDirs, which } from "../../discovery/executables.ts"
46
+ import { discoverRoots } from "../../discovery/host.ts"
47
+ import type { DiscoveryProbe } from "../../discovery/probe.ts"
48
+ import type { ProbeRunner } from "./ports.ts"
49
+ import { verifyClaude, type ClaudeBinary, type ClaudeProbes } from "./verify.ts"
50
+
51
+ const HARNESS = "claude-code" as const
52
+ const BINARY = HARNESS_BINARIES[HARNESS]
53
+
54
+ /** Hub's own Claude Code integration config (hub-owned; never a Claude Code file). */
55
+ export const CLAUDE_CONFIG_REF: PathRef = Object.freeze({ root: "config", rel: "hub/claude-code.json" })
56
+ /** The key in it naming the claude binary the spoke runs. */
57
+ export const CLAUDE_CONFIG_POINTER = "/binary"
58
+ /** The hub-installed binary, inside the `current` link. */
59
+ export const currentBinaryRel = `${harnessCurrentRef(HARNESS).rel}/${BINARY}`
60
+
61
+ export type ClaudeDecision = "none" | "user-managed" | "keep" | "select" | "install" | "upgrade" | "downgrade"
62
+
63
+ export interface ClaudePlan {
64
+ readonly decision: ClaudeDecision
65
+ readonly actions: readonly Action[]
66
+ }
67
+
68
+ export interface ClaudeAdapterOptions {
69
+ /** The hub release manifest, verified against the shipped trust root. Without it nothing is installed. */
70
+ readonly manifest?: VerifiedManifest
71
+ /** What hub installed before, from the history (installedHarnesses). */
72
+ readonly installed?: { readonly version: string; readonly sha256: string }
73
+ /** The highest Claude Code floor this machine has seen. */
74
+ readonly floor?: string
75
+ /** Explicit --allow-downgrade. */
76
+ readonly allowDowngrade?: boolean
77
+ /** existing (default): keep a Claude Code the user installed; hub: install the pinned one beside it. */
78
+ readonly prefer?: "existing" | "hub"
79
+ /** The state of a config file discovery does not record (config:hub/claude-code.json), by its path. */
80
+ readonly configState?: (path: string) => Precondition | undefined
81
+ /** Read-only host access for discover() and the binary readiness runs against. */
82
+ readonly probe?: DiscoveryProbe
83
+ /** The roots (default: discovered through the probe) and where hubctl runs (default host). */
84
+ readonly roots?: readonly RootMapping[]
85
+ readonly context?: ExecutionContext
86
+ /** Real probes (verify.ts); without a runner nothing can be verified. */
87
+ readonly probes?: { readonly runner: ProbeRunner } & Pick<ClaudeProbes, "spoke" | "conformance">
88
+ }
89
+
90
+ const VERB = { install: "Install", upgrade: "Upgrade", downgrade: "Downgrade" } as const
91
+
92
+ const verifyAction = (): VerifyHarness => ({
93
+ id: `verify-${HARNESS}`,
94
+ kind: "verify-harness",
95
+ escalation: "user",
96
+ summary: "Verify Claude Code runs, is logged in, and its spoke connects to the hub",
97
+ harness: HARNESS,
98
+ probes: REQUIRED_PROBES[HARNESS].filter((p): p is Exclude<typeof p, "presence"> => p !== "presence"),
99
+ })
100
+
101
+ function selectAction(version: string, previous?: { readonly version: string; readonly sha256: string }): SelectHarnessVersion {
102
+ return {
103
+ id: `select-${HARNESS}`,
104
+ kind: "select-harness-version",
105
+ escalation: "user",
106
+ summary: `Make Claude Code ${version} the current version`,
107
+ harness: HARNESS,
108
+ version,
109
+ path: harnessCurrentRef(HARNESS),
110
+ target: harnessVersionRef(HARNESS, version),
111
+ ...(previous ? { previous: { ...previous } } : {}),
112
+ }
113
+ }
114
+
115
+ /** Where a root is for the context the plan runs in. */
116
+ function rootPath(snapshot: DiscoverySnapshot, id: string): string | undefined {
117
+ const root = snapshot.roots.find((r) => r.id === id)
118
+ return root ? rootPathIn(root, executionContextOf(snapshot)) : undefined
119
+ }
120
+
121
+ function configAction(snapshot: DiscoverySnapshot, options: ClaudeAdapterOptions): Result<MergeConfig> {
122
+ const data = rootPath(snapshot, "data")
123
+ const where = resolvePathRef(CLAUDE_CONFIG_REF, snapshot.roots, executionContextOf(snapshot))
124
+ if (!data || !where.ok) return fail("/roots", "unmapped_root", "this host has no data or config root for the hub-installed Claude Code")
125
+ const known = snapshot.configs.find((c) => c.path === where.value)
126
+ const precondition: Precondition = known ? (known.sha256 ? { exists: true, sha256: known.sha256 } : { exists: false }) : (options.configState?.(where.value) ?? { exists: false })
127
+ return ok({
128
+ id: `${HARNESS}-config`,
129
+ kind: "merge-config",
130
+ escalation: "user",
131
+ summary: "Point hub's Claude Code spoke at the hub-installed claude",
132
+ path: CLAUDE_CONFIG_REF,
133
+ format: "json",
134
+ owner: "hub",
135
+ precondition,
136
+ set: [{ pointer: CLAUDE_CONFIG_POINTER, value: `${data}/${currentBinaryRel}` }],
137
+ preserveUnknown: true,
138
+ })
139
+ }
140
+
141
+ /** Plans Claude Code for this snapshot and profile, with the reason when it cannot. Pure and deterministic. */
142
+ export function planClaude(input: PlanInput, options: ClaudeAdapterOptions): Result<ClaudePlan> {
143
+ const { snapshot, profile } = input
144
+ if (!profile.harnesses.includes(HARNESS)) return ok({ decision: "none", actions: [] })
145
+ const presence = snapshot.harnesses.find((h) => h.id === HARNESS)
146
+ const verify = verifyAction()
147
+ if (presence?.installed && presence.installMethod !== "hub" && options.prefer !== "hub") return ok({ decision: "user-managed", actions: [verify] })
148
+ const target = snapshot.host.target
149
+ const manifest = options.manifest
150
+ const pin = manifest?.harnesses.find((p) => p.harness === HARNESS && p.target === target)
151
+ if (!manifest || !pin) return fail("/harnesses/claude-code", "unpinned_artifact", manifest ? `hub ${manifest.release} pins no Claude Code for ${target}` : "no verified hub manifest to install Claude Code from")
152
+ const decided = decideHarnessInstall(pin, { ...(options.installed ? { installed: options.installed } : {}), ...(options.floor ? { floor: options.floor } : {}), allowDowngrade: options.allowDowngrade })
153
+ if (!decided.ok) return decided
154
+ const config = configAction(snapshot, options)
155
+ if (!config.ok) return config
156
+ const currentIsPinned = presence?.installed === true && presence.installMethod === "hub" && presence.version === pin.version
157
+ if (decided.value === "reinstall" || (currentIsPinned && !options.installed)) {
158
+ if (currentIsPinned) return ok({ decision: "keep", actions: [verify] })
159
+ return ok({ decision: "select", actions: [selectAction(pin.version), config.value, verify] })
160
+ }
161
+ const previous = options.installed
162
+ const install: InstallHarness = {
163
+ id: `install-${HARNESS}`,
164
+ kind: "install-harness",
165
+ escalation: "user",
166
+ summary: `${VERB[decided.value]} Claude Code ${pin.version} for ${pin.target} (pinned by hub ${manifest.release})`,
167
+ harness: HARNESS,
168
+ version: pin.version,
169
+ target: pin.target,
170
+ source: { manifest: manifest.release, url: pin.url, file: pin.file },
171
+ size: pin.size,
172
+ sha256: pin.sha256,
173
+ evidence: [...pin.evidence],
174
+ path: harnessVersionRef(HARNESS, pin.version),
175
+ owner: "hub",
176
+ rollback: "restore-previous",
177
+ ...(previous ? { previous: { ...previous } } : {}),
178
+ }
179
+ const pinned = checkHarnessPin(manifest, install)
180
+ if (!pinned.ok) return pinned
181
+ return ok({ decision: decided.value, actions: [install, selectAction(pin.version, previous), config.value, verify] })
182
+ }
183
+
184
+ export interface ClaudeAdapter extends HarnessAdapter {
185
+ /** The binary readiness runs against (hub's current version, else the program `claude` on PATH resolves to). */
186
+ binary(): ClaudeBinary
187
+ }
188
+
189
+ export function createClaudeAdapter(options: ClaudeAdapterOptions): ClaudeAdapter {
190
+ const context = options.context ?? "host"
191
+ const roots = (): readonly RootMapping[] => options.roots ?? (options.probe ? (discoverRoots(options.probe) ?? []) : [])
192
+ const at = (id: string) => {
193
+ const r = roots().find((x) => x.id === id)
194
+ return r ? rootPathIn(r, context) : undefined
195
+ }
196
+ const current = () => {
197
+ const data = at("data")
198
+ return data ? `${data}/${currentBinaryRel}` : undefined
199
+ }
200
+
201
+ /** `claude` on PATH (or in upstream's own install directories), as discovery finds it. */
202
+ const onPath = (probe: DiscoveryProbe, home: string) => {
203
+ const dirs = pathDirs(probe)
204
+ const known = [`${home}/.local/bin`, `${home}/.claude/local`].filter((d) => !dirs.includes(d))
205
+ const path = which(probe, [...dirs, ...known], BINARY)
206
+ return path ? classifyExecutable(probe, BINARY, path) : undefined
207
+ }
208
+
209
+ const adapter: ClaudeAdapter = {
210
+ id: HARNESS,
211
+ async discover(ctx): Promise<HarnessPresence> {
212
+ const none: HarnessPresence = { id: HARNESS, installed: false, health: { state: "unverified", probes: [] } }
213
+ const probe = options.probe
214
+ if (!probe) return none
215
+ const home = at("home") ?? ctx.home
216
+ const shim = onPath(probe, home)
217
+ const hub = current()
218
+ const found = shim ?? (hub && probe.executable(hub) ? classifyExecutable(probe, BINARY, hub) : undefined)
219
+ if (!found) return none
220
+ const install = installOf(probe, HARNESS, found, roots(), context)
221
+ return { id: HARNESS, installed: true, path: found.path, ...(install.version ? { version: install.version } : {}), installMethod: install.installMethod, health: { state: "unverified", probes: [] } }
222
+ },
223
+ plan(input) {
224
+ const planned = planClaude(input, options)
225
+ // Unplannable (unpinned, refused downgrade): verify only, so the setup reports Claude Code as not ready.
226
+ return planned.ok ? planned.value.actions : input.profile.harnesses.includes(HARNESS) ? [verifyAction()] : []
227
+ },
228
+ async verify(ctx) {
229
+ const probes = options.probes
230
+ if (!probes) return verifyClaude({ runner: { run: async () => ({ kind: "error", message: "no runner" }) }, binary: () => ({ problem: "this hubctl has no probe runner" }) }, ctx)
231
+ return verifyClaude({ ...probes, binary: () => adapter.binary() }, ctx)
232
+ },
233
+ binary(): ClaudeBinary {
234
+ const probe = options.probe
235
+ if (!probe) return { problem: "no read-only host probe to find claude" }
236
+ const hub = current()
237
+ if (hub && probe.executable(hub)) {
238
+ const version = installOf(probe, HARNESS, classifyExecutable(probe, BINARY, hub), roots(), context).version
239
+ return { path: hub, ...(version ? { version } : {}) }
240
+ }
241
+ const shim = onPath(probe, at("home") ?? probe.home)
242
+ if (!shim) return { problem: "Claude Code is not installed (no claude on PATH or in hub's harness directory)" }
243
+ if (shim.kind === "container-shim") return { problem: `${shim.path} is a shim into a container; readiness is checked against a binary on this host` }
244
+ const path = shim.resolvesTo ?? shim.path
245
+ const version = installOf(probe, HARNESS, shim, roots(), context).version
246
+ return { path, ...(version ? { version } : {}) }
247
+ },
248
+ }
249
+ return adapter
250
+ }
@@ -0,0 +1,42 @@
1
+ // The Claude Code bootstrap adapter (HBS-08): planning and discovery (adapter.ts), the pinned-artifact source with
2
+ // optional upstream evidence (source.ts, openpgp.ts), real readiness probes (verify.ts), and the injected ports
3
+ // (ports.ts) with their default Node implementations (node.ts). Integration into the CLI is HBS-13's.
4
+ import type { HarnessVerification } from "../../contracts/index.ts"
5
+ import { installHarnessExecutor, type HarnessSource } from "../../transaction/builtin.ts"
6
+ import type { ActionExecutor } from "../../transaction/executor.ts"
7
+ import type { ReadinessGate } from "../readiness.ts"
8
+ import { claudeVerifyExecutor } from "./verify.ts"
9
+
10
+ export * from "./adapter.ts"
11
+ export * from "./node.ts"
12
+ export * from "./openpgp.ts"
13
+ export * from "./ports.ts"
14
+ export * from "./source.ts"
15
+ export * from "./verify.ts"
16
+
17
+ export interface ClaudeExecutorOptions extends ReadinessGate {
18
+ /** createClaudeHarnessSource(...) */
19
+ readonly source: HarnessSource
20
+ /** adapter.verify(context) */
21
+ readonly verify: () => Promise<HarnessVerification>
22
+ readonly now: () => number
23
+ readonly onVerified?: (verification: HarnessVerification) => void
24
+ }
25
+
26
+ /**
27
+ * The executors the Claude Code plan needs beyond the built-in local ones: install-harness from the Claude Code
28
+ * source, and verify-harness for claude-code. The engine has one executor per kind, so with several harnesses the
29
+ * integrator dispatches these by `action.harness` (HBS-13).
30
+ */
31
+ export function claudeExecutors(options: ClaudeExecutorOptions): { readonly "install-harness": ActionExecutor<"install-harness">; readonly "verify-harness": ActionExecutor<"verify-harness"> } {
32
+ return {
33
+ "install-harness": installHarnessExecutor(options.source),
34
+ "verify-harness": claudeVerifyExecutor({
35
+ verify: options.verify,
36
+ now: options.now,
37
+ ...(options.onVerified ? { onVerified: options.onVerified } : {}),
38
+ ...(options.requireReady !== undefined ? { requireReady: options.requireReady } : {}),
39
+ ...(options.onReadiness ? { onReadiness: options.onReadiness } : {}),
40
+ }),
41
+ }
42
+ }
@@ -0,0 +1,99 @@
1
+ // Default Node implementations of the Claude Code adapter's ports (ports.ts). The integrator (HBS-13) passes them;
2
+ // tests inject fakes.
3
+ //
4
+ // nodeRunner spawn(file, args) with shell: false, exactly the given environment, stdin closed, stderr
5
+ // dropped, stdout capped, and a hard timeout (SIGKILL). The only process starter here.
6
+ // httpsDownloader https only (also after redirects), non-2xx refused, the body streamed and capped
7
+ // (DownloadTooLarge, as soon as it runs past the limit).
8
+ // tempScratch a 0700 directory under the system temp dir with one 0600 file (written as its content
9
+ // streams), removed afterwards.
10
+ import { spawn } from "node:child_process"
11
+ import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs"
12
+ import { tmpdir } from "node:os"
13
+ import { join } from "node:path"
14
+ import { chunksOf, responseChunks } from "../../transaction/stream.ts"
15
+ import { DownloadTooLarge, type Downloader, type ProbeRunner, type RunResult, type Scratch } from "./ports.ts"
16
+
17
+ export function nodeRunner(): ProbeRunner {
18
+ return {
19
+ run: (file, args, options) =>
20
+ new Promise<RunResult>((resolve) => {
21
+ let settled = false
22
+ const done = (r: RunResult) => {
23
+ if (settled) return
24
+ settled = true
25
+ clearTimeout(timer)
26
+ resolve(r)
27
+ }
28
+ let child: ReturnType<typeof spawn>
29
+ try {
30
+ child = spawn(file, [...args], { shell: false, env: { ...options.env }, stdio: ["ignore", "pipe", "ignore"], windowsHide: true })
31
+ } catch (error) {
32
+ return resolve({ kind: "error", message: error instanceof Error ? error.message : String(error) })
33
+ }
34
+ const timer = setTimeout(() => {
35
+ child.kill("SIGKILL")
36
+ done({ kind: "timeout" })
37
+ }, options.timeoutMs)
38
+ const chunks: Buffer[] = []
39
+ let size = 0
40
+ child.stdout?.on("data", (chunk: Buffer) => {
41
+ if (size >= options.maxOutputBytes) return
42
+ const part = chunk.subarray(0, options.maxOutputBytes - size)
43
+ chunks.push(part)
44
+ size += part.length
45
+ })
46
+ child.on("error", (error: NodeJS.ErrnoException) => done(error.code === "ENOENT" ? { kind: "not-found" } : { kind: "error", message: error.code ?? "spawn failed" }))
47
+ // Settle once the process exited and its stdout ended (whichever comes last), or on "close".
48
+ let exitCode: number | undefined
49
+ let ended = !child.stdout
50
+ const finish = () => {
51
+ if (exitCode !== undefined && ended) done({ kind: "exited", code: exitCode, stdout: Buffer.concat(chunks).toString("utf8") })
52
+ }
53
+ child.stdout?.on("end", () => ((ended = true), finish()))
54
+ child.stdout?.on("close", () => ((ended = true), finish()))
55
+ child.on("exit", (code, signal) => ((exitCode = code ?? (signal ? -1 : 0)), finish()))
56
+ child.on("close", (code) => ((exitCode ??= code ?? -1), (ended = true), finish()))
57
+ }),
58
+ }
59
+ }
60
+
61
+ export interface DownloaderOptions {
62
+ /** The fetch implementation (default: the global fetch). */
63
+ readonly fetch?: (url: string, init: RequestInit) => Promise<Response>
64
+ }
65
+
66
+ export function httpsDownloader(options: DownloaderOptions = {}): Downloader {
67
+ const doFetch = options.fetch ?? ((url: string, init: RequestInit) => fetch(url, init))
68
+ return {
69
+ async get(url, maxBytes) {
70
+ if (!url.startsWith("https://")) throw new Error(`refusing a non-https download: ${url}`)
71
+ const response = await doFetch(url, { redirect: "follow" })
72
+ if (response.url && !response.url.startsWith("https://")) throw new Error(`${url} redirected away from https`)
73
+ if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`)
74
+ const declared = Number(response.headers.get("content-length") ?? NaN)
75
+ if (Number.isFinite(declared) && declared > maxBytes) throw new DownloadTooLarge(url, maxBytes)
76
+ return responseChunks(response, maxBytes, () => new DownloadTooLarge(url, maxBytes))
77
+ },
78
+ }
79
+ }
80
+
81
+ export function tempScratch(base: string = tmpdir()): Scratch {
82
+ return {
83
+ async withFile(content, use) {
84
+ const dir = mkdtempSync(join(base, "hubctl-claude-"))
85
+ try {
86
+ const file = join(dir, "claude")
87
+ const fd = openSync(file, "wx", 0o600)
88
+ try {
89
+ for await (const chunk of chunksOf(content)) for (let at = 0; at < chunk.byteLength; ) at += writeSync(fd, chunk, at)
90
+ } finally {
91
+ closeSync(fd)
92
+ }
93
+ return await use(file)
94
+ } finally {
95
+ rmSync(dir, { recursive: true, force: true })
96
+ }
97
+ },
98
+ }
99
+ }
@@ -0,0 +1,221 @@
1
+ // An in-house check of one OpenPGP detached signature (RFC 4880 v4, RSA), enough to verify Claude Code's signed
2
+ // per-release manifest.json without a hard dependency on gpg (contracts §5, decision Q3). It is deliberately
3
+ // narrow: a v4 RSA public key pinned by its fingerprint, a v4 binary-document signature, SHA-2 hashes. Every
4
+ // outcome is one of
5
+ // verified the pinned key signed exactly these bytes;
6
+ // unavailable this check cannot vouch either way (another or unpinned key, a format this verifier does not
7
+ // read): recorded, never a pass;
8
+ // failed checked and wrong (a bad signature in the pinned key's name, a malformed signature packet).
9
+ // It never throws, and its detail is one short line with no input bytes in it.
10
+ import { createHash, createPublicKey, constants, verify, type KeyObject } from "node:crypto"
11
+
12
+ /** Anthropic's Claude Code release-signing key (upstream docs: `gpg --fingerprint security@anthropic.com`). */
13
+ export const CLAUDE_SIGNING_FINGERPRINT = "31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE"
14
+
15
+ export interface PgpOutcome {
16
+ readonly status: "verified" | "unavailable" | "failed"
17
+ readonly detail: string
18
+ }
19
+
20
+ export interface DetachedSignatureInput {
21
+ /** The signed bytes (manifest.json, exactly as downloaded). */
22
+ readonly data: Uint8Array
23
+ /** The detached signature, ASCII-armored or binary. */
24
+ readonly signature: string | Uint8Array
25
+ /** The signer's transferable public key, ASCII-armored or binary. */
26
+ readonly publicKey: string | Uint8Array
27
+ /** The pinned v4 fingerprint (40 hex digits) the key must have. */
28
+ readonly fingerprint: string
29
+ }
30
+
31
+ class Malformed extends Error {}
32
+
33
+ const RSA_ALGORITHMS = new Set([1, 2, 3])
34
+ /** OpenPGP hash IDs this verifier accepts (SHA-1 and MD5 are not accepted). */
35
+ const HASHES: Readonly<Record<number, string>> = { 8: "sha256", 9: "sha384", 10: "sha512", 11: "sha224" }
36
+ const BASE64_LINE = /^[A-Za-z0-9+/]*={0,2}$/
37
+
38
+ const outcome = (status: PgpOutcome["status"], detail: string): PgpOutcome => ({ status, detail: detail.slice(0, 200) })
39
+
40
+ /** The binary packets of an armored or binary OpenPGP message. */
41
+ function bytesOf(input: string | Uint8Array): Uint8Array {
42
+ const text = typeof input === "string" ? input : input[0] === 0x2d ? new TextDecoder().decode(input) : undefined
43
+ if (text === undefined) return input as Uint8Array
44
+ const lines = text.split(/\r?\n/)
45
+ let i = lines.findIndex((l) => /^-----BEGIN PGP [A-Z ]+-----$/.test(l.trim()))
46
+ if (i < 0) throw new Malformed("not an armored OpenPGP block")
47
+ // Armor headers end at the first blank line.
48
+ for (i++; i < lines.length && lines[i]!.trim() !== ""; i++) if (!lines[i]!.includes(":")) break
49
+ if (lines[i]?.trim() === "") i++
50
+ const body: string[] = []
51
+ for (; i < lines.length; i++) {
52
+ const line = lines[i]!.trim()
53
+ if (line.startsWith("-----END ")) break
54
+ if (line.startsWith("=") && line.length === 5) continue // the armor checksum
55
+ if (!BASE64_LINE.test(line)) throw new Malformed("the armor is not base64")
56
+ body.push(line)
57
+ }
58
+ const bytes = Buffer.from(body.join(""), "base64")
59
+ if (bytes.length === 0) throw new Malformed("the armor is empty")
60
+ return new Uint8Array(bytes)
61
+ }
62
+
63
+ interface Packet {
64
+ readonly tag: number
65
+ readonly body: Uint8Array
66
+ }
67
+
68
+ /** Packets (RFC 4880 §4.2), old and new format; partial lengths are not used by keys or signatures. */
69
+ function packets(b: Uint8Array): Packet[] {
70
+ const out: Packet[] = []
71
+ let i = 0
72
+ const need = (n: number) => {
73
+ if (i + n > b.length) throw new Malformed("a packet is truncated")
74
+ }
75
+ while (i < b.length) {
76
+ const h = b[i]!
77
+ if (!(h & 0x80)) throw new Malformed("not an OpenPGP packet")
78
+ let tag: number
79
+ let len: number
80
+ let head: number
81
+ if (h & 0x40) {
82
+ tag = h & 0x3f
83
+ need(2)
84
+ const o = b[i + 1]!
85
+ if (o < 192) [len, head] = [o, 2]
86
+ else if (o < 224) {
87
+ need(3)
88
+ ;[len, head] = [((o - 192) << 8) + b[i + 2]! + 192, 3]
89
+ } else if (o === 255) {
90
+ need(6)
91
+ ;[len, head] = [((b[i + 2]! << 24) >>> 0) + (b[i + 3]! << 16) + (b[i + 4]! << 8) + b[i + 5]!, 6]
92
+ } else throw new Malformed("partial packet lengths are not used here")
93
+ } else {
94
+ tag = (h >> 2) & 0x0f
95
+ const type = h & 3
96
+ if (type === 3) throw new Malformed("indeterminate packet lengths are not used here")
97
+ head = [2, 3, 5][type]!
98
+ need(head)
99
+ len = type === 0 ? b[i + 1]! : type === 1 ? (b[i + 1]! << 8) + b[i + 2]! : ((b[i + 1]! << 24) >>> 0) + (b[i + 2]! << 16) + (b[i + 3]! << 8) + b[i + 4]!
100
+ }
101
+ need(head + len)
102
+ out.push({ tag, body: b.subarray(i + head, i + head + len) })
103
+ i += head + len
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** Reads MPIs from `body` starting at `at`. */
109
+ function mpis(body: Uint8Array, at: number, count: number): { values: Uint8Array[]; end: number } {
110
+ const values: Uint8Array[] = []
111
+ let p = at
112
+ for (let k = 0; k < count; k++) {
113
+ if (p + 2 > body.length) throw new Malformed("an MPI is truncated")
114
+ const bytes = Math.ceil(((body[p]! << 8) + body[p + 1]!) / 8)
115
+ if (bytes === 0 || p + 2 + bytes > body.length) throw new Malformed("an MPI is truncated")
116
+ values.push(body.subarray(p + 2, p + 2 + bytes))
117
+ p += 2 + bytes
118
+ }
119
+ return { values, end: p }
120
+ }
121
+
122
+ const hex = (b: Uint8Array) => Buffer.from(b).toString("hex").toUpperCase()
123
+
124
+ interface PublicKey {
125
+ readonly fingerprint: string
126
+ readonly key?: KeyObject
127
+ readonly modulusBytes: number
128
+ readonly problem?: string
129
+ }
130
+
131
+ function readPublicKey(input: string | Uint8Array): PublicKey | undefined {
132
+ try {
133
+ const primary = packets(bytesOf(input)).find((p) => p.tag === 6)
134
+ if (!primary) return undefined
135
+ const body = primary.body
136
+ const fingerprint = createHash("sha1")
137
+ .update(Buffer.concat([Buffer.from([0x99, (body.length >> 8) & 0xff, body.length & 0xff]), body]))
138
+ .digest("hex")
139
+ .toUpperCase()
140
+ if (body[0] !== 4) return { fingerprint, modulusBytes: 0, problem: `a v${body[0]} key is not supported` }
141
+ if (body.length < 6) throw new Malformed("the key is truncated")
142
+ if (!RSA_ALGORITHMS.has(body[5]!)) return { fingerprint, modulusBytes: 0, problem: `key algorithm ${body[5]} is not supported (RSA only)` }
143
+ const [n, e] = mpis(body, 6, 2).values as [Uint8Array, Uint8Array]
144
+ const key = createPublicKey({ key: { kty: "RSA", n: Buffer.from(n).toString("base64url"), e: Buffer.from(e).toString("base64url") }, format: "jwk" })
145
+ return { fingerprint, key, modulusBytes: n.length }
146
+ } catch {
147
+ return undefined
148
+ }
149
+ }
150
+
151
+ /** The v4 fingerprint of a public key (40 hex digits), or undefined when it cannot be read. */
152
+ export function publicKeyFingerprint(input: string | Uint8Array): string | undefined {
153
+ return readPublicKey(input)?.fingerprint
154
+ }
155
+
156
+ /** Issuer fingerprint (33) and issuer key ID (16) subpackets. */
157
+ function issuers(area: Uint8Array): { fingerprint?: string; keyID?: string } {
158
+ const found: { fingerprint?: string; keyID?: string } = {}
159
+ let r = 0
160
+ while (r < area.length) {
161
+ let len = area[r]!
162
+ let o = 1
163
+ if (len >= 192 && len < 255) {
164
+ if (r + 1 >= area.length) throw new Malformed("a subpacket is truncated")
165
+ len = ((len - 192) << 8) + area[r + 1]! + 192
166
+ o = 2
167
+ } else if (len === 255) {
168
+ if (r + 5 > area.length) throw new Malformed("a subpacket is truncated")
169
+ len = ((area[r + 1]! << 24) >>> 0) + (area[r + 2]! << 16) + (area[r + 3]! << 8) + area[r + 4]!
170
+ o = 5
171
+ }
172
+ if (len < 1 || r + o + len > area.length) throw new Malformed("a subpacket is truncated")
173
+ const type = area[r + o]! & 0x7f
174
+ const value = area.subarray(r + o + 1, r + o + len)
175
+ if (type === 33 && value[0] === 4 && value.length === 21) found.fingerprint = hex(value.subarray(1))
176
+ if (type === 16 && value.length === 8) found.keyID = hex(value)
177
+ r += o + len
178
+ }
179
+ return found
180
+ }
181
+
182
+ /** Verifies a detached signature over `data` by the pinned key. */
183
+ export function verifyDetachedSignature(input: DetachedSignatureInput): PgpOutcome {
184
+ const pinned = input.fingerprint.replace(/\s+/g, "").toUpperCase()
185
+ const key = readPublicKey(input.publicKey)
186
+ if (!key) return outcome("unavailable", "the published signing key cannot be read")
187
+ if (key.fingerprint !== pinned) return outcome("unavailable", `the published signing key ${key.fingerprint} is not the pinned key ${pinned}`)
188
+ if (!key.key) return outcome("unavailable", `the signing key cannot be used here: ${key.problem}`)
189
+ try {
190
+ const sig = packets(bytesOf(input.signature)).find((p) => p.tag === 2)?.body
191
+ if (!sig) throw new Malformed("no signature packet")
192
+ if (sig[0] !== 4) return outcome("unavailable", `a v${sig[0]} signature is not supported`)
193
+ if (sig.length < 6) throw new Malformed("the signature is truncated")
194
+ const [, type, algorithm, hashID] = sig as unknown as [number, number, number, number]
195
+ const hashedLen = (sig[4]! << 8) + sig[5]!
196
+ if (6 + hashedLen + 2 > sig.length) throw new Malformed("the signature is truncated")
197
+ const hashed = sig.subarray(6, 6 + hashedLen)
198
+ const unhashedLen = (sig[6 + hashedLen]! << 8) + sig[7 + hashedLen]!
199
+ const unhashedAt = 8 + hashedLen
200
+ if (unhashedAt + unhashedLen + 2 > sig.length) throw new Malformed("the signature is truncated")
201
+ const unhashed = sig.subarray(unhashedAt, unhashedAt + unhashedLen)
202
+ const [value] = mpis(sig, unhashedAt + unhashedLen + 2, 1).values as [Uint8Array]
203
+ if (type !== 0x00) return outcome("unavailable", `signature type 0x${type.toString(16).padStart(2, "0")} is not supported (binary documents only)`)
204
+ if (!RSA_ALGORITHMS.has(algorithm)) return outcome("unavailable", `signature algorithm ${algorithm} is not supported (RSA only)`)
205
+ const hash = HASHES[hashID]
206
+ if (!hash) return outcome("unavailable", `hash algorithm ${hashID} is not accepted`)
207
+ const ids = { ...issuers(unhashed), ...issuers(hashed) }
208
+ if (ids.fingerprint && ids.fingerprint !== pinned) return outcome("unavailable", `signed by another key ${ids.fingerprint}`)
209
+ if (!ids.fingerprint && ids.keyID && ids.keyID !== pinned.slice(-16)) return outcome("unavailable", `signed by another key ${ids.keyID}`)
210
+ const trailer = Buffer.alloc(6)
211
+ trailer[0] = 4
212
+ trailer[1] = 0xff
213
+ trailer.writeUInt32BE(6 + hashedLen, 2)
214
+ const signed = Buffer.concat([input.data, sig.subarray(0, 6 + hashedLen), trailer])
215
+ const padded = Buffer.concat([Buffer.alloc(Math.max(0, key.modulusBytes - value.length)), value])
216
+ const good = verify(hash, signed, { key: key.key, padding: constants.RSA_PKCS1_PADDING }, padded)
217
+ return good ? outcome("verified", `good signature by ${pinned}`) : outcome("failed", `the signature by ${pinned} does not match these bytes`)
218
+ } catch (error) {
219
+ return outcome("failed", `the signature is malformed: ${error instanceof Malformed ? error.message : "unreadable"}`)
220
+ }
221
+ }
@@ -0,0 +1,44 @@
1
+ // The Claude Code adapter's only ways to reach outside itself, all injected: an https downloader, a fixed-argv
2
+ // process runner (never a shell) and a private scratch file. node.ts has the default implementations; tests
3
+ // inject fakes. Bodies and scratch content are ByteSources: the ~200 MB binary streams, it is never held whole.
4
+ import type { ByteSource } from "../../transaction/stream.ts"
5
+
6
+ /** Thrown by a Downloader when the body is longer than allowed. */
7
+ export class DownloadTooLarge extends Error {
8
+ constructor(url: string, maxBytes: number) {
9
+ super(`${url} is larger than ${maxBytes} bytes`)
10
+ this.name = "DownloadTooLarge"
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Fetches an https URL's body, refusing more than `maxBytes` (DownloadTooLarge, also while it streams); throws on
16
+ * any failure. The body is whole (small things, fakes) or chunks as they arrive.
17
+ */
18
+ export interface Downloader {
19
+ get(url: string, maxBytes: number): Promise<ByteSource>
20
+ }
21
+
22
+ /** How a fixed-argv probe ended. `stdout` is the probe's to parse and drop; it is never recorded. */
23
+ export type RunResult =
24
+ | { readonly kind: "exited"; readonly code: number; readonly stdout: string }
25
+ | { readonly kind: "not-found" }
26
+ | { readonly kind: "timeout" }
27
+ | { readonly kind: "error"; readonly message: string }
28
+
29
+ export interface RunOptions {
30
+ /** The child's whole environment (nothing else is inherited). */
31
+ readonly env: Readonly<Record<string, string>>
32
+ readonly timeoutMs: number
33
+ readonly maxOutputBytes: number
34
+ }
35
+
36
+ /** Runs one program with an argument list (never a shell). The only way this adapter starts a process. */
37
+ export interface ProbeRunner {
38
+ run(file: string, args: readonly string[], options: RunOptions): Promise<RunResult>
39
+ }
40
+
41
+ /** A private (0700) scratch copy of some content (written as it streams) for the duration of `use`, removed afterwards. */
42
+ export interface Scratch {
43
+ withFile<T>(content: ByteSource, use: (path: string) => Promise<T>): Promise<T>
44
+ }