@openparachute/vault 0.7.3-rc.8 → 0.7.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 (44) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/bytes-provider.ts +65 -0
  3. package/core/src/content-range-constants.ts +19 -0
  4. package/core/src/content-range.test.ts +127 -0
  5. package/core/src/content-range.ts +105 -8
  6. package/core/src/core.test.ts +66 -4
  7. package/core/src/expand.ts +11 -3
  8. package/core/src/lede.test.ts +96 -0
  9. package/core/src/mcp-manifest.test.ts +200 -0
  10. package/core/src/mcp-manifest.ts +736 -0
  11. package/core/src/mcp.ts +357 -607
  12. package/core/src/notes.ts +69 -10
  13. package/core/src/vault-projection.ts +17 -10
  14. package/package.json +1 -1
  15. package/src/attachment-bytes.ts +68 -0
  16. package/src/attachment-tickets.test.ts +126 -1
  17. package/src/attachment-tickets.ts +77 -1
  18. package/src/auth-hub-jwt.test.ts +118 -1
  19. package/src/auth.ts +64 -0
  20. package/src/config.test.ts +16 -0
  21. package/src/config.ts +17 -0
  22. package/src/embedding/select.test.ts +58 -30
  23. package/src/embedding/select.ts +62 -21
  24. package/src/live-frame-parity.test.ts +21 -0
  25. package/src/mcp-http.ts +20 -3
  26. package/src/mcp-tools.ts +15 -3
  27. package/src/oauth-discovery.ts +31 -0
  28. package/src/read-attachment.test.ts +436 -0
  29. package/src/routes.ts +80 -4
  30. package/src/routing.test.ts +229 -4
  31. package/src/routing.ts +135 -23
  32. package/src/scopes.ts +22 -0
  33. package/src/server.ts +17 -8
  34. package/src/storage.test.ts +200 -1
  35. package/src/subscriptions.ts +13 -1
  36. package/src/transcription-worker.test.ts +151 -0
  37. package/src/transcription-worker.ts +113 -52
  38. package/src/vault-embeddings-capability.test.ts +28 -6
  39. package/src/vault-store-embedding-wiring.test.ts +25 -16
  40. package/src/vault-store.ts +32 -16
  41. package/src/vault.test.ts +26 -13
  42. package/src/ws-server.ts +9 -1
  43. package/src/ws-subscribe.test.ts +87 -0
  44. package/src/ws-subscribe.ts +25 -6
@@ -0,0 +1,436 @@
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
+ });
package/src/routes.ts CHANGED
@@ -143,7 +143,7 @@ import {
143
143
  type ExpandMode,
144
144
  } from "../core/src/expand.ts";
145
145
  import { join, extname, normalize } from "path";
146
- import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
146
+ import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
147
147
  import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
148
148
  import { shouldAutoTranscribe } from "./auto-transcribe.ts";
149
149
  // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
@@ -4453,6 +4453,56 @@ export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 12
4453
4453
  const BLOCKED_EXTENSIONS = BLOCKED_ATTACHMENT_EXTENSIONS;
4454
4454
  const MIME_TYPES = ATTACHMENT_MIME_TYPES;
4455
4455
 
4456
+ /**
4457
+ * Parse a single-range `Range: bytes=a-b` header (RFC 7233 §2.1 — single
4458
+ * range only, attachments-for-agents design D9, the REST twin of MCP's
4459
+ * `content_offset`). Returns `null` — this function's own contract is
4460
+ * "serve the full response, unranged" — for a missing header, a
4461
+ * MALFORMED value, an unrecognized unit, a multi-range list
4462
+ * (`bytes=0-10,20-30` — ignored, not an error, per D9), or a range this
4463
+ * file can't satisfy (a `start` past EOF). `start`/`end` are both
4464
+ * INCLUSIVE byte offsets; `end` is clamped to `total - 1` when the
4465
+ * request left it open (`bytes=500-`) or asked past EOF.
4466
+ *
4467
+ * IMPORTANT — this `null` contract is NOT the last word for an
4468
+ * unsatisfiable (syntactically-valid but out-of-bounds) range on a real
4469
+ * `Bun.serve()` deployment. Live-verified against an actual socket (not
4470
+ * the in-process `handleStorage()` call the test suite uses): when the
4471
+ * response body is a `Bun.file()` — which `handleStorage`'s full-response
4472
+ * branch below always hands back — Bun's OWN runtime transparently
4473
+ * reinterprets the incoming request's `Range` header a second time and,
4474
+ * for an out-of-bounds range, overrides our 200 with a native
4475
+ * **416 Range Not Satisfiable**, regardless of what this function or
4476
+ * `handleStorage` returned. That's RFC 7233-correct and is being KEPT,
4477
+ * not fought — so in practice, `null` from an out-of-bounds `start`
4478
+ * still results in a 200 from `handleStorage`'s own logic, but the byte
4479
+ * that actually reaches a real client for that specific case is a 416
4480
+ * courtesy of Bun itself. MALFORMED and multi-range headers are NOT
4481
+ * range-shaped at all, so Bun's native layer doesn't touch them — those
4482
+ * two cases genuinely serve the full 200, in-process harness and real
4483
+ * socket alike.
4484
+ */
4485
+ export function parseByteRangeHeader(header: string | null, total: number): { start: number; end: number } | null {
4486
+ if (!header || total <= 0) return null;
4487
+ const match = header.match(/^bytes=(\d*)-(\d*)$/);
4488
+ if (!match) return null; // malformed, unrecognized unit, or a multi-range list
4489
+ const [, startRaw, endRaw] = match;
4490
+ if (startRaw === "" && endRaw === "") return null;
4491
+
4492
+ if (startRaw === "") {
4493
+ // Suffix range: last N bytes (`bytes=-500`).
4494
+ const suffixLength = Number(endRaw);
4495
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
4496
+ return { start: Math.max(0, total - suffixLength), end: total - 1 };
4497
+ }
4498
+
4499
+ const start = Number(startRaw);
4500
+ if (!Number.isSafeInteger(start) || start < 0 || start >= total) return null;
4501
+ const end = endRaw === "" ? total - 1 : Math.min(Number(endRaw), total - 1);
4502
+ if (!Number.isSafeInteger(end) || end < start) return null;
4503
+ return { start, end };
4504
+ }
4505
+
4456
4506
  export async function handleStorage(
4457
4507
  req: Request,
4458
4508
  path: string,
@@ -4615,12 +4665,38 @@ export async function handleStorage(
4615
4665
  const stat = statSync(filePath);
4616
4666
  const ext = extname(filePath).toLowerCase();
4617
4667
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
4618
- const fileBuffer = readFileSync(filePath);
4668
+ const total = stat.size;
4669
+
4670
+ // vault attachments-for-agents design (D9) — the REST twin of MCP's
4671
+ // `content_offset`. `Bun.file(filePath)` resolves lazily: `.slice()`
4672
+ // creates a bounded view and only the requested bytes are actually read
4673
+ // on `.arrayBuffer()`/streaming, replacing the prior whole-file
4674
+ // `readFileSync` (a standing memory smell on a large attachment, e.g. a
4675
+ // 90 MB video) on BOTH the ranged and full-file paths below.
4676
+ const bunFile = Bun.file(filePath);
4677
+ const range = parseByteRangeHeader(req.headers.get("range"), total);
4678
+
4679
+ if (range) {
4680
+ const { start, end } = range; // inclusive
4681
+ return new Response(bunFile.slice(start, end + 1), {
4682
+ status: 206,
4683
+ headers: {
4684
+ "Content-Type": contentType,
4685
+ "Content-Length": String(end - start + 1),
4686
+ "Content-Range": `bytes ${start}-${end}/${total}`,
4687
+ "Accept-Ranges": "bytes",
4688
+ // Defense-in-depth: never let a browser MIME-sniff a stored asset
4689
+ // into an active type — see the full-response branch below.
4690
+ "X-Content-Type-Options": "nosniff",
4691
+ },
4692
+ });
4693
+ }
4619
4694
 
4620
- return new Response(fileBuffer, {
4695
+ return new Response(bunFile, {
4621
4696
  headers: {
4622
4697
  "Content-Type": contentType,
4623
- "Content-Length": String(stat.size),
4698
+ "Content-Length": String(total),
4699
+ "Accept-Ranges": "bytes",
4624
4700
  // Defense-in-depth: never let a browser MIME-sniff a stored asset into
4625
4701
  // an active type (e.g. an octet-stream body sniffed as text/html).
4626
4702
  // Combined with the upload blocklist (no .svg/.html) this closes the