@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41

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.
Files changed (122) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +654 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message-seam.test.ts +150 -0
  30. package/src/desktop-message.test.ts +368 -0
  31. package/src/ext-token-shared.test.ts +60 -0
  32. package/src/ext-validation-regression.test.ts +131 -0
  33. package/src/external-schedule-edits-http.test.ts +316 -0
  34. package/src/external-schedule-edits.ts +314 -0
  35. package/src/file-download-authz-dev-open.test.ts +135 -0
  36. package/src/file-download-authz.test.ts +403 -0
  37. package/src/file-download-date-bucket.test.ts +436 -0
  38. package/src/file-network-scope-dev-open.test.ts +97 -0
  39. package/src/file-network-scope.test.ts +731 -0
  40. package/src/health-redaction.test.ts +637 -0
  41. package/src/inbox-count-sse.test.ts +310 -0
  42. package/src/inbox-count.ts +29 -0
  43. package/src/index.ts +15 -2187
  44. package/src/lifecycle-guard.ts +50 -0
  45. package/src/list-host-supervisors.test.ts +314 -0
  46. package/src/mcp-write-network-resolution.test.ts +429 -0
  47. package/src/network-name-http.test.ts +71 -0
  48. package/src/network-scope.ts +101 -0
  49. package/src/node-attrs-validate.ts +88 -0
  50. package/src/node-attrs.test.ts +213 -0
  51. package/src/node-id-alias.test.ts +147 -0
  52. package/src/node-lifecycle-controllable.test.ts +64 -0
  53. package/src/node-lifecycle-controllable.ts +51 -0
  54. package/src/observer-avatar-http.test.ts +424 -0
  55. package/src/observer-push.test.ts +252 -0
  56. package/src/package-version-consistency.test.ts +25 -0
  57. package/src/peer-reply-atomic.test.ts +455 -0
  58. package/src/peer-reply-capability.test.ts +70 -0
  59. package/src/probe-validate.test.ts +221 -0
  60. package/src/probe-validate.ts +104 -0
  61. package/src/probe.test.ts +80 -0
  62. package/src/probe.ts +200 -0
  63. package/src/push.ts +163 -7
  64. package/src/redact-tokens.test.ts +65 -0
  65. package/src/redact-tokens.ts +40 -0
  66. package/src/reply-target-derivation.test.ts +96 -0
  67. package/src/require-explicit-test-db.ts +9 -0
  68. package/src/resolve-port.test.ts +57 -0
  69. package/src/resolve-port.ts +44 -0
  70. package/src/response-charset.test.ts +130 -0
  71. package/src/rest-explicit-columns-http.test.ts +212 -0
  72. package/src/rest-identity.test.ts +129 -0
  73. package/src/rest-identity.ts +100 -0
  74. package/src/rest-projections.ts +67 -0
  75. package/src/rest-write-network-resolution.test.ts +133 -0
  76. package/src/runtime-label.test.ts +22 -0
  77. package/src/runtime-label.ts +21 -0
  78. package/src/scheduled-run-terminal.test.ts +268 -0
  79. package/src/scheduled-tasks-http.test.ts +479 -0
  80. package/src/scheduled-tasks.ts +549 -0
  81. package/src/send-reply-agent-warning.test.ts +202 -0
  82. package/src/send-reply-alias-optional.test.ts +131 -0
  83. package/src/send-reply-attachments.test.ts +437 -0
  84. package/src/server.ts +3664 -0
  85. package/src/shared/external-schedule-contract.ts +60 -0
  86. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  87. package/src/shared/probe-host-allowlist.ts +128 -0
  88. package/src/side-thread-command-transport.test.ts +302 -0
  89. package/src/side-thread-command-transport.ts +318 -0
  90. package/src/side-thread-http-integration.test.ts +229 -0
  91. package/src/side-thread-http.ts +504 -0
  92. package/src/side-thread-production.test.ts +72 -0
  93. package/src/side-thread-production.ts +118 -0
  94. package/src/side-thread.test.ts +1067 -0
  95. package/src/side-thread.ts +2495 -0
  96. package/src/skillhub-http.test.ts +88 -0
  97. package/src/start-node.test.ts +109 -0
  98. package/src/stop-delete-node.test.ts +952 -0
  99. package/src/task-auth-origin-http.test.ts +87 -0
  100. package/src/task-auth-origin.test.ts +24 -0
  101. package/src/task-auth-origin.ts +7 -0
  102. package/src/task-consumption.test.ts +471 -0
  103. package/src/task-diagnostic-http.test.ts +145 -0
  104. package/src/task-diagnostic.test.ts +115 -0
  105. package/src/task-diagnostic.ts +118 -0
  106. package/src/task-idempotency-mcp.test.ts +142 -0
  107. package/src/task-idempotency.test.ts +60 -0
  108. package/src/task-idempotency.ts +75 -0
  109. package/src/task-lifecycle-watcher.test.ts +200 -0
  110. package/src/task-lifecycle-watcher.ts +49 -0
  111. package/src/task-network-resolution-http.test.ts +137 -0
  112. package/src/tasks-pagination-http.test.ts +137 -0
  113. package/src/tools.ts +2994 -191
  114. package/src/update-provider.test.ts +234 -0
  115. package/src/upload-early-reject-guard.test.ts +155 -0
  116. package/src/uploads-http.test.ts +92 -14
  117. package/src/uploads.test.ts +66 -0
  118. package/src/uploads.ts +130 -5
  119. package/src/user-inbox-read-http.test.ts +220 -0
  120. package/src/user-inbox-schema.test.ts +53 -0
  121. package/src/vault.test.ts +200 -0
  122. package/src/vault.ts +156 -0
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ type PackageManifest = { version: string };
6
+ type PackageLock = {
7
+ version: string;
8
+ packages: { "": { version: string } };
9
+ };
10
+
11
+ const readJson = <T>(name: string): T =>
12
+ JSON.parse(readFileSync(join(import.meta.dir, "..", name), "utf8")) as T;
13
+
14
+ describe("server package version consistency", () => {
15
+ const manifest = readJson<PackageManifest>("package.json");
16
+ const lock = readJson<PackageLock>("package-lock.json");
17
+
18
+ test("package-lock top-level version matches package.json", () => {
19
+ expect(lock.version).toBe(manifest.version);
20
+ });
21
+
22
+ test("package-lock root package version matches package.json", () => {
23
+ expect(lock.packages[""].version).toBe(manifest.version);
24
+ });
25
+ });
@@ -0,0 +1,455 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { registerTools } from "./tools.js";
5
+ import { CommHubError } from "../../agent-node/src/reply-reliability.js";
6
+ import { sendPeerReplyCompatible } from "../../agent-node/src/peer-reply-send.js";
7
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
8
+ import { eventBus } from "./event_bus.js";
9
+
10
+ const NET = "net_peer_reply_698";
11
+ const USER = "user_peer_reply_698";
12
+ const A = "peer-a-698";
13
+ const B = "peer-b-698";
14
+ const A_ID = "node-peer-a-698";
15
+ const B_ID = "node-peer-b-698";
16
+
17
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
18
+
19
+ function cleanup() {
20
+ __resetSSEClientsForTest();
21
+ try { db.exec("DROP TRIGGER IF EXISTS test698_abort_terminal"); } catch {}
22
+ try { db.exec("DROP TRIGGER IF EXISTS test698_abort_run"); } catch {}
23
+ for (const table of ["scheduled_task_runs", "scheduled_tasks", "tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
24
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
25
+ }
26
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
27
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
28
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
29
+ }
30
+
31
+ function seed() {
32
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [USER]);
33
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
34
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
35
+ for (const [alias, nodeId] of [[A, A_ID], [B, B_ID]] as const) {
36
+ db.run(
37
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', ?4, datetime('now'), datetime('now'), 'active')",
38
+ [nodeId, alias, NET, JSON.stringify({ peer_reply_inbox_capable: true })],
39
+ );
40
+ db.run(
41
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, peer_reply_inbox_capable, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, 1, datetime('now'), datetime('now'))",
42
+ [`resume-${alias}`, alias, nodeId, NET],
43
+ );
44
+ createSSEStream(alias, NET);
45
+ db.run(
46
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
47
+ [`token-${alias}`, `hash-token-${alias}`, USER, NET, `node:${alias}`, nodeId],
48
+ );
49
+ }
50
+ }
51
+
52
+ function toolsFor(alias: string): Record<string, ToolHandler> {
53
+ const server = new McpServer({ name: "peer-reply-698", version: "0" }) as any;
54
+ const tools: Record<string, ToolHandler> = {};
55
+ const original = server.tool.bind(server);
56
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
57
+ tools[name] = handler;
58
+ return original(name, description, schema, handler);
59
+ };
60
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
61
+ return tools;
62
+ }
63
+
64
+ async function call(handler: ToolHandler, args: any) {
65
+ const result = await handler(args);
66
+ return JSON.parse(result.content[0].text);
67
+ }
68
+
69
+ async function callOrThrow(handler: ToolHandler, args: any) {
70
+ const payload = await call(handler, args);
71
+ if (payload?.ok === false) {
72
+ throw new CommHubError(`app-level rejection: ${payload.error}`, {
73
+ code: payload.error,
74
+ payload,
75
+ appLevel: true,
76
+ });
77
+ }
78
+ return payload;
79
+ }
80
+
81
+ async function dispatch(from: string, to: string, content: string): Promise<string> {
82
+ const sent = await call(toolsFor(from).send_task, { alias: to, task: content, priority: "normal" });
83
+ expect(sent.ok).toBe(true);
84
+ return sent.task_id ?? sent.message_id;
85
+ }
86
+
87
+ function task(taskId: string) {
88
+ return db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", taskId);
89
+ }
90
+
91
+ function replies(taskId: string) {
92
+ return db.all<any>("SELECT * FROM inbox WHERE in_reply_to = ?1 ORDER BY rowid", taskId);
93
+ }
94
+
95
+ function bindScheduledRun(taskId: string): { runId: string; scheduleId: string } {
96
+ const suffix = crypto.randomUUID();
97
+ const runId = `srun_${suffix}`;
98
+ const scheduleId = `sched_${suffix}`;
99
+ db.run(
100
+ `INSERT INTO scheduled_tasks
101
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
102
+ schedule_type, schedule_json, timezone, status, next_run_at)
103
+ VALUES (?1, ?2, 'peer reply schedule', ?3, ?4, 'scheduled peer task', 'normal',
104
+ 'interval', '{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
105
+ [scheduleId, NET, B_ID, B],
106
+ );
107
+ db.run(
108
+ `INSERT INTO scheduled_task_runs
109
+ (run_id, schedule_id, network_id, scheduled_for, task_id, status, completed_at)
110
+ VALUES (?1, ?2, ?3, datetime('now'), ?4, 'delivered', NULL)`,
111
+ [runId, scheduleId, NET, taskId],
112
+ );
113
+ return { runId, scheduleId };
114
+ }
115
+
116
+ beforeEach(() => { cleanup(); seed(); });
117
+ afterAll(cleanup);
118
+
119
+ describe("#698 atomic peer reply", () => {
120
+ test("exact bound token works even when sessions.node_id is NULL", async () => {
121
+ const taskId = await dispatch(A, B, "session row is not identity");
122
+ db.run("UPDATE sessions SET node_id = NULL WHERE alias = ?1 AND network_id = ?2", [B, NET]);
123
+ const result = await call(toolsFor(B).send_peer_reply, {
124
+ alias: A, text: "bound-token wins", in_reply_to: taskId, status: "replied",
125
+ });
126
+ expect(result.ok).toBe(true);
127
+ expect(task(taskId).status).toBe("replied");
128
+ });
129
+
130
+ test("a rollback heartbeat clears the current-session capability before any peer write", async () => {
131
+ const tools = toolsFor(A);
132
+ await call(tools.report_status, {
133
+ resume_id: `resume-${A}`, alias: A, status: "idle", node_id: A_ID,
134
+ config_snapshot: { config_update_capable: true, peer_reply_inbox_capable: true },
135
+ });
136
+ expect(db.get<any>(
137
+ "SELECT peer_reply_inbox_capable FROM sessions WHERE alias = ?1 AND network_id = ?2",
138
+ A, NET,
139
+ ).peer_reply_inbox_capable).toBe(1);
140
+ await call(tools.report_status, {
141
+ resume_id: `resume-${A}`, alias: A, status: "idle", node_id: A_ID,
142
+ config_snapshot: { config_update_capable: true },
143
+ });
144
+ expect(db.get<any>(
145
+ "SELECT peer_reply_inbox_capable FROM sessions WHERE alias = ?1 AND network_id = ?2",
146
+ A, NET,
147
+ ).peer_reply_inbox_capable).toBe(0);
148
+
149
+ const taskId = await dispatch(A, B, "recipient rolled back");
150
+ const result = await call(toolsFor(B).send_peer_reply, {
151
+ alias: A, text: "must use legacy", in_reply_to: taskId, status: "replied",
152
+ });
153
+ expect(result).toEqual(expect.objectContaining({ ok: false, error: "peer_reply_unsupported" }));
154
+ expect(task(taskId).status).toBe("delivered");
155
+ expect(replies(taskId)).toHaveLength(0);
156
+ });
157
+
158
+ test("a capable but disconnected recipient commits atomically for reconnect drain", async () => {
159
+ const taskId = await dispatch(A, B, "recipient disconnected");
160
+ __resetSSEClientsForTest();
161
+ const result = await call(toolsFor(B).send_peer_reply, {
162
+ alias: A, text: "stored for reconnect", in_reply_to: taskId, status: "replied",
163
+ });
164
+ expect(result).toEqual(expect.objectContaining({ ok: true }));
165
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "stored for reconnect" }));
166
+ expect(replies(taskId)).toHaveLength(1);
167
+ });
168
+
169
+ test("recipient capability is bound to the exact canonical alias, not another session on the same node", async () => {
170
+ const taskId = await dispatch(A, B, "same node must not lend capability across aliases");
171
+ db.run(
172
+ "UPDATE sessions SET peer_reply_inbox_capable = 0 WHERE alias = ?1 AND network_id = ?2",
173
+ [A, NET],
174
+ );
175
+ db.run(
176
+ `INSERT INTO sessions
177
+ (resume_id, alias, status, node_id, network_id, peer_reply_inbox_capable, updated_at, last_seen_at)
178
+ VALUES ('resume-wrong-alias-698', 'wrong-alias-698', 'idle', ?1, ?2, 1,
179
+ datetime('now', '+1 minute'), datetime('now', '+1 minute'))`,
180
+ [A_ID, NET],
181
+ );
182
+ const result = await call(toolsFor(B).send_peer_reply, {
183
+ alias: A, text: "must not borrow wrong alias capability", in_reply_to: taskId, status: "replied",
184
+ });
185
+ expect(result).toEqual(expect.objectContaining({ ok: false, error: "peer_reply_unsupported" }));
186
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
187
+ expect(replies(taskId)).toHaveLength(0);
188
+ });
189
+
190
+ test("origin rename during work routes the terminal reply to the canonical alias and stable node", async () => {
191
+ const taskId = await dispatch(A, B, "origin will rename");
192
+ const renamed = `${A}-renamed`;
193
+ db.run(
194
+ `INSERT INTO rename_txn (txn_id, network_id, old_alias, new_alias, status, committed_at)
195
+ VALUES (?1, ?2, ?3, ?4, 'committed', datetime('now'))`,
196
+ [`rtxn-${crypto.randomUUID()}`, NET, A, renamed],
197
+ );
198
+ db.run("UPDATE nodes SET alias = ?1 WHERE node_id = ?2 AND network_id = ?3", [renamed, A_ID, NET]);
199
+ db.run("UPDATE sessions SET alias = ?1 WHERE node_id = ?2 AND network_id = ?3", [renamed, A_ID, NET]);
200
+ eventBus.emit("rename-committed", {
201
+ networkId: NET, old_alias: A, new_alias: renamed, node_id: A_ID,
202
+ });
203
+ const result = await call(toolsFor(B).send_peer_reply, {
204
+ alias: A, text: "reply after rename", in_reply_to: taskId, status: "replied",
205
+ });
206
+ expect(result.ok).toBe(true);
207
+ expect(task(taskId).status).toBe("replied");
208
+ expect(replies(taskId)).toEqual([
209
+ expect.objectContaining({ session_name: renamed, node_id: A_ID, requires_response: "none" }),
210
+ ]);
211
+ });
212
+
213
+ test("legacy caller or recipient capability downgrade returns unsupported with zero writes", async () => {
214
+ for (const mode of ["caller-unbound", "recipient-downgraded", "legacy-task-origin"] as const) {
215
+ cleanup(); seed();
216
+ const taskId = await dispatch(A, B, `compat-${mode}`);
217
+ if (mode === "caller-unbound") {
218
+ db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1", `token-${B}`);
219
+ } else if (mode === "recipient-downgraded") {
220
+ db.run("UPDATE sessions SET peer_reply_inbox_capable = 0 WHERE node_id = ?1 AND network_id = ?2", [A_ID, NET]);
221
+ } else {
222
+ db.run("UPDATE tasks SET from_node_id = NULL WHERE task_id = ?1", taskId);
223
+ }
224
+ const result = await call(toolsFor(B).send_peer_reply, {
225
+ alias: A, text: "must fall back", in_reply_to: taskId, status: "replied",
226
+ });
227
+ expect(result).toEqual(expect.objectContaining({
228
+ ok: false,
229
+ error: mode === "legacy-task-origin" ? "peer_reply_origin_not_node" : "peer_reply_unsupported",
230
+ reply_queued: false,
231
+ }));
232
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
233
+ expect(replies(taskId)).toHaveLength(0);
234
+ }
235
+ });
236
+
237
+ test("old Hub or legacy recipient falls back once and terminalizes the original", async () => {
238
+ for (const mode of ["old-hub", "legacy-recipient"] as const) {
239
+ cleanup(); seed();
240
+ const taskId = await dispatch(A, B, `mixed-${mode}`);
241
+ if (mode === "legacy-recipient") {
242
+ db.run("UPDATE sessions SET peer_reply_inbox_capable = 0 WHERE node_id = ?1 AND network_id = ?2", [A_ID, NET]);
243
+ }
244
+ const tools = toolsFor(B);
245
+ let atomicCalls = 0;
246
+ let legacyReplyCalls = 0;
247
+ const routed = await sendPeerReplyCompatible({
248
+ target: A, text: `fallback-${mode}`, taskId, failed: false, fromAlias: B,
249
+ }, {
250
+ sendAtomic: async (args) => {
251
+ atomicCalls++;
252
+ if (mode === "old-hub") {
253
+ throw new CommHubError("JSON-RPC error: -32601: unknown tool send_peer_reply", { code: -32601 });
254
+ }
255
+ return callOrThrow(tools.send_peer_reply, {
256
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
257
+ });
258
+ },
259
+ sendLegacyReply: async (args) => {
260
+ legacyReplyCalls++;
261
+ return callOrThrow(tools.send_reply, {
262
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
263
+ });
264
+ },
265
+ });
266
+ expect(routed.route).toBe("legacy-reply");
267
+ expect([atomicCalls, legacyReplyCalls]).toEqual([1, 1]);
268
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: `fallback-${mode}` }));
269
+ expect(replies(taskId)).toHaveLength(1);
270
+ expect(db.get<{ n: number }>(
271
+ "SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1 AND requires_response = 'reply'",
272
+ taskId,
273
+ )?.n).toBe(0);
274
+ }
275
+ });
276
+
277
+ test("node-id rotation falls back to alias-bound terminal send_reply", async () => {
278
+ const taskId = await dispatch(A, B, "rotate B after dispatch");
279
+ const rotatedNodeId = `${B_ID}-rotated`;
280
+ db.run(
281
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host-rotated', ?4, datetime('now'), datetime('now'), 'active')",
282
+ [rotatedNodeId, B, NET, JSON.stringify({ peer_reply_inbox_capable: true })],
283
+ );
284
+ db.run("UPDATE api_tokens SET bound_node_id = ?1 WHERE token_id = ?2", [rotatedNodeId, `token-${B}`]);
285
+ db.run("UPDATE sessions SET node_id = ?1 WHERE alias = ?2 AND network_id = ?3", [rotatedNodeId, B, NET]);
286
+
287
+ const tools = toolsFor(B);
288
+ let atomicCalls = 0;
289
+ let legacyReplyCalls = 0;
290
+ const routed = await sendPeerReplyCompatible({
291
+ target: A, text: "result after identity rotation", taskId, failed: false, fromAlias: B,
292
+ }, {
293
+ sendAtomic: async (args) => {
294
+ atomicCalls++;
295
+ return callOrThrow(tools.send_peer_reply, {
296
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
297
+ });
298
+ },
299
+ sendLegacyReply: async (args) => {
300
+ legacyReplyCalls++;
301
+ return callOrThrow(tools.send_reply, {
302
+ alias: args.target, text: args.text, in_reply_to: args.taskId, status: "replied",
303
+ });
304
+ },
305
+ });
306
+
307
+ expect(routed.route).toBe("legacy-reply");
308
+ expect([atomicCalls, legacyReplyCalls]).toEqual([1, 1]);
309
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "result after identity rotation" }));
310
+ expect(replies(taskId)).toHaveLength(1);
311
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId)?.n).toBe(0);
312
+ });
313
+
314
+ test("compatibility send_reply cannot terminalize another node's task or target a third party", async () => {
315
+ const taskId = await dispatch(A, B, "compatibility ownership boundary");
316
+
317
+ const foreign = await call(toolsFor(A).send_reply, {
318
+ alias: A, text: "foreign close attempt", in_reply_to: taskId, status: "replied",
319
+ });
320
+ expect(foreign).toEqual(expect.objectContaining({ ok: false, error: "reply_task_not_owned" }));
321
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
322
+ expect(replies(taskId)).toHaveLength(0);
323
+
324
+ const wrongTarget = await call(toolsFor(B).send_reply, {
325
+ alias: B, text: "wrong target attempt", in_reply_to: taskId, status: "replied",
326
+ });
327
+ expect(wrongTarget).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
328
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "delivered", result: null }));
329
+ expect(replies(taskId)).toHaveLength(0);
330
+
331
+ const valid = await call(toolsFor(B).send_reply, {
332
+ alias: A, text: "valid compatibility close", in_reply_to: taskId, status: "replied",
333
+ });
334
+ expect(valid.ok).toBe(true);
335
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "valid compatibility close" }));
336
+ expect(replies(taskId)).toHaveLength(1);
337
+ });
338
+
339
+ test("owner node terminalizes exact original and emits one no-response reply without a task row", async () => {
340
+ const taskId = await dispatch(A, B, "do work");
341
+ const result = await call(toolsFor(B).send_peer_reply, {
342
+ alias: A,
343
+ text: "done exactly once",
344
+ in_reply_to: taskId,
345
+ status: "replied",
346
+ });
347
+ expect(result.ok).toBe(true);
348
+ expect(task(taskId)).toEqual(expect.objectContaining({ status: "replied", result: "done exactly once" }));
349
+ expect(task(taskId).completed_at).toBeTruthy();
350
+ expect(replies(taskId)).toHaveLength(1);
351
+ expect(replies(taskId)[0]).toEqual(expect.objectContaining({
352
+ session_name: A,
353
+ node_id: A_ID,
354
+ type: "reply",
355
+ requires_response: "none",
356
+ content: "done exactly once",
357
+ }));
358
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId)?.n).toBe(0);
359
+ });
360
+
361
+ test("terminal replay is rejected and cannot deliver twice", async () => {
362
+ const taskId = await dispatch(A, B, "idempotent work");
363
+ const handler = toolsFor(B).send_peer_reply;
364
+ expect((await call(handler, { alias: A, text: "first", in_reply_to: taskId, status: "replied" })).ok).toBe(true);
365
+ const replay = await call(handler, { alias: A, text: "second", in_reply_to: taskId, status: "replied" });
366
+ expect(replay).toEqual(expect.objectContaining({ ok: false, error: "reply_task_terminal", reply_queued: false }));
367
+ expect(task(taskId).result).toBe("first");
368
+ expect(replies(taskId)).toHaveLength(1);
369
+ });
370
+
371
+ test("all declared peer reply outcomes become exact terminal task states", async () => {
372
+ for (const status of ["replied", "failed", "cancelled"] as const) {
373
+ const taskId = await dispatch(A, B, `terminal-${status}`);
374
+ const result = await call(toolsFor(B).send_peer_reply, {
375
+ alias: A,
376
+ text: `result-${status}`,
377
+ in_reply_to: taskId,
378
+ status,
379
+ });
380
+ expect(result.ok).toBe(true);
381
+ expect(task(taskId)).toEqual(expect.objectContaining({
382
+ status,
383
+ result: `result-${status}`,
384
+ }));
385
+ expect(task(taskId).completed_at).toBeTruthy();
386
+ expect(replies(taskId)).toHaveLength(1);
387
+ expect(replies(taskId)[0].requires_response).toBe("none");
388
+ }
389
+ });
390
+
391
+ test("foreign node and wrong origin target are rejected with zero writes", async () => {
392
+ const taskId = await dispatch(A, B, "owned by B");
393
+ const foreign = await call(toolsFor(A).send_peer_reply, { alias: A, text: "forge", in_reply_to: taskId, status: "replied" });
394
+ expect(foreign).toEqual(expect.objectContaining({ ok: false, error: "reply_task_not_owned" }));
395
+ const wrongTarget = await call(toolsFor(B).send_peer_reply, { alias: B, text: "misroute", in_reply_to: taskId, status: "replied" });
396
+ expect(wrongTarget).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
397
+ expect(task(taskId).status).toBe("delivered");
398
+ expect(replies(taskId)).toHaveLength(0);
399
+ });
400
+
401
+ test("task update failure rolls back the reply inbox insert", async () => {
402
+ const taskId = await dispatch(A, B, "atomic failure");
403
+ db.exec(`CREATE TRIGGER test698_abort_terminal BEFORE UPDATE OF status ON tasks
404
+ WHEN OLD.task_id = '${taskId}' BEGIN SELECT RAISE(ABORT, 'test698 injected'); END`);
405
+ await expect(call(toolsFor(B).send_peer_reply, {
406
+ alias: A,
407
+ text: "must rollback",
408
+ in_reply_to: taskId,
409
+ status: "replied",
410
+ })).rejects.toThrow("test698 injected");
411
+ expect(task(taskId).status).toBe("delivered");
412
+ expect(task(taskId).result).toBeNull();
413
+ expect(replies(taskId)).toHaveLength(0);
414
+ });
415
+
416
+ test("node-token peer reply terminalizes the exact scheduled run in the same transaction", async () => {
417
+ const taskId = await dispatch(A, B, "scheduled work");
418
+ const { runId } = bindScheduledRun(taskId);
419
+ const result = await call(toolsFor(B).send_peer_reply, {
420
+ alias: A, text: "scheduled done", in_reply_to: taskId, status: "replied",
421
+ });
422
+ expect(result.ok).toBe(true);
423
+ expect(task(taskId).status).toBe("replied");
424
+ expect(db.get<any>("SELECT status, completed_at FROM scheduled_task_runs WHERE run_id = ?1", runId))
425
+ .toEqual(expect.objectContaining({ status: "replied" }));
426
+ });
427
+
428
+ test("scheduled-run write failure rolls back task and reply inbox together", async () => {
429
+ const taskId = await dispatch(A, B, "scheduled rollback");
430
+ const { runId } = bindScheduledRun(taskId);
431
+ db.exec(`CREATE TRIGGER test698_abort_run BEFORE UPDATE OF status ON scheduled_task_runs
432
+ WHEN OLD.run_id = '${runId}' BEGIN SELECT RAISE(ABORT, 'test698 injected run'); END`);
433
+ await expect(call(toolsFor(B).send_peer_reply, {
434
+ alias: A, text: "must all roll back", in_reply_to: taskId, status: "replied",
435
+ })).rejects.toThrow("test698 injected run");
436
+ expect(task(taskId).status).toBe("delivered");
437
+ expect(db.get<any>("SELECT status FROM scheduled_task_runs WHERE run_id = ?1", runId)?.status).toBe("delivered");
438
+ expect(replies(taskId)).toHaveLength(0);
439
+ });
440
+
441
+ test("three exchanges leave only dispatched work rows and no open tasks", async () => {
442
+ for (let i = 0; i < 3; i++) {
443
+ const taskId = await dispatch(A, B, `work-${i}`);
444
+ expect((await call(toolsFor(B).send_peer_reply, {
445
+ alias: A,
446
+ text: `result-${i}`,
447
+ in_reply_to: taskId,
448
+ status: "replied",
449
+ })).ok).toBe(true);
450
+ }
451
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1", NET)?.n).toBe(3);
452
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND status IN ('created','delivered','acked','running')", NET)?.n).toBe(0);
453
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1 AND type = 'reply' AND requires_response = 'none'", NET)?.n).toBe(3);
454
+ });
455
+ });
@@ -0,0 +1,70 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { trustedConfigSnapshotForNode, upsertNodeWithSec1Guard } from "./tools.js";
4
+
5
+ const NET = "net_peer_cap_698";
6
+ const USER = "user_peer_cap_698";
7
+
8
+ function seedToken(tokenId: string, nodeId: string | null) {
9
+ db.run(
10
+ "INSERT OR IGNORE INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))",
11
+ [USER, USER],
12
+ );
13
+ db.run(
14
+ "INSERT OR IGNORE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))",
15
+ [NET, NET, USER],
16
+ );
17
+ db.run(
18
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id)
19
+ VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)`,
20
+ [tokenId, `hash_${tokenId}`, USER, NET, `node:${tokenId}`, nodeId],
21
+ );
22
+ }
23
+
24
+ beforeEach(() => {
25
+ db.run("DELETE FROM nodes WHERE network_id = ?1", NET);
26
+ db.run("DELETE FROM api_tokens WHERE network_id = ?1", NET);
27
+ });
28
+
29
+ describe("peer reply inbox capability provenance", () => {
30
+ test("exact token-bound node retains the capability", () => {
31
+ seedToken("tok_cap_exact", "node_cap_exact");
32
+ const snapshot = trustedConfigSnapshotForNode(
33
+ { model: "m", peer_reply_inbox_capable: true },
34
+ "tok_cap_exact",
35
+ "node_cap_exact",
36
+ ) as any;
37
+ expect(snapshot.peer_reply_inbox_capable).toBe(true);
38
+ });
39
+
40
+ test("legacy unbound and wrong-node tokens cannot advertise it", () => {
41
+ seedToken("tok_cap_legacy", null);
42
+ seedToken("tok_cap_wrong", "node_other");
43
+ for (const tokenId of ["tok_cap_legacy", "tok_cap_wrong", null]) {
44
+ const snapshot = trustedConfigSnapshotForNode(
45
+ { model: "m", peer_reply_inbox_capable: true },
46
+ tokenId,
47
+ "node_target",
48
+ ) as any;
49
+ expect(snapshot.peer_reply_inbox_capable).toBeUndefined();
50
+ expect(snapshot.model).toBe("m");
51
+ }
52
+ });
53
+
54
+ test("stored node snapshot is stripped for an unbound legacy token", () => {
55
+ seedToken("tok_cap_store_legacy", null);
56
+ expect(upsertNodeWithSec1Guard({
57
+ node_id: "node_cap_store_legacy",
58
+ callerNetworkId: NET,
59
+ callerUserId: USER,
60
+ callerTokenId: "tok_cap_store_legacy",
61
+ alias: "legacy-cap-node",
62
+ config_snapshot: { peer_reply_inbox_capable: true, model: "m" },
63
+ }).result).toBe("inserted");
64
+ const row = db.get<{ config_snapshot: string }>(
65
+ "SELECT config_snapshot FROM nodes WHERE node_id = ?1",
66
+ "node_cap_store_legacy",
67
+ );
68
+ expect(JSON.parse(row!.config_snapshot).peer_reply_inbox_capable).toBeUndefined();
69
+ });
70
+ });