@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2
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/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/src/config.ts
CHANGED
|
@@ -34,8 +34,11 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSy
|
|
|
34
34
|
import crypto from "node:crypto";
|
|
35
35
|
|
|
36
36
|
import {
|
|
37
|
+
// vault#400: only the PARSE side is used here now — to detect a legacy
|
|
38
|
+
// server-wide `mirror:` block so the boot migration can relocate it to the
|
|
39
|
+
// owning vault's per-vault file. `writeGlobalConfig` no longer serializes a
|
|
40
|
+
// mirror block (per-vault writes live in `mirror-config.ts`).
|
|
37
41
|
parseMirrorConfig as parseMirrorSectionFromYaml,
|
|
38
|
-
serializeMirrorConfig as serializeMirrorSection,
|
|
39
42
|
type MirrorConfig as MirrorConfigType,
|
|
40
43
|
} from "./mirror-config.ts";
|
|
41
44
|
|
|
@@ -1330,9 +1333,14 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
1330
1333
|
lines.push(...serializeBackup(config.backup));
|
|
1331
1334
|
}
|
|
1332
1335
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
+
// vault#400: mirror config is now PER-VAULT (`data/<vault>/mirror-config.yaml`),
|
|
1337
|
+
// not a server-wide block here. `writeGlobalConfig` deliberately does NOT
|
|
1338
|
+
// re-emit a `mirror:` block — doing so would resurrect the legacy
|
|
1339
|
+
// server-wide config the boot migration just commented out, re-introducing
|
|
1340
|
+
// the "same remote on every vault page" bug. `readGlobalConfig` still parses
|
|
1341
|
+
// a legacy block (below) so the one-time migration can detect + relocate it.
|
|
1342
|
+
// `serializeMirrorSection` remains used by the per-vault writer in
|
|
1343
|
+
// `mirror-config.ts`.
|
|
1336
1344
|
|
|
1337
1345
|
if (config.auto_transcribe) {
|
|
1338
1346
|
lines.push("auto_transcribe:");
|
package/src/export-watch.test.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
runGitCommitCycle,
|
|
36
36
|
shouldCommit,
|
|
37
37
|
} from "./export-watch.ts";
|
|
38
|
+
import { GitNotInstalledError } from "./git-preflight.ts";
|
|
38
39
|
|
|
39
40
|
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
40
41
|
|
|
@@ -504,6 +505,28 @@ describe("runGitCommitCycle", () => {
|
|
|
504
505
|
});
|
|
505
506
|
expect(result.message).toBe("note: Inbox/DonorMeeting");
|
|
506
507
|
});
|
|
508
|
+
|
|
509
|
+
test("git missing → throws GitNotInstalledError (sync surfaces friendly error, not raw spawn crash)", async () => {
|
|
510
|
+
// vault#415 — the sync/commit path must surface the actionable
|
|
511
|
+
// git-not-installed message (which the manager threads into
|
|
512
|
+
// status.last_error) instead of crashing with a raw "Executable not
|
|
513
|
+
// found in $PATH". Force the preflight to see no git via the `which`
|
|
514
|
+
// seam; no real spawn should be reached.
|
|
515
|
+
fs.writeFileSync(path.join(dir, "Note.md"), "# n\n");
|
|
516
|
+
await expect(
|
|
517
|
+
runGitCommitCycle({
|
|
518
|
+
repoDir: dir,
|
|
519
|
+
template: DEFAULT_COMMIT_TEMPLATE,
|
|
520
|
+
notesChanged: 1,
|
|
521
|
+
vaultName: "default",
|
|
522
|
+
firstNoteTitle: "Note",
|
|
523
|
+
push: false,
|
|
524
|
+
which: () => null,
|
|
525
|
+
}),
|
|
526
|
+
).rejects.toBeInstanceOf(GitNotInstalledError);
|
|
527
|
+
// The commit cycle bailed at the preflight — no commit landed.
|
|
528
|
+
expect(gitLogOneline(dir)).toHaveLength(1); // only the seed
|
|
529
|
+
});
|
|
507
530
|
});
|
|
508
531
|
|
|
509
532
|
// ---------------------------------------------------------------------------
|
package/src/export-watch.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
17
|
+
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Commit message templating
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
@@ -269,11 +271,23 @@ export async function runGitCommitCycle(opts: {
|
|
|
269
271
|
push: boolean;
|
|
270
272
|
/** Override for tests — defaults to `new Date().toISOString()`. */
|
|
271
273
|
now?: () => string;
|
|
274
|
+
/**
|
|
275
|
+
* Override the git-presence probe (test seam — defaults to `Bun.which`).
|
|
276
|
+
* Inject a fn returning `null` to exercise the git-not-installed path.
|
|
277
|
+
*/
|
|
278
|
+
which?: (cmd: string) => string | null;
|
|
272
279
|
}): Promise<{
|
|
273
280
|
committed: boolean;
|
|
274
281
|
message?: string;
|
|
275
282
|
push?: { attempted: true; ok: boolean; error?: string };
|
|
276
283
|
}> {
|
|
284
|
+
// Preflight: every step below shells `git`. On a git-less server the first
|
|
285
|
+
// `Bun.spawn(["git", ...])` would throw a raw "Executable not found" error;
|
|
286
|
+
// surface the friendly, actionable GitNotInstalledError so callers can
|
|
287
|
+
// thread it into mirror status (`last_error`) instead of crashing the
|
|
288
|
+
// watch loop with an opaque message.
|
|
289
|
+
ensureGitAvailable(opts.which);
|
|
290
|
+
|
|
277
291
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
278
292
|
|
|
279
293
|
const add = await gitAddAll(opts.repoDir);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared git-availability preflight (vault#415).
|
|
3
|
+
*
|
|
4
|
+
* Found live: importing a repo on a git-less Amazon Linux EC2 box failed
|
|
5
|
+
* with a raw `Executable not found in $PATH: "git"` 500. The preflight gives
|
|
6
|
+
* every git entry point a fast, friendly, actionable failure instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
GitNotInstalledError,
|
|
12
|
+
ensureGitAvailable,
|
|
13
|
+
isGitNotFoundSpawnError,
|
|
14
|
+
} from "./git-preflight.ts";
|
|
15
|
+
|
|
16
|
+
describe("ensureGitAvailable", () => {
|
|
17
|
+
test("throws GitNotInstalledError when which returns null", () => {
|
|
18
|
+
expect(() => ensureGitAvailable(() => null)).toThrow(GitNotInstalledError);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("does not throw when which resolves git", () => {
|
|
22
|
+
expect(() => ensureGitAvailable(() => "/usr/bin/git")).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("defaults to Bun.which (git is present in this test env)", () => {
|
|
26
|
+
// The test host has git; the default-arg path resolves it cleanly.
|
|
27
|
+
expect(() => ensureGitAvailable()).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("GitNotInstalledError message", () => {
|
|
32
|
+
test("is OS-agnostic-but-helpful — names dnf, apt-get, and brew", () => {
|
|
33
|
+
const msg = new GitNotInstalledError().message;
|
|
34
|
+
expect(msg).toContain("git is required for this operation");
|
|
35
|
+
expect(msg).toContain("sudo dnf install git");
|
|
36
|
+
expect(msg).toContain("sudo apt-get install -y git");
|
|
37
|
+
expect(msg).toContain("brew install git");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("carries the GitNotInstalledError name (instanceof + name both work)", () => {
|
|
41
|
+
const err = new GitNotInstalledError();
|
|
42
|
+
expect(err).toBeInstanceOf(GitNotInstalledError);
|
|
43
|
+
expect(err.name).toBe("GitNotInstalledError");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isGitNotFoundSpawnError", () => {
|
|
48
|
+
test("matches Bun's executable-not-found message for git", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isGitNotFoundSpawnError(
|
|
51
|
+
new Error('Executable not found in $PATH: "git"'),
|
|
52
|
+
),
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("matches an ENOENT spawn error mentioning git", () => {
|
|
57
|
+
const err = new Error("spawn git ENOENT") as Error & { code?: string };
|
|
58
|
+
err.code = "ENOENT";
|
|
59
|
+
expect(isGitNotFoundSpawnError(err)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not match an unrelated error", () => {
|
|
63
|
+
expect(isGitNotFoundSpawnError(new Error("network unreachable"))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not match a non-Error value", () => {
|
|
67
|
+
expect(isGitNotFoundSpawnError("git missing")).toBe(false);
|
|
68
|
+
expect(isGitNotFoundSpawnError(null)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared git-availability preflight.
|
|
3
|
+
*
|
|
4
|
+
* Every git-using entry point in vault (mirror import, mirror sync/commit/
|
|
5
|
+
* push, internal-mirror bootstrap) shells out to the `git` binary via
|
|
6
|
+
* `Bun.spawn(["git", ...])`. On a server where `git` isn't installed (a
|
|
7
|
+
* fresh Amazon Linux / minimal Docker image, etc.) Bun throws a raw
|
|
8
|
+
* `Executable not found in $PATH: "git"` error, which the import route only
|
|
9
|
+
* caught in its generic `internal` 500 branch — surfacing an unhelpful,
|
|
10
|
+
* un-actionable error to the operator.
|
|
11
|
+
*
|
|
12
|
+
* This module centralizes the preflight so every git entry point fails
|
|
13
|
+
* fast with a clear, actionable message that tells the operator HOW to
|
|
14
|
+
* fix it (install git via their distro's package manager).
|
|
15
|
+
*
|
|
16
|
+
* Found live on the gitcoin-parachute EC2 deploy (Amazon Linux, no git).
|
|
17
|
+
* See vault#415-era fix.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Thrown when `git` is required for an operation but isn't on PATH. Carries
|
|
22
|
+
* an OS-agnostic-but-helpful message with the common install commands so the
|
|
23
|
+
* operator can act without leaving the error surface.
|
|
24
|
+
*/
|
|
25
|
+
export class GitNotInstalledError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(
|
|
28
|
+
"git is required for this operation but was not found on the server. " +
|
|
29
|
+
"Install git and retry — e.g. `sudo dnf install git` (Amazon Linux / Fedora), " +
|
|
30
|
+
"`sudo apt-get install -y git` (Debian / Ubuntu), or `brew install git` (macOS).",
|
|
31
|
+
);
|
|
32
|
+
this.name = "GitNotInstalledError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Throw `GitNotInstalledError` if `git` isn't resolvable on PATH.
|
|
38
|
+
*
|
|
39
|
+
* `which` is a TEST SEAM (default `Bun.which`) so tests can force the
|
|
40
|
+
* git-missing branch without uninstalling git from the test host. Production
|
|
41
|
+
* callers pass nothing and get the real `Bun.which`.
|
|
42
|
+
*/
|
|
43
|
+
export function ensureGitAvailable(
|
|
44
|
+
which: (cmd: string) => string | null = Bun.which,
|
|
45
|
+
): void {
|
|
46
|
+
if (which("git") === null) {
|
|
47
|
+
throw new GitNotInstalledError();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Heuristic: does this error look like the "git executable not found" failure
|
|
53
|
+
* Bun throws when it can't resolve the binary? Used as a belt-and-suspenders
|
|
54
|
+
* catch around `Bun.spawn(["git", ...])` so a spawn that slips past the
|
|
55
|
+
* preflight (race where git is removed between check and spawn, or a code path
|
|
56
|
+
* that didn't preflight) still surfaces the friendly error instead of the raw
|
|
57
|
+
* `Executable not found in $PATH: "git"` string.
|
|
58
|
+
*/
|
|
59
|
+
export function isGitNotFoundSpawnError(err: unknown): boolean {
|
|
60
|
+
if (!(err instanceof Error)) return false;
|
|
61
|
+
const msg = err.message ?? "";
|
|
62
|
+
// Bun: `Executable not found in $PATH: "git"`.
|
|
63
|
+
// Node/posix: ENOENT spawn errors mention the missing file.
|
|
64
|
+
return (
|
|
65
|
+
(msg.includes("Executable not found") && msg.includes("git")) ||
|
|
66
|
+
((err as { code?: string }).code === "ENOENT" && msg.includes("git"))
|
|
67
|
+
);
|
|
68
|
+
}
|
package/src/hub-jwt.test.ts
CHANGED
|
@@ -87,15 +87,19 @@ interface SignOpts {
|
|
|
87
87
|
expiresAtSeconds?: number;
|
|
88
88
|
omitKid?: boolean;
|
|
89
89
|
kid?: string;
|
|
90
|
+
/** `permissions` claim (auth-unification C0). Undefined → omit. */
|
|
91
|
+
permissions?: unknown;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
93
95
|
const iat = Math.floor(Date.now() / 1000);
|
|
94
96
|
const exp = opts.expiresAtSeconds ?? iat + (opts.ttlSeconds ?? 60);
|
|
95
|
-
const
|
|
97
|
+
const claims: Record<string, unknown> = {
|
|
96
98
|
scope: opts.scope ?? "vault:read vault:write",
|
|
97
99
|
client_id: opts.clientId ?? "test-client",
|
|
98
|
-
}
|
|
100
|
+
};
|
|
101
|
+
if (opts.permissions !== undefined) claims.permissions = opts.permissions;
|
|
102
|
+
const builder = new SignJWT(claims)
|
|
99
103
|
.setProtectedHeader(opts.omitKid ? { alg: "RS256" } : { alg: "RS256", kid: opts.kid ?? kp.kid })
|
|
100
104
|
.setIssuer(opts.iss ?? "http://issuer.invalid")
|
|
101
105
|
.setSubject(opts.sub ?? "user-1")
|
|
@@ -187,6 +191,27 @@ describe("validateHubJwt — happy path", () => {
|
|
|
187
191
|
const claims = await validateHubJwt(token, { expectedAudience: "vault.work" });
|
|
188
192
|
expect(claims.aud).toBe("vault.work");
|
|
189
193
|
});
|
|
194
|
+
|
|
195
|
+
test("permissions claim surfaces on the validated result (C0)", async () => {
|
|
196
|
+
const token = await signJwt(kp, {
|
|
197
|
+
iss: fixture.origin,
|
|
198
|
+
permissions: { scoped_tags: ["health", "finance"] },
|
|
199
|
+
});
|
|
200
|
+
const claims = await validateHubJwt(token);
|
|
201
|
+
expect(claims.permissions).toEqual({ scoped_tags: ["health", "finance"] });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("no permissions claim → permissions is undefined", async () => {
|
|
205
|
+
const token = await signJwt(kp, { iss: fixture.origin });
|
|
206
|
+
const claims = await validateHubJwt(token);
|
|
207
|
+
expect(claims.permissions).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("non-object permissions claim → permissions is undefined (not surfaced)", async () => {
|
|
211
|
+
const token = await signJwt(kp, { iss: fixture.origin, permissions: "not-an-object" });
|
|
212
|
+
const claims = await validateHubJwt(token);
|
|
213
|
+
expect(claims.permissions).toBeUndefined();
|
|
214
|
+
});
|
|
190
215
|
});
|
|
191
216
|
|
|
192
217
|
describe("validateHubJwt — audience strict-check", () => {
|
package/src/hub-jwt.ts
CHANGED
|
@@ -59,6 +59,16 @@ const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
|
|
|
59
59
|
* Scope-shape policy (e.g. "hub-issued tokens may not carry broad
|
|
60
60
|
* `vault:<verb>` scopes") is enforced one layer up in `authenticateHubJwt`,
|
|
61
61
|
* not here — this function stays focused on JWT-level concerns.
|
|
62
|
+
*
|
|
63
|
+
* The returned `HubJwtClaims` carries the native `permissions` claim
|
|
64
|
+
* (scope-guard ≥0.4.0-rc.2 parses + surfaces it; `undefined` when absent or
|
|
65
|
+
* not a JSON object). Tag-scope enforcement reads `permissions.scoped_tags`
|
|
66
|
+
* in `authenticateHubJwt` — see auth-unification arc C0.
|
|
67
|
+
*
|
|
68
|
+
* jti policy: scope-guard's `createScopeGuard` defaults `allowMissingJti:
|
|
69
|
+
* false` (per hub#218 / scope-guard #322), so a hub JWT lacking a `jti`
|
|
70
|
+
* claim is rejected here. Vault doesn't opt out — every hub mint stamps a
|
|
71
|
+
* jti, and revocation can't be enforced on tokens we can't index.
|
|
62
72
|
*/
|
|
63
73
|
export async function validateHubJwt(
|
|
64
74
|
token: string,
|
package/src/init-summary.test.ts
CHANGED
|
@@ -57,9 +57,9 @@ describe("buildInitSummaryLines", () => {
|
|
|
57
57
|
expect(out).not.toContain("Baked into ~/.claude.json");
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test("includes the
|
|
60
|
+
test("includes the mcp-install-later hint", () => {
|
|
61
61
|
expect(out).toContain("Token in ~/.claude.json");
|
|
62
|
-
expect(out).toContain("parachute
|
|
62
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test("omits the Bearer curl example", () => {
|
|
@@ -103,9 +103,9 @@ describe("buildInitSummaryLines", () => {
|
|
|
103
103
|
expect(out).toContain("your vault isn't reachable by any client");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
test("points to
|
|
106
|
+
test("points to the mcp-install recovery path (hub JWT)", () => {
|
|
107
107
|
expect(out).toContain("parachute-vault mcp-install");
|
|
108
|
-
expect(out).toContain("
|
|
108
|
+
expect(out).toContain("mints a hub JWT");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
test("does not print any token", () => {
|
package/src/init-summary.ts
CHANGED
|
@@ -12,46 +12,73 @@ export type InitSummaryInput = {
|
|
|
12
12
|
bindHost: string;
|
|
13
13
|
port: number;
|
|
14
14
|
mcpUrl: string;
|
|
15
|
+
/**
|
|
16
|
+
* Guidance from the bootstrap-credential step when no token could be issued
|
|
17
|
+
* (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
|
|
18
|
+
* the operator wanted a token (`addMcp || addToken`) but `apiKey` is
|
|
19
|
+
* undefined, so they know why and how to make the vault reachable.
|
|
20
|
+
*/
|
|
21
|
+
noTokenGuidance?: string | undefined;
|
|
15
22
|
};
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Build the post-install summary lines for `vault init`, branched on the
|
|
19
|
-
* (addMcp, addToken) decision matrix
|
|
26
|
+
* (addMcp, addToken, apiKey) decision matrix. Post-0.5.0 the token is a
|
|
27
|
+
* hub-issued JWT minted via operator.token; when no hub is reachable `apiKey`
|
|
28
|
+
* is undefined even though the operator opted in (`addToken`/`addMcp`):
|
|
20
29
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
30
|
+
* addMcp, addToken, apiKey → token baked into claude.json + printed
|
|
31
|
+
* addMcp, !addToken, apiKey → token baked into claude.json, hint
|
|
32
|
+
* !addMcp, addToken, apiKey → token printed prominently
|
|
33
|
+
* wanted-token-but-no-hub → guidance: no token issued, recovery paths
|
|
34
|
+
* !addMcp, !addToken → warning: vault unreachable; recovery paths
|
|
25
35
|
*/
|
|
26
36
|
export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
27
|
-
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl } = input;
|
|
37
|
+
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
|
|
28
38
|
const lines: string[] = [];
|
|
29
39
|
lines.push("");
|
|
30
40
|
lines.push("---");
|
|
31
41
|
|
|
42
|
+
const wantedToken = addMcp || addToken;
|
|
43
|
+
|
|
32
44
|
if (addMcp && addToken && apiKey) {
|
|
33
45
|
lines.push("");
|
|
34
46
|
lines.push(`Your API token: ${apiKey}`);
|
|
35
47
|
lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
|
|
36
48
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
37
49
|
lines.push(` - Won't be shown again — save it now.`);
|
|
38
|
-
} else if (addMcp && !addToken) {
|
|
50
|
+
} else if (addMcp && !addToken && apiKey) {
|
|
39
51
|
lines.push("");
|
|
40
52
|
lines.push(
|
|
41
|
-
"Token in ~/.claude.json; run `parachute
|
|
53
|
+
"Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
|
|
42
54
|
);
|
|
43
55
|
} else if (!addMcp && addToken && apiKey) {
|
|
44
56
|
lines.push("");
|
|
45
57
|
lines.push(`Your API token: ${apiKey}`);
|
|
46
58
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
47
59
|
lines.push(` - Won't be shown again — save it now.`);
|
|
60
|
+
} else if (wantedToken && !apiKey) {
|
|
61
|
+
// Opted into a token but no hub was reachable to mint one (vault#282
|
|
62
|
+
// Stage 2 — vault no longer mints local pvt_* tokens). Surface why and
|
|
63
|
+
// the recovery paths.
|
|
64
|
+
lines.push("");
|
|
65
|
+
lines.push(
|
|
66
|
+
noTokenGuidance ??
|
|
67
|
+
"No token issued — no hub was reachable to mint a hub JWT.",
|
|
68
|
+
);
|
|
69
|
+
lines.push(
|
|
70
|
+
" Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
|
|
71
|
+
);
|
|
72
|
+
lines.push(
|
|
73
|
+
" or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
|
|
74
|
+
);
|
|
48
75
|
} else if (!addMcp && !addToken) {
|
|
49
76
|
lines.push("");
|
|
50
77
|
lines.push(
|
|
51
78
|
"You've skipped both MCP install and token generation — your vault isn't reachable by any client.",
|
|
52
79
|
);
|
|
53
80
|
lines.push(
|
|
54
|
-
" Add Claude Code later with `parachute-vault mcp-install`,
|
|
81
|
+
" Add Claude Code later with `parachute-vault mcp-install`, which mints a hub JWT (needs a hub running).",
|
|
55
82
|
);
|
|
56
83
|
}
|
|
57
84
|
|
|
@@ -81,7 +108,6 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
|
81
108
|
lines.push(` - Or add Claude Code back anytime: parachute-vault mcp-install`);
|
|
82
109
|
} else {
|
|
83
110
|
lines.push(` - Add Claude Code: parachute-vault mcp-install`);
|
|
84
|
-
lines.push(` - Mint a token: parachute vault tokens create`);
|
|
85
111
|
}
|
|
86
112
|
lines.push(` - Check status: parachute-vault status`);
|
|
87
113
|
lines.push(` - Edit config: parachute-vault config`);
|
package/src/mcp-config.test.ts
CHANGED
|
@@ -189,8 +189,10 @@ describe("mcp-config CLI", () => {
|
|
|
189
189
|
expect(res.stderr).toMatch(/--token/);
|
|
190
190
|
expect(res.stderr).toMatch(/PARACHUTE_VAULT_TOKEN/);
|
|
191
191
|
// The error message points operators at the workaround paths so they
|
|
192
|
-
// can recover without re-reading the docs.
|
|
193
|
-
|
|
192
|
+
// can recover without re-reading the docs. (vault#282 Stage 2: tokens are
|
|
193
|
+
// hub-issued JWTs now, so the hint names mcp-install, not the removed
|
|
194
|
+
// `tokens create`.)
|
|
195
|
+
expect(res.stderr).toMatch(/parachute-vault mcp-install/);
|
|
194
196
|
expect(res.stderr).toMatch(/--env-vars/);
|
|
195
197
|
});
|
|
196
198
|
|
package/src/mcp-http.ts
CHANGED
|
@@ -45,13 +45,34 @@ function requiredVerbForTool(tool: { requiredVerb?: VaultVerb }): VaultVerb {
|
|
|
45
45
|
return tool.requiredVerb ?? "write";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Handle scoped MCP at /vault/{name}/mcp (single vault).
|
|
50
|
+
*
|
|
51
|
+
* `callerBearer` is the RAW credential the session presented (from
|
|
52
|
+
* `extractApiKey`). It's threaded into `generateScopedMcpTools` so the
|
|
53
|
+
* manage-token tool can forward it to hub's mint-token attenuation proxy
|
|
54
|
+
* (vault#403, MGT). NULL when the request carried no bearer (auth would have
|
|
55
|
+
* already rejected) — the tool treats a missing/non-JWT bearer as
|
|
56
|
+
* non-forwardable and returns a clear error on mint.
|
|
57
|
+
*/
|
|
58
|
+
export async function handleScopedMcp(
|
|
59
|
+
req: Request,
|
|
60
|
+
vaultName: string,
|
|
61
|
+
auth: AuthResult,
|
|
62
|
+
callerBearer?: string | null,
|
|
63
|
+
): Promise<Response> {
|
|
50
64
|
// Auth flows through to getServerInstruction so the connect-time
|
|
51
65
|
// markdown brief is filtered by `scoped_tags` — symmetric with the
|
|
52
66
|
// JSON `vault-info` wrapper.
|
|
53
67
|
const instruction = await getServerInstruction(vaultName, auth);
|
|
54
|
-
return handleMcp(
|
|
68
|
+
return handleMcp(
|
|
69
|
+
req,
|
|
70
|
+
() => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
|
|
71
|
+
`parachute-vault/${vaultName}`,
|
|
72
|
+
vaultName,
|
|
73
|
+
auth,
|
|
74
|
+
instruction,
|
|
75
|
+
);
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
async function handleMcp(
|
|
@@ -237,12 +237,11 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
237
237
|
expect(state.logs.some((l) => /expected one of/.test(l))).toBe(true);
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
test("hub not reachable: walkthrough
|
|
240
|
+
test("hub not reachable: walkthrough falls back to paste-only (vault#282 Stage 2 — no legacy mint)", async () => {
|
|
241
241
|
const { io, state } = mockIO([
|
|
242
|
-
null,
|
|
243
|
-
"
|
|
244
|
-
|
|
245
|
-
true, // proceed
|
|
242
|
+
null, // accept install-scope default
|
|
243
|
+
"pasted-jwt", // the bearer (no auth-mode prompt — paste is the only path)
|
|
244
|
+
true, // proceed
|
|
246
245
|
]);
|
|
247
246
|
const result = await runInteractiveInstall(
|
|
248
247
|
baseCtx({ hubReachable: false }),
|
|
@@ -250,16 +249,15 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
250
249
|
);
|
|
251
250
|
expect(result).not.toBe("abort");
|
|
252
251
|
if (result === "abort") return;
|
|
253
|
-
expect(result.mode).toBe("
|
|
254
|
-
expect(result.
|
|
252
|
+
expect(result.mode).toBe("token");
|
|
253
|
+
expect(result.pastedToken).toBe("pasted-jwt");
|
|
255
254
|
// Auth prompt should explain the no-hub state.
|
|
256
255
|
expect(state.logs.some((l) => /Hub-mint isn't available/.test(l))).toBe(true);
|
|
257
256
|
});
|
|
258
257
|
|
|
259
|
-
test("hub reachable but no operator.token: also
|
|
258
|
+
test("hub reachable but no operator.token: also falls back to paste-only", async () => {
|
|
260
259
|
const { io, state } = mockIO([
|
|
261
260
|
null, // accept install-scope default
|
|
262
|
-
"paste", // pick paste
|
|
263
261
|
"pasted-jwt", // the bearer
|
|
264
262
|
true, // proceed
|
|
265
263
|
]);
|
|
@@ -287,22 +285,13 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
287
285
|
expect(result.scope).toBe("vault:write");
|
|
288
286
|
});
|
|
289
287
|
|
|
290
|
-
test("typing 'admin'
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// `
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
// `
|
|
297
|
-
//
|
|
298
|
-
// Hub mint-token rejected (HTTP 400, invalid_scope):
|
|
299
|
-
// scope vault:default:admin is not requestable via mint-token;
|
|
300
|
-
// use OAuth flow or operator rotation
|
|
301
|
-
//
|
|
302
|
-
// Fix: auto-route "admin" in the interactive prompt to legacy-pat
|
|
303
|
-
// mode (which mints a vault-DB pvt_* — the right shape for an MCP
|
|
304
|
-
// entry needing admin permissions), with a printed explanation so
|
|
305
|
-
// the switch isn't silent.
|
|
288
|
+
test("typing 'admin' mints a hub JWT with vault:admin scope (hub PR-A / hub#449)", async () => {
|
|
289
|
+
// As of hub PR-A (hub#449), `POST /api/auth/mint-token` mints
|
|
290
|
+
// `vault:<name>:admin` when the calling operator bearer carries
|
|
291
|
+
// `parachute:host:admin` (which the default operator.token does).
|
|
292
|
+
// So picking "admin" in the mint prompt now resolves to mint mode
|
|
293
|
+
// with vault:admin scope — the verb extraction downstream narrows
|
|
294
|
+
// it to `vault:<name>:admin`. No more legacy-pat auto-route.
|
|
306
295
|
const { io, state } = mockIO([
|
|
307
296
|
null, // accept install-scope default
|
|
308
297
|
"admin",
|
|
@@ -311,13 +300,11 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
311
300
|
const result = await runInteractiveInstall(baseCtx(), io);
|
|
312
301
|
expect(result).not.toBe("abort");
|
|
313
302
|
if (result === "abort") return;
|
|
314
|
-
expect(result.mode).toBe("
|
|
303
|
+
expect(result.mode).toBe("mint");
|
|
315
304
|
expect(result.scope).toBe("vault:admin");
|
|
316
|
-
// The
|
|
317
|
-
// mislead operators who specifically want a hub JWT.
|
|
305
|
+
// The branch surfaces that admin mints a scope-narrowed hub JWT.
|
|
318
306
|
const logged = state.logs.join("\n");
|
|
319
|
-
expect(logged).toMatch(/
|
|
320
|
-
expect(logged).toMatch(/hub policy/);
|
|
307
|
+
expect(logged).toMatch(/scope-narrowed hub JWT/);
|
|
321
308
|
});
|
|
322
309
|
|
|
323
310
|
test("typing 'paste' at the auth prompt switches to token mode + asks for token", async () => {
|
|
@@ -334,48 +321,23 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
334
321
|
expect(result.pastedToken).toBe("my-existing-jwt");
|
|
335
322
|
});
|
|
336
323
|
|
|
337
|
-
test("typing 'legacy' at the auth prompt
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
"legacy",
|
|
341
|
-
null, // accept default scope (read) on the F2 scope prompt
|
|
342
|
-
true,
|
|
343
|
-
]);
|
|
344
|
-
const result = await runInteractiveInstall(baseCtx(), io);
|
|
345
|
-
expect(result).not.toBe("abort");
|
|
346
|
-
if (result === "abort") return;
|
|
347
|
-
expect(result.mode).toBe("legacy-pat");
|
|
348
|
-
expect(result.scope).toBe("vault:read");
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("legacy-pat path: typing 'write' on the scope prompt widens to vault:write (F2)", async () => {
|
|
324
|
+
test("typing 'legacy' at the auth prompt is no longer accepted (vault#282 Stage 2)", async () => {
|
|
325
|
+
// The 'legacy' choice was removed — the only options are mint / write /
|
|
326
|
+
// admin / paste. An unrecognized input re-prompts; we then pick paste.
|
|
352
327
|
const { io, state } = mockIO([
|
|
353
|
-
null,
|
|
354
|
-
"legacy",
|
|
355
|
-
"
|
|
356
|
-
|
|
328
|
+
null, // accept install-scope default
|
|
329
|
+
"legacy", // no longer a valid choice → validation re-prompt
|
|
330
|
+
"paste", // pick paste
|
|
331
|
+
"my-existing-jwt", // the bearer
|
|
332
|
+
true,
|
|
357
333
|
]);
|
|
358
334
|
const result = await runInteractiveInstall(baseCtx(), io);
|
|
359
335
|
expect(result).not.toBe("abort");
|
|
360
336
|
if (result === "abort") return;
|
|
361
|
-
expect(result.mode).toBe("
|
|
362
|
-
expect(result.
|
|
363
|
-
//
|
|
364
|
-
expect(state.
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
test("legacy-pat path (no-hub branch): scope prompt also fires (F2)", async () => {
|
|
368
|
-
const { io } = mockIO([
|
|
369
|
-
null, // accept install-scope default
|
|
370
|
-
"legacy", // pick legacy (no-hub branch)
|
|
371
|
-
"admin", // widen scope
|
|
372
|
-
true, // proceed
|
|
373
|
-
]);
|
|
374
|
-
const result = await runInteractiveInstall(baseCtx({ hubReachable: false }), io);
|
|
375
|
-
expect(result).not.toBe("abort");
|
|
376
|
-
if (result === "abort") return;
|
|
377
|
-
expect(result.mode).toBe("legacy-pat");
|
|
378
|
-
expect(result.scope).toBe("vault:admin");
|
|
337
|
+
expect(result.mode).toBe("token");
|
|
338
|
+
expect(result.pastedToken).toBe("my-existing-jwt");
|
|
339
|
+
// The validator rejected 'legacy' with the expected-one-of message.
|
|
340
|
+
expect(state.logs.some((l) => /expected one of/.test(l))).toBe(true);
|
|
379
341
|
});
|
|
380
342
|
|
|
381
343
|
test("paste path: preview clarifies scope is determined by the pasted token (F2)", async () => {
|
|
@@ -477,10 +439,10 @@ describe("runInteractiveInstall — decision tree", () => {
|
|
|
477
439
|
const result = await runInteractiveInstall(baseCtx(), io);
|
|
478
440
|
expect(result).not.toBe("abort");
|
|
479
441
|
// The help text should have been logged between the two ask calls.
|
|
480
|
-
// It enumerates the choices (mint / write / admin / paste
|
|
481
|
-
// matching on the "Choices:"
|
|
482
|
-
// against future re-wording
|
|
483
|
-
const helpLogged = state.logs.some((l) => /Choices:/.test(l) && /paste/.test(l)
|
|
442
|
+
// It enumerates the choices (mint / write / admin / paste — vault#282
|
|
443
|
+
// Stage 2 dropped the legacy pvt_* option); matching on the "Choices:"
|
|
444
|
+
// header keeps the assertion stable against future re-wording.
|
|
445
|
+
const helpLogged = state.logs.some((l) => /Choices:/.test(l) && /paste/.test(l));
|
|
484
446
|
expect(helpLogged).toBe(true);
|
|
485
447
|
});
|
|
486
448
|
|