@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,470 @@
|
|
|
1
|
+
// The typed Action union: everything a setup plan can ask the applier to do. There is deliberately no "run a
|
|
2
|
+
// command" action and no field that could carry one: each kind names a local operation the applier implements
|
|
3
|
+
// itself (write this embedded asset there, merge these keys into that config, load this user service). A plan that
|
|
4
|
+
// smuggles a command-shaped key anywhere, at any depth, is refused (exec_field), as is any unknown field or kind.
|
|
5
|
+
import { EMBEDDED_ASSETS, SERVICE_IDS, type ServiceID } from "./exec.ts"
|
|
6
|
+
import { HARNESS_EVIDENCE, HARNESS_ORDER, REAL_PROBES, RUNTIME_IDS, type EvidenceKind, type HarnessID, type ProbeKind, type RuntimeID } from "./harness.ts"
|
|
7
|
+
import { checkPathRef, ELEVATED_ROOTS, type PathRef } from "./paths.ts"
|
|
8
|
+
import { supportedTarget } from "./platform.ts"
|
|
9
|
+
import { findSecrets, isSecretKey } from "./secrets.ts"
|
|
10
|
+
import { bool, Checker, digest, FILE_NAME, httpsURL, hubURL, int, isRecord, list, oneOf, pointer, semver, shape, subset, text, type Json, type Result } from "./validate.ts"
|
|
11
|
+
|
|
12
|
+
/** Keys that would let a document carry something to execute. No action field is named like these, at any depth. */
|
|
13
|
+
export const EXEC_KEYS = ["command", "cmd", "commands", "shell", "script", "scripts", "exec", "execute", "argv", "args", "arguments", "run", "eval", "sh", "bash", "program", "spawn", "hook", "hooks"] as const
|
|
14
|
+
|
|
15
|
+
export type Escalation = "user" | "elevated"
|
|
16
|
+
export type ConfigOwner = "user" | "hub" | `harness:${HarnessID}`
|
|
17
|
+
export const CONFIG_OWNERS: readonly ConfigOwner[] = ["user", "hub", ...HARNESS_ORDER.map((h) => `harness:${h}` as const)]
|
|
18
|
+
export type ConfigFormat = "json" | "jsonc" | "toml"
|
|
19
|
+
export type ServiceManagerID = "launchd" | "systemd-user"
|
|
20
|
+
|
|
21
|
+
/** What the target file must look like for an edit to apply; anything else is a conflicting user edit. */
|
|
22
|
+
export interface Precondition {
|
|
23
|
+
readonly exists: boolean
|
|
24
|
+
readonly sha256?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Base<K extends string> {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly kind: K
|
|
30
|
+
readonly escalation: Escalation
|
|
31
|
+
/** One printable line shown in the preview. */
|
|
32
|
+
readonly summary: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EnsureDirectory extends Base<"ensure-directory"> {
|
|
36
|
+
readonly path: PathRef
|
|
37
|
+
readonly mode: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface InstallArtifact extends Base<"install-artifact"> {
|
|
41
|
+
readonly artifact: "hubctl"
|
|
42
|
+
readonly version: string
|
|
43
|
+
readonly target: string
|
|
44
|
+
/** The manifest's digest for this target; the download must match it. */
|
|
45
|
+
readonly sha256: string
|
|
46
|
+
readonly path: PathRef
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Installs a harness from an artifact pinned in hub's signed hubctl release manifest (transitive trust): exact
|
|
51
|
+
* version, target, source and digest, all of which must equal the manifest's pin (checkHarnessPin). Upstream
|
|
52
|
+
* evidence (GPG manifest, notarization, sigstore) is attempted and recorded, never a substitute for the pin. The
|
|
53
|
+
* install is owned by hub and rolls back by restoring the previous binary/version. Installing is not readiness:
|
|
54
|
+
* a plan verifies every harness it installs.
|
|
55
|
+
*/
|
|
56
|
+
export interface InstallHarness extends Base<"install-harness"> {
|
|
57
|
+
readonly harness: HarnessID
|
|
58
|
+
readonly version: string
|
|
59
|
+
readonly target: string
|
|
60
|
+
readonly source: {
|
|
61
|
+
/** The hub release whose signed manifest pins this artifact. */
|
|
62
|
+
readonly manifest: string
|
|
63
|
+
readonly url: string
|
|
64
|
+
readonly file: string
|
|
65
|
+
}
|
|
66
|
+
readonly size: number
|
|
67
|
+
readonly sha256: string
|
|
68
|
+
/** Upstream evidence to attempt: exactly the kinds the pin lists as available. */
|
|
69
|
+
readonly evidence: readonly EvidenceKind[]
|
|
70
|
+
/** The install root: v1.1, exactly harnessVersionRef(harness, version), an immutable versioned directory. */
|
|
71
|
+
readonly path: PathRef
|
|
72
|
+
readonly owner: "hub"
|
|
73
|
+
readonly rollback: "restore-previous"
|
|
74
|
+
/** The version it replaces, as discovery found it (restored on rollback). */
|
|
75
|
+
readonly previous?: { readonly version: string; readonly sha256: string }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* v1.1 harness layout. Every pinned version installs into its own immutable directory
|
|
80
|
+
* data:hub/harnesses/<harness>/<version> (install-harness), and data:hub/harnesses/<harness>/current is a symlink to
|
|
81
|
+
* the version in use. select-harness-version switches that link atomically (stage, rename); its rollback restores
|
|
82
|
+
* the previous link from its backup, so the previous version is current again, and a rolled-back install removes
|
|
83
|
+
* only its own fresh directory.
|
|
84
|
+
*/
|
|
85
|
+
export const harnessVersionRef = (harness: HarnessID, version: string): PathRef => ({ root: "data", rel: `hub/harnesses/${harness}/${version}` })
|
|
86
|
+
export const harnessCurrentRef = (harness: HarnessID): PathRef => ({ root: "data", rel: `hub/harnesses/${harness}/current` })
|
|
87
|
+
|
|
88
|
+
/** Points harnessCurrentRef(harness) at harnessVersionRef(harness, version). */
|
|
89
|
+
export interface SelectHarnessVersion extends Base<"select-harness-version"> {
|
|
90
|
+
readonly harness: HarnessID
|
|
91
|
+
readonly version: string
|
|
92
|
+
/** The `current` link: harnessCurrentRef(harness). */
|
|
93
|
+
readonly path: PathRef
|
|
94
|
+
/** The version directory it points to: harnessVersionRef(harness, version). */
|
|
95
|
+
readonly target: PathRef
|
|
96
|
+
/** The version `current` pointed at before, as discovery found it (current again after a rollback). */
|
|
97
|
+
readonly previous?: { readonly version: string; readonly sha256: string }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A symlink (the hub ↔ hubctl alias); `target` is where it points. */
|
|
101
|
+
export interface LinkExecutable extends Base<"link-executable"> {
|
|
102
|
+
readonly path: PathRef
|
|
103
|
+
readonly target: PathRef
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Writes an embedded or rendered asset whose content digest is pinned in the plan. */
|
|
107
|
+
export interface WriteFile extends Base<"write-file"> {
|
|
108
|
+
readonly path: PathRef
|
|
109
|
+
readonly asset: string
|
|
110
|
+
readonly sha256: string
|
|
111
|
+
readonly mode: number
|
|
112
|
+
/** An existing file that is not ours, or not as we left it, is never overwritten. */
|
|
113
|
+
readonly onConflict: "refuse"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ConfigSet {
|
|
117
|
+
/** RFC 6901 pointer; "-" appends to an array. */
|
|
118
|
+
readonly pointer: string
|
|
119
|
+
readonly value: Json
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A lossless merge: only the listed pointers change; every other key (known or not) is preserved byte for byte. */
|
|
123
|
+
export interface MergeConfig extends Base<"merge-config"> {
|
|
124
|
+
readonly path: PathRef
|
|
125
|
+
readonly format: ConfigFormat
|
|
126
|
+
readonly owner: ConfigOwner
|
|
127
|
+
readonly precondition: Precondition
|
|
128
|
+
readonly set: readonly ConfigSet[]
|
|
129
|
+
readonly preserveUnknown: true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Registers this hubctl's `mcp` subcommand with a harness. The command is implied, never carried. */
|
|
133
|
+
export interface RegisterMcpServer extends Base<"register-mcp-server"> {
|
|
134
|
+
readonly harness: HarnessID
|
|
135
|
+
readonly path: PathRef
|
|
136
|
+
readonly name: string
|
|
137
|
+
readonly precondition: Precondition
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface InstallService extends Base<"install-service"> {
|
|
141
|
+
readonly service: ServiceID
|
|
142
|
+
readonly manager: ServiceManagerID
|
|
143
|
+
readonly path: PathRef
|
|
144
|
+
/** An EMBEDDED_ASSETS key for this service and manager. */
|
|
145
|
+
readonly asset: string
|
|
146
|
+
/** Digest of the rendered unit. */
|
|
147
|
+
readonly sha256: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Loads a user service. Only ever explicit: the user confirmed this step. */
|
|
151
|
+
export interface ActivateService extends Base<"activate-service"> {
|
|
152
|
+
readonly service: ServiceID
|
|
153
|
+
readonly manager: ServiceManagerID
|
|
154
|
+
readonly confirm: "explicit"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** v1.1: unloads a hub user service (a profile downgrade; needs --allow-downgrade). Rollback loads it again. */
|
|
158
|
+
export interface DeactivateService extends Base<"deactivate-service"> {
|
|
159
|
+
readonly service: ServiceID
|
|
160
|
+
readonly manager: ServiceManagerID
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* v1.1: removes a hub service unit (a profile downgrade; needs --allow-downgrade), only while it is still exactly
|
|
165
|
+
* the unit hub installed (`sha256`); a unit the user changed is a conflict, never removed. Rollback restores it.
|
|
166
|
+
*/
|
|
167
|
+
export interface RemoveService extends Base<"remove-service"> {
|
|
168
|
+
readonly service: ServiceID
|
|
169
|
+
readonly manager: ServiceManagerID
|
|
170
|
+
readonly path: PathRef
|
|
171
|
+
readonly sha256: string
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Device-code enrollment of this machine (the user approves it on the hub); carries no credential. */
|
|
175
|
+
export interface EnrollMachine extends Base<"enroll-machine"> {
|
|
176
|
+
readonly server: string
|
|
177
|
+
readonly machineName: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface VerifyHarness extends Base<"verify-harness"> {
|
|
181
|
+
readonly harness: HarnessID
|
|
182
|
+
readonly probes: readonly Exclude<ProbeKind, "presence">[]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Opt-in check of a sandboxed runtime (OpenShell); never installs or configures it. */
|
|
186
|
+
export interface VerifyRuntime extends Base<"verify-runtime"> {
|
|
187
|
+
readonly runtime: RuntimeID
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One bounded session launch to prove the setup end to end. */
|
|
191
|
+
export interface LaunchSession extends Base<"launch-session"> {
|
|
192
|
+
readonly harness: HarnessID
|
|
193
|
+
readonly workspace: PathRef
|
|
194
|
+
readonly maxDurationS: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type Action =
|
|
198
|
+
| EnsureDirectory
|
|
199
|
+
| InstallArtifact
|
|
200
|
+
| InstallHarness
|
|
201
|
+
| SelectHarnessVersion
|
|
202
|
+
| LinkExecutable
|
|
203
|
+
| WriteFile
|
|
204
|
+
| MergeConfig
|
|
205
|
+
| RegisterMcpServer
|
|
206
|
+
| InstallService
|
|
207
|
+
| ActivateService
|
|
208
|
+
| DeactivateService
|
|
209
|
+
| RemoveService
|
|
210
|
+
| EnrollMachine
|
|
211
|
+
| VerifyHarness
|
|
212
|
+
| VerifyRuntime
|
|
213
|
+
| LaunchSession
|
|
214
|
+
|
|
215
|
+
export type ActionKind = Action["kind"]
|
|
216
|
+
|
|
217
|
+
const COMMON = ["id", "kind", "escalation", "summary"] as const
|
|
218
|
+
|
|
219
|
+
/** Every field each kind accepts (all required unless listed in ACTION_OPTIONAL). */
|
|
220
|
+
export const ACTION_FIELDS: Readonly<Record<ActionKind, readonly string[]>> = Object.freeze({
|
|
221
|
+
"ensure-directory": [...COMMON, "path", "mode"],
|
|
222
|
+
"install-artifact": [...COMMON, "artifact", "version", "target", "sha256", "path"],
|
|
223
|
+
"install-harness": [...COMMON, "harness", "version", "target", "source", "size", "sha256", "evidence", "path", "owner", "rollback"],
|
|
224
|
+
"select-harness-version": [...COMMON, "harness", "version", "path", "target"],
|
|
225
|
+
"link-executable": [...COMMON, "path", "target"],
|
|
226
|
+
"write-file": [...COMMON, "path", "asset", "sha256", "mode", "onConflict"],
|
|
227
|
+
"merge-config": [...COMMON, "path", "format", "owner", "precondition", "set", "preserveUnknown"],
|
|
228
|
+
"register-mcp-server": [...COMMON, "harness", "path", "name", "precondition"],
|
|
229
|
+
"install-service": [...COMMON, "service", "manager", "path", "asset", "sha256"],
|
|
230
|
+
"activate-service": [...COMMON, "service", "manager", "confirm"],
|
|
231
|
+
"deactivate-service": [...COMMON, "service", "manager"],
|
|
232
|
+
"remove-service": [...COMMON, "service", "manager", "path", "sha256"],
|
|
233
|
+
"enroll-machine": [...COMMON, "server", "machineName"],
|
|
234
|
+
"verify-harness": [...COMMON, "harness", "probes"],
|
|
235
|
+
"verify-runtime": [...COMMON, "runtime"],
|
|
236
|
+
"launch-session": [...COMMON, "harness", "workspace", "maxDurationS"],
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
/** Optional fields per kind. */
|
|
240
|
+
export const ACTION_OPTIONAL: Readonly<Partial<Record<ActionKind, readonly string[]>>> = Object.freeze({ "install-harness": ["previous"], "select-harness-version": ["previous"] })
|
|
241
|
+
|
|
242
|
+
export const ACTION_KINDS = Object.keys(ACTION_FIELDS) as ActionKind[]
|
|
243
|
+
|
|
244
|
+
const ACTION_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
245
|
+
const ASSET_ID = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/
|
|
246
|
+
const MACHINE_NAME = /^[A-Za-z0-9._-]{1,64}$/
|
|
247
|
+
const MCP_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
248
|
+
const JSON_POINTER = /^(?:\/(?:[^~/]|~[01])*)+$/
|
|
249
|
+
const SERVICE_ROOT: Record<ServiceManagerID, string> = { launchd: "launch-agents", "systemd-user": "systemd-user" }
|
|
250
|
+
const EXEC = new Set<string>(EXEC_KEYS)
|
|
251
|
+
|
|
252
|
+
/** JSON pointers of every key named like EXEC_KEYS inside `value`. */
|
|
253
|
+
export function findExecKeys(value: unknown, base = ""): string[] {
|
|
254
|
+
const found: string[] = []
|
|
255
|
+
const walk = (v: unknown, path: string) => {
|
|
256
|
+
if (Array.isArray(v)) v.forEach((item, i) => walk(item, pointer(path, i)))
|
|
257
|
+
else if (isRecord(v))
|
|
258
|
+
for (const [key, item] of Object.entries(v)) {
|
|
259
|
+
const p = pointer(path, key)
|
|
260
|
+
if (EXEC.has(key.toLowerCase())) found.push(p)
|
|
261
|
+
walk(item, p)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
walk(value, base)
|
|
265
|
+
return found
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const mode = (c: Checker, path: string, v: unknown) => int(c, path, v, 0, 0o7777)
|
|
269
|
+
const sameRef = (v: unknown, ref: PathRef) => isRecord(v) && v.root === ref.root && v.rel === ref.rel
|
|
270
|
+
|
|
271
|
+
function checkPrevious(c: Checker, path: string, v: unknown) {
|
|
272
|
+
if (v === undefined) return
|
|
273
|
+
const prev = shape(c, path, v, ["version", "sha256"])
|
|
274
|
+
if (!prev) return
|
|
275
|
+
semver(c, pointer(path, "version"), prev.version)
|
|
276
|
+
digest(c, pointer(path, "sha256"), prev.sha256)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function checkServiceUnitPath(c: Checker, at: (key: string) => string, r: Record<string, unknown>) {
|
|
280
|
+
const p = r.path as PathRef | undefined
|
|
281
|
+
const root = typeof r.manager === "string" ? SERVICE_ROOT[r.manager as ServiceManagerID] : undefined
|
|
282
|
+
if (root && p && root !== p.root) c.add(at("path"), "inconsistent", `${String(r.manager)} units go in the ${root} root`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function checkPrecondition(c: Checker, path: string, v: unknown) {
|
|
286
|
+
const r = shape(c, path, v, ["exists"], ["sha256"])
|
|
287
|
+
if (!r) return
|
|
288
|
+
if (bool(c, pointer(path, "exists"), r.exists)) {
|
|
289
|
+
if (r.exists && r.sha256 === undefined) c.add(pointer(path, "sha256"), "missing_field", "an existing file is pinned by its digest")
|
|
290
|
+
if (!r.exists && r.sha256 !== undefined) c.add(pointer(path, "sha256"), "inconsistent", "a missing file has no digest")
|
|
291
|
+
}
|
|
292
|
+
if (r.sha256 !== undefined) digest(c, pointer(path, "sha256"), r.sha256)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function jsonValue(c: Checker, path: string, v: unknown, depth = 0): void {
|
|
296
|
+
if (depth > 32) return c.add(path, "invalid_value", "too deeply nested")
|
|
297
|
+
if (v === null || typeof v === "boolean" || typeof v === "string") return
|
|
298
|
+
if (typeof v === "number") return void (Number.isFinite(v) || c.add(path, "invalid_value", "not a finite number"))
|
|
299
|
+
if (Array.isArray(v)) return v.forEach((item, i) => jsonValue(c, pointer(path, i), item, depth + 1))
|
|
300
|
+
if (isRecord(v)) return Object.entries(v).forEach(([k, item]) => jsonValue(c, pointer(path, k), item, depth + 1))
|
|
301
|
+
c.add(path, "invalid_value", "not JSON")
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A list of config sets: JSON pointers (never to a credential-named key) and JSON values. */
|
|
305
|
+
export function checkConfigSets(c: Checker, path: string, v: unknown) {
|
|
306
|
+
if (Array.isArray(v) && v.length === 0) c.add(path, "invalid_value", "a merge sets at least one key")
|
|
307
|
+
list(c, path, v, 64, (p, item) => {
|
|
308
|
+
const s = shape(c, p, item, ["pointer", "value"])
|
|
309
|
+
if (!s) return
|
|
310
|
+
if (text(c, pointer(p, "pointer"), s.pointer, 512, JSON_POINTER) && s.pointer.split("/").some((seg) => isSecretKey(seg)))
|
|
311
|
+
c.add(pointer(p, "pointer"), "secret", "credentials are never written by a config merge")
|
|
312
|
+
jsonValue(c, pointer(p, "value"), s.value)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Path-bearing fields of an action (for escalation and ownership checks). */
|
|
317
|
+
export function actionPaths(action: Action): PathRef[] {
|
|
318
|
+
const refs: PathRef[] = []
|
|
319
|
+
if ("path" in action) refs.push(action.path)
|
|
320
|
+
if (action.kind === "link-executable" || action.kind === "select-harness-version") refs.push(action.target)
|
|
321
|
+
if (action.kind === "launch-session") refs.push(action.workspace)
|
|
322
|
+
return refs
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function checkAction(c: Checker, path: string, value: unknown): void {
|
|
326
|
+
for (const p of findExecKeys(value, path)) c.add(p, "exec_field", "actions never carry commands")
|
|
327
|
+
if (!isRecord(value)) return c.add(path, "invalid_value", "expected an action object")
|
|
328
|
+
const kind = value.kind
|
|
329
|
+
if (!oneOf(c, pointer(path, "kind"), kind, ACTION_KINDS)) {
|
|
330
|
+
if (kind === undefined) c.add(pointer(path, "kind"), "missing_field", "missing kind")
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const r = shape(c, path, value, ACTION_FIELDS[kind], ACTION_OPTIONAL[kind])!
|
|
334
|
+
text(c, pointer(path, "id"), r.id, 64, ACTION_ID)
|
|
335
|
+
oneOf(c, pointer(path, "escalation"), r.escalation, ["user", "elevated"] as const)
|
|
336
|
+
text(c, pointer(path, "summary"), r.summary)
|
|
337
|
+
const at = (key: string) => pointer(path, key)
|
|
338
|
+
const refs: unknown[] = []
|
|
339
|
+
const ref = (key: string) => {
|
|
340
|
+
if (r[key] === undefined) return
|
|
341
|
+
checkPathRef(c, at(key), r[key])
|
|
342
|
+
refs.push(r[key])
|
|
343
|
+
}
|
|
344
|
+
switch (kind) {
|
|
345
|
+
case "ensure-directory":
|
|
346
|
+
ref("path")
|
|
347
|
+
mode(c, at("mode"), r.mode)
|
|
348
|
+
break
|
|
349
|
+
case "install-artifact":
|
|
350
|
+
if (r.artifact !== "hubctl") c.add(at("artifact"), "invalid_value", "only the hubctl artifact is installed by the bootstrap")
|
|
351
|
+
semver(c, at("version"), r.version)
|
|
352
|
+
if (typeof r.target !== "string" || !supportedTarget(r.target)) c.add(at("target"), "unsupported_platform", "not a supported target")
|
|
353
|
+
digest(c, at("sha256"), r.sha256)
|
|
354
|
+
ref("path")
|
|
355
|
+
break
|
|
356
|
+
case "install-harness": {
|
|
357
|
+
const harnessOK = oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
|
|
358
|
+
semver(c, at("version"), r.version)
|
|
359
|
+
if (typeof r.target !== "string" || !supportedTarget(r.target)) c.add(at("target"), "unsupported_platform", "not a supported target")
|
|
360
|
+
const src = shape(c, at("source"), r.source, ["manifest", "url", "file"])
|
|
361
|
+
if (src) {
|
|
362
|
+
semver(c, pointer(at("source"), "manifest"), src.manifest)
|
|
363
|
+
httpsURL(c, pointer(at("source"), "url"), src.url)
|
|
364
|
+
if (typeof src.file !== "string" || !FILE_NAME.test(src.file)) c.add(pointer(at("source"), "file"), "invalid_path", "expected a plain file name")
|
|
365
|
+
}
|
|
366
|
+
int(c, at("size"), r.size, 1, 2 ** 40)
|
|
367
|
+
digest(c, at("sha256"), r.sha256)
|
|
368
|
+
if (harnessOK) subset(c, at("evidence"), r.evidence, HARNESS_EVIDENCE[r.harness as HarnessID])
|
|
369
|
+
ref("path")
|
|
370
|
+
if (r.owner !== "hub") c.add(at("owner"), "invalid_value", "harness installs are owned by hub (recorded in the journal)")
|
|
371
|
+
if (r.rollback !== "restore-previous") c.add(at("rollback"), "invalid_value", "harness installs roll back by restoring the previous version")
|
|
372
|
+
checkPrevious(c, at("previous"), r.previous)
|
|
373
|
+
if (harnessOK && typeof r.version === "string" && r.path !== undefined && !sameRef(r.path, harnessVersionRef(r.harness as HarnessID, r.version)))
|
|
374
|
+
c.add(at("path"), "inconsistent", `a harness version installs into its own directory data:${harnessVersionRef(r.harness as HarnessID, r.version).rel}`)
|
|
375
|
+
break
|
|
376
|
+
}
|
|
377
|
+
case "select-harness-version": {
|
|
378
|
+
const harnessOK = oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
|
|
379
|
+
const versionOK = semver(c, at("version"), r.version)
|
|
380
|
+
ref("path")
|
|
381
|
+
ref("target")
|
|
382
|
+
checkPrevious(c, at("previous"), r.previous)
|
|
383
|
+
if (harnessOK && r.path !== undefined && !sameRef(r.path, harnessCurrentRef(r.harness as HarnessID)))
|
|
384
|
+
c.add(at("path"), "inconsistent", `the current version is the link data:${harnessCurrentRef(r.harness as HarnessID).rel}`)
|
|
385
|
+
if (harnessOK && versionOK && r.target !== undefined && !sameRef(r.target, harnessVersionRef(r.harness as HarnessID, r.version as string)))
|
|
386
|
+
c.add(at("target"), "inconsistent", `the link points at the version directory data:${harnessVersionRef(r.harness as HarnessID, r.version as string).rel}`)
|
|
387
|
+
break
|
|
388
|
+
}
|
|
389
|
+
case "link-executable":
|
|
390
|
+
ref("path")
|
|
391
|
+
ref("target")
|
|
392
|
+
break
|
|
393
|
+
case "write-file":
|
|
394
|
+
ref("path")
|
|
395
|
+
text(c, at("asset"), r.asset, 128, ASSET_ID)
|
|
396
|
+
digest(c, at("sha256"), r.sha256)
|
|
397
|
+
mode(c, at("mode"), r.mode)
|
|
398
|
+
if (r.onConflict !== "refuse") c.add(at("onConflict"), "invalid_value", "conflicting files are always refused, never overwritten")
|
|
399
|
+
break
|
|
400
|
+
case "merge-config":
|
|
401
|
+
ref("path")
|
|
402
|
+
oneOf(c, at("format"), r.format, ["json", "jsonc", "toml"] as const)
|
|
403
|
+
oneOf(c, at("owner"), r.owner, CONFIG_OWNERS)
|
|
404
|
+
checkPrecondition(c, at("precondition"), r.precondition)
|
|
405
|
+
if (r.preserveUnknown !== true) c.add(at("preserveUnknown"), "invalid_value", "merges are always lossless")
|
|
406
|
+
checkConfigSets(c, at("set"), r.set)
|
|
407
|
+
break
|
|
408
|
+
case "register-mcp-server":
|
|
409
|
+
oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
|
|
410
|
+
ref("path")
|
|
411
|
+
text(c, at("name"), r.name, 32, MCP_NAME)
|
|
412
|
+
checkPrecondition(c, at("precondition"), r.precondition)
|
|
413
|
+
break
|
|
414
|
+
case "install-service": {
|
|
415
|
+
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
416
|
+
oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
|
|
417
|
+
ref("path")
|
|
418
|
+
digest(c, at("sha256"), r.sha256)
|
|
419
|
+
const asset = typeof r.asset === "string" ? EMBEDDED_ASSETS[r.asset] : undefined
|
|
420
|
+
if (!asset) c.add(at("asset"), "invalid_value", "expected an embedded service template")
|
|
421
|
+
else if (asset.service !== r.service || asset.manager !== r.manager) c.add(at("asset"), "inconsistent", "the template is for another service or manager")
|
|
422
|
+
checkServiceUnitPath(c, at, r)
|
|
423
|
+
break
|
|
424
|
+
}
|
|
425
|
+
case "deactivate-service":
|
|
426
|
+
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
427
|
+
oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
|
|
428
|
+
break
|
|
429
|
+
case "remove-service":
|
|
430
|
+
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
431
|
+
oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
|
|
432
|
+
ref("path")
|
|
433
|
+
digest(c, at("sha256"), r.sha256)
|
|
434
|
+
checkServiceUnitPath(c, at, r)
|
|
435
|
+
break
|
|
436
|
+
case "activate-service":
|
|
437
|
+
oneOf(c, at("service"), r.service, SERVICE_IDS)
|
|
438
|
+
oneOf(c, at("manager"), r.manager, ["launchd", "systemd-user"] as const)
|
|
439
|
+
if (r.confirm !== "explicit") c.add(at("confirm"), "invalid_value", "services are only activated on explicit confirmation")
|
|
440
|
+
break
|
|
441
|
+
case "enroll-machine":
|
|
442
|
+
hubURL(c, at("server"), r.server)
|
|
443
|
+
text(c, at("machineName"), r.machineName, 64, MACHINE_NAME)
|
|
444
|
+
break
|
|
445
|
+
case "verify-harness":
|
|
446
|
+
oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
|
|
447
|
+
if (Array.isArray(r.probes) && r.probes.length === 0) c.add(at("probes"), "invalid_value", "verification runs at least one real probe")
|
|
448
|
+
subset(c, at("probes"), r.probes, REAL_PROBES)
|
|
449
|
+
break
|
|
450
|
+
case "verify-runtime":
|
|
451
|
+
oneOf(c, at("runtime"), r.runtime, RUNTIME_IDS)
|
|
452
|
+
break
|
|
453
|
+
case "launch-session":
|
|
454
|
+
oneOf(c, at("harness"), r.harness, HARNESS_ORDER)
|
|
455
|
+
ref("workspace")
|
|
456
|
+
int(c, at("maxDurationS"), r.maxDurationS, 1, 86_400)
|
|
457
|
+
break
|
|
458
|
+
}
|
|
459
|
+
// Escalation is explicit and exact: elevated iff the action touches an elevated root.
|
|
460
|
+
const touchesElevated = refs.some((p) => isRecord(p) && (ELEVATED_ROOTS as readonly unknown[]).includes(p.root))
|
|
461
|
+
if (r.escalation === "user" && touchesElevated) c.add(at("escalation"), "inconsistent", "writing a system location needs escalation: elevated")
|
|
462
|
+
if (r.escalation === "elevated" && !touchesElevated) c.add(at("escalation"), "inconsistent", "only actions on system locations are elevated")
|
|
463
|
+
for (const p of findSecrets(value, path)) c.add(p, "secret", "actions never carry credentials")
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function validateAction(value: unknown): Result<Action> {
|
|
467
|
+
const c = new Checker()
|
|
468
|
+
checkAction(c, "", value)
|
|
469
|
+
return c.result(value)
|
|
470
|
+
}
|