@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.
- package/CHANGELOG.md +70 -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/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 +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -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/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Upgrade and downgrade planning, under the contract's downgrade policy.
|
|
2
|
+
//
|
|
3
|
+
// Profiles. planProfileChange compares two profiles as sets of capabilities (harnesses, runtimes, services,
|
|
4
|
+
// launching, escalation). Adding is an upgrade. Removing anything is a downgrade and needs explicit consent
|
|
5
|
+
// (downgrade_rejected otherwise, like --allow-downgrade for artifacts). v1.1: given the host's service manager and
|
|
6
|
+
// the units hub installed (installedServiceUnits), a removed service is planned as typed deactivate-service and
|
|
7
|
+
// remove-service actions (the unit only while it is exactly what hub installed). Everything else removed (harnesses,
|
|
8
|
+
// runtimes, launching, escalation) has no typed uninstall and is reported as retained: left in place until a
|
|
9
|
+
// rollback (history.ts) or the user removes it.
|
|
10
|
+
//
|
|
11
|
+
// Catalog revisions. rebaseProfile moves a profile from the defaults it was made from (base) to a newer catalog's
|
|
12
|
+
// (next), field by field: a field still equal to the old default follows the new one; a field the user changed is
|
|
13
|
+
// kept and reported. New defaults never raise escalation or opt in to a runtime (OpenShell is opt-in): such changes
|
|
14
|
+
// are refused and reported. A rebase that would produce an invalid profile fails instead of guessing. v1.1: the base
|
|
15
|
+
// is found by the profile's own `revision` in the catalogs given (or checked against it when passed explicitly), a
|
|
16
|
+
// rebase never goes to an older revision, and the result records the new revision.
|
|
17
|
+
//
|
|
18
|
+
// Harness versions. planHarnessVersions decides, per desired harness, against the pins of a verified manifest and
|
|
19
|
+
// the versions hub installed (from the history), with decideHarnessInstall: nothing below the security floor,
|
|
20
|
+
// older pins only with explicit consent, the same version with other bytes is tampering. A harness the user
|
|
21
|
+
// installed another way is never replaced. Each install, upgrade or downgrade carries its install-harness action,
|
|
22
|
+
// recording the version it replaces so a rollback can restore it.
|
|
23
|
+
import {
|
|
24
|
+
checkHarnessPin,
|
|
25
|
+
Checker,
|
|
26
|
+
canonicalJSON,
|
|
27
|
+
decideHarnessInstall,
|
|
28
|
+
EMBEDDED_ASSETS,
|
|
29
|
+
fail,
|
|
30
|
+
HARNESS_ORDER,
|
|
31
|
+
ok,
|
|
32
|
+
validateProfile,
|
|
33
|
+
type HarnessID,
|
|
34
|
+
type Action,
|
|
35
|
+
type InstallHarness,
|
|
36
|
+
type InstallKind,
|
|
37
|
+
type Profile,
|
|
38
|
+
type Result,
|
|
39
|
+
type ServiceID,
|
|
40
|
+
type ServiceManagerID,
|
|
41
|
+
type VerifiedManifest,
|
|
42
|
+
} from "../contracts/index.ts"
|
|
43
|
+
import { PROFILE_CATALOG, type ProfileCatalog } from "./catalog.ts"
|
|
44
|
+
import type { DesiredState } from "./desired.ts"
|
|
45
|
+
import type { InstalledHarness } from "./history.ts"
|
|
46
|
+
|
|
47
|
+
/** "harness:<id>", "runtime:<id>", "service:<id>", "launch", "escalation:explicit". */
|
|
48
|
+
export type Capability = string
|
|
49
|
+
|
|
50
|
+
export interface ProfileChange {
|
|
51
|
+
readonly from: Profile["id"]
|
|
52
|
+
readonly to: Profile["id"]
|
|
53
|
+
readonly direction: "same" | "upgrade" | "downgrade"
|
|
54
|
+
readonly adds: readonly Capability[]
|
|
55
|
+
readonly removes: readonly Capability[]
|
|
56
|
+
/** Removed capabilities hub leaves in place (no typed uninstall): undo them with a rollback. */
|
|
57
|
+
readonly retained: readonly Capability[]
|
|
58
|
+
/** v1.1: the typed steps that remove what can be removed (services), in execution order. */
|
|
59
|
+
readonly actions: readonly Action[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** v1.1: what planProfileChange needs to plan removing services. */
|
|
63
|
+
export interface ServiceFacts {
|
|
64
|
+
/** The host's user service manager. */
|
|
65
|
+
readonly manager: ServiceManagerID
|
|
66
|
+
/** The digest of each unit hub installed and still owns (installedServiceUnits). */
|
|
67
|
+
readonly installed: Partial<Record<ServiceID, string>>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const UNIT_ROOT: Readonly<Record<ServiceManagerID, "launch-agents" | "systemd-user">> = { launchd: "launch-agents", "systemd-user": "systemd-user" }
|
|
71
|
+
|
|
72
|
+
/** Deactivates, then removes (when hub knows the unit it installed), one service of a removed component. */
|
|
73
|
+
function removeServiceActions(service: ServiceID, facts: ServiceFacts): Action[] {
|
|
74
|
+
const actions: Action[] = [
|
|
75
|
+
{ id: `deactivate-${service}`, kind: "deactivate-service", escalation: "user", summary: `Unload the hub ${service} service (profile downgrade)`, service, manager: facts.manager },
|
|
76
|
+
]
|
|
77
|
+
const asset = EMBEDDED_ASSETS[`service/${facts.manager}/${service}`]
|
|
78
|
+
const sha256 = facts.installed[service]
|
|
79
|
+
if (asset && sha256)
|
|
80
|
+
actions.push({
|
|
81
|
+
id: `remove-${service}`,
|
|
82
|
+
kind: "remove-service",
|
|
83
|
+
escalation: "user",
|
|
84
|
+
summary: `Remove the hub ${service} ${facts.manager} unit (profile downgrade)`,
|
|
85
|
+
service,
|
|
86
|
+
manager: facts.manager,
|
|
87
|
+
path: { root: UNIT_ROOT[facts.manager], rel: asset.installAs },
|
|
88
|
+
sha256,
|
|
89
|
+
})
|
|
90
|
+
return actions
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function capabilities(p: Profile): Capability[] {
|
|
94
|
+
return [
|
|
95
|
+
...HARNESS_ORDER.filter((h) => p.harnesses.includes(h)).map((h) => `harness:${h}`),
|
|
96
|
+
...p.runtimes.map((r) => `runtime:${r}`),
|
|
97
|
+
...p.services.map((s) => `service:${s}`),
|
|
98
|
+
...(p.launch.enabled ? ["launch"] : []),
|
|
99
|
+
...(p.escalation === "explicit" ? ["escalation:explicit"] : []),
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function planProfileChange(fromDoc: unknown, toDoc: unknown, options: { readonly allowDowngrade?: boolean; readonly services?: ServiceFacts } = {}): Result<ProfileChange> {
|
|
104
|
+
const c = new Checker()
|
|
105
|
+
const from = validateProfile(fromDoc)
|
|
106
|
+
const to = validateProfile(toDoc)
|
|
107
|
+
c.merge("/from", from)
|
|
108
|
+
c.merge("/to", to)
|
|
109
|
+
if (!from.ok || !to.ok) return c.result(undefined)
|
|
110
|
+
const before = capabilities(from.value)
|
|
111
|
+
const after = capabilities(to.value)
|
|
112
|
+
const adds = after.filter((x) => !before.includes(x))
|
|
113
|
+
const removes = before.filter((x) => !after.includes(x))
|
|
114
|
+
const direction = removes.length > 0 ? "downgrade" : adds.length > 0 ? "upgrade" : "same"
|
|
115
|
+
if (direction === "downgrade" && !options.allowDowngrade)
|
|
116
|
+
return fail("/to", "downgrade_rejected", `${to.value.id} removes ${removes.join(", ")}; pass --allow-downgrade to change to it`)
|
|
117
|
+
const actions: Action[] = []
|
|
118
|
+
const removed = new Set<Capability>()
|
|
119
|
+
if (options.services)
|
|
120
|
+
for (const capability of removes) {
|
|
121
|
+
if (!capability.startsWith("service:")) continue
|
|
122
|
+
const service = capability.slice("service:".length) as ServiceID
|
|
123
|
+
const planned = removeServiceActions(service, options.services)
|
|
124
|
+
actions.push(...planned)
|
|
125
|
+
// A service is gone only once its unit is removed too; a deactivated unit hub never recorded stays.
|
|
126
|
+
if (planned.some((a) => a.kind === "remove-service")) removed.add(capability)
|
|
127
|
+
}
|
|
128
|
+
// Deactivate everything first, then remove the units (the plan rule: a service is deactivated before it is removed).
|
|
129
|
+
actions.sort((a, b) => Number(a.kind === "remove-service") - Number(b.kind === "remove-service"))
|
|
130
|
+
return ok({ from: from.value.id, to: to.value.id, direction, adds, removes, retained: removes.filter((x) => !removed.has(x)), actions })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const REBASED_FIELDS = ["harnesses", "runtimes", "services", "launch", "escalation", "enrollment"] as const
|
|
134
|
+
export type RebasedField = (typeof REBASED_FIELDS)[number]
|
|
135
|
+
|
|
136
|
+
export interface RebasedProfile {
|
|
137
|
+
readonly profile: Profile
|
|
138
|
+
/** Fields the user had changed from the old defaults: kept. */
|
|
139
|
+
readonly kept: readonly RebasedField[]
|
|
140
|
+
/** Fields that followed the new defaults. */
|
|
141
|
+
readonly updated: readonly RebasedField[]
|
|
142
|
+
/** New defaults refused because they would raise privilege or opt in to a runtime. */
|
|
143
|
+
readonly refused: readonly RebasedField[]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An absent optional field (enrollment) compares as null.
|
|
147
|
+
const same = (a: unknown, b: unknown) => canonicalJSON(a ?? null) === canonicalJSON(b ?? null)
|
|
148
|
+
|
|
149
|
+
export interface RebaseInput {
|
|
150
|
+
readonly current: unknown
|
|
151
|
+
/** The defaults `current` was made from. Optional when `current.revision` names one of `catalogs`. */
|
|
152
|
+
readonly base?: unknown
|
|
153
|
+
readonly next: unknown
|
|
154
|
+
/** v1.1: catalogs (any revisions) to find the base in by `current.revision`; default: this hubctl's catalog. */
|
|
155
|
+
readonly catalogs?: readonly ProfileCatalog[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function rebaseProfile(input: RebaseInput): Result<RebasedProfile> {
|
|
159
|
+
const c = new Checker()
|
|
160
|
+
const current = validateProfile(input.current)
|
|
161
|
+
const next = validateProfile(input.next)
|
|
162
|
+
c.merge("/current", current)
|
|
163
|
+
c.merge("/next", next)
|
|
164
|
+
if (!current.ok || !next.ok) return c.result(undefined)
|
|
165
|
+
let baseDoc = input.base
|
|
166
|
+
const revision = current.value.revision
|
|
167
|
+
if (baseDoc === undefined) {
|
|
168
|
+
if (revision === undefined) return fail("/current/revision", "missing_field", "the profile records no catalog revision; pass the defaults it was made from as base")
|
|
169
|
+
const catalog = (input.catalogs ?? [PROFILE_CATALOG]).find((k) => k.revision === revision)
|
|
170
|
+
if (!catalog) return fail("/current/revision", "invalid_value", `this hubctl does not know catalog revision ${revision}`)
|
|
171
|
+
baseDoc = catalog.profiles[current.value.id]
|
|
172
|
+
}
|
|
173
|
+
const base = validateProfile(baseDoc)
|
|
174
|
+
c.merge("/base", base)
|
|
175
|
+
if (!base.ok) return c.result(undefined)
|
|
176
|
+
const [cur, old, neu] = [current.value, base.value, next.value]
|
|
177
|
+
if (cur.id !== old.id || cur.id !== neu.id) return fail("/next/id", "invalid_value", "a rebase keeps the profile; change profiles with planProfileChange")
|
|
178
|
+
if (revision !== undefined && old.revision !== undefined && old.revision !== revision)
|
|
179
|
+
return fail("/base/revision", "inconsistent", `the profile was made from catalog revision ${revision}, not ${old.revision}`)
|
|
180
|
+
if (revision !== undefined && neu.revision !== undefined && neu.revision < revision)
|
|
181
|
+
return fail("/next/revision", "downgrade_rejected", `catalog revision ${neu.revision} is older than the profile's ${revision}`)
|
|
182
|
+
|
|
183
|
+
const result: Record<string, unknown> = { ...cur }
|
|
184
|
+
const kept: RebasedField[] = []
|
|
185
|
+
const updated: RebasedField[] = []
|
|
186
|
+
const refused: RebasedField[] = []
|
|
187
|
+
for (const field of REBASED_FIELDS) {
|
|
188
|
+
if (!same(cur[field], old[field])) {
|
|
189
|
+
kept.push(field)
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (same(cur[field], neu[field])) continue
|
|
193
|
+
const raises = (field === "escalation" && neu.escalation === "explicit") || (field === "runtimes" && neu.runtimes.some((r) => !cur.runtimes.includes(r)))
|
|
194
|
+
if (raises) refused.push(field)
|
|
195
|
+
else {
|
|
196
|
+
result[field] = neu[field]
|
|
197
|
+
updated.push(field)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (neu.revision !== undefined) result.revision = neu.revision
|
|
201
|
+
const profile = validateProfile(result)
|
|
202
|
+
if (!profile.ok) return profile
|
|
203
|
+
return ok({ profile: profile.value, kept, updated, refused })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type HarnessVersionDecision = "install" | "upgrade" | "downgrade" | "keep" | "keep-user-managed"
|
|
207
|
+
|
|
208
|
+
export interface HarnessVersionStep {
|
|
209
|
+
readonly harness: HarnessID
|
|
210
|
+
readonly decision: HarnessVersionDecision
|
|
211
|
+
/** The version there now (when known). */
|
|
212
|
+
readonly from?: string
|
|
213
|
+
/** The pinned version. */
|
|
214
|
+
readonly to?: string
|
|
215
|
+
/** For install, upgrade and downgrade. */
|
|
216
|
+
readonly action?: InstallHarness
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface HarnessVersionInput {
|
|
220
|
+
readonly desired: DesiredState
|
|
221
|
+
readonly manifest: VerifiedManifest
|
|
222
|
+
/** What hub installed, from the history (installedHarnesses). */
|
|
223
|
+
readonly installed: Partial<Record<HarnessID, InstalledHarness>>
|
|
224
|
+
/** The highest harness floors this machine has seen. */
|
|
225
|
+
readonly floors?: Partial<Record<HarnessID, string>>
|
|
226
|
+
readonly allowDowngrade?: boolean
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Where hub installs a pinned harness version (a versioned directory under the data root). */
|
|
230
|
+
export const harnessInstallPath = (harness: HarnessID, version: string) => ({ root: "data", rel: `hub/harnesses/${harness}/${version}` }) as const
|
|
231
|
+
|
|
232
|
+
const VERB: Readonly<Record<Exclude<InstallKind, "reinstall">, string>> = { install: "Install", upgrade: "Upgrade", downgrade: "Downgrade" }
|
|
233
|
+
|
|
234
|
+
export function planHarnessVersions(input: HarnessVersionInput): Result<HarnessVersionStep[]> {
|
|
235
|
+
const c = new Checker()
|
|
236
|
+
const { desired, manifest } = input
|
|
237
|
+
const steps: HarnessVersionStep[] = []
|
|
238
|
+
desired.harnesses.forEach((h, i) => {
|
|
239
|
+
const at = `/harnesses/${i}`
|
|
240
|
+
if (h.owner === "user") return void steps.push({ harness: h.id, decision: "keep-user-managed", ...(h.version ? { from: h.version } : {}) })
|
|
241
|
+
const inst = h.presence === "installed" ? input.installed[h.id] : undefined
|
|
242
|
+
if (h.presence === "installed") {
|
|
243
|
+
if (!inst) return c.add(at, "inconsistent", `${h.id} was installed by hub, but no committed transaction records it; recover or reinstall`)
|
|
244
|
+
if (h.version !== undefined && h.version !== inst.version) return c.add(at, "inconsistent", `${h.id} is ${h.version}, not the ${inst.version} hub installed; verify it or roll back`)
|
|
245
|
+
}
|
|
246
|
+
const pin = manifest.harnesses.find((p) => p.harness === h.id && p.target === desired.target)
|
|
247
|
+
if (!pin) {
|
|
248
|
+
if (inst) return void steps.push({ harness: h.id, decision: "keep", from: inst.version })
|
|
249
|
+
return c.add(at, "unpinned_artifact", `hub ${manifest.release} pins no ${h.id} for ${desired.target}`)
|
|
250
|
+
}
|
|
251
|
+
const decided = decideHarnessInstall(pin, { ...(inst ? { installed: { version: inst.version, sha256: inst.sha256 } } : {}), ...(input.floors?.[h.id] ? { floor: input.floors[h.id] } : {}), allowDowngrade: input.allowDowngrade })
|
|
252
|
+
if (!decided.ok) return c.merge(at, decided)
|
|
253
|
+
if (decided.value === "reinstall") return void steps.push({ harness: h.id, decision: "keep", from: pin.version, to: pin.version })
|
|
254
|
+
const action: InstallHarness = {
|
|
255
|
+
id: `install-${h.id}`,
|
|
256
|
+
kind: "install-harness",
|
|
257
|
+
escalation: "user",
|
|
258
|
+
summary: `${VERB[decided.value]} ${h.id} ${pin.version} for ${pin.target} (pinned by hub ${manifest.release})`,
|
|
259
|
+
harness: h.id,
|
|
260
|
+
version: pin.version,
|
|
261
|
+
target: pin.target,
|
|
262
|
+
source: { manifest: manifest.release, url: pin.url, file: pin.file },
|
|
263
|
+
size: pin.size,
|
|
264
|
+
sha256: pin.sha256,
|
|
265
|
+
evidence: [...pin.evidence],
|
|
266
|
+
path: harnessInstallPath(h.id, pin.version),
|
|
267
|
+
owner: "hub",
|
|
268
|
+
rollback: "restore-previous",
|
|
269
|
+
...(inst ? { previous: { version: inst.version, sha256: inst.sha256 } } : {}),
|
|
270
|
+
}
|
|
271
|
+
const pinned = checkHarnessPin(manifest, action)
|
|
272
|
+
if (!pinned.ok) return c.merge(at, pinned)
|
|
273
|
+
steps.push({ harness: h.id, decision: decided.value, ...(inst ? { from: inst.version } : {}), to: pin.version, action })
|
|
274
|
+
})
|
|
275
|
+
return c.result(steps)
|
|
276
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Profile document versions. A persisted profile is read through the contract's migration hook (migrateProfile),
|
|
2
|
+
// one version at a time, and the steps taken are reported so the caller can say "migrated from v0". A document from
|
|
3
|
+
// a newer hubctl is refused (unsupported_version), never read as if it were current; a version no chain of
|
|
4
|
+
// migrations reaches is unmigratable. Negotiation picks the highest version both sides understand.
|
|
5
|
+
import { fail, isRecord, migrateProfile, ok, PROFILE_MIGRATIONS, PROFILE_VERSION, type Profile, type ProfileMigrations, type Result } from "../contracts/index.ts"
|
|
6
|
+
|
|
7
|
+
export interface MigrationStep {
|
|
8
|
+
readonly from: number
|
|
9
|
+
readonly to: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LoadedProfile {
|
|
13
|
+
readonly profile: Profile
|
|
14
|
+
/** The version the document had on disk. */
|
|
15
|
+
readonly from: number
|
|
16
|
+
/** The migrations applied, oldest first (empty for a current document). */
|
|
17
|
+
readonly steps: readonly MigrationStep[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reads a profile document of any readable version; the input is never modified. */
|
|
21
|
+
export function loadProfile(raw: unknown, migrations: ProfileMigrations = PROFILE_MIGRATIONS): Result<LoadedProfile> {
|
|
22
|
+
const steps: MigrationStep[] = []
|
|
23
|
+
const recording: Record<number, (doc: Record<string, unknown>) => unknown> = {}
|
|
24
|
+
for (const [key, step] of Object.entries(migrations))
|
|
25
|
+
recording[Number(key)] = (doc) => {
|
|
26
|
+
const next = step(structuredClone(doc))
|
|
27
|
+
if (isRecord(next) && Number.isInteger(next.version)) steps.push({ from: doc.version as number, to: next.version as number })
|
|
28
|
+
return next
|
|
29
|
+
}
|
|
30
|
+
const result = migrateProfile(raw, recording)
|
|
31
|
+
if (!result.ok) return result
|
|
32
|
+
return ok({ profile: result.value, from: (raw as { version: number }).version, steps })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every profile version this hubctl can read, ascending: the current one and each older one with an unbroken chain. */
|
|
36
|
+
export function supportedProfileVersions(migrations: ProfileMigrations = PROFILE_MIGRATIONS): number[] {
|
|
37
|
+
const versions = [PROFILE_VERSION]
|
|
38
|
+
for (let v = PROFILE_VERSION - 1; typeof migrations[v] === "function"; v--) versions.unshift(v)
|
|
39
|
+
return versions
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The highest offered version this hubctl can read. */
|
|
43
|
+
export function negotiateProfileVersion(offered: readonly number[], migrations: ProfileMigrations = PROFILE_MIGRATIONS): Result<number> {
|
|
44
|
+
if (offered.length === 0 || !offered.every((v) => Number.isInteger(v))) return fail("", "invalid_value", "offer at least one integer profile version")
|
|
45
|
+
const supported = new Set(supportedProfileVersions(migrations))
|
|
46
|
+
const common = offered.filter((v) => supported.has(v))
|
|
47
|
+
if (common.length > 0) return ok(Math.max(...common))
|
|
48
|
+
if (offered.some((v) => v > PROFILE_VERSION)) return fail("", "unsupported_version", `profile versions ${offered.join(", ")} are newer than this hubctl understands (${PROFILE_VERSION}); upgrade hubctl`)
|
|
49
|
+
return fail("", "unmigratable", `no migration reaches profile version ${PROFILE_VERSION} from ${offered.join(", ")}`)
|
|
50
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Standalone hubctl releases
|
|
2
|
+
|
|
3
|
+
This directory builds, signs and verifies the standalone `hubctl` (contracts §3.6, §3.7, I5, I6 in
|
|
4
|
+
`docs/bootstrap-contracts.md`). The central hub server binaries (`scripts/build-binaries.sh`) are separate and
|
|
5
|
+
unchanged.
|
|
6
|
+
|
|
7
|
+
| File | Role |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `verify.ts` | The installer's checks on a downloaded hubctl: signature (trust root), exact target, size and digest, downgrade policy. |
|
|
10
|
+
| `manifest.ts` | Builds the manifest body (every supported target, or an explicit `targets` subset; matrix order; harness pins) and signs it (ed25519, detached, over `manifestPayload`). |
|
|
11
|
+
| `pins.ts` | The reviewed harness pin list (`harness-pins.json`): format, validation, re-download check. |
|
|
12
|
+
| `trust-root.ts` | The trust root embedded at build time (`--define HUBCTL_TRUST_ROOT=…`), never read at run time. |
|
|
13
|
+
| `assets.ts` | The six service templates of `EMBEDDED_ASSETS`, imported as text so the binary carries them. |
|
|
14
|
+
| `cli.ts` | The release tool behind `bun scripts/hubctl-release.ts build\|manifest\|sign\|verify\|trust-root`. |
|
|
15
|
+
|
|
16
|
+
Everything except `build` needs only bun and node's built-ins, so the signing job installs no npm packages.
|
|
17
|
+
|
|
18
|
+
## Building
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
scripts/build-hubctl.sh # dev build: every supported target, no trust root
|
|
22
|
+
scripts/build-hubctl.sh linux-x64 # one target
|
|
23
|
+
scripts/build-hubctl.sh --require-trust-root # release build: refuses without trust-root.json
|
|
24
|
+
scripts/build-hubctl.sh linux-x64 --outdir /tmp/out # binaries and checksums in /tmp/out
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Outputs: `dist/hubctl-<target>` and `dist/HUBCTL_SHA256SUMS` (in `--outdir` instead, when given). x64 targets are
|
|
28
|
+
compiled against Bun's `-baseline` runtime, which needs no AVX2 (older CPUs, QEMU's default `kvm64` model). The supported targets are `darwin-arm64`,
|
|
29
|
+
`darwin-x64`, `linux-x64` and `linux-arm64` (glibc). Windows and musl are refused with their reasons. The
|
|
30
|
+
binary is the whole program: it runs without bun, node or the repository, and a `hub` link to it is the same
|
|
31
|
+
command surface. A build without a trust root cannot verify any release (`shippedTrustRoot` fails closed).
|
|
32
|
+
|
|
33
|
+
## The harness pin list (`harness-pins.json`)
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "schema": "hub.harness-pin-list", "version": 1,
|
|
37
|
+
"pins": [ { "pin": { "harness": "opencode", "version": "…", "target": "darwin-arm64", "url": "https://…/file.zip",
|
|
38
|
+
"file": "file.zip", "size": 123, "sha256": "sha256:…", "evidence": [] },
|
|
39
|
+
"review": { "by": "github-handle", "on": "2026-10-28",
|
|
40
|
+
"checked": ["upstream-release", "digest-redownloaded", "evidence-listed"] } } ] }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`pin` is exactly a manifest `HarnessPin`. Each pin is checked with the manifest's own rules: a supported
|
|
44
|
+
target, an exact version, https, a plain file name, one pin per harness and target, and only evidence that
|
|
45
|
+
harness can offer. On top of that, the file must be the URL's last path segment, the URL may not carry
|
|
46
|
+
credentials, a query or a fragment, and nothing secret-shaped may appear anywhere in the list.
|
|
47
|
+
|
|
48
|
+
The list is empty until the harness adapter tasks confirm the upstream sources (HBS-06 for OpenCode 2.x).
|
|
49
|
+
|
|
50
|
+
**Review process**
|
|
51
|
+
|
|
52
|
+
1. A pin changes only through a pull request that touches `harness-pins.json`. The reviewer is not the author.
|
|
53
|
+
2. The reviewer fills in `review`:
|
|
54
|
+
- `by`: their GitHub handle.
|
|
55
|
+
- `on`: the review date.
|
|
56
|
+
- `checked`: all three checks.
|
|
57
|
+
- `upstream-release`: the URL is the upstream's own release asset for exactly this version and target.
|
|
58
|
+
- `digest-redownloaded`: they downloaded the file themselves and computed its size and sha256, for example
|
|
59
|
+
with `curl -fsSL <url> | shasum -a 256`.
|
|
60
|
+
- `evidence-listed`: `evidence` lists exactly the upstream evidence published for that file (§5 of the
|
|
61
|
+
contracts doc).
|
|
62
|
+
3. CI validates the file (`bootstrap-release-pins.test.ts`).
|
|
63
|
+
4. At release time, the `hubctl-sign` job downloads every pinned file again and refuses to sign on any size or
|
|
64
|
+
digest mismatch. The signature never vouches for a file that nobody fetched during that release.
|
|
65
|
+
|
|
66
|
+
## The release key (decision Q5)
|
|
67
|
+
|
|
68
|
+
There is one ed25519 key, with threshold 1. The private key is the `HUB_RELEASE_SIGNING_KEY` secret of the
|
|
69
|
+
`hubctl-release` GitHub environment. Only the `hubctl-sign` job uses that environment, and only its Sign step
|
|
70
|
+
sees the secret. The key is PKCS#8, either PEM or base64 DER. No key is ever committed.
|
|
71
|
+
|
|
72
|
+
A maintainer sets the key up once, on their own machine. This has not been done yet:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
openssl genpkey -algorithm ed25519 -out release-key.pem # keep it offline
|
|
76
|
+
HUB_RELEASE_SIGNING_KEY="$(cat release-key.pem)" bun scripts/hubctl-release.ts trust-root --key-id hub-release-1 \
|
|
77
|
+
> packages/cli/src/bootstrap/release/trust-root.json # public key only; commit through a reviewed PR
|
|
78
|
+
gh secret set HUB_RELEASE_SIGNING_KEY --env hubctl-release < release-key.pem
|
|
79
|
+
gh variable set HUBCTL_RELEASE --body true # enables the hubctl release jobs
|
|
80
|
+
gh variable set HUBCTL_MINIMUM_VERSION --body 0.0.0 # the security floor (optional, default 0.0.0)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
To rotate the key, publish a version-2 trust root signed by the current key (`rotateTrustRoot`), with the old key
|
|
84
|
+
`retired`, or `revoked` if it was compromised. Then update `trust-root.json` and the secret.
|
|
85
|
+
|
|
86
|
+
## The release workflow (`.github/workflows/release.yml`)
|
|
87
|
+
|
|
88
|
+
The hubctl jobs are added alongside the existing jobs. They run only when `HUBCTL_RELEASE` is `true`.
|
|
89
|
+
|
|
90
|
+
1. `hubctl` (needs `test`): `scripts/build-hubctl.sh --require-trust-root` cross-compiles all four targets.
|
|
91
|
+
Then it smoke-tests `linux-x64` with an empty `PATH` (no bun or node): `--version` must equal the CLI
|
|
92
|
+
version, and `hub --help` must equal `hubctl --help`.
|
|
93
|
+
2. `hubctl-sign` (needs `hubctl`, environment `hubctl-release`, no npm install):
|
|
94
|
+
- `manifest` re-downloads the pins and writes the body. `issuedAt` is the tagged commit's time.
|
|
95
|
+
- `sign` signs the body and verifies it against `trust-root.json`. A wrong secret writes nothing.
|
|
96
|
+
- `verify --dist dist` runs the installer's checks on every artifact.
|
|
97
|
+
3. `hubctl-publish` (needs the existing `release` job and `hubctl-sign`): it verifies again, then attaches
|
|
98
|
+
`hubctl-*`, `hubctl-manifest.json` and `HUBCTL_SHA256SUMS` to the GitHub release.
|
|
99
|
+
|
|
100
|
+
## Verifying by hand
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
bun scripts/hubctl-release.ts verify --manifest hubctl-manifest.json --target linux-x64 --file hubctl-linux-x64
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The exit code is 0 when the artifact is accepted and 11 when it is rejected: `signature_invalid`, `wrong_platform`,
|
|
107
|
+
`integrity_mismatch`, `downgrade_rejected` or `below_security_floor`. Pass `--installed V --installed-sha256 D`,
|
|
108
|
+
`--floor V` and `--allow-downgrade` to apply the downgrade policy.
|
|
109
|
+
|
|
110
|
+
## A partial release (some targets only)
|
|
111
|
+
|
|
112
|
+
`manifest`, `sign` and `verify --dist` take `--targets T,…` (e.g. `--targets linux-x64`) to list exactly the
|
|
113
|
+
targets a test or partial release ships. Each listed target must be built and is checked in full; an artifact for
|
|
114
|
+
any other target is refused. `sign` refuses a partial body unless `--targets` names the same list, so a partial
|
|
115
|
+
release is never signed by accident. Without `--targets`, every supported target is required.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The embedded-asset bundle: the six service templates of EMBEDDED_ASSETS (contracts §3.7, I2), imported as text so
|
|
2
|
+
// `bun build --compile` (and the npm bundle) carry them inside the program. A standalone hubctl never reads
|
|
3
|
+
// scripts/ at run time; node.ts / spoke-services.ts render from embeddedAsset(id).text (wired by HBS-13).
|
|
4
|
+
//
|
|
5
|
+
// Each import must match EMBEDDED_ASSETS[id].source; the release tests check every entry byte for byte.
|
|
6
|
+
import { EMBEDDED_ASSETS, type EmbeddedAsset } from "../contracts/index.ts"
|
|
7
|
+
import { digestBytes } from "./verify.ts"
|
|
8
|
+
import launchdNode from "../../../../../scripts/io.github.huynle.hub-node.plist" with { type: "text" }
|
|
9
|
+
import systemdNode from "../../../../../scripts/hub-node.service" with { type: "text" }
|
|
10
|
+
import launchdClaude from "../../../../../scripts/io.github.huynle.hub-claude-code.plist" with { type: "text" }
|
|
11
|
+
import systemdClaude from "../../../../../scripts/hub-claude-code.service" with { type: "text" }
|
|
12
|
+
import launchdCodex from "../../../../../scripts/io.github.huynle.hub-codex-spoke.plist" with { type: "text" }
|
|
13
|
+
import systemdCodex from "../../../../../scripts/hub-codex-spoke.service" with { type: "text" }
|
|
14
|
+
|
|
15
|
+
export type AssetID = keyof typeof EMBEDDED_ASSETS
|
|
16
|
+
|
|
17
|
+
/** The template text of every embedded asset, by EMBEDDED_ASSETS id. */
|
|
18
|
+
export const EMBEDDED_TEXT: Readonly<Record<string, string>> = Object.freeze({
|
|
19
|
+
"service/launchd/node": launchdNode,
|
|
20
|
+
"service/systemd-user/node": systemdNode,
|
|
21
|
+
"service/launchd/claude-code": launchdClaude,
|
|
22
|
+
"service/systemd-user/claude-code": systemdClaude,
|
|
23
|
+
"service/launchd/codex-spoke": launchdCodex,
|
|
24
|
+
"service/systemd-user/codex-spoke": systemdCodex,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export interface EmbeddedText {
|
|
28
|
+
readonly asset: EmbeddedAsset
|
|
29
|
+
readonly text: string
|
|
30
|
+
/** "sha256:<hex>" of the template's UTF-8 bytes (a write-file action pins the rendered result instead). */
|
|
31
|
+
readonly sha256: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An embedded template with its contract entry and digest; undefined for any other id. */
|
|
35
|
+
export function embeddedAsset(id: string): EmbeddedText | undefined {
|
|
36
|
+
if (!Object.hasOwn(EMBEDDED_ASSETS, id) || !Object.hasOwn(EMBEDDED_TEXT, id)) return undefined
|
|
37
|
+
const text = EMBEDDED_TEXT[id]!
|
|
38
|
+
return { asset: EMBEDDED_ASSETS[id]!, text, sha256: digestBytes(new TextEncoder().encode(text)).sha256 }
|
|
39
|
+
}
|