@openparachute/hub 0.6.3 → 0.6.4-rc.10
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/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-isolation boundary guard for destructive service-manager verbs (hub#535).
|
|
3
|
+
*
|
|
4
|
+
* THE OUTAGE (2026-06-03): a hub test running on a LIVE operator machine reached
|
|
5
|
+
* the production default Runner — `Bun.spawnSync(["launchctl", "bootout", …])` —
|
|
6
|
+
* with the real hub label, and `launchctl bootout computer.parachute.hub`'d the
|
|
7
|
+
* running `computer.parachute.hub` launchd daemon, taking hub + vault + scribe
|
|
8
|
+
* down under the operator's feet. Every daemon-op helper (`removeManagedUnit`,
|
|
9
|
+
* `installManagedUnit`, `stopHubUnit`/`restartHubUnit`, `disableStaleModuleUnits`,
|
|
10
|
+
* `teardownHubUnit`) shells launchctl through an injectable `deps.run([...])`
|
|
11
|
+
* whose PRODUCTION DEFAULT is a real spawn. A test that forgets to inject a fake
|
|
12
|
+
* `run` (or whose fake gets removed in a refactor) silently falls back to that
|
|
13
|
+
* real spawn → it drives the operator's actual service manager.
|
|
14
|
+
*
|
|
15
|
+
* THE GUARD: when running under a test runner (`NODE_ENV === "test"`, which Bun
|
|
16
|
+
* sets automatically for `bun test`), the production default Runner REFUSES the
|
|
17
|
+
* destructive launchd verbs — `bootout`, `bootstrap`, `load`, `kickstart` (and
|
|
18
|
+
* their systemd analogues `enable`/`disable`/`start`/`stop`/`restart` against a
|
|
19
|
+
* real systemctl) — and THROWS loudly instead of spawning. A test is thereby
|
|
20
|
+
* FORCED to inject a fake `run`; it can never reach the operator's live daemon by
|
|
21
|
+
* omission. Read-only verbs (`launchctl print`, `systemctl is-active`/
|
|
22
|
+
* `is-enabled`, `journalctl`, `loginctl`, `which`-style probes) are left alone —
|
|
23
|
+
* they're harmless and some tests intentionally exercise the default deps for
|
|
24
|
+
* them.
|
|
25
|
+
*
|
|
26
|
+
* PRODUCTION BEHAVIOR IS IDENTICAL: outside a test runner (`NODE_ENV !== "test"`)
|
|
27
|
+
* the guard is a no-op and the spawn proceeds exactly as before. There is also an
|
|
28
|
+
* explicit escape hatch — `PARACHUTE_ALLOW_REAL_LAUNCHCTL=1` — for the rare
|
|
29
|
+
* deliberate integration test that genuinely wants to drive a real (sandboxed)
|
|
30
|
+
* manager; it must opt IN, loudly, rather than reaching the daemon by accident.
|
|
31
|
+
*
|
|
32
|
+
* This is layer (a) of the hub#535 fix (the durable boundary guard). Layer (b) is
|
|
33
|
+
* the targeted fake-injection in the offending tests; layer (c) is the regression
|
|
34
|
+
* test that asserts the default deps throw here rather than spawn.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Destructive launchd subcommands that mutate / tear down a loaded unit. */
|
|
38
|
+
const DESTRUCTIVE_LAUNCHCTL_VERBS = new Set([
|
|
39
|
+
"bootout",
|
|
40
|
+
"bootstrap",
|
|
41
|
+
"load",
|
|
42
|
+
"unload",
|
|
43
|
+
"kickstart",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Destructive systemd subcommands that mutate a unit's run/enable state. `enable`
|
|
48
|
+
* / `disable` may carry `--now` (which also starts/stops); `start`/`stop`/
|
|
49
|
+
* `restart` mutate run-state directly. Read-only `is-active` / `is-enabled` /
|
|
50
|
+
* `show` / `status` / `cat` are NOT here — tests use the default deps for those.
|
|
51
|
+
*/
|
|
52
|
+
const DESTRUCTIVE_SYSTEMCTL_VERBS = new Set([
|
|
53
|
+
"start",
|
|
54
|
+
"stop",
|
|
55
|
+
"restart",
|
|
56
|
+
"reload",
|
|
57
|
+
"enable",
|
|
58
|
+
"disable",
|
|
59
|
+
"daemon-reload",
|
|
60
|
+
"mask",
|
|
61
|
+
"unmask",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** True when we're executing under a test runner. Bun sets NODE_ENV=test for `bun test`. */
|
|
65
|
+
function underTestRunner(): boolean {
|
|
66
|
+
return process.env.NODE_ENV === "test";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True when an operator has explicitly opted in to real service-manager calls under test. */
|
|
70
|
+
function realCallsExplicitlyAllowed(): boolean {
|
|
71
|
+
const v = process.env.PARACHUTE_ALLOW_REAL_LAUNCHCTL;
|
|
72
|
+
return v === "1" || v === "true";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Find the first meaningful subcommand token after the tool name, skipping
|
|
77
|
+
* scope/option flags (`--user`, `-k`, `--now`, etc.) so we classify the VERB,
|
|
78
|
+
* not a flag. Returns undefined when there's nothing past the flags.
|
|
79
|
+
*/
|
|
80
|
+
function firstSubcommand(rest: readonly string[]): string | undefined {
|
|
81
|
+
for (const tok of rest) {
|
|
82
|
+
if (tok.startsWith("-")) continue;
|
|
83
|
+
return tok;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decide whether a command (as the argv the default Runner is about to spawn) is
|
|
90
|
+
* a DESTRUCTIVE service-manager mutation that must be blocked under a test runner.
|
|
91
|
+
*
|
|
92
|
+
* - `launchctl <verb> …` where verb ∈ {bootout, bootstrap, load, unload, kickstart}
|
|
93
|
+
* - `systemctl [--user] <verb> …` where verb ∈ {start, stop, restart, reload,
|
|
94
|
+
* enable, disable, daemon-reload, mask, unmask}
|
|
95
|
+
*
|
|
96
|
+
* The tool name is matched on the basename so an absolute path (`/bin/launchctl`)
|
|
97
|
+
* is classified too — though in this codebase every invocation is bare (the PATH
|
|
98
|
+
* shim's safety relies on that), this keeps the guard correct regardless.
|
|
99
|
+
*/
|
|
100
|
+
export function isDestructiveServiceManagerCommand(cmd: readonly string[]): boolean {
|
|
101
|
+
if (cmd.length === 0) return false;
|
|
102
|
+
const tool = (cmd[0] ?? "").split("/").pop() ?? "";
|
|
103
|
+
const rest = cmd.slice(1);
|
|
104
|
+
const verb = firstSubcommand(rest);
|
|
105
|
+
if (verb === undefined) return false;
|
|
106
|
+
if (tool === "launchctl") return DESTRUCTIVE_LAUNCHCTL_VERBS.has(verb);
|
|
107
|
+
if (tool === "systemctl") return DESTRUCTIVE_SYSTEMCTL_VERBS.has(verb);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The boundary guard the production default Runner calls before spawning. When
|
|
113
|
+
* running under a test runner and NOT explicitly opted in, a destructive
|
|
114
|
+
* service-manager command THROWS — forcing the test to inject a fake `run`
|
|
115
|
+
* instead of driving the operator's live daemon. A no-op everywhere else
|
|
116
|
+
* (production, or a non-destructive/read-only command, or explicit opt-in).
|
|
117
|
+
*
|
|
118
|
+
* The thrown error names the exact command and tells the author how to fix it,
|
|
119
|
+
* so a regressed test fails with an actionable message rather than a silent
|
|
120
|
+
* daemon teardown.
|
|
121
|
+
*/
|
|
122
|
+
export function guardServiceManagerCommand(cmd: readonly string[]): void {
|
|
123
|
+
if (!underTestRunner()) return; // production — spawn proceeds unchanged.
|
|
124
|
+
if (realCallsExplicitlyAllowed()) return; // deliberate integration test opted in.
|
|
125
|
+
if (!isDestructiveServiceManagerCommand(cmd)) return; // read-only / unrelated — fine.
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[launchctl-guard] Refusing to run a destructive service-manager command under a test runner: \`${cmd.join(
|
|
128
|
+
" ",
|
|
129
|
+
)}\`. The default (production) Runner shells out to the REAL launchctl/systemctl — on a live machine this would tear down the operator's running daemon (this is the hub#535 outage class). Inject a fake \`run\` into this code path's deps so the test never touches the real service manager. (If you GENUINELY need a real, sandboxed manager call in this test, set PARACHUTE_ALLOW_REAL_LAUNCHCTL=1 to opt in.)`,
|
|
130
|
+
);
|
|
131
|
+
}
|
package/src/managed-unit.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { guardServiceManagerCommand } from "./launchctl-guard.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Platform-agnostic "managed unit" machinery — the reusable launchd/systemd
|
|
@@ -86,6 +87,12 @@ export const defaultManagedUnitDeps: ManagedUnitDeps = {
|
|
|
86
87
|
userName: () => process.env.USER ?? process.env.LOGNAME ?? process.env.USERNAME ?? "",
|
|
87
88
|
which: (binary) => Bun.which(binary),
|
|
88
89
|
run: (cmd) => {
|
|
90
|
+
// hub#535 boundary guard: under a test runner, REFUSE destructive
|
|
91
|
+
// launchctl/systemctl verbs (bootout/bootstrap/load/kickstart, etc.) instead
|
|
92
|
+
// of spawning the REAL service manager — a test that forgot to inject a fake
|
|
93
|
+
// `run` must not be able to tear down the operator's live daemon by omission.
|
|
94
|
+
// No-op in production (NODE_ENV !== "test"); see src/launchctl-guard.ts.
|
|
95
|
+
guardServiceManagerCommand(cmd);
|
|
89
96
|
const proc = Bun.spawnSync([...cmd], { env: process.env });
|
|
90
97
|
return {
|
|
91
98
|
code: proc.exitCode ?? 1,
|
|
@@ -659,9 +666,12 @@ export interface BuildHubManagedUnitOpts {
|
|
|
659
666
|
* the bind host to `0.0.0.0` (serve.ts), which is correct for the container
|
|
660
667
|
* shape (the platform's HTTP forwarder must reach the hub) but WRONG for a
|
|
661
668
|
* self-hosted box — bare `serve` would expose the admin/OAuth surfaces on every
|
|
662
|
-
* interface, contradicting the pre-supervisor detached behavior
|
|
663
|
-
*
|
|
664
|
-
*
|
|
669
|
+
* interface, contradicting the pre-supervisor detached behavior. Forcing a
|
|
670
|
+
* loopback bind also keeps `layerOf` (hub-server.ts) precise: post-#526 it
|
|
671
|
+
* derives trust from the peer address (`server.requestIP`), failing closed to
|
|
672
|
+
* "public" for any non-loopback peer — so a 127.0.0.1-only listener is what
|
|
673
|
+
* lets a header-absent on-box CLI caller classify as "loopback". The container
|
|
674
|
+
* path never calls this builder (the Dockerfile pins
|
|
665
675
|
* `ENV PARACHUTE_BIND_HOST=0.0.0.0` + runs `serve` directly), so it stays
|
|
666
676
|
* 0.0.0.0. The canonical expose path is unaffected: cloudflared/tailscale dial
|
|
667
677
|
* `127.0.0.1:<port>` from the same host, and the hub's own proxy targets
|
package/src/migrate-offer.ts
CHANGED
|
@@ -22,11 +22,15 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { existsSync } from "node:fs";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
// `migrate-cutover.ts` is imported as a TYPE only (erased at compile time, no
|
|
26
|
+
// module evaluation) and loaded LAZILY at the call site below. This breaks the
|
|
27
|
+
// transitive eager-load chain `cli.ts` → `lifecycle.ts` → `migrate-offer.ts` →
|
|
28
|
+
// `migrate-cutover.ts`: a broken `migrate-cutover` (e.g. the 0.6.2 eval-time
|
|
29
|
+
// ReferenceError) must not crash the start/stop/restart/logs lifecycle commands
|
|
30
|
+
// that pull in this module purely for the §7.5 detect-and-offer machinery. The
|
|
31
|
+
// cutover is only ever evaluated when an operator interactively accepts the
|
|
32
|
+
// offer, so deferring its import to that moment keeps the whole chain robust.
|
|
33
|
+
import type { CutoverOpts, CutoverResult } from "./commands/migrate-cutover.ts";
|
|
30
34
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
31
35
|
import { HUB_SVC } from "./hub-control.ts";
|
|
32
36
|
import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "./hub-unit.ts";
|
|
@@ -149,7 +153,6 @@ export async function offerMigrateToSupervised(
|
|
|
149
153
|
const unitInstalledFn = opts.isHubUnitInstalled ?? isHubUnitInstalled;
|
|
150
154
|
const hubUnitDeps = opts.hubUnitDeps ?? defaultHubUnitDeps;
|
|
151
155
|
const hasPriorDetached = opts.hasPriorDetached ?? hasPriorDetachedInstall;
|
|
152
|
-
const cutover = opts.cutover ?? cutoverToSupervised;
|
|
153
156
|
const prompt = opts.prompt ?? defaultOfferPrompt;
|
|
154
157
|
const isTty = opts.isTty ?? Boolean(process.stdin.isTTY);
|
|
155
158
|
|
|
@@ -179,6 +182,12 @@ export async function offerMigrateToSupervised(
|
|
|
179
182
|
return { outcome: "declined" };
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
// Resolve the cutover lazily: only import `migrate-cutover.ts` now that the
|
|
186
|
+
// operator has accepted, so the offer's mere availability never drags the
|
|
187
|
+
// cutover module into the lifecycle-command load graph (see the `import type`
|
|
188
|
+
// note at the top). Tests inject `opts.cutover` and never hit the import.
|
|
189
|
+
const cutover =
|
|
190
|
+
opts.cutover ?? (await import("./commands/migrate-cutover.ts")).cutoverToSupervised;
|
|
182
191
|
const result = await cutover({ configDir, manifestPath, log });
|
|
183
192
|
for (const line of result.messages) log(line);
|
|
184
193
|
const ok = result.outcome === "migrated" || result.outcome === "already-migrated";
|
package/src/module-ops-client.ts
CHANGED
|
@@ -183,7 +183,7 @@ export async function driveModuleOp(
|
|
|
183
183
|
const body = await parseJsonSafe(res);
|
|
184
184
|
|
|
185
185
|
if (res.status < 200 || res.status >= 300) {
|
|
186
|
-
const { error, error_description } = asErrorBody(body);
|
|
186
|
+
const { error, error_description } = asErrorBody(body, res.status);
|
|
187
187
|
throw new ModuleOpHttpError(res.status, error, error_description);
|
|
188
188
|
}
|
|
189
189
|
|
|
@@ -231,7 +231,7 @@ async function pollOperation(
|
|
|
231
231
|
});
|
|
232
232
|
const body = await parseJsonSafe(res);
|
|
233
233
|
if (res.status < 200 || res.status >= 300) {
|
|
234
|
-
const { error, error_description } = asErrorBody(body);
|
|
234
|
+
const { error, error_description } = asErrorBody(body, res.status);
|
|
235
235
|
throw new ModuleOpHttpError(res.status, error, error_description);
|
|
236
236
|
}
|
|
237
237
|
const status = extractOpStatus(body);
|
|
@@ -288,7 +288,7 @@ export async function fetchModuleLogs(
|
|
|
288
288
|
});
|
|
289
289
|
const body = await parseJsonSafe(res);
|
|
290
290
|
if (res.status < 200 || res.status >= 300) {
|
|
291
|
-
const { error, error_description } = asErrorBody(body);
|
|
291
|
+
const { error, error_description } = asErrorBody(body, res.status);
|
|
292
292
|
throw new ModuleOpHttpError(res.status, error, error_description);
|
|
293
293
|
}
|
|
294
294
|
const b = (body ?? {}) as { lines?: unknown; text?: unknown };
|
|
@@ -330,6 +330,14 @@ export interface ModuleStatesResult {
|
|
|
330
330
|
readonly supervisorAvailable: boolean;
|
|
331
331
|
/** Per-module supervisor snapshots, keyed by short name in array order. */
|
|
332
332
|
readonly modules: ModuleStateSnapshot[];
|
|
333
|
+
/**
|
|
334
|
+
* Run-state for ALL supervised modules — including non-curated ones the
|
|
335
|
+
* `modules` catalog omits (e.g. the `surface` UI host). `status` falls back
|
|
336
|
+
* to this so a running-but-non-curated module reads `active`, not `inactive`
|
|
337
|
+
* (hub#539). The live `fetchModuleStates` always populates it (`[]` against an
|
|
338
|
+
* older hub that predates the field); optional so test stubs may omit it.
|
|
339
|
+
*/
|
|
340
|
+
readonly supervised?: ModuleStateSnapshot[];
|
|
333
341
|
}
|
|
334
342
|
|
|
335
343
|
/**
|
|
@@ -388,25 +396,37 @@ export async function fetchModuleStates(deps: DriveModuleOpDeps): Promise<Module
|
|
|
388
396
|
}
|
|
389
397
|
const body = await parseJsonSafe(res);
|
|
390
398
|
if (res.status < 200 || res.status >= 300) {
|
|
391
|
-
const { error, error_description } = asErrorBody(body);
|
|
399
|
+
const { error, error_description } = asErrorBody(body, res.status);
|
|
392
400
|
throw new ModuleOpHttpError(res.status, error, error_description);
|
|
393
401
|
}
|
|
394
|
-
const b = (body ?? {}) as {
|
|
402
|
+
const b = (body ?? {}) as {
|
|
403
|
+
modules?: unknown;
|
|
404
|
+
supervised?: unknown;
|
|
405
|
+
supervisor_available?: unknown;
|
|
406
|
+
};
|
|
395
407
|
const supervisorAvailable = b.supervisor_available === true;
|
|
396
|
-
const modules
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
:
|
|
409
|
-
|
|
408
|
+
const modules = parseSnapshots(b.modules);
|
|
409
|
+
// `supervised` (hub#539) carries run-state for ALL supervised modules,
|
|
410
|
+
// including non-curated ones absent from `modules` (e.g. the surface host).
|
|
411
|
+
// Older hubs without the field yield []; consumers tolerate that.
|
|
412
|
+
const supervised = parseSnapshots(b.supervised);
|
|
413
|
+
return { supervisorAvailable, modules, supervised };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Parse a `modules`/`supervised` array into validated snapshots (hub#539). */
|
|
417
|
+
function parseSnapshots(raw: unknown): ModuleStateSnapshot[] {
|
|
418
|
+
if (!Array.isArray(raw)) return [];
|
|
419
|
+
return raw
|
|
420
|
+
.filter((m): m is Record<string, unknown> => !!m && typeof m === "object")
|
|
421
|
+
.map((m) => ({
|
|
422
|
+
short: typeof m.short === "string" ? m.short : "",
|
|
423
|
+
installed: m.installed === true,
|
|
424
|
+
installed_version: typeof m.installed_version === "string" ? m.installed_version : null,
|
|
425
|
+
supervisor_status: typeof m.supervisor_status === "string" ? m.supervisor_status : null,
|
|
426
|
+
pid: typeof m.pid === "number" ? m.pid : null,
|
|
427
|
+
supervisor_start_error:
|
|
428
|
+
m.supervisor_start_error !== undefined ? (m.supervisor_start_error ?? null) : null,
|
|
429
|
+
}));
|
|
410
430
|
}
|
|
411
431
|
|
|
412
432
|
async function parseJsonSafe(res: Response): Promise<unknown> {
|
|
@@ -417,15 +437,20 @@ async function parseJsonSafe(res: Response): Promise<unknown> {
|
|
|
417
437
|
}
|
|
418
438
|
}
|
|
419
439
|
|
|
420
|
-
function asErrorBody(body: unknown): { error: string; error_description: string } {
|
|
440
|
+
function asErrorBody(body: unknown, status: number): { error: string; error_description: string } {
|
|
441
|
+
// A bare/unparseable error response used to collapse to "request failed",
|
|
442
|
+
// which gave the operator nothing to act on (hub#536 — a spawn-throw
|
|
443
|
+
// escaping a handler produced exactly this). Carry the HTTP status so even
|
|
444
|
+
// the worst case names the failure class.
|
|
445
|
+
const fallback = `hub returned HTTP ${status} with no error detail`;
|
|
421
446
|
if (body && typeof body === "object") {
|
|
422
447
|
const b = body as Record<string, unknown>;
|
|
423
448
|
const error = typeof b.error === "string" ? b.error : "error";
|
|
424
449
|
const error_description =
|
|
425
|
-
typeof b.error_description === "string" ? b.error_description :
|
|
450
|
+
typeof b.error_description === "string" ? b.error_description : fallback;
|
|
426
451
|
return { error, error_description };
|
|
427
452
|
}
|
|
428
|
-
return { error: "error", error_description:
|
|
453
|
+
return { error: "error", error_description: fallback };
|
|
429
454
|
}
|
|
430
455
|
|
|
431
456
|
function extractOperationId(body: unknown): string | undefined {
|
package/src/scope-attenuation.ts
CHANGED
|
@@ -83,3 +83,22 @@ export function hasMintingAuthority(bearerScopes: string[]): boolean {
|
|
|
83
83
|
bearerScopes.some((s) => isVaultAdminScope(s))
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is this an *operator* bearer — i.e. does it hold host-level minting authority
|
|
89
|
+
* (`parachute:host:auth` or `parachute:host:admin`)?
|
|
90
|
+
*
|
|
91
|
+
* The distinction matters for the `subject` override on the mint endpoint: an
|
|
92
|
+
* operator bearer is the on-box host administrator (or a service account it
|
|
93
|
+
* delegated to), so it may legitimately mint a token whose `sub` names a
|
|
94
|
+
* service account other than its own — the documented service-account override.
|
|
95
|
+
* A merely vault-scoped bearer (`vault:<N>:admin` only) has NO host authority,
|
|
96
|
+
* so letting it set an arbitrary `sub` is audit-attribution forgery: it could
|
|
97
|
+
* mint a token that the registry + revocation list attribute to a foreign
|
|
98
|
+
* subject. Non-operator bearers are therefore pinned to their own `sub`.
|
|
99
|
+
*/
|
|
100
|
+
export function isOperatorBearer(bearerScopes: string[]): boolean {
|
|
101
|
+
return (
|
|
102
|
+
bearerScopes.includes(MINT_HOST_AUTH_SCOPE) || bearerScopes.includes(MINT_HOST_ADMIN_SCOPE)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -270,7 +270,15 @@ export function isNonRequestableScope(scope: string): boolean {
|
|
|
270
270
|
// consent path and through `canGrant` rule 1, capped to the consenting
|
|
271
271
|
// user's held authority at the `issueAuthCodeRedirect` choke-point. Only
|
|
272
272
|
// the host-level operator scopes stay non-requestable here.
|
|
273
|
-
|
|
273
|
+
//
|
|
274
|
+
// Item C — case-insensitive guard. The membership check is exact-string,
|
|
275
|
+
// but Parachute scope tokens are canonically lowercase. A casing variant
|
|
276
|
+
// like `PARACHUTE:HOST:AUTH` would slip past a raw `Set.has` and be treated
|
|
277
|
+
// as requestable — minting a junk-but-harmless token today (consumers are
|
|
278
|
+
// case-sensitive + anchored, so it grants nothing), but a backstop against
|
|
279
|
+
// a future consumer that case-folds. Normalize to lowercase before the
|
|
280
|
+
// membership check so every casing of a host-level scope is refused.
|
|
281
|
+
return NON_REQUESTABLE_SCOPES.has(scope.toLowerCase());
|
|
274
282
|
}
|
|
275
283
|
|
|
276
284
|
/** True when the scope can appear in a public `/oauth/authorize` request. */
|
package/src/service-spec.ts
CHANGED
|
@@ -39,9 +39,14 @@ import type { ServiceEntry } from "./services-manifest.ts";
|
|
|
39
39
|
*
|
|
40
40
|
* Operator override is now "edit services.json" (or `parachute config`
|
|
41
41
|
* once that lands), not "edit `.env`". Pre-#206 stale `.env` PORT lines on
|
|
42
|
-
* existing operator machines stay where they are — harmless
|
|
43
|
-
*
|
|
44
|
-
* PORT env tier — and future installs no longer touch them.
|
|
42
|
+
* existing operator machines stay where they are — harmless to the module's
|
|
43
|
+
* own resolvePort ladder, which reads services.json before falling through to
|
|
44
|
+
* the bare PORT env tier — and future installs no longer touch them. (Caveat,
|
|
45
|
+
* hub#537: the supervisor's spawn-env builder used to echo a stale `.env` PORT
|
|
46
|
+
* into the injected `PORT`, and its readiness probe trusted that — so a `.env`
|
|
47
|
+
* PORT disagreeing with services.json yielded a false `started_but_unbound`.
|
|
48
|
+
* Fixed by making `entry.port` authoritative: `buildModuleSpawnRequest` drops a
|
|
49
|
+
* `.env` PORT.)
|
|
45
50
|
*
|
|
46
51
|
* **No speculative reservations.** Future first-party modules claim a slot
|
|
47
52
|
* the moment they ship, not before — pre-reservation for unbuilt things has
|
|
@@ -177,7 +182,15 @@ const NOTES_SERVE_PATH = fileURLToPath(new URL("./notes-serve.ts", import.meta.u
|
|
|
177
182
|
* telegraphs the state: the row is a stopgap, and the service's first boot
|
|
178
183
|
* will overwrite with its own authoritative write.
|
|
179
184
|
*/
|
|
180
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Version string stamped on a CLI-seeded services.json entry — the
|
|
187
|
+
* "module installed, but its own daemon hasn't booted and registered a real
|
|
188
|
+
* row yet" placeholder. A real service boot overwrites this with the package's
|
|
189
|
+
* actual version (see "Services own their write side of services.json" in
|
|
190
|
+
* CLAUDE.md). Exported so consumers (e.g. well-known generation, hub#577) can
|
|
191
|
+
* tell a placeholder entry apart from a live one.
|
|
192
|
+
*/
|
|
193
|
+
export const SEED_VERSION = "0.0.0-linked";
|
|
181
194
|
|
|
182
195
|
function pathBasedUrl(entry: ServiceEntry): string {
|
|
183
196
|
const first = entry.paths[0] ?? "";
|
package/src/setup-wizard.ts
CHANGED
|
@@ -399,6 +399,18 @@ export interface SetupWizardDeps {
|
|
|
399
399
|
userId: string,
|
|
400
400
|
opts: MintOperatorTokenOpts & { dir?: string },
|
|
401
401
|
) => Promise<IssueOperatorTokenResult>;
|
|
402
|
+
/**
|
|
403
|
+
* Whether the in-flight request arrived over loopback (peer `127.0.0.1` /
|
|
404
|
+
* `::1`). Set by `hub-server.ts` from `layerOf(req, peerAddr)`. hub#576: a
|
|
405
|
+
* loopback caller already proves on-box access (it's the operator's own
|
|
406
|
+
* shell — `parachute init` driving the CLI wizard), so the GET `/admin/setup`
|
|
407
|
+
* JSON probe reveals the actual bootstrap token VALUE to it, not just the
|
|
408
|
+
* `requireBootstrapToken` boolean. Public / tailnet callers (any browser
|
|
409
|
+
* that found the FQDN) get only the boolean and must paste the token the
|
|
410
|
+
* operator copied from their terminal. Absent (undefined) is treated as
|
|
411
|
+
* NON-loopback — fail closed, never leak the token to a header-less caller.
|
|
412
|
+
*/
|
|
413
|
+
requestIsLoopback?: boolean;
|
|
402
414
|
}
|
|
403
415
|
|
|
404
416
|
/**
|
|
@@ -1601,8 +1613,17 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1601
1613
|
// the HTML rendering branches means the CLI gets the answer it needs
|
|
1602
1614
|
// without the wizard having to render a 30KB HTML page per poll.
|
|
1603
1615
|
if (wantsJson) {
|
|
1604
|
-
const
|
|
1605
|
-
const
|
|
1616
|
+
const activeToken = getBootstrapToken();
|
|
1617
|
+
const requireToken = activeToken !== undefined;
|
|
1618
|
+
const envelope: {
|
|
1619
|
+
step: typeof state.step;
|
|
1620
|
+
hasAdmin: boolean;
|
|
1621
|
+
hasVault: boolean;
|
|
1622
|
+
hasExposeMode: boolean;
|
|
1623
|
+
requireBootstrapToken: boolean;
|
|
1624
|
+
csrfToken: string;
|
|
1625
|
+
bootstrapToken?: string;
|
|
1626
|
+
} = {
|
|
1606
1627
|
step: state.step,
|
|
1607
1628
|
hasAdmin: state.hasAdmin,
|
|
1608
1629
|
hasVault: state.hasVault,
|
|
@@ -1610,6 +1631,17 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1610
1631
|
requireBootstrapToken: requireToken,
|
|
1611
1632
|
csrfToken: csrf.token,
|
|
1612
1633
|
};
|
|
1634
|
+
// hub#576: hand the actual token to a LOOPBACK caller only. The on-box
|
|
1635
|
+
// operator (`parachute init` → CLI wizard, or a curl from their own shell)
|
|
1636
|
+
// already proves box access by reaching loopback — same trust level as
|
|
1637
|
+
// reading the token off the startup banner in the hub log. This lets init
|
|
1638
|
+
// surface the token in the operator's terminal and feed it to the CLI
|
|
1639
|
+
// wizard transparently, instead of making them dig through `parachute logs
|
|
1640
|
+
// hub`. A public / tailnet browser never gets the value — it stays gated on
|
|
1641
|
+
// the operator pasting what they copied from their terminal.
|
|
1642
|
+
if (requireToken && deps.requestIsLoopback === true && activeToken !== undefined) {
|
|
1643
|
+
envelope.bootstrapToken = activeToken;
|
|
1644
|
+
}
|
|
1613
1645
|
const jsonHeaders: Record<string, string> = {
|
|
1614
1646
|
"content-type": "application/json; charset=utf-8",
|
|
1615
1647
|
"cache-control": "no-store",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATH enrichment for spawned modules + the hub's own boot env.
|
|
3
|
+
*
|
|
4
|
+
* The hub-as-supervisor unit bakes a hardcoded base PATH (see
|
|
5
|
+
* `enrichedUnitPath` below), and `Bun.spawn` defaults to an empty env, so
|
|
6
|
+
* supervised children only ever see the PATH the unit handed the hub. On a
|
|
7
|
+
* launchd-managed Mac that PATH omits the two dirs operator tools actually live
|
|
8
|
+
* in — `$HOME/.local/bin` (scribe's `parakeet-mlx`) and the Homebrew bin
|
|
9
|
+
* (`ffmpeg`, `/opt/homebrew/bin` on Apple Silicon). The result: scribe's
|
|
10
|
+
* `Bun.which("ffmpeg")` / `Bun.which("parakeet-mlx")` probes come up empty and
|
|
11
|
+
* transcription is dead on canonical installs.
|
|
12
|
+
*
|
|
13
|
+
* `enrichedPath` is the shared fix. It takes a base env (defaults to
|
|
14
|
+
* `process.env`), keeps whatever PATH was inherited, and APPENDS the operator-
|
|
15
|
+
* tool dirs that exist on disk and aren't already present. Inherited PATH wins
|
|
16
|
+
* (append, not prepend) so an operator's explicit PATH ordering is never
|
|
17
|
+
* reordered out from under them. `PARACHUTE_EXTRA_PATH` (colon-joined) is
|
|
18
|
+
* PREPENDED so an operator can intentionally shadow a system binary.
|
|
19
|
+
*
|
|
20
|
+
* Two consumers:
|
|
21
|
+
* 1. The spawn side (`buildModuleSpawnRequest` in `commands/serve-boot.ts` +
|
|
22
|
+
* `spawnSupervised` in `api-modules-ops.ts`) injects `enrichedPath()` into
|
|
23
|
+
* every supervised child's env. This is the PRIMARY fix — it self-heals
|
|
24
|
+
* every existing install at the next hub restart, no re-init needed.
|
|
25
|
+
* 2. The hub's own `serve` startup enriches `process.env.PATH` so the hub's
|
|
26
|
+
* own `Bun.which` probes (cloudflared / tailscale detection, etc.) also
|
|
27
|
+
* see brew + `.local`.
|
|
28
|
+
*
|
|
29
|
+
* The unit generator (`enrichedUnitPath`, used by `hub-unit.ts` +
|
|
30
|
+
* `commands/migrate-cutover.ts`) bakes the same dirs into the launchd/systemd
|
|
31
|
+
* unit PATH as belt-and-suspenders for fresh installs.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
|
|
37
|
+
/** Injectable seams so tests don't touch the real fs / os / platform. */
|
|
38
|
+
export interface EnrichedPathDeps {
|
|
39
|
+
/** Operator home dir. */
|
|
40
|
+
homeDir: () => string;
|
|
41
|
+
/** True when `path` exists on disk. */
|
|
42
|
+
exists: (path: string) => boolean;
|
|
43
|
+
/** `process.platform` value (e.g. "darwin", "linux"). */
|
|
44
|
+
platform: NodeJS.Platform;
|
|
45
|
+
/** `process.arch` value (e.g. "arm64", "x64"). */
|
|
46
|
+
arch: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const defaultEnrichedPathDeps: EnrichedPathDeps = {
|
|
50
|
+
homeDir: () => homedir(),
|
|
51
|
+
exists: (path) => existsSync(path),
|
|
52
|
+
platform: process.platform,
|
|
53
|
+
arch: process.arch,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The Homebrew bin dir for the current platform/arch, or null when there
|
|
58
|
+
* isn't a canonical one (non-darwin — Linux brew is opt-in + non-canonical, so
|
|
59
|
+
* we only contribute `$HOME/.local/bin` there).
|
|
60
|
+
*
|
|
61
|
+
* Apple Silicon brew installs under `/opt/homebrew`; Intel macOS brew under
|
|
62
|
+
* `/usr/local`.
|
|
63
|
+
*/
|
|
64
|
+
function brewBinDir(platform: NodeJS.Platform, arch: string): string | null {
|
|
65
|
+
if (platform !== "darwin") return null;
|
|
66
|
+
return arch === "arm64" ? "/opt/homebrew/bin" : "/usr/local/bin";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Operator-tool dirs to APPEND (in this order): `$HOME/.local/bin` (pipx /
|
|
71
|
+
* `pip install --user` — scribe's `parakeet-mlx`), the platform brew bin
|
|
72
|
+
* (`ffmpeg`), and `$HOME/.bun/bin` (bun-linked binaries on a cold boot). Order
|
|
73
|
+
* is deterministic and stable. Pure of fs — the runtime enrichment filters by
|
|
74
|
+
* existence, the unit generator includes them unconditionally.
|
|
75
|
+
*/
|
|
76
|
+
export function operatorToolDirs(home: string, platform: NodeJS.Platform, arch: string): string[] {
|
|
77
|
+
const dirs = [`${home}/.local/bin`];
|
|
78
|
+
const brew = brewBinDir(platform, arch);
|
|
79
|
+
if (brew) dirs.push(brew);
|
|
80
|
+
dirs.push(`${home}/.bun/bin`);
|
|
81
|
+
return dirs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function candidateDirs(deps: EnrichedPathDeps): string[] {
|
|
85
|
+
return operatorToolDirs(deps.homeDir(), deps.platform, deps.arch);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function dedupe(entries: string[]): string[] {
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
const out: string[] = [];
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (seen.has(entry)) continue;
|
|
93
|
+
seen.add(entry);
|
|
94
|
+
out.push(entry);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The PATH baked into the launchd/systemd hub unit at generation time.
|
|
101
|
+
*
|
|
102
|
+
* `${bunInstall}/bin` first (so supervised children resolve a bun-linked binary
|
|
103
|
+
* on cold boot, R20), then the usual system dirs, then the operator-tool dirs
|
|
104
|
+
* (`$HOME/.local/bin`, the platform brew bin) so a managed hub — and the modules
|
|
105
|
+
* it spawns — can find scribe's `parakeet-mlx` + `ffmpeg`. `PARACHUTE_EXTRA_PATH`
|
|
106
|
+
* (if set at generation time) is PREPENDED for intentional operator shadowing.
|
|
107
|
+
* Deduped end-to-end.
|
|
108
|
+
*
|
|
109
|
+
* Unlike `enrichedPath`, the unit-side dirs are included UNCONDITIONALLY (no
|
|
110
|
+
* existence check): the unit is generated once at install/migrate time but
|
|
111
|
+
* brew/tools may be installed afterward, and a non-existent PATH entry is simply
|
|
112
|
+
* skipped by the OS — so baking them in is the robust choice for fresh installs.
|
|
113
|
+
*
|
|
114
|
+
* Shared by `hub-unit.ts` (init bringup) + `commands/migrate-cutover.ts`
|
|
115
|
+
* (`--to-supervised` cutover) so the two unit-generation paths can't drift.
|
|
116
|
+
*/
|
|
117
|
+
export function enrichedUnitPath(
|
|
118
|
+
bunInstall: string,
|
|
119
|
+
home: string,
|
|
120
|
+
platform: NodeJS.Platform = process.platform,
|
|
121
|
+
arch: string = process.arch,
|
|
122
|
+
extraPath: string | undefined = process.env.PARACHUTE_EXTRA_PATH,
|
|
123
|
+
): string {
|
|
124
|
+
const extra = (extraPath ?? "").split(":").filter((e) => e.length > 0);
|
|
125
|
+
const base = [`${bunInstall}/bin`, "/usr/local/bin", "/usr/bin", "/bin"];
|
|
126
|
+
return dedupe([...extra, ...base, ...operatorToolDirs(home, platform, arch)]).join(":");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a PATH that enriches `env.PATH` with operator-tool dirs.
|
|
131
|
+
*
|
|
132
|
+
* Ordering: `PARACHUTE_EXTRA_PATH` (prepended) : inherited PATH : appended
|
|
133
|
+
* operator-tool dirs that exist and aren't already present. Deduped end-to-end
|
|
134
|
+
* (first occurrence wins, so an inherited entry is never reordered).
|
|
135
|
+
*/
|
|
136
|
+
export function enrichedPath(
|
|
137
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
138
|
+
deps: EnrichedPathDeps = defaultEnrichedPathDeps,
|
|
139
|
+
): string {
|
|
140
|
+
const inherited = (env.PATH ?? "").split(":").filter((e) => e.length > 0);
|
|
141
|
+
const extra = (env.PARACHUTE_EXTRA_PATH ?? "").split(":").filter((e) => e.length > 0);
|
|
142
|
+
const appended = candidateDirs(deps).filter((d) => deps.exists(d));
|
|
143
|
+
|
|
144
|
+
// PARACHUTE_EXTRA_PATH first (intentional operator shadow), then the
|
|
145
|
+
// inherited PATH (inherited wins over our appended defaults), then our
|
|
146
|
+
// appended operator-tool dirs.
|
|
147
|
+
return dedupe([...extra, ...inherited, ...appended]).join(":");
|
|
148
|
+
}
|