@openparachute/vault 0.5.2-rc.3 → 0.5.2-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +98 -32
- package/src/init-summary.test.ts +44 -1
- package/src/init-summary.ts +34 -10
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/vault-create.test.ts +12 -5
- package/web/ui/dist/assets/{index-C4Sth1Tq.js → index-D8nCVT1e.js} +11 -11
- package/web/ui/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.5.2-rc.
|
|
3
|
+
"version": "0.5.2-rc.5",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
25
|
-
"postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
|
|
26
25
|
"prepack": "bun run build:spa"
|
|
27
26
|
},
|
|
28
27
|
"dependencies": {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { decideAutostart } from "./autostart.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure matrix for the autostart decision (ParachuteComputer/parachute-hub#580
|
|
6
|
+
* item 2). No launchd/systemd is touched — `decideAutostart` is side-effect
|
|
7
|
+
* free; the CLI consumes its result to register or skip.
|
|
8
|
+
*/
|
|
9
|
+
describe("decideAutostart", () => {
|
|
10
|
+
test("hub present, no flag, no persisted → default OFF (#580)", () => {
|
|
11
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: undefined, hubPresent: true });
|
|
12
|
+
expect(d.enabled).toBe(false);
|
|
13
|
+
expect(d.reason).toBe("hub-default-off");
|
|
14
|
+
// Per-run inference — not persisted, so a later standalone re-run registers.
|
|
15
|
+
expect(d.persist).toBe(false);
|
|
16
|
+
expect(d.overrodeHub).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("hub absent, no flag, no persisted → default ON (standalone)", () => {
|
|
20
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: undefined, hubPresent: false });
|
|
21
|
+
expect(d.enabled).toBe(true);
|
|
22
|
+
expect(d.reason).toBe("default-on");
|
|
23
|
+
expect(d.persist).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("explicit --autostart forces ON even under a hub (operator override + warn flag)", () => {
|
|
27
|
+
const d = decideAutostart({ flagOn: true, flagOff: false, persisted: undefined, hubPresent: true });
|
|
28
|
+
expect(d.enabled).toBe(true);
|
|
29
|
+
expect(d.reason).toBe("flag-on");
|
|
30
|
+
expect(d.persist).toBe(true);
|
|
31
|
+
expect(d.overrodeHub).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("explicit --autostart with no hub does not set overrodeHub", () => {
|
|
35
|
+
const d = decideAutostart({ flagOn: true, flagOff: false, persisted: undefined, hubPresent: false });
|
|
36
|
+
expect(d.enabled).toBe(true);
|
|
37
|
+
expect(d.overrodeHub).toBe(false);
|
|
38
|
+
expect(d.persist).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("explicit --no-autostart forces OFF and persists (even under a hub)", () => {
|
|
42
|
+
const d = decideAutostart({ flagOn: false, flagOff: true, persisted: undefined, hubPresent: true });
|
|
43
|
+
expect(d.enabled).toBe(false);
|
|
44
|
+
expect(d.reason).toBe("flag-off");
|
|
45
|
+
expect(d.persist).toBe(true);
|
|
46
|
+
expect(d.overrodeHub).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("--no-autostart wins over --autostart on the same line (safer default)", () => {
|
|
50
|
+
const d = decideAutostart({ flagOn: true, flagOff: true, persisted: undefined, hubPresent: false });
|
|
51
|
+
expect(d.enabled).toBe(false);
|
|
52
|
+
expect(d.reason).toBe("flag-off");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("persisted=false honored over hub-present default", () => {
|
|
56
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: false, hubPresent: true });
|
|
57
|
+
expect(d.enabled).toBe(false);
|
|
58
|
+
expect(d.reason).toBe("persisted");
|
|
59
|
+
expect(d.persist).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("persisted=true honored even when a hub is present (prior explicit choice)", () => {
|
|
63
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: true, hubPresent: true });
|
|
64
|
+
expect(d.enabled).toBe(true);
|
|
65
|
+
expect(d.reason).toBe("persisted");
|
|
66
|
+
expect(d.persist).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("flag beats persisted: --no-autostart over persisted=true", () => {
|
|
70
|
+
const d = decideAutostart({ flagOn: false, flagOff: true, persisted: true, hubPresent: false });
|
|
71
|
+
expect(d.enabled).toBe(false);
|
|
72
|
+
expect(d.reason).toBe("flag-off");
|
|
73
|
+
expect(d.persist).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/autostart.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether `parachute-vault init` should register a boot/restart daemon
|
|
3
|
+
* (launchd on macOS, systemd on Linux).
|
|
4
|
+
*
|
|
5
|
+
* Pure: extracted so the flag × persisted-config × hub-presence matrix can be
|
|
6
|
+
* unit-tested without spawning the CLI or touching launchd/systemd. The caller
|
|
7
|
+
* passes in the resolved `hubPresent` signal (from `detectHubPresence`) so this
|
|
8
|
+
* function stays side-effect-free.
|
|
9
|
+
*
|
|
10
|
+
* Why hub-presence flips the default (ParachuteComputer/parachute-hub#580):
|
|
11
|
+
* under hub-as-supervisor the hub owns the vault lifecycle — it spawns vault as
|
|
12
|
+
* a supervised child. If init *also* registers a launchd/systemd unit with
|
|
13
|
+
* `KeepAlive`/`RunAtLoad`, two lifecycles race for :1940: the supervisor's
|
|
14
|
+
* child and the platform manager's respawn. `parachute stop` kills the
|
|
15
|
+
* supervised one, launchd resurrects the other (EADDRINUSE crash-loop, pidfile
|
|
16
|
+
* records the loser, the rogue holds the port with no injected
|
|
17
|
+
* PARACHUTE_HUB_ORIGIN → iss mismatch). Defaulting autostart OFF when a hub is
|
|
18
|
+
* present makes the standalone daemon an explicit opt-in.
|
|
19
|
+
*
|
|
20
|
+
* Precedence (first match wins):
|
|
21
|
+
* 1. `--no-autostart` on this run → off (persisted; safer-default
|
|
22
|
+
* precedence beats --autostart)
|
|
23
|
+
* 2. `--autostart` on this run → on (persisted; operator
|
|
24
|
+
* override — caller warns if a
|
|
25
|
+
* supervised hub was detected)
|
|
26
|
+
* 3. Existing `config.autostart` (boolean) → that value (honor prior choice)
|
|
27
|
+
* 4. Hub present, no flag, no persisted value → off (the hub supervisor owns
|
|
28
|
+
* the lifecycle — #580)
|
|
29
|
+
* 5. Default → on (standalone deploys
|
|
30
|
+
* genuinely need a daemon)
|
|
31
|
+
*
|
|
32
|
+
* `persist` is true only when the choice came from an explicit flag (cases 1+2)
|
|
33
|
+
* — matching the prior behavior where a flagless re-run never rewrote the
|
|
34
|
+
* persisted value. The hub-present default (case 4) is intentionally NOT
|
|
35
|
+
* persisted: it's a per-run inference, so a later standalone re-run (no hub)
|
|
36
|
+
* falls back to the register default rather than being stuck off.
|
|
37
|
+
*
|
|
38
|
+
* `overrodeHub` is true only for case 2 when a hub was detected — the signal the
|
|
39
|
+
* caller uses to log the "supervised hub detected, registering anyway" warning.
|
|
40
|
+
*/
|
|
41
|
+
export interface AutostartDecisionInput {
|
|
42
|
+
/** `--autostart` present on this invocation. */
|
|
43
|
+
flagOn: boolean;
|
|
44
|
+
/** `--no-autostart` present on this invocation. */
|
|
45
|
+
flagOff: boolean;
|
|
46
|
+
/** Persisted `config.autostart` from a prior run, if a boolean. */
|
|
47
|
+
persisted?: boolean | undefined;
|
|
48
|
+
/** Whether a hub supervisor was detected (from `detectHubPresence`). */
|
|
49
|
+
hubPresent: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AutostartDecision {
|
|
53
|
+
/** Final resolved value. */
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
/** Whether the caller should write `config.autostart` to disk. */
|
|
56
|
+
persist: boolean;
|
|
57
|
+
/** True when `--autostart` forced registration despite a detected hub. */
|
|
58
|
+
overrodeHub: boolean;
|
|
59
|
+
/** Which precedence rule decided the outcome (for testing + copy). */
|
|
60
|
+
reason: "flag-off" | "flag-on" | "persisted" | "hub-default-off" | "default-on";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function decideAutostart(input: AutostartDecisionInput): AutostartDecision {
|
|
64
|
+
const { flagOn, flagOff, persisted, hubPresent } = input;
|
|
65
|
+
|
|
66
|
+
if (flagOff) {
|
|
67
|
+
return { enabled: false, persist: true, overrodeHub: false, reason: "flag-off" };
|
|
68
|
+
}
|
|
69
|
+
if (flagOn) {
|
|
70
|
+
return {
|
|
71
|
+
enabled: true,
|
|
72
|
+
persist: true,
|
|
73
|
+
overrodeHub: hubPresent,
|
|
74
|
+
reason: "flag-on",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (typeof persisted === "boolean") {
|
|
78
|
+
return { enabled: persisted, persist: false, overrodeHub: false, reason: "persisted" };
|
|
79
|
+
}
|
|
80
|
+
if (hubPresent) {
|
|
81
|
+
return { enabled: false, persist: false, overrodeHub: false, reason: "hub-default-off" };
|
|
82
|
+
}
|
|
83
|
+
return { enabled: true, persist: false, overrodeHub: false, reason: "default-on" };
|
|
84
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -57,8 +57,10 @@ import {
|
|
|
57
57
|
buildMcpEntryPlan,
|
|
58
58
|
chooseHubOrigin,
|
|
59
59
|
chooseMcpUrl,
|
|
60
|
+
detectHubPresence,
|
|
60
61
|
detectInstallContext,
|
|
61
62
|
mintHubJwt,
|
|
63
|
+
noOperatorTokenGuidance,
|
|
62
64
|
readOperatorToken,
|
|
63
65
|
removeMcpConfig,
|
|
64
66
|
resolveInstallTarget,
|
|
@@ -102,6 +104,7 @@ import { resolveBindHostname } from "./bind.ts";
|
|
|
102
104
|
import { listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
|
|
103
105
|
import { VAULT_SCOPES } from "./scopes.ts";
|
|
104
106
|
import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
|
|
107
|
+
import { decideAutostart } from "./autostart.ts";
|
|
105
108
|
import { getVaultStore } from "./vault-store.ts";
|
|
106
109
|
import {
|
|
107
110
|
defaultMirrorConfig,
|
|
@@ -273,12 +276,16 @@ async function cmdInit(args: string[] = []) {
|
|
|
273
276
|
const flagMcpOff = args.includes("--no-mcp");
|
|
274
277
|
const flagTokenOn = args.includes("--token");
|
|
275
278
|
const flagTokenOff = args.includes("--no-token");
|
|
276
|
-
// --autostart / --no-autostart toggle daemon registration.
|
|
277
|
-
// (
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
279
|
+
// --autostart / --no-autostart toggle daemon registration. The default is
|
|
280
|
+
// context-aware (resolved by decideAutostart below): ON for standalone
|
|
281
|
+
// deploys, but OFF when a hub supervisor is detected, since the hub owns
|
|
282
|
+
// vault's lifecycle and a launchd/systemd unit would race it for :1940
|
|
283
|
+
// (ParachuteComputer/parachute-hub#580). --no-autostart always skips
|
|
284
|
+
// registering AND removes any prior registration — for CI, dev sandboxes,
|
|
285
|
+
// Docker, or any environment where another supervisor manages the process.
|
|
286
|
+
// --autostart forces registration even under a hub (logged with a warning).
|
|
287
|
+
// --no-autostart wins over --autostart on the same command line
|
|
288
|
+
// (safer-default precedence).
|
|
282
289
|
const flagAutostartOn = args.includes("--autostart");
|
|
283
290
|
const flagAutostartOff = args.includes("--no-autostart");
|
|
284
291
|
|
|
@@ -415,37 +422,73 @@ async function cmdInit(args: string[] = []) {
|
|
|
415
422
|
// a folder move; this refreshes ~/.parachute/server-path and bounces the
|
|
416
423
|
// daemon so the new location takes effect immediately.
|
|
417
424
|
//
|
|
418
|
-
// Autostart precedence
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
// 3. Existing config.autostart
|
|
423
|
-
// 4.
|
|
425
|
+
// Autostart precedence is resolved by `decideAutostart` (pure, unit-tested):
|
|
426
|
+
// 1. --no-autostart on this run → false (and persisted)
|
|
427
|
+
// 2. --autostart on this run → true (and persisted; warns
|
|
428
|
+
// if a supervised hub was seen)
|
|
429
|
+
// 3. Existing config.autostart → that value
|
|
430
|
+
// 4. Hub present, no flag, no persisted val → false (the hub supervisor
|
|
431
|
+
// owns the lifecycle — #580)
|
|
432
|
+
// 5. Default → true (standalone deploys
|
|
433
|
+
// genuinely need a daemon)
|
|
424
434
|
// When false: skip register AND uninstall any prior registration so the
|
|
425
|
-
//
|
|
435
|
+
// decision's intent ("don't auto-start / don't auto-restart") matches reality
|
|
426
436
|
// even if a previous run had registered a daemon.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
437
|
+
//
|
|
438
|
+
// The hub probe runs only when neither flag was passed AND no value is
|
|
439
|
+
// persisted — i.e. only when the hub signal can actually change the outcome.
|
|
440
|
+
// It targets the hub's fixed loopback port (1939 / $PARACHUTE_HUB_PORT) and
|
|
441
|
+
// never throws (see detectHubPresence). We skip it when a flag/persisted
|
|
442
|
+
// value already decides, to avoid an 800ms wait on a flagged run.
|
|
443
|
+
//
|
|
444
|
+
// False-positive risk: a stale expose-state / leftover PARACHUTE_HUB_ORIGIN
|
|
445
|
+
// makes detectHubPresence return true on a genuinely hubless box, so init
|
|
446
|
+
// silently skips registering a daemon. Narrow + accepted — recover with
|
|
447
|
+
// `parachute-vault init --autostart`. The pre-decided guard below means any
|
|
448
|
+
// explicit flag or persisted value never even reaches the probe.
|
|
449
|
+
const autostartPreDecided =
|
|
450
|
+
flagAutostartOff || flagAutostartOn || typeof globalConfig.autostart === "boolean";
|
|
451
|
+
const hubPresentForAutostart = autostartPreDecided ? false : await detectHubPresence();
|
|
452
|
+
const autostartDecision = decideAutostart({
|
|
453
|
+
flagOn: flagAutostartOn,
|
|
454
|
+
flagOff: flagAutostartOff,
|
|
455
|
+
persisted: globalConfig.autostart,
|
|
456
|
+
hubPresent: hubPresentForAutostart,
|
|
457
|
+
});
|
|
458
|
+
const autostartEnabled = autostartDecision.enabled;
|
|
432
459
|
|
|
433
|
-
if (
|
|
460
|
+
if (autostartDecision.persist) {
|
|
434
461
|
globalConfig.autostart = autostartEnabled;
|
|
435
462
|
writeGlobalConfig(globalConfig);
|
|
436
463
|
}
|
|
437
464
|
|
|
438
465
|
let serverPath: string | null = null;
|
|
439
466
|
if (!autostartEnabled) {
|
|
440
|
-
|
|
467
|
+
if (autostartDecision.reason === "hub-default-off") {
|
|
468
|
+
console.log(
|
|
469
|
+
"Hub supervisor detected — not registering a separate daemon. The hub manages vault's lifecycle.",
|
|
470
|
+
);
|
|
471
|
+
console.log(" To force a standalone daemon anyway: parachute-vault init --autostart");
|
|
472
|
+
} else {
|
|
473
|
+
console.log("Autostart disabled — skipping daemon registration.");
|
|
474
|
+
}
|
|
441
475
|
if (isMac) {
|
|
442
476
|
await uninstallAgent();
|
|
443
477
|
} else if (isLinux && isSystemdAvailable()) {
|
|
444
478
|
await uninstallSystemdService();
|
|
445
479
|
}
|
|
446
480
|
console.log(" To run vault: parachute-vault serve (or use your own supervisor)");
|
|
447
|
-
|
|
481
|
+
if (autostartDecision.reason !== "hub-default-off") {
|
|
482
|
+
console.log(" To re-enable: parachute-vault init --autostart");
|
|
483
|
+
}
|
|
448
484
|
} else {
|
|
485
|
+
if (autostartDecision.overrodeHub) {
|
|
486
|
+
console.log(
|
|
487
|
+
"Warning: a supervised hub was detected, but --autostart was passed — registering a "
|
|
488
|
+
+ "standalone daemon anyway. This can race the hub supervisor for the vault port; "
|
|
489
|
+
+ "prefer letting the hub manage vault unless you know you need both.",
|
|
490
|
+
);
|
|
491
|
+
}
|
|
449
492
|
console.log("Installing daemon...");
|
|
450
493
|
if (isMac) {
|
|
451
494
|
({ serverPath } = await installAgent());
|
|
@@ -548,6 +591,11 @@ async function cmdInit(args: string[] = []) {
|
|
|
548
591
|
// 8. Summary
|
|
549
592
|
const port = globalConfig.port || DEFAULT_PORT;
|
|
550
593
|
const mcpUrl = `http://127.0.0.1:${port}/vault/${defaultVault}/mcp`;
|
|
594
|
+
// Probe whether a hub is present so the summary's "opted into a token but
|
|
595
|
+
// none minted" copy reflects reality: under a hub the vault is reachable via
|
|
596
|
+
// browser OAuth even with no header-auth token (#445). Only matters for the
|
|
597
|
+
// !apiKey branches; cheap + best-effort (never throws).
|
|
598
|
+
const hubPresent = !apiKey ? await detectHubPresence() : true;
|
|
551
599
|
const lines = buildInitSummaryLines({
|
|
552
600
|
addMcp,
|
|
553
601
|
addToken,
|
|
@@ -558,6 +606,7 @@ async function cmdInit(args: string[] = []) {
|
|
|
558
606
|
mcpUrl,
|
|
559
607
|
vaultName: defaultVault,
|
|
560
608
|
noTokenGuidance: credentialGuidance,
|
|
609
|
+
hubPresent,
|
|
561
610
|
});
|
|
562
611
|
for (const line of lines) console.log(line);
|
|
563
612
|
}
|
|
@@ -3369,15 +3418,25 @@ interface VaultCredential {
|
|
|
3369
3418
|
async function mintBootstrapCredential(
|
|
3370
3419
|
name: string,
|
|
3371
3420
|
verb: "read" | "write" = "read",
|
|
3421
|
+
/**
|
|
3422
|
+
* Test seam — injectable hub-presence probe. Defaults to the live
|
|
3423
|
+
* `detectHubPresence` (loopback `/health` + configured-origin check). Lets
|
|
3424
|
+
* tests drive both branches of the no-operator-token copy without a real hub.
|
|
3425
|
+
*/
|
|
3426
|
+
detectHub: typeof detectHubPresence = detectHubPresence,
|
|
3372
3427
|
): Promise<VaultCredential> {
|
|
3373
3428
|
const operatorToken = readOperatorToken();
|
|
3374
3429
|
if (!operatorToken) {
|
|
3430
|
+
// No operator.token. Two very different worlds, identical symptom:
|
|
3431
|
+
// (a) Hub running on a fresh box — the token isn't minted until the
|
|
3432
|
+
// admin wizard creates the first admin user (hub init Step 1.5 is a
|
|
3433
|
+
// no-op until then). NOTHING to do here; the old "install the hub …"
|
|
3434
|
+
// copy is circular (this very flow was spawned *by* the hub). #445.
|
|
3435
|
+
// (b) Genuinely standalone — no hub at all. The original guidance holds.
|
|
3436
|
+
const hubPresent = await detectHub();
|
|
3375
3437
|
return {
|
|
3376
3438
|
token: null,
|
|
3377
|
-
guidance:
|
|
3378
|
-
"No token issued — no hub operator token at ~/.parachute/operator.token. " +
|
|
3379
|
-
"Install the hub (`bun add -g @openparachute/hub` + `parachute init`) and re-run, " +
|
|
3380
|
-
"or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
|
|
3439
|
+
guidance: noOperatorTokenGuidance(hubPresent),
|
|
3381
3440
|
};
|
|
3382
3441
|
}
|
|
3383
3442
|
const port = readGlobalConfig().port || DEFAULT_PORT;
|
|
@@ -3639,13 +3698,20 @@ Setup:
|
|
|
3639
3698
|
--vault-name skips the prompt and names the vault
|
|
3640
3699
|
(lowercase alphanumeric, hyphens, underscores;
|
|
3641
3700
|
omit to be prompted interactively, default "default").
|
|
3642
|
-
--autostart
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3701
|
+
--autostart registers vault with launchd / systemd so
|
|
3702
|
+
it starts on boot AND auto-restarts on crash; it forces
|
|
3703
|
+
registration even when a hub supervisor is detected
|
|
3704
|
+
(logged with a warning). --no-autostart skips daemon
|
|
3705
|
+
registration AND uninstalls any prior registration — for
|
|
3706
|
+
CI, dev sandboxes, Docker, or environments where another
|
|
3707
|
+
supervisor manages the process. Default: register when
|
|
3708
|
+
standalone, but skip when a hub is detected (the hub
|
|
3709
|
+
supervisor owns vault's lifecycle). An explicit flag
|
|
3710
|
+
persists in config.yaml as 'autostart: true|false'.
|
|
3711
|
+
Upgrade note: a box with a persisted 'autostart: true'
|
|
3712
|
+
(from an earlier explicit --autostart) keeps registering
|
|
3713
|
+
even under a hub — run init --no-autostart once to clear
|
|
3714
|
+
it and let the hub manage vault.
|
|
3649
3715
|
parachute-vault doctor Diagnose install/config issues
|
|
3650
3716
|
parachute-vault uninstall [--wipe] [--yes]
|
|
3651
3717
|
Remove daemon + MCP entry; --wipe also removes vaults, .env,
|
package/src/init-summary.test.ts
CHANGED
|
@@ -175,19 +175,62 @@ describe("buildInitSummaryLines", () => {
|
|
|
175
175
|
|
|
176
176
|
// Explicit opt-in but no hub reachable to mint (vault#282 Stage 2 path,
|
|
177
177
|
// reached only when the operator passes --token without a hub).
|
|
178
|
-
describe("MCP=N + token=Y but no hub (opt-in mint failed)", () => {
|
|
178
|
+
describe("MCP=N + token=Y but no hub (opt-in mint failed, standalone)", () => {
|
|
179
179
|
const out = buildInitSummaryLines({
|
|
180
180
|
...baseInput,
|
|
181
181
|
addMcp: false,
|
|
182
182
|
addToken: true,
|
|
183
183
|
apiKey: undefined,
|
|
184
184
|
noTokenGuidance: "No token issued — hub unreachable.",
|
|
185
|
+
hubPresent: false,
|
|
185
186
|
}).join("\n");
|
|
186
187
|
|
|
187
188
|
test("surfaces the no-token-issued guidance + recovery", () => {
|
|
188
189
|
expect(out).toContain("No token issued");
|
|
189
190
|
expect(out).toContain("parachute-vault mcp-install");
|
|
190
191
|
});
|
|
192
|
+
|
|
193
|
+
test("standalone framing — points at bringing a hub up / VAULT_AUTH_TOKEN", () => {
|
|
194
|
+
expect(out).toContain("Once a hub is running");
|
|
195
|
+
expect(out).toContain("VAULT_AUTH_TOKEN");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("does NOT claim the vault is reachable (no hub present)", () => {
|
|
199
|
+
expect(out).not.toContain("Your vault is still reachable");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// #445: opted into a token, none minted, but a HUB IS PRESENT. The vault is
|
|
204
|
+
// reachable via the hub's browser OAuth flow even with no header-auth token,
|
|
205
|
+
// so the standalone "isn't reachable" framing would be false here.
|
|
206
|
+
describe("MCP=N + token=Y, no token minted, but hub present (#445)", () => {
|
|
207
|
+
const out = buildInitSummaryLines({
|
|
208
|
+
...baseInput,
|
|
209
|
+
addMcp: false,
|
|
210
|
+
addToken: true,
|
|
211
|
+
apiKey: undefined,
|
|
212
|
+
noTokenGuidance: "No token yet — the hub's admin wizard mints it.",
|
|
213
|
+
hubPresent: true,
|
|
214
|
+
}).join("\n");
|
|
215
|
+
|
|
216
|
+
test("affirms the vault is still reachable via the hub's OAuth flow", () => {
|
|
217
|
+
expect(out).toContain("Your vault is still reachable");
|
|
218
|
+
expect(out).toContain("sign-in (OAuth)");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("frames a header-auth token as optional (scripts / non-OAuth clients)", () => {
|
|
222
|
+
expect(out).toContain("only needed for scripts");
|
|
223
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("does NOT print the standalone 'Once a hub is running' / VAULT_AUTH_TOKEN copy", () => {
|
|
227
|
+
expect(out).not.toContain("Once a hub is running");
|
|
228
|
+
expect(out).not.toContain("VAULT_AUTH_TOKEN");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("never claims the vault isn't reachable by any client", () => {
|
|
232
|
+
expect(out).not.toContain("isn't reachable by any client");
|
|
233
|
+
});
|
|
191
234
|
});
|
|
192
235
|
|
|
193
236
|
test("always prints Config: and Server: lines", () => {
|
package/src/init-summary.ts
CHANGED
|
@@ -26,6 +26,15 @@ export type InitSummaryInput = {
|
|
|
26
26
|
* undefined, so they know why and how to make the vault reachable.
|
|
27
27
|
*/
|
|
28
28
|
noTokenGuidance?: string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a hub is present on this host (live `/health` probe or a
|
|
31
|
+
* configured hub origin — see `detectHubPresence`). Branches the
|
|
32
|
+
* opted-into-a-token-but-none-minted copy: under a hub the vault is reachable
|
|
33
|
+
* via the hub's browser OAuth flow even with no header-auth token, so the
|
|
34
|
+
* old "your vault isn't reachable by any client" framing is false. #445.
|
|
35
|
+
* Undefined → treat as the conservative standalone case.
|
|
36
|
+
*/
|
|
37
|
+
hubPresent?: boolean | undefined;
|
|
29
38
|
};
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -45,7 +54,7 @@ export type InitSummaryInput = {
|
|
|
45
54
|
* !addMcp, !addToken → OAuth-first: add Claude Code later
|
|
46
55
|
*/
|
|
47
56
|
export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
48
|
-
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance } = input;
|
|
57
|
+
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance, hubPresent } = input;
|
|
49
58
|
const lines: string[] = [];
|
|
50
59
|
lines.push("");
|
|
51
60
|
lines.push("---");
|
|
@@ -75,20 +84,35 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
|
75
84
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
76
85
|
lines.push(` - Won't be shown again — save it now.`);
|
|
77
86
|
} else if (!addMcp && addToken && !apiKey) {
|
|
78
|
-
// Explicitly opted into a token but
|
|
79
|
-
//
|
|
80
|
-
// why and the recovery paths.
|
|
87
|
+
// Explicitly opted into a token but none was minted (vault#282 Stage 2 —
|
|
88
|
+
// vault no longer mints local pvt_* tokens). Surface why + recovery.
|
|
81
89
|
lines.push("");
|
|
82
90
|
lines.push(
|
|
83
91
|
noTokenGuidance ??
|
|
84
92
|
"No token issued — no hub was reachable to mint a hub JWT.",
|
|
85
93
|
);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
if (hubPresent) {
|
|
95
|
+
// A hub IS present — the vault is already reachable via the hub's
|
|
96
|
+
// browser OAuth flow / web UI. A header-auth token is optional, only for
|
|
97
|
+
// non-OAuth clients + scripts. The "isn't reachable" framing is false
|
|
98
|
+
// here (#445).
|
|
99
|
+
lines.push(
|
|
100
|
+
" Your vault is still reachable — clients connect through the hub's browser",
|
|
101
|
+
);
|
|
102
|
+
lines.push(
|
|
103
|
+
" sign-in (OAuth); a header-auth token is only needed for scripts / non-OAuth",
|
|
104
|
+
);
|
|
105
|
+
lines.push(
|
|
106
|
+
" clients. Run `parachute-vault mcp-install` to mint + wire one when you want it.",
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
lines.push(
|
|
110
|
+
" Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
|
|
111
|
+
);
|
|
112
|
+
lines.push(
|
|
113
|
+
" or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
92
116
|
} else if (!addMcp && !addToken) {
|
|
93
117
|
// OAuth-first, but the operator skipped wiring Claude Code too.
|
|
94
118
|
lines.push("");
|
package/src/mcp-install.test.ts
CHANGED
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
buildMcpEntryPlan,
|
|
24
24
|
chooseHubOrigin,
|
|
25
25
|
chooseMcpUrl,
|
|
26
|
+
detectHubPresence,
|
|
26
27
|
mintHubJwt,
|
|
28
|
+
noOperatorTokenGuidance,
|
|
27
29
|
readOperatorToken,
|
|
28
30
|
removeMcpConfig,
|
|
29
31
|
resolveInstallTarget,
|
|
@@ -315,6 +317,97 @@ describe("readOperatorToken", () => {
|
|
|
315
317
|
});
|
|
316
318
|
});
|
|
317
319
|
|
|
320
|
+
describe("detectHubPresence", () => {
|
|
321
|
+
let origHome: string | undefined;
|
|
322
|
+
let tmpHome: string;
|
|
323
|
+
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
origHome = process.env.PARACHUTE_HOME;
|
|
326
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-hub-presence-"));
|
|
327
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
afterEach(() => {
|
|
331
|
+
if (origHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
332
|
+
else process.env.PARACHUTE_HOME = origHome;
|
|
333
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("a configured non-loopback hub origin counts as present (no probe)", async () => {
|
|
337
|
+
let probed = false;
|
|
338
|
+
const mockFetch: typeof fetch = async () => {
|
|
339
|
+
probed = true;
|
|
340
|
+
return new Response(null, { status: 500 });
|
|
341
|
+
};
|
|
342
|
+
const present = await detectHubPresence({
|
|
343
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://hub.example" },
|
|
344
|
+
fetchImpl: mockFetch,
|
|
345
|
+
});
|
|
346
|
+
expect(present).toBe(true);
|
|
347
|
+
expect(probed).toBe(false); // configured origin short-circuits the probe
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("loopback + healthy hub (2xx /health) → present", async () => {
|
|
351
|
+
const calls: string[] = [];
|
|
352
|
+
const mockFetch: typeof fetch = async (url) => {
|
|
353
|
+
calls.push(String(url));
|
|
354
|
+
return new Response("ok", { status: 200 });
|
|
355
|
+
};
|
|
356
|
+
const present = await detectHubPresence({ env: {}, fetchImpl: mockFetch });
|
|
357
|
+
expect(present).toBe(true);
|
|
358
|
+
expect(calls).toHaveLength(1);
|
|
359
|
+
// Probes the hub's fixed loopback port (1939), not vault's listen port.
|
|
360
|
+
expect(calls[0]).toBe("http://127.0.0.1:1939/health");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("loopback + no hub answering (fetch throws) → absent", async () => {
|
|
364
|
+
const mockFetch: typeof fetch = async () => {
|
|
365
|
+
throw new Error("ECONNREFUSED");
|
|
366
|
+
};
|
|
367
|
+
const present = await detectHubPresence({ env: {}, fetchImpl: mockFetch });
|
|
368
|
+
expect(present).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("loopback + hub answers non-2xx → absent", async () => {
|
|
372
|
+
const mockFetch: typeof fetch = async () => new Response("nope", { status: 503 });
|
|
373
|
+
const present = await detectHubPresence({ env: {}, fetchImpl: mockFetch });
|
|
374
|
+
expect(present).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("$PARACHUTE_HUB_PORT overrides the probed port (deterministic for tests)", async () => {
|
|
378
|
+
const calls: string[] = [];
|
|
379
|
+
const mockFetch: typeof fetch = async (url) => {
|
|
380
|
+
calls.push(String(url));
|
|
381
|
+
return new Response("ok", { status: 200 });
|
|
382
|
+
};
|
|
383
|
+
const present = await detectHubPresence({
|
|
384
|
+
env: { PARACHUTE_HUB_PORT: "59399" },
|
|
385
|
+
fetchImpl: mockFetch,
|
|
386
|
+
});
|
|
387
|
+
expect(present).toBe(true);
|
|
388
|
+
expect(calls[0]).toBe("http://127.0.0.1:59399/health");
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("noOperatorTokenGuidance (#445)", () => {
|
|
393
|
+
test("hub present → non-circular 'finish in the wizard' copy", () => {
|
|
394
|
+
const msg = noOperatorTokenGuidance(true);
|
|
395
|
+
// Does NOT tell the operator to install the hub (circular — this flow ran
|
|
396
|
+
// *under* the hub).
|
|
397
|
+
expect(msg).not.toContain("Install the hub");
|
|
398
|
+
expect(msg).not.toContain("bun add -g @openparachute/hub");
|
|
399
|
+
expect(msg).toContain("admin wizard mints");
|
|
400
|
+
expect(msg).toContain("Nothing to do here");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("hub absent → keeps the standalone install-the-hub advice", () => {
|
|
404
|
+
const msg = noOperatorTokenGuidance(false);
|
|
405
|
+
expect(msg).toContain("Install the hub");
|
|
406
|
+
expect(msg).toContain("bun add -g @openparachute/hub");
|
|
407
|
+
expect(msg).toContain("VAULT_AUTH_TOKEN");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
318
411
|
describe("resolveInstallTarget", () => {
|
|
319
412
|
test("user scope → ~/.claude.json", () => {
|
|
320
413
|
const res = resolveInstallTarget("user");
|