@openparachute/hub 0.7.4-rc.4 → 0.7.4-rc.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.4",
3
+ "version": "0.7.4-rc.6",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -11,15 +11,8 @@
11
11
  "bin": {
12
12
  "parachute": "src/cli.ts"
13
13
  },
14
- "workspaces": [
15
- "packages/*"
16
- ],
17
- "files": [
18
- "src",
19
- "web/ui/dist",
20
- "README.md",
21
- "LICENSE"
22
- ],
14
+ "workspaces": ["packages/*"],
15
+ "files": ["src", "web/ui/dist", "README.md", "LICENSE"],
23
16
  "repository": {
24
17
  "type": "git",
25
18
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { HOST_ADMIN_SCOPE, type RunResult, handleCreateVault } from "../admin-vaults.ts";
5
+ import {
6
+ HOST_ADMIN_SCOPE,
7
+ type RunResult,
8
+ handleCreateVault,
9
+ listVaultInstanceNames,
10
+ provisionVault,
11
+ } from "../admin-vaults.ts";
6
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
13
  import { signAccessToken } from "../jwt-sign.ts";
8
14
  import { upsertService, writeManifest } from "../services-manifest.ts";
@@ -1304,3 +1310,160 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1304
1310
  }
1305
1311
  });
1306
1312
  });
1313
+
1314
+ // ===========================================================================
1315
+ // #478 — empty-paths vault rows must not resolve to phantom "default"
1316
+ // ===========================================================================
1317
+
1318
+ describe("#478 — empty-paths vault row tolerance", () => {
1319
+ test("findExistingVault: empty-paths vault row does NOT match 'default'", () => {
1320
+ // A vault module registered in services.json with paths:[] is "installed
1321
+ // but no servable vault instance". Hub must skip it — never synthesize a
1322
+ // phantom "default" — so provisionVault can proceed to a real create.
1323
+ const h = makeHarness();
1324
+ try {
1325
+ // Write a services.json with a parachute-vault entry carrying paths:[].
1326
+ writeManifest(
1327
+ {
1328
+ services: [
1329
+ {
1330
+ name: "parachute-vault",
1331
+ port: 1940,
1332
+ paths: [],
1333
+ health: "/health",
1334
+ version: "0.5.0",
1335
+ },
1336
+ ],
1337
+ },
1338
+ h.manifestPath,
1339
+ );
1340
+ // Calling provisionVault("default") internally calls findExistingVault.
1341
+ // We verify the behaviour indirectly via listVaultInstanceNames (exported
1342
+ // for this test) and via provisionVault's created:true path below.
1343
+ const names = listVaultInstanceNames(h.manifestPath);
1344
+ expect(names.has("default")).toBe(false);
1345
+ } finally {
1346
+ h.cleanup();
1347
+ }
1348
+ });
1349
+
1350
+ test("listVaultInstanceNames: empty-paths vault row is omitted from the Set", () => {
1351
+ const h = makeHarness();
1352
+ try {
1353
+ writeManifest(
1354
+ {
1355
+ services: [
1356
+ {
1357
+ name: "parachute-vault",
1358
+ port: 1940,
1359
+ paths: [],
1360
+ health: "/health",
1361
+ version: "0.5.0",
1362
+ },
1363
+ ],
1364
+ },
1365
+ h.manifestPath,
1366
+ );
1367
+ const names = listVaultInstanceNames(h.manifestPath);
1368
+ expect(names.size).toBe(0);
1369
+ } finally {
1370
+ h.cleanup();
1371
+ }
1372
+ });
1373
+
1374
+ test("provisionVault: empty-paths row → created:true (proceeds to orchestrate, not false 'already exists')", async () => {
1375
+ // Core regression test for #478: before the fix, an empty-paths row
1376
+ // resolved to phantom "default" → findExistingVault returned non-null →
1377
+ // provisionVault short-circuited to created:false with "already exists".
1378
+ // After the fix: findExistingVault returns null → orchestrate runs →
1379
+ // created:true.
1380
+ const h = makeHarness();
1381
+ try {
1382
+ const db = openHubDb(hubDbPath(h.dir));
1383
+ try {
1384
+ rotateSigningKey(db);
1385
+ // Seed an empty-paths vault row (what vault's self-register emits at
1386
+ // zero vaults, per the #478 contract).
1387
+ writeManifest(
1388
+ {
1389
+ services: [
1390
+ {
1391
+ name: "parachute-vault",
1392
+ port: 1940,
1393
+ paths: [],
1394
+ health: "/health",
1395
+ version: "0.5.0",
1396
+ },
1397
+ ],
1398
+ },
1399
+ h.manifestPath,
1400
+ );
1401
+
1402
+ const calls: Array<readonly string[]> = [];
1403
+ const runCommand = async (cmd: readonly string[]): Promise<RunResult> => {
1404
+ calls.push(cmd);
1405
+ // Simulate vault CLI writing the real path into services.json after
1406
+ // a successful create. Because vault IS already registered (paths:[]),
1407
+ // orchestrate picks the `parachute-vault create --json` branch and
1408
+ // expects JSON stdout.
1409
+ upsertService(
1410
+ {
1411
+ name: "parachute-vault",
1412
+ port: 1940,
1413
+ paths: ["/vault/default"],
1414
+ health: "/health",
1415
+ version: "0.5.0",
1416
+ },
1417
+ h.manifestPath,
1418
+ );
1419
+ return { exitCode: 0, stdout: vaultCreateJson("default"), stderr: "" };
1420
+ };
1421
+
1422
+ const result = await provisionVault("default", {
1423
+ issuer: ISSUER,
1424
+ manifestPath: h.manifestPath,
1425
+ runCommand,
1426
+ });
1427
+
1428
+ // Must have proceeded to orchestrate and returned created:true.
1429
+ expect(result.ok).toBe(true);
1430
+ if (!result.ok) return; // narrow for TS
1431
+ expect(result.created).toBe(true);
1432
+ // The orchestration command ran (not short-circuited).
1433
+ expect(calls.length).toBeGreaterThan(0);
1434
+ } finally {
1435
+ db.close();
1436
+ }
1437
+ } finally {
1438
+ h.cleanup();
1439
+ }
1440
+ });
1441
+
1442
+ test("listVaultInstanceNames: real paths still enumerate correctly (empty-paths does not break them)", () => {
1443
+ // Sanity: mixing an empty-paths row with a real-paths row — the real
1444
+ // paths are still found, the empty one is still skipped.
1445
+ const h = makeHarness();
1446
+ try {
1447
+ writeManifest(
1448
+ {
1449
+ services: [
1450
+ {
1451
+ name: "parachute-vault",
1452
+ port: 1940,
1453
+ paths: ["/vault/default", "/vault/work"],
1454
+ health: "/health",
1455
+ version: "0.5.0",
1456
+ },
1457
+ ],
1458
+ },
1459
+ h.manifestPath,
1460
+ );
1461
+ const names = listVaultInstanceNames(h.manifestPath);
1462
+ expect(names.has("default")).toBe(true);
1463
+ expect(names.has("work")).toBe(true);
1464
+ expect(names.size).toBe(2);
1465
+ } finally {
1466
+ h.cleanup();
1467
+ }
1468
+ });
1469
+ });
@@ -323,8 +323,15 @@ describe("POST /api/hub/upgrade — redeploy-required short-circuit (§5.3)", ()
323
323
  });
324
324
 
325
325
  describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", () => {
326
- /** Seed the status file with a prior op in the given phase. */
327
- function seedStatus(dir: string, phase: HubUpgradeStatus["phase"], opId = "prior-op"): void {
326
+ /** Seed the status file with a prior op in the given phase. `startedAt`
327
+ * defaults to now (a FRESH in-flight slot); pass an old ISO string to seed a
328
+ * stale / abandoned slot for the #506 TTL tests. */
329
+ function seedStatus(
330
+ dir: string,
331
+ phase: HubUpgradeStatus["phase"],
332
+ opId = "prior-op",
333
+ startedAt: string = new Date().toISOString(),
334
+ ): void {
328
335
  writeHubUpgradeStatus(dir, {
329
336
  operation_id: opId,
330
337
  phase,
@@ -333,7 +340,7 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
333
340
  target_version: "0.6.3-rc.2",
334
341
  channel: "rc",
335
342
  log: [],
336
- started_at: new Date().toISOString(),
343
+ started_at: startedAt,
337
344
  });
338
345
  }
339
346
 
@@ -385,6 +392,55 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
385
392
  expect(res.status).toBe(202);
386
393
  expect(spawned.length).toBe(1);
387
394
  });
395
+
396
+ // #506: a crashed helper leaves an in-flight slot stuck forever — without a
397
+ // TTL it 409-deadlocks every future upgrade. A STALE in-flight slot must be
398
+ // treated as abandoned so the new request proceeds.
399
+ for (const phase of ["pending", "running", "restarting"] as const) {
400
+ test(`#506: STALE in-flight slot (phase=${phase}, started 30m ago) → proceeds, not 409`, async () => {
401
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
402
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
403
+ seedStatus(harness.dir, phase, "crashed-op", thirtyMinAgo);
404
+ const { deps, spawned } = baseDeps(harness);
405
+ const res = await handleHubUpgrade(
406
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
407
+ deps,
408
+ );
409
+ // Abandoned slot freed: a fresh op took over + spawned its helper.
410
+ expect(res.status).toBe(202);
411
+ expect(spawned.length).toBe(1);
412
+ const status = readHubUpgradeStatus(harness.dir);
413
+ expect(status?.operation_id).not.toBe("crashed-op");
414
+ expect(spawned[0]?.operationId).toBe(status?.operation_id);
415
+ });
416
+ }
417
+
418
+ test("#506: FRESH in-flight slot (started just now) → still 409", async () => {
419
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
420
+ // Just-started (well within the 15m TTL) → a real, live upgrade → 409.
421
+ seedStatus(harness.dir, "running", "live-op", new Date().toISOString());
422
+ const { deps, spawned } = baseDeps(harness);
423
+ const res = await handleHubUpgrade(
424
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
425
+ deps,
426
+ );
427
+ expect(res.status).toBe(409);
428
+ expect(spawned.length).toBe(0);
429
+ expect(readHubUpgradeStatus(harness.dir)?.operation_id).toBe("live-op");
430
+ });
431
+
432
+ test("#506: in-flight slot with a malformed started_at → treated as stale, proceeds", async () => {
433
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
434
+ seedStatus(harness.dir, "running", "garbage-op", "not-a-date");
435
+ const { deps, spawned } = baseDeps(harness);
436
+ const res = await handleHubUpgrade(
437
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
438
+ deps,
439
+ );
440
+ // An unparseable timestamp must not deadlock — treat as abandoned.
441
+ expect(res.status).toBe(202);
442
+ expect(spawned.length).toBe(1);
443
+ });
388
444
  });
389
445
 
390
446
  describe("appendHubUpgradeStatus — operation_id guard (stale-helper isolation)", () => {
@@ -258,8 +258,10 @@ describe("installConnectorService — Linux systemd", () => {
258
258
  platform: "linux",
259
259
  getuid: () => 1000,
260
260
  userName: () => "op",
261
- // enable-linger FAIL, then daemon-reload OK, enable --now OK.
261
+ // #528 probe: show-user → Linger=no (off, so we proceed to enable);
262
+ // then enable-linger FAIL, daemon-reload OK, enable --now OK.
262
263
  runResults: [
264
+ { code: 0, stdout: "Linger=no\n", stderr: "" },
263
265
  { code: 1, stdout: "", stderr: "Failed to enable linger" },
264
266
  { code: 0, stdout: "", stderr: "" },
265
267
  { code: 0, stdout: "", stderr: "" },
@@ -398,6 +398,68 @@ describe("installManagedUnit — start:boolean (§7.1)", () => {
398
398
  expect(f.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]);
399
399
  expect(f.calls.some((c) => c.includes("enable"))).toBe(false);
400
400
  });
401
+
402
+ // #528: a per-command fake `run` so the linger probe + enable-linger can return
403
+ // distinct results. Non-linger commands (systemctl daemon-reload / enable) all
404
+ // succeed; only the linger sequence is scripted via `linger`.
405
+ function lingerDeps(linger: {
406
+ probe?: ServiceCommandResult;
407
+ enable?: ServiceCommandResult;
408
+ }): FakeDepsState {
409
+ const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
410
+ return fakeDeps({
411
+ platform: "linux",
412
+ getuid: () => 1000,
413
+ userName: () => "op",
414
+ run: ((cmd: readonly string[]) => {
415
+ // `calls` is recorded by the default run; here we record into a closure
416
+ // list returned alongside via the returned FakeDepsState — but fakeDeps
417
+ // only records in its OWN default run. So push into a shared array.
418
+ recorded.push([...cmd]);
419
+ if (cmd[0] === "loginctl" && cmd[1] === "show-user") return linger.probe ?? ok;
420
+ if (cmd[0] === "loginctl" && cmd[1] === "enable-linger") return linger.enable ?? ok;
421
+ return ok;
422
+ }) as ManagedUnitDeps["run"],
423
+ });
424
+ }
425
+ // Shared recorder for the per-command run above (fakeDeps's own `calls` array
426
+ // isn't populated when we override `run`).
427
+ let recorded: string[][] = [];
428
+
429
+ test("#528: linger ALREADY on → no enable attempt, no warning (false-alarm fix)", () => {
430
+ recorded = [];
431
+ const f = lingerDeps({ probe: { code: 0, stdout: "Linger=yes\n", stderr: "" } });
432
+ const result = installManagedUnit({
433
+ unit: hubUnit(f.deps),
434
+ deps: f.deps,
435
+ messages: HUB_MESSAGES,
436
+ start: false,
437
+ });
438
+ // Probed current state...
439
+ expect(recorded).toContainEqual(["loginctl", "show-user", "op", "--property=Linger"]);
440
+ // ...and because it's already on, did NOT try to enable it.
441
+ expect(recorded.some((c) => c[0] === "loginctl" && c[1] === "enable-linger")).toBe(false);
442
+ // ...and emitted NO scary linger warning.
443
+ expect(result.messages).not.toContain(HUB_MESSAGES.lingerWarning);
444
+ });
445
+
446
+ test("#528: linger OFF + enable-linger fails → warning surfaces", () => {
447
+ recorded = [];
448
+ const f = lingerDeps({
449
+ probe: { code: 0, stdout: "Linger=no\n", stderr: "" },
450
+ enable: { code: 1, stdout: "", stderr: "operation not permitted" },
451
+ });
452
+ const result = installManagedUnit({
453
+ unit: hubUnit(f.deps),
454
+ deps: f.deps,
455
+ messages: HUB_MESSAGES,
456
+ start: false,
457
+ });
458
+ // Off → did attempt to enable...
459
+ expect(recorded).toContainEqual(["loginctl", "enable-linger", "op"]);
460
+ // ...and the genuine failure warns.
461
+ expect(result.messages).toContain(HUB_MESSAGES.lingerWarning);
462
+ });
401
463
  });
402
464
 
403
465
  // ---------------------------------------------------------------------------
@@ -1591,6 +1591,31 @@ describe("Supervisor port-readiness + structured start-error (§6.5)", () => {
1591
1591
  expect(spawner.calls).toHaveLength(0);
1592
1592
  });
1593
1593
 
1594
+ test("(#634) preflight non-executable binary → non_executable start-error, NO spawn", async () => {
1595
+ const spawner = makeQueueSpawner();
1596
+ const sup = new Supervisor({
1597
+ spawnFn: spawner.spawn,
1598
+ killFn: noopKill,
1599
+ // `which` requires X_OK so it returns null for a 100644 bin...
1600
+ which: () => null,
1601
+ // ...but the secondary probe finds it present-but-non-executable.
1602
+ findNonExecutable: () => "/x/vault/bin/parachute-vault",
1603
+ portListening: async () => true,
1604
+ startReadyMs: 50,
1605
+ sleep: () => Promise.resolve(),
1606
+ });
1607
+ const state = await sup.start(reqWithPort("vault", 1940));
1608
+
1609
+ expect(state.status).toBe("crashed");
1610
+ expect(state.startError?.error_type).toBe("non_executable");
1611
+ expect(state.startError?.error_description).toContain(
1612
+ "but is not executable — run chmod +x /x/vault/bin/parachute-vault",
1613
+ );
1614
+ // No misleading "not installed" install card, and never spawned.
1615
+ expect(state.startError?.binary).toBe("parachute-vault");
1616
+ expect(spawner.calls).toHaveLength(0);
1617
+ });
1618
+
1594
1619
  test("a clean re-start clears a prior started-but-unbound start-error", async () => {
1595
1620
  const first = makeFakeProc(201);
1596
1621
  const second = makeFakeProc(202);
@@ -68,11 +68,16 @@ describe("listVaultNames", () => {
68
68
  expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
69
69
  });
70
70
 
71
- test("entry with no paths falls back to the manifest-suffix name (hub#143)", () => {
71
+ test("#478: bare empty-paths `parachute-vault` row yields NO name (no phantom 'default')", () => {
72
+ // What vault's self-register emits at zero vaults: the row stays present
73
+ // (installed-detection) but advertises no `/vault/<name>` path. It must not
74
+ // leak a selectable/assignable "default" before any vault exists. Mirrors
75
+ // the empty-paths skip in admin-vaults.ts (findExistingVault /
76
+ // listVaultInstanceNames).
72
77
  const manifest: ServicesManifest = {
73
78
  services: [
74
79
  {
75
- name: "parachute-vault-archived",
80
+ name: "parachute-vault",
76
81
  port: 1940,
77
82
  paths: [],
78
83
  health: "/h",
@@ -80,7 +85,31 @@ describe("listVaultNames", () => {
80
85
  },
81
86
  ],
82
87
  };
83
- expect(listVaultNames(manifest)).toEqual(["archived"]);
88
+ expect(listVaultNames(manifest)).toEqual([]);
89
+ });
90
+
91
+ test("#478: empty-paths row is skipped; real-paths vault rows are unchanged (positive control)", () => {
92
+ // An empty-paths row alongside a real-paths row: the real instance still
93
+ // resolves, the empty one contributes nothing.
94
+ const manifest: ServicesManifest = {
95
+ services: [
96
+ {
97
+ name: "parachute-vault",
98
+ port: 1940,
99
+ paths: [],
100
+ health: "/h",
101
+ version: "0.1.0",
102
+ },
103
+ {
104
+ name: "parachute-vault-work",
105
+ port: 1941,
106
+ paths: ["/vault/work"],
107
+ health: "/h",
108
+ version: "0.1.0",
109
+ },
110
+ ],
111
+ };
112
+ expect(listVaultNames(manifest)).toEqual(["work"]);
84
113
  });
85
114
 
86
115
  test("deduplicates collisions across single-entry + per-vault shapes", () => {
@@ -207,15 +207,11 @@ function findExistingVault(
207
207
  } catch {
208
208
  return null;
209
209
  }
210
- const target = `/vault/${name}`;
211
210
  for (const svc of manifest.services) {
212
211
  if (!isVaultEntry(svc)) continue;
213
- if (svc.paths.length === 0) {
214
- if (vaultInstanceNameFor(svc.name, undefined) === name) {
215
- return { url: target, version: svc.version, path: target };
216
- }
217
- continue;
218
- }
212
+ // #478: an empty-paths vault row means "installed but no servable vault
213
+ // instance" skip it entirely so it never resolves to a phantom "default".
214
+ if (svc.paths.length === 0) continue;
219
215
  for (const path of svc.paths) {
220
216
  if (vaultInstanceNameFor(svc.name, path) === name) {
221
217
  return { url: path, version: svc.version, path };
@@ -607,7 +603,7 @@ function emptyCascadeSummary(): CascadeSummary {
607
603
  }
608
604
 
609
605
  /** Every vault instance name currently registered in services.json. */
610
- function listVaultInstanceNames(manifestPath: string): Set<string> {
606
+ export function listVaultInstanceNames(manifestPath: string): Set<string> {
611
607
  const names = new Set<string>();
612
608
  let manifest: ReturnType<typeof readManifest>;
613
609
  try {
@@ -617,10 +613,9 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
617
613
  }
618
614
  for (const svc of manifest.services) {
619
615
  if (!isVaultEntry(svc)) continue;
620
- if (svc.paths.length === 0) {
621
- names.add(vaultInstanceNameFor(svc.name, undefined));
622
- continue;
623
- }
616
+ // #478: an empty-paths vault row means "installed but no servable vault
617
+ // instance" — skip it so no phantom "default" is synthesized.
618
+ if (svc.paths.length === 0) continue;
624
619
  for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
625
620
  }
626
621
  return names;
@@ -67,6 +67,34 @@ export const HUB_UPGRADE_REQUIRED_SCOPE = "parachute:host:admin";
67
67
  */
68
68
  const IN_FLIGHT_PHASES = new Set<HubUpgradeStatus["phase"]>(["pending", "running", "restarting"]);
69
69
 
70
+ /**
71
+ * #506: TTL for the 409 in-flight guard. The status file is single-slot, and a
72
+ * helper that CRASHES (OOM, killed mid-rewrite, host reboot) never reaches a
73
+ * terminal phase — leaving the slot stuck in `pending`/`running`/`restarting`
74
+ * FOREVER and 409-deadlocking every future upgrade. So: an in-flight slot whose
75
+ * `started_at` is older than this bound is treated as ABANDONED and the new
76
+ * request proceeds (overwriting the stale slot).
77
+ *
78
+ * 15 minutes — comfortably past the longest expected in-place upgrade (an
79
+ * `npm view` + `bun add -g` rewrite + restart is seconds-to-low-minutes even on
80
+ * a slow box / cold cache). A live upgrade finishing under the bound is never
81
+ * mistaken for abandoned; a crashed one frees the slot within 15 min instead of
82
+ * never. (A missing/garbage `started_at` is treated as stale → not 409, so a
83
+ * malformed file can't deadlock either.)
84
+ */
85
+ const IN_FLIGHT_TTL_MS = 15 * 60 * 1000;
86
+
87
+ /**
88
+ * Is an in-flight slot still FRESH (within the TTL), so a second POST must be
89
+ * rejected 409? An unparseable / missing `started_at` is treated as stale
90
+ * (not fresh) so a malformed file frees the slot rather than deadlocking it.
91
+ */
92
+ function isInFlightFresh(existing: HubUpgradeStatus, now: Date): boolean {
93
+ const startedMs = Date.parse(existing.started_at);
94
+ if (Number.isNaN(startedMs)) return false;
95
+ return now.getTime() - startedMs < IN_FLIGHT_TTL_MS;
96
+ }
97
+
70
98
  export interface SpawnHelperArgs {
71
99
  operationId: string;
72
100
  channel: "rc" | "latest";
@@ -213,7 +241,9 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
213
241
  const parsed = await parseBody(req);
214
242
  if (parsed instanceof Response) return parsed;
215
243
 
216
- // ── 409 in-flight guard ────────────────────────────────────────────────────
244
+ const now = (deps.now ?? (() => new Date()))();
245
+
246
+ // ── 409 in-flight guard (TTL-bounded) ──────────────────────────────────────
217
247
  // The status file is single-slot (one hub, one upgrade). If a prior upgrade
218
248
  // is still in a non-terminal phase (pending/running/restarting), starting a
219
249
  // SECOND would overwrite its operation_id — and a still-running first helper
@@ -222,9 +252,15 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
222
252
  // server-side too (a second tab, a stale page, a scripted POST). Reject with
223
253
  // 409 unless the slot is free (no file) or the prior op reached a terminal
224
254
  // phase (failed / redeploy-required / succeeded).
255
+ //
256
+ // #506: BUT a non-terminal slot is only a real block while it's FRESH. A
257
+ // helper that crashed (OOM / killed / host reboot) leaves the slot stuck
258
+ // in-flight forever and would 409-deadlock every future upgrade. So an
259
+ // in-flight slot older than IN_FLIGHT_TTL_MS is treated as ABANDONED and the
260
+ // request proceeds (the seeded status below overwrites the stale slot).
225
261
  const readStatus = deps.readStatus ?? readHubUpgradeStatus;
226
262
  const existing = readStatus(deps.configDir);
227
- if (existing && IN_FLIGHT_PHASES.has(existing.phase)) {
263
+ if (existing && IN_FLIGHT_PHASES.has(existing.phase) && isInFlightFresh(existing, now)) {
228
264
  return jsonError(
229
265
  409,
230
266
  "upgrade_in_flight",
@@ -234,7 +270,6 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
234
270
 
235
271
  const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
236
272
  const env = deps.env ?? process.env;
237
- const now = (deps.now ?? (() => new Date()))();
238
273
 
239
274
  const currentVersion = (deps.currentVersion ?? (() => defaultCurrentVersion(hubSrcDir)))();
240
275
  // Auto-detect the channel from the current version when not explicitly set —
@@ -454,6 +454,25 @@ function installLaunchdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallRes
454
454
  };
455
455
  }
456
456
 
457
+ /**
458
+ * #528: is `loginctl` linger already enabled for `userName`? Best-effort probe:
459
+ * `loginctl show-user <user> --property=Linger` prints `Linger=yes` / `Linger=no`.
460
+ * Returns true ONLY on a clear `Linger=yes`; ANY ambiguity (non-zero exit, a
461
+ * throw, or unparseable output) returns false so the caller falls through to the
462
+ * enable attempt — we never SKIP enabling on a guess, only when linger is
463
+ * provably already on. (`show-user` of a user with no session can itself exit
464
+ * non-zero; treat that as "unknown → try to enable".)
465
+ */
466
+ function lingerAlreadyOn(deps: ManagedUnitDeps, userName: string): boolean {
467
+ try {
468
+ const probe = deps.run(["loginctl", "show-user", userName, "--property=Linger"]);
469
+ if (probe.code !== 0) return false;
470
+ return /(^|\n)\s*Linger=yes\s*(\n|$)/i.test(probe.stdout);
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+
457
476
  function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
458
477
  const { unit, deps, messages } = opts;
459
478
  const start = opts.start ?? true;
@@ -490,10 +509,20 @@ function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallRes
490
509
  // systemctl but not loginctl would propagate the spawn error out and hard-fail
491
510
  // the calling command. (Run on both start + install-without-start: linger is a
492
511
  // boot-survival nicety independent of whether we start the unit now.)
512
+ //
513
+ // #528: pre-check the CURRENT linger state before trying to enable it. When
514
+ // linger is ALREADY on (the common re-install / re-migrate case on a box
515
+ // whose owner already enabled it), `enable-linger` is a no-op we don't need —
516
+ // and on some systemd builds it can return non-zero even though linger is
517
+ // genuinely on, raising a scary "couldn't enable lingering, your hub won't
518
+ // survive reboot" warning that is a FALSE ALARM. So: probe first; if linger
519
+ // is on, skip both the enable AND the warning. Only when linger is genuinely
520
+ // OFF and the enable attempt then fails do we warn. This is the single-owner
521
+ // self-host reboot-survival happy path — keep it quiet when it's already good.
493
522
  if (!root && userName) {
494
523
  if (deps.which("loginctl") === null) {
495
524
  outMessages.push(messages.lingerWarning);
496
- } else {
525
+ } else if (!lingerAlreadyOn(deps, userName)) {
497
526
  try {
498
527
  const linger = deps.run(["loginctl", "enable-linger", userName]);
499
528
  if (linger.code !== 0) outMessages.push(messages.lingerWarning);
package/src/supervisor.ts CHANGED
@@ -38,6 +38,7 @@ import { spawnSync } from "node:child_process";
38
38
  import {
39
39
  MissingDependencyError,
40
40
  type MissingDependencyWire,
41
+ NonExecutableError,
41
42
  ensureExecutable,
42
43
  rethrowIfMissing,
43
44
  } from "@openparachute/depcheck";
@@ -263,6 +264,14 @@ export interface SupervisorOpts {
263
264
  * Tests exercising the missing-binary branch inject `which: () => null`.
264
265
  */
265
266
  readonly which?: (cmd: string) => string | null;
267
+ /**
268
+ * #634 secondary-probe seam for `ensureExecutable`: when `which` returns null,
269
+ * walk PATH IGNORING X_OK to detect a present-but-non-executable binary (a
270
+ * `bin` that lost its +x bit). Production leaves this undefined so depcheck's
271
+ * real PATH walk runs (gated to the real `Bun.which`); tests inject it to
272
+ * exercise the non-executable preflight branch through a stubbed `which`.
273
+ */
274
+ readonly findNonExecutable?: (binary: string) => string | null;
266
275
  /**
267
276
  * Pre-spawn port-squatter detection (#580 item 4). Returns the pid holding a
268
277
  * TCP LISTEN on the module's port, or undefined when the port is free /
@@ -427,8 +436,11 @@ export class LogRingBuffer {
427
436
  * boot and threads it into the API handlers.
428
437
  */
429
438
  export class Supervisor {
430
- private readonly opts: Required<Omit<SupervisorOpts, "spawnFn">> & {
439
+ private readonly opts: Required<Omit<SupervisorOpts, "spawnFn" | "findNonExecutable">> & {
431
440
  readonly spawnFn: SpawnFn;
441
+ // Optional #634 probe seam — undefined on the production path so depcheck's
442
+ // own real PATH walk runs (gated to the real `Bun.which`).
443
+ readonly findNonExecutable?: (binary: string) => string | null;
432
444
  };
433
445
  private readonly modules = new Map<string, ModuleEntry>();
434
446
 
@@ -459,6 +471,9 @@ export class Supervisor {
459
471
  lateBindWatchMs: opts.lateBindWatchMs ?? DEFAULT_LATE_BIND_WATCH_MS,
460
472
  lateBindPollMs: opts.lateBindPollMs ?? DEFAULT_LATE_BIND_POLL_MS,
461
473
  which: opts.which ?? (isProductionPath ? Bun.which : () => "/stub/bin/preflight-skipped"),
474
+ // #634: undefined on production so depcheck's real PATH walk runs (its
475
+ // gate keys on the real `Bun.which`); tests inject it to drive the branch.
476
+ findNonExecutable: opts.findNonExecutable,
462
477
  // Squatter detection (#580 item 4): real probes on the production path;
463
478
  // the stub-spawner test path defaults to "no squatter / unknown owner" so
464
479
  // fake-proc tests (which never hold a real port) aren't tripped. Tests
@@ -509,7 +524,9 @@ export class Supervisor {
509
524
  const startBinary = req.cmd[0];
510
525
  if (startBinary) {
511
526
  try {
512
- ensureExecutable(startBinary, { which: this.opts.which });
527
+ const ensureOpts: Parameters<typeof ensureExecutable>[1] = { which: this.opts.which };
528
+ if (this.opts.findNonExecutable) ensureOpts.findNonExecutable = this.opts.findNonExecutable;
529
+ ensureExecutable(startBinary, ensureOpts);
513
530
  } catch (err) {
514
531
  if (err instanceof MissingDependencyError) {
515
532
  entry.state = {
@@ -520,6 +537,18 @@ export class Supervisor {
520
537
  };
521
538
  return entry.state;
522
539
  }
540
+ // #634: the binary IS present but not executable (a `bin` that lost its
541
+ // +x bit). Record the actionable chmod hint instead of a misleading
542
+ // "not installed" — and never throw out of `start`.
543
+ if (err instanceof NonExecutableError) {
544
+ entry.state = {
545
+ ...entry.state,
546
+ status: "crashed",
547
+ pid: undefined,
548
+ startError: nonExecutableStartError(err, this.opts.now),
549
+ };
550
+ return entry.state;
551
+ }
523
552
  throw err;
524
553
  }
525
554
  }
@@ -1243,6 +1272,21 @@ function startErrorFromWire(wire: MissingDependencyWire, now: () => number): Mod
1243
1272
  };
1244
1273
  }
1245
1274
 
1275
+ /**
1276
+ * #634: map a `NonExecutableError` (binary present on PATH but not +x) onto the
1277
+ * `ModuleStartError` shape. `error_type: "non_executable"` so a UI can branch;
1278
+ * `error_description` is the formatted `chmod +x` block. No install card — the
1279
+ * fix is a permission flip, not a reinstall.
1280
+ */
1281
+ function nonExecutableStartError(err: NonExecutableError, now: () => number): ModuleStartError {
1282
+ return {
1283
+ error_type: err.errorType,
1284
+ error_description: err.message,
1285
+ binary: err.binary,
1286
+ at: new Date(now()).toISOString(),
1287
+ };
1288
+ }
1289
+
1246
1290
  /**
1247
1291
  * Production group-aware kill (hub#88). Sends `signal` to the entire process
1248
1292
  * group rooted at `pid` (the negative-pid syscall) so a wrapped startCmd's
@@ -23,8 +23,17 @@
23
23
  * Walks both manifest shapes: single-entry-multi-path (`parachute-vault`
24
24
  * with `paths: ["/vault/work", "/vault/personal"]`) and per-vault entries
25
25
  * (`parachute-vault-work`) by delegating each (name, path) pair to
26
- * `vaultInstanceNameFor`. Entries with no paths still resolve to a name via
27
- * the helper's manifest-suffix fallback (hub#143).
26
+ * `vaultInstanceNameFor`.
27
+ *
28
+ * #478: an empty-paths vault row (e.g. `parachute-vault` with `paths: []`,
29
+ * which vault's self-register emits at zero vaults) is "installed but no
30
+ * servable vault instance" and is SKIPPED entirely — it must not synthesize a
31
+ * name (the bare `parachute-vault` would otherwise resolve to a phantom
32
+ * "default"). This mirrors the empty-paths `continue` in `admin-vaults.ts`'s
33
+ * `findExistingVault`/`listVaultInstanceNames`, so every read path agrees: a
34
+ * vault instance is named only by a real `/vault/<name>` mount path. This
35
+ * supersedes the prior hub#143 manifest-suffix fallback for path-less entries
36
+ * — a registered vault carries its mount path once a vault exists.
28
37
  */
29
38
  import { type ServicesManifest, readManifestLenient } from "./services-manifest.ts";
30
39
  import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
@@ -39,8 +48,10 @@ export function listVaultNames(manifest: ServicesManifest): string[] {
39
48
  const names = new Set<string>();
40
49
  for (const svc of manifest.services) {
41
50
  if (!isVaultEntry(svc)) continue;
42
- const paths = svc.paths.length > 0 ? svc.paths : [undefined];
43
- for (const path of paths) {
51
+ // #478: an empty-paths vault row means "installed but no servable vault
52
+ // instance" skip it so it never synthesizes a phantom "default".
53
+ if (svc.paths.length === 0) continue;
54
+ for (const path of svc.paths) {
44
55
  names.add(vaultInstanceNameFor(svc.name, path));
45
56
  }
46
57
  }