@openparachute/vault 0.5.2-rc.3 → 0.5.2-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/src/cli.ts +22 -4
- 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 +99 -0
- package/src/vault-create.test.ts +12 -5
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.4",
|
|
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": {
|
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,
|
|
@@ -548,6 +550,11 @@ async function cmdInit(args: string[] = []) {
|
|
|
548
550
|
// 8. Summary
|
|
549
551
|
const port = globalConfig.port || DEFAULT_PORT;
|
|
550
552
|
const mcpUrl = `http://127.0.0.1:${port}/vault/${defaultVault}/mcp`;
|
|
553
|
+
// Probe whether a hub is present so the summary's "opted into a token but
|
|
554
|
+
// none minted" copy reflects reality: under a hub the vault is reachable via
|
|
555
|
+
// browser OAuth even with no header-auth token (#445). Only matters for the
|
|
556
|
+
// !apiKey branches; cheap + best-effort (never throws).
|
|
557
|
+
const hubPresent = !apiKey ? await detectHubPresence() : true;
|
|
551
558
|
const lines = buildInitSummaryLines({
|
|
552
559
|
addMcp,
|
|
553
560
|
addToken,
|
|
@@ -558,6 +565,7 @@ async function cmdInit(args: string[] = []) {
|
|
|
558
565
|
mcpUrl,
|
|
559
566
|
vaultName: defaultVault,
|
|
560
567
|
noTokenGuidance: credentialGuidance,
|
|
568
|
+
hubPresent,
|
|
561
569
|
});
|
|
562
570
|
for (const line of lines) console.log(line);
|
|
563
571
|
}
|
|
@@ -3369,15 +3377,25 @@ interface VaultCredential {
|
|
|
3369
3377
|
async function mintBootstrapCredential(
|
|
3370
3378
|
name: string,
|
|
3371
3379
|
verb: "read" | "write" = "read",
|
|
3380
|
+
/**
|
|
3381
|
+
* Test seam — injectable hub-presence probe. Defaults to the live
|
|
3382
|
+
* `detectHubPresence` (loopback `/health` + configured-origin check). Lets
|
|
3383
|
+
* tests drive both branches of the no-operator-token copy without a real hub.
|
|
3384
|
+
*/
|
|
3385
|
+
detectHub: typeof detectHubPresence = detectHubPresence,
|
|
3372
3386
|
): Promise<VaultCredential> {
|
|
3373
3387
|
const operatorToken = readOperatorToken();
|
|
3374
3388
|
if (!operatorToken) {
|
|
3389
|
+
// No operator.token. Two very different worlds, identical symptom:
|
|
3390
|
+
// (a) Hub running on a fresh box — the token isn't minted until the
|
|
3391
|
+
// admin wizard creates the first admin user (hub init Step 1.5 is a
|
|
3392
|
+
// no-op until then). NOTHING to do here; the old "install the hub …"
|
|
3393
|
+
// copy is circular (this very flow was spawned *by* the hub). #445.
|
|
3394
|
+
// (b) Genuinely standalone — no hub at all. The original guidance holds.
|
|
3395
|
+
const hubPresent = await detectHub();
|
|
3375
3396
|
return {
|
|
3376
3397
|
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.",
|
|
3398
|
+
guidance: noOperatorTokenGuidance(hubPresent),
|
|
3381
3399
|
};
|
|
3382
3400
|
}
|
|
3383
3401
|
const port = readGlobalConfig().port || DEFAULT_PORT;
|
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");
|
package/src/mcp-install.ts
CHANGED
|
@@ -216,6 +216,105 @@ export function readOperatorToken(env: NodeJS.ProcessEnv = process.env): string
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Hub-presence probe
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Default loopback port the hub binds. Mirrors `hub-jwt.ts`'s
|
|
225
|
+
* `DEFAULT_HUB_LOOPBACK` (`http://127.0.0.1:1939`). When no hub origin is
|
|
226
|
+
* configured (the common fresh-box case), this is where a co-located hub
|
|
227
|
+
* answers.
|
|
228
|
+
*/
|
|
229
|
+
export const DEFAULT_HUB_LOOPBACK_PORT = 1939;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Best-effort: is a hub actually present on this host *right now*?
|
|
233
|
+
*
|
|
234
|
+
* This is distinct from {@link InstallContext.hubReachable}, which only asks
|
|
235
|
+
* "is a non-loopback hub *origin* configured?" (env / expose-state). On a fresh
|
|
236
|
+
* box the hub is installed and running on loopback, but no origin is configured
|
|
237
|
+
* and the operator token isn't minted yet (hub mints it only when the first
|
|
238
|
+
* admin user is created in the web wizard). The stale standalone-era copy
|
|
239
|
+
* ("install the hub …") fires off `operatorTokenPresent === false` and so
|
|
240
|
+
* misreads that fresh-box state as "no hub". This probe lets the copy branch on
|
|
241
|
+
* whether a hub is genuinely absent vs. merely not-yet-bootstrapped.
|
|
242
|
+
*
|
|
243
|
+
* Signals, cheapest-first:
|
|
244
|
+
* 1. A configured non-loopback hub origin (`PARACHUTE_HUB_ORIGIN` /
|
|
245
|
+
* expose-state) → a hub origin exists, treat as present without a probe.
|
|
246
|
+
* 2. A live `GET http://127.0.0.1:<hubPort>/health` returning a 2xx. The
|
|
247
|
+
* hub binds its own fixed loopback port (1939 by default), independent of
|
|
248
|
+
* the vault's listen port — so the probe always targets the hub port, not
|
|
249
|
+
* `chooseHubOrigin`'s vault-loopback fallback. Short timeout; any error →
|
|
250
|
+
* not present.
|
|
251
|
+
*
|
|
252
|
+
* `port` is the hub's loopback port (defaults to `$PARACHUTE_HUB_PORT`, else
|
|
253
|
+
* 1939). `fetchImpl` is an injectable test seam; `timeoutMs` keeps a dead port
|
|
254
|
+
* from stalling init. Never throws — returns `false` on any failure.
|
|
255
|
+
*/
|
|
256
|
+
export async function detectHubPresence(opts: {
|
|
257
|
+
port?: number;
|
|
258
|
+
env?: { PARACHUTE_HUB_ORIGIN?: string | undefined; PARACHUTE_HUB_PORT?: string | undefined };
|
|
259
|
+
fetchImpl?: typeof fetch;
|
|
260
|
+
timeoutMs?: number;
|
|
261
|
+
} = {}): Promise<boolean> {
|
|
262
|
+
const env =
|
|
263
|
+
opts.env ?? (process.env as { PARACHUTE_HUB_ORIGIN?: string; PARACHUTE_HUB_PORT?: string });
|
|
264
|
+
// Port precedence: explicit arg → `$PARACHUTE_HUB_PORT` → 1939. The env
|
|
265
|
+
// override keeps the probe deterministic for tests + non-default-port hubs,
|
|
266
|
+
// so it never accidentally hits an unrelated hub on the host's 1939.
|
|
267
|
+
const envPort = env.PARACHUTE_HUB_PORT ? Number(env.PARACHUTE_HUB_PORT) : undefined;
|
|
268
|
+
const hubPort =
|
|
269
|
+
opts.port ?? (envPort !== undefined && Number.isFinite(envPort) ? envPort : DEFAULT_HUB_LOOPBACK_PORT);
|
|
270
|
+
// 1. A configured hub origin (env / expose-state) is itself a present-hub
|
|
271
|
+
// signal — no need to probe. We pass `hubPort` purely as the loopback
|
|
272
|
+
// fallback arg; its only role here is the source discriminator.
|
|
273
|
+
const configured = chooseHubOrigin(hubPort, env);
|
|
274
|
+
// A stale expose-state can false-positive here — acceptable: this only
|
|
275
|
+
// selects guidance copy, never gates behavior.
|
|
276
|
+
if (configured.source !== "loopback") return true;
|
|
277
|
+
|
|
278
|
+
// 2. Live health probe against the hub's fixed loopback port.
|
|
279
|
+
const origin = `http://127.0.0.1:${hubPort}`;
|
|
280
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
281
|
+
const timeoutMs = opts.timeoutMs ?? 800;
|
|
282
|
+
const controller = new AbortController();
|
|
283
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
284
|
+
try {
|
|
285
|
+
const res = await fetchImpl(`${origin}/health`, { signal: controller.signal });
|
|
286
|
+
return res.ok;
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
} finally {
|
|
290
|
+
clearTimeout(timer);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Pick the operator-facing guidance for the "no operator.token present" case,
|
|
296
|
+
* branched on whether a hub is genuinely absent vs. merely not-yet-bootstrapped
|
|
297
|
+
* (#445). Extracted as a pure function so the copy is unit-testable without
|
|
298
|
+
* importing cli.ts (which dispatches on import).
|
|
299
|
+
*
|
|
300
|
+
* hubPresent === true → a hub is running; the operator token just hasn't
|
|
301
|
+
* been minted yet (it's minted when the first admin
|
|
302
|
+
* user is created in the hub's web wizard). The old
|
|
303
|
+
* "install the hub …" advice is circular here — this
|
|
304
|
+
* flow was spawned *by* the hub. Tell them there's
|
|
305
|
+
* nothing to do and to finish in the wizard.
|
|
306
|
+
* hubPresent === false → genuinely standalone. Keep the original advice.
|
|
307
|
+
*/
|
|
308
|
+
export function noOperatorTokenGuidance(hubPresent: boolean): string {
|
|
309
|
+
return hubPresent
|
|
310
|
+
? "No token yet — the hub's admin wizard mints the operator token when you " +
|
|
311
|
+
"create the first admin user. Nothing to do here; finish setup in the wizard, " +
|
|
312
|
+
"then run `parachute-vault mcp-install` if you want a header-auth token for scripts."
|
|
313
|
+
: "No token issued — no hub operator token at ~/.parachute/operator.token. " +
|
|
314
|
+
"Install the hub (`bun add -g @openparachute/hub` + `parachute init`) and re-run, " +
|
|
315
|
+
"or set VAULT_AUTH_TOKEN for an operator-channel bearer.";
|
|
316
|
+
}
|
|
317
|
+
|
|
219
318
|
// ---------------------------------------------------------------------------
|
|
220
319
|
// Hub mint-token client
|
|
221
320
|
// ---------------------------------------------------------------------------
|
package/src/vault-create.test.ts
CHANGED
|
@@ -236,16 +236,23 @@ describe("vault create — OAuth-first auth (vault#442)", () => {
|
|
|
236
236
|
test("--mint (no hub reachable) opts in but mints scope-narrow read, never admin", () => {
|
|
237
237
|
// In this sandbox there's no hub/operator.token, so the mint can't complete
|
|
238
238
|
// — but the request is scope-narrow read by default and must NEVER ask for
|
|
239
|
-
// admin. We assert the create still succeeds and the guidance
|
|
240
|
-
//
|
|
239
|
+
// an admin grant. We assert the create still succeeds and the guidance is
|
|
240
|
+
// the standalone path (the scope requested is read, per
|
|
241
|
+
// mintBootstrapCredential).
|
|
242
|
+
//
|
|
243
|
+
// Point the hub-presence probe at a guaranteed-closed port so the test is
|
|
244
|
+
// deterministic regardless of whether a real hub happens to be running on
|
|
245
|
+
// the dev box's 1939 (#445 added a live `/health` probe to branch the
|
|
246
|
+
// no-operator-token copy).
|
|
241
247
|
const { exitCode, stdout } = runCli(
|
|
242
248
|
["create", "wantmint", "--mint", "--json"],
|
|
243
|
-
{ PARACHUTE_HOME: home },
|
|
249
|
+
{ PARACHUTE_HOME: home, PARACHUTE_HUB_PORT: "59399" },
|
|
244
250
|
);
|
|
245
251
|
expect(exitCode).toBe(0);
|
|
246
252
|
const payload = JSON.parse(stdout.trim());
|
|
247
|
-
// No hub here → no token,
|
|
248
|
-
//
|
|
253
|
+
// No hub here → no token, and the standalone guidance asks for NO admin
|
|
254
|
+
// grant (the #445 hub-present "admin wizard" copy is gated out by the dead
|
|
255
|
+
// probe port above).
|
|
249
256
|
expect(payload.token).toBe("");
|
|
250
257
|
expect(payload.token_guidance).not.toContain("admin");
|
|
251
258
|
});
|