@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30
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 +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -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.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- 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 +316 -0
- package/src/external-schedule-edits.ts +314 -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 +637 -0
- package/src/index.ts +15 -2172
- 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 +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +96 -7
- 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 +211 -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/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 +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3492 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -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 +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2302 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// #498 compatibility warning remains load-bearing during the mixed-version
|
|
7
|
+
// rollout. Capability-negotiated agents use send_peer_reply; old send_reply
|
|
8
|
+
// callers targeting an agent get explicit migration guidance while Dashboard
|
|
9
|
+
// targets stay quiet.
|
|
10
|
+
|
|
11
|
+
const NET_ID = "net_498_warn";
|
|
12
|
+
const USER_ID = "u_498_warn";
|
|
13
|
+
const NODE_ID = "node_498_warn";
|
|
14
|
+
const AGENT_ALIAS = "peer-agent-498";
|
|
15
|
+
const DISPATCHER_NODE_ID = "node_498_dispatcher";
|
|
16
|
+
const DISPATCHER_ALIAS = "dispatcher";
|
|
17
|
+
const AGENT_TOK_ID = "tok_498_warn";
|
|
18
|
+
|
|
19
|
+
interface ToolHandler {
|
|
20
|
+
(args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
21
|
+
}
|
|
22
|
+
interface Reply {
|
|
23
|
+
ok?: boolean;
|
|
24
|
+
message_id?: string;
|
|
25
|
+
session_status?: string;
|
|
26
|
+
warning?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanup() {
|
|
32
|
+
try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
39
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
|
|
40
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(cleanup);
|
|
44
|
+
afterAll(cleanup);
|
|
45
|
+
|
|
46
|
+
function seed() {
|
|
47
|
+
db.run(
|
|
48
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
49
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
50
|
+
[USER_ID, USER_ID],
|
|
51
|
+
);
|
|
52
|
+
db.run(
|
|
53
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
54
|
+
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
|
|
55
|
+
[DISPATCHER_NODE_ID, DISPATCHER_ALIAS, DISPATCHER_ALIAS, NET_ID, `host-${DISPATCHER_ALIAS}`],
|
|
56
|
+
);
|
|
57
|
+
db.run(
|
|
58
|
+
`INSERT INTO networks (network_id, network_name, owner_id, created_at)
|
|
59
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
60
|
+
[NET_ID, NET_ID, USER_ID],
|
|
61
|
+
);
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
64
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
65
|
+
[`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
|
|
66
|
+
);
|
|
67
|
+
db.run(
|
|
68
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
69
|
+
VALUES (?1, ?2, 'owner', datetime('now'))`,
|
|
70
|
+
[USER_ID, NET_ID],
|
|
71
|
+
);
|
|
72
|
+
db.run(
|
|
73
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
74
|
+
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
|
|
75
|
+
[NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
|
|
76
|
+
);
|
|
77
|
+
db.run(
|
|
78
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
79
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
80
|
+
[`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
|
|
81
|
+
);
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
84
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
85
|
+
[AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeTask(from: string): string {
|
|
90
|
+
const id = "t_" + Math.random().toString(36).slice(2, 14);
|
|
91
|
+
db.run(
|
|
92
|
+
`INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
|
|
93
|
+
VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
|
|
94
|
+
[id, from, AGENT_ALIAS, NODE_ID, NET_ID],
|
|
95
|
+
);
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildHandlers(): Record<string, ToolHandler> {
|
|
100
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
101
|
+
const tools: Record<string, ToolHandler> = {};
|
|
102
|
+
const origTool = server.tool.bind(server);
|
|
103
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
104
|
+
tools[name] = handler;
|
|
105
|
+
return origTool(name, _desc, _schema, handler);
|
|
106
|
+
};
|
|
107
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
108
|
+
if (origRegisterTool) {
|
|
109
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
110
|
+
tools[name] = handler;
|
|
111
|
+
return origRegisterTool(name, _cfg, handler);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// Bind as a network token whose caller alias is the sender.
|
|
115
|
+
registerTools(
|
|
116
|
+
server,
|
|
117
|
+
undefined,
|
|
118
|
+
/* enforceNetworkId */ NET_ID,
|
|
119
|
+
/* enforceUserId */ USER_ID,
|
|
120
|
+
/* callerAlias */ AGENT_ALIAS,
|
|
121
|
+
/* callerTokenIsNetwork */ true,
|
|
122
|
+
/* callerTokenId */ AGENT_TOK_ID,
|
|
123
|
+
);
|
|
124
|
+
return tools;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
128
|
+
const r = await handler(args);
|
|
129
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("send_reply legacy/Dashboard compatibility (#698)", () => {
|
|
133
|
+
test("legacy call still closes the original and enqueues one no-response result", async () => {
|
|
134
|
+
seed();
|
|
135
|
+
const tools = buildHandlers();
|
|
136
|
+
const sendReply = tools["send_reply"];
|
|
137
|
+
expect(sendReply).toBeDefined();
|
|
138
|
+
|
|
139
|
+
const taskId = makeTask(DISPATCHER_ALIAS);
|
|
140
|
+
const reply = await call(sendReply, {
|
|
141
|
+
alias: DISPATCHER_ALIAS,
|
|
142
|
+
text: "peer reply (atomic)",
|
|
143
|
+
in_reply_to: taskId,
|
|
144
|
+
status: "replied",
|
|
145
|
+
from_session: AGENT_ALIAS,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(reply.ok).toBe(true);
|
|
149
|
+
expect(reply.warning).toContain("commhub_send_peer_reply");
|
|
150
|
+
expect(reply.warning).toContain("terminalized the original task");
|
|
151
|
+
expect(reply.warning).not.toContain("commhub_send_task");
|
|
152
|
+
expect(reply.warning).toContain("mixed-version");
|
|
153
|
+
expect(db.get<{ status: string; result: string }>(
|
|
154
|
+
"SELECT status, result FROM tasks WHERE task_id = ?1", taskId,
|
|
155
|
+
)).toEqual(expect.objectContaining({ status: "replied", result: "peer reply (atomic)" }));
|
|
156
|
+
expect(db.all<{ type: string; requires_response: string; session_name: string }>(
|
|
157
|
+
"SELECT type, requires_response, session_name FROM inbox WHERE in_reply_to = ?1", taskId,
|
|
158
|
+
)).toEqual([{ type: "reply", requires_response: "none", session_name: DISPATCHER_ALIAS }]);
|
|
159
|
+
expect(db.get<{ n: number }>(
|
|
160
|
+
"SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId,
|
|
161
|
+
)?.n).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("target alias is `hub` (Dashboard path) → NO warning field", async () => {
|
|
165
|
+
seed();
|
|
166
|
+
const tools = buildHandlers();
|
|
167
|
+
const sendReply = tools["send_reply"];
|
|
168
|
+
|
|
169
|
+
const taskId = makeTask("hub");
|
|
170
|
+
const reply = await call(sendReply, {
|
|
171
|
+
alias: "hub",
|
|
172
|
+
text: "dashboard reply",
|
|
173
|
+
in_reply_to: taskId,
|
|
174
|
+
status: "replied",
|
|
175
|
+
from_session: AGENT_ALIAS,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(reply.ok).toBe(true);
|
|
179
|
+
// Silence-affirms the Dashboard path. Every reply carrying a
|
|
180
|
+
// warning would train the LLM to ignore it, so absence here is
|
|
181
|
+
// load-bearing.
|
|
182
|
+
expect(reply.warning).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("target alias is `api` (Dashboard path) → NO warning field", async () => {
|
|
186
|
+
seed();
|
|
187
|
+
const tools = buildHandlers();
|
|
188
|
+
const sendReply = tools["send_reply"];
|
|
189
|
+
|
|
190
|
+
const taskId = makeTask("api");
|
|
191
|
+
const reply = await call(sendReply, {
|
|
192
|
+
alias: "api",
|
|
193
|
+
text: "dashboard reply via api",
|
|
194
|
+
in_reply_to: taskId,
|
|
195
|
+
status: "replied",
|
|
196
|
+
from_session: AGENT_ALIAS,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(reply.ok).toBe(true);
|
|
200
|
+
expect(reply.warning).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -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
|
+
});
|