@openparachute/vault 0.7.3-rc.10 → 0.7.3-rc.12

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.
@@ -37,7 +37,11 @@ function json(data: unknown, status = 200): Response {
37
37
  * `take` returns, collapsing "expired" into the same null as "spent" /
38
38
  * "unknown" so the HTTP layer can give a uniform 404 (no oracle).
39
39
  */
40
- class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
40
+ // Exported (alongside the shared-singleton accessors below) so
41
+ // vault#612's sweep logic can be unit-tested against an ISOLATED instance
42
+ // — no risk of a test's reset touching the one process-wide provider every
43
+ // OTHER test file's ticket mint/spend flow also shares.
44
+ export class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
41
45
  private readonly tickets = new Map<string, AttachmentTicket>();
42
46
 
43
47
  async put(ticket: AttachmentTicket): Promise<void> {
@@ -50,6 +54,30 @@ class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
50
54
  this.tickets.delete(id);
51
55
  return ticket;
52
56
  }
57
+
58
+ /**
59
+ * Drop every unspent ticket whose TTL has elapsed (vault#612). `take()`
60
+ * already enforces expiry AT SPEND TIME — a caller can never successfully
61
+ * spend a stale ticket — so this is purely a memory-hygiene backstop: an
62
+ * agent that mints and then abandons the flow (network drop, a curl that
63
+ * never runs) would otherwise leave its ticket in this Map forever. Returns
64
+ * the count dropped, for test assertions / logging.
65
+ */
66
+ sweepExpired(now: number = Date.now()): number {
67
+ let dropped = 0;
68
+ for (const [id, ticket] of this.tickets) {
69
+ if (ticket.expiresAt < now) {
70
+ this.tickets.delete(id);
71
+ dropped++;
72
+ }
73
+ }
74
+ return dropped;
75
+ }
76
+
77
+ /** Test-only visibility into how many tickets are currently held. */
78
+ size(): number {
79
+ return this.tickets.size;
80
+ }
53
81
  }
54
82
 
55
83
  let sharedProvider: InProcessAttachmentTicketProvider | undefined;
@@ -65,6 +93,54 @@ export function resetSharedAttachmentTicketProviderForTests(): void {
65
93
  sharedProvider = undefined;
66
94
  }
67
95
 
96
+ /** Test-only: how many tickets the shared provider currently holds (0 if never created). */
97
+ export function sharedAttachmentTicketCountForTests(): number {
98
+ return sharedProvider?.size() ?? 0;
99
+ }
100
+
101
+ /**
102
+ * Drop every expired-unspent ticket from the shared provider (vault#612). A
103
+ * no-op (returns 0) before the shared provider has ever been created — the
104
+ * periodic sweep below calls this unconditionally, so this must tolerate
105
+ * running before the first mint.
106
+ */
107
+ export function sweepExpiredAttachmentTickets(now: number = Date.now()): number {
108
+ return sharedProvider?.sweepExpired(now) ?? 0;
109
+ }
110
+
111
+ /**
112
+ * Sweep cadence (vault#612) — same cadence family as `EmbeddingWorker`'s
113
+ * default sweep interval (`src/embedding-worker.ts`'s `DEFAULT_SWEEP_MS`).
114
+ * Tickets are short-TTL (10-30 min, `computeTicketTtlMs`) and low-volume, so
115
+ * a 30s sweep is comfortably frequent without being wasteful.
116
+ */
117
+ const TICKET_SWEEP_INTERVAL_MS = 30_000;
118
+
119
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
120
+
121
+ /**
122
+ * Start the periodic expired-ticket sweep. No-op if already started. Mirrors
123
+ * `EmbeddingWorker.start()`'s shape (`.unref()` so the timer never keeps the
124
+ * process alive on its own — `server.ts`'s graceful-shutdown path still
125
+ * calls `stopAttachmentTicketSweep()` explicitly for a clean stop, same as
126
+ * `embeddingWorker.stop()`).
127
+ */
128
+ export function startAttachmentTicketSweep(intervalMs: number = TICKET_SWEEP_INTERVAL_MS): void {
129
+ if (sweepTimer) return;
130
+ sweepTimer = setInterval(() => {
131
+ sweepExpiredAttachmentTickets();
132
+ }, intervalMs);
133
+ sweepTimer.unref?.();
134
+ }
135
+
136
+ /** Stop the periodic expired-ticket sweep. */
137
+ export function stopAttachmentTicketSweep(): void {
138
+ if (sweepTimer) {
139
+ clearInterval(sweepTimer);
140
+ sweepTimer = null;
141
+ }
142
+ }
143
+
68
144
  /**
69
145
  * Take + validate a ticket against the URL's vault name and the spend
70
146
  * route's expected kind (a GET must not spend an upload ticket, etc.).
package/src/mcp-http.ts CHANGED
@@ -180,9 +180,15 @@ export async function handleMcp(
180
180
  }
181
181
  try {
182
182
  const result = await tool.execute((args ?? {}) as Record<string, unknown>);
183
- return {
184
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
185
- };
183
+ // The one wrapper change (attachments-for-agents design, Wave 2):
184
+ // `resultContent`, when a tool defines it, decides the MCP content
185
+ // blocks instead of the universal single-text-block default —
186
+ // `read-attachment`'s image branch is the only current user (needs a
187
+ // REAL {type:"image"} block alongside the row-JSON text block).
188
+ const content = tool.resultContent
189
+ ? tool.resultContent(result)
190
+ : [{ type: "text" as const, text: JSON.stringify(result, null, 2) }];
191
+ return { content };
186
192
  } catch (err) {
187
193
  // vault#555 fix 6 — never re-wrap an already-formed McpError. Passing
188
194
  // the SAME instance straight through is strictly correct (no
@@ -231,6 +237,12 @@ export async function handleMcp(
231
237
  referencing_tags?: unknown;
232
238
  /** Attachment-tickets design (§2c "errors as JIT docs") — a short, imperative next-step, distinct from the older free-form `hint`. */
233
239
  how_to?: string;
240
+ /** `read-attachment` (Wave 2) refusals — `image_too_large` / `unsupported_attachment_type` carry the attachment's actual byte size. */
241
+ size?: number;
242
+ /** `read-attachment` `image_too_large` — the 4 MiB cap it exceeded. */
243
+ max_bytes?: number;
244
+ /** `read-attachment` `unsupported_attachment_type` — the mime type that couldn't be read. */
245
+ mime_type?: string;
234
246
  };
235
247
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
236
248
  // values that are structurally invalid rather than merely "no
@@ -411,6 +423,11 @@ export async function handleMcp(
411
423
  ...(e.got !== undefined ? { got: e.got } : {}),
412
424
  ...(e.extension !== undefined ? { extension: e.extension } : {}),
413
425
  ...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
426
+ // read-attachment (Wave 2) refusal fields — same forward-when-present
427
+ // discipline as the ticket fields just above.
428
+ ...(e.size !== undefined ? { size: e.size } : {}),
429
+ ...(e.max_bytes !== undefined ? { max_bytes: e.max_bytes } : {}),
430
+ ...(e.mime_type !== undefined ? { mime_type: e.mime_type } : {}),
414
431
  });
415
432
  }
416
433
  return {
package/src/mcp-tools.ts CHANGED
@@ -45,6 +45,7 @@ import { looksLikeJwt } from "./hub-jwt.ts";
45
45
  import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
46
46
  import { getBaseUrl } from "./oauth-discovery.ts";
47
47
  import { getSharedAttachmentTicketProvider } from "./attachment-tickets.ts";
48
+ import { createFsAttachmentBytesProvider } from "./attachment-bytes.ts";
48
49
 
49
50
  /**
50
51
  * Filter a vault projection to entries an in-scope tag contributes to.
@@ -112,10 +113,12 @@ export async function getServerInstruction(
112
113
  description: config?.description ?? null,
113
114
  projection,
114
115
  coordinates: resolveVaultCoordinates(),
115
- // Bun always wires an in-process AttachmentTicketProvider (see
116
+ // Bun always wires an in-process AttachmentTicketProvider AND a fresh
117
+ // fs-backed AttachmentBytesProvider per session (see
116
118
  // `generateScopedMcpTools` below) — the connect-time brief can
117
- // unconditionally teach the ticket tools on this door.
118
- attachments: { ticketsEnabled: true },
119
+ // unconditionally teach both the ticket tools and read-attachment on
120
+ // this door.
121
+ attachments: { ticketsEnabled: true, readEnabled: true },
119
122
  });
120
123
  }
121
124
 
@@ -273,6 +276,15 @@ export function generateScopedMcpTools(
273
276
  urlBase: ticketUrlBase,
274
277
  ...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
275
278
  },
279
+ // Attachment bytes (Wave 2 model lane): bun always wires a fresh fs
280
+ // provider per session — cheap (stateless), unlike the ticket
281
+ // provider's process-wide shared Map. Same `ticketNoteVisible`
282
+ // tag-scope predicate as the ticket seam above (identical contract:
283
+ // "is the owning note in scope").
284
+ attachmentBytes: {
285
+ provider: createFsAttachmentBytesProvider(vaultName),
286
+ ...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
287
+ },
276
288
  });
277
289
 
278
290
  overrideVaultInfo(tools, vaultName, auth);
@@ -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
+ });