@openparachute/vault 0.2.4 → 0.3.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/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { Note, Store } from "../core/src/types.ts";
|
|
3
|
+
import { appendContextPart, fetchContextEntries } from "./context.ts";
|
|
4
|
+
|
|
5
|
+
function mkNote(overrides: Partial<Note>): Note {
|
|
6
|
+
return {
|
|
7
|
+
id: overrides.id ?? "n1",
|
|
8
|
+
content: "",
|
|
9
|
+
createdAt: "2026-04-20T00:00:00Z",
|
|
10
|
+
updatedAt: "2026-04-20T00:00:00Z",
|
|
11
|
+
tags: [],
|
|
12
|
+
metadata: {},
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mkStore(byTag: Record<string, Note[]>): Store {
|
|
18
|
+
return {
|
|
19
|
+
queryNotes: async ({ tags, excludeTags }) => {
|
|
20
|
+
const tag = tags?.[0];
|
|
21
|
+
if (!tag) return [];
|
|
22
|
+
const pool = byTag[tag] ?? [];
|
|
23
|
+
if (!excludeTags?.length) return pool;
|
|
24
|
+
const excluded = new Set(excludeTags);
|
|
25
|
+
return pool.filter((n) => !(n.tags ?? []).some((t) => excluded.has(t)));
|
|
26
|
+
},
|
|
27
|
+
} as unknown as Store;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("fetchContextEntries", () => {
|
|
31
|
+
test("returns whitelisted metadata keyed on path basename", async () => {
|
|
32
|
+
const store = mkStore({
|
|
33
|
+
person: [
|
|
34
|
+
mkNote({
|
|
35
|
+
id: "p1",
|
|
36
|
+
path: "People/Aaron.md",
|
|
37
|
+
tags: ["person"],
|
|
38
|
+
metadata: { summary: "founder", aliases: ["A"], secret: "don't leak" },
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const payload = await fetchContextEntries(store, [
|
|
44
|
+
{ tag: "person", include_metadata: ["summary", "aliases"] },
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
expect(payload.entries.length).toBe(1);
|
|
48
|
+
expect(payload.entries[0].name).toBe("Aaron");
|
|
49
|
+
expect(payload.entries[0].summary).toBe("founder");
|
|
50
|
+
expect(payload.entries[0].aliases).toEqual(["A"]);
|
|
51
|
+
// Non-whitelisted metadata never surfaces.
|
|
52
|
+
expect(payload.entries[0].secret).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("honors exclude_tag", async () => {
|
|
56
|
+
const store = mkStore({
|
|
57
|
+
project: [
|
|
58
|
+
mkNote({ id: "pj1", path: "Projects/Active.md", tags: ["project"] }),
|
|
59
|
+
mkNote({ id: "pj2", path: "Projects/Old.md", tags: ["project", "archived"] }),
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const payload = await fetchContextEntries(store, [
|
|
64
|
+
{ tag: "project", exclude_tag: "archived", include_metadata: [] },
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
expect(payload.entries.map((e) => e.name)).toEqual(["Active"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("dedups notes across predicates by note id (first-match wins)", async () => {
|
|
71
|
+
const overlap = mkNote({ id: "x1", path: "People/X.md", tags: ["person", "project"] });
|
|
72
|
+
const store = mkStore({ person: [overlap], project: [overlap] });
|
|
73
|
+
|
|
74
|
+
const payload = await fetchContextEntries(store, [
|
|
75
|
+
{ tag: "person", include_metadata: [] },
|
|
76
|
+
{ tag: "project", include_metadata: [] },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
expect(payload.entries.length).toBe(1);
|
|
80
|
+
expect(payload.entries[0].name).toBe("X");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("falls back to note.id when path is absent", async () => {
|
|
84
|
+
const store = mkStore({
|
|
85
|
+
person: [mkNote({ id: "no-path-note", path: undefined, tags: ["person"] })],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const payload = await fetchContextEntries(store, [{ tag: "person" }]);
|
|
89
|
+
|
|
90
|
+
expect(payload.entries[0].name).toBe("no-path-note");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("skips predicates with empty tag (defensive — not throw)", async () => {
|
|
94
|
+
const store = mkStore({ person: [mkNote({ id: "p1", path: "x" })] });
|
|
95
|
+
const payload = await fetchContextEntries(store, [
|
|
96
|
+
{ tag: "" },
|
|
97
|
+
{ tag: "person" },
|
|
98
|
+
]);
|
|
99
|
+
expect(payload.entries.length).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("logs and continues on queryNotes throw; does not abort the whole fetch", async () => {
|
|
103
|
+
const errors: unknown[] = [];
|
|
104
|
+
const logger = { error: (...args: unknown[]) => errors.push(args) };
|
|
105
|
+
const store: Store = {
|
|
106
|
+
queryNotes: async ({ tags }) => {
|
|
107
|
+
if (tags?.[0] === "broken") throw new Error("boom");
|
|
108
|
+
return [mkNote({ id: "ok", path: "Ok.md", tags: ["ok"] })];
|
|
109
|
+
},
|
|
110
|
+
} as unknown as Store;
|
|
111
|
+
|
|
112
|
+
const payload = await fetchContextEntries(
|
|
113
|
+
store,
|
|
114
|
+
[{ tag: "broken" }, { tag: "ok" }],
|
|
115
|
+
logger,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(errors.length).toBe(1);
|
|
119
|
+
expect(payload.entries.map((e) => e.name)).toEqual(["Ok"]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("appendContextPart", () => {
|
|
124
|
+
test("appends a JSON blob when entries exist", () => {
|
|
125
|
+
const form = new FormData();
|
|
126
|
+
appendContextPart(form, { entries: [{ name: "x" }] });
|
|
127
|
+
const part = form.get("context");
|
|
128
|
+
expect(part).toBeInstanceOf(Blob);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("no-ops on zero-entries payload", () => {
|
|
132
|
+
const form = new FormData();
|
|
133
|
+
appendContextPart(form, { entries: [] });
|
|
134
|
+
expect(form.get("context")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "vault-provided context" helpers for outbound webhook/worker calls.
|
|
3
|
+
*
|
|
4
|
+
* Triggers (`include_context`) and the transcription worker (`transcription.context`
|
|
5
|
+
* in vault.yaml) both send vault context to an external caller so the caller
|
|
6
|
+
* does not need to reach back into vault to fetch person/project/etc. notes
|
|
7
|
+
* on its own. The shape is caller-agnostic — the receiver just gets a JSON
|
|
8
|
+
* blob of `{ entries: [...] }` — so this module doesn't know anything about
|
|
9
|
+
* scribe specifically.
|
|
10
|
+
*
|
|
11
|
+
* ## Predicate shape
|
|
12
|
+
*
|
|
13
|
+
* include_context:
|
|
14
|
+
* - tag: person
|
|
15
|
+
* exclude_tag: archived
|
|
16
|
+
* include_metadata: [summary, aliases]
|
|
17
|
+
*
|
|
18
|
+
* Each predicate is a query (scoped by `tag`, optionally excluding `exclude_tag`)
|
|
19
|
+
* plus a whitelist of metadata fields to surface on the resulting entries.
|
|
20
|
+
* Fields not in `include_metadata` are dropped. `name` is always included and
|
|
21
|
+
* is the note's path basename (or id, if no path).
|
|
22
|
+
*
|
|
23
|
+
* ## Output
|
|
24
|
+
*
|
|
25
|
+
* {"entries": [{"name": "Aaron", "aliases": ["A"], "summary": "..."}, ...]}
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Store } from "../core/src/types.ts";
|
|
29
|
+
|
|
30
|
+
export interface ContextPredicate {
|
|
31
|
+
/** Tag the note must carry. Required — a predicate with no tag is a no-op. */
|
|
32
|
+
tag: string;
|
|
33
|
+
/** If set, notes with this tag are excluded. */
|
|
34
|
+
exclude_tag?: string;
|
|
35
|
+
/** Metadata keys to pass through on each entry. Unknown keys are ignored. */
|
|
36
|
+
include_metadata?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContextEntry {
|
|
40
|
+
/** Note path basename, or note id if no path. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** Whitelisted metadata fields from the predicate. */
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ContextPayload {
|
|
47
|
+
entries: ContextEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Query the vault for notes matching each predicate, project to the whitelisted
|
|
52
|
+
* metadata fields, and return a combined payload. Predicates are run in order;
|
|
53
|
+
* duplicate notes (same id across predicates) are included once by first match.
|
|
54
|
+
*
|
|
55
|
+
* Errors on a single predicate are logged and skipped — a malformed predicate
|
|
56
|
+
* should not take down a whole trigger or worker cycle.
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchContextEntries(
|
|
59
|
+
store: Store,
|
|
60
|
+
predicates: ContextPredicate[],
|
|
61
|
+
logger: { error: (...args: unknown[]) => void } = console,
|
|
62
|
+
): Promise<ContextPayload> {
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const entries: ContextEntry[] = [];
|
|
65
|
+
|
|
66
|
+
for (const pred of predicates) {
|
|
67
|
+
if (!pred.tag) continue;
|
|
68
|
+
let notes;
|
|
69
|
+
try {
|
|
70
|
+
notes = await store.queryNotes({
|
|
71
|
+
tags: [pred.tag],
|
|
72
|
+
excludeTags: pred.exclude_tag ? [pred.exclude_tag] : undefined,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.error(`[context] query failed for tag="${pred.tag}":`, err);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const note of notes) {
|
|
80
|
+
if (seen.has(note.id)) continue;
|
|
81
|
+
seen.add(note.id);
|
|
82
|
+
|
|
83
|
+
const entry: ContextEntry = { name: nameForNote(note.path, note.id) };
|
|
84
|
+
const meta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
85
|
+
for (const key of pred.include_metadata ?? []) {
|
|
86
|
+
if (key in meta) entry[key] = meta[key];
|
|
87
|
+
}
|
|
88
|
+
entries.push(entry);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { entries };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function nameForNote(path: string | undefined, id: string): string {
|
|
96
|
+
if (!path) return id;
|
|
97
|
+
const base = path.split("/").pop() ?? path;
|
|
98
|
+
// Drop extension if present — same rule a UI would apply for display.
|
|
99
|
+
const dot = base.lastIndexOf(".");
|
|
100
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Append a `context` multipart part to the given FormData. Does nothing if
|
|
105
|
+
* the payload has no entries (avoids a zero-entries part that the receiver
|
|
106
|
+
* would have to special-case).
|
|
107
|
+
*/
|
|
108
|
+
export function appendContextPart(form: FormData, payload: ContextPayload): void {
|
|
109
|
+
if (!payload.entries.length) return;
|
|
110
|
+
form.append(
|
|
111
|
+
"context",
|
|
112
|
+
new Blob([JSON.stringify(payload)], { type: "application/json" }),
|
|
113
|
+
"context.json",
|
|
114
|
+
);
|
|
115
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -5,25 +5,26 @@
|
|
|
5
5
|
* Rationale: both platforms historically hardcoded the absolute path to
|
|
6
6
|
* `src/server.ts` into their respective service/wrapper files at init
|
|
7
7
|
* time. When the repo moved, the service kept respawning bun on a missing
|
|
8
|
-
* file and crash-looped silently into `~/.parachute/vault.err`.
|
|
8
|
+
* file and crash-looped silently into `~/.parachute/vault/vault.err`.
|
|
9
9
|
*
|
|
10
|
-
* The fix: a small bash wrapper (`~/.parachute/start.sh`) and a
|
|
11
|
-
* file (`~/.parachute/server-path`) that the wrapper reads
|
|
12
|
-
* Moving the repo now only requires re-running
|
|
13
|
-
* no plist/unit re-registration needed. Callers
|
|
14
|
-
* a single boot via `PARACHUTE_VAULT_SERVER_PATH`
|
|
10
|
+
* The fix: a small bash wrapper (`~/.parachute/vault/start.sh`) and a
|
|
11
|
+
* pointer file (`~/.parachute/vault/server-path`) that the wrapper reads
|
|
12
|
+
* at every boot. Moving the repo now only requires re-running
|
|
13
|
+
* `parachute-vault init` — no plist/unit re-registration needed. Callers
|
|
14
|
+
* can override the path for a single boot via `PARACHUTE_VAULT_SERVER_PATH`
|
|
15
|
+
* in `~/.parachute/vault/.env`.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { homedir } from "os";
|
|
18
19
|
import { join, resolve, dirname } from "path";
|
|
19
20
|
import { writeFile, unlink } from "fs/promises";
|
|
20
21
|
import { existsSync, readFileSync } from "fs";
|
|
21
|
-
import {
|
|
22
|
+
import { VAULT_HOME, ENV_PATH } from "./config.ts";
|
|
22
23
|
|
|
23
24
|
/** Start-up script sourced by launchd and systemd alike. */
|
|
24
|
-
export const WRAPPER_PATH = join(
|
|
25
|
+
export const WRAPPER_PATH = join(VAULT_HOME, "start.sh");
|
|
25
26
|
/** Pointer file. Contents: absolute path to `server.ts`, one line. */
|
|
26
|
-
export const SERVER_PATH_FILE = join(
|
|
27
|
+
export const SERVER_PATH_FILE = join(VAULT_HOME, "server-path");
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Build the start.sh contents. Pure string builder — easy to assert on.
|
|
@@ -31,7 +32,7 @@ export const SERVER_PATH_FILE = join(CONFIG_DIR, "server-path");
|
|
|
31
32
|
* The generated script:
|
|
32
33
|
* 1. Sources the user's login shell profiles to pick up PATH (needed
|
|
33
34
|
* when the daemon shells out to tools like ffmpeg, parakeet-mlx).
|
|
34
|
-
* 2. Sources ~/.parachute/.env.
|
|
35
|
+
* 2. Sources ~/.parachute/vault/.env.
|
|
35
36
|
* 3. Resolves the server path: env override first, then pointer file.
|
|
36
37
|
* 4. Fails loudly with an actionable message if the path is missing or
|
|
37
38
|
* the target file doesn't exist — instead of silently respawning
|
|
@@ -46,10 +47,10 @@ export function generateWrapper(opts: {
|
|
|
46
47
|
const envPath = opts.envPath ?? ENV_PATH;
|
|
47
48
|
const pointer = opts.serverPathFile ?? SERVER_PATH_FILE;
|
|
48
49
|
return `#!/bin/bash
|
|
49
|
-
# Auto-generated by \`parachute
|
|
50
|
+
# Auto-generated by \`parachute-vault init\`. Do not edit by hand — edits
|
|
50
51
|
# are clobbered on the next init. To override the server path for a
|
|
51
|
-
# single boot, set PARACHUTE_VAULT_SERVER_PATH in ~/.parachute/.env.
|
|
52
|
-
# To point at a different repo permanently, re-run \`parachute
|
|
52
|
+
# single boot, set PARACHUTE_VAULT_SERVER_PATH in ~/.parachute/vault/.env.
|
|
53
|
+
# To point at a different repo permanently, re-run \`parachute-vault init\`
|
|
53
54
|
# from that repo.
|
|
54
55
|
|
|
55
56
|
set -u
|
|
@@ -61,7 +62,7 @@ set -u
|
|
|
61
62
|
# empty because of the 2>/dev/null below — launchd would respawn silently
|
|
62
63
|
# until it gave up. Keep the stderr redirect so expected "command not found"
|
|
63
64
|
# noise from incomplete setups doesn't fill vault.err; to debug silent
|
|
64
|
-
# wrapper failures, run \`bash -x ~/.parachute/start.sh\` by hand.
|
|
65
|
+
# wrapper failures, run \`bash -x ~/.parachute/vault/start.sh\` by hand.
|
|
65
66
|
set +u
|
|
66
67
|
[ -f "$HOME/.zprofile" ] && source "$HOME/.zprofile" 2>/dev/null
|
|
67
68
|
[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null
|
|
@@ -81,13 +82,13 @@ fi
|
|
|
81
82
|
|
|
82
83
|
if [ -z "$SERVER_PATH" ]; then
|
|
83
84
|
echo "parachute-vault: server path not configured (no ${pointer} and PARACHUTE_VAULT_SERVER_PATH is unset)." >&2
|
|
84
|
-
echo "parachute-vault: run \\\`parachute
|
|
85
|
+
echo "parachute-vault: run \\\`parachute-vault init\\\` to configure it." >&2
|
|
85
86
|
exit 1
|
|
86
87
|
fi
|
|
87
88
|
|
|
88
89
|
if [ ! -f "$SERVER_PATH" ]; then
|
|
89
90
|
echo "parachute-vault: server.ts not found at $SERVER_PATH" >&2
|
|
90
|
-
echo "parachute-vault: the repo may have moved. Run \\\`parachute
|
|
91
|
+
echo "parachute-vault: the repo may have moved. Run \\\`parachute-vault init\\\` from the current repo location." >&2
|
|
91
92
|
exit 1
|
|
92
93
|
fi
|
|
93
94
|
|
package/src/doctor.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration tests for `parachute
|
|
2
|
+
* Integration tests for `parachute-vault doctor` and `parachute-vault url`.
|
|
3
3
|
*
|
|
4
4
|
* We spawn the CLI as a subprocess with `PARACHUTE_HOME` pointed at a
|
|
5
5
|
* fresh tempdir — so each test exercises the real code path (config +
|
|
@@ -74,7 +74,7 @@ describe("vault doctor", () => {
|
|
|
74
74
|
expect(res.exitCode).toBe(1);
|
|
75
75
|
expect(res.stdout).toMatch(/server-path pointer/);
|
|
76
76
|
expect(res.stdout).toMatch(/missing/);
|
|
77
|
-
expect(res.stdout).toMatch(/parachute
|
|
77
|
+
expect(res.stdout).toMatch(/parachute-vault init/);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
test("fails when the pointer targets a non-existent file (moved repo)", () => {
|
|
@@ -172,7 +172,7 @@ describe("vault doctor — extended checks", () => {
|
|
|
172
172
|
// Use a non-default port to prove we're actually reading config.yaml,
|
|
173
173
|
// not just matching against DEFAULT_PORT.
|
|
174
174
|
writeFileSync(join(dir, "config.yaml"), "port: 4321\n");
|
|
175
|
-
writeClaudeJson(dir, "http://127.0.0.1:4321/
|
|
175
|
+
writeClaudeJson(dir, "http://127.0.0.1:4321/vault/default/mcp");
|
|
176
176
|
const res = runCli(["doctor"], dir, { HOME: dir });
|
|
177
177
|
expect(res.stdout).toMatch(/✓ MCP entry in ~\/\.claude\.json/);
|
|
178
178
|
expect(res.stdout).toMatch(/✓ MCP URL port matches vault\s+\(port 4321\)/);
|
|
@@ -184,7 +184,7 @@ describe("vault doctor — extended checks", () => {
|
|
|
184
184
|
|
|
185
185
|
test("warns when MCP URL port does not match the vault's configured port", () => {
|
|
186
186
|
writeFileSync(join(dir, "config.yaml"), "port: 4321\n");
|
|
187
|
-
writeClaudeJson(dir, "http://127.0.0.1:9999/
|
|
187
|
+
writeClaudeJson(dir, "http://127.0.0.1:9999/vault/default/mcp");
|
|
188
188
|
const res = runCli(["doctor"], dir, { HOME: dir });
|
|
189
189
|
expect(res.stdout).toMatch(/✓ MCP entry in ~\/\.claude\.json/);
|
|
190
190
|
expect(res.stdout).toMatch(/! MCP URL port matches vault/);
|
|
@@ -304,9 +304,11 @@ describe("vault uninstall", () => {
|
|
|
304
304
|
});
|
|
305
305
|
|
|
306
306
|
test("answering 'no' at the prompt does not touch daemon/filesystem", async () => {
|
|
307
|
-
// Set up a fake install: wrapper + pointer in the
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
// Set up a fake install: wrapper + pointer in the vault/ subdir of the
|
|
308
|
+
// temp PARACHUTE_HOME (the post-0.3 layout).
|
|
309
|
+
mkdirSync(join(dir, "vault"), { recursive: true });
|
|
310
|
+
const wrapper = join(dir, "vault", "start.sh");
|
|
311
|
+
const pointer = join(dir, "vault", "server-path");
|
|
310
312
|
writeFileSync(wrapper, "#!/bin/bash\n");
|
|
311
313
|
writeFileSync(pointer, "/tmp/fake.ts\n");
|
|
312
314
|
|
package/src/launchd.test.ts
CHANGED
|
@@ -40,7 +40,7 @@ describe("generateWrapper", () => {
|
|
|
40
40
|
expect(wrapper).toContain('if [ -z "$SERVER_PATH" ]; then');
|
|
41
41
|
expect(wrapper).toContain("exit 1");
|
|
42
42
|
// Actionable message — user needs to know what to run.
|
|
43
|
-
expect(wrapper).toMatch(/parachute
|
|
43
|
+
expect(wrapper).toMatch(/parachute-vault init/);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
test("fails loudly when pointer target no longer exists (moved repo)", () => {
|
package/src/launchd.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* macOS launchd agent management for the vault daemon.
|
|
3
3
|
*
|
|
4
|
-
* The plist runs `~/.parachute/start.sh` (the shared wrapper from
|
|
4
|
+
* The plist runs `~/.parachute/vault/start.sh` (the shared wrapper from
|
|
5
5
|
* daemon.ts). The wrapper reads the pointer file at every boot, so
|
|
6
|
-
* moving the repo only requires re-running `parachute
|
|
6
|
+
* moving the repo only requires re-running `parachute-vault init`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { join } from "path";
|
|
11
11
|
import { writeFile, unlink } from "fs/promises";
|
|
12
12
|
import { $ } from "bun";
|
|
13
|
-
import {
|
|
13
|
+
import { VAULT_HOME, LOG_PATH, ERR_PATH } from "./config.ts";
|
|
14
14
|
import { WRAPPER_PATH, writeDaemonWrapper } from "./daemon.ts";
|
|
15
15
|
|
|
16
16
|
const LABEL = "computer.parachute.vault";
|
|
@@ -37,7 +37,7 @@ export function generatePlist(): string {
|
|
|
37
37
|
<key>StandardErrorPath</key>
|
|
38
38
|
<string>${ERR_PATH}</string>
|
|
39
39
|
<key>WorkingDirectory</key>
|
|
40
|
-
<string>${
|
|
40
|
+
<string>${VAULT_HOME}</string>
|
|
41
41
|
</dict>
|
|
42
42
|
</plist>`;
|
|
43
43
|
}
|
|
@@ -45,7 +45,7 @@ export function generatePlist(): string {
|
|
|
45
45
|
/**
|
|
46
46
|
* Install or re-install the launchd agent. Idempotent: if the agent is
|
|
47
47
|
* already loaded, it's unloaded first so the new wrapper + pointer take
|
|
48
|
-
* effect. This is what makes `parachute
|
|
48
|
+
* effect. This is what makes `parachute-vault init` safe to re-run after
|
|
49
49
|
* a folder move — the incident that motivated this PR.
|
|
50
50
|
*/
|
|
51
51
|
export async function installAgent(): Promise<{ serverPath: string }> {
|
|
@@ -86,7 +86,7 @@ export async function uninstallAgent(): Promise<void> {
|
|
|
86
86
|
// Linux uninstall path. Callers that want a fully-clean teardown must
|
|
87
87
|
// also call `removeDaemonWrapper()` — the CLI's `uninstall` command in
|
|
88
88
|
// PR 3 wires that up. Leaving them here programmatically would strand
|
|
89
|
-
// orphaned files in `~/.parachute/`.
|
|
89
|
+
// orphaned files in `~/.parachute/vault/`.
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export async function isAgentLoaded(): Promise<boolean> {
|
package/src/mcp-http.ts
CHANGED
|
@@ -10,12 +10,9 @@
|
|
|
10
10
|
* root cause of vault#56. The `initialize` method still works if a
|
|
11
11
|
* client sends it (the Server class handles it natively).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Vault description is sent as the MCP server instruction.
|
|
18
|
-
* Read-only keys see fewer tools.
|
|
13
|
+
* Every MCP session is scoped to one vault via `/vault/{name}/mcp`.
|
|
14
|
+
* The vault's description is sent as the MCP server instruction, and
|
|
15
|
+
* read-only keys see a filtered tool list.
|
|
19
16
|
*/
|
|
20
17
|
|
|
21
18
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -23,22 +20,45 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
23
20
|
import {
|
|
24
21
|
ListToolsRequestSchema,
|
|
25
22
|
CallToolRequestSchema,
|
|
23
|
+
ErrorCode,
|
|
24
|
+
McpError,
|
|
26
25
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
26
|
+
import { generateScopedMcpTools, getServerInstruction } from "./mcp-tools.ts";
|
|
27
|
+
import { requireScope } from "./auth.ts";
|
|
29
28
|
import type { AuthResult } from "./auth.ts";
|
|
30
29
|
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
30
|
+
import { SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Required scope for each MCP tool. Tools that mutate note/tag state require
|
|
34
|
+
* `vault:write`; pure query tools need `vault:read`. `vault-info` is listed as
|
|
35
|
+
* read because read-only callers can fetch stats — the description-update
|
|
36
|
+
* branch inside vault-info performs its own secondary `vault:write` check
|
|
37
|
+
* (see `overrideVaultInfo` in mcp-tools.ts). Do not assume the outer gate
|
|
38
|
+
* alone protects the inner branch.
|
|
39
|
+
*/
|
|
40
|
+
const TOOL_REQUIRED_SCOPE: Record<string, string> = {
|
|
41
|
+
"query-notes": SCOPE_READ,
|
|
42
|
+
"list-tags": SCOPE_READ,
|
|
43
|
+
"find-path": SCOPE_READ,
|
|
44
|
+
"vault-info": SCOPE_READ,
|
|
45
|
+
"create-note": SCOPE_WRITE,
|
|
46
|
+
"update-note": SCOPE_WRITE,
|
|
47
|
+
"delete-note": SCOPE_WRITE,
|
|
48
|
+
"update-tag": SCOPE_WRITE,
|
|
49
|
+
"delete-tag": SCOPE_WRITE,
|
|
50
|
+
};
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
52
|
+
function requiredScopeForTool(toolName: string): string {
|
|
53
|
+
// Default-deny: unknown tools require write. Keeps accidental reads of
|
|
54
|
+
// a not-yet-mapped mutation tool from slipping past.
|
|
55
|
+
return TOOL_REQUIRED_SCOPE[toolName] ?? SCOPE_WRITE;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
/** Handle scoped MCP at /
|
|
58
|
+
/** Handle scoped MCP at /vault/{name}/mcp (single vault). */
|
|
39
59
|
export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
|
|
40
60
|
const instruction = getServerInstruction(vaultName);
|
|
41
|
-
return handleMcp(req, () => generateScopedMcpTools(vaultName), `parachute-vault/${vaultName}`, auth, instruction);
|
|
61
|
+
return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, auth, instruction);
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
async function handleMcp(
|
|
@@ -48,7 +68,6 @@ async function handleMcp(
|
|
|
48
68
|
auth: AuthResult,
|
|
49
69
|
instruction: string,
|
|
50
70
|
): Promise<Response> {
|
|
51
|
-
const { permission } = auth;
|
|
52
71
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
53
72
|
sessionIdGenerator: undefined,
|
|
54
73
|
enableJsonResponse: true,
|
|
@@ -64,10 +83,13 @@ async function handleMcp(
|
|
|
64
83
|
|
|
65
84
|
const mcpTools = getTools();
|
|
66
85
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
// Filter the advertised tool list to what the caller's scopes actually
|
|
87
|
+
// permit. Callers without `vault:write` don't see mutation tools at all —
|
|
88
|
+
// matches the prior behavior of the read/full permission model but is now
|
|
89
|
+
// driven by scope inheritance.
|
|
90
|
+
const visibleTools = mcpTools.filter((t) =>
|
|
91
|
+
requireScope(auth, requiredScopeForTool(t.name)),
|
|
92
|
+
);
|
|
71
93
|
|
|
72
94
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
73
95
|
tools: visibleTools.map((t) => ({
|
|
@@ -80,9 +102,13 @@ async function handleMcp(
|
|
|
80
102
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
81
103
|
const { name, arguments: args } = request.params;
|
|
82
104
|
|
|
83
|
-
|
|
105
|
+
const neededScope = requiredScopeForTool(name);
|
|
106
|
+
if (!requireScope(auth, neededScope)) {
|
|
84
107
|
return {
|
|
85
|
-
content: [{
|
|
108
|
+
content: [{
|
|
109
|
+
type: "text" as const,
|
|
110
|
+
text: `Forbidden: tool '${name}' requires the '${neededScope}' scope. Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
|
|
111
|
+
}],
|
|
86
112
|
isError: true,
|
|
87
113
|
};
|
|
88
114
|
}
|
|
@@ -100,7 +126,35 @@ async function handleMcp(
|
|
|
100
126
|
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
101
127
|
};
|
|
102
128
|
} catch (err) {
|
|
129
|
+
// Domain errors from the core tools (conflict, missing precondition) get
|
|
130
|
+
// surfaced as JSON-RPC errors with a structured `data` field so an
|
|
131
|
+
// agent can key off `data.error_type` and the concurrency tokens.
|
|
132
|
+
// Everything else falls through to an in-band tool error with
|
|
133
|
+
// `isError: true` — legible but unstructured.
|
|
103
134
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
135
|
+
const e = err as {
|
|
136
|
+
code?: string;
|
|
137
|
+
note_id?: string;
|
|
138
|
+
note_path?: string | null;
|
|
139
|
+
current_updated_at?: string | null;
|
|
140
|
+
expected_updated_at?: string;
|
|
141
|
+
};
|
|
142
|
+
if (e?.code === "CONFLICT") {
|
|
143
|
+
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
144
|
+
error_type: "conflict",
|
|
145
|
+
current_updated_at: e.current_updated_at ?? null,
|
|
146
|
+
your_updated_at: e.expected_updated_at,
|
|
147
|
+
path: e.note_path ?? null,
|
|
148
|
+
note_id: e.note_id,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (e?.code === "PRECONDITION_REQUIRED") {
|
|
152
|
+
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
153
|
+
error_type: "precondition_required",
|
|
154
|
+
note_id: e.note_id,
|
|
155
|
+
path: e.note_path ?? null,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
104
158
|
return {
|
|
105
159
|
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
106
160
|
isError: true,
|