@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.
- package/CHANGELOG.md +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The reviewed harness pin list: the checked-in input (PIN_LIST_FILE, schema `hub.harness-pin-list` v1) that the
|
|
2
|
+
// release job turns into the signed manifest's `harnesses` (contracts §3.6.1, I5).
|
|
3
|
+
//
|
|
4
|
+
// Format:
|
|
5
|
+
// { "schema": "hub.harness-pin-list", "version": 1,
|
|
6
|
+
// "pins": [ { "pin": <HarnessPin, exactly as the manifest lists it>,
|
|
7
|
+
// "review": { "by": "<GitHub handle>", "on": "YYYY-MM-DD",
|
|
8
|
+
// "checked": ["upstream-release", "digest-redownloaded", "evidence-listed"] } } ] }
|
|
9
|
+
//
|
|
10
|
+
// Review process (see README.md next to this file):
|
|
11
|
+
// - A pin changes only through a pull request that touches this file; the reviewer is someone other than the
|
|
12
|
+
// author, and the `review` record names who checked the pin, when, and that every required check was done:
|
|
13
|
+
// upstream-release the URL is the upstream's own release asset for exactly this version and target;
|
|
14
|
+
// digest-redownloaded the reviewer downloaded the file and computed the size and sha256 themselves;
|
|
15
|
+
// evidence-listed `evidence` lists exactly the upstream evidence published for this file.
|
|
16
|
+
// - Each pin is checked with the manifest's own rules (supported target, exact version, https, plain file name,
|
|
17
|
+
// one per harness and target, evidence the harness can offer) plus: the file is the URL's last path segment,
|
|
18
|
+
// and the URL has no credentials, query or fragment. Nothing secret-shaped may appear anywhere.
|
|
19
|
+
// - The release job re-downloads every pinned file and refuses to sign on any size or digest mismatch
|
|
20
|
+
// (verifyPinDownloads), so the signature never vouches for a file nobody fetched in that release.
|
|
21
|
+
import { createHash } from "node:crypto"
|
|
22
|
+
import {
|
|
23
|
+
Checker,
|
|
24
|
+
HARNESS_ORDER,
|
|
25
|
+
fail,
|
|
26
|
+
findSecrets,
|
|
27
|
+
header,
|
|
28
|
+
isRecord,
|
|
29
|
+
list,
|
|
30
|
+
ok,
|
|
31
|
+
pointer,
|
|
32
|
+
shape,
|
|
33
|
+
text,
|
|
34
|
+
validateArtifactManifest,
|
|
35
|
+
type HarnessPin,
|
|
36
|
+
type Result,
|
|
37
|
+
} from "../contracts/index.ts"
|
|
38
|
+
|
|
39
|
+
export const PIN_LIST_SCHEMA = "hub.harness-pin-list"
|
|
40
|
+
export const PIN_LIST_VERSION = 1
|
|
41
|
+
/** The checked-in pin list, relative to the repository root. */
|
|
42
|
+
export const PIN_LIST_FILE = "packages/cli/src/bootstrap/release/harness-pins.json"
|
|
43
|
+
|
|
44
|
+
/** What a reviewer must have checked for each pin (all of them, every time). */
|
|
45
|
+
export const PIN_CHECKS = ["upstream-release", "digest-redownloaded", "evidence-listed"] as const
|
|
46
|
+
export type PinCheck = (typeof PIN_CHECKS)[number]
|
|
47
|
+
|
|
48
|
+
export interface PinReview {
|
|
49
|
+
/** The reviewer's GitHub handle. */
|
|
50
|
+
readonly by: string
|
|
51
|
+
/** The review date, YYYY-MM-DD. */
|
|
52
|
+
readonly on: string
|
|
53
|
+
readonly checked: readonly PinCheck[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ReviewedPin {
|
|
57
|
+
readonly pin: HarnessPin
|
|
58
|
+
readonly review: PinReview
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PinList {
|
|
62
|
+
readonly schema: typeof PIN_LIST_SCHEMA
|
|
63
|
+
readonly version: typeof PIN_LIST_VERSION
|
|
64
|
+
readonly pins: readonly ReviewedPin[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const HANDLE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/
|
|
68
|
+
const DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
|
|
69
|
+
const MAX_PINS = 64
|
|
70
|
+
|
|
71
|
+
/** A stand-in hubctl artifact, so the pins can be checked by the manifest validator itself. */
|
|
72
|
+
const SKELETON = {
|
|
73
|
+
schema: "hub.artifact-manifest",
|
|
74
|
+
version: 1,
|
|
75
|
+
product: "hubctl",
|
|
76
|
+
release: "0.0.0",
|
|
77
|
+
channel: "stable",
|
|
78
|
+
minimumVersion: "0.0.0",
|
|
79
|
+
issuedAt: 0,
|
|
80
|
+
artifacts: [{ target: "linux-x64", file: "hubctl-linux-x64", size: 1, sha256: `sha256:${"0".repeat(64)}` }],
|
|
81
|
+
signatures: [],
|
|
82
|
+
} as const
|
|
83
|
+
|
|
84
|
+
/** "/harnesses/3/url" → "/pins/3/pin/url"; "/harnesses/3" → "/pins/3". */
|
|
85
|
+
function pinPath(path: string): string | undefined {
|
|
86
|
+
const m = /^\/harnesses\/(\d+)(\/.*)?$/.exec(path)
|
|
87
|
+
if (!m) return undefined
|
|
88
|
+
return m[2] ? `/pins/${m[1]}/pin${m[2]}` : `/pins/${m[1]}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkReview(c: Checker, p: string, v: unknown) {
|
|
92
|
+
const r = shape(c, p, v, ["by", "on", "checked"])
|
|
93
|
+
if (!r) return
|
|
94
|
+
text(c, pointer(p, "by"), r.by, 39, HANDLE)
|
|
95
|
+
text(c, pointer(p, "on"), r.on, 10, DATE)
|
|
96
|
+
if (!list(c, pointer(p, "checked"), r.checked, PIN_CHECKS.length, (q, item) => {
|
|
97
|
+
if (typeof item !== "string" || !(PIN_CHECKS as readonly string[]).includes(item)) c.add(q, "invalid_value", `expected one of ${PIN_CHECKS.join(", ")}`)
|
|
98
|
+
}, (item) => (typeof item === "string" ? item : undefined))) return
|
|
99
|
+
for (const check of PIN_CHECKS) if (!(r.checked as unknown[]).includes(check)) c.add(pointer(p, "checked"), "missing_field", `the review did not record ${check}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkSource(c: Checker, p: string, pin: unknown) {
|
|
103
|
+
if (!isRecord(pin) || typeof pin.url !== "string") return
|
|
104
|
+
let url: URL
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(pin.url)
|
|
107
|
+
} catch {
|
|
108
|
+
return // the manifest rules report it
|
|
109
|
+
}
|
|
110
|
+
if (url.username || url.password || url.search || url.hash) c.add(pointer(p, "url"), "invalid_value", "a pinned URL carries no credentials, query or fragment")
|
|
111
|
+
const last = decodeURIComponent(url.pathname.split("/").pop() ?? "")
|
|
112
|
+
if (typeof pin.file === "string" && last !== pin.file) c.add(pointer(p, "file"), "inconsistent", `the file must be the URL's last path segment (${last})`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function validatePinList(value: unknown): Result<PinList> {
|
|
116
|
+
const c = new Checker()
|
|
117
|
+
const r = shape(c, "", value, ["schema", "version", "pins"])
|
|
118
|
+
if (!r) return c.result(value)
|
|
119
|
+
header(c, "", r, PIN_LIST_SCHEMA, PIN_LIST_VERSION)
|
|
120
|
+
const pins: unknown[] = []
|
|
121
|
+
const listed = list(c, "/pins", r.pins, MAX_PINS, (p, entry) => {
|
|
122
|
+
const e = shape(c, p, entry, ["pin", "review"])
|
|
123
|
+
pins.push(e?.pin)
|
|
124
|
+
if (!e) return
|
|
125
|
+
if (e.review !== undefined) checkReview(c, pointer(p, "review"), e.review)
|
|
126
|
+
checkSource(c, pointer(p, "pin"), e.pin)
|
|
127
|
+
})
|
|
128
|
+
if (listed) {
|
|
129
|
+
const m = validateArtifactManifest({ ...SKELETON, harnesses: pins.map((p) => p ?? {}) })
|
|
130
|
+
if (!m.ok) for (const issue of m.issues) {
|
|
131
|
+
const path = pinPath(issue.path)
|
|
132
|
+
if (path !== undefined) c.add(path, issue.code, issue.message)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const path of findSecrets(value)) c.add(path, "secret", "a pin list never carries a secret")
|
|
136
|
+
return c.result(value)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const targetOrder = (a: HarnessPin, b: HarnessPin) =>
|
|
140
|
+
HARNESS_ORDER.indexOf(a.harness) - HARNESS_ORDER.indexOf(b.harness) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0)
|
|
141
|
+
|
|
142
|
+
/** The manifest's `harnesses`: the pins in HARNESS_ORDER, then by target (deterministic). */
|
|
143
|
+
export function harnessPins(list: PinList): HarnessPin[] {
|
|
144
|
+
return list.pins.map((p) => p.pin).sort(targetOrder)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type Downloader = (url: string) => Promise<{ readonly size: number; readonly sha256: string }>
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Re-downloads every pinned file and compares its size and digest with the pin. Any mismatch or failed download is
|
|
151
|
+
* integrity_mismatch; every pin is checked and reported. Returns the manifest's `harnesses` when all match.
|
|
152
|
+
*/
|
|
153
|
+
export async function verifyPinDownloads(value: unknown, download: Downloader): Promise<Result<HarnessPin[]>> {
|
|
154
|
+
const valid = validatePinList(value)
|
|
155
|
+
if (!valid.ok) return valid
|
|
156
|
+
const c = new Checker()
|
|
157
|
+
for (const [i, { pin }] of valid.value.pins.entries()) {
|
|
158
|
+
const at = `/pins/${i}/pin`
|
|
159
|
+
try {
|
|
160
|
+
const got = await download(pin.url)
|
|
161
|
+
if (got.size !== pin.size) c.add(`${at}/size`, "integrity_mismatch", `${pin.harness} ${pin.version} ${pin.target}: expected ${pin.size} bytes, downloaded ${got.size}`)
|
|
162
|
+
else if (got.sha256 !== pin.sha256) c.add(`${at}/sha256`, "integrity_mismatch", `${pin.harness} ${pin.version} ${pin.target}: the download does not match the pinned digest`)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
c.add(`${at}/url`, "integrity_mismatch", `${pin.harness} ${pin.version} ${pin.target}: could not download (${error instanceof Error ? error.message : String(error)})`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (c.issues.length > 0) return { ok: false, issues: c.issues }
|
|
168
|
+
return ok(harnessPins(valid.value))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Streams a URL and returns its size and digest (redirects followed); throws on an HTTP error. */
|
|
172
|
+
export async function httpDigest(url: string, fetcher: typeof fetch = fetch): Promise<{ size: number; sha256: string }> {
|
|
173
|
+
const response = await fetcher(url, { redirect: "follow" })
|
|
174
|
+
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`)
|
|
175
|
+
const hash = createHash("sha256")
|
|
176
|
+
let size = 0
|
|
177
|
+
const reader = response.body.getReader()
|
|
178
|
+
for (let next = await reader.read(); !next.done; next = await reader.read()) {
|
|
179
|
+
hash.update(next.value)
|
|
180
|
+
size += next.value.byteLength
|
|
181
|
+
}
|
|
182
|
+
return { size, sha256: `sha256:${hash.digest("hex")}` }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Reads a pin list document (JSON text). */
|
|
186
|
+
export function parsePinList(text: string): Result<PinList> {
|
|
187
|
+
try {
|
|
188
|
+
return validatePinList(JSON.parse(text))
|
|
189
|
+
} catch {
|
|
190
|
+
return fail("", "invalid_value", "the pin list is not JSON")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The trust root a standalone hubctl ships (contracts §3.6, I6): public keys only, embedded at build time with
|
|
2
|
+
// `bun build --compile --define HUBCTL_TRUST_ROOT=<JSON string>` (trustRootDefine). It is never read from the
|
|
3
|
+
// environment or the disk at run time, so nothing on the machine can swap it; replacing it goes through
|
|
4
|
+
// rotateTrustRoot. A build without it (a source run, the npm bundle, a dev build) has no trust root and refuses
|
|
5
|
+
// every manifest, rather than trusting one by default.
|
|
6
|
+
import { canonicalJSON, fail, findSecrets, validateTrustRoot, type Result, type TrustRoot } from "../contracts/index.ts"
|
|
7
|
+
|
|
8
|
+
/** The identifier `bun build --define` replaces with the trust root's canonical JSON (a string literal). */
|
|
9
|
+
export const TRUST_ROOT_DEFINE = "HUBCTL_TRUST_ROOT"
|
|
10
|
+
|
|
11
|
+
declare const HUBCTL_TRUST_ROOT: string | undefined
|
|
12
|
+
|
|
13
|
+
/** The embedded trust root text, or undefined when this build has none. */
|
|
14
|
+
export function embeddedTrustRootText(): string | undefined {
|
|
15
|
+
return typeof HUBCTL_TRUST_ROOT === "string" ? HUBCTL_TRUST_ROOT : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The shipped trust root, validated; signature_invalid when the build has none or it is unusable. */
|
|
19
|
+
export function shippedTrustRoot(text: string | undefined = embeddedTrustRootText()): Result<TrustRoot> {
|
|
20
|
+
if (text === undefined) return fail("", "signature_invalid", "this hubctl build has no trust root, so it cannot verify releases (use a signed release build)")
|
|
21
|
+
let value: unknown
|
|
22
|
+
try {
|
|
23
|
+
value = JSON.parse(text)
|
|
24
|
+
} catch {
|
|
25
|
+
return fail("", "signature_invalid", "the embedded trust root is not JSON")
|
|
26
|
+
}
|
|
27
|
+
const r = validateTrustRoot(value)
|
|
28
|
+
if (!r.ok) return fail("", "signature_invalid", `the embedded trust root is unusable: ${r.issues.map((i) => `${i.path} ${i.message}`).join("; ")}`)
|
|
29
|
+
if (findSecrets(value).length > 0) return fail("", "signature_invalid", "the embedded trust root carries key material that is not a public key")
|
|
30
|
+
return r
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `bun build` words that embed `root`; throws on anything but a valid, public-keys-only trust root. */
|
|
34
|
+
export function trustRootDefine(root: TrustRoot): string[] {
|
|
35
|
+
const text = canonicalJSON(root)
|
|
36
|
+
const r = shippedTrustRoot(text)
|
|
37
|
+
if (!r.ok) throw new Error(r.issues.map((i) => i.message).join("; "))
|
|
38
|
+
return ["--define", `${TRUST_ROOT_DEFINE}=${JSON.stringify(text)}`]
|
|
39
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The hubctl release verification library: everything an installer checks about a downloaded hubctl before it
|
|
2
|
+
// touches the disk, in the order that keeps untrusted data inert:
|
|
3
|
+
// 1. the manifest is well formed and signed by `threshold` active keys of the shipped trust root (T1, T2);
|
|
4
|
+
// 2. it lists an artifact for exactly this host's target, and the host is a supported one (T3);
|
|
5
|
+
// 3. the downloaded bytes have exactly that size and digest (T1);
|
|
6
|
+
// 4. the install is not a downgrade below the security floor, nor an unconsented downgrade (T4).
|
|
7
|
+
// Only the contracts are used (node:crypto, no dependencies), so the release signing job can run it without any
|
|
8
|
+
// npm install.
|
|
9
|
+
import { createHash } from "node:crypto"
|
|
10
|
+
import {
|
|
11
|
+
checkArtifactBytes,
|
|
12
|
+
compareVersions,
|
|
13
|
+
decideInstall,
|
|
14
|
+
fail,
|
|
15
|
+
ok,
|
|
16
|
+
selectArtifact,
|
|
17
|
+
verifyManifest,
|
|
18
|
+
type ArtifactEntry,
|
|
19
|
+
type InstallKind,
|
|
20
|
+
type Result,
|
|
21
|
+
type SignatureVerifier,
|
|
22
|
+
type TrustRoot,
|
|
23
|
+
type VerifiedManifest,
|
|
24
|
+
} from "../contracts/index.ts"
|
|
25
|
+
|
|
26
|
+
export interface ReleaseCheckInput {
|
|
27
|
+
/** The parsed manifest document (untrusted). */
|
|
28
|
+
readonly manifest: unknown
|
|
29
|
+
/** The trust root this hubctl ships (or a rotated successor accepted through rotateTrustRoot). */
|
|
30
|
+
readonly root: TrustRoot
|
|
31
|
+
/** This host's target (targetOf); never a "close enough" one. */
|
|
32
|
+
readonly target: string
|
|
33
|
+
/** Size and digest of the downloaded hubctl (digestBytes). */
|
|
34
|
+
readonly bytes: { readonly sha256: string; readonly size: number }
|
|
35
|
+
/** The hubctl installed now, if any. */
|
|
36
|
+
readonly installed?: { readonly version: string; readonly sha256: string }
|
|
37
|
+
/** The highest security floor this machine has seen in a verified manifest. */
|
|
38
|
+
readonly floor?: string
|
|
39
|
+
/** Explicit --allow-downgrade (never below the floor). */
|
|
40
|
+
readonly allowDowngrade?: boolean
|
|
41
|
+
readonly verifier?: SignatureVerifier
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AcceptedRelease {
|
|
45
|
+
readonly manifest: VerifiedManifest
|
|
46
|
+
readonly artifact: ArtifactEntry
|
|
47
|
+
readonly kind: InstallKind
|
|
48
|
+
/** The security floor to persist after the install: max(seen floor, manifest floor). */
|
|
49
|
+
readonly floor: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Largest manifest accepted (a real one is a few kilobytes). */
|
|
53
|
+
export const MAX_MANIFEST_BYTES = 1_048_576
|
|
54
|
+
|
|
55
|
+
const maxVersion = (a: string, b?: string) => (b !== undefined && compareVersions(b, a) > 0 ? b : a)
|
|
56
|
+
|
|
57
|
+
export function verifyHubctlRelease(input: ReleaseCheckInput): Result<AcceptedRelease> {
|
|
58
|
+
const verified = verifyManifest(input.manifest, input.root, input.verifier)
|
|
59
|
+
if (!verified.ok) return verified
|
|
60
|
+
const manifest = verified.value
|
|
61
|
+
const artifact = selectArtifact(manifest, input.target)
|
|
62
|
+
if (!artifact.ok) return artifact
|
|
63
|
+
const bytes = checkArtifactBytes(artifact.value, input.bytes)
|
|
64
|
+
if (!bytes.ok) return bytes
|
|
65
|
+
const kind = decideInstall({
|
|
66
|
+
candidate: { release: manifest.release, minimumVersion: manifest.minimumVersion, sha256: input.bytes.sha256 },
|
|
67
|
+
...(input.installed ? { installed: input.installed } : {}),
|
|
68
|
+
...(input.floor !== undefined ? { floor: input.floor } : {}),
|
|
69
|
+
...(input.allowDowngrade !== undefined ? { allowDowngrade: input.allowDowngrade } : {}),
|
|
70
|
+
})
|
|
71
|
+
if (!kind.ok) return kind
|
|
72
|
+
return ok({ manifest, artifact: artifact.value, kind: kind.value, floor: maxVersion(manifest.minimumVersion, input.floor) })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Size and "sha256:<hex>" of some bytes, the form manifests list. */
|
|
76
|
+
export function digestBytes(bytes: Uint8Array): { size: number; sha256: string } {
|
|
77
|
+
return { size: bytes.byteLength, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** JSON text of a downloaded manifest, bounded; the document still needs verifyHubctlRelease. */
|
|
81
|
+
export function parseManifest(text: string): Result<unknown> {
|
|
82
|
+
if (text.length > MAX_MANIFEST_BYTES) return fail("", "invalid_value", `a manifest is at most ${MAX_MANIFEST_BYTES} bytes`)
|
|
83
|
+
try {
|
|
84
|
+
return ok(JSON.parse(text) as unknown)
|
|
85
|
+
} catch {
|
|
86
|
+
return fail("", "invalid_value", "the manifest is not JSON")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// The built-in executors: the local file, config and directory steps, plus the two artifact installs whose bytes
|
|
2
|
+
// must be authenticated before use. Each only computes what the target should become; the engine stages, journals
|
|
3
|
+
// and replaces it atomically.
|
|
4
|
+
//
|
|
5
|
+
// ensure-directory a directory (an existing directory is left as it is)
|
|
6
|
+
// write-file an embedded/rendered asset whose bytes must match the plan's digest; never overwrites
|
|
7
|
+
// merge-config a lossless JSON merge, only if the file is still as previewed (precondition digest)
|
|
8
|
+
// link-executable the hub ↔ hubctl alias symlink (an existing entry is backed up and replaced)
|
|
9
|
+
// select-harness-version v1.1: the harness's `current` link, switched atomically to an installed version
|
|
10
|
+
// directory (relative, so it reads the same inside and outside a container); the previous
|
|
11
|
+
// link is backed up, so a rollback makes the previous version current again
|
|
12
|
+
// install-service the rendered unit, whose bytes must match the plan's digest
|
|
13
|
+
// deactivate-service v1.1: unloads a hub service through the injected ServiceControl (an external effect; its
|
|
14
|
+
// undo loads it again)
|
|
15
|
+
// remove-service v1.1: removes the unit, only while it is still exactly the one hub installed (backed up, so
|
|
16
|
+
// a rollback restores it)
|
|
17
|
+
// install-artifact hubctl from a verified manifest: exact target, pinned digest, downgrade policy, bytes checked
|
|
18
|
+
// (hubctl's own executable is digested and copied in chunks, never read whole)
|
|
19
|
+
// install-harness a harness pinned by the verified manifest: checkHarnessPin, downgrade policy, the pinned
|
|
20
|
+
// digest of the download first, then the upstream evidence, then the adapter's unpacker
|
|
21
|
+
//
|
|
22
|
+
// Fetching and unpacking come from injected, typed sources (no shell, no generic exec); verification always
|
|
23
|
+
// happens here, so a source cannot skip it. Harness downloads stream into the step's staging area, which digests
|
|
24
|
+
// them as it writes: the pin is checked against that digest, never one the source reports, before anything is
|
|
25
|
+
// unpacked; the unpacker streams the archive's members into the staging area too (bounded memory, stream.ts).
|
|
26
|
+
import {
|
|
27
|
+
checkArtifactBytes,
|
|
28
|
+
checkHarnessEvidence,
|
|
29
|
+
checkHarnessPin,
|
|
30
|
+
decideHarnessInstall,
|
|
31
|
+
decideInstall,
|
|
32
|
+
isSafeRel,
|
|
33
|
+
selectArtifact,
|
|
34
|
+
type ArtifactEntry,
|
|
35
|
+
type EvidenceResult,
|
|
36
|
+
type HarnessID,
|
|
37
|
+
type InstallHarness,
|
|
38
|
+
type InstallService,
|
|
39
|
+
type ServiceID,
|
|
40
|
+
type ServiceManagerID,
|
|
41
|
+
type VerifiedManifest,
|
|
42
|
+
} from "../contracts/index.ts"
|
|
43
|
+
import type { ActionExecutor, ExecutorMap, StepContext, TreeFile } from "./executor.ts"
|
|
44
|
+
import { mergeConfigText } from "./json-merge.ts"
|
|
45
|
+
import { fromResult, txFail, txOk, type TxResult } from "./result.ts"
|
|
46
|
+
import { sha256Of } from "./state.ts"
|
|
47
|
+
import { fileDigest, type StagedFile, type StagingArea } from "./stream.ts"
|
|
48
|
+
|
|
49
|
+
export interface HubctlSource {
|
|
50
|
+
/** The release manifest, already verified against the shipped trust root. */
|
|
51
|
+
readonly manifest: VerifiedManifest
|
|
52
|
+
/**
|
|
53
|
+
* The release file for `entry`: its bytes, or the path of a local copy (hubctl's own standalone executable), which
|
|
54
|
+
* the executor digests and the engine copies in chunks. Either way the executor checks it against the pin.
|
|
55
|
+
*/
|
|
56
|
+
fetch(entry: ArtifactEntry): Promise<Uint8Array | { readonly path: string }>
|
|
57
|
+
/** The hubctl currently installed, if any (downgrade policy). */
|
|
58
|
+
readonly installed?: { readonly version: string; readonly sha256: string }
|
|
59
|
+
/** The highest security floor this machine has seen. */
|
|
60
|
+
readonly floor?: string
|
|
61
|
+
/** Explicit --allow-downgrade. */
|
|
62
|
+
readonly allowDowngrade?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface HarnessSource {
|
|
66
|
+
readonly manifest: VerifiedManifest
|
|
67
|
+
/**
|
|
68
|
+
* Downloads the pinned file into `staging` (streamed, staging.write) and runs the upstream evidence checks the
|
|
69
|
+
* action lists (adapter-specific).
|
|
70
|
+
*/
|
|
71
|
+
fetch(action: InstallHarness, staging: StagingArea): Promise<{ readonly file: StagedFile; readonly evidence: readonly EvidenceResult[] }>
|
|
72
|
+
/**
|
|
73
|
+
* Unpacks the verified download into the install tree (adapter-specific): large members are streamed into
|
|
74
|
+
* `staging` and named as staged files; nothing else is written.
|
|
75
|
+
*/
|
|
76
|
+
unpack(action: InstallHarness, file: StagedFile, staging: StagingArea): Promise<TxResult<readonly TreeFile[]>>
|
|
77
|
+
installed?(harness: HarnessID): { readonly version: string; readonly sha256: string } | undefined
|
|
78
|
+
floor?(harness: HarnessID): string | undefined
|
|
79
|
+
readonly allowDowngrade?: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* v1.1: the typed client for the user's service manager (launchd / systemd --user), supplied by the integrator
|
|
84
|
+
* (HBS-13). It names a service and manager, never a command; how it reaches the manager is its own business.
|
|
85
|
+
*/
|
|
86
|
+
export interface ServiceControl {
|
|
87
|
+
deactivate(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<void>>
|
|
88
|
+
/** Loads a service again (the undo of a deactivation). */
|
|
89
|
+
activate(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<void>>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface BuiltinDeps {
|
|
93
|
+
/** Embedded or rendered assets by id (write-file). */
|
|
94
|
+
readonly assets?: (asset: string) => Uint8Array | undefined
|
|
95
|
+
/** The rendered unit for an install-service action (HBS-13 renders EMBEDDED_ASSETS with serviceArgv). */
|
|
96
|
+
readonly renderService?: (action: InstallService) => Uint8Array | undefined
|
|
97
|
+
readonly hubctl?: HubctlSource
|
|
98
|
+
readonly harness?: HarnessSource
|
|
99
|
+
/** v1.1: deactivate-service (and its undo). Without it, deactivation is refused at preflight. */
|
|
100
|
+
readonly services?: ServiceControl
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ok = txOk(undefined)
|
|
104
|
+
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
|
|
105
|
+
|
|
106
|
+
export const ensureDirectoryExecutor = (): ActionExecutor<"ensure-directory"> => ({
|
|
107
|
+
kind: "ensure-directory",
|
|
108
|
+
plan: (action) => ({ class: "directory", path: action.path }),
|
|
109
|
+
change: async (action) => txOk({ type: "directory", mode: action.mode }),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/** Bytes whose digest must equal the plan's pin. */
|
|
113
|
+
function pinned(bytes: Uint8Array | undefined, sha256: string, what: string): TxResult<Uint8Array> {
|
|
114
|
+
if (!bytes) return txFail("not_found", `${what} is not available in this hubctl`)
|
|
115
|
+
if (sha256Of(bytes) !== sha256) return txFail("integrity_mismatch", `${what} does not match the digest in the plan`)
|
|
116
|
+
return txOk(bytes)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const writeFileExecutor = (assets: BuiltinDeps["assets"] = () => undefined): ActionExecutor<"write-file"> => ({
|
|
120
|
+
kind: "write-file",
|
|
121
|
+
plan: (action) => ({ class: "file", path: action.path }),
|
|
122
|
+
check: async (action) => {
|
|
123
|
+
const r = pinned(assets(action.asset), action.sha256, `asset ${action.asset}`)
|
|
124
|
+
return r.ok ? ok : r
|
|
125
|
+
},
|
|
126
|
+
change: async (action) => {
|
|
127
|
+
const r = pinned(assets(action.asset), action.sha256, `asset ${action.asset}`)
|
|
128
|
+
return r.ok ? txOk({ type: "file", bytes: r.value, mode: action.mode, replace: "refuse" }) : r
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
function preconditionHolds(ctx: StepContext, precondition: { readonly exists: boolean; readonly sha256?: string }): boolean {
|
|
133
|
+
const o = ctx.original
|
|
134
|
+
if (o.symlink) return false
|
|
135
|
+
return precondition.exists ? o.exists && o.sha256 === precondition.sha256 : !o.exists
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const mergeConfigExecutor = (): ActionExecutor<"merge-config"> => ({
|
|
139
|
+
kind: "merge-config",
|
|
140
|
+
plan: (action) => ({ class: "config", path: action.path }),
|
|
141
|
+
check: async (action, ctx) =>
|
|
142
|
+
preconditionHolds(ctx, action.precondition) ? ok : txFail("conflict", `${ctx.target} changed since the preview; nothing was overwritten (preview again)`),
|
|
143
|
+
change: async (action, ctx) => {
|
|
144
|
+
const text = ctx.current.exists ? decode(ctx.fs.readFile(ctx.target!)) : undefined
|
|
145
|
+
const merged = mergeConfigText(text, action.format, action.set)
|
|
146
|
+
if (!merged.ok) return merged
|
|
147
|
+
if (merged.value === text) return txOk({ type: "none", reason: "already merged" })
|
|
148
|
+
return txOk({ type: "file", bytes: new TextEncoder().encode(merged.value), mode: ctx.current.mode ?? 0o600, replace: "backup" })
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
export const linkExecutableExecutor = (): ActionExecutor<"link-executable"> => ({
|
|
153
|
+
kind: "link-executable",
|
|
154
|
+
plan: (action) => ({ class: "link", path: action.path, resolve: { finalMayBeSymlink: true } }),
|
|
155
|
+
change: async (action, ctx) => {
|
|
156
|
+
// The link's own path is symlink-checked (it is what gets written). Its target is only the link's text, never
|
|
157
|
+
// written through, and may lead through a harness's `current` link, which is a symlink by design (§3.6.1).
|
|
158
|
+
const target = ctx.resolve(action.target, { targetOnly: true })
|
|
159
|
+
return target.ok ? txOk({ type: "symlink", target: target.value, replace: "backup" }) : target
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
export const selectHarnessVersionExecutor = (): ActionExecutor<"select-harness-version"> => ({
|
|
164
|
+
kind: "select-harness-version",
|
|
165
|
+
plan: (action) => ({ class: "link", path: action.path, resolve: { finalMayBeSymlink: true } }),
|
|
166
|
+
change: async (action, ctx) => {
|
|
167
|
+
const dir = ctx.resolve(action.target)
|
|
168
|
+
if (!dir.ok) return dir
|
|
169
|
+
if (ctx.fs.lstat(dir.value)?.kind !== "directory") return txFail("not_found", `${action.harness} ${action.version} is not installed (${dir.value} is not a directory)`)
|
|
170
|
+
// `current` and the version directory are siblings (contract harnessCurrentRef / harnessVersionRef).
|
|
171
|
+
return txOk({ type: "symlink", target: action.version, replace: "backup" })
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
export const installServiceExecutor = (render: BuiltinDeps["renderService"] = () => undefined): ActionExecutor<"install-service"> => ({
|
|
176
|
+
kind: "install-service",
|
|
177
|
+
plan: (action) => ({ class: "service", path: action.path }),
|
|
178
|
+
check: async (action) => {
|
|
179
|
+
const r = pinned(render(action), action.sha256, `the ${action.service} ${action.manager} unit`)
|
|
180
|
+
return r.ok ? ok : r
|
|
181
|
+
},
|
|
182
|
+
change: async (action) => {
|
|
183
|
+
const r = pinned(render(action), action.sha256, `the ${action.service} ${action.manager} unit`)
|
|
184
|
+
return r.ok ? txOk({ type: "file", bytes: r.value, mode: 0o644, replace: "backup" }) : r
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
export const deactivateServiceExecutor = (services?: ServiceControl): ActionExecutor<"deactivate-service"> => ({
|
|
189
|
+
kind: "deactivate-service",
|
|
190
|
+
plan: () => ({ class: "external" }),
|
|
191
|
+
check: async () => (services ? ok : missing("service manager")),
|
|
192
|
+
change: async (action) => (services ? txOk({ type: "effect", perform: () => services.deactivate(action.service, action.manager) }) : missing("service manager")),
|
|
193
|
+
undo: async (action) => (services ? services.activate(action.service, action.manager) : missing("service manager")),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
export const removeServiceExecutor = (): ActionExecutor<"remove-service"> => ({
|
|
197
|
+
kind: "remove-service",
|
|
198
|
+
plan: (action) => ({ class: "service", path: action.path }),
|
|
199
|
+
check: async (action, ctx) =>
|
|
200
|
+
!ctx.original.exists || (ctx.original.sha256 === action.sha256 && !ctx.original.symlink)
|
|
201
|
+
? ok
|
|
202
|
+
: txFail("conflict", `${ctx.target} is not the ${action.service} unit hub installed (it changed since); nothing was removed`),
|
|
203
|
+
change: async (action, ctx) => (ctx.current.exists ? txOk({ type: "remove" }) : txOk({ type: "none", reason: `the ${action.service} unit is already gone` })),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const missing = (what: string) => txFail("internal", `this hubctl was built without a ${what} source`)
|
|
207
|
+
|
|
208
|
+
export const installArtifactExecutor = (source?: HubctlSource): ActionExecutor<"install-artifact"> => {
|
|
209
|
+
const entryFor = (action: { readonly target: string; readonly version: string; readonly sha256: string }, host: string): TxResult<ArtifactEntry> => {
|
|
210
|
+
if (!source) return missing("hubctl release")
|
|
211
|
+
if (action.target !== host) return txFail("wrong_platform", `the plan installs hubctl for ${action.target}, but this host is ${host}`)
|
|
212
|
+
const entry = fromResult(selectArtifact(source.manifest, action.target))
|
|
213
|
+
if (!entry.ok) return entry
|
|
214
|
+
if (source.manifest.release !== action.version || entry.value.sha256 !== action.sha256)
|
|
215
|
+
return txFail("integrity_mismatch", `the plan's hubctl ${action.version} is not the artifact the verified ${source.manifest.release} manifest pins`)
|
|
216
|
+
const decision = fromResult(
|
|
217
|
+
decideInstall({
|
|
218
|
+
candidate: { release: source.manifest.release, minimumVersion: source.manifest.minimumVersion, sha256: entry.value.sha256 },
|
|
219
|
+
installed: source.installed,
|
|
220
|
+
floor: source.floor,
|
|
221
|
+
allowDowngrade: source.allowDowngrade,
|
|
222
|
+
}),
|
|
223
|
+
)
|
|
224
|
+
return decision.ok ? entry : decision
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
kind: "install-artifact",
|
|
228
|
+
plan: (action) => ({ class: "artifact", path: action.path }),
|
|
229
|
+
check: async (action, ctx) => {
|
|
230
|
+
const entry = entryFor(action, ctx.host.target)
|
|
231
|
+
return entry.ok ? ok : entry
|
|
232
|
+
},
|
|
233
|
+
change: async (action, ctx) => {
|
|
234
|
+
const entry = entryFor(action, ctx.host.target)
|
|
235
|
+
if (!entry.ok) return entry
|
|
236
|
+
const fetched = await source!.fetch(entry.value)
|
|
237
|
+
if (fetched instanceof Uint8Array) {
|
|
238
|
+
const checked = fromResult(checkArtifactBytes(entry.value, { sha256: sha256Of(fetched), size: fetched.byteLength }))
|
|
239
|
+
if (!checked.ok) return checked
|
|
240
|
+
return txOk({ type: "file", bytes: fetched, mode: 0o755, replace: "backup" })
|
|
241
|
+
}
|
|
242
|
+
const info = ctx.fs.lstat(fetched.path)
|
|
243
|
+
if (info?.kind !== "file") return txFail("not_found", `${fetched.path} is not a regular file`)
|
|
244
|
+
const staged = { path: fetched.path, sha256: fileDigest(ctx.fs, fetched.path), size: info.size }
|
|
245
|
+
const checked = fromResult(checkArtifactBytes(entry.value, { sha256: staged.sha256, size: staged.size }))
|
|
246
|
+
if (!checked.ok) return checked
|
|
247
|
+
// The engine copies it in chunks and checks this digest again while it copies.
|
|
248
|
+
return txOk({ type: "file", staged, mode: 0o755, replace: "backup" })
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const installHarnessExecutor = (source?: HarnessSource): ActionExecutor<"install-harness"> => {
|
|
254
|
+
const pinFor = (action: InstallHarness, host: string) => {
|
|
255
|
+
if (!source) return missing("harness")
|
|
256
|
+
if (action.target !== host) return txFail("wrong_platform", `the plan installs ${action.harness} for ${action.target}, but this host is ${host}`)
|
|
257
|
+
const pin = fromResult(checkHarnessPin(source.manifest, action))
|
|
258
|
+
if (!pin.ok) return pin
|
|
259
|
+
const decision = fromResult(
|
|
260
|
+
decideHarnessInstall(pin.value, { installed: source.installed?.(action.harness) ?? action.previous, floor: source.floor?.(action.harness), allowDowngrade: source.allowDowngrade }),
|
|
261
|
+
)
|
|
262
|
+
return decision.ok ? pin : decision
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
kind: "install-harness",
|
|
266
|
+
plan: (action) => ({ class: "harness", path: action.path }),
|
|
267
|
+
check: async (action, ctx) => {
|
|
268
|
+
const pin = pinFor(action, ctx.host.target)
|
|
269
|
+
return pin.ok ? ok : pin
|
|
270
|
+
},
|
|
271
|
+
change: async (action, ctx) => {
|
|
272
|
+
const pin = pinFor(action, ctx.host.target)
|
|
273
|
+
if (!pin.ok) return pin
|
|
274
|
+
const download = await source!.fetch(action, ctx.staging)
|
|
275
|
+
// The digest the staging area recorded while it wrote the download (never one the source reports).
|
|
276
|
+
const written = ctx.staging.written(download.file)
|
|
277
|
+
if (!written) return txFail("internal", `the ${action.harness} source did not download into the transaction's staging area`)
|
|
278
|
+
// The pinned digest first (mandatory), then the upstream evidence; only verified bytes reach the unpacker.
|
|
279
|
+
const evidence = fromResult(checkHarnessEvidence(action, written, download.evidence))
|
|
280
|
+
if (!evidence.ok) return evidence
|
|
281
|
+
ctx.recordEvidence(evidence.value)
|
|
282
|
+
const files = await source!.unpack(action, { path: download.file.path, ...written }, ctx.staging)
|
|
283
|
+
if (!files.ok) return files
|
|
284
|
+
const seen = new Set<string>()
|
|
285
|
+
for (const f of files.value) {
|
|
286
|
+
if (!isSafeRel(f.rel) || seen.has(f.rel) || !Number.isInteger(f.mode) || f.mode < 0 || f.mode > 0o777) return txFail("invalid", `the ${action.harness} archive has an unsafe entry ${JSON.stringify(f.rel)}`)
|
|
287
|
+
// Unpacked members are files the staging area wrote, with the digest it recorded.
|
|
288
|
+
if ("staged" in f && ctx.staging.written(f.staged)?.sha256 !== f.staged.sha256) return txFail("invalid", `the ${action.harness} archive's ${JSON.stringify(f.rel)} was not unpacked into the staging area`)
|
|
289
|
+
seen.add(f.rel)
|
|
290
|
+
}
|
|
291
|
+
for (const rel of seen) if ([...seen].some((other) => other.startsWith(`${rel}/`))) return txFail("invalid", `the ${action.harness} archive uses ${rel} as both a file and a directory`)
|
|
292
|
+
if (seen.size === 0) return txFail("invalid", `the ${action.harness} archive is empty`)
|
|
293
|
+
return txOk({ type: "tree", files: files.value, mode: 0o755 })
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The built-in executors for the local kinds; adapters add the rest (see executor.ts). */
|
|
299
|
+
export function builtinExecutors(deps: BuiltinDeps): ExecutorMap {
|
|
300
|
+
return {
|
|
301
|
+
"ensure-directory": ensureDirectoryExecutor(),
|
|
302
|
+
"write-file": writeFileExecutor(deps.assets),
|
|
303
|
+
"merge-config": mergeConfigExecutor(),
|
|
304
|
+
"link-executable": linkExecutableExecutor(),
|
|
305
|
+
"select-harness-version": selectHarnessVersionExecutor(),
|
|
306
|
+
"install-service": installServiceExecutor(deps.renderService),
|
|
307
|
+
"deactivate-service": deactivateServiceExecutor(deps.services),
|
|
308
|
+
"remove-service": removeServiceExecutor(),
|
|
309
|
+
"install-artifact": installArtifactExecutor(deps.hubctl),
|
|
310
|
+
"install-harness": installHarnessExecutor(deps.harness),
|
|
311
|
+
}
|
|
312
|
+
}
|