@openparachute/hub 0.7.4-rc.1 → 0.7.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.
Files changed (38) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-vaults.test.ts +164 -1
  3. package/src/__tests__/api-account-2fa.test.ts +381 -0
  4. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  5. package/src/__tests__/clients.test.ts +28 -8
  6. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  7. package/src/__tests__/hub-server.test.ts +127 -5
  8. package/src/__tests__/init.test.ts +153 -0
  9. package/src/__tests__/managed-unit.test.ts +62 -0
  10. package/src/__tests__/oauth-handlers.test.ts +488 -0
  11. package/src/__tests__/oauth-ui.test.ts +55 -1
  12. package/src/__tests__/scope-explanations.test.ts +19 -0
  13. package/src/__tests__/setup-wizard.test.ts +124 -7
  14. package/src/__tests__/status-supervisor.test.ts +152 -3
  15. package/src/__tests__/supervisor.test.ts +25 -0
  16. package/src/__tests__/vault-names.test.ts +32 -3
  17. package/src/__tests__/well-known.test.ts +37 -2
  18. package/src/admin-vaults.ts +7 -12
  19. package/src/api-account-2fa.ts +373 -0
  20. package/src/api-hub-upgrade.ts +38 -3
  21. package/src/api-me.ts +11 -2
  22. package/src/cli.ts +27 -5
  23. package/src/clients.ts +14 -0
  24. package/src/commands/init.ts +108 -0
  25. package/src/commands/status.ts +108 -5
  26. package/src/help.ts +12 -1
  27. package/src/hub-server.ts +72 -7
  28. package/src/managed-unit.ts +30 -1
  29. package/src/oauth-handlers.ts +98 -6
  30. package/src/oauth-ui.ts +123 -0
  31. package/src/scope-explanations.ts +2 -1
  32. package/src/setup-wizard.ts +40 -21
  33. package/src/supervisor.ts +46 -2
  34. package/src/vault-names.ts +15 -4
  35. package/src/well-known.ts +10 -1
  36. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  37. package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
  38. package/web/ui/dist/index.html +2 -2
@@ -294,8 +294,11 @@ export function buildServicesCatalog(
294
294
  if (audiences.has("vault")) {
295
295
  for (const entry of manifest.services) {
296
296
  if (!isVaultEntry(entry)) continue;
297
- const paths = entry.paths.length > 0 ? entry.paths : ["/"];
298
- for (const path of paths) {
297
+ // #478: an empty-paths vault row is "installed but no servable instance"
298
+ // — skip it so it never counts toward (or, below, fabricates) a phantom
299
+ // vault. Mirrors the continue in well-known.ts / admin-vaults.ts.
300
+ if (entry.paths.length === 0) continue;
301
+ for (const path of entry.paths) {
299
302
  const instance = vaultInstanceNameFor(entry.name, path);
300
303
  if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
301
304
  }
@@ -308,12 +311,16 @@ export function buildServicesCatalog(
308
311
  for (const entry of manifest.services) {
309
312
  if (isVaultEntry(entry)) {
310
313
  if (!audiences.has("vault")) continue;
314
+ // #478: an empty-paths vault row is "installed but no servable instance"
315
+ // — skip it so the catalog never offers a phantom `vault` / `vault:default`
316
+ // entry pointing at root before any vault exists. Mirrors well-known.ts /
317
+ // admin-vaults.ts / vault-names.ts.
318
+ if (entry.paths.length === 0) continue;
311
319
  // Walk every path the row exposes. Real multi-vault on the hub is a
312
320
  // single `parachute-vault` row with N paths (one per vault instance);
313
321
  // legacy per-vault rows (`parachute-vault-<name>`) are handled by the
314
322
  // same loop because each contributes one path.
315
- const paths = entry.paths.length > 0 ? entry.paths : ["/"];
316
- for (const path of paths) {
323
+ for (const path of entry.paths) {
317
324
  const instance = vaultInstanceNameFor(entry.name, path);
318
325
  const admit = broadVaultScope || namedVaults.has(instance);
319
326
  if (!admit) continue;
@@ -1209,9 +1216,31 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
1209
1216
  return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
1210
1217
  }
1211
1218
 
1219
+ // hub#689 — does the user hold ADMIN on every vault they could pick? Admin
1220
+ // (isFirstAdmin) owns the whole hub. A non-admin owns a vault only if their
1221
+ // `user_vaults` role grants admin there (today role=write does; a role=read
1222
+ // assignment would NOT). Re-derived from the DB so the owner-verb-selector
1223
+ // is offered only to a genuine owner — the submit path re-checks the PICKED
1224
+ // vault and the cap is the backstop, but rendering it precisely avoids
1225
+ // promising an admin upgrade the cap would silently demote.
1226
+ const userHoldsAdminOnPickable =
1227
+ userIsAdmin ||
1228
+ (assignedVaults.length > 0 &&
1229
+ assignedVaults.every((v) =>
1230
+ (vaultVerbsForUserVault(db, session.userId, v) ?? []).includes("admin"),
1231
+ ));
1232
+
1212
1233
  return htmlResponse(
1213
1234
  renderConsent(
1214
- consentProps(client, parsed, vaultNames, csrf.token, assignedVaults, userIsAdmin),
1235
+ consentProps(
1236
+ client,
1237
+ parsed,
1238
+ vaultNames,
1239
+ csrf.token,
1240
+ assignedVaults,
1241
+ userIsAdmin,
1242
+ userHoldsAdminOnPickable,
1243
+ ),
1215
1244
  ),
1216
1245
  200,
1217
1246
  extra,
@@ -1270,7 +1299,8 @@ function capScopesToUserAuthority(
1270
1299
  if (name === undefined || verb === undefined || !VAULT_VERBS.has(verb)) return true;
1271
1300
  // Named vault verb requested by a non-owner: admit only if the user holds
1272
1301
  // it. `vaultVerbsForUserVault` returns null for an unassigned vault (drop)
1273
- // or the held verb list (today read/write only never admin).
1302
+ // or the held verb list a `write` role holds [read, write, admin], a
1303
+ // `read` role holds [read] (see `vaultVerbsForRole`).
1274
1304
  const held = vaultVerbsForUserVault(db, userId, name);
1275
1305
  return held !== null && (held as readonly string[]).includes(verb);
1276
1306
  });
@@ -1682,6 +1712,44 @@ async function handleConsentSubmit(
1682
1712
  400,
1683
1713
  );
1684
1714
  }
1715
+ // hub#689 — owner-on-own-vault verb widening. The consent screen offers
1716
+ // owners a read/write/admin selector (pre-selected to admin) for an
1717
+ // unnamed `vault:read`/`vault:write` request, so an owner whose AI client
1718
+ // asked for read-only can grant the level it actually needs in-flow. The
1719
+ // submitted `verb_select` is an UNTRUSTED hint — we re-derive ownership of
1720
+ // the PICKED vault server-side here, and `capScopesToUserAuthority` (inside
1721
+ // issueAuthCodeRedirect) is the backstop that drops any verb the user
1722
+ // doesn't actually hold. This only ever rewrites the unnamed read/write
1723
+ // verb(s) to the selected level on the picked vault; named scopes and every
1724
+ // other scope are untouched. A forged `verb_select=admin` from a user who
1725
+ // doesn't own the picked vault gets capped back to what they hold (or, for
1726
+ // a vault outside a pinned user's assignment, never reaches here — the
1727
+ // mismatch checks above already 400'd it).
1728
+ const selectedVerb = String(form.get("verb_select") ?? "").trim();
1729
+ if (selectedVerb === "read" || selectedVerb === "write" || selectedVerb === "admin") {
1730
+ // Re-derive, server-side, whether THIS user owns (holds admin on) the
1731
+ // PICKED vault. Owner === first admin (holds admin everywhere) OR an
1732
+ // assigned user whose role grants admin on this vault. Never trust the
1733
+ // client-submitted selector to establish authority.
1734
+ const heldOnPicked = vaultVerbsForUserVault(db, session.userId, pickedVault);
1735
+ const ownsPicked = userIsAdmin || (heldOnPicked?.includes("admin") ?? false);
1736
+ if (ownsPicked) {
1737
+ scopes = scopes.map((s) => {
1738
+ const parts = s.split(":");
1739
+ // Only widen the unnamed read/write verbs the selector was offered
1740
+ // for — leave an unnamed `vault:admin`, named scopes, and non-vault
1741
+ // scopes exactly as requested.
1742
+ if (
1743
+ parts.length === 2 &&
1744
+ parts[0] === "vault" &&
1745
+ (parts[1] === "read" || parts[1] === "write")
1746
+ ) {
1747
+ return `vault:${selectedVerb}`;
1748
+ }
1749
+ return s;
1750
+ });
1751
+ }
1752
+ }
1685
1753
  scopes = narrowVaultScopes(scopes, pickedVault);
1686
1754
  }
1687
1755
 
@@ -2746,6 +2814,10 @@ function consentProps(
2746
2814
  csrfToken: string,
2747
2815
  assignedVaults: readonly string[],
2748
2816
  userIsAdmin: boolean,
2817
+ // hub#689 — true when the user holds admin on every vault they could pick
2818
+ // (admin owns the hub; an assigned non-admin only if their role grants admin
2819
+ // on each assigned vault). Gates whether the owner-verb-selector renders.
2820
+ userHoldsAdminOnPickable = userIsAdmin,
2749
2821
  ) {
2750
2822
  const scopes = params.scope.split(" ").filter((s) => s.length > 0);
2751
2823
  const unnamedVerbs = unnamedVaultVerbs(scopes);
@@ -2875,6 +2947,25 @@ function consentProps(
2875
2947
  const only = vaultNames[0];
2876
2948
  if (only) displayVault = only;
2877
2949
  }
2950
+ // hub#689 — owner-on-own-vault verb selector. The client requested an
2951
+ // unnamed `vault:read`/`vault:write` verb, and the consenting user owns
2952
+ // (holds admin on) every vault they could pick — first admin owns the whole
2953
+ // hub; an assigned non-admin holds admin on each of their assigned vaults
2954
+ // (vaultVerbsForRole('write') → [read,write,admin]). Offer the selector so
2955
+ // they can grant the level their client actually needs (or downgrade), with
2956
+ // admin pre-selected. Suppressed when the request can't be authorized (zero-
2957
+ // vault non-admin) or the assignment is stale (no valid vault to own).
2958
+ //
2959
+ // SECURITY: this only DECIDES WHETHER TO RENDER. The actual widening is
2960
+ // re-derived server-side in `handleConsentSubmit` against the *picked* vault
2961
+ // and capped by `capScopesToUserAuthority`. The selector value is a hint.
2962
+ const upgradeableUnnamedVerbs = unnamedVerbs.filter((v) => v === "read" || v === "write");
2963
+ const userOwnsEveryPickableVault =
2964
+ !hasStaleAssignment && userCanAuthorizeRequest && userHoldsAdminOnPickable;
2965
+ const ownerVerbSelector =
2966
+ upgradeableUnnamedVerbs.length > 0 && userOwnsEveryPickableVault
2967
+ ? { requestedVerbs: upgradeableUnnamedVerbs }
2968
+ : undefined;
2878
2969
  return {
2879
2970
  params,
2880
2971
  clientId: client.clientId,
@@ -2883,6 +2974,7 @@ function consentProps(
2883
2974
  csrfToken,
2884
2975
  vaultPicker,
2885
2976
  displayVault,
2977
+ ownerVerbSelector,
2886
2978
  staleAssignedVault,
2887
2979
  // Approve stays enabled for non-vault scopes even when assigned_vault
2888
2980
  // is stale — the user can still consent to e.g. `scribe:transcribe`
package/src/oauth-ui.ts CHANGED
@@ -147,6 +147,31 @@ export interface ConsentViewProps {
147
147
  * the user on an error page. Defaults to authorizable when omitted.
148
148
  */
149
149
  userCanAuthorizeRequest?: boolean;
150
+ /**
151
+ * hub#689 — owner-on-own-vault verb selector. Set when the consenting user
152
+ * OWNS (holds admin on) every vault they could pick AND the client requested
153
+ * an unnamed `vault:read`/`vault:write` verb. Renders a read/write/admin
154
+ * radio group, pre-selected to admin, so the owner can grant the level their
155
+ * AI client actually needs in-flow (the requested-scope shape was the
156
+ * blocker, not the user's authority) — or transparently downgrade.
157
+ *
158
+ * The submitted `verb_select` is an UNTRUSTED hint: the consent-submit
159
+ * handler re-derives, server-side, whether the user actually owns the picked
160
+ * vault before widening, and `capScopesToUserAuthority` remains the backstop
161
+ * that drops any verb the user doesn't hold. The selector only ever WIDENS
162
+ * the unnamed verb(s) on the picked vault; it never touches any other scope.
163
+ */
164
+ ownerVerbSelector?: OwnerVerbSelector;
165
+ }
166
+
167
+ export interface OwnerVerbSelector {
168
+ /**
169
+ * The unnamed read/write verb(s) the client requested. Only `read`/`write`
170
+ * are upgradeable here — an unnamed `vault:admin` request already renders
171
+ * with the admin badge and needs no selector. Used to word the selector
172
+ * help text ("the app asked for write access").
173
+ */
174
+ requestedVerbs: string[];
150
175
  }
151
176
 
152
177
  export interface VaultPicker {
@@ -328,6 +353,7 @@ export function renderConsent(props: ConsentViewProps): string {
328
353
  staleAssignedVault,
329
354
  blockApproveForStaleAssignment,
330
355
  userCanAuthorizeRequest,
356
+ ownerVerbSelector,
331
357
  } = props;
332
358
  // Substitute unnamed `vault:<verb>` rows with the resolved named form so
333
359
  // the operator sees the scope shape that will appear in the token. Raw
@@ -339,6 +365,7 @@ export function renderConsent(props: ConsentViewProps): string {
339
365
  ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
340
366
  : displayedScopes.map(renderScopeRow).join("\n");
341
367
  const pickerSection = vaultPicker ? renderVaultPicker(vaultPicker) : "";
368
+ const verbSelectorSection = ownerVerbSelector ? renderOwnerVerbSelector(ownerVerbSelector) : "";
342
369
  // Approve is disabled when the picker can't yield a valid vault. The
343
370
  // empty-vault branch (no vaults registered) is the original case. A
344
371
  // locked-vault picker (multi-user Phase 1) always has a valid value via
@@ -418,6 +445,7 @@ export function renderConsent(props: ConsentViewProps): string {
418
445
  ${renderCsrfHiddenInput(csrfToken)}
419
446
  ${renderHiddenInputs(params)}
420
447
  ${pickerSection}
448
+ ${verbSelectorSection}
421
449
  <div class="button-row">
422
450
  <button type="submit" name="approve" value="yes" class="btn btn-primary"${approveDisabled}>Approve</button>
423
451
  <button type="submit" name="approve" value="no" class="btn btn-secondary">Deny</button>
@@ -492,6 +520,60 @@ function renderVaultPicker(picker: VaultPicker): string {
492
520
  </section>`;
493
521
  }
494
522
 
523
+ /**
524
+ * hub#689 — owner-on-own-vault verb selector. Rendered only when the
525
+ * consenting user owns (holds admin on) every vault they could pick and the
526
+ * client requested an unnamed `vault:read`/`vault:write` verb. Three radios
527
+ * (read / write / admin), pre-selected to **admin** so the common case (the
528
+ * owner's own AI client that needs full access) is one click — but the owner
529
+ * sees and submits the choice, and can downgrade.
530
+ *
531
+ * The `admin` option keeps the `.scope-admin` red border + admin badge so an
532
+ * admin grant stays visibly flagged even when pre-selected. The submitted
533
+ * `verb_select` is an untrusted hint re-checked server-side (ownership
534
+ * re-derivation in `handleConsentSubmit` + `capScopesToUserAuthority` backstop);
535
+ * this template only renders the choice.
536
+ */
537
+ function renderOwnerVerbSelector(selector: OwnerVerbSelector): string {
538
+ const requested = selector.requestedVerbs.map((v) => `<code>vault:${escapeHtml(v)}</code>`);
539
+ const requestedList =
540
+ requested.length === 1
541
+ ? requested[0]
542
+ : `${requested.slice(0, -1).join(", ")} and ${requested.at(-1)}`;
543
+ const option = (
544
+ verb: "read" | "write" | "admin",
545
+ title: string,
546
+ desc: string,
547
+ checked: boolean,
548
+ ): string => {
549
+ const isAdmin = verb === "admin";
550
+ const cls = `verb-option${isAdmin ? " verb-option-admin scope-admin" : ""}`;
551
+ const badge = isAdmin ? `<span class="badge badge-admin">admin</span>` : "";
552
+ return `
553
+ <label class="${cls}">
554
+ <input type="radio" name="verb_select" value="${verb}"${checked ? " checked" : ""} />
555
+ <span class="verb-option-body">
556
+ <span class="verb-option-head">
557
+ <span class="verb-option-title">${escapeHtml(title)}</span>
558
+ ${badge}
559
+ </span>
560
+ <span class="verb-option-desc">${escapeHtml(desc)}</span>
561
+ </span>
562
+ </label>`;
563
+ };
564
+ return `
565
+ <section class="verb-selector">
566
+ <h2 class="scopes-title">Access level</h2>
567
+ <p class="picker-help">
568
+ This app asked for ${requestedList} access to your vault. Because you own
569
+ this vault, you can grant a different level — admin is selected so your app
570
+ can do everything it might need; lower it if you'd rather not.
571
+ </p>
572
+ <div class="verb-options">${option("read", "Read only", "View notes, tags, attachments, and config.", false)}${option("write", "Read & write", "Create, edit, and delete notes, tags, and attachments.", false)}${option("admin", "Admin", "Full access plus config, triggers/automation, GitHub backup, and minting tokens.", true)}
573
+ </div>
574
+ </section>`;
575
+ }
576
+
495
577
  /**
496
578
  * "App not yet approved" page (#74). Two branches:
497
579
  *
@@ -1282,6 +1364,47 @@ const STYLES = `
1282
1364
  font-size: 0.88rem;
1283
1365
  color: ${PALETTE.fg};
1284
1366
  }
1367
+ /* hub#689 — owner-on-own-vault verb selector. Same card shell as the
1368
+ vault picker; the admin option carries the .scope-admin red border so an
1369
+ admin grant stays visibly flagged even when pre-selected. */
1370
+ .verb-selector {
1371
+ margin: 0 0 1.25rem;
1372
+ padding: 0.75rem 0.85rem;
1373
+ border: 1px solid ${PALETTE.borderLight};
1374
+ border-radius: 6px;
1375
+ background: ${PALETTE.bgSoft};
1376
+ }
1377
+ .verb-selector .scopes-title { margin-bottom: 0.4rem; }
1378
+ .verb-options {
1379
+ display: flex;
1380
+ flex-direction: column;
1381
+ gap: 0.4rem;
1382
+ }
1383
+ .verb-option {
1384
+ display: flex;
1385
+ align-items: flex-start;
1386
+ gap: 0.5rem;
1387
+ padding: 0.5rem 0.65rem;
1388
+ border: 1px solid ${PALETTE.border};
1389
+ border-radius: 6px;
1390
+ background: ${PALETTE.cardBg};
1391
+ cursor: pointer;
1392
+ transition: border-color 0.15s ease, background 0.15s ease;
1393
+ }
1394
+ .verb-option:hover { border-color: ${PALETTE.accent}; }
1395
+ .verb-option input[type=radio] { margin-top: 0.25rem; }
1396
+ .verb-option input[type=radio]:focus { outline: 2px solid ${PALETTE.accent}; outline-offset: 2px; }
1397
+ .verb-option-body { display: flex; flex-direction: column; gap: 0.1rem; }
1398
+ .verb-option-head {
1399
+ display: flex;
1400
+ align-items: center;
1401
+ gap: 0.4rem;
1402
+ flex-wrap: wrap;
1403
+ }
1404
+ .verb-option-title { font-weight: 500; color: ${PALETTE.fg}; font-size: 0.9rem; }
1405
+ .verb-option-desc { font-size: 0.82rem; color: ${PALETTE.fgMuted}; }
1406
+ .verb-option-admin .verb-option-title { color: ${PALETTE.danger}; }
1407
+
1285
1408
  .vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
1286
1409
  .vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
1287
1410
  .vault-picker-locked .picker-help { color: ${PALETTE.fgMuted}; }
@@ -42,7 +42,8 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
42
42
  level: "write",
43
43
  },
44
44
  "vault:admin": {
45
- label: "Full vault access plus configuration changes (rotate tokens, change settings).",
45
+ label:
46
+ "Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
46
47
  level: "admin",
47
48
  },
48
49
  // Optional-module scopes (scribe / agent). These are in FIRST_PARTY_SCOPES
@@ -1592,14 +1592,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1592
1592
  // poll on the auth the wizard already carries.
1593
1593
  const opId = url.searchParams.get("op");
1594
1594
  if (opId) {
1595
- const op = deps.registry?.get(opId);
1596
- if (op) {
1597
- envelope.operation = {
1598
- id: op.id,
1599
- status: op.status,
1600
- log: op.log,
1601
- ...(op.error !== undefined ? { error: op.error } : {}),
1602
- };
1595
+ // hub#618: post-setup this JSON `?op=` surface is unauth-reachable —
1596
+ // `/admin/setup` is always lockout-exempt (the dispatcher's
1597
+ // `shouldGateForSetup` lets it through so a stale bookmark resolves), and
1598
+ // the snapshot is read BEFORE any session check. The leak is small (an
1599
+ // in-memory op's status + install-progress log lines, behind an
1600
+ // unguessable UUID), but it's still a post-setup admin surface, so gate
1601
+ // it once setup is COMPLETE. During setup (no admin yet) the surface
1602
+ // stays OPEN: the unauth CLI wizard (`parachute init`) AND the brand-new-
1603
+ // operator browser both poll this `?op=` snapshot mid-setup before any
1604
+ // session exists — gating then would break first-boot vault
1605
+ // provisioning. Loopback always passes (same on-box trust as the
1606
+ // `bootstrapToken` branch below); a valid session also passes.
1607
+ const setupComplete = state.hasAdmin && state.hasVault && state.hasExposeMode;
1608
+ const opSnapshotAllowed =
1609
+ !setupComplete ||
1610
+ deps.requestIsLoopback === true ||
1611
+ findActiveSession(deps.db, req) !== null;
1612
+ if (opSnapshotAllowed) {
1613
+ const op = deps.registry?.get(opId);
1614
+ if (op) {
1615
+ envelope.operation = {
1616
+ id: op.id,
1617
+ status: op.status,
1618
+ log: op.log,
1619
+ ...(op.error !== undefined ? { error: op.error } : {}),
1620
+ };
1621
+ }
1603
1622
  }
1604
1623
  }
1605
1624
  // hub#576: hand the actual token to a LOOPBACK caller only. The on-box
@@ -2325,19 +2344,19 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2325
2344
  if (registry) {
2326
2345
  // hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
2327
2346
  // vault's first-boot path (vault#342) names the created vault
2328
- // accordingly. Skip the env override when the operator left the
2329
- // field blank — vault's `resolveFirstBootVaultName` defaults to
2330
- // `default` on absent env vars, so this preserves the prior
2331
- // behaviour for the empty-input case.
2347
+ // accordingly.
2332
2348
  //
2333
- // If the operator typed "default" explicitly, treat the same as
2334
- // blank vault's first-boot defaults to "default" anyway, so
2335
- // skipping the env override is correct (the comparison below
2336
- // catches both blank-trimmed-to-DEFAULT and typed-"default").
2337
- const spawnEnv: Record<string, string> = {};
2338
- if (vaultName !== DEFAULT_VAULT_NAME) {
2339
- spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
2340
- }
2349
+ // #478 Part 2: ALWAYS set `PARACHUTE_VAULT_NAME`, even when the name
2350
+ // is "default". Once vault removes its silent auto-create-on-missing-env
2351
+ // behavior, vault's first-boot will require the env var to know which
2352
+ // vault to create — a missing `PARACHUTE_VAULT_NAME` will mean "no vault
2353
+ // to create" rather than "create one named default". Passing it
2354
+ // explicitly for every path (including the default) is correct and safe:
2355
+ // vault's `resolveFirstBootVaultName` accepts "default" as a valid name
2356
+ // and behaves identically to the prior implicit default.
2357
+ const spawnEnv: Record<string, string> = {
2358
+ PARACHUTE_VAULT_NAME: vaultName,
2359
+ };
2341
2360
  // Capture importParams + deps in the runInstall promise chain — when
2342
2361
  // mode === "import", run the vault-side `/.parachute/mirror/import`
2343
2362
  // POST as a follow-up step once the supervised vault has come up
@@ -2358,7 +2377,7 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2358
2377
  registry,
2359
2378
  ...(deps.run ? { run: deps.run } : {}),
2360
2379
  ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
2361
- ...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
2380
+ spawnEnv,
2362
2381
  })
2363
2382
  .then(async () => {
2364
2383
  if (!importToRun) return;
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
  }
package/src/well-known.ts CHANGED
@@ -247,7 +247,16 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
247
247
  // multi-path on those is treated as aliases rather than separate
248
248
  // installs.
249
249
  const isVault = isVaultEntry(s);
250
- const pathsToEmit = isVault && s.paths.length > 0 ? s.paths : [s.paths[0] ?? "/"];
250
+ // #478: an empty-paths VAULT row means "installed but no servable vault
251
+ // instance" — vault's self-register emits `paths: []` at zero vaults.
252
+ // Skip it entirely: emitting `["/"]` here would fabricate a phantom vault
253
+ // entry at root in both the `services` catalog and the `vaults` array.
254
+ // This mirrors the empty-paths `continue` in admin-vaults.ts / vault-names.ts
255
+ // so every read path agrees: a vault instance exists only by a real
256
+ // `/vault/<name>` mount path. Non-vault services keep the `paths[0] ?? "/"`
257
+ // fallback (a path-less non-vault row legitimately mounts at root).
258
+ if (isVault && s.paths.length === 0) continue;
259
+ const pathsToEmit = isVault ? s.paths : [s.paths[0] ?? "/"];
251
260
  for (const path of pathsToEmit) {
252
261
  const url = new URL(path, `${base}/`).toString();
253
262
  const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();