@openparachute/vault 0.7.3-rc.10 → 0.7.3-rc.11
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/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +100 -0
- package/core/src/mcp.ts +331 -1
- 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/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/server.ts +7 -0
- package/src/storage.test.ts +200 -1
- package/src/vault.test.ts +26 -13
|
@@ -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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
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
|
+
});
|