@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.
- package/README.md +5 -3
- package/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range-constants.ts +19 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +105 -8
- package/core/src/core.test.ts +66 -4
- package/core/src/expand.ts +11 -3
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-manifest.test.ts +200 -0
- package/core/src/mcp-manifest.ts +736 -0
- package/core/src/mcp.ts +357 -607
- package/core/src/notes.ts +69 -10
- package/core/src/vault-projection.ts +17 -10
- package/package.json +1 -1
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +126 -1
- package/src/attachment-tickets.ts +77 -1
- package/src/auth-hub-jwt.test.ts +118 -1
- package/src/auth.ts +64 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +17 -0
- package/src/embedding/select.test.ts +58 -30
- package/src/embedding/select.ts +62 -21
- package/src/live-frame-parity.test.ts +21 -0
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/oauth-discovery.ts +31 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/routing.test.ts +229 -4
- package/src/routing.ts +135 -23
- package/src/scopes.ts +22 -0
- package/src/server.ts +17 -8
- package/src/storage.test.ts +200 -1
- package/src/subscriptions.ts +13 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault-embeddings-capability.test.ts +28 -6
- package/src/vault-store-embedding-wiring.test.ts +25 -16
- package/src/vault-store.ts +32 -16
- package/src/vault.test.ts +26 -13
- package/src/ws-server.ts +9 -1
- package/src/ws-subscribe.test.ts +87 -0
- 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,
|
|
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
|
|
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(
|
|
4695
|
+
return new Response(bunFile, {
|
|
4621
4696
|
headers: {
|
|
4622
4697
|
"Content-Type": contentType,
|
|
4623
|
-
"Content-Length": String(
|
|
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
|