@openparachute/hub 0.6.3-rc.2 → 0.6.3-rc.4

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.
@@ -648,10 +648,25 @@ export interface BuildHubManagedUnitOpts {
648
648
  *
649
649
  * Resolves the absolute `bun` path via the `which` seam (launchd/systemd don't
650
650
  * search `$PATH` — mirrors how the connector resolves cloudflared). The env
651
- * carries `PARACHUTE_HOME` / `PORT` / `PATH` / `BUN_INSTALL` — and INTENTIONALLY
652
- * OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale origin here would re-create the
653
- * iss-mismatch class; `resolveStartupIssuer` derives it and start-hub self-heals
654
- * the operator token + vault `.env` to the current origin (design §4.1 comment).
651
+ * carries `PARACHUTE_BIND_HOST` / `PARACHUTE_HOME` / `PORT` / `PATH` /
652
+ * `BUN_INSTALL` — and INTENTIONALLY OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale
653
+ * origin here would re-create the iss-mismatch class; `resolveStartupIssuer`
654
+ * derives it and start-hub self-heals the operator token + vault `.env` to the
655
+ * current origin (design §4.1 comment).
656
+ *
657
+ * BIND HOST — `PARACHUTE_BIND_HOST=127.0.0.1` is forced here so every
658
+ * self-hosted supervised hub binds loopback. `parachute serve` itself defaults
659
+ * the bind host to `0.0.0.0` (serve.ts), which is correct for the container
660
+ * shape (the platform's HTTP forwarder must reach the hub) but WRONG for a
661
+ * self-hosted box — bare `serve` would expose the admin/OAuth surfaces on every
662
+ * interface, contradicting the pre-supervisor detached behavior and the trust
663
+ * model `layerOf` (hub-server.ts) assumes (header-absent ⇒ "loopback"). The
664
+ * container path never calls this builder (the Dockerfile pins
665
+ * `ENV PARACHUTE_BIND_HOST=0.0.0.0` + runs `serve` directly), so it stays
666
+ * 0.0.0.0. The canonical expose path is unaffected: cloudflared/tailscale dial
667
+ * `127.0.0.1:<port>` from the same host, and the hub's own proxy targets
668
+ * `http://127.0.0.1:<port>` (hub-server.ts). An operator who genuinely wants
669
+ * all-interfaces can override the generated unit; the default is loopback.
655
670
  *
656
671
  * NOT called by any command in this PR (additive — Phase 3 wires it into `init`).
657
672
  */
@@ -676,6 +691,11 @@ export function buildHubManagedUnit(opts: BuildHubManagedUnitOpts): ManagedUnit
676
691
  systemdDescription: "Parachute hub (serve + supervisor)",
677
692
  execStart: [bunPath, opts.cliPath, "serve"],
678
693
  env: {
694
+ // Force loopback on every self-hosted supervised hub. serve.ts defaults
695
+ // to 0.0.0.0 (container-first); a self-hosted box must NOT bare-serve
696
+ // all-interfaces. Container path bypasses this builder (Dockerfile pins
697
+ // its own 0.0.0.0). See the docstring for the full trust-model rationale.
698
+ PARACHUTE_BIND_HOST: "127.0.0.1",
679
699
  // PARACHUTE_HOME captured at install time (design §4.2) — NOT the default.
680
700
  PARACHUTE_HOME: opts.parachuteHome,
681
701
  PORT: String(port),
@@ -30,7 +30,12 @@ import type { Database } from "bun:sqlite";
30
30
  import { promises as fs } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { configDir } from "./config.ts";
33
+ import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
34
+ import { validateHostAdminToken } from "./host-admin-token-validation.ts";
35
+ import { readHubPort } from "./hub-control.ts";
36
+ import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
33
37
  import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
38
+ import { buildHubBoundOrigins } from "./origin-check.ts";
34
39
  import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
35
40
 
36
41
  export const OPERATOR_TOKEN_FILENAME = "operator.token";
@@ -279,7 +284,14 @@ export class OperatorTokenExpiredError extends Error {
279
284
  }
280
285
 
281
286
  export interface UseOperatorTokenOpts {
282
- /** Hub origin used as `iss` validator. Required. */
287
+ /**
288
+ * Hub origin the caller resolved. Required. As of hub#516 this is a SEED of
289
+ * the known-issuer SET the token's `iss` is validated against
290
+ * ({@link buildKnownIssuersForOperatorToken}), not the sole `iss` validator —
291
+ * so a caller that resolves loopback (`status`) still accepts a public-`iss`
292
+ * operator token from `expose-state.json`, and vice versa. It also remains
293
+ * the `iss` stamped on an auto-rotated re-mint.
294
+ */
283
295
  issuer: string;
284
296
  /** configDir override (where operator.token lives). Defaults to `configDir()`. */
285
297
  configDir?: string;
@@ -345,9 +357,78 @@ export interface UsedOperatorToken {
345
357
  status: RotationStatus;
346
358
  }
347
359
 
360
+ /**
361
+ * Compose the Fly.io default public origin from `FLY_APP_NAME`, mirroring the
362
+ * server-side `flyDefaultOrigin` (hub-server.ts) so the client-side
363
+ * known-issuer set matches the origin the hub stamps tokens with on Fly. Kept
364
+ * local (a one-liner) rather than imported to avoid pulling hub-server.ts /
365
+ * serve.ts into the CLI auth path. Fly slugs never contain `/`; anything with
366
+ * one is spoofed or malformed.
367
+ */
368
+ function flyDefaultOrigin(env: NodeJS.ProcessEnv): string | undefined {
369
+ const app = env.FLY_APP_NAME;
370
+ if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
371
+ return `https://${app}.fly.dev`;
372
+ }
373
+
374
+ /**
375
+ * Assemble the SET of origins this hub legitimately answers on, from on-disk
376
+ * client state — the client-side mirror of hub-server.ts's per-request
377
+ * `buildHubBoundOrigins` call (hub#516). The operator token's `iss` is
378
+ * validated against this set rather than a single issuer, so a token whose
379
+ * `iss` is the hub's PUBLIC origin (stamped after `parachute expose`) is
380
+ * accepted even when the CLI command resolved a loopback `issuer` (the
381
+ * `status` case) — and vice versa.
382
+ *
383
+ * The set is:
384
+ * - `seedIssuer` — the issuer the caller resolved (loopback for `status`,
385
+ * `r.hubOrigin` / public for lifecycle). Kept as a seed so callers that
386
+ * pass a value still contribute it; never the sole gate.
387
+ * - loopback aliases — `http://127.0.0.1:<port>` AND `http://localhost:<port>`
388
+ * for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`),
389
+ * matching the hub's own loopback alias set (`buildHubBoundOrigins`).
390
+ * - the expose-state public origin — `expose-state.json`'s `hubOrigin`, the
391
+ * public URL the hub stamps on tokens once exposed.
392
+ * - the platform/env public origin — `PARACHUTE_HUB_ORIGIN` ∪
393
+ * `RENDER_EXTERNAL_URL` ∪ the composed Fly default — for container deploys
394
+ * where the public origin comes from the platform, not expose-state.
395
+ *
396
+ * Provenance is NOT established here: `validateHostAdminToken` runs the JWKS
397
+ * signature check FIRST and unconditionally. This set is the belt-and-suspenders
398
+ * `iss` allowlist layered on top — a foreign `iss` (not loopback / expose /
399
+ * env) is rejected, and an empty set fails closed.
400
+ */
401
+ export function buildKnownIssuersForOperatorToken(
402
+ configDirOverride: string | undefined,
403
+ seedIssuer: string,
404
+ ): readonly string[] {
405
+ const dir = configDirOverride ?? configDir();
406
+ const loopbackPort = readHubPort(dir) ?? HUB_UNIT_DEFAULT_PORT;
407
+ let exposeHubOrigin: string | undefined;
408
+ try {
409
+ exposeHubOrigin = readExposeState(join(dir, "expose-state.json"))?.hubOrigin;
410
+ } catch {
411
+ // A malformed expose-state.json must never lock the operator out of the
412
+ // CLI — the seed issuer + loopback aliases already cover legitimate
413
+ // loopback access; treat it as "no public origin known."
414
+ exposeHubOrigin = undefined;
415
+ }
416
+ const platformOrigin =
417
+ process.env.PARACHUTE_HUB_ORIGIN ??
418
+ process.env.RENDER_EXTERNAL_URL ??
419
+ flyDefaultOrigin(process.env);
420
+ return buildHubBoundOrigins({
421
+ issuer: seedIssuer,
422
+ loopbackPort,
423
+ ...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
424
+ ...(platformOrigin !== undefined ? { platformOrigin } : {}),
425
+ });
426
+ }
427
+
348
428
  /**
349
429
  * The canonical "use the operator token in a CLI flow" helper. Reads
350
- * `~/.parachute/operator.token`, validates against `db` + `issuer`, and:
430
+ * `~/.parachute/operator.token`, validates against `db` + the hub's
431
+ * known-issuer SET, and:
351
432
  *
352
433
  * - If the token has fully expired: throws `OperatorTokenExpiredError`
353
434
  * with an actionable message. Does NOT auto-rotate from a dead token —
@@ -397,9 +478,19 @@ export async function useOperatorTokenWithAutoRotate(
397
478
  if (!token) return null;
398
479
  const now = opts.now ?? (() => new Date());
399
480
 
400
- // Validation failures (signature mismatch, wrong issuer, missing kid,
401
- // expired-by-jose) bubble out for the caller to render the right message.
402
- const validated = await validateAccessToken(db, token, opts.issuer);
481
+ // Validate against the hub's KNOWN-ISSUER SET, not a single `opts.issuer`
482
+ // (hub#516). The operator token is the hub's OWN self-issued credential; its
483
+ // `iss` is the hub's loopback origin before `expose` and its PUBLIC origin
484
+ // after. Callers resolve `opts.issuer` inconsistently — `status` hardcodes
485
+ // loopback, lifecycle uses `r.hubOrigin` (public when exposed) — so a single
486
+ // per-issuer check rejected the public-`iss` operator token on `status` even
487
+ // though `restart` worked. `validateHostAdminToken` (the #517 helper) gates
488
+ // on the JWKS SIGNATURE first+unconditionally (provenance), then accepts the
489
+ // `iss` if it's ANY origin the hub legitimately answers on. Validation
490
+ // failures (signature mismatch, missing kid, expired-by-jose, revoked, or an
491
+ // `iss` foreign to the whole set) bubble out for the caller to render.
492
+ const knownIssuers = buildKnownIssuersForOperatorToken(opts.configDir, opts.issuer);
493
+ const validated = await validateHostAdminToken(db, token, knownIssuers);
403
494
  const { payload } = validated;
404
495
 
405
496
  const exp = typeof payload.exp === "number" ? payload.exp : 0;
@@ -74,6 +74,16 @@ export function buildHubBoundOrigins(opts: {
74
74
  // Malformed URL — skip.
75
75
  }
76
76
  };
77
+ // `opts.issuer` is the PER-REQUEST issuer, which `resolveIssuer` derives
78
+ // from the request's Host header (hub-server.ts, "closes #245"). Including a
79
+ // Host-derived value in the known-issuers set is SAFE — it is NOT a
80
+ // forged-`iss` bypass. Token provenance is signature-gated: the JWKS verify
81
+ // in `validateAccessToken` / `validateHostAdminToken` runs UNCONDITIONALLY
82
+ // FIRST, before `iss` is ever checked against this set. So a token whose
83
+ // `iss` matches an attacker-injected Host (and thus lands in this set) but
84
+ // which isn't signed by THIS hub's key is still rejected at the signature
85
+ // step. The known-issuers membership check is belt-and-suspenders layered on
86
+ // top of the signature gate, never a substitute for it.
77
87
  add(opts.issuer);
78
88
  add(opts.exposeHubOrigin);
79
89
  add(opts.platformOrigin);
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Detect + disable STALE per-module autostart units during the
3
+ * detached→supervised cutover + teardown (hub#522, design
4
+ * `parachute.computer/design/2026-06-01-hub-as-supervisor-unification.md` §7.2).
5
+ *
6
+ * THE BUG (validated hands-on on friends.parachute.computer): after a box
7
+ * migrates to the supervised model, a leftover STANDALONE per-module autostart
8
+ * unit from the pre-supervisor era — a systemd user unit `parachute-vault.service`
9
+ * with `Restart=always`, or a launchd `computer.parachute.vault` LaunchAgent with
10
+ * `KeepAlive` — keeps RESPAWNING an unsupervised vault that binds port 1940. The
11
+ * supervised hub's own vault child then can't bind → EADDRINUSE crash-loop →
12
+ * `crashed`, giving up. Killing the squatting PROCESS is whack-a-mole: the unit's
13
+ * KeepAlive / Restart=always resurrects it within seconds, serving OLD code.
14
+ *
15
+ * THE FIX (the load-bearing half of #522): the cutover must DISABLE THE UNIT, not
16
+ * just kill the process. Disabling deregisters the keep-alive intent so the
17
+ * module stays down and the supervised hub owns the port. The complementary half
18
+ * — the supervisor reclaiming its own port on EADDRINUSE at every start — is a
19
+ * separate follow-on; THIS module is the unit-disable that stops the respawn at
20
+ * the source.
21
+ *
22
+ * SCOPE + OWNERSHIP SAFETY (the hard constraint): we ONLY ever disable a unit
23
+ * whose name EXACTLY matches `parachute-<short>.service` (systemd) or
24
+ * `computer.parachute.<short>` (launchd) for a KNOWN module short
25
+ * (`knownServices()` — vault / scribe / runner / surface / notes / channel). We
26
+ * NEVER disable an arbitrary or unrecognized unit — an unknown unit is invisible
27
+ * to this sweep by construction (we look up exact names, never enumerate-and-
28
+ * match-loosely). On top of that we EXPLICITLY exclude the units the supervised
29
+ * model legitimately owns:
30
+ * - the hub unit (`computer.parachute.hub` / `parachute-hub.service`), and
31
+ * - the cloudflared connector (`computer.parachute.cloudflared.*` /
32
+ * `parachute-cloudflared-*`, owned by `expose off --cloudflare`).
33
+ * The skip-list reuses the canonical name constants (HUB_* + the cloudflared
34
+ * prefixes) so it can't drift.
35
+ *
36
+ * BEHAVIOR per platform (reuses the `ManagedUnitDeps` seam — `which` / `run`):
37
+ * - systemd (Linux): for each known short, query the USER unit
38
+ * `systemctl --user is-enabled parachute-<short>.service`. If it reads
39
+ * enabled (`enabled` / `enabled-runtime` / `static` / `alias`/`indirect`-ish)
40
+ * → `systemctl --user disable --now parachute-<short>.service`. A SYSTEM-level
41
+ * unit of the same name (detected via `systemctl is-enabled` without --user)
42
+ * is NOT touched (migrate has no sudo) — we WARN with the exact manual
43
+ * `sudo systemctl disable --now …` command instead.
44
+ * - launchd (Mac): for each known short, `launchctl print
45
+ * gui/<uid>/computer.parachute.<short>`; if the label is loaded → `launchctl
46
+ * bootout gui/<uid>/computer.parachute.<short>`.
47
+ *
48
+ * IDEMPOTENT: a unit that's already disabled / not-enabled / absent is a clean
49
+ * no-op (we never report disabling it). NON-FATAL: a disable that fails (perms,
50
+ * launchctl quirk) WARNS + continues — it never aborts the cutover. EVERYTHING
51
+ * behind the injectable `ManagedUnitDeps` seam so tests never touch real
52
+ * systemctl/launchctl.
53
+ */
54
+
55
+ import {
56
+ CLOUDFLARED_LAUNCHD_LABEL_PREFIX,
57
+ CLOUDFLARED_SYSTEMD_UNIT_PREFIX,
58
+ } from "./cloudflare/connector-service.ts";
59
+ import {
60
+ HUB_LAUNCHD_LABEL,
61
+ HUB_SYSTEMD_UNIT_NAME,
62
+ type ManagedUnitDeps,
63
+ defaultManagedUnitDeps,
64
+ } from "./managed-unit.ts";
65
+ import { knownServices } from "./service-spec.ts";
66
+
67
+ /** systemd unit name for a module short, e.g. `vault` → `parachute-vault.service`. */
68
+ export function moduleSystemdUnitName(short: string): string {
69
+ return `parachute-${short}.service`;
70
+ }
71
+
72
+ /** launchd label for a module short, e.g. `vault` → `computer.parachute.vault`. */
73
+ export function moduleLaunchdLabel(short: string): string {
74
+ return `computer.parachute.${short}`;
75
+ }
76
+
77
+ /**
78
+ * Is this systemd unit name one the supervised model legitimately owns (and the
79
+ * sweep must therefore NEVER disable)? The hub unit + any cloudflared connector
80
+ * unit. Reuses the canonical name constants so the skip can't drift.
81
+ */
82
+ function isProtectedSystemdUnit(unitName: string): boolean {
83
+ return unitName === HUB_SYSTEMD_UNIT_NAME || unitName.startsWith(CLOUDFLARED_SYSTEMD_UNIT_PREFIX);
84
+ }
85
+
86
+ /**
87
+ * Is this launchd label one the supervised model legitimately owns? The hub
88
+ * label + any cloudflared connector label (`computer.parachute.cloudflared.*`).
89
+ */
90
+ function isProtectedLaunchdLabel(label: string): boolean {
91
+ return (
92
+ label === HUB_LAUNCHD_LABEL ||
93
+ label === CLOUDFLARED_LAUNCHD_LABEL_PREFIX ||
94
+ label.startsWith(`${CLOUDFLARED_LAUNCHD_LABEL_PREFIX}.`)
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The module shorts whose stale standalone autostart units the sweep targets.
100
+ * Derived from `knownServices()` (the canonical FIRST_PARTY_FALLBACKS +
101
+ * KNOWN_MODULES list — vault / scribe / runner / surface / notes / channel), so
102
+ * a future module is covered automatically. `hub` is deliberately NOT in that
103
+ * list — the hub unit is the supervised model itself; we never disable it. As a
104
+ * defensive double-check we also drop any short whose derived unit name lands in
105
+ * the protected skip-list (so the sweep can never disable the hub / cloudflared
106
+ * even if a future short collided).
107
+ */
108
+ export function targetModuleShorts(): string[] {
109
+ return knownServices().filter(
110
+ (short) =>
111
+ !isProtectedSystemdUnit(moduleSystemdUnitName(short)) &&
112
+ !isProtectedLaunchdLabel(moduleLaunchdLabel(short)),
113
+ );
114
+ }
115
+
116
+ /**
117
+ * systemd `is-enabled` tokens that mean "this unit will autostart" — i.e. the
118
+ * stale-unit problem we're disabling. `disabled` / `masked` / `not-found` (and a
119
+ * nonzero exit with empty stdout) mean it won't, so they're a no-op.
120
+ *
121
+ * `static` and `indirect` units have no [Install] section / are pulled in by
122
+ * another unit; a standalone leftover `parachute-vault.service` written by the
123
+ * old per-module autostall path always carried `[Install] WantedBy=…` so reads
124
+ * `enabled` — but we treat `static`/`indirect` as "present + active intent" too
125
+ * so an oddly-written leftover still gets cleaned. `linked`/`generated` likewise.
126
+ */
127
+ const SYSTEMD_ENABLED_TOKENS = new Set([
128
+ "enabled",
129
+ "enabled-runtime",
130
+ "static",
131
+ "indirect",
132
+ "linked",
133
+ "linked-runtime",
134
+ "generated",
135
+ "alias",
136
+ ]);
137
+
138
+ /** Outcome of one unit's detect-and-disable attempt. */
139
+ export interface StaleUnitAction {
140
+ /** The module short the unit belongs to. */
141
+ short: string;
142
+ /** "launchd" | "systemd-user" | "systemd-system". */
143
+ kind: "launchd" | "systemd-user" | "systemd-system";
144
+ /** The unit/label name acted on. */
145
+ unit: string;
146
+ /**
147
+ * "disabled" → we disabled it (report it; the operator sees what changed).
148
+ * "warn-system" → a system-level systemd unit we can't disable without sudo;
149
+ * we warn with the manual command. Non-fatal.
150
+ * "failed" → the disable command failed (perms/quirk); we warn + continue.
151
+ */
152
+ result: "disabled" | "warn-system" | "failed";
153
+ /** The exact line(s) the caller should surface (report / warning). */
154
+ messages: string[];
155
+ }
156
+
157
+ export interface DisableStaleModuleUnitsOpts {
158
+ /** Injectable platform deps (defaults to production). */
159
+ deps?: ManagedUnitDeps;
160
+ /** Sink for human-readable report / warning lines. */
161
+ log?: (line: string) => void;
162
+ }
163
+
164
+ export interface DisableStaleModuleUnitsResult {
165
+ /** Every unit we acted on (disabled / warned / failed). Empty = clean no-op. */
166
+ actions: StaleUnitAction[];
167
+ }
168
+
169
+ /**
170
+ * Detect + disable any STALE per-module autostart unit on this platform (#522).
171
+ * Idempotent + non-fatal: already-disabled/absent units are silent no-ops, and a
172
+ * failed disable warns + continues. Returns the list of actions taken; the caller
173
+ * surfaces the messages (the cutover threads them through its own `log`).
174
+ *
175
+ * Dispatch mirrors `managed-unit.ts`: darwin → launchctl, linux → systemctl.
176
+ * Other platforms (no per-module unit possible) → empty no-op.
177
+ */
178
+ export function disableStaleModuleUnits(
179
+ opts: DisableStaleModuleUnitsOpts = {},
180
+ ): DisableStaleModuleUnitsResult {
181
+ const deps = opts.deps ?? defaultManagedUnitDeps;
182
+ const log = opts.log ?? (() => {});
183
+ const actions: StaleUnitAction[] = [];
184
+
185
+ const record = (action: StaleUnitAction): void => {
186
+ actions.push(action);
187
+ for (const m of action.messages) log(m);
188
+ };
189
+
190
+ if (deps.platform === "darwin") {
191
+ if (deps.which("launchctl") === null) return { actions };
192
+ const uid = deps.getuid() ?? 0;
193
+ for (const short of targetModuleShorts()) {
194
+ const label = moduleLaunchdLabel(short);
195
+ // Belt-and-suspenders: never touch a protected (hub / cloudflared) label.
196
+ if (isProtectedLaunchdLabel(label)) continue;
197
+ const action = disableStaleLaunchdUnit(short, label, uid, deps);
198
+ if (action) record(action);
199
+ }
200
+ return { actions };
201
+ }
202
+
203
+ if (deps.platform === "linux") {
204
+ if (deps.which("systemctl") === null) return { actions };
205
+ for (const short of targetModuleShorts()) {
206
+ const unit = moduleSystemdUnitName(short);
207
+ if (isProtectedSystemdUnit(unit)) continue;
208
+ const action = disableStaleSystemdUnit(short, unit, deps);
209
+ if (action) record(action);
210
+ }
211
+ return { actions };
212
+ }
213
+
214
+ // No per-platform manager (container / init-less / Windows) → nothing to do.
215
+ return { actions };
216
+ }
217
+
218
+ /**
219
+ * launchd arm: probe `launchctl print gui/<uid>/<label>`. The label is LOADED
220
+ * (a stale KeepAlive LaunchAgent) when the print succeeds with non-empty output;
221
+ * we then `launchctl bootout` it (unload + stop → KeepAlive can't resurrect it).
222
+ * An unloaded/absent label prints empty/nonzero → clean no-op (returns undefined).
223
+ */
224
+ function disableStaleLaunchdUnit(
225
+ short: string,
226
+ label: string,
227
+ uid: number,
228
+ deps: ManagedUnitDeps,
229
+ ): StaleUnitAction | undefined {
230
+ let printed: { code: number; stdout: string; stderr: string };
231
+ try {
232
+ printed = deps.run(["launchctl", "print", `gui/${uid}/${label}`]);
233
+ } catch {
234
+ // launchctl threw (ENOENT between which() and run, or a quirk) — non-fatal.
235
+ return undefined;
236
+ }
237
+ // Not loaded → nothing to disable. `launchctl print` is nonzero + empty when
238
+ // the label isn't bootstrapped.
239
+ if (printed.stdout.trim().length === 0) return undefined;
240
+
241
+ let booted: { code: number; stdout: string; stderr: string };
242
+ try {
243
+ booted = deps.run(["launchctl", "bootout", `gui/${uid}/${label}`]);
244
+ } catch (err) {
245
+ return {
246
+ short,
247
+ kind: "launchd",
248
+ unit: label,
249
+ result: "failed",
250
+ messages: [
251
+ ` ⚠ Could not disable the stale LaunchAgent ${label} (${err instanceof Error ? err.message : String(err)}).`,
252
+ ` Run it yourself: launchctl bootout gui/${uid}/${label}`,
253
+ ],
254
+ };
255
+ }
256
+ if (booted.code !== 0) {
257
+ const detail = booted.stderr.trim() || booted.stdout.trim() || "unknown error";
258
+ return {
259
+ short,
260
+ kind: "launchd",
261
+ unit: label,
262
+ result: "failed",
263
+ messages: [
264
+ ` ⚠ Could not disable the stale LaunchAgent ${label} (${detail}).`,
265
+ ` Run it yourself: launchctl bootout gui/${uid}/${label}`,
266
+ ],
267
+ };
268
+ }
269
+ return {
270
+ short,
271
+ kind: "launchd",
272
+ unit: label,
273
+ result: "disabled",
274
+ messages: [
275
+ ` ✓ Disabled stale ${label} (it was fighting the supervised hub for ${short}'s port).`,
276
+ ],
277
+ };
278
+ }
279
+
280
+ /**
281
+ * systemd arm: a stale standalone module unit can live at USER scope (the common
282
+ * pre-supervisor leftover, no sudo to write) or SYSTEM scope (rarer). We probe
283
+ * both:
284
+ * - USER (`systemctl --user is-enabled <unit>`): if enabled → `--user disable
285
+ * --now`. This is the path migrate can actually fix.
286
+ * - SYSTEM (`systemctl is-enabled <unit>`): if enabled but USER wasn't → migrate
287
+ * has no sudo, so WARN with the exact `sudo systemctl disable --now …` command
288
+ * (never attempt sudo).
289
+ * An absent/disabled unit at both scopes → clean no-op (returns undefined).
290
+ */
291
+ function disableStaleSystemdUnit(
292
+ short: string,
293
+ unit: string,
294
+ deps: ManagedUnitDeps,
295
+ ): StaleUnitAction | undefined {
296
+ // --- USER scope first (what migrate can actually disable). ---
297
+ if (systemdUnitEnabled(unit, ["--user"], deps)) {
298
+ let res: { code: number; stdout: string; stderr: string };
299
+ try {
300
+ res = deps.run(["systemctl", "--user", "disable", "--now", unit]);
301
+ } catch (err) {
302
+ return {
303
+ short,
304
+ kind: "systemd-user",
305
+ unit,
306
+ result: "failed",
307
+ messages: [
308
+ ` ⚠ Could not disable the stale user unit ${unit} (${err instanceof Error ? err.message : String(err)}).`,
309
+ ` Run it yourself: systemctl --user disable --now ${unit}`,
310
+ ],
311
+ };
312
+ }
313
+ if (res.code !== 0) {
314
+ const detail = res.stderr.trim() || res.stdout.trim() || "unknown error";
315
+ return {
316
+ short,
317
+ kind: "systemd-user",
318
+ unit,
319
+ result: "failed",
320
+ messages: [
321
+ ` ⚠ Could not disable the stale user unit ${unit} (${detail}).`,
322
+ ` Run it yourself: systemctl --user disable --now ${unit}`,
323
+ ],
324
+ };
325
+ }
326
+ return {
327
+ short,
328
+ kind: "systemd-user",
329
+ unit,
330
+ result: "disabled",
331
+ messages: [
332
+ ` ✓ Disabled stale ${unit} (it was fighting the supervised hub for ${short}'s port).`,
333
+ ],
334
+ };
335
+ }
336
+
337
+ // --- SYSTEM scope: detect-only + warn (no sudo in migrate). ---
338
+ if (systemdUnitEnabled(unit, [], deps)) {
339
+ return {
340
+ short,
341
+ kind: "systemd-system",
342
+ unit,
343
+ result: "warn-system",
344
+ messages: [
345
+ ` ⚠ A SYSTEM-level ${unit} is enabled and may fight the supervised hub for ${short}'s port.`,
346
+ " Migrate can't disable a system unit (it needs root). Disable it yourself:",
347
+ ` sudo systemctl disable --now ${unit}`,
348
+ ],
349
+ };
350
+ }
351
+
352
+ return undefined;
353
+ }
354
+
355
+ /**
356
+ * `systemctl [--user] is-enabled <unit>` → true iff the printed token means the
357
+ * unit will autostart (see `SYSTEMD_ENABLED_TOKENS`). `is-enabled` exits nonzero
358
+ * for non-enabled states and prints the token to stdout regardless of exit, so
359
+ * we classify from the stdout token. A throw (ENOENT/quirk) → treated as
360
+ * not-enabled (non-fatal; the sweep continues).
361
+ */
362
+ function systemdUnitEnabled(unit: string, scope: string[], deps: ManagedUnitDeps): boolean {
363
+ let res: { code: number; stdout: string; stderr: string };
364
+ try {
365
+ res = deps.run(["systemctl", ...scope, "is-enabled", unit]);
366
+ } catch {
367
+ return false;
368
+ }
369
+ const token = res.stdout.trim() || res.stderr.trim();
370
+ if (token.length === 0) return false;
371
+ // `is-enabled` can print the token then a hint on a second line; read line 1.
372
+ const first = token.split("\n")[0]?.trim() ?? "";
373
+ return SYSTEMD_ENABLED_TOKENS.has(first);
374
+ }