@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.3

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.
Files changed (45) hide show
  1. package/README.md +3 -5
  2. package/core/src/content-range.test.ts +0 -127
  3. package/core/src/content-range.ts +0 -100
  4. package/core/src/core.test.ts +4 -66
  5. package/core/src/expand.ts +3 -11
  6. package/core/src/mcp.ts +6 -577
  7. package/core/src/notes.ts +4 -201
  8. package/core/src/search-fts-v25.test.ts +1 -9
  9. package/core/src/search-query.test.ts +0 -42
  10. package/core/src/search-query.ts +0 -27
  11. package/core/src/seed-packs.test.ts +1 -117
  12. package/core/src/seed-packs.ts +1 -217
  13. package/core/src/types.ts +0 -6
  14. package/core/src/vault-projection.ts +0 -55
  15. package/package.json +1 -1
  16. package/src/add-pack.test.ts +0 -32
  17. package/src/auth-hub-jwt.test.ts +1 -118
  18. package/src/auth.ts +0 -64
  19. package/src/cli.ts +1 -5
  20. package/src/mcp-http.ts +4 -33
  21. package/src/mcp-tools.ts +14 -61
  22. package/src/oauth-discovery.ts +0 -31
  23. package/src/onboarding-seed.test.ts +0 -64
  24. package/src/routes.ts +95 -92
  25. package/src/routing.test.ts +4 -229
  26. package/src/routing.ts +23 -152
  27. package/src/scopes.ts +0 -22
  28. package/src/server.ts +0 -7
  29. package/src/storage.test.ts +1 -200
  30. package/src/transcription-worker.test.ts +0 -151
  31. package/src/transcription-worker.ts +52 -113
  32. package/src/vault.test.ts +11 -48
  33. package/core/src/attachment/bytes-provider.ts +0 -65
  34. package/core/src/attachment/policy.test.ts +0 -66
  35. package/core/src/attachment/policy.ts +0 -131
  36. package/core/src/attachment/tickets.test.ts +0 -45
  37. package/core/src/attachment/tickets.ts +0 -117
  38. package/core/src/attachment-tickets-tool.test.ts +0 -286
  39. package/core/src/display-title.test.ts +0 -190
  40. package/core/src/lede.test.ts +0 -96
  41. package/core/src/search-title-boost.test.ts +0 -125
  42. package/src/attachment-bytes.ts +0 -68
  43. package/src/attachment-tickets.test.ts +0 -475
  44. package/src/attachment-tickets.ts +0 -340
  45. package/src/read-attachment.test.ts +0 -436
@@ -1,436 +0,0 @@
1
- /**
2
- * `read-attachment` — the model-lane (Wave 2) MCP tool. Bytes DO pass
3
- * through this tool (unlike the ticket tools), dispatched by mime family:
4
- * text (byte-windowed pagination, the query-notes content_offset contract),
5
- * image (a real MCP image content block, 4 MiB cap), audio/video (a
6
- * transcript pointer, never bytes), and other binary (an honest refusal
7
- * pointing at a download ticket). Exercised end-to-end through the real
8
- * `tools/call` JSON-RPC path (`handleScopedMcp`), same harness shape as
9
- * `attachment-tickets.test.ts`.
10
- */
11
-
12
- import { describe, test, expect } from "bun:test";
13
- import { join } from "path";
14
- import { tmpdir } from "os";
15
- import { mkdirSync, writeFileSync } from "fs";
16
-
17
- const testDir = join(
18
- tmpdir(),
19
- `vault-read-attachment-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
20
- );
21
- process.env.PARACHUTE_HOME = testDir;
22
- // Deliberately NOT setting ASSETS_DIR — see attachment-tickets.test.ts's doc
23
- // comment for why (it's process-global; each vault below gets its own
24
- // unset-ASSETS_DIR-default assets dir, fully isolated under this file's own
25
- // unique PARACHUTE_HOME).
26
-
27
- const { handleScopedMcp } = await import("./mcp-http.ts");
28
- const { writeVaultConfig, assetsDir } = await import("./config.ts");
29
- const { getVaultStore } = await import("./vault-store.ts");
30
- const { transcriptPathFor } = await import("./transcript-note.ts");
31
- const { MAX_ATTACHMENT_IMAGE_BYTES } = await import("../core/src/attachment/bytes-provider.ts");
32
- const { DEFAULT_ATTACHMENT_WINDOW_BYTES, MAX_ATTACHMENT_WINDOW_BYTES } = await import(
33
- "../core/src/content-range.ts"
34
- );
35
-
36
- function freshVault(prefix: string): string {
37
- const name = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
38
- writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
39
- return name;
40
- }
41
-
42
- function auth(scopedTags: string[] | null = null) {
43
- return {
44
- permission: "full" as const,
45
- scopes: ["vault:read", "vault:write"],
46
- legacyDerived: false,
47
- scoped_tags: scopedTags,
48
- } as any;
49
- }
50
-
51
- /** Full `tools/call` content array — needed for the image branch's two-block shape (a plain callTool() only sees content[0]). */
52
- async function callToolContent(
53
- vaultName: string,
54
- name: string,
55
- args: Record<string, unknown>,
56
- a = auth(),
57
- ): Promise<any[]> {
58
- const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
59
- method: "POST",
60
- headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
61
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
62
- });
63
- const res = await handleScopedMcp(req, vaultName, a);
64
- const body = (await res.json()) as any;
65
- if (body.error) {
66
- const err = new Error(body.error.message);
67
- Object.assign(err, body.error.data ?? {});
68
- throw err;
69
- }
70
- return body.result.content;
71
- }
72
-
73
- async function callTool(
74
- vaultName: string,
75
- name: string,
76
- args: Record<string, unknown>,
77
- a = auth(),
78
- ): Promise<any> {
79
- const content = await callToolContent(vaultName, name, args, a);
80
- return JSON.parse(content[0].text);
81
- }
82
-
83
- /** Write bytes to the vault's real on-disk assets dir and register the attachment row — mirrors what REST upload / ticket spend do, without going through either. */
84
- async function makeAttachment(
85
- vaultName: string,
86
- relPath: string,
87
- bytes: Buffer,
88
- mimeType: string,
89
- opts: { noteTags?: string[]; metadata?: Record<string, unknown>; skipWrite?: boolean } = {},
90
- ): Promise<{ attachmentId: string; noteId: string }> {
91
- const store = getVaultStore(vaultName);
92
- const note = await store.createNote(`note for ${relPath}`, { tags: opts.noteTags ?? ["misc"] });
93
- if (!opts.skipWrite) {
94
- const dir = join(assetsDir(vaultName), relPath.split("/").slice(0, -1).join("/") || ".");
95
- mkdirSync(dir, { recursive: true });
96
- writeFileSync(join(assetsDir(vaultName), relPath), bytes);
97
- }
98
- const attachment = await store.addAttachment(note.id, relPath, mimeType, opts.metadata);
99
- return { attachmentId: attachment.id, noteId: note.id };
100
- }
101
-
102
- describe("read-attachment — attachment_id validation + not_found", () => {
103
- test("missing attachment_id → missing_required_field", async () => {
104
- const vaultName = freshVault("ra-missing-id");
105
- await expect(callTool(vaultName, "read-attachment", {})).rejects.toMatchObject({
106
- error_type: "missing_required_field",
107
- field: "attachment_id",
108
- });
109
- });
110
-
111
- test("unknown attachment_id → not_found", async () => {
112
- const vaultName = freshVault("ra-unknown-id");
113
- await expect(
114
- callTool(vaultName, "read-attachment", { attachment_id: "does-not-exist" }),
115
- ).rejects.toMatchObject({ error_type: "not_found", field: "attachment_id" });
116
- });
117
- });
118
-
119
- describe("read-attachment — text family (D2)", () => {
120
- test("plain text, no range params → default 64 KiB window, content_next_offset present when there's more", async () => {
121
- const vaultName = freshVault("ra-text-default");
122
- const content = "x".repeat(DEFAULT_ATTACHMENT_WINDOW_BYTES + 500);
123
- const { attachmentId } = await makeAttachment(vaultName, "d/note.txt", Buffer.from(content, "utf8"), "text/plain; charset=utf-8");
124
-
125
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
126
- expect(result.mime_type).toBe("text/plain; charset=utf-8");
127
- expect(Buffer.byteLength(result.content, "utf8")).toBe(DEFAULT_ATTACHMENT_WINDOW_BYTES);
128
- expect(result.content_offset).toBe(0);
129
- expect(result.content_total_length).toBe(content.length);
130
- expect(result.content_next_offset).toBe(DEFAULT_ATTACHMENT_WINDOW_BYTES);
131
- });
132
-
133
- test("a small file fits in one call → content_next_offset null", async () => {
134
- const vaultName = freshVault("ra-text-small");
135
- const { attachmentId } = await makeAttachment(vaultName, "d/small.txt", Buffer.from("hello world", "utf8"), "text/plain; charset=utf-8");
136
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
137
- expect(result.content).toBe("hello world");
138
- expect(result.content_next_offset).toBeNull();
139
- expect(result.content_total_length).toBe(11);
140
- });
141
-
142
- test("explicit content_offset/content_length page a window mid-file", async () => {
143
- const vaultName = freshVault("ra-text-window");
144
- const { attachmentId } = await makeAttachment(vaultName, "d/abc.txt", Buffer.from("abcdefghij", "utf8"), "text/plain; charset=utf-8");
145
- const result = await callTool(vaultName, "read-attachment", {
146
- attachment_id: attachmentId,
147
- content_offset: 3,
148
- content_length: 4,
149
- });
150
- expect(result.content).toBe("defg");
151
- expect(result.content_offset).toBe(3);
152
- expect(result.content_next_offset).toBe(7);
153
- });
154
-
155
- test("content_length above the 256 KiB max → invalid_query", async () => {
156
- const vaultName = freshVault("ra-text-toobig");
157
- const { attachmentId } = await makeAttachment(vaultName, "d/x.txt", Buffer.from("hi", "utf8"), "text/plain; charset=utf-8");
158
- await expect(
159
- callTool(vaultName, "read-attachment", { attachment_id: attachmentId, content_length: MAX_ATTACHMENT_WINDOW_BYTES + 1 }),
160
- ).rejects.toMatchObject({ error_type: "invalid_query" });
161
- });
162
-
163
- test("content_length below the minimum (4 bytes) → invalid_query", async () => {
164
- const vaultName = freshVault("ra-text-toosmall");
165
- const { attachmentId } = await makeAttachment(vaultName, "d/x.txt", Buffer.from("hi", "utf8"), "text/plain; charset=utf-8");
166
- await expect(
167
- callTool(vaultName, "read-attachment", { attachment_id: attachmentId, content_length: 1 }),
168
- ).rejects.toMatchObject({ error_type: "invalid_query" });
169
- });
170
-
171
- test("JSON (extension-curated) is treated as text", async () => {
172
- const vaultName = freshVault("ra-text-json");
173
- const { attachmentId } = await makeAttachment(vaultName, "d/data.json", Buffer.from('{"a":1}', "utf8"), "application/json; charset=utf-8");
174
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
175
- expect(result.content).toBe('{"a":1}');
176
- });
177
-
178
- test("application/x-ndjson (TEXT_MIME_ALLOWLIST, no curated extension) is treated as text", async () => {
179
- const vaultName = freshVault("ra-text-ndjson");
180
- // .ndjson has no ATTACHMENT_MIME_TYPES entry, so effectiveAttachmentMime
181
- // falls through to the row's own mimeType — exercising the allowlist
182
- // path specifically, not the extension-curation path.
183
- const { attachmentId } = await makeAttachment(
184
- vaultName,
185
- "d/log.ndjson",
186
- Buffer.from('{"a":1}\n{"b":2}\n', "utf8"),
187
- "application/x-ndjson",
188
- );
189
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
190
- expect(result.mime_type).toBe("application/x-ndjson");
191
- expect(result.content).toBe('{"a":1}\n{"b":2}\n');
192
- });
193
-
194
- test("range paging round-trip on a >256 KiB file with multi-byte UTF-8 reassembles byte-identical content", async () => {
195
- const vaultName = freshVault("ra-text-bigroundtrip");
196
- // Deterministic mixed-width content: ASCII + a repeating multi-byte
197
- // sequence, long enough that MAX_ATTACHMENT_WINDOW_BYTES pages don't
198
- // divide it evenly (forces a short final page) and multiple full-cap
199
- // pages are needed.
200
- const unit = "The quick brown fox jumps over the lazy dog. 你好世界 😀 café. ";
201
- let content = "";
202
- while (Buffer.byteLength(content, "utf8") < 300_000) content += unit;
203
- const totalBytes = Buffer.byteLength(content, "utf8");
204
- expect(totalBytes).toBeGreaterThan(256 * 1024);
205
-
206
- const { attachmentId } = await makeAttachment(vaultName, "d/big.txt", Buffer.from(content, "utf8"), "text/plain; charset=utf-8");
207
-
208
- let offset: number | null = 0;
209
- let assembled = "";
210
- let calls = 0;
211
- while (offset !== null) {
212
- const result = await callTool(vaultName, "read-attachment", {
213
- attachment_id: attachmentId,
214
- content_offset: offset,
215
- content_length: MAX_ATTACHMENT_WINDOW_BYTES, // deliberate big bites — exercises the 256 KiB max
216
- });
217
- expect(Buffer.byteLength(result.content, "utf8")).toBeLessThanOrEqual(MAX_ATTACHMENT_WINDOW_BYTES);
218
- expect(result.content_total_length).toBe(totalBytes);
219
- assembled += result.content;
220
- offset = result.content_next_offset;
221
- calls++;
222
- expect(calls).toBeLessThan(10); // sanity bound — must make progress
223
- }
224
- expect(assembled).toBe(content);
225
- expect(calls).toBeGreaterThan(1); // actually exercised pagination, not a single call
226
- });
227
-
228
- test("attachment_binary_missing when the row exists but the file was never written", async () => {
229
- const vaultName = freshVault("ra-text-missing-binary");
230
- const { attachmentId } = await makeAttachment(vaultName, "d/gone.txt", Buffer.from("x"), "text/plain; charset=utf-8", {
231
- skipWrite: true,
232
- });
233
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
234
- error_type: "attachment_binary_missing",
235
- });
236
- });
237
- });
238
-
239
- describe("read-attachment — image family (D3)", () => {
240
- test("a small image returns a real MCP image content block alongside the row-JSON text block", async () => {
241
- const vaultName = freshVault("ra-image-small");
242
- const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
243
- const { attachmentId } = await makeAttachment(vaultName, "d/pic.png", bytes, "image/png");
244
-
245
- const content = await callToolContent(vaultName, "read-attachment", { attachment_id: attachmentId });
246
- expect(content.length).toBe(2);
247
- expect(content[0].type).toBe("text");
248
- const rowJson = JSON.parse(content[0].text);
249
- expect(rowJson.attachment_id).toBe(attachmentId);
250
- expect(rowJson.mime_type).toBe("image/png");
251
- expect(rowJson.size_bytes).toBe(bytes.length);
252
- // The base64 payload must NOT be duplicated into the text block.
253
- expect(rowJson._mcpImage).toBeUndefined();
254
- expect(rowJson.data).toBeUndefined();
255
-
256
- expect(content[1].type).toBe("image");
257
- expect(content[1].mimeType).toBe("image/png");
258
- expect(Buffer.from(content[1].data, "base64").equals(bytes)).toBe(true);
259
- });
260
-
261
- test("an image over the 4 MiB cap refuses with image_too_large (size, max_bytes, how_to) — never reads the bytes", async () => {
262
- const vaultName = freshVault("ra-image-toobig");
263
- const bytes = Buffer.alloc(MAX_ATTACHMENT_IMAGE_BYTES + 1);
264
- const { attachmentId } = await makeAttachment(vaultName, "d/huge.png", bytes, "image/png");
265
-
266
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
267
- error_type: "image_too_large",
268
- size: MAX_ATTACHMENT_IMAGE_BYTES + 1,
269
- max_bytes: MAX_ATTACHMENT_IMAGE_BYTES,
270
- });
271
- });
272
-
273
- test("an image exactly at the 4 MiB cap succeeds", async () => {
274
- const vaultName = freshVault("ra-image-atcap");
275
- const bytes = Buffer.alloc(MAX_ATTACHMENT_IMAGE_BYTES, 7);
276
- const { attachmentId } = await makeAttachment(vaultName, "d/exact.png", bytes, "image/png");
277
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
278
- expect(result.size_bytes).toBe(MAX_ATTACHMENT_IMAGE_BYTES);
279
- });
280
-
281
- test("range params on an image → invalid_query (images don't page)", async () => {
282
- const vaultName = freshVault("ra-image-range");
283
- const { attachmentId } = await makeAttachment(vaultName, "d/pic.png", Buffer.from([1, 2, 3]), "image/png");
284
- await expect(
285
- callTool(vaultName, "read-attachment", { attachment_id: attachmentId, content_offset: 0 }),
286
- ).rejects.toMatchObject({ error_type: "invalid_query" });
287
- });
288
-
289
- test("attachment_binary_missing for an image row whose bytes were never written", async () => {
290
- const vaultName = freshVault("ra-image-missing");
291
- const { attachmentId } = await makeAttachment(vaultName, "d/ghost.png", Buffer.from([1]), "image/png", {
292
- skipWrite: true,
293
- });
294
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
295
- error_type: "attachment_binary_missing",
296
- });
297
- });
298
- });
299
-
300
- describe("read-attachment — audio/video family (D4): never bytes", () => {
301
- test("no transcribe_status at all → audio_bytes_not_supported", async () => {
302
- const vaultName = freshVault("ra-audio-none");
303
- const { attachmentId } = await makeAttachment(vaultName, "d/voice.m4a", Buffer.from([1, 2, 3]), "audio/mp4");
304
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
305
- error_type: "audio_bytes_not_supported",
306
- });
307
- });
308
-
309
- test("transcribe_status: pending → returns the pointer, not an error", async () => {
310
- const vaultName = freshVault("ra-audio-pending");
311
- const { attachmentId, noteId } = await makeAttachment(vaultName, "d/voice.m4a", Buffer.from([1, 2, 3]), "audio/mp4", {
312
- metadata: { transcribe_status: "pending" },
313
- });
314
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
315
- expect(result.transcribe_status).toBe("pending");
316
- expect(result.note_id).toBe(noteId);
317
- expect(result.transcript_note).toBeUndefined();
318
- });
319
-
320
- test("transcribe_status: failed → returns the pointer with failed status, not an error", async () => {
321
- const vaultName = freshVault("ra-audio-failed");
322
- const { attachmentId, noteId } = await makeAttachment(vaultName, "d/voice.m4a", Buffer.from([1, 2, 3]), "audio/mp4", {
323
- metadata: { transcribe_status: "failed" },
324
- });
325
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
326
- expect(result.transcribe_status).toBe("failed");
327
- expect(result.note_id).toBe(noteId);
328
- });
329
-
330
- test("transcribe_status: done + a resolvable sibling transcript note → transcript_note {id, path}", async () => {
331
- const vaultName = freshVault("ra-audio-done");
332
- const relPath = "d/voice.m4a";
333
- const { attachmentId, noteId } = await makeAttachment(vaultName, relPath, Buffer.from([1, 2, 3]), "audio/mp4", {
334
- metadata: { transcribe_status: "done" },
335
- });
336
- const store = getVaultStore(vaultName);
337
- const transcriptNote = await store.createNote("the transcript text", {
338
- path: transcriptPathFor(relPath),
339
- tags: ["transcript"],
340
- });
341
-
342
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
343
- expect(result.transcribe_status).toBe("done");
344
- expect(result.note_id).toBe(noteId);
345
- expect(result.transcript_note).toEqual({ id: transcriptNote.id, path: transcriptNote.path });
346
- // Never the raw transcript bytes/text.
347
- expect(result.content).toBeUndefined();
348
- expect(result.transcript).toBeUndefined();
349
- });
350
-
351
- test("transcribe_status: done but NO sibling note resolves → note_id pointer only, no transcript_note key", async () => {
352
- const vaultName = freshVault("ra-audio-done-nosibling");
353
- const { attachmentId, noteId } = await makeAttachment(vaultName, "d/voice.m4a", Buffer.from([1, 2, 3]), "audio/mp4", {
354
- metadata: { transcribe_status: "done" },
355
- });
356
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId });
357
- expect(result.transcribe_status).toBe("done");
358
- expect(result.note_id).toBe(noteId);
359
- expect(result.transcript_note).toBeUndefined();
360
- });
361
-
362
- test("video mime is treated the same as audio (never bytes)", async () => {
363
- const vaultName = freshVault("ra-video-none");
364
- const { attachmentId } = await makeAttachment(vaultName, "d/clip.mp4", Buffer.from([1, 2, 3]), "video/mp4");
365
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
366
- error_type: "audio_bytes_not_supported",
367
- });
368
- });
369
- });
370
-
371
- describe("read-attachment — other binary (D5): unsupported_attachment_type", () => {
372
- test("PDF refuses with mime_type, size, how_to — pointing at a download ticket", async () => {
373
- const vaultName = freshVault("ra-pdf");
374
- const bytes = Buffer.from("%PDF-1.4 fake", "utf8");
375
- const { attachmentId } = await makeAttachment(vaultName, "d/doc.pdf", bytes, "application/pdf");
376
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
377
- error_type: "unsupported_attachment_type",
378
- mime_type: "application/pdf",
379
- size: bytes.length,
380
- });
381
- });
382
-
383
- test("a zip (arbitrary binary) also refuses as unsupported_attachment_type", async () => {
384
- const vaultName = freshVault("ra-zip");
385
- const { attachmentId } = await makeAttachment(vaultName, "d/archive.zip", Buffer.from([0x50, 0x4b, 0x03, 0x04]), "application/zip");
386
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
387
- error_type: "unsupported_attachment_type",
388
- });
389
- });
390
-
391
- test("a PDF row whose bytes are gone reports attachment_binary_missing, not unsupported_attachment_type", async () => {
392
- const vaultName = freshVault("ra-pdf-missing");
393
- const { attachmentId } = await makeAttachment(vaultName, "d/gone.pdf", Buffer.from("x"), "application/pdf", {
394
- skipWrite: true,
395
- });
396
- await expect(callTool(vaultName, "read-attachment", { attachment_id: attachmentId })).rejects.toMatchObject({
397
- error_type: "attachment_binary_missing",
398
- });
399
- });
400
- });
401
-
402
- describe("read-attachment — tag-scope refusal", () => {
403
- test("a tag-scoped session can't read an out-of-scope note's attachment (uniform not_found, no oracle)", async () => {
404
- const vaultName = freshVault("ra-scope-out");
405
- const { attachmentId } = await makeAttachment(vaultName, "d/secret.txt", Buffer.from("shh", "utf8"), "text/plain; charset=utf-8", {
406
- noteTags: ["health"],
407
- });
408
- await expect(
409
- callTool(vaultName, "read-attachment", { attachment_id: attachmentId }, auth(["work"])),
410
- ).rejects.toMatchObject({ error_type: "not_found", field: "attachment_id" });
411
- });
412
-
413
- test("a tag-scoped session CAN read an in-scope note's attachment", async () => {
414
- const vaultName = freshVault("ra-scope-in");
415
- const { attachmentId } = await makeAttachment(vaultName, "d/visible.txt", Buffer.from("hi", "utf8"), "text/plain; charset=utf-8", {
416
- noteTags: ["work"],
417
- });
418
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId }, auth(["work"]));
419
- expect(result.content).toBe("hi");
420
- });
421
- });
422
-
423
- describe("read-attachment — discoverability + tool tiering", () => {
424
- test("read-attachment is read-tier: a vault:read-only session can call it", async () => {
425
- const vaultName = freshVault("ra-tier");
426
- const { attachmentId } = await makeAttachment(vaultName, "d/x.txt", Buffer.from("ok", "utf8"), "text/plain; charset=utf-8");
427
- const readOnlyAuth = {
428
- permission: "read" as const,
429
- scopes: ["vault:read"],
430
- legacyDerived: false,
431
- scoped_tags: null,
432
- } as any;
433
- const result = await callTool(vaultName, "read-attachment", { attachment_id: attachmentId }, readOnlyAuth);
434
- expect(result.content).toBe("ok");
435
- });
436
- });