@openparachute/vault 0.3.1 → 0.4.0
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/.parachute/module.json +15 -0
- package/README.md +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/hub-jwt.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub-issued JWT validation. Vault as resource server: trusts tokens that the
|
|
3
|
+
* hub signs against keys we fetch from the hub's `/.well-known/jwks.json`.
|
|
4
|
+
*
|
|
5
|
+
* The trust kernel — JWKS fetch + verify, issuer pin, audience strict-check,
|
|
6
|
+
* RFC 7519 string-or-array `aud` handling — lives in the shared
|
|
7
|
+
* `@openparachute/scope-guard` library so vault, scribe, and paraclaw can't
|
|
8
|
+
* silently drift on the worst place to drift. This file is the vault-side
|
|
9
|
+
* adapter: hub-origin resolution (env-var precedence + loopback fallback),
|
|
10
|
+
* a process-wide guard instance, and re-exports preserving the public
|
|
11
|
+
* surface every existing call site already imports.
|
|
12
|
+
*
|
|
13
|
+
* Vault#169 / hub-as-issuer Phase B2; vault#TBD / scope-guard adoption.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
createScopeGuard,
|
|
17
|
+
HubJwtError,
|
|
18
|
+
type HubJwtClaims,
|
|
19
|
+
looksLikeJwt,
|
|
20
|
+
type ValidateHubJwtOptions,
|
|
21
|
+
} from "@openparachute/scope-guard";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the hub origin used to fetch JWKS and validate `iss`. Strips a
|
|
27
|
+
* trailing slash so we get a single canonical form.
|
|
28
|
+
*
|
|
29
|
+
* Order: env var → loopback fallback. We deliberately don't read
|
|
30
|
+
* `~/.parachute/services.json` — the hub is the dispatcher, not a registered
|
|
31
|
+
* service in that file. If a deployment exposes the hub on a non-default
|
|
32
|
+
* origin, the env var is the contract.
|
|
33
|
+
*/
|
|
34
|
+
export function getHubOrigin(): string {
|
|
35
|
+
const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
36
|
+
if (env && env.length > 0) return env;
|
|
37
|
+
return DEFAULT_HUB_LOOPBACK;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Process-wide guard. The resolver form lets tests flip
|
|
41
|
+
// `PARACHUTE_HUB_ORIGIN` between cases — the lib re-resolves on every
|
|
42
|
+
// `validateHubJwt` and `resetJwksCache` call so the env-var change picks up
|
|
43
|
+
// without a server restart. JWKS cache (5min/30s defaults) lives inside the
|
|
44
|
+
// guard, shared across requests.
|
|
45
|
+
const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Verify a presented JWT against the hub's JWKS. Throws `HubJwtError` on any
|
|
49
|
+
* failure (bad signature, wrong issuer, expired, missing kid, JWKS
|
|
50
|
+
* unreachable, audience mismatch). On success returns the surfaced claims
|
|
51
|
+
* plus the parsed scope list.
|
|
52
|
+
*
|
|
53
|
+
* Trust model:
|
|
54
|
+
* - `iss` MUST equal the configured hub origin. Without this, anyone could
|
|
55
|
+
* mint a token against any RSA key and pass verification.
|
|
56
|
+
* - `aud` is strict-checked against `opts.expectedAudience` when provided
|
|
57
|
+
* — the resource-server backstop for per-vault binding.
|
|
58
|
+
*
|
|
59
|
+
* Scope-shape policy (e.g. "hub-issued tokens may not carry broad
|
|
60
|
+
* `vault:<verb>` scopes") is enforced one layer up in `authenticateHubJwt`,
|
|
61
|
+
* not here — this function stays focused on JWT-level concerns.
|
|
62
|
+
*/
|
|
63
|
+
export async function validateHubJwt(
|
|
64
|
+
token: string,
|
|
65
|
+
opts: ValidateHubJwtOptions = {},
|
|
66
|
+
): Promise<HubJwtClaims> {
|
|
67
|
+
return guard.validateHubJwt(token, opts);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reset the cached JWKS getter. Tests use this to switch origins between
|
|
72
|
+
* cases; production callers shouldn't need it (origin is process-stable).
|
|
73
|
+
*/
|
|
74
|
+
export function resetJwksCache(): void {
|
|
75
|
+
guard.resetJwksCache();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { HubJwtError, looksLikeJwt };
|
|
79
|
+
export type { HubJwtClaims, ValidateHubJwtOptions };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `buildInitSummaryLines` — the post-install summary printed at the
|
|
3
|
+
* end of `vault init`. The summary branches on the (addMcp, addToken) decision
|
|
4
|
+
* matrix; these tests cover all four cells plus the token surfacing /
|
|
5
|
+
* Bearer-example rules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect } from "bun:test";
|
|
9
|
+
import { buildInitSummaryLines } from "./init-summary.ts";
|
|
10
|
+
|
|
11
|
+
const baseInput = {
|
|
12
|
+
configDir: "/tmp/parachute",
|
|
13
|
+
bindHost: "127.0.0.1",
|
|
14
|
+
port: 1940,
|
|
15
|
+
mcpUrl: "http://127.0.0.1:1940/vault/default/mcp",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function lines(addMcp: boolean, addToken: boolean, apiKey: string | undefined) {
|
|
19
|
+
return buildInitSummaryLines({ ...baseInput, addMcp, addToken, apiKey });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("buildInitSummaryLines", () => {
|
|
23
|
+
describe("MCP=Y + token=Y (most common)", () => {
|
|
24
|
+
const out = lines(true, true, "pvt_abc123").join("\n");
|
|
25
|
+
|
|
26
|
+
test("prints token prominently", () => {
|
|
27
|
+
expect(out).toContain("Your API token: pvt_abc123");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("notes token is baked into ~/.claude.json", () => {
|
|
31
|
+
expect(out).toContain("Baked into ~/.claude.json for Claude Code");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("includes save-it-now warning", () => {
|
|
35
|
+
expect(out).toContain("Won't be shown again — save it now.");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("includes Bearer curl example", () => {
|
|
39
|
+
expect(out).toContain(
|
|
40
|
+
'curl -H "Authorization: Bearer pvt_abc123" http://localhost:1940/api/notes',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("Next steps mentions starting a Claude Code session", () => {
|
|
45
|
+
expect(out).toContain("Start a new Claude Code session");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("MCP=Y + token=N (MCP wired, token not surfaced)", () => {
|
|
50
|
+
const out = lines(true, false, "pvt_secret").join("\n");
|
|
51
|
+
|
|
52
|
+
test("does not print the token prominently", () => {
|
|
53
|
+
expect(out).not.toContain("pvt_secret");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("does not include the 'Baked into' bullet", () => {
|
|
57
|
+
expect(out).not.toContain("Baked into ~/.claude.json");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("includes the tokens-create-later hint", () => {
|
|
61
|
+
expect(out).toContain("Token in ~/.claude.json");
|
|
62
|
+
expect(out).toContain("parachute vault tokens create");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("omits the Bearer curl example", () => {
|
|
66
|
+
expect(out).not.toContain("Authorization: Bearer");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("still shows the Claude-Code-session next step", () => {
|
|
70
|
+
expect(out).toContain("Start a new Claude Code session");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("MCP=N + token=Y (token only)", () => {
|
|
75
|
+
const out = lines(false, true, "pvt_xyz").join("\n");
|
|
76
|
+
|
|
77
|
+
test("prints token prominently", () => {
|
|
78
|
+
expect(out).toContain("Your API token: pvt_xyz");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("omits the 'Baked into' bullet (no claude.json entry written)", () => {
|
|
82
|
+
expect(out).not.toContain("Baked into ~/.claude.json");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("includes Bearer curl example", () => {
|
|
86
|
+
expect(out).toContain('Authorization: Bearer pvt_xyz');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("Next steps points at any local MCP client", () => {
|
|
90
|
+
expect(out).toContain("Point any local MCP client");
|
|
91
|
+
expect(out).toContain("http://127.0.0.1:1940/vault/default/mcp");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("Next steps offers mcp-install as a way back", () => {
|
|
95
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("MCP=N + token=N (unreachable)", () => {
|
|
100
|
+
const out = lines(false, false, undefined).join("\n");
|
|
101
|
+
|
|
102
|
+
test("warns the vault is unreachable", () => {
|
|
103
|
+
expect(out).toContain("your vault isn't reachable by any client");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("points to both recovery paths", () => {
|
|
107
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
108
|
+
expect(out).toContain("parachute vault tokens create");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("does not print any token", () => {
|
|
112
|
+
expect(out).not.toContain("Your API token:");
|
|
113
|
+
expect(out).not.toMatch(/pvt_/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("omits the Bearer curl example", () => {
|
|
117
|
+
expect(out).not.toContain("Authorization: Bearer");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("always prints Config: and Server: lines", () => {
|
|
122
|
+
for (const [addMcp, addToken] of [
|
|
123
|
+
[true, true],
|
|
124
|
+
[true, false],
|
|
125
|
+
[false, true],
|
|
126
|
+
[false, false],
|
|
127
|
+
] as const) {
|
|
128
|
+
const out = lines(addMcp, addToken, addMcp || addToken ? "pvt_k" : undefined).join("\n");
|
|
129
|
+
expect(out).toContain("Config: /tmp/parachute");
|
|
130
|
+
expect(out).toContain("Server: http://127.0.0.1:1940");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helper for `vault init`'s post-install summary. Extracted from cli.ts
|
|
3
|
+
* so the (addMcp, addToken) decision-matrix branches can be unit-tested
|
|
4
|
+
* without side-effects from importing the CLI entrypoint.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type InitSummaryInput = {
|
|
8
|
+
addMcp: boolean;
|
|
9
|
+
addToken: boolean;
|
|
10
|
+
apiKey: string | undefined;
|
|
11
|
+
configDir: string;
|
|
12
|
+
bindHost: string;
|
|
13
|
+
port: number;
|
|
14
|
+
mcpUrl: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the post-install summary lines for `vault init`, branched on the
|
|
19
|
+
* (addMcp, addToken) decision matrix:
|
|
20
|
+
*
|
|
21
|
+
* Y, Y → token baked into claude.json + printed prominently
|
|
22
|
+
* Y, N → token baked into claude.json, hint about `tokens create`
|
|
23
|
+
* N, Y → token printed prominently, no claude.json entry
|
|
24
|
+
* N, N → warning: vault unreachable; both recovery paths listed
|
|
25
|
+
*/
|
|
26
|
+
export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
27
|
+
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl } = input;
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
lines.push("");
|
|
30
|
+
lines.push("---");
|
|
31
|
+
|
|
32
|
+
if (addMcp && addToken && apiKey) {
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push(`Your API token: ${apiKey}`);
|
|
35
|
+
lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
|
|
36
|
+
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
37
|
+
lines.push(` - Won't be shown again — save it now.`);
|
|
38
|
+
} else if (addMcp && !addToken) {
|
|
39
|
+
lines.push("");
|
|
40
|
+
lines.push(
|
|
41
|
+
"Token in ~/.claude.json; run `parachute vault tokens create` later if you need one for other clients.",
|
|
42
|
+
);
|
|
43
|
+
} else if (!addMcp && addToken && apiKey) {
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push(`Your API token: ${apiKey}`);
|
|
46
|
+
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
47
|
+
lines.push(` - Won't be shown again — save it now.`);
|
|
48
|
+
} else if (!addMcp && !addToken) {
|
|
49
|
+
lines.push("");
|
|
50
|
+
lines.push(
|
|
51
|
+
"You've skipped both MCP install and token generation — your vault isn't reachable by any client.",
|
|
52
|
+
);
|
|
53
|
+
lines.push(
|
|
54
|
+
" Add Claude Code later with `parachute-vault mcp-install`, or mint a token with `parachute vault tokens create`.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push(`Config: ${configDir}`);
|
|
60
|
+
lines.push(`Server: http://${bindHost}:${port}`);
|
|
61
|
+
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push(`Usage examples:`);
|
|
64
|
+
lines.push(` curl http://localhost:${port}/health`);
|
|
65
|
+
if (addToken && apiKey) {
|
|
66
|
+
lines.push(` curl -H "Authorization: Bearer ${apiKey}" http://localhost:${port}/api/notes`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push("");
|
|
70
|
+
lines.push(`Next steps:`);
|
|
71
|
+
if (addMcp) {
|
|
72
|
+
lines.push(` - Start a new Claude Code session — your Vault is already wired in. Try:`);
|
|
73
|
+
lines.push(` claude "Help me set up my parachute vault"`);
|
|
74
|
+
lines.push(` - Or point any other local MCP client (Codex, Goose, OpenCode, Cursor,`);
|
|
75
|
+
lines.push(` Zed, Cline, your own agent) at:`);
|
|
76
|
+
lines.push(` ${mcpUrl}`);
|
|
77
|
+
} else if (addToken) {
|
|
78
|
+
lines.push(` - Point any local MCP client (Codex, Goose, OpenCode, Cursor, Zed,`);
|
|
79
|
+
lines.push(` Cline, your own agent) at:`);
|
|
80
|
+
lines.push(` ${mcpUrl}`);
|
|
81
|
+
lines.push(` - Or add Claude Code back anytime: parachute-vault mcp-install`);
|
|
82
|
+
} else {
|
|
83
|
+
lines.push(` - Add Claude Code: parachute-vault mcp-install`);
|
|
84
|
+
lines.push(` - Mint a token: parachute vault tokens create`);
|
|
85
|
+
}
|
|
86
|
+
lines.push(` - Check status: parachute-vault status`);
|
|
87
|
+
lines.push(` - Edit config: parachute-vault config`);
|
|
88
|
+
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
package/src/init.test.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `parachute-vault init` flag plumbing — the cases
|
|
3
|
+
* where init exits early without touching the daemon, ~/.claude.json, or
|
|
4
|
+
* the vault filesystem. The full happy-path of init isn't run here because
|
|
5
|
+
* it would install a launchd agent on macOS and write into the developer's
|
|
6
|
+
* real ~/Library/LaunchAgents — out of scope for unit tests. The vault-name
|
|
7
|
+
* decision logic is fully covered by `vault-name.test.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect } from "bun:test";
|
|
11
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { join, resolve } from "path";
|
|
14
|
+
|
|
15
|
+
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
16
|
+
|
|
17
|
+
function runCli(args: string[], env: Record<string, string> = {}): {
|
|
18
|
+
exitCode: number;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
} {
|
|
22
|
+
const proc = Bun.spawnSync({
|
|
23
|
+
cmd: ["bun", CLI, ...args],
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
env: { ...process.env, ...env },
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
exitCode: proc.exitCode ?? -1,
|
|
30
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
31
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("vault init — --vault-name validation", () => {
|
|
36
|
+
test("rejects --vault-name with uppercase + space and exits non-zero", () => {
|
|
37
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name", "My Vault"]);
|
|
38
|
+
expect(exitCode).not.toBe(0);
|
|
39
|
+
expect(stderr).toContain("--vault-name:");
|
|
40
|
+
expect(stderr).toContain("lowercase alphanumeric");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects --vault-name with a slash and exits non-zero", () => {
|
|
44
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name", "team/work"]);
|
|
45
|
+
expect(exitCode).not.toBe(0);
|
|
46
|
+
expect(stderr).toContain("lowercase alphanumeric");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects --vault-name with no value and exits non-zero", () => {
|
|
50
|
+
// `--vault-name` is the last arg → no value follows.
|
|
51
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name"]);
|
|
52
|
+
expect(exitCode).not.toBe(0);
|
|
53
|
+
expect(stderr).toContain("requires a value");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("rejects reserved name 'list' and exits non-zero", () => {
|
|
57
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name", "list"]);
|
|
58
|
+
expect(exitCode).not.toBe(0);
|
|
59
|
+
expect(stderr).toContain("reserved");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("vault init — --help mentions --vault-name", () => {
|
|
64
|
+
test("usage text documents the new flag", () => {
|
|
65
|
+
const { exitCode, stdout } = runCli(["--help"]);
|
|
66
|
+
expect(exitCode).toBe(0);
|
|
67
|
+
expect(stdout).toContain("--vault-name");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("usage text documents --no-autostart (#113)", () => {
|
|
71
|
+
const { exitCode, stdout } = runCli(["--help"]);
|
|
72
|
+
expect(exitCode).toBe(0);
|
|
73
|
+
expect(stdout).toContain("--no-autostart");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* End-to-end init under an isolated $HOME / $PARACHUTE_HOME so we never touch
|
|
79
|
+
* the developer's real ~/.parachute or ~/Library/LaunchAgents. With
|
|
80
|
+
* --no-autostart, init must:
|
|
81
|
+
* 1. Persist `autostart: false` in config.yaml.
|
|
82
|
+
* 2. NOT write the daemon wrapper (start.sh / server-path).
|
|
83
|
+
*
|
|
84
|
+
* --no-mcp / --no-token avoid the ~/.claude.json side effect; HOME=tmpdir
|
|
85
|
+
* makes the launchd-uninstall-prior-registration call land inside the
|
|
86
|
+
* sandbox even on macOS (where uninstallAgent operates on
|
|
87
|
+
* `homedir()/Library/LaunchAgents/...`).
|
|
88
|
+
*/
|
|
89
|
+
describe("vault init — --no-autostart (#113)", () => {
|
|
90
|
+
test("persists autostart=false and skips the daemon wrapper", () => {
|
|
91
|
+
const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
|
|
92
|
+
try {
|
|
93
|
+
const parachuteHome = join(sandbox, ".parachute");
|
|
94
|
+
const { exitCode, stdout } = runCli(
|
|
95
|
+
[
|
|
96
|
+
"init",
|
|
97
|
+
"--no-autostart",
|
|
98
|
+
"--no-mcp",
|
|
99
|
+
"--no-token",
|
|
100
|
+
"--vault-name",
|
|
101
|
+
"autostarttest",
|
|
102
|
+
],
|
|
103
|
+
{ HOME: sandbox, PARACHUTE_HOME: parachuteHome },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(exitCode).toBe(0);
|
|
107
|
+
expect(stdout).toContain("Autostart disabled");
|
|
108
|
+
|
|
109
|
+
const configPath = join(parachuteHome, "vault", "config.yaml");
|
|
110
|
+
expect(existsSync(configPath)).toBe(true);
|
|
111
|
+
expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
|
|
112
|
+
|
|
113
|
+
// Daemon wrapper / pointer are written by installAgent /
|
|
114
|
+
// installSystemdService — neither should run when autostart is off.
|
|
115
|
+
expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
|
|
116
|
+
expect(existsSync(join(parachuteHome, "vault", "server-path"))).toBe(false);
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("re-running init without a flag preserves persisted autostart=false", () => {
|
|
123
|
+
// We can't drive the --autostart re-run end-to-end here: it calls
|
|
124
|
+
// installAgent() / installSystemdService() which write to launchd /
|
|
125
|
+
// systemd state outside the PARACHUTE_HOME sandbox, breaking test
|
|
126
|
+
// hermeticity. Instead verify the inverse property — that a no-flag
|
|
127
|
+
// re-run honors the persisted opt-out and does NOT fall back to the
|
|
128
|
+
// default-on. This is the actual user-facing risk (forgetting to pass
|
|
129
|
+
// --no-autostart on every re-run shouldn't re-enable the daemon).
|
|
130
|
+
const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
|
|
131
|
+
try {
|
|
132
|
+
const parachuteHome = join(sandbox, ".parachute");
|
|
133
|
+
const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
|
|
134
|
+
|
|
135
|
+
const first = runCli(
|
|
136
|
+
[
|
|
137
|
+
"init",
|
|
138
|
+
"--no-autostart",
|
|
139
|
+
"--no-mcp",
|
|
140
|
+
"--no-token",
|
|
141
|
+
"--vault-name",
|
|
142
|
+
"autostarttest",
|
|
143
|
+
],
|
|
144
|
+
env,
|
|
145
|
+
);
|
|
146
|
+
expect(first.exitCode).toBe(0);
|
|
147
|
+
|
|
148
|
+
const configPath = join(parachuteHome, "vault", "config.yaml");
|
|
149
|
+
expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
|
|
150
|
+
|
|
151
|
+
// No --autostart / --no-autostart on this run; init should read the
|
|
152
|
+
// persisted false and skip daemon install again.
|
|
153
|
+
const second = runCli(["init", "--no-mcp", "--no-token"], env);
|
|
154
|
+
expect(second.exitCode).toBe(0);
|
|
155
|
+
expect(second.stdout).toContain("Autostart disabled");
|
|
156
|
+
expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
|
|
157
|
+
expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
|
|
158
|
+
} finally {
|
|
159
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* #210: re-running `parachute-vault init` is the documented recovery path
|
|
166
|
+
* for installs whose `services.json` is stale (#208 left some vaults out of
|
|
167
|
+
* the manifest). The recovery is implicit — init re-registers the full
|
|
168
|
+
* vault list every run via `buildVaultServicePaths` — so this test pins it
|
|
169
|
+
* down with an explicit fixture: corrupt the manifest to drop one vault,
|
|
170
|
+
* re-run init, expect the manifest to grow back.
|
|
171
|
+
*/
|
|
172
|
+
describe("vault init — repairs stale services.json (#210)", () => {
|
|
173
|
+
test("re-running init rewrites services.json to include every vault on disk", () => {
|
|
174
|
+
const sandbox = mkdtempSync(join(tmpdir(), "vault-init-repair-"));
|
|
175
|
+
try {
|
|
176
|
+
const parachuteHome = join(sandbox, ".parachute");
|
|
177
|
+
const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
|
|
178
|
+
|
|
179
|
+
// Use `create` to bootstrap two vaults into a real, healthy state —
|
|
180
|
+
// this also writes the initial services.json with both vaults so we
|
|
181
|
+
// have a known-good baseline to corrupt.
|
|
182
|
+
expect(runCli(["create", "alpha", "--json"], env).exitCode).toBe(0);
|
|
183
|
+
expect(runCli(["create", "beta", "--json"], env).exitCode).toBe(0);
|
|
184
|
+
|
|
185
|
+
const servicesPath = join(parachuteHome, "services.json");
|
|
186
|
+
const baseline = JSON.parse(readFileSync(servicesPath, "utf-8"));
|
|
187
|
+
const baselineEntry = baseline.services.find(
|
|
188
|
+
(s: { name: string }) => s.name === "parachute-vault",
|
|
189
|
+
);
|
|
190
|
+
expect(baselineEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
|
|
191
|
+
|
|
192
|
+
// Corrupt: drop beta from the manifest, mimicking the #208 state where
|
|
193
|
+
// an older `create` ran without the upsert.
|
|
194
|
+
baselineEntry.paths = ["/vault/alpha"];
|
|
195
|
+
writeFileSync(servicesPath, JSON.stringify(baseline, null, 2));
|
|
196
|
+
|
|
197
|
+
// Re-run init with no flags that would change vault topology. The
|
|
198
|
+
// sandbox env keeps launchd / ~/.claude.json side effects out of the
|
|
199
|
+
// dev environment.
|
|
200
|
+
const repair = runCli(
|
|
201
|
+
["init", "--no-autostart", "--no-mcp", "--no-token"],
|
|
202
|
+
env,
|
|
203
|
+
);
|
|
204
|
+
expect(repair.exitCode).toBe(0);
|
|
205
|
+
|
|
206
|
+
const repaired = JSON.parse(readFileSync(servicesPath, "utf-8"));
|
|
207
|
+
const repairedEntry = repaired.services.find(
|
|
208
|
+
(s: { name: string }) => s.name === "parachute-vault",
|
|
209
|
+
);
|
|
210
|
+
// alpha is still default (created first), so it leads. beta is back.
|
|
211
|
+
expect(repairedEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
|
|
212
|
+
} finally {
|
|
213
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
package/src/mcp-http.ts
CHANGED
|
@@ -24,47 +24,49 @@ import {
|
|
|
24
24
|
McpError,
|
|
25
25
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
26
26
|
import { generateScopedMcpTools, getServerInstruction } from "./mcp-tools.ts";
|
|
27
|
-
import { requireScope } from "./auth.ts";
|
|
28
27
|
import type { AuthResult } from "./auth.ts";
|
|
29
28
|
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
30
|
-
import {
|
|
29
|
+
import { hasScopeForVault } from "./scopes.ts";
|
|
30
|
+
import type { VaultVerb } from "./scopes.ts";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Required
|
|
34
|
-
*
|
|
35
|
-
* read
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* Required verb for each MCP tool. Tools that mutate note/tag state require
|
|
34
|
+
* write; pure query tools need read. `vault-info` is listed as read because
|
|
35
|
+
* read-only callers can fetch stats — the description-update branch inside
|
|
36
|
+
* vault-info performs its own secondary write check (see `overrideVaultInfo`
|
|
37
|
+
* in mcp-tools.ts). Do not assume the outer gate alone protects the inner
|
|
38
|
+
* branch.
|
|
39
39
|
*/
|
|
40
|
-
const
|
|
41
|
-
"query-notes":
|
|
42
|
-
"list-tags":
|
|
43
|
-
"find-path":
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
40
|
+
const TOOL_REQUIRED_VERB: Record<string, VaultVerb> = {
|
|
41
|
+
"query-notes": "read",
|
|
42
|
+
"list-tags": "read",
|
|
43
|
+
"find-path": "read",
|
|
44
|
+
"synthesize-notes": "read",
|
|
45
|
+
"vault-info": "read",
|
|
46
|
+
"create-note": "write",
|
|
47
|
+
"update-note": "write",
|
|
48
|
+
"delete-note": "write",
|
|
49
|
+
"update-tag": "write",
|
|
50
|
+
"delete-tag": "write",
|
|
50
51
|
};
|
|
51
52
|
|
|
52
|
-
function
|
|
53
|
+
function requiredVerbForTool(toolName: string): VaultVerb {
|
|
53
54
|
// Default-deny: unknown tools require write. Keeps accidental reads of
|
|
54
55
|
// a not-yet-mapped mutation tool from slipping past.
|
|
55
|
-
return
|
|
56
|
+
return TOOL_REQUIRED_VERB[toolName] ?? "write";
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** Handle scoped MCP at /vault/{name}/mcp (single vault). */
|
|
59
60
|
export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
|
|
60
61
|
const instruction = getServerInstruction(vaultName);
|
|
61
|
-
return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, auth, instruction);
|
|
62
|
+
return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, vaultName, auth, instruction);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
async function handleMcp(
|
|
65
66
|
req: Request,
|
|
66
67
|
getTools: () => McpToolDef[],
|
|
67
68
|
serverName: string,
|
|
69
|
+
vaultName: string,
|
|
68
70
|
auth: AuthResult,
|
|
69
71
|
instruction: string,
|
|
70
72
|
): Promise<Response> {
|
|
@@ -84,11 +86,11 @@ async function handleMcp(
|
|
|
84
86
|
const mcpTools = getTools();
|
|
85
87
|
|
|
86
88
|
// Filter the advertised tool list to what the caller's scopes actually
|
|
87
|
-
// permit. Callers without
|
|
88
|
-
// matches the prior behavior of the read/full permission model but
|
|
89
|
-
// driven by scope inheritance.
|
|
89
|
+
// permit for THIS vault. Callers without write don't see mutation tools at
|
|
90
|
+
// all — matches the prior behavior of the read/full permission model but
|
|
91
|
+
// now driven by per-vault scope inheritance.
|
|
90
92
|
const visibleTools = mcpTools.filter((t) =>
|
|
91
|
-
|
|
93
|
+
hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t.name)),
|
|
92
94
|
);
|
|
93
95
|
|
|
94
96
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -102,12 +104,12 @@ async function handleMcp(
|
|
|
102
104
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
103
105
|
const { name, arguments: args } = request.params;
|
|
104
106
|
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
+
const neededVerb = requiredVerbForTool(name);
|
|
108
|
+
if (!hasScopeForVault(auth.scopes, vaultName, neededVerb)) {
|
|
107
109
|
return {
|
|
108
110
|
content: [{
|
|
109
111
|
type: "text" as const,
|
|
110
|
-
text: `Forbidden: tool '${name}' requires the '
|
|
112
|
+
text: `Forbidden: tool '${name}' requires the 'vault:${neededVerb}' scope (or 'vault:${vaultName}:${neededVerb}'). Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
|
|
111
113
|
}],
|
|
112
114
|
isError: true,
|
|
113
115
|
};
|
package/src/mcp-install.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type McpUrlSource = "hub-origin" | "expose-state" | "loopback";
|
|
|
20
20
|
export function chooseMcpUrl(
|
|
21
21
|
vaultName: string,
|
|
22
22
|
port: number,
|
|
23
|
-
env: { PARACHUTE_HUB_ORIGIN?: string } = process.env,
|
|
23
|
+
env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
|
|
24
24
|
): { url: string; source: McpUrlSource } {
|
|
25
25
|
const hub = env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
26
26
|
if (hub) {
|