@sleep2agi/commhub-server 0.9.0-preview.5 → 0.9.0-preview.50
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +690 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +320 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +641 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2227
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +428 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/rules-file-transport.test.ts +235 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +483 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +3736 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +471 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3026 -197
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,458 @@
|
|
|
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
|
+
// #1823 —— 任务行上回复附件在 reply_attachments;attachments 键留给提问者自己带的附件
|
|
179
|
+
expect(taskMeta?.attachments).toBeUndefined();
|
|
180
|
+
expect(taskMeta?.reply_attachments?.length).toBe(2);
|
|
181
|
+
expect(taskMeta.reply_attachments.map((a: any) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort());
|
|
182
|
+
|
|
183
|
+
const inboxMeta = readInboxMeta(reply.message_id!);
|
|
184
|
+
expect(inboxMeta?.attachments?.length).toBe(2);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("P2: meta.attachments (nested form) also accepted", async () => {
|
|
188
|
+
const { taskId } = seed();
|
|
189
|
+
const handler = await getSendReplyHandler();
|
|
190
|
+
const a1 = att("cccccccccccccccccccccccccccccccc");
|
|
191
|
+
const reply = await callReply(handler, {
|
|
192
|
+
alias: AGENT_ALIAS,
|
|
193
|
+
text: "nested-form",
|
|
194
|
+
in_reply_to: taskId,
|
|
195
|
+
status: "replied" as const,
|
|
196
|
+
meta: { attachments: [a1], other_field: "preserved" },
|
|
197
|
+
});
|
|
198
|
+
expect(reply.ok).toBe(true);
|
|
199
|
+
expect(reply.attachments_saved!.length).toBe(1);
|
|
200
|
+
const persisted = readTaskMeta(taskId);
|
|
201
|
+
expect(persisted?.reply_attachments?.[0]?.file_id).toBe(a1.file_id);
|
|
202
|
+
expect(persisted?.attachments).toBeUndefined();
|
|
203
|
+
// #1823 —— 回复 meta 的兄弟字段属于回复(inbox 行),不写进提问者的任务行
|
|
204
|
+
expect(readInboxMeta(reply.message_id)?.other_field).toBe("preserved");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("P3: top-level attachments WIN over meta.attachments when both supplied (parity with REST /api/task L2101)", async () => {
|
|
208
|
+
const { taskId } = seed();
|
|
209
|
+
const handler = await getSendReplyHandler();
|
|
210
|
+
const topLevel = att("dddddddddddddddddddddddddddddddd", { name: "wins.pdf" });
|
|
211
|
+
const nested = att("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", { name: "loses.pdf" });
|
|
212
|
+
const reply = await callReply(handler, {
|
|
213
|
+
alias: AGENT_ALIAS,
|
|
214
|
+
text: "conflict resolution",
|
|
215
|
+
in_reply_to: taskId,
|
|
216
|
+
status: "replied" as const,
|
|
217
|
+
attachments: [topLevel],
|
|
218
|
+
meta: { attachments: [nested], other: "kept" },
|
|
219
|
+
});
|
|
220
|
+
expect(reply.ok).toBe(true);
|
|
221
|
+
expect(reply.attachments_saved!.length).toBe(1);
|
|
222
|
+
expect(reply.attachments_saved![0].file_id).toBe(topLevel.file_id);
|
|
223
|
+
const persisted = readTaskMeta(taskId);
|
|
224
|
+
expect(persisted?.reply_attachments?.length).toBe(1);
|
|
225
|
+
expect(persisted?.reply_attachments?.[0]?.file_id).toBe(topLevel.file_id);
|
|
226
|
+
expect(readInboxMeta(reply.message_id)?.other).toBe("kept");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// #1823 —— 提问者随任务带的附件必须留在 attachments;回复附件另放 reply_attachments。
|
|
230
|
+
// (Vincent 2026-09-06:回复的图被画进提问者气泡;以前整体 COALESCE 替换还会把提问者的附件覆盖掉)
|
|
231
|
+
test("P6 (#1823): asker's attachments survive a reply with attachments; reply goes to reply_attachments", async () => {
|
|
232
|
+
const { taskId } = seed();
|
|
233
|
+
const asked = att("11111111111111111111111111111111", { name: "asked.png" });
|
|
234
|
+
db.run("UPDATE tasks SET meta_json = ?1 WHERE task_id = ?2", [JSON.stringify({ attachments: [asked], origin: "dashboard" }), taskId]);
|
|
235
|
+
const handler = await getSendReplyHandler();
|
|
236
|
+
const answer = att("22222222222222222222222222222222", { name: "answer.md" });
|
|
237
|
+
const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "here", in_reply_to: taskId, status: "replied" as const, attachments: [answer] });
|
|
238
|
+
expect(reply.ok).toBe(true);
|
|
239
|
+
const persisted = readTaskMeta(taskId);
|
|
240
|
+
expect(persisted?.attachments?.map((a: any) => a.file_id)).toEqual([asked.file_id]);
|
|
241
|
+
expect(persisted?.origin).toBe("dashboard");
|
|
242
|
+
expect(persisted?.reply_attachments?.map((a: any) => a.file_id)).toEqual([answer.file_id]);
|
|
243
|
+
expect(readInboxMeta(reply.message_id)?.attachments?.[0]?.file_id).toBe(answer.file_id);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("#507 — reverse (e): no-attachments call is byte-identical to pre-#507", () => {
|
|
248
|
+
test("R-e1: reply with no attachments field returns response without attachments_saved key at all", async () => {
|
|
249
|
+
const { taskId } = seed();
|
|
250
|
+
const handler = await getSendReplyHandler();
|
|
251
|
+
const reply = await callReply(handler, {
|
|
252
|
+
alias: AGENT_ALIAS,
|
|
253
|
+
text: "no attachments here",
|
|
254
|
+
in_reply_to: taskId,
|
|
255
|
+
status: "replied" as const,
|
|
256
|
+
});
|
|
257
|
+
expect(reply.ok).toBe(true);
|
|
258
|
+
expect(reply.message_id).toBeDefined();
|
|
259
|
+
// Reverse-(e) core: key must not appear at all (undefined, not empty).
|
|
260
|
+
// If a caller had a strict equality assertion on the response shape,
|
|
261
|
+
// adding an empty attachments_saved field would break it.
|
|
262
|
+
expect("attachments_saved" in reply).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("R-e2: reply with attachments=undefined returns response without attachments_saved key", async () => {
|
|
266
|
+
const { taskId } = seed();
|
|
267
|
+
const handler = await getSendReplyHandler();
|
|
268
|
+
const reply = await callReply(handler, {
|
|
269
|
+
alias: AGENT_ALIAS,
|
|
270
|
+
text: "explicit undefined",
|
|
271
|
+
in_reply_to: taskId,
|
|
272
|
+
status: "replied" as const,
|
|
273
|
+
attachments: undefined,
|
|
274
|
+
});
|
|
275
|
+
expect(reply.ok).toBe(true);
|
|
276
|
+
expect("attachments_saved" in reply).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("R-e3: reply with attachments=[] (empty array) returns response without attachments_saved key", async () => {
|
|
280
|
+
const { taskId } = seed();
|
|
281
|
+
const handler = await getSendReplyHandler();
|
|
282
|
+
const reply = await callReply(handler, {
|
|
283
|
+
alias: AGENT_ALIAS,
|
|
284
|
+
text: "explicit empty array",
|
|
285
|
+
in_reply_to: taskId,
|
|
286
|
+
status: "replied" as const,
|
|
287
|
+
attachments: [],
|
|
288
|
+
});
|
|
289
|
+
expect(reply.ok).toBe(true);
|
|
290
|
+
// Empty array is a valid input — validator returns ok+empty.
|
|
291
|
+
// Response shape stays clean (no attachments_saved field).
|
|
292
|
+
expect("attachments_saved" in reply).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("R-e4: reply with no attachments does not touch pre-existing tasks.meta_json (COALESCE preserves)", async () => {
|
|
296
|
+
const { taskId } = seed();
|
|
297
|
+
const handler = await getSendReplyHandler();
|
|
298
|
+
// Pre-set a meta_json on the task row so we can prove COALESCE preserves it.
|
|
299
|
+
db.run("UPDATE tasks SET meta_json = ?1 WHERE task_id = ?2", [
|
|
300
|
+
JSON.stringify({ pre_existing: true, some_field: "must survive" }),
|
|
301
|
+
taskId,
|
|
302
|
+
]);
|
|
303
|
+
const reply = await callReply(handler, {
|
|
304
|
+
alias: AGENT_ALIAS,
|
|
305
|
+
text: "no attachments — must not clobber existing meta",
|
|
306
|
+
in_reply_to: taskId,
|
|
307
|
+
status: "replied" as const,
|
|
308
|
+
});
|
|
309
|
+
expect(reply.ok).toBe(true);
|
|
310
|
+
const meta = readTaskMeta(taskId);
|
|
311
|
+
expect(meta?.pre_existing).toBe(true);
|
|
312
|
+
expect(meta?.some_field).toBe("must survive");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("#507 — negative: malformed attachments explicitly rejected (bad_attachments)", () => {
|
|
317
|
+
test("N1: attachments not an array → 400 bad_attachments", async () => {
|
|
318
|
+
const { taskId } = seed();
|
|
319
|
+
const handler = await getSendReplyHandler();
|
|
320
|
+
const reply = await callReply(handler, {
|
|
321
|
+
alias: AGENT_ALIAS,
|
|
322
|
+
text: "malformed",
|
|
323
|
+
in_reply_to: taskId,
|
|
324
|
+
status: "replied" as const,
|
|
325
|
+
attachments: "not an array" as any,
|
|
326
|
+
});
|
|
327
|
+
expect(reply.ok).toBe(false);
|
|
328
|
+
expect(reply.error).toBe("bad_attachments");
|
|
329
|
+
expect(typeof reply.message).toBe("string");
|
|
330
|
+
// Reply never landed on disk (validation happened before DB write).
|
|
331
|
+
const meta = readTaskMeta(taskId);
|
|
332
|
+
expect(meta).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("N2: too many attachments (>20) → 400 bad_attachments", async () => {
|
|
336
|
+
const { taskId } = seed();
|
|
337
|
+
const handler = await getSendReplyHandler();
|
|
338
|
+
// 21 valid-shape attachments
|
|
339
|
+
const many = Array.from({ length: 21 }, (_, i) => att("a".repeat(32).slice(0, 30) + (i < 10 ? "0" + i : "" + i)));
|
|
340
|
+
const reply = await callReply(handler, {
|
|
341
|
+
alias: AGENT_ALIAS,
|
|
342
|
+
text: "too many",
|
|
343
|
+
in_reply_to: taskId,
|
|
344
|
+
status: "replied" as const,
|
|
345
|
+
attachments: many,
|
|
346
|
+
});
|
|
347
|
+
expect(reply.ok).toBe(false);
|
|
348
|
+
expect(reply.error).toBe("bad_attachments");
|
|
349
|
+
expect(reply.message).toMatch(/too many/i);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("N3: bad file_id (contains path separator) → 400 bad_attachments", async () => {
|
|
353
|
+
const { taskId } = seed();
|
|
354
|
+
const handler = await getSendReplyHandler();
|
|
355
|
+
const reply = await callReply(handler, {
|
|
356
|
+
alias: AGENT_ALIAS,
|
|
357
|
+
text: "bad file_id",
|
|
358
|
+
in_reply_to: taskId,
|
|
359
|
+
status: "replied" as const,
|
|
360
|
+
attachments: [{ type: "file", file_id: "../etc/passwd", name: "x", size: 1 }],
|
|
361
|
+
});
|
|
362
|
+
expect(reply.ok).toBe(false);
|
|
363
|
+
expect(reply.error).toBe("bad_attachments");
|
|
364
|
+
expect(reply.message).toMatch(/file_id/i);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("N4: wrong type field (must be 'file') → 400 bad_attachments", async () => {
|
|
368
|
+
const { taskId } = seed();
|
|
369
|
+
const handler = await getSendReplyHandler();
|
|
370
|
+
const reply = await callReply(handler, {
|
|
371
|
+
alias: AGENT_ALIAS,
|
|
372
|
+
text: "bad type",
|
|
373
|
+
in_reply_to: taskId,
|
|
374
|
+
status: "replied" as const,
|
|
375
|
+
attachments: [{ type: "url", file_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }],
|
|
376
|
+
});
|
|
377
|
+
expect(reply.ok).toBe(false);
|
|
378
|
+
expect(reply.error).toBe("bad_attachments");
|
|
379
|
+
expect(reply.message).toMatch(/type/i);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("N5: attachment size > MAX_UPLOAD_BYTES → 400 bad_attachments", async () => {
|
|
383
|
+
const { taskId } = seed();
|
|
384
|
+
const handler = await getSendReplyHandler();
|
|
385
|
+
const reply = await callReply(handler, {
|
|
386
|
+
alias: AGENT_ALIAS,
|
|
387
|
+
text: "too big",
|
|
388
|
+
in_reply_to: taskId,
|
|
389
|
+
status: "replied" as const,
|
|
390
|
+
attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { size: 999_999_999_999 })],
|
|
391
|
+
});
|
|
392
|
+
expect(reply.ok).toBe(false);
|
|
393
|
+
expect(reply.error).toBe("bad_attachments");
|
|
394
|
+
expect(reply.message).toMatch(/size/i);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("N6: attachment name too long (>255) → 400 bad_attachments", async () => {
|
|
398
|
+
const { taskId } = seed();
|
|
399
|
+
const handler = await getSendReplyHandler();
|
|
400
|
+
const reply = await callReply(handler, {
|
|
401
|
+
alias: AGENT_ALIAS,
|
|
402
|
+
text: "long name",
|
|
403
|
+
in_reply_to: taskId,
|
|
404
|
+
status: "replied" as const,
|
|
405
|
+
attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "x".repeat(256) })],
|
|
406
|
+
});
|
|
407
|
+
expect(reply.ok).toBe(false);
|
|
408
|
+
expect(reply.error).toBe("bad_attachments");
|
|
409
|
+
expect(reply.message).toMatch(/name/i);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("N7: non-object attachment item → 400 bad_attachments", async () => {
|
|
413
|
+
const { taskId } = seed();
|
|
414
|
+
const handler = await getSendReplyHandler();
|
|
415
|
+
const reply = await callReply(handler, {
|
|
416
|
+
alias: AGENT_ALIAS,
|
|
417
|
+
text: "junk item",
|
|
418
|
+
in_reply_to: taskId,
|
|
419
|
+
status: "replied" as const,
|
|
420
|
+
attachments: ["string-not-object"] as any,
|
|
421
|
+
});
|
|
422
|
+
expect(reply.ok).toBe(false);
|
|
423
|
+
expect(reply.error).toBe("bad_attachments");
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe("#507 — echo integrity (read-back-from-DB, not in-memory)", () => {
|
|
428
|
+
test("echo values are exactly what the DB round-trip produced (proves read-back not passthrough)", async () => {
|
|
429
|
+
const { taskId } = seed();
|
|
430
|
+
const handler = await getSendReplyHandler();
|
|
431
|
+
const a = att("ffffffffffffffffffffffffffffffff", {
|
|
432
|
+
name: "roundtrip.pdf",
|
|
433
|
+
mime: "application/pdf",
|
|
434
|
+
size: 4096,
|
|
435
|
+
});
|
|
436
|
+
const reply = await callReply(handler, {
|
|
437
|
+
alias: AGENT_ALIAS,
|
|
438
|
+
text: "roundtrip check",
|
|
439
|
+
in_reply_to: taskId,
|
|
440
|
+
status: "replied" as const,
|
|
441
|
+
attachments: [a],
|
|
442
|
+
});
|
|
443
|
+
expect(reply.ok).toBe(true);
|
|
444
|
+
// The echoed attachment must equal what DB round-trip gives back —
|
|
445
|
+
// fetch independently and compare.
|
|
446
|
+
const inboxMeta = readInboxMeta(reply.message_id!);
|
|
447
|
+
const dbAttachment = inboxMeta?.attachments?.[0];
|
|
448
|
+
expect(reply.attachments_saved![0]).toEqual(dbAttachment);
|
|
449
|
+
// Also the echo must contain the full shape as re-hydrated from JSON
|
|
450
|
+
// (proves it went through normalize/serialize/read/parse — not just
|
|
451
|
+
// an in-memory pass-through).
|
|
452
|
+
expect(reply.attachments_saved![0].file_id).toBe(a.file_id);
|
|
453
|
+
expect(reply.attachments_saved![0].name).toBe(a.name);
|
|
454
|
+
expect(reply.attachments_saved![0].mime).toBe(a.mime);
|
|
455
|
+
expect(reply.attachments_saved![0].size).toBe(a.size);
|
|
456
|
+
expect(reply.attachments_saved![0].type).toBe("file");
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildServeErrorResponse, serveErrorBody } from "./serve-error.js";
|
|
3
|
+
|
|
4
|
+
// 注:「没有 error 回调时 Bun 回默认非 JSON 500」这一半没法在 bun test 里当用例:
|
|
5
|
+
// 运行器会把 fetch 里抛出的异常当成未处理错误直接判这条用例失败(实测),证据在 #695 末评。
|
|
6
|
+
describe("#695 fetch exceptions become JSON with the exception's own message", () => {
|
|
7
|
+
test("body carries THE message, both JSON-RPC-shaped and REST-shaped", () => {
|
|
8
|
+
const b = serveErrorBody(new Error("boom-695: sqlite is locked"));
|
|
9
|
+
expect(b.error.code).toBe(-32603);
|
|
10
|
+
expect(b.error.message).toBe("boom-695: sqlite is locked");
|
|
11
|
+
expect(b.message).toBe("boom-695: sqlite is locked");
|
|
12
|
+
expect(b.ok).toBe(false);
|
|
13
|
+
// 不是固定 JSON:换一个异常,message 跟着变
|
|
14
|
+
expect(serveErrorBody(new Error("other")).error.message).toBe("other");
|
|
15
|
+
expect(serveErrorBody("plain string").error.message).toBe("plain string");
|
|
16
|
+
expect(serveErrorBody(new Error("")).error.message).toBe("internal error");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("real Bun.serve: a throwing fetch answers 500 + application/json + the message (was a non-JSON default page)", async () => {
|
|
20
|
+
const server = Bun.serve({
|
|
21
|
+
port: 0,
|
|
22
|
+
fetch() { throw new Error("boom-695-live"); },
|
|
23
|
+
error: (err) => buildServeErrorResponse(err),
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/mcp`, { method: "POST", body: "{}" });
|
|
27
|
+
expect(res.status).toBe(500);
|
|
28
|
+
expect(res.headers.get("content-type") ?? "").toContain("application/json");
|
|
29
|
+
const json = await res.json() as { error: { code: number; message: string }; ok: boolean };
|
|
30
|
+
expect(json.error.code).toBe(-32603);
|
|
31
|
+
expect(json.error.message).toBe("boom-695-live");
|
|
32
|
+
expect(json.ok).toBe(false);
|
|
33
|
+
} finally {
|
|
34
|
+
server.stop(true);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// #695 —— Bun.serve 没注册 `error` 回调时,fetch 处理器抛出的异常会变成 Bun 的默认 500,
|
|
2
|
+
// body 不是 JSON;MCP 客户端于是只能报一个泛化的 -32603,拿不到任何一句人话。
|
|
3
|
+
// 这里把异常变成一个带 message 的 JSON 响应:/mcp 的客户端按 JSON-RPC 形状读 error.message,
|
|
4
|
+
// REST 客户端按本仓其它路由的 { ok:false, error, message } 形状读。
|
|
5
|
+
// 🔴 判据(#695 末评):同一个异常,响应里要带**那个异常的 message**,不是一个固定 JSON。
|
|
6
|
+
|
|
7
|
+
export interface ServeErrorBody {
|
|
8
|
+
jsonrpc: "2.0";
|
|
9
|
+
id: null;
|
|
10
|
+
error: { code: -32603; message: string };
|
|
11
|
+
ok: false;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function serveErrorBody(err: unknown): ServeErrorBody {
|
|
16
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
17
|
+
const message = (raw && raw.trim()) ? raw.trim().slice(0, 500) : "internal error";
|
|
18
|
+
return { jsonrpc: "2.0", id: null, error: { code: -32603, message }, ok: false, message };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildServeErrorResponse(err: unknown): Response {
|
|
22
|
+
const body = serveErrorBody(err);
|
|
23
|
+
return new Response(JSON.stringify(body), {
|
|
24
|
+
status: 500,
|
|
25
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
26
|
+
});
|
|
27
|
+
}
|