@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,720 @@
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
+ // RFC-027 PR1 — handler-driven tests for stop_node, delete_node,
7
+ // get_stop_request, ack_stop_request. Drives the MCP handlers via
8
+ // registerTools() so the wired auth context is real (not the
9
+ // mirror-SQL pattern that PR2 v1 used and which let SEC-1 leak slip
10
+ // through — per team rule: independent fork-review catches wired-wrong even when unit tests pass).
11
+ //
12
+ // Covers RFC-027 P1 e2e plan §5.2 scenarios A/B/C/D/E/F at the
13
+ // handler layer:
14
+ // A — stop happy path
15
+ // B — stop + in-flight (default refuse)
16
+ // C — stop + in-flight + force=true → audit row written
17
+ // D — delete happy path
18
+ // E — cross-tenant SEC-1 refusal
19
+ // F — delete daemon via delete_node refused (D6 gate)
20
+ // Plus state machine + confirm_alias + ack finalize transitions.
21
+
22
+ const NET_A = "net_sd_alpha";
23
+ const NET_B = "net_sd_beta";
24
+ const USER_A_ID = "u_sd_alice";
25
+ const USER_B_ID = "u_sd_bob";
26
+ const DAEMON_A_ID = "node_sd_daemon_a";
27
+ const DAEMON_A_ALIAS = "sd-daemon-a";
28
+ const DAEMON_A_TOK = "tok_sd_daemon_a";
29
+ const CHILD_A_ID = "node_sd_child_a";
30
+ const CHILD_A_ALIAS = "sd-child-a";
31
+ const CHILD_TOK_A_ID = "tok_sd_child_a";
32
+
33
+ interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
34
+ interface Reply { ok?: boolean; error?: string; [k: string]: unknown; }
35
+
36
+ function cleanup() {
37
+ for (const n of [NET_A, NET_B]) {
38
+ try { db.run("DELETE FROM node_stop_requests WHERE network_id = ?1", [n]); } catch {}
39
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [n]); } catch {}
40
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [n]); } catch {}
41
+ // PR1.2a restart_node tests leave node_config_updates rows; clear
42
+ // so the next test's restart_node doesn't see them as in_flight.
43
+ try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [n]); } catch {}
44
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
45
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
46
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
47
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [n]); } catch {}
48
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [n]); } catch {}
49
+ }
50
+ // SF-5 test plants a NULL-network inbox row keyed by alias — clean
51
+ // by alias too so it doesn't pollute later tests that use the same
52
+ // CHILD_A_ALIAS.
53
+ for (const alias of [CHILD_A_ALIAS, DAEMON_A_ALIAS]) {
54
+ try { db.run("DELETE FROM inbox WHERE session_name = ?1", [alias]); } catch {}
55
+ }
56
+ try { db.run("DELETE FROM users WHERE user_id IN (?1, ?2)", [USER_A_ID, USER_B_ID]); } catch {}
57
+ }
58
+
59
+ beforeEach(cleanup);
60
+ afterAll(cleanup);
61
+
62
+ function seedUser(user_id: string) {
63
+ db.run(
64
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
65
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
66
+ [user_id, user_id],
67
+ );
68
+ }
69
+ function seedNetwork(net_id: string, owner_id: string) {
70
+ db.run(
71
+ `INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
72
+ VALUES (?1, ?2, ?3, datetime('now'))`,
73
+ [net_id, net_id, owner_id],
74
+ );
75
+ }
76
+ function seedMembership(user_id: string, net_id: string, role: "owner" | "admin" | "member" | "viewer" = "admin") {
77
+ db.run(
78
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
79
+ VALUES (?1, ?2, ?3, datetime('now'))`,
80
+ [user_id, net_id, role],
81
+ );
82
+ }
83
+ function seedDaemon(net: string, node_id: string, alias: string, user_id: string, tokenId: string, role = "host_supervisor") {
84
+ db.run(
85
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
86
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), 'active')`,
87
+ [node_id, alias, alias, net, JSON.stringify({ role, daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] } }), `host-${alias}`],
88
+ );
89
+ db.run(
90
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
91
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
92
+ [tokenId, user_id, net, `node:${alias}`, `hash_${tokenId}`],
93
+ );
94
+ }
95
+ function seedChild(net: string, node_id: string, alias: string, user_id: string, tokenId: string, opts: { lifecycle_state?: string; role?: string } = {}) {
96
+ db.run(
97
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
98
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), ?7)`,
99
+ [node_id, alias, alias, net, JSON.stringify({ role: opts.role ?? "member" }), `host-${alias}`, opts.lifecycle_state ?? "active"],
100
+ );
101
+ db.run(
102
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
103
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
104
+ [tokenId, user_id, net, `node:${alias}`, `hash_${tokenId}`],
105
+ );
106
+ }
107
+ function seedInbox(net: string, alias: string, n: number) {
108
+ for (let i = 0; i < n; i++) {
109
+ db.run(
110
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id, acked)
111
+ VALUES (?1, ?2, 'task', 'normal', ?3, 'caller', ?4, 0)`,
112
+ [`inbox_${alias}_${i}_${Date.now()}`, alias, `task ${i}`, net],
113
+ );
114
+ }
115
+ }
116
+
117
+ function buildHandlers(
118
+ enforceUserId: string | null,
119
+ daemonTokenBound = false,
120
+ callerTokenId: string | null = null,
121
+ daemonNetId: string | null = null,
122
+ ): Record<string, ToolHandler> {
123
+ const server = new McpServer({ name: "test", version: "0" }) as any;
124
+ const tools: Record<string, ToolHandler> = {};
125
+ const origTool = server.tool.bind(server);
126
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
127
+ tools[name] = handler;
128
+ return origTool(name, _desc, _schema, handler);
129
+ };
130
+ const origRegisterTool = server.registerTool?.bind(server);
131
+ if (origRegisterTool) {
132
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
133
+ tools[name] = handler;
134
+ return origRegisterTool(name, _cfg, handler);
135
+ };
136
+ }
137
+ registerTools(
138
+ server, undefined,
139
+ /* enforceNetworkId */ daemonTokenBound ? daemonNetId : null,
140
+ /* enforceUserId */ enforceUserId,
141
+ /* callerAlias */ null,
142
+ /* callerTokenIsNetwork */ daemonTokenBound,
143
+ /* callerTokenId */ callerTokenId,
144
+ );
145
+ return tools;
146
+ }
147
+
148
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
149
+ const r = await handler(args);
150
+ return JSON.parse(r.content[0].text) as Reply;
151
+ }
152
+
153
+ function readNode(node_id: string) {
154
+ return db.get<{ lifecycle_state: string | null; alias: string }>(
155
+ `SELECT lifecycle_state, alias FROM nodes WHERE node_id = ?1`, node_id,
156
+ );
157
+ }
158
+ function readToken(tokenId: string) {
159
+ return db.get<{ revoked_at: string | null }>(
160
+ `SELECT revoked_at FROM api_tokens WHERE token_id = ?1`, tokenId,
161
+ );
162
+ }
163
+ function readRequest(request_id: string) {
164
+ return db.get<{ status: string; action: string; backup_path: string | null; exit_signal: string | null }>(
165
+ `SELECT status, action, backup_path, exit_signal FROM node_stop_requests WHERE request_id = ?1`,
166
+ request_id,
167
+ );
168
+ }
169
+ function readAudit(action: string, network_id: string) {
170
+ return db.all<{ action: string; target_id: string; detail: string }>(
171
+ `SELECT action, target_id, detail FROM audit_log WHERE action = ?1 AND network_id = ?2 ORDER BY id DESC`,
172
+ [action, network_id],
173
+ );
174
+ }
175
+
176
+ function setupAlphaNetwork() {
177
+ seedUser(USER_A_ID);
178
+ seedNetwork(NET_A, USER_A_ID);
179
+ seedMembership(USER_A_ID, NET_A, "admin");
180
+ seedDaemon(NET_A, DAEMON_A_ID, DAEMON_A_ALIAS, USER_A_ID, DAEMON_A_TOK);
181
+ seedChild(NET_A, CHILD_A_ID, CHILD_A_ALIAS, USER_A_ID, CHILD_TOK_A_ID);
182
+ }
183
+
184
+ // ── stop_node ──────────────────────────────────────────────────────
185
+
186
+ describe("stop_node — happy path (RFC-027 §5.2 A)", () => {
187
+ test("active child + no in-flight → dispatch + state→stopping", async () => {
188
+ setupAlphaNetwork();
189
+ const tools = buildHandlers(USER_A_ID);
190
+ const r = await call(tools.stop_node, {
191
+ child_node_id: CHILD_A_ID,
192
+ daemon_node_id: DAEMON_A_ID,
193
+ network_id: NET_A,
194
+ });
195
+ expect(r.ok).toBe(true);
196
+ expect(r.action).toBe("stop");
197
+ expect(r.lifecycle_state).toBe("stopping");
198
+ expect(r.in_flight_at_dispatch).toBe(0);
199
+ expect(typeof r.request_id).toBe("string");
200
+ expect((r.request_id as string).startsWith("sr_")).toBe(true);
201
+
202
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
203
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(1);
204
+ });
205
+ });
206
+
207
+ describe("stop_node — in-flight default-refuse (RFC-027 §5.2 B)", () => {
208
+ test("inbox has 2 unacked tasks → error=node_busy_in_flight, count surfaced", async () => {
209
+ setupAlphaNetwork();
210
+ seedInbox(NET_A, CHILD_A_ALIAS, 2);
211
+ const tools = buildHandlers(USER_A_ID);
212
+ const r = await call(tools.stop_node, {
213
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
214
+ });
215
+ expect(r.ok).toBe(false);
216
+ expect(r.error).toBe("node_busy_in_flight");
217
+ expect(r.in_flight_count).toBe(2);
218
+ // state untouched
219
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
220
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
221
+ });
222
+ });
223
+
224
+ describe("stop_node — force=true with in-flight (RFC-027 §5.2 C)", () => {
225
+ test("force overrides + forced_stop_with_in_flight audit row written", async () => {
226
+ setupAlphaNetwork();
227
+ seedInbox(NET_A, CHILD_A_ALIAS, 3);
228
+ const tools = buildHandlers(USER_A_ID);
229
+ const r = await call(tools.stop_node, {
230
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
231
+ force: true,
232
+ });
233
+ expect(r.ok).toBe(true);
234
+ expect(r.in_flight_at_dispatch).toBe(3);
235
+ const audits = readAudit("forced_stop_with_in_flight", NET_A);
236
+ expect(audits.length).toBe(1);
237
+ expect(JSON.parse(audits[0].detail).in_flight_count).toBe(3);
238
+ });
239
+ });
240
+
241
+ describe("stop_node — state machine guards", () => {
242
+ test("stop on already-stopped node → node_not_active", async () => {
243
+ setupAlphaNetwork();
244
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]);
245
+ const tools = buildHandlers(USER_A_ID);
246
+ const r = await call(tools.stop_node, {
247
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
248
+ });
249
+ expect(r.ok).toBe(false);
250
+ expect(r.error).toBe("node_not_active");
251
+ expect(r.current_state).toBe("stopped");
252
+ });
253
+
254
+ test("stop on already-stopping node → node_already_stopping", async () => {
255
+ setupAlphaNetwork();
256
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopping' WHERE node_id = ?1`, [CHILD_A_ID]);
257
+ const tools = buildHandlers(USER_A_ID);
258
+ const r = await call(tools.stop_node, {
259
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
260
+ });
261
+ expect(r.ok).toBe(false);
262
+ expect(r.error).toBe("node_already_stopping");
263
+ });
264
+ });
265
+
266
+ // ── delete_node ────────────────────────────────────────────────────
267
+
268
+ describe("delete_node — happy path (RFC-027 §5.2 D)", () => {
269
+ test("active child + correct confirm_alias → dispatch + state→deleting", async () => {
270
+ setupAlphaNetwork();
271
+ const tools = buildHandlers(USER_A_ID);
272
+ const r = await call(tools.delete_node, {
273
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
274
+ confirm_alias: CHILD_A_ALIAS,
275
+ });
276
+ expect(r.ok).toBe(true);
277
+ expect(r.action).toBe("delete");
278
+ expect(r.lifecycle_state).toBe("deleting");
279
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("deleting");
280
+ expect(readAudit("delete_node_dispatched", NET_A).length).toBe(1);
281
+ });
282
+ });
283
+
284
+ describe("delete_node — confirm_alias gate", () => {
285
+ test("wrong alias input → confirm_alias_mismatch", async () => {
286
+ setupAlphaNetwork();
287
+ const tools = buildHandlers(USER_A_ID);
288
+ const r = await call(tools.delete_node, {
289
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
290
+ confirm_alias: "wrong-alias",
291
+ });
292
+ expect(r.ok).toBe(false);
293
+ expect(r.error).toBe("confirm_alias_mismatch");
294
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
295
+ });
296
+ });
297
+
298
+ describe("delete_node — D6 daemon-role-gate (RFC-027 §5.2 F)", () => {
299
+ test("delete targeting host_supervisor daemon → cannot_delete_daemon_via_delete_node", async () => {
300
+ setupAlphaNetwork();
301
+ const tools = buildHandlers(USER_A_ID);
302
+ const r = await call(tools.delete_node, {
303
+ child_node_id: DAEMON_A_ID, // targeting the daemon itself
304
+ daemon_node_id: DAEMON_A_ID,
305
+ network_id: NET_A,
306
+ confirm_alias: DAEMON_A_ALIAS,
307
+ });
308
+ expect(r.ok).toBe(false);
309
+ expect(r.error).toBe("cannot_delete_daemon_via_delete_node");
310
+ // Daemon row untouched.
311
+ expect(readNode(DAEMON_A_ID)?.lifecycle_state).toBe("active");
312
+ });
313
+ });
314
+
315
+ // ── SEC-1 cross-tenant ─────────────────────────────────────────────
316
+
317
+ describe("stop_node / delete_node — SEC-1 cross-tenant (RFC-027 §5.2 E)", () => {
318
+ test("user-B targeting user-A's child → forbidden_cross_tenant; netA node untouched", async () => {
319
+ setupAlphaNetwork();
320
+ // also seed user B in own network
321
+ seedUser(USER_B_ID);
322
+ seedNetwork(NET_B, USER_B_ID);
323
+ seedMembership(USER_B_ID, NET_B, "admin");
324
+
325
+ const tools = buildHandlers(USER_B_ID);
326
+ const r = await call(tools.stop_node, {
327
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID,
328
+ network_id: NET_A, // user-B claiming netA scope
329
+ });
330
+ expect(r.ok).toBe(false);
331
+ expect(r.error).toMatch(/forbidden_cross_tenant|access denied/i);
332
+ // netA child unaffected
333
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
334
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
335
+ });
336
+
337
+ test("user-B with no network_id arg still can't see/touch netA child", async () => {
338
+ setupAlphaNetwork();
339
+ seedUser(USER_B_ID);
340
+ seedNetwork(NET_B, USER_B_ID);
341
+ seedMembership(USER_B_ID, NET_B, "admin");
342
+
343
+ const tools = buildHandlers(USER_B_ID);
344
+ const r = await call(tools.delete_node, {
345
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID,
346
+ confirm_alias: CHILD_A_ALIAS,
347
+ });
348
+ expect(r.ok).toBe(false);
349
+ expect(r.error).toMatch(/forbidden_cross_tenant/i);
350
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
351
+ });
352
+ });
353
+
354
+ // ── get_stop_request + ack_stop_request (daemon-side flow) ─────────
355
+
356
+ describe("get_stop_request + ack_stop_request — daemon flow", () => {
357
+ test("daemon pulls request → ack 'stopped' → state machine finalizes (stop branch)", async () => {
358
+ setupAlphaNetwork();
359
+ const userTools = buildHandlers(USER_A_ID);
360
+ const dispatch = await call(userTools.stop_node, {
361
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
362
+ });
363
+ expect(dispatch.ok).toBe(true);
364
+ const requestId = dispatch.request_id as string;
365
+
366
+ // daemon perspective: token-bound to its own ntok
367
+ const daemonTools = buildHandlers(USER_A_ID, /*tokenBound*/ true, DAEMON_A_TOK, NET_A);
368
+ const got = await call(daemonTools.get_stop_request, { request_id: requestId });
369
+ expect(got.ok).toBe(true);
370
+ expect(got.action).toBe("stop");
371
+ expect(got.child_node_id).toBe(CHILD_A_ID);
372
+ expect(got.child_alias).toBe(CHILD_A_ALIAS);
373
+ expect(readRequest(requestId)?.status).toBe("delivered");
374
+
375
+ const acked = await call(daemonTools.ack_stop_request, {
376
+ request_id: requestId, status: "stopped", exit_signal: "SIGTERM",
377
+ });
378
+ expect(acked.ok).toBe(true);
379
+ expect(readRequest(requestId)?.status).toBe("stopped");
380
+ expect(readRequest(requestId)?.exit_signal).toBe("SIGTERM");
381
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopped");
382
+ expect(readAudit("stop_node_completed", NET_A).length).toBe(1);
383
+ // ntok must NOT be revoked on stop (reversible).
384
+ expect(readToken(CHILD_TOK_A_ID)?.revoked_at).toBeNull();
385
+ });
386
+
387
+ test("daemon ack 'stopped' on delete branch → ntok revoked + node row DELETEd", async () => {
388
+ setupAlphaNetwork();
389
+ const userTools = buildHandlers(USER_A_ID);
390
+ const dispatch = await call(userTools.delete_node, {
391
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
392
+ confirm_alias: CHILD_A_ALIAS,
393
+ });
394
+ expect(dispatch.ok).toBe(true);
395
+ const requestId = dispatch.request_id as string;
396
+
397
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
398
+ const acked = await call(daemonTools.ack_stop_request, {
399
+ request_id: requestId, status: "stopped", exit_signal: "SIGTERM",
400
+ backup_path: "/home/u/.anet/deleted/1719647200000-sd-child-a",
401
+ });
402
+ expect(acked.ok).toBe(true);
403
+ // Row gone, ntok revoked, audit row written
404
+ expect(readNode(CHILD_A_ID) ?? null).toBeNull();
405
+ expect(readToken(CHILD_TOK_A_ID)?.revoked_at).not.toBeNull();
406
+ const audits = readAudit("delete_node_completed", NET_A);
407
+ expect(audits.length).toBe(1);
408
+ expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a");
409
+ });
410
+
411
+ test("daemon ack 'stop_failed' → lifecycle_state=stop_failed, request error captured", async () => {
412
+ setupAlphaNetwork();
413
+ const userTools = buildHandlers(USER_A_ID);
414
+ const dispatch = await call(userTools.stop_node, {
415
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
416
+ });
417
+ const requestId = dispatch.request_id as string;
418
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
419
+ await call(daemonTools.ack_stop_request, {
420
+ request_id: requestId, status: "stop_failed", error: "SIGKILL also failed",
421
+ });
422
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stop_failed");
423
+ expect(readRequest(requestId)?.status).toBe("stop_failed");
424
+ });
425
+
426
+ test("daemon B can't ack daemon A's request → not_your_request", async () => {
427
+ setupAlphaNetwork();
428
+ const userTools = buildHandlers(USER_A_ID);
429
+ const dispatch = await call(userTools.stop_node, {
430
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
431
+ });
432
+ const requestId = dispatch.request_id as string;
433
+ // Seed an unrelated daemon B in netA with its own token.
434
+ db.run(
435
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
436
+ VALUES ('node_other_daemon', 'other-daemon', 'other-daemon', ?1, '{}', 'h', datetime('now'), datetime('now'), 'active')`,
437
+ [NET_A],
438
+ );
439
+ db.run(
440
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
441
+ VALUES ('tok_other_daemon', ?1, ?2, 'network', 'node:other-daemon', 'hash_other', NULL, NULL)`,
442
+ [USER_A_ID, NET_A],
443
+ );
444
+ const otherDaemonTools = buildHandlers(USER_A_ID, true, "tok_other_daemon", NET_A);
445
+ const r = await call(otherDaemonTools.ack_stop_request, {
446
+ request_id: requestId, status: "stopped",
447
+ });
448
+ expect(r.ok).toBe(false);
449
+ expect(r.error).toBe("not_your_request");
450
+ });
451
+ });
452
+
453
+ // ── PR1 v3: 通信龙 #345 deep-review fixes ──────────────────────────
454
+
455
+ describe("SF-2 — audit in tx propagates failure (D8 atomicity真)", () => {
456
+ test("forced audit row written alongside dispatch in same tx (rollback if either fails)", async () => {
457
+ // We can't easily simulate audit_log INSERT failure without
458
+ // schema munging; the atomicity guarantee is structural (BEGIN..
459
+ // COMMIT + auditCreateNodeStrict re-throws). Pin the invariant
460
+ // observable to ops: the dispatch + forced audit both appear OR
461
+ // both are absent. Happy path here; a true failure-injection
462
+ // belongs in PR1.1's docker e2e where we can drop audit_log
463
+ // permissions / corrupt the row.
464
+ setupAlphaNetwork();
465
+ seedInbox(NET_A, CHILD_A_ALIAS, 1);
466
+ const tools = buildHandlers(USER_A_ID);
467
+ const r = await call(tools.stop_node, {
468
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
469
+ force: true,
470
+ });
471
+ expect(r.ok).toBe(true);
472
+ // Both audit rows present.
473
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(1);
474
+ expect(readAudit("forced_stop_with_in_flight", NET_A).length).toBe(1);
475
+ // node state moved
476
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
477
+ });
478
+ });
479
+
480
+ describe("SF-4 — D6 fail-CLOSED on ambiguous role", () => {
481
+ test("corrupt config_snapshot + node referenced as daemon → still refused", async () => {
482
+ setupAlphaNetwork();
483
+ // Wreck the daemon's snapshot but leave the row + create-request
484
+ // history. delete_node MUST still refuse — corrupt snapshot was the
485
+ // SF-4 fail-open path.
486
+ db.run(
487
+ `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
488
+ ["not-valid-json{{{", DAEMON_A_ID],
489
+ );
490
+ // Plant a node_create_request that points to DAEMON_A as daemon,
491
+ // simulating that this daemon has handed out children before.
492
+ db.run(
493
+ `INSERT INTO node_create_requests
494
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
495
+ VALUES ('cr_sf4_evidence', ?1, 'past-child', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'succeeded', NULL, ?3, 'tok_test')`,
496
+ [DAEMON_A_ID, NET_A, Date.now()],
497
+ );
498
+ const tools = buildHandlers(USER_A_ID);
499
+ const r = await call(tools.delete_node, {
500
+ child_node_id: DAEMON_A_ID,
501
+ daemon_node_id: DAEMON_A_ID,
502
+ network_id: NET_A,
503
+ confirm_alias: DAEMON_A_ALIAS,
504
+ });
505
+ expect(r.ok).toBe(false);
506
+ expect(r.error).toBe("cannot_delete_daemon_via_delete_node");
507
+ expect(readNode(DAEMON_A_ID)?.lifecycle_state).toBe("active");
508
+ });
509
+
510
+ test("corrupt snapshot + NO daemon evidence → can be deleted (no FP)", async () => {
511
+ // A plain child whose snapshot got mangled mustn't be refused.
512
+ // Simulates corruption on a regular member node.
513
+ setupAlphaNetwork();
514
+ db.run(
515
+ `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
516
+ ["{broken", CHILD_A_ID],
517
+ );
518
+ const tools = buildHandlers(USER_A_ID);
519
+ const r = await call(tools.delete_node, {
520
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
521
+ confirm_alias: CHILD_A_ALIAS,
522
+ });
523
+ expect(r.ok).toBe(true);
524
+ expect(r.action).toBe("delete");
525
+ });
526
+ });
527
+
528
+ describe("SF-5 — in-flight COALESCE for legacy inbox rows", () => {
529
+ test("inbox row with network_id=NULL but session_name matches → counted", async () => {
530
+ setupAlphaNetwork();
531
+ // Legacy-shape inbox row: session_name set, network_id NULL.
532
+ db.run(
533
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id, acked)
534
+ VALUES (?1, ?2, 'task', 'normal', 'legacy-task', 'caller', NULL, 0)`,
535
+ [`inbox_legacy_${Date.now()}`, CHILD_A_ALIAS],
536
+ );
537
+ const tools = buildHandlers(USER_A_ID);
538
+ const r = await call(tools.stop_node, {
539
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
540
+ });
541
+ expect(r.ok).toBe(false);
542
+ expect(r.error).toBe("node_busy_in_flight");
543
+ expect(r.in_flight_count).toBeGreaterThanOrEqual(1);
544
+ });
545
+ });
546
+
547
+ // ─── RFC-027 PR1.1 — D8 atomicity failure-injection (real teeth) ──
548
+ //
549
+ // PR1's SF-2 fix introduced auditCreateNodeStrict (re-throws inside tx
550
+ // callback). The PR1 v3 test only verified happy path: both rows
551
+ // present after success. 通信龙's #345 ack flagged that as a shape-pin
552
+ // — could pass even if the strict variant were silently reverted. This
553
+ // PR1.1 test injects a real failure (monkey-patch db.run to throw at
554
+ // the audit_log INSERT moment) and asserts BOTH rollback halves:
555
+ // 1. nodes.lifecycle_state UNCHANGED (didn't transition to 'stopping')
556
+ // 2. node_stop_requests row absent (INSERT rolled back too)
557
+ describe("SF-2 D8 atomicity — REAL failure injection forces ROLLBACK", () => {
558
+ test("audit_log table dropped → audit INSERT throws inside tx → ROLLBACK leaves nodes + request untouched", async () => {
559
+ setupAlphaNetwork();
560
+ const tools = buildHandlers(USER_A_ID);
561
+ // Real failure injection: drop audit_log so the strict audit
562
+ // INSERT inside the tx throws a real SQLite "no such table"
563
+ // error. The whole tx must roll back per §4.5 D8. (Patching
564
+ // adapter.db.run via property overwrite proved unreliable under
565
+ // bun:sqlite's transaction wrapper in earlier draft; dropping
566
+ // the table is the simplest way to make the failure REAL — the
567
+ // error originates inside the SQL engine, not a JS mock.)
568
+ db.exec("DROP TABLE IF EXISTS audit_log_BACKUP");
569
+ db.exec("ALTER TABLE audit_log RENAME TO audit_log_BACKUP");
570
+ let r: Reply;
571
+ try {
572
+ r = await call(tools.stop_node, {
573
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
574
+ });
575
+ } finally {
576
+ // Restore so subsequent tests + cleanup work.
577
+ db.exec("DROP TABLE IF EXISTS audit_log");
578
+ db.exec("ALTER TABLE audit_log_BACKUP RENAME TO audit_log");
579
+ }
580
+ expect(r.ok).toBe(false);
581
+ // dispatch_tx_failed is the expected error. Print the actual reply
582
+ // if we got something else so the failure mode is diagnosable.
583
+ if (r.error !== "dispatch_tx_failed") {
584
+ console.error("[SF-2 unexpected reply]", JSON.stringify(r));
585
+ }
586
+ expect(r.error).toBe("dispatch_tx_failed");
587
+
588
+ // ROLLBACK observable:
589
+ // (a) lifecycle_state did NOT transition out of 'active'
590
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
591
+ // (b) no node_stop_requests row exists for this child
592
+ const reqs = db.all<{ request_id: string }>(
593
+ `SELECT request_id FROM node_stop_requests WHERE child_node_id = ?1`, [CHILD_A_ID],
594
+ );
595
+ expect(reqs.length).toBe(0);
596
+ // (c) no audit row was written
597
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
598
+ });
599
+ });
600
+
601
+ // ─── RFC-027 PR1.1 — inbox-enqueue lifecycle guard at routing layer ──
602
+ //
603
+ // 6 inbox INSERT sites in tools.ts (send_task / send_message / reply /
604
+ // retry_task / reassign_task / broadcast) now pre-check the target
605
+ // alias's nodes.lifecycle_state. Anything other than `active` refuses
606
+ // with `node_not_active`. Sample the highest-traffic path (send_task)
607
+ // for a stopping target — this catches the routing-after-SIGTERM
608
+ // window that RFC §2.3 explicitly closes.
609
+ describe("inbox-enqueue lifecycle_state guard (RFC-027 §2.3 race-free)", () => {
610
+ test("send_task to a stopping node → refused with node_not_active", async () => {
611
+ setupAlphaNetwork();
612
+ // Caller + target sessions rows (send_task's resolveDeliveryTarget
613
+ // looks up the target in sessions, not just nodes).
614
+ db.run(
615
+ `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
616
+ VALUES (?1, ?2, ?3, datetime('now'), 'idle', 4, 8, '10.0.0.99')`,
617
+ [`s_caller_${Date.now()}`, "sender-x", NET_A],
618
+ );
619
+ db.run(
620
+ `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
621
+ VALUES (?1, ?2, ?3, datetime('now'), 'idle', 4, 8, '10.0.0.5')`,
622
+ [`s_child_${Date.now()}`, CHILD_A_ALIAS, NET_A],
623
+ );
624
+ // Move target into stopping (mid-flight stop_node race window).
625
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopping' WHERE node_id = ?1`, [CHILD_A_ID]);
626
+
627
+ const tools = buildHandlers(USER_A_ID);
628
+ const r = await call(tools.send_task, {
629
+ alias: CHILD_A_ALIAS,
630
+ task: "racey post-SIGTERM task",
631
+ from_session: "sender-x",
632
+ network_id: NET_A,
633
+ });
634
+ expect(r.ok).toBe(false);
635
+ expect(r.error).toBe("node_not_active");
636
+ expect(r.lifecycle_state).toBe("stopping");
637
+ // Inbox unchanged (no row inserted for this alias post-stopping).
638
+ const inboxRows = db.all<{ id: string }>(
639
+ `SELECT id FROM inbox WHERE session_name = ?1 AND network_id = ?2`,
640
+ [CHILD_A_ALIAS, NET_A],
641
+ );
642
+ expect(inboxRows.length).toBe(0);
643
+ });
644
+ });
645
+
646
+ // ─── list_my_children (PR1.1 hub support for rebuild) ────────────
647
+ describe("list_my_children HANDLER (RFC-027 PR1.1)", () => {
648
+ test("daemon-bound caller gets its children list", async () => {
649
+ setupAlphaNetwork();
650
+ // Plant a create-request row simulating that DAEMON_A has spawned
651
+ // CHILD_A previously (the daemon's child must be in this table).
652
+ db.run(
653
+ `INSERT INTO node_create_requests
654
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
655
+ VALUES ('cr_lmc_test', ?1, ?2, ?3, 'claude-agent-sdk', 'x', '{}', '[]', 'succeeded', NULL, ?4, 'tok_test')`,
656
+ [DAEMON_A_ID, CHILD_A_ALIAS, NET_A, Date.now()],
657
+ );
658
+ // The child's nodes row must exist (setupAlphaNetwork seeds it),
659
+ // and the canonical child_node_id needs to match `node_${request_id.slice(3)}`
660
+ // We update the existing child row's id so the LEFT JOIN matches.
661
+ db.run(`UPDATE nodes SET node_id = 'node_lmc_test' WHERE alias = ?1 AND network_id = ?2`, [CHILD_A_ALIAS, NET_A]);
662
+
663
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
664
+ const r = await call(daemonTools.list_my_children, {});
665
+ expect(r.ok).toBe(true);
666
+ expect(Array.isArray(r.children)).toBe(true);
667
+ const aliases = (r.children as Array<{ alias: string }>).map(c => c.alias);
668
+ expect(aliases).toContain(CHILD_A_ALIAS);
669
+ });
670
+
671
+ test("non-daemon caller (utok) refused", async () => {
672
+ setupAlphaNetwork();
673
+ const userTools = buildHandlers(USER_A_ID); // utok, not daemon-bound
674
+ const r = await call(userTools.list_my_children, {});
675
+ expect(r.ok).toBe(false);
676
+ });
677
+ });
678
+
679
+ // ─── PR1.2a — restart_node resets lifecycle_state ──────────────────
680
+ //
681
+ // 通信龙 #346 ack latent: without this, a stopped node restart-ed via
682
+ // restart_node would still have lifecycle_state='stopped' in the
683
+ // nodes table → the 6 MCP + 2 REST inbox guards would refuse all
684
+ // routing → node silently unreachable until manual DB fix. Asserts
685
+ // the dispatch + state flip happen atomically (tx).
686
+ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", () => {
687
+ test("restart_node on a stopped node → lifecycle_state flips back to 'active' + dispatch row created", async () => {
688
+ setupAlphaNetwork();
689
+ // Move the child to 'stopped' (simulating prior stop_node completion).
690
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]);
691
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopped");
692
+
693
+ const tools = buildHandlers(USER_A_ID);
694
+ const r = await call(tools.restart_node, {
695
+ node_id: CHILD_A_ID,
696
+ network_id: NET_A,
697
+ });
698
+ expect(r.ok).toBe(true);
699
+ expect(r.apply_mode).toBe("restart_only");
700
+
701
+ // State flipped back.
702
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
703
+ // node_config_updates row exists (the dispatch).
704
+ const rows = db.all<{ update_id: string; status: string }>(
705
+ `SELECT update_id, status FROM node_config_updates WHERE node_id = ?1 ORDER BY created_at DESC LIMIT 1`,
706
+ [CHILD_A_ID],
707
+ );
708
+ expect(rows.length).toBe(1);
709
+ expect(rows[0].status).toBe("pending");
710
+ });
711
+
712
+ test("restart_node on an active node → still resets to active (idempotent, no-op state-wise)", async () => {
713
+ setupAlphaNetwork();
714
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
715
+ const tools = buildHandlers(USER_A_ID);
716
+ const r = await call(tools.restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
717
+ expect(r.ok).toBe(true);
718
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
719
+ });
720
+ });