@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.26

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.
@@ -0,0 +1,437 @@
1
+ // #507 — send_reply attachments regression + parity with send_task.
2
+ //
3
+ // Before this PR: send_reply had no `attachments` field in its Zod schema.
4
+ // MCP's default schema-validation strips unknown fields, so a caller
5
+ // passing `attachments` would see `ok:true` and never learn the field
6
+ // was dropped. Nothing landed in `tasks.meta_json`. Silent failure.
7
+ //
8
+ // After this PR: schema declares `attachments` (top-level, parity with
9
+ // REST /api/task L506) and `meta` (parity with send_task L837). Handler
10
+ // validates via `validateAttachments` (uploads.ts:359 — the same helper
11
+ // the REST path uses), persists into inbox.meta_json + tasks.meta_json
12
+ // (parity with send_task L952/957), and **echoes attachments READ BACK
13
+ // FROM DB** in the response (lead 2b5f6634 catch: echoing the in-memory
14
+ // variable would still succeed if the UPDATE actually landed 0 rows —
15
+ // the echo needs to prove the DB write, not the caller's intent).
16
+ //
17
+ // Coverage:
18
+ // Positive P1-P5 — attachments actually persist + echoed correctly
19
+ // Reverse (e) — no-attachments call is byte-identical to today
20
+ // Negative N1-N6 — malformed attachments explicitly rejected
21
+ // No-in_reply_to — attachments still persisted onto inbox.meta_json
22
+ // Cross-network — same permission rules as bare send_reply
23
+
24
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
25
+ import { db } from "./db.js";
26
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
+ import { registerTools } from "./tools.js";
28
+
29
+ const NET_ID = "net_507_att";
30
+ const USER_ID = "u_507_att";
31
+ const NODE_ID = "node_507_att";
32
+ const AGENT_ALIAS = "peer-507";
33
+ const AGENT_TOK_ID = "tok_507_att";
34
+
35
+ interface ToolHandler {
36
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
37
+ }
38
+
39
+ interface Reply {
40
+ ok?: boolean;
41
+ message_id?: string;
42
+ session_status?: string;
43
+ warning?: string;
44
+ error?: string;
45
+ message?: string;
46
+ attachments_saved?: Array<{ type: string; file_id: string; name?: string; mime?: string; size?: number }>;
47
+ [k: string]: unknown;
48
+ }
49
+
50
+ function cleanup() {
51
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
52
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
53
+ try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
54
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
55
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
56
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
57
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
58
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
59
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
60
+ }
61
+
62
+ beforeEach(cleanup);
63
+ afterAll(cleanup);
64
+
65
+ function seed(): { taskId: string } {
66
+ db.run(
67
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
68
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
69
+ [USER_ID, USER_ID],
70
+ );
71
+ db.run(
72
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
73
+ VALUES (?1, ?2, ?3, datetime('now'))`,
74
+ [NET_ID, NET_ID, USER_ID],
75
+ );
76
+ db.run(
77
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
78
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
79
+ [USER_ID, NET_ID],
80
+ );
81
+ db.run(
82
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
83
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
84
+ [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
85
+ );
86
+ db.run(
87
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
88
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
89
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
90
+ );
91
+ // Also seed hub session so alias="hub" is a valid dashboard-style target.
92
+ db.run(
93
+ `INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at)
94
+ VALUES ('res_hub_507', 'hub', 'idle', ?1, datetime('now'), datetime('now'))
95
+ ON CONFLICT DO NOTHING`,
96
+ [NET_ID],
97
+ );
98
+ // Seed a task the reply will target. Dashboard-style: from_name='hub',
99
+ // to_name=agent, status='delivered'.
100
+ const taskId = `task_507_${Date.now()}${Math.floor(Math.random() * 1000)}`;
101
+ db.run(
102
+ `INSERT INTO tasks (task_id, from_name, to_name, status, content, requires_response, created_at, delivered_at, expires_at, network_id, priority)
103
+ VALUES (?1, 'hub', ?2, 'delivered', 'test task from dashboard', 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?3, 'normal')`,
104
+ [taskId, AGENT_ALIAS, NET_ID],
105
+ );
106
+ return { taskId };
107
+ }
108
+
109
+ async function getSendReplyHandler(): Promise<ToolHandler> {
110
+ const server = new McpServer({ name: "test", version: "0" });
111
+ const tools: Record<string, ToolHandler> = {};
112
+ const originalTool = (server as any).tool.bind(server);
113
+ (server as any).tool = (name: string, ...rest: any[]) => {
114
+ // send_reply is registered with (name, desc, schema, handler)
115
+ const handler = rest[rest.length - 1];
116
+ if (typeof handler === "function") tools[name] = handler;
117
+ return originalTool(name, ...rest);
118
+ };
119
+ registerTools(server, {
120
+ enforceNetworkId: NET_ID,
121
+ enforceUserId: USER_ID,
122
+ callerAlias: AGENT_ALIAS,
123
+ callerTokenIsNetwork: true,
124
+ callerTokenId: AGENT_TOK_ID,
125
+ } as any);
126
+ const handler = tools.send_reply;
127
+ if (!handler) throw new Error("send_reply handler not registered");
128
+ return handler;
129
+ }
130
+
131
+ async function callReply(handler: ToolHandler, args: any): Promise<Reply> {
132
+ const result = await handler(args);
133
+ return JSON.parse(result.content[0].text) as Reply;
134
+ }
135
+
136
+ // A valid file_id shape (matches FILE_ID_REGEX /^[A-Za-z0-9_-]{8,64}$/).
137
+ // Note: these attachments do NOT need to point at real uploaded files
138
+ // for the persistence/echo tests — validateAttachments only checks shape.
139
+ // The dashboard proxy resolves file_id to disk at download time separately.
140
+ function att(file_id: string, extra: Record<string, unknown> = {}) {
141
+ return { type: "file", file_id, name: "test.bin", size: 1024, mime: "application/octet-stream", ...extra };
142
+ }
143
+
144
+ function readTaskMeta(taskId: string): any {
145
+ const row = db.get<{ meta_json: string | null }>(
146
+ "SELECT meta_json FROM tasks WHERE task_id = ?1", [taskId],
147
+ );
148
+ return row?.meta_json ? JSON.parse(row.meta_json) : null;
149
+ }
150
+
151
+ function readInboxMeta(msgId: string): any {
152
+ const row = db.get<{ meta_json: string | null }>(
153
+ "SELECT meta_json FROM inbox WHERE id = ?1", [msgId],
154
+ );
155
+ return row?.meta_json ? JSON.parse(row.meta_json) : null;
156
+ }
157
+
158
+ describe("#507 — send_reply attachments (positive)", () => {
159
+ test("P1: two attachments persist to tasks.meta_json AND inbox.meta_json AND echo READ BACK FROM DB", async () => {
160
+ const { taskId } = seed();
161
+ const handler = await getSendReplyHandler();
162
+ const a1 = att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "one.pdf" });
163
+ const a2 = att("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", { name: "two.png" });
164
+ const reply = await callReply(handler, {
165
+ alias: AGENT_ALIAS,
166
+ text: "here you go",
167
+ in_reply_to: taskId,
168
+ status: "replied" as const,
169
+ attachments: [a1, a2],
170
+ });
171
+ expect(reply.ok).toBe(true);
172
+ expect(reply.attachments_saved).toBeDefined();
173
+ expect(reply.attachments_saved!.length).toBe(2);
174
+ expect(reply.attachments_saved!.map((a) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort());
175
+
176
+ // Verify persistence — the echo must come from what actually landed on disk.
177
+ const taskMeta = readTaskMeta(taskId);
178
+ expect(taskMeta?.attachments?.length).toBe(2);
179
+ expect(taskMeta.attachments.map((a: any) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort());
180
+
181
+ const inboxMeta = readInboxMeta(reply.message_id!);
182
+ expect(inboxMeta?.attachments?.length).toBe(2);
183
+ });
184
+
185
+ test("P2: meta.attachments (nested form) also accepted", async () => {
186
+ const { taskId } = seed();
187
+ const handler = await getSendReplyHandler();
188
+ const a1 = att("cccccccccccccccccccccccccccccccc");
189
+ const reply = await callReply(handler, {
190
+ alias: AGENT_ALIAS,
191
+ text: "nested-form",
192
+ in_reply_to: taskId,
193
+ status: "replied" as const,
194
+ meta: { attachments: [a1], other_field: "preserved" },
195
+ });
196
+ expect(reply.ok).toBe(true);
197
+ expect(reply.attachments_saved!.length).toBe(1);
198
+ const persisted = readTaskMeta(taskId);
199
+ expect(persisted?.attachments?.[0]?.file_id).toBe(a1.file_id);
200
+ expect(persisted?.other_field).toBe("preserved"); // meta merge preserves siblings
201
+ });
202
+
203
+ test("P3: top-level attachments WIN over meta.attachments when both supplied (parity with REST /api/task L2101)", async () => {
204
+ const { taskId } = seed();
205
+ const handler = await getSendReplyHandler();
206
+ const topLevel = att("dddddddddddddddddddddddddddddddd", { name: "wins.pdf" });
207
+ const nested = att("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", { name: "loses.pdf" });
208
+ const reply = await callReply(handler, {
209
+ alias: AGENT_ALIAS,
210
+ text: "conflict resolution",
211
+ in_reply_to: taskId,
212
+ status: "replied" as const,
213
+ attachments: [topLevel],
214
+ meta: { attachments: [nested], other: "kept" },
215
+ });
216
+ expect(reply.ok).toBe(true);
217
+ expect(reply.attachments_saved!.length).toBe(1);
218
+ expect(reply.attachments_saved![0].file_id).toBe(topLevel.file_id);
219
+ const persisted = readTaskMeta(taskId);
220
+ expect(persisted?.attachments?.length).toBe(1);
221
+ expect(persisted?.attachments?.[0]?.file_id).toBe(topLevel.file_id);
222
+ expect(persisted?.other).toBe("kept");
223
+ });
224
+ });
225
+
226
+ describe("#507 — reverse (e): no-attachments call is byte-identical to pre-#507", () => {
227
+ test("R-e1: reply with no attachments field returns response without attachments_saved key at all", async () => {
228
+ const { taskId } = seed();
229
+ const handler = await getSendReplyHandler();
230
+ const reply = await callReply(handler, {
231
+ alias: AGENT_ALIAS,
232
+ text: "no attachments here",
233
+ in_reply_to: taskId,
234
+ status: "replied" as const,
235
+ });
236
+ expect(reply.ok).toBe(true);
237
+ expect(reply.message_id).toBeDefined();
238
+ // Reverse-(e) core: key must not appear at all (undefined, not empty).
239
+ // If a caller had a strict equality assertion on the response shape,
240
+ // adding an empty attachments_saved field would break it.
241
+ expect("attachments_saved" in reply).toBe(false);
242
+ });
243
+
244
+ test("R-e2: reply with attachments=undefined returns response without attachments_saved key", async () => {
245
+ const { taskId } = seed();
246
+ const handler = await getSendReplyHandler();
247
+ const reply = await callReply(handler, {
248
+ alias: AGENT_ALIAS,
249
+ text: "explicit undefined",
250
+ in_reply_to: taskId,
251
+ status: "replied" as const,
252
+ attachments: undefined,
253
+ });
254
+ expect(reply.ok).toBe(true);
255
+ expect("attachments_saved" in reply).toBe(false);
256
+ });
257
+
258
+ test("R-e3: reply with attachments=[] (empty array) returns response without attachments_saved key", async () => {
259
+ const { taskId } = seed();
260
+ const handler = await getSendReplyHandler();
261
+ const reply = await callReply(handler, {
262
+ alias: AGENT_ALIAS,
263
+ text: "explicit empty array",
264
+ in_reply_to: taskId,
265
+ status: "replied" as const,
266
+ attachments: [],
267
+ });
268
+ expect(reply.ok).toBe(true);
269
+ // Empty array is a valid input — validator returns ok+empty.
270
+ // Response shape stays clean (no attachments_saved field).
271
+ expect("attachments_saved" in reply).toBe(false);
272
+ });
273
+
274
+ test("R-e4: reply with no attachments does not touch pre-existing tasks.meta_json (COALESCE preserves)", async () => {
275
+ const { taskId } = seed();
276
+ const handler = await getSendReplyHandler();
277
+ // Pre-set a meta_json on the task row so we can prove COALESCE preserves it.
278
+ db.run("UPDATE tasks SET meta_json = ?1 WHERE task_id = ?2", [
279
+ JSON.stringify({ pre_existing: true, some_field: "must survive" }),
280
+ taskId,
281
+ ]);
282
+ const reply = await callReply(handler, {
283
+ alias: AGENT_ALIAS,
284
+ text: "no attachments — must not clobber existing meta",
285
+ in_reply_to: taskId,
286
+ status: "replied" as const,
287
+ });
288
+ expect(reply.ok).toBe(true);
289
+ const meta = readTaskMeta(taskId);
290
+ expect(meta?.pre_existing).toBe(true);
291
+ expect(meta?.some_field).toBe("must survive");
292
+ });
293
+ });
294
+
295
+ describe("#507 — negative: malformed attachments explicitly rejected (bad_attachments)", () => {
296
+ test("N1: attachments not an array → 400 bad_attachments", async () => {
297
+ const { taskId } = seed();
298
+ const handler = await getSendReplyHandler();
299
+ const reply = await callReply(handler, {
300
+ alias: AGENT_ALIAS,
301
+ text: "malformed",
302
+ in_reply_to: taskId,
303
+ status: "replied" as const,
304
+ attachments: "not an array" as any,
305
+ });
306
+ expect(reply.ok).toBe(false);
307
+ expect(reply.error).toBe("bad_attachments");
308
+ expect(typeof reply.message).toBe("string");
309
+ // Reply never landed on disk (validation happened before DB write).
310
+ const meta = readTaskMeta(taskId);
311
+ expect(meta).toBeNull();
312
+ });
313
+
314
+ test("N2: too many attachments (>20) → 400 bad_attachments", async () => {
315
+ const { taskId } = seed();
316
+ const handler = await getSendReplyHandler();
317
+ // 21 valid-shape attachments
318
+ const many = Array.from({ length: 21 }, (_, i) => att("a".repeat(32).slice(0, 30) + (i < 10 ? "0" + i : "" + i)));
319
+ const reply = await callReply(handler, {
320
+ alias: AGENT_ALIAS,
321
+ text: "too many",
322
+ in_reply_to: taskId,
323
+ status: "replied" as const,
324
+ attachments: many,
325
+ });
326
+ expect(reply.ok).toBe(false);
327
+ expect(reply.error).toBe("bad_attachments");
328
+ expect(reply.message).toMatch(/too many/i);
329
+ });
330
+
331
+ test("N3: bad file_id (contains path separator) → 400 bad_attachments", async () => {
332
+ const { taskId } = seed();
333
+ const handler = await getSendReplyHandler();
334
+ const reply = await callReply(handler, {
335
+ alias: AGENT_ALIAS,
336
+ text: "bad file_id",
337
+ in_reply_to: taskId,
338
+ status: "replied" as const,
339
+ attachments: [{ type: "file", file_id: "../etc/passwd", name: "x", size: 1 }],
340
+ });
341
+ expect(reply.ok).toBe(false);
342
+ expect(reply.error).toBe("bad_attachments");
343
+ expect(reply.message).toMatch(/file_id/i);
344
+ });
345
+
346
+ test("N4: wrong type field (must be 'file') → 400 bad_attachments", async () => {
347
+ const { taskId } = seed();
348
+ const handler = await getSendReplyHandler();
349
+ const reply = await callReply(handler, {
350
+ alias: AGENT_ALIAS,
351
+ text: "bad type",
352
+ in_reply_to: taskId,
353
+ status: "replied" as const,
354
+ attachments: [{ type: "url", file_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }],
355
+ });
356
+ expect(reply.ok).toBe(false);
357
+ expect(reply.error).toBe("bad_attachments");
358
+ expect(reply.message).toMatch(/type/i);
359
+ });
360
+
361
+ test("N5: attachment size > MAX_UPLOAD_BYTES → 400 bad_attachments", async () => {
362
+ const { taskId } = seed();
363
+ const handler = await getSendReplyHandler();
364
+ const reply = await callReply(handler, {
365
+ alias: AGENT_ALIAS,
366
+ text: "too big",
367
+ in_reply_to: taskId,
368
+ status: "replied" as const,
369
+ attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { size: 999_999_999_999 })],
370
+ });
371
+ expect(reply.ok).toBe(false);
372
+ expect(reply.error).toBe("bad_attachments");
373
+ expect(reply.message).toMatch(/size/i);
374
+ });
375
+
376
+ test("N6: attachment name too long (>255) → 400 bad_attachments", async () => {
377
+ const { taskId } = seed();
378
+ const handler = await getSendReplyHandler();
379
+ const reply = await callReply(handler, {
380
+ alias: AGENT_ALIAS,
381
+ text: "long name",
382
+ in_reply_to: taskId,
383
+ status: "replied" as const,
384
+ attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "x".repeat(256) })],
385
+ });
386
+ expect(reply.ok).toBe(false);
387
+ expect(reply.error).toBe("bad_attachments");
388
+ expect(reply.message).toMatch(/name/i);
389
+ });
390
+
391
+ test("N7: non-object attachment item → 400 bad_attachments", async () => {
392
+ const { taskId } = seed();
393
+ const handler = await getSendReplyHandler();
394
+ const reply = await callReply(handler, {
395
+ alias: AGENT_ALIAS,
396
+ text: "junk item",
397
+ in_reply_to: taskId,
398
+ status: "replied" as const,
399
+ attachments: ["string-not-object"] as any,
400
+ });
401
+ expect(reply.ok).toBe(false);
402
+ expect(reply.error).toBe("bad_attachments");
403
+ });
404
+ });
405
+
406
+ describe("#507 — echo integrity (read-back-from-DB, not in-memory)", () => {
407
+ test("echo values are exactly what the DB round-trip produced (proves read-back not passthrough)", async () => {
408
+ const { taskId } = seed();
409
+ const handler = await getSendReplyHandler();
410
+ const a = att("ffffffffffffffffffffffffffffffff", {
411
+ name: "roundtrip.pdf",
412
+ mime: "application/pdf",
413
+ size: 4096,
414
+ });
415
+ const reply = await callReply(handler, {
416
+ alias: AGENT_ALIAS,
417
+ text: "roundtrip check",
418
+ in_reply_to: taskId,
419
+ status: "replied" as const,
420
+ attachments: [a],
421
+ });
422
+ expect(reply.ok).toBe(true);
423
+ // The echoed attachment must equal what DB round-trip gives back —
424
+ // fetch independently and compare.
425
+ const inboxMeta = readInboxMeta(reply.message_id!);
426
+ const dbAttachment = inboxMeta?.attachments?.[0];
427
+ expect(reply.attachments_saved![0]).toEqual(dbAttachment);
428
+ // Also the echo must contain the full shape as re-hydrated from JSON
429
+ // (proves it went through normalize/serialize/read/parse — not just
430
+ // an in-memory pass-through).
431
+ expect(reply.attachments_saved![0].file_id).toBe(a.file_id);
432
+ expect(reply.attachments_saved![0].name).toBe(a.name);
433
+ expect(reply.attachments_saved![0].mime).toBe(a.mime);
434
+ expect(reply.attachments_saved![0].size).toBe(a.size);
435
+ expect(reply.attachments_saved![0].type).toBe("file");
436
+ });
437
+ });