@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,11 @@
1
+ {
2
+ "name": "@huynle/hub-cli",
3
+ "version": "0.7.2",
4
+ "private": true,
5
+ "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "exports": {
9
+ "./package.json": "./package.json"
10
+ }
11
+ }
@@ -0,0 +1,70 @@
1
+ // The act verbs in the CLI (design §5, §8): the answer of `hub answer` (key=value pairs, or --json '{…}' / --json -),
2
+ // the short text of what the hub answers to approve, deny, answer, prompt and stop, and the lines a needs_user
3
+ // failure adds (its evaluation and the Decision it opened). The shapes are packages/sdk's types (acting-routes.ts).
4
+ import type { Approved, Denied, Envelope, Evaluation, FormAnswer, FormAnswered, PromptDelivery, Prompted, Stopped } from "../../sdk/src/index.ts"
5
+ import { jsonObjectArg, UsageError, type Parsed } from "./args"
6
+ import { oneLine } from "./format"
7
+
8
+ const grantText = (grant: string | undefined) => (grant ? `grant ${grant}` : undefined)
9
+ const inParens = (parts: readonly (string | undefined)[]) => {
10
+ const shown = parts.filter(Boolean)
11
+ return shown.length ? ` (${shown.join(", ")})` : ""
12
+ }
13
+
14
+ export const approvedText = (b: Approved): string => `approved ${b.requestID} in ${b.sessionID}${inParens([b.decision, grantText(b.grant)])}`
15
+
16
+ export function deniedText(b: Denied): string {
17
+ const reason = b.reasonSent === true ? "; the reason reached the harness" : b.reasonSent === false ? "; the harness did not get the reason (older spoke)" : ""
18
+ return `denied ${b.requestID} in ${b.sessionID}${inParens([grantText(b.grant)])}${reason}`
19
+ }
20
+
21
+ export const answeredText = (b: FormAnswered, cancelled: boolean): string => `${cancelled ? "cancelled" : "answered"} ${b.formID} in ${b.sessionID}${inParens([grantText(b.grant)])}`
22
+
23
+ export function promptedText(b: Prompted): string {
24
+ if ("messageID" in b) return `prompted ${b.sessionID}: ${b.messageID} delivered (${b.delivered})${b.grant ? `, grant ${b.grant}` : ""}`
25
+ return `prompted ${b.sessionID}: ${b.mode === "steer" ? "steered into the running turn" : "started a turn"}`
26
+ }
27
+
28
+ export const stoppedText = (b: Stopped): string => `stopped ${b.sessionID}${inParens([grantText(b.grant)])}`
29
+
30
+ /** The deliveries of a prompt. */
31
+ export const DELIVERIES: readonly PromptDelivery[] = ["queue", "steer"]
32
+
33
+ /** The --delivery of `hub prompt`. */
34
+ export function deliveryArg(value: string | undefined): PromptDelivery | undefined {
35
+ if (value !== undefined && !(DELIVERIES as readonly string[]).includes(value)) throw new UsageError(`--delivery is one of ${DELIVERIES.join(", ")}`)
36
+ return value as PromptDelivery | undefined
37
+ }
38
+
39
+ /** The answer of `hub answer`: key=value pairs (a repeated key is a list), or a JSON object from --json; "cancel" for --cancel. */
40
+ export async function formAnswerArg(pairs: readonly string[], flags: Parsed["flags"], stdin: (() => Promise<string>) | undefined): Promise<FormAnswer | "cancel"> {
41
+ const json = typeof flags.json === "string" ? flags.json : undefined
42
+ const given = [pairs.length > 0, json !== undefined, flags.cancel === true].filter(Boolean).length
43
+ if (given > 1) throw new UsageError("give either key=value pairs, --json '{…}' or --cancel")
44
+ if (flags.cancel === true) return "cancel"
45
+ if (json !== undefined) return (await jsonObjectArg(json, stdin, "answer")) as FormAnswer
46
+ if (!pairs.length) throw new UsageError("needs key=value pairs (or --json '{…}', or --cancel)")
47
+ const answer: Record<string, string | string[]> = {}
48
+ for (const pair of pairs) {
49
+ const eq = pair.indexOf("=")
50
+ if (eq <= 0) throw new UsageError(`answers are key=value (got "${pair}")`)
51
+ const key = pair.slice(0, eq)
52
+ const value = pair.slice(eq + 1)
53
+ const before = answer[key]
54
+ answer[key] = before === undefined ? value : [...(Array.isArray(before) ? before : [before]), value]
55
+ }
56
+ return answer
57
+ }
58
+
59
+ /** What a needs_user failure adds: the evaluation, the grants checked and the Decision opened for the user. */
60
+ export function needsUserLines(envelope: Envelope): string[] {
61
+ const lines: string[] = []
62
+ const evaluation = envelope.evaluation as Evaluation | undefined
63
+ if (evaluation && typeof evaluation === "object") {
64
+ lines.push(`evaluation: ${evaluation.verdict}${evaluation.tier ? ` (${evaluation.tier})` : ""} — ${oneLine(String(evaluation.reason ?? ""), 300)}`)
65
+ const grants = Array.isArray(evaluation.grants) ? evaluation.grants : []
66
+ lines.push(`grants checked: ${grants.length ? grants.join(", ") : "none"}`)
67
+ }
68
+ if (typeof envelope.decisionID === "string") lines.push(`decision ${envelope.decisionID} is open for the user`)
69
+ return lines
70
+ }
@@ -0,0 +1,91 @@
1
+ // Command-line parsing: `--flag`, `--flag value`, `--flag=value` and short aliases (-n, -h, -V). Unknown flags
2
+ // and wrong positional counts are usage errors (exit 2).
3
+
4
+ /**
5
+ * optional: a value only when given with `=` or when the next token starts with a digit (`--wait`, `--wait 30`).
6
+ * json: a value only when given with `=`, or when the next token looks like JSON (`{`, `[`) or is `-` (stdin):
7
+ * `hub answer … --json '{…}'` takes the answer while a bare `--json` keeps its usual meaning (raw output).
8
+ */
9
+ export type FlagKind = "bool" | "string" | "multi" | "optional" | "json"
10
+
11
+ export interface CommandSpec {
12
+ readonly flags?: Readonly<Record<string, FlagKind>>
13
+ /** Positional arguments: required names, then optional ones (suffixed `?`), then any number (suffixed `...`). */
14
+ readonly args?: readonly string[]
15
+ }
16
+
17
+ export interface Parsed {
18
+ readonly flags: Record<string, string | boolean | string[]>
19
+ readonly args: string[]
20
+ }
21
+
22
+ export class UsageError extends Error {}
23
+
24
+ /** Flags every command takes. */
25
+ const COMMON: Record<string, FlagKind> = { json: "bool", help: "bool" }
26
+ const SHORT: Record<string, string> = { h: "help", n: "n", V: "version" }
27
+
28
+ export function parse(argv: readonly string[], spec: CommandSpec): Parsed {
29
+ const kinds: Record<string, FlagKind> = { ...COMMON, ...spec.flags }
30
+ const flags: Parsed["flags"] = {}
31
+ const args: string[] = []
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const token = argv[i]!
34
+ if (token === "--") {
35
+ args.push(...argv.slice(i + 1))
36
+ break
37
+ }
38
+ const long = /^--([a-z][a-z-]*)(?:=(.*))?$/s.exec(token)
39
+ const short = /^-([a-zA-Z])$/.exec(token)
40
+ if (!long && !short) {
41
+ if (token.startsWith("-") && token.length > 1) throw new UsageError(`unknown option ${token}`)
42
+ args.push(token)
43
+ continue
44
+ }
45
+ const name = long ? long[1]! : SHORT[short![1]!]
46
+ const kind = name === undefined ? undefined : kinds[name]
47
+ if (name === undefined || kind === undefined) throw new UsageError(`unknown option ${token}`)
48
+ if (kind === "bool") {
49
+ if (long?.[2] !== undefined) throw new UsageError(`${token}: --${name} takes no value`)
50
+ flags[name] = true
51
+ continue
52
+ }
53
+ let value = long?.[2]
54
+ const next = argv[i + 1] ?? ""
55
+ if (value === undefined && ((kind === "optional" && !/^\d/.test(next)) || (kind === "json" && !/^[{[]/.test(next) && next !== "-"))) {
56
+ flags[name] = true
57
+ continue
58
+ }
59
+ if (value === undefined) {
60
+ value = argv[++i]
61
+ if (value === undefined) throw new UsageError(`--${name} needs a value`)
62
+ }
63
+ if (kind === "multi") flags[name] = [...((flags[name] as string[] | undefined) ?? []), value]
64
+ else flags[name] = value
65
+ }
66
+ if (flags.help) return { flags, args }
67
+ const names = spec.args ?? []
68
+ const required = names.filter((n) => !n.endsWith("?") && !n.endsWith("...")).length
69
+ if (args.length < required) throw new UsageError(`missing ${names.slice(args.length, required).join(" ")}`)
70
+ if (args.length > names.length && !names.at(-1)?.endsWith("...")) throw new UsageError(`unexpected argument ${args[names.length]}`)
71
+ return { flags, args }
72
+ }
73
+
74
+ export const str = (flags: Parsed["flags"], name: string): string | undefined => (typeof flags[name] === "string" ? (flags[name] as string) : undefined)
75
+
76
+ /** A --json value: a JSON object, or all of stdin for "-"; anything else is a usage error. */
77
+ export async function jsonObjectArg(value: string, stdin: (() => Promise<string>) | undefined, what: string): Promise<Record<string, unknown>> {
78
+ let text = value
79
+ if (value === "-") {
80
+ if (!stdin) throw new UsageError(`cannot read the ${what} from stdin here`)
81
+ text = await stdin()
82
+ }
83
+ let parsed: unknown
84
+ try {
85
+ parsed = JSON.parse(text)
86
+ } catch {
87
+ parsed = undefined
88
+ }
89
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new UsageError(`--json takes the ${what} as a JSON object`)
90
+ return parsed as Record<string, unknown>
91
+ }
@@ -0,0 +1,276 @@
1
+ // Streaming readers for the release archives harness adapters unpack (OpenCode's tar.gz and zip, Codex's tar.gz),
2
+ // so an archive and what it unpacks to are never in memory whole: gzip and raw deflate through hub's own streaming
3
+ // decoder (inflate.ts), tar parsed block by block from the inflated stream, zip members located through the central
4
+ // directory and inflated by offset. Each reader only reports what the archive says; the adapters decide what is acceptable
5
+ // (names, types, layout) before a member's body is written anywhere. Limits are enforced while streaming: the
6
+ // inflated total (a gzip bomb stops at `maxBytes`), and a zip member never inflates past its recorded size.
7
+ // No dependency.
8
+ import type { ByteReader } from "../transaction/stream.ts"
9
+ import { crc32, gunzipChunks, InflateError, inflateRawChunks } from "./inflate.ts"
10
+
11
+ /** Why an archive was refused (the adapters phrase it). */
12
+ export class ArchiveError extends Error {
13
+ constructor(message: string) {
14
+ super(message)
15
+ this.name = "ArchiveError"
16
+ }
17
+ }
18
+
19
+ const BLOCK = 512
20
+
21
+ const field = (block: Uint8Array, at: number, len: number) => {
22
+ const raw = block.subarray(at, at + len)
23
+ const end = raw.indexOf(0)
24
+ return new TextDecoder().decode(end < 0 ? raw : raw.subarray(0, end))
25
+ }
26
+
27
+ /** An octal tar number, or GNU base-256 (high bit set) for large sizes; undefined when unreadable. */
28
+ function tarNumber(block: Uint8Array, at: number, len: number): number | undefined {
29
+ if (block[at]! & 0x80) {
30
+ let n = 0
31
+ for (let i = at + 1; i < at + len; i++) n = n * 256 + block[i]!
32
+ return Number.isSafeInteger(n) ? n : undefined
33
+ }
34
+ const text = field(block, at, len).trim()
35
+ if (text === "") return 0
36
+ return /^[0-7]+$/.test(text) ? parseInt(text, 8) : undefined
37
+ }
38
+
39
+ function checksumOK(block: Uint8Array): boolean {
40
+ let sum = 0
41
+ for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 0x20 : block[i]!
42
+ return tarNumber(block, 148, 8) === sum
43
+ }
44
+
45
+ /** Pulls exact byte counts out of a chunked stream, counting everything it pulls against a limit. */
46
+ class Pull {
47
+ private chunk: Uint8Array = new Uint8Array()
48
+ private at = 0
49
+ private total = 0
50
+ constructor(
51
+ private readonly source: Iterator<Uint8Array>,
52
+ private readonly maxBytes: number,
53
+ private readonly overLimit: string,
54
+ private readonly damaged: string,
55
+ ) {}
56
+
57
+ private async more(): Promise<boolean> {
58
+ let next: IteratorResult<Uint8Array>
59
+ try {
60
+ next = this.source.next()
61
+ } catch (error) {
62
+ if (error instanceof InflateError) throw new ArchiveError(`${this.damaged} (${error.message})`)
63
+ throw error
64
+ }
65
+ if (next.done) return false
66
+ this.total += next.value.byteLength
67
+ if (this.total > this.maxBytes) throw new ArchiveError(this.overLimit)
68
+ this.chunk = next.value
69
+ this.at = 0
70
+ return true
71
+ }
72
+
73
+ /** Exactly `n` bytes (a header), or undefined at a clean end of the stream before any of them. */
74
+ async exact(n: number): Promise<Uint8Array | undefined> {
75
+ const out = new Uint8Array(n)
76
+ let got = 0
77
+ while (got < n) {
78
+ if (this.at >= this.chunk.byteLength && !(await this.more())) {
79
+ if (got === 0) return undefined
80
+ throw new ArchiveError("it is truncated")
81
+ }
82
+ const take = Math.min(n - got, this.chunk.byteLength - this.at)
83
+ out.set(this.chunk.subarray(this.at, this.at + take), got)
84
+ this.at += take
85
+ got += take
86
+ }
87
+ return out
88
+ }
89
+
90
+ /** The next `n` bytes, as the chunks they arrive in. */
91
+ async *take(n: number): AsyncGenerator<Uint8Array> {
92
+ let left = n
93
+ while (left > 0) {
94
+ if (this.at >= this.chunk.byteLength && !(await this.more())) throw new ArchiveError("it is truncated")
95
+ const take = Math.min(left, this.chunk.byteLength - this.at)
96
+ const part = this.chunk.subarray(this.at, this.at + take)
97
+ this.at += take
98
+ left -= take
99
+ yield part
100
+ }
101
+ }
102
+
103
+ async skip(n: number): Promise<void> {
104
+ for await (const _ of this.take(n));
105
+ }
106
+ }
107
+
108
+ export interface TarHeader {
109
+ /** The member name as the header has it (ustar prefix joined), before pax or GNU long names. */
110
+ readonly name: string
111
+ /** The type flag; a NUL flag reads as "0". */
112
+ readonly type: string
113
+ readonly size: number
114
+ /** The permission bits field, when readable. */
115
+ readonly mode: number | undefined
116
+ }
117
+
118
+ export interface TarMember {
119
+ readonly header: TarHeader
120
+ /** The member's data, in chunks (once; what is not read is skipped). */
121
+ body(): AsyncGenerator<Uint8Array>
122
+ /** A small member's data whole (pax and GNU long-name records); ArchiveError beyond `limit`. */
123
+ bytes(limit: number): Promise<Uint8Array>
124
+ }
125
+
126
+ /**
127
+ * Reads a gzipped tar from `reader`, calling `visit` for each member in order until the end marker. `visit` may
128
+ * refuse (throw ArchiveError) before reading the body. ArchiveError for a damaged, truncated or oversized archive:
129
+ * `maxBytes` bounds the inflated stream, and the limit is enforced as it inflates.
130
+ */
131
+ export async function readTarGz(reader: ByteReader, options: { readonly maxBytes: number }, visit: (member: TarMember) => Promise<void>): Promise<void> {
132
+ const iterator = gunzipChunks(reader)
133
+ const pull = new Pull(iterator, options.maxBytes, `it inflates beyond ${options.maxBytes} bytes`, "it is not a gzip stream (or it is damaged)")
134
+ try {
135
+ for (;;) {
136
+ const block = await pull.exact(BLOCK)
137
+ if (!block) throw new ArchiveError("a tar without its end marker")
138
+ if (block.every((b) => b === 0)) return
139
+ if (!checksumOK(block)) throw new ArchiveError("a header checksum does not match")
140
+ const size = tarNumber(block, 124, 12)
141
+ if (size === undefined) throw new ArchiveError("a header has an unreadable size")
142
+ const magic = field(block, 257, 6)
143
+ const prefix = magic.startsWith("ustar") && magic !== "ustar " ? field(block, 345, 155) : ""
144
+ const name = prefix ? `${prefix}/${field(block, 0, 100)}` : field(block, 0, 100)
145
+ const flag = block[156]!
146
+ const header: TarHeader = { name, type: flag === 0 ? "0" : String.fromCharCode(flag), size, mode: tarNumber(block, 100, 8) }
147
+ let consumed = false
148
+ const body = async function* () {
149
+ if (consumed) throw new Error("a tar member's body is read once")
150
+ consumed = true
151
+ yield* pull.take(size)
152
+ }
153
+ await visit({
154
+ header,
155
+ body,
156
+ async bytes(limit) {
157
+ if (size > limit) throw new ArchiveError(`a ${header.type} record is larger than ${limit} bytes`)
158
+ const out = new Uint8Array(size)
159
+ let at = 0
160
+ for await (const part of body()) {
161
+ out.set(part, at)
162
+ at += part.byteLength
163
+ }
164
+ return out
165
+ },
166
+ })
167
+ if (!consumed) await pull.skip(size)
168
+ await pull.skip((BLOCK - (size % BLOCK)) % BLOCK)
169
+ }
170
+ } finally {
171
+ iterator.return(undefined)
172
+ }
173
+ }
174
+
175
+ // ---------- zip ----------
176
+
177
+ export interface ZipMember {
178
+ readonly name: string
179
+ readonly kind: "file" | "directory" | "other"
180
+ readonly encrypted: boolean
181
+ readonly method: number
182
+ readonly compressedSize: number
183
+ readonly size: number
184
+ /** The CRC-32 the directory records for the member's data. */
185
+ readonly crc32: number
186
+ /** The local header's offset. */
187
+ readonly local: number
188
+ }
189
+
190
+ /** The members a zip's central directory lists (zip64 is refused); ArchiveError when it is not a readable zip. */
191
+ export function zipMembers(reader: ByteReader, options: { readonly maxBytes: number; readonly maxEntries?: number }): ZipMember[] {
192
+ const tailStart = Math.max(0, reader.size - 22 - 0xffff)
193
+ const tail = reader.read(tailStart, reader.size - tailStart)
194
+ const tv = new DataView(tail.buffer, tail.byteOffset, tail.byteLength)
195
+ let eocd = -1
196
+ for (let i = tail.byteLength - 22; i >= 0; i--)
197
+ if (tv.getUint32(i, true) === 0x06054b50) {
198
+ eocd = i
199
+ break
200
+ }
201
+ if (eocd < 0) throw new ArchiveError("not a zip archive")
202
+ const count = tv.getUint16(eocd + 10, true)
203
+ const cdSize = tv.getUint32(eocd + 12, true)
204
+ const cdOffset = tv.getUint32(eocd + 16, true)
205
+ if (count === 0xffff || cdOffset === 0xffffffff || cdOffset + cdSize > tailStart + eocd) throw new ArchiveError("an unsupported or damaged zip (zip64 or bad directory)")
206
+ if (count > (options.maxEntries ?? 10_000) || cdSize > 16 * 1024 * 1024) throw new ArchiveError("a zip directory larger than any release has")
207
+ const cd = reader.read(cdOffset, cdSize)
208
+ const view = new DataView(cd.buffer, cd.byteOffset, cd.byteLength)
209
+ const decoder = new TextDecoder("utf-8", { fatal: true })
210
+ const members: ZipMember[] = []
211
+ let at = 0
212
+ let total = 0
213
+ for (let n = 0; n < count; n++) {
214
+ if (at + 46 > cd.byteLength || view.getUint32(at, true) !== 0x02014b50) throw new ArchiveError("a damaged zip directory")
215
+ const flags = view.getUint16(at + 8, true)
216
+ const method = view.getUint16(at + 10, true)
217
+ const crc = view.getUint32(at + 16, true)
218
+ const compressedSize = view.getUint32(at + 20, true)
219
+ const size = view.getUint32(at + 24, true)
220
+ const nameLen = view.getUint16(at + 28, true)
221
+ const extraLen = view.getUint16(at + 30, true)
222
+ const commentLen = view.getUint16(at + 32, true)
223
+ const madeBy = view.getUint8(at + 5)
224
+ const external = view.getUint32(at + 38, true)
225
+ const local = view.getUint32(at + 42, true)
226
+ if (at + 46 + nameLen > cd.byteLength) throw new ArchiveError("a damaged zip directory")
227
+ let name: string
228
+ try {
229
+ name = decoder.decode(cd.subarray(at + 46, at + 46 + nameLen))
230
+ } catch {
231
+ throw new ArchiveError("a member name that is not UTF-8")
232
+ }
233
+ at += 46 + nameLen + extraLen + commentLen
234
+ const type = madeBy === 3 ? (external >>> 16) & 0o170000 : name.endsWith("/") ? 0o040000 : 0o100000
235
+ const kind = type === 0o040000 ? "directory" : type === 0o100000 ? "file" : "other"
236
+ if (kind === "file") {
237
+ total += size
238
+ if (total > options.maxBytes) throw new ArchiveError(`it unpacks to more than ${options.maxBytes} bytes`)
239
+ }
240
+ members.push({ name, kind, encrypted: (flags & 0x1) !== 0, method, compressedSize, size, crc32: crc, local })
241
+ }
242
+ return members
243
+ }
244
+
245
+ /**
246
+ * One zip member's data, inflated as it streams, in chunks; ArchiveError if it is damaged, uses an unsupported
247
+ * method, does not inflate to exactly its recorded size (it is cut off at that size, never past it), or does not
248
+ * have its recorded CRC-32 (checked once the last chunk is out: the caller discards what it wrote).
249
+ */
250
+ export async function* zipMemberData(reader: ByteReader, member: ZipMember): AsyncGenerator<Uint8Array> {
251
+ if (member.encrypted) throw new ArchiveError(`${member.name} is encrypted`)
252
+ if (member.method !== 0 && member.method !== 8) throw new ArchiveError(`${member.name} uses an unsupported compression method (${member.method})`)
253
+ const header = reader.read(member.local, 30)
254
+ const hv = new DataView(header.buffer, header.byteOffset, header.byteLength)
255
+ if (header.byteLength < 30 || hv.getUint32(0, true) !== 0x04034b50) throw new ArchiveError("a damaged zip member")
256
+ const start = member.local + 30 + hv.getUint16(26, true) + hv.getUint16(28, true)
257
+ if (start + member.compressedSize > reader.size) throw new ArchiveError("a truncated zip")
258
+ let produced = 0
259
+ let crc = 0
260
+ const wrongSize = () => new ArchiveError(`${member.name} does not have its recorded size`)
261
+ try {
262
+ const chunks = member.method === 0 ? reader.stream(start, start + member.compressedSize) : inflateRawChunks(reader, start, start + member.compressedSize).chunks
263
+ for await (const chunk of chunks) {
264
+ produced += chunk.byteLength
265
+ if (produced > member.size) throw wrongSize()
266
+ crc = crc32(crc, chunk)
267
+ yield chunk
268
+ }
269
+ } catch (error) {
270
+ if (error instanceof InflateError) throw new ArchiveError(`${member.name} does not decompress (${error.message})`)
271
+ throw error
272
+ }
273
+ if (produced !== member.size) throw wrongSize()
274
+ if (crc !== member.crc32) throw new ArchiveError(`${member.name} does not have its recorded CRC-32`)
275
+ }
276
+
@@ -0,0 +1,63 @@
1
+ # Claude Code bootstrap adapter (HBS-08)
2
+
3
+ This adapter plans, installs and verifies Claude Code for `hubctl setup`. It implements contracts §3.6.1, §3.10 and
4
+ §5 of `docs/bootstrap-contracts.md`. It is a pure planner. It reaches outside itself only through injected ports,
5
+ and it never gets a shell.
6
+
7
+ | File | Role |
8
+ |---|---|
9
+ | `adapter.ts` | `createClaudeAdapter` (a `HarnessAdapter`) and `planClaude`. Plans, in order: pinned `install-harness`, `select-harness-version`, `merge-config` of hub's own `config:hub/claude-code.json`, `verify-harness`. Also `discover()` and `binary()`, both through the read-only `DiscoveryProbe`. |
10
+ | `source.ts` | `createClaudeHarnessSource`, the `HarnessSource` for `installHarnessExecutor`. It streams the pinned file into the step's staging area, records the optional evidence (against the staged digest; codesign on a scratch copy written in chunks) and installs the staged binary itself as `<version>/claude`. |
11
+ | `openpgp.ts` | An in-house check of a detached OpenPGP v4 RSA signature. It verifies upstream's signed `manifest.json` against the pinned release key and needs no `gpg`. |
12
+ | `verify.ts` | The real readiness probes, `claudeReadiness` (the capability is offered only when the harness is ready) and `claudeVerifyExecutor`. |
13
+ | `ports.ts` / `node.ts` | The injected ports (`Downloader`, `ProbeRunner`, `Scratch`) and their Node defaults (`httpsDownloader`, `nodeRunner`, `tempScratch`). |
14
+ | `index.ts` | Re-exports, and `claudeExecutors`: `install-harness` and `verify-harness` for claude-code. |
15
+
16
+ ## Authenticity
17
+
18
+ The mandatory check is the sha256 that hub's signed manifest pins. `installHarnessExecutor` checks it first,
19
+ through `checkHarnessEvidence`. The optional evidence is then recorded as `verified`, `unavailable` or `failed`.
20
+ Any `failed` result rejects the artifact.
21
+
22
+ - **`gpg-manifest`.** The adapter fetches `…/claude-code-releases/<v>/manifest.json`, its `.sig`, and
23
+ `https://downloads.claude.ai/keys/claude-code.asc`. The key's fingerprint must be
24
+ `31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` (`CLAUDE_SIGNING_FINGERPRINT`). The signature is a v4 RSA signature
25
+ over a binary document, using SHA-2.
26
+ - `verified`: the signature is good, and the signed manifest lists exactly the pinned version, checksum and size
27
+ for this platform.
28
+ - `failed`: the signature does not verify, the signature is malformed, or the signed manifest lists other bytes
29
+ or another version.
30
+ - `unavailable`: there is no signature (releases before 2.1.89), the bucket cannot be reached, the key is
31
+ another one or a rotated one, the format is unsupported, or the pin URL is outside upstream's bucket.
32
+ - **`macos-notarization`.** This check runs on macOS only. It runs `/usr/bin/codesign --verify --strict` (fixed
33
+ argv) on a private copy of the downloaded bytes.
34
+ - It first checks `-R=anchor apple generic and certificate leaf[subject.OU] = "Q6L2SF6YDW"`, which means signed
35
+ by Anthropic PBC. If this fails, the result is `failed`.
36
+ - It then checks `-R=notarized and …`. If this passes, the result is `verified`. If it fails, the result is
37
+ `unavailable`, because the ticket may not be confirmable offline.
38
+ - The result is also `unavailable` when codesign is missing, when the host is not macOS, or when the target is
39
+ Linux.
40
+
41
+ ## User config and credentials
42
+
43
+ - No plan ever touches `~/.claude` or `~/.claude.json`. The existing integration loads the hub plugin with
44
+ `--plugin-dir` (`packages/claude-code/src/wrapper.ts`).
45
+ - The only config hub edits is its own `config:hub/claude-code.json`, and only the key `/binary`. The edit is
46
+ lossless and pinned by a precondition. A rollback removes the file, or restores it if it existed before.
47
+ - Logins are user-mediated (`claude auth login`). `provider-auth` runs `claude auth status`, reads only its exit
48
+ code and `authMethod`, and drops the rest of the output. The child gets the user's environment minus `HUB_*` and
49
+ `BUN_BE_BUN`. `version-exec` gets only `PATH`, `HOME` and the update switches.
50
+
51
+ ## Pin template for `release/harness-pins.json` (fill in from a fresh re-download; never copy digests from here)
52
+
53
+ ```json
54
+ { "pin": { "harness": "claude-code", "version": "<exact, e.g. the stable channel's>", "target": "darwin-arm64",
55
+ "url": "https://downloads.claude.ai/claude-code-releases/<version>/darwin-arm64/claude",
56
+ "file": "claude", "size": <bytes>, "sha256": "sha256:<hex of the re-downloaded file>",
57
+ "evidence": ["gpg-manifest", "macos-notarization"], "minimumVersion": "2.1.89" },
58
+ "review": { "by": "<handle>", "on": "<date>", "checked": ["upstream-release", "digest-redownloaded", "evidence-listed"] } }
59
+ ```
60
+
61
+ - Pins for `linux-x64` and `linux-arm64` list `"evidence": ["gpg-manifest"]`. Linux binaries are not codesigned.
62
+ - `minimumVersion` 2.1.89 is the first release with a signed manifest.
63
+ - `file` must be `claude`, upstream's raw binary. The `.zst` variant is refused by `unpack`.