@openparachute/vault 0.6.4-rc.5 → 0.6.4-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/scale.bench.test.ts +82 -0
- package/src/test-support/spawn.ts +85 -0
- package/src/vault.test.ts +93 -0
package/package.json
CHANGED
|
@@ -15,30 +15,21 @@ import { resolve } from "path";
|
|
|
15
15
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
|
16
16
|
import { tmpdir } from "os";
|
|
17
17
|
import { join } from "path";
|
|
18
|
+
import { runSubprocess } from "./test-support/spawn.ts";
|
|
18
19
|
|
|
19
20
|
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
20
21
|
|
|
22
|
+
// Async spawn — the daemon-busy probe inside the CLI fetches the test
|
|
23
|
+
// process's stub server, so the parent event loop must keep servicing
|
|
24
|
+
// requests while the child runs. `Bun.spawnSync` blocks the event loop and
|
|
25
|
+
// the in-test server can't answer, which makes every probe time out into
|
|
26
|
+
// the "not listening" branch (vault#324). `runSubprocess` is the shared
|
|
27
|
+
// safe primitive (vault#325 Part 1) — see src/test-support/spawn.ts.
|
|
21
28
|
async function runCli(
|
|
22
29
|
args: string[],
|
|
23
30
|
env: Record<string, string>,
|
|
24
31
|
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
25
|
-
|
|
26
|
-
// process's stub server, so the parent event loop must keep servicing
|
|
27
|
-
// requests while the child runs. Bun.spawnSync blocks the event loop
|
|
28
|
-
// and the in-test server can't answer, which makes every probe time
|
|
29
|
-
// out into the "not listening" branch.
|
|
30
|
-
const proc = Bun.spawn({
|
|
31
|
-
cmd: ["bun", CLI, ...args],
|
|
32
|
-
stdout: "pipe",
|
|
33
|
-
stderr: "pipe",
|
|
34
|
-
env: { ...process.env, ...env },
|
|
35
|
-
});
|
|
36
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
37
|
-
new Response(proc.stdout).text(),
|
|
38
|
-
new Response(proc.stderr).text(),
|
|
39
|
-
proc.exited,
|
|
40
|
-
]);
|
|
41
|
-
return { exitCode: exitCode ?? -1, stdout, stderr };
|
|
32
|
+
return runSubprocess({ cmd: ["bun", CLI, ...args], env });
|
|
42
33
|
}
|
|
43
34
|
|
|
44
35
|
let home: string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in scale smoke (vault#325 Part 3).
|
|
3
|
+
*
|
|
4
|
+
* Guards the synthetic scale harness against bit-rot WITHOUT slowing the
|
|
5
|
+
* default `bun test` gate. The real benchmark lives in
|
|
6
|
+
* `scripts/scale-bench.ts` (10k / 50k / 100k); this is a fast, tiny-size
|
|
7
|
+
* functional smoke of the same hot-path code so a green CI run still
|
|
8
|
+
* proves the harness paths execute end-to-end.
|
|
9
|
+
*
|
|
10
|
+
* SKIPPED BY DEFAULT. To run it:
|
|
11
|
+
*
|
|
12
|
+
* VAULT_SCALE_BENCH=1 bun test ./src/scale.bench.test.ts
|
|
13
|
+
*
|
|
14
|
+
* For real timings + the documented ceiling, run the standalone harness:
|
|
15
|
+
*
|
|
16
|
+
* bun scripts/scale-bench.ts # 10000 50000 100000
|
|
17
|
+
*
|
|
18
|
+
* See docs/SCALE.md.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, test, expect } from "bun:test";
|
|
22
|
+
import { Database } from "bun:sqlite";
|
|
23
|
+
import { BunSqliteStore } from "../core/src/store.ts";
|
|
24
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
25
|
+
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
26
|
+
import type { BulkNoteInput } from "../core/src/notes.ts";
|
|
27
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
28
|
+
import { tmpdir } from "os";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
|
|
31
|
+
const ENABLED = process.env.VAULT_SCALE_BENCH === "1";
|
|
32
|
+
|
|
33
|
+
// `describe.skipIf` keeps the suite registered (so it shows in the test list)
|
|
34
|
+
// but contributes zero runtime to the default gate. Size is intentionally
|
|
35
|
+
// tiny — this is a functional smoke, not a timing run.
|
|
36
|
+
describe.skipIf(!ENABLED)("scale harness smoke (opt-in: VAULT_SCALE_BENCH=1)", () => {
|
|
37
|
+
test("seed + hot-path queries + export run at small N", async () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "vault-scale-smoke-"));
|
|
39
|
+
const db = new Database(join(dir, "smoke.db"));
|
|
40
|
+
const store = new BunSqliteStore(db);
|
|
41
|
+
try {
|
|
42
|
+
const N = 500;
|
|
43
|
+
|
|
44
|
+
// Declare the indexed field via the update-tag tool (the owner of the
|
|
45
|
+
// indexed_fields lifecycle — mirrors scripts/scale-bench.ts).
|
|
46
|
+
const tools = generateMcpTools(store);
|
|
47
|
+
const updateTag = tools.find((t) => t.name === "update-tag");
|
|
48
|
+
expect(updateTag).toBeDefined();
|
|
49
|
+
await updateTag!.execute({
|
|
50
|
+
tag: "work",
|
|
51
|
+
fields: { status: { type: "string", indexed: true } },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const inputs: BulkNoteInput[] = Array.from({ length: N }, (_, i) => ({
|
|
55
|
+
id: `note-${i}`,
|
|
56
|
+
path: `notes/note-${i}`,
|
|
57
|
+
content: `# Note ${i}\n\nconsectetur body ${i}`,
|
|
58
|
+
tags: i % 3 === 0 ? ["work", "work/meeting"] : ["health"],
|
|
59
|
+
metadata: { status: i % 2 === 0 ? "active" : "done", seq: i },
|
|
60
|
+
}));
|
|
61
|
+
await store.createNotes(inputs);
|
|
62
|
+
|
|
63
|
+
// Hot paths — same shapes the standalone harness times.
|
|
64
|
+
expect((await store.queryNotes({ tags: ["health"], limit: 50 })).length).toBeGreaterThan(0);
|
|
65
|
+
expect(
|
|
66
|
+
(await store.queryNotes({ metadata: { status: { eq: "active" } }, limit: 50 })).length,
|
|
67
|
+
).toBeGreaterThan(0);
|
|
68
|
+
expect((await store.queryNotes({ orderBy: "status", limit: 50 })).length).toBe(50);
|
|
69
|
+
expect((await store.searchNotes("consectetur", { limit: 50 })).length).toBeGreaterThan(0);
|
|
70
|
+
|
|
71
|
+
const stats = await exportVaultToDir(store, {
|
|
72
|
+
outDir: join(dir, "export"),
|
|
73
|
+
vaultName: "smoke",
|
|
74
|
+
exportedAt: "2026-01-01T00:00:00Z",
|
|
75
|
+
});
|
|
76
|
+
expect(stats.notes).toBe(N);
|
|
77
|
+
} finally {
|
|
78
|
+
db.close();
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared subprocess helper for tests (vault#325 Part 1).
|
|
3
|
+
*
|
|
4
|
+
* THE RULE — never `Bun.spawnSync` in a test that keeps an in-process
|
|
5
|
+
* `Bun.serve` listening for the child to probe.
|
|
6
|
+
*
|
|
7
|
+
* `Bun.spawnSync` blocks the parent's event loop until the child exits.
|
|
8
|
+
* If the child makes an HTTP request back to a server the *same test
|
|
9
|
+
* process* is hosting (e.g. the CLI's daemon-busy pre-flight fetching a
|
|
10
|
+
* stub `/health`), the parent can't answer — the child's request times
|
|
11
|
+
* out into the "nothing listening" branch and the assertion silently
|
|
12
|
+
* tests the wrong path (the guard never fires, or fails for a non-obvious
|
|
13
|
+
* reason). This bit the daemon-busy import guard; the fix landed in
|
|
14
|
+
* vault#324 (`src/import-daemon-busy.test.ts`) and this helper is the
|
|
15
|
+
* institutional-memory follow-up so future subprocess tests reach for the
|
|
16
|
+
* safe primitive by default.
|
|
17
|
+
*
|
|
18
|
+
* `runSubprocess` uses async `Bun.spawn` + `await proc.exited`, so the
|
|
19
|
+
* parent's event loop keeps servicing requests while the child runs.
|
|
20
|
+
*
|
|
21
|
+
* NOTE on what's *fine*: `Bun.spawnSync` is safe in tests that don't run
|
|
22
|
+
* an in-test server the child talks to over HTTP — e.g. shelling out to
|
|
23
|
+
* `git` to build a fixture repo (`mirror-*.test.ts`), or a `Bun.serve`
|
|
24
|
+
* that merely *holds a port* so the child's `lsof`/availability probe
|
|
25
|
+
* sees it occupied (`doctor.test.ts`). The deadlock is specifically
|
|
26
|
+
* "child fetches the parent test process's server." When in doubt, use
|
|
27
|
+
* this helper — it's never wrong, only sometimes unnecessary.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface RunSubprocessResult {
|
|
31
|
+
exitCode: number;
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunSubprocessOptions {
|
|
37
|
+
cmd: string[];
|
|
38
|
+
cwd?: string;
|
|
39
|
+
/** Extra env merged over `process.env`. `undefined` values delete the key. */
|
|
40
|
+
env?: Record<string, string | undefined>;
|
|
41
|
+
/** Optional bytes piped to the child's stdin. */
|
|
42
|
+
stdin?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a subprocess WITHOUT blocking the event loop.
|
|
47
|
+
*
|
|
48
|
+
* Drop-in for the `Bun.spawnSync({ cmd, env, stdout, stderr })` pattern
|
|
49
|
+
* that recurs across the CLI integration tests, but built on async
|
|
50
|
+
* `Bun.spawn` so an in-process `Bun.serve` the child probes can still
|
|
51
|
+
* answer. Returns once the child has exited, with decoded stdout/stderr.
|
|
52
|
+
*/
|
|
53
|
+
export async function runSubprocess(
|
|
54
|
+
opts: RunSubprocessOptions,
|
|
55
|
+
): Promise<RunSubprocessResult> {
|
|
56
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
57
|
+
if (opts.env) {
|
|
58
|
+
for (const [k, v] of Object.entries(opts.env)) {
|
|
59
|
+
if (v === undefined) delete env[k];
|
|
60
|
+
else env[k] = v;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const proc = Bun.spawn({
|
|
65
|
+
cmd: opts.cmd,
|
|
66
|
+
cwd: opts.cwd,
|
|
67
|
+
env,
|
|
68
|
+
stdin: opts.stdin === undefined ? undefined : "pipe",
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "pipe",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (opts.stdin !== undefined) {
|
|
74
|
+
proc.stdin!.write(opts.stdin);
|
|
75
|
+
await proc.stdin!.end();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
79
|
+
new Response(proc.stdout).text(),
|
|
80
|
+
new Response(proc.stderr).text(),
|
|
81
|
+
proc.exited,
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
return { exitCode: exitCode ?? -1, stdout, stderr };
|
|
85
|
+
}
|
package/src/vault.test.ts
CHANGED
|
@@ -1393,6 +1393,99 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1393
1393
|
closeAllStores();
|
|
1394
1394
|
});
|
|
1395
1395
|
|
|
1396
|
+
// -- Q6 through the MCP TRANSPORT (vault#325 Part 2) --------------------
|
|
1397
|
+
//
|
|
1398
|
+
// The two tests above call `tool.execute()` directly — that exercises the
|
|
1399
|
+
// tag-scope WRAPPER but bypasses `handleScopedMcp`: the JSON-RPC transport,
|
|
1400
|
+
// the `hasScopeForVault` tool-visibility gate, and the tools/call dispatch.
|
|
1401
|
+
// The HTTP-layer twin lives in `routing.test.ts` (Q6 read-path). What was
|
|
1402
|
+
// missing — and what this pins — is the orphan-sub-tag fail-open driven
|
|
1403
|
+
// through the actual MCP `tools/call` path a real client hits. Same fixture
|
|
1404
|
+
// as the HTTP test (`#health/food` with no `_tags/health/food` schema,
|
|
1405
|
+
// token allowlisted for `health`), different transport.
|
|
1406
|
+
|
|
1407
|
+
test("MCP query-notes (tools/call) sees orphan sub-tag via string-form root (vault#325 Part 2)", async () => {
|
|
1408
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
1409
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1410
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1411
|
+
|
|
1412
|
+
const vaultName = `mcp-orphan-query-${Date.now()}`;
|
|
1413
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1414
|
+
const store = getVaultStore(vaultName);
|
|
1415
|
+
// No `_tags/health/food` schema — this is the orphan case. The hierarchy
|
|
1416
|
+
// is implicit, so authorization must fall back to the string-form root.
|
|
1417
|
+
const orphan = await store.createNote("orphan", { tags: ["health/food"] });
|
|
1418
|
+
|
|
1419
|
+
const auth = {
|
|
1420
|
+
permission: "read" as const,
|
|
1421
|
+
scopes: ["vault:read"],
|
|
1422
|
+
legacyDerived: false,
|
|
1423
|
+
scoped_tags: ["health"],
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// The URL is inert here — handleScopedMcp hands it to the SDK
|
|
1427
|
+
// transport, which only reads the body; the `vaultName` route wiring
|
|
1428
|
+
// is exercised by routing.test.ts, not this transport-level test. The
|
|
1429
|
+
// `accept` header is load-bearing: `enableJsonResponse` returns JSON
|
|
1430
|
+
// only when text/event-stream is also acceptable, else it streams SSE.
|
|
1431
|
+
const callReq = (id: number, name: string, args: Record<string, unknown>) =>
|
|
1432
|
+
new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
1433
|
+
method: "POST",
|
|
1434
|
+
headers: {
|
|
1435
|
+
"content-type": "application/json",
|
|
1436
|
+
accept: "application/json, text/event-stream",
|
|
1437
|
+
},
|
|
1438
|
+
body: JSON.stringify({
|
|
1439
|
+
jsonrpc: "2.0",
|
|
1440
|
+
id,
|
|
1441
|
+
method: "tools/call",
|
|
1442
|
+
params: { name, arguments: args },
|
|
1443
|
+
}),
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// Fetch the orphan by id through the MCP transport with a token scoped
|
|
1447
|
+
// to ["health"]. The orphan is tagged `health/food` (no schema) → the
|
|
1448
|
+
// string-form fallback resolves the root `health` → in allowlist → the
|
|
1449
|
+
// note must come back rather than 404/forbidden.
|
|
1450
|
+
const res = await handleScopedMcp(callReq(1, "query-notes", { id: orphan.id }), vaultName, auth as any);
|
|
1451
|
+
expect(res.status).toBe(200);
|
|
1452
|
+
const body = (await res.json()) as any;
|
|
1453
|
+
// tools/call returns content[].text with the JSON-stringified tool result.
|
|
1454
|
+
expect(body.result?.isError).toBeFalsy();
|
|
1455
|
+
const text: string = body.result.content[0].text;
|
|
1456
|
+
const parsed = JSON.parse(text);
|
|
1457
|
+
expect(parsed.id).toBe(orphan.id);
|
|
1458
|
+
expect(parsed.tags).toContain("health/food");
|
|
1459
|
+
|
|
1460
|
+
// Control: a token scoped to a DIFFERENT root must NOT see the orphan
|
|
1461
|
+
// through the same transport — proves the green result above is the
|
|
1462
|
+
// fail-open fallback firing, not scoping being inert.
|
|
1463
|
+
const denied = {
|
|
1464
|
+
permission: "read" as const,
|
|
1465
|
+
scopes: ["vault:read"],
|
|
1466
|
+
legacyDerived: false,
|
|
1467
|
+
scoped_tags: ["work"],
|
|
1468
|
+
};
|
|
1469
|
+
const resDenied = await handleScopedMcp(
|
|
1470
|
+
callReq(2, "query-notes", { id: orphan.id }),
|
|
1471
|
+
vaultName,
|
|
1472
|
+
denied as any,
|
|
1473
|
+
);
|
|
1474
|
+
expect(resDenied.status).toBe(200);
|
|
1475
|
+
const deniedBody = (await resDenied.json()) as any;
|
|
1476
|
+
const deniedText: string = deniedBody.result.content[0].text;
|
|
1477
|
+
const deniedParsed = JSON.parse(deniedText);
|
|
1478
|
+
// Out-of-scope single-note fetch fails closed: the wrapper replaces the
|
|
1479
|
+
// note body with `{ error: "Note not found" }` (no content/tags leak).
|
|
1480
|
+
// This proves the `health`-scoped green result above is the fail-open
|
|
1481
|
+
// string-form fallback firing, not the wrapper being a no-op.
|
|
1482
|
+
expect(deniedParsed.error).toBe("Note not found");
|
|
1483
|
+
expect(deniedParsed.content).toBeUndefined();
|
|
1484
|
+
expect(deniedParsed.tags).toBeUndefined();
|
|
1485
|
+
|
|
1486
|
+
closeAllStores();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1396
1489
|
// -- Q5: MCP delete-tag dependency check -------------------------------
|
|
1397
1490
|
|
|
1398
1491
|
test("MCP delete-tag returns tag_in_use_by_tokens when a vestigial tag-scoped token row references the tag", async () => {
|