@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,952 @@
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 node_create_requests WHERE network_id = ?1", [n]); } catch {}
40
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [n]); } catch {}
41
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [n]); } catch {}
42
+ // PR1.2a restart_node tests leave node_config_updates rows; clear
43
+ // so the next test's restart_node doesn't see them as in_flight.
44
+ try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [n]); } catch {}
45
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
46
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
47
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
48
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [n]); } catch {}
49
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [n]); } catch {}
50
+ }
51
+ // SF-5 test plants a NULL-network inbox row keyed by alias — clean
52
+ // by alias too so it doesn't pollute later tests that use the same
53
+ // CHILD_A_ALIAS.
54
+ for (const alias of [CHILD_A_ALIAS, DAEMON_A_ALIAS]) {
55
+ try { db.run("DELETE FROM inbox WHERE session_name = ?1", [alias]); } catch {}
56
+ }
57
+ try { db.run("DELETE FROM users WHERE user_id IN (?1, ?2)", [USER_A_ID, USER_B_ID]); } catch {}
58
+ }
59
+
60
+ beforeEach(cleanup);
61
+ afterAll(cleanup);
62
+
63
+ function seedUser(user_id: string) {
64
+ db.run(
65
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
66
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
67
+ [user_id, user_id],
68
+ );
69
+ }
70
+ function seedNetwork(net_id: string, owner_id: string) {
71
+ db.run(
72
+ `INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
73
+ VALUES (?1, ?2, ?3, datetime('now'))`,
74
+ [net_id, net_id, owner_id],
75
+ );
76
+ }
77
+ function seedMembership(user_id: string, net_id: string, role: "owner" | "admin" | "member" | "viewer" = "admin") {
78
+ db.run(
79
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
80
+ VALUES (?1, ?2, ?3, datetime('now'))`,
81
+ [user_id, net_id, role],
82
+ );
83
+ }
84
+ function seedDaemon(net: string, node_id: string, alias: string, user_id: string, tokenId: string, role = "host_supervisor") {
85
+ db.run(
86
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
87
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), 'active')`,
88
+ [node_id, alias, alias, net, JSON.stringify({ role, daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] } }), `host-${alias}`],
89
+ );
90
+ db.run(
91
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
92
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
93
+ [tokenId, user_id, net, `node:${alias}`, `hash_${tokenId}`],
94
+ );
95
+ }
96
+ function seedChild(net: string, node_id: string, alias: string, user_id: string, tokenId: string, opts: { lifecycle_state?: string; role?: string } = {}) {
97
+ db.run(
98
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
99
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), ?7)`,
100
+ [node_id, alias, alias, net, JSON.stringify({ role: opts.role ?? "member" }), `host-${alias}`, opts.lifecycle_state ?? "active"],
101
+ );
102
+ db.run(
103
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
104
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
105
+ [tokenId, user_id, net, `node:${alias}`, `hash_${tokenId}`],
106
+ );
107
+ }
108
+ function seedInbox(net: string, alias: string, n: number) {
109
+ for (let i = 0; i < n; i++) {
110
+ db.run(
111
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id, acked)
112
+ VALUES (?1, ?2, 'task', 'normal', ?3, 'caller', ?4, 0)`,
113
+ [`inbox_${alias}_${i}_${Date.now()}`, alias, `task ${i}`, net],
114
+ );
115
+ }
116
+ }
117
+
118
+ function buildHandlers(
119
+ enforceUserId: string | null,
120
+ daemonTokenBound = false,
121
+ callerTokenId: string | null = null,
122
+ daemonNetId: string | null = null,
123
+ ): Record<string, ToolHandler> {
124
+ const server = new McpServer({ name: "test", version: "0" }) as any;
125
+ const tools: Record<string, ToolHandler> = {};
126
+ const origTool = server.tool.bind(server);
127
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
128
+ tools[name] = handler;
129
+ return origTool(name, _desc, _schema, handler);
130
+ };
131
+ const origRegisterTool = server.registerTool?.bind(server);
132
+ if (origRegisterTool) {
133
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
134
+ tools[name] = handler;
135
+ return origRegisterTool(name, _cfg, handler);
136
+ };
137
+ }
138
+ registerTools(
139
+ server, undefined,
140
+ /* enforceNetworkId */ daemonTokenBound ? daemonNetId : null,
141
+ /* enforceUserId */ enforceUserId,
142
+ /* callerAlias */ null,
143
+ /* callerTokenIsNetwork */ daemonTokenBound,
144
+ /* callerTokenId */ callerTokenId,
145
+ );
146
+ return tools;
147
+ }
148
+
149
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
150
+ const r = await handler(args);
151
+ return JSON.parse(r.content[0].text) as Reply;
152
+ }
153
+
154
+ function readNode(node_id: string) {
155
+ return db.get<{ lifecycle_state: string | null; alias: string }>(
156
+ `SELECT lifecycle_state, alias FROM nodes WHERE node_id = ?1`, node_id,
157
+ );
158
+ }
159
+ function readToken(tokenId: string) {
160
+ return db.get<{ revoked_at: string | null }>(
161
+ `SELECT revoked_at FROM api_tokens WHERE token_id = ?1`, tokenId,
162
+ );
163
+ }
164
+ function readRequest(request_id: string) {
165
+ return db.get<{ status: string; action: string; backup_path: string | null; exit_signal: string | null }>(
166
+ `SELECT status, action, backup_path, exit_signal FROM node_stop_requests WHERE request_id = ?1`,
167
+ request_id,
168
+ );
169
+ }
170
+ function readAudit(action: string, network_id: string) {
171
+ return db.all<{ action: string; target_id: string; detail: string }>(
172
+ `SELECT action, target_id, detail FROM audit_log WHERE action = ?1 AND network_id = ?2 ORDER BY id DESC`,
173
+ [action, network_id],
174
+ );
175
+ }
176
+
177
+ function setupAlphaNetwork() {
178
+ seedUser(USER_A_ID);
179
+ seedNetwork(NET_A, USER_A_ID);
180
+ seedMembership(USER_A_ID, NET_A, "admin");
181
+ seedDaemon(NET_A, DAEMON_A_ID, DAEMON_A_ALIAS, USER_A_ID, DAEMON_A_TOK);
182
+ seedChild(NET_A, CHILD_A_ID, CHILD_A_ALIAS, USER_A_ID, CHILD_TOK_A_ID);
183
+ }
184
+
185
+ // ── stop_node ──────────────────────────────────────────────────────
186
+
187
+ describe("stop_node — happy path (RFC-027 §5.2 A)", () => {
188
+ test("active child + no in-flight → dispatch + state→stopping", async () => {
189
+ setupAlphaNetwork();
190
+ const tools = buildHandlers(USER_A_ID);
191
+ const r = await call(tools.stop_node, {
192
+ child_node_id: CHILD_A_ID,
193
+ daemon_node_id: DAEMON_A_ID,
194
+ network_id: NET_A,
195
+ });
196
+ expect(r.ok).toBe(true);
197
+ expect(r.action).toBe("stop");
198
+ expect(r.lifecycle_state).toBe("stopping");
199
+ expect(r.in_flight_at_dispatch).toBe(0);
200
+ expect(typeof r.request_id).toBe("string");
201
+ expect((r.request_id as string).startsWith("sr_")).toBe(true);
202
+
203
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
204
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(1);
205
+ });
206
+ });
207
+
208
+ describe("stop_node — in-flight default-refuse (RFC-027 §5.2 B)", () => {
209
+ test("inbox has 2 unacked tasks → error=node_busy_in_flight, count surfaced", async () => {
210
+ setupAlphaNetwork();
211
+ seedInbox(NET_A, CHILD_A_ALIAS, 2);
212
+ const tools = buildHandlers(USER_A_ID);
213
+ const r = await call(tools.stop_node, {
214
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
215
+ });
216
+ expect(r.ok).toBe(false);
217
+ expect(r.error).toBe("node_busy_in_flight");
218
+ expect(r.in_flight_count).toBe(2);
219
+ // state untouched
220
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
221
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
222
+ });
223
+ });
224
+
225
+ describe("stop_node — force=true with in-flight (RFC-027 §5.2 C)", () => {
226
+ test("force overrides + forced_stop_with_in_flight audit row written", async () => {
227
+ setupAlphaNetwork();
228
+ seedInbox(NET_A, CHILD_A_ALIAS, 3);
229
+ const tools = buildHandlers(USER_A_ID);
230
+ const r = await call(tools.stop_node, {
231
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
232
+ force: true,
233
+ });
234
+ expect(r.ok).toBe(true);
235
+ expect(r.in_flight_at_dispatch).toBe(3);
236
+ const audits = readAudit("forced_stop_with_in_flight", NET_A);
237
+ expect(audits.length).toBe(1);
238
+ expect(JSON.parse(audits[0].detail).in_flight_count).toBe(3);
239
+ });
240
+ });
241
+
242
+ describe("stop_node — state machine guards", () => {
243
+ test("stop on already-stopped node → node_not_active", async () => {
244
+ setupAlphaNetwork();
245
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]);
246
+ const tools = buildHandlers(USER_A_ID);
247
+ const r = await call(tools.stop_node, {
248
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
249
+ });
250
+ expect(r.ok).toBe(false);
251
+ expect(r.error).toBe("node_not_active");
252
+ expect(r.current_state).toBe("stopped");
253
+ });
254
+
255
+ test("stop on already-stopping node → node_already_stopping", async () => {
256
+ setupAlphaNetwork();
257
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopping' WHERE node_id = ?1`, [CHILD_A_ID]);
258
+ const tools = buildHandlers(USER_A_ID);
259
+ const r = await call(tools.stop_node, {
260
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
261
+ });
262
+ expect(r.ok).toBe(false);
263
+ expect(r.error).toBe("node_already_stopping");
264
+ });
265
+ });
266
+
267
+ // ── delete_node ────────────────────────────────────────────────────
268
+
269
+ describe("delete_node — happy path (RFC-027 §5.2 D)", () => {
270
+ test("active child + correct confirm_alias → dispatch + state→deleting", async () => {
271
+ setupAlphaNetwork();
272
+ const tools = buildHandlers(USER_A_ID);
273
+ const r = await call(tools.delete_node, {
274
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
275
+ confirm_alias: CHILD_A_ALIAS,
276
+ });
277
+ expect(r.ok).toBe(true);
278
+ expect(r.action).toBe("delete");
279
+ expect(r.lifecycle_state).toBe("deleting");
280
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("deleting");
281
+ expect(readAudit("delete_node_dispatched", NET_A).length).toBe(1);
282
+ });
283
+ });
284
+
285
+ describe("delete_node — confirm_alias gate", () => {
286
+ test("wrong alias input → confirm_alias_mismatch", async () => {
287
+ setupAlphaNetwork();
288
+ const tools = buildHandlers(USER_A_ID);
289
+ const r = await call(tools.delete_node, {
290
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
291
+ confirm_alias: "wrong-alias",
292
+ });
293
+ expect(r.ok).toBe(false);
294
+ expect(r.error).toBe("confirm_alias_mismatch");
295
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
296
+ });
297
+ });
298
+
299
+ describe("delete_node — D6 daemon-role-gate (RFC-027 §5.2 F)", () => {
300
+ test("delete targeting host_supervisor daemon → cannot_delete_daemon_via_delete_node", async () => {
301
+ setupAlphaNetwork();
302
+ const tools = buildHandlers(USER_A_ID);
303
+ const r = await call(tools.delete_node, {
304
+ child_node_id: DAEMON_A_ID, // targeting the daemon itself
305
+ daemon_node_id: DAEMON_A_ID,
306
+ network_id: NET_A,
307
+ confirm_alias: DAEMON_A_ALIAS,
308
+ });
309
+ expect(r.ok).toBe(false);
310
+ expect(r.error).toBe("cannot_delete_daemon_via_delete_node");
311
+ // Daemon row untouched.
312
+ expect(readNode(DAEMON_A_ID)?.lifecycle_state).toBe("active");
313
+ });
314
+ });
315
+
316
+ // ── SEC-1 cross-tenant ─────────────────────────────────────────────
317
+
318
+ describe("stop_node / delete_node — SEC-1 cross-tenant (RFC-027 §5.2 E)", () => {
319
+ test("user-B targeting user-A's child → forbidden_cross_tenant; netA node untouched", async () => {
320
+ setupAlphaNetwork();
321
+ // also seed user B in own network
322
+ seedUser(USER_B_ID);
323
+ seedNetwork(NET_B, USER_B_ID);
324
+ seedMembership(USER_B_ID, NET_B, "admin");
325
+
326
+ const tools = buildHandlers(USER_B_ID);
327
+ const r = await call(tools.stop_node, {
328
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID,
329
+ network_id: NET_A, // user-B claiming netA scope
330
+ });
331
+ expect(r.ok).toBe(false);
332
+ expect(r.error).toMatch(/forbidden_cross_tenant|access denied/i);
333
+ // netA child unaffected
334
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
335
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
336
+ });
337
+
338
+ test("user-B with no network_id arg still can't see/touch netA child", async () => {
339
+ setupAlphaNetwork();
340
+ seedUser(USER_B_ID);
341
+ seedNetwork(NET_B, USER_B_ID);
342
+ seedMembership(USER_B_ID, NET_B, "admin");
343
+
344
+ const tools = buildHandlers(USER_B_ID);
345
+ const r = await call(tools.delete_node, {
346
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID,
347
+ confirm_alias: CHILD_A_ALIAS,
348
+ });
349
+ expect(r.ok).toBe(false);
350
+ expect(r.error).toMatch(/forbidden_cross_tenant/i);
351
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
352
+ });
353
+ });
354
+
355
+ // ── get_stop_request + ack_stop_request (daemon-side flow) ─────────
356
+
357
+ describe("get_stop_request + ack_stop_request — daemon flow", () => {
358
+ test("daemon pulls request → ack 'stopped' → state machine finalizes (stop branch)", async () => {
359
+ setupAlphaNetwork();
360
+ const userTools = buildHandlers(USER_A_ID);
361
+ const dispatch = await call(userTools.stop_node, {
362
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
363
+ });
364
+ expect(dispatch.ok).toBe(true);
365
+ const requestId = dispatch.request_id as string;
366
+
367
+ // daemon perspective: token-bound to its own ntok
368
+ const daemonTools = buildHandlers(USER_A_ID, /*tokenBound*/ true, DAEMON_A_TOK, NET_A);
369
+ const got = await call(daemonTools.get_stop_request, { request_id: requestId });
370
+ expect(got.ok).toBe(true);
371
+ expect(got.action).toBe("stop");
372
+ expect(got.child_node_id).toBe(CHILD_A_ID);
373
+ expect(got.child_alias).toBe(CHILD_A_ALIAS);
374
+ expect(readRequest(requestId)?.status).toBe("delivered");
375
+
376
+ const acked = await call(daemonTools.ack_stop_request, {
377
+ request_id: requestId, status: "stopped", exit_signal: "SIGTERM",
378
+ });
379
+ expect(acked.ok).toBe(true);
380
+ expect(readRequest(requestId)?.status).toBe("stopped");
381
+ expect(readRequest(requestId)?.exit_signal).toBe("SIGTERM");
382
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopped");
383
+ expect(readAudit("stop_node_completed", NET_A).length).toBe(1);
384
+ // ntok must NOT be revoked on stop (reversible).
385
+ expect(readToken(CHILD_TOK_A_ID)?.revoked_at).toBeNull();
386
+ });
387
+
388
+ test("daemon ack 'stopped' on delete branch → ntok revoked + node row DELETEd", async () => {
389
+ setupAlphaNetwork();
390
+ const userTools = buildHandlers(USER_A_ID);
391
+ const dispatch = await call(userTools.delete_node, {
392
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
393
+ confirm_alias: CHILD_A_ALIAS,
394
+ });
395
+ expect(dispatch.ok).toBe(true);
396
+ const requestId = dispatch.request_id as string;
397
+
398
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
399
+ const acked = await call(daemonTools.ack_stop_request, {
400
+ request_id: requestId, status: "stopped", exit_signal: "SIGTERM",
401
+ backup_path: "/home/u/.anet/deleted/1719647200000-sd-child-a",
402
+ });
403
+ expect(acked.ok).toBe(true);
404
+ // Row gone, ntok revoked, audit row written
405
+ expect(readNode(CHILD_A_ID) ?? null).toBeNull();
406
+ expect(readToken(CHILD_TOK_A_ID)?.revoked_at).not.toBeNull();
407
+ const audits = readAudit("delete_node_completed", NET_A);
408
+ expect(audits.length).toBe(1);
409
+ expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a");
410
+ });
411
+
412
+ // #1448 finding-4 — stop/delete 必须校验 daemon 是 child 的权威创建者(镜像 start)。
413
+ // 改前只校验 daemon 存在+同网 → 同网调用方传错 daemon_node_id,门铃路由到错 daemon。
414
+ // f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
415
+ test("#1448 finding-4: stop/delete reject a wrong same-network daemon_node_id (daemon_child_mismatch)", async () => {
416
+ setupAlphaNetwork();
417
+ // authority: CHILD_A 由 DAEMON_A 创建(resolveDaemonForChild 读 node_create_requests)
418
+ db.run(
419
+ `INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
420
+ VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
421
+ [`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
422
+ );
423
+ // 一个同网、存在、但没创建 CHILD_A 的 daemon
424
+ const DAEMON_B = "node_sd_daemon_b";
425
+ seedDaemon(NET_A, DAEMON_B, "sd-daemon-b", USER_A_ID, "tok_sd_daemon_b");
426
+ const u = buildHandlers(USER_A_ID);
427
+ // 错 daemon → mismatch(stop + delete 都拦)。改前会放行、路由到错 daemon。
428
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, network_id: NET_A })).error).toBe("daemon_child_mismatch");
429
+ expect((await call(u.delete_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, confirm_alias: CHILD_A_ALIAS, network_id: NET_A })).error).toBe("daemon_child_mismatch");
430
+ // 正确 daemon → 放行(回归钉)
431
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A })).ok).toBe(true);
432
+ });
433
+
434
+ // #1448 finding-5 — delete 完成时按派发时定住的 token_id 精确撤 ntok,而非按
435
+ // name='node:<alias>' 广撤。改前:同 alias 在 delete 未收敛窗口内被重建、拿到同名新
436
+ // token,ack 会把新 token 一并误撤(下面 REBUILT_TOK 断言 not revoked 会红)。
437
+ test("#1448 finding-5: delete revokes by token_id, spares a same-alias rebuilt token", async () => {
438
+ setupAlphaNetwork();
439
+ const userTools = buildHandlers(USER_A_ID);
440
+ const dispatch = await call(userTools.delete_node, {
441
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
442
+ });
443
+ expect(dispatch.ok).toBe(true);
444
+ // 派发后、ack 前:同 alias 节点被重建,拿到**同名**新 ntok(不同 token_id,仍 active)。
445
+ const REBUILT_TOK = "tok_sd_child_a_rebuilt";
446
+ db.run(
447
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
448
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
449
+ [REBUILT_TOK, USER_A_ID, NET_A, `node:${CHILD_A_ALIAS}`, `hash_${REBUILT_TOK}`],
450
+ );
451
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
452
+ await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
453
+ expect(readToken(CHILD_TOK_A_ID)?.revoked_at).not.toBeNull(); // 原 token 撤销(精确命中)
454
+ expect(readToken(REBUILT_TOK)?.revoked_at).toBeNull(); // 重建的同名新 token 未被误撤(改前按 name 广撤会红)
455
+ });
456
+
457
+ test("daemon ack 'stop_failed' → lifecycle_state=stop_failed, request error captured", async () => {
458
+ setupAlphaNetwork();
459
+ const userTools = buildHandlers(USER_A_ID);
460
+ const dispatch = await call(userTools.stop_node, {
461
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
462
+ });
463
+ const requestId = dispatch.request_id as string;
464
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
465
+ await call(daemonTools.ack_stop_request, {
466
+ request_id: requestId, status: "stop_failed", error: "SIGKILL also failed",
467
+ });
468
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stop_failed");
469
+ expect(readRequest(requestId)?.status).toBe("stop_failed");
470
+ });
471
+
472
+ test("daemon B can't ack daemon A's request → not_your_request", async () => {
473
+ setupAlphaNetwork();
474
+ const userTools = buildHandlers(USER_A_ID);
475
+ const dispatch = await call(userTools.stop_node, {
476
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
477
+ });
478
+ const requestId = dispatch.request_id as string;
479
+ // Seed an unrelated daemon B in netA with its own token.
480
+ db.run(
481
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
482
+ VALUES ('node_other_daemon', 'other-daemon', 'other-daemon', ?1, '{}', 'h', datetime('now'), datetime('now'), 'active')`,
483
+ [NET_A],
484
+ );
485
+ db.run(
486
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
487
+ VALUES ('tok_other_daemon', ?1, ?2, 'network', 'node:other-daemon', 'hash_other', NULL, NULL)`,
488
+ [USER_A_ID, NET_A],
489
+ );
490
+ const otherDaemonTools = buildHandlers(USER_A_ID, true, "tok_other_daemon", NET_A);
491
+ const r = await call(otherDaemonTools.ack_stop_request, {
492
+ request_id: requestId, status: "stopped",
493
+ });
494
+ expect(r.ok).toBe(false);
495
+ expect(r.error).toBe("not_your_request");
496
+ });
497
+ });
498
+
499
+ // ── PR1 v3: 通信龙 #345 deep-review fixes ──────────────────────────
500
+
501
+ describe("SF-2 — audit in tx propagates failure (D8 atomicity真)", () => {
502
+ test("forced audit row written alongside dispatch in same tx (rollback if either fails)", async () => {
503
+ // We can't easily simulate audit_log INSERT failure without
504
+ // schema munging; the atomicity guarantee is structural (BEGIN..
505
+ // COMMIT + auditCreateNodeStrict re-throws). Pin the invariant
506
+ // observable to ops: the dispatch + forced audit both appear OR
507
+ // both are absent. Happy path here; a true failure-injection
508
+ // belongs in PR1.1's docker e2e where we can drop audit_log
509
+ // permissions / corrupt the row.
510
+ setupAlphaNetwork();
511
+ seedInbox(NET_A, CHILD_A_ALIAS, 1);
512
+ const tools = buildHandlers(USER_A_ID);
513
+ const r = await call(tools.stop_node, {
514
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
515
+ force: true,
516
+ });
517
+ expect(r.ok).toBe(true);
518
+ // Both audit rows present.
519
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(1);
520
+ expect(readAudit("forced_stop_with_in_flight", NET_A).length).toBe(1);
521
+ // node state moved
522
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
523
+ });
524
+ });
525
+
526
+ describe("SF-4 — D6 fail-CLOSED on ambiguous role", () => {
527
+ test("corrupt config_snapshot + node referenced as daemon → still refused", async () => {
528
+ setupAlphaNetwork();
529
+ // Wreck the daemon's snapshot but leave the row + create-request
530
+ // history. delete_node MUST still refuse — corrupt snapshot was the
531
+ // SF-4 fail-open path.
532
+ db.run(
533
+ `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
534
+ ["not-valid-json{{{", DAEMON_A_ID],
535
+ );
536
+ // Plant a node_create_request that points to DAEMON_A as daemon,
537
+ // simulating that this daemon has handed out children before.
538
+ db.run(
539
+ `INSERT INTO node_create_requests
540
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
541
+ VALUES ('cr_sf4_evidence', ?1, 'past-child', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'succeeded', NULL, ?3, 'tok_test')`,
542
+ [DAEMON_A_ID, NET_A, Date.now()],
543
+ );
544
+ const tools = buildHandlers(USER_A_ID);
545
+ const r = await call(tools.delete_node, {
546
+ child_node_id: DAEMON_A_ID,
547
+ daemon_node_id: DAEMON_A_ID,
548
+ network_id: NET_A,
549
+ confirm_alias: DAEMON_A_ALIAS,
550
+ });
551
+ expect(r.ok).toBe(false);
552
+ expect(r.error).toBe("cannot_delete_daemon_via_delete_node");
553
+ expect(readNode(DAEMON_A_ID)?.lifecycle_state).toBe("active");
554
+ });
555
+
556
+ test("corrupt snapshot + NO daemon evidence → can be deleted (no FP)", async () => {
557
+ // A plain child whose snapshot got mangled mustn't be refused.
558
+ // Simulates corruption on a regular member node.
559
+ setupAlphaNetwork();
560
+ db.run(
561
+ `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
562
+ ["{broken", CHILD_A_ID],
563
+ );
564
+ const tools = buildHandlers(USER_A_ID);
565
+ const r = await call(tools.delete_node, {
566
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
567
+ confirm_alias: CHILD_A_ALIAS,
568
+ });
569
+ expect(r.ok).toBe(true);
570
+ expect(r.action).toBe("delete");
571
+ });
572
+ });
573
+
574
+ describe("SF-5 — in-flight COALESCE for legacy inbox rows", () => {
575
+ test("inbox row with network_id=NULL but session_name matches → counted", async () => {
576
+ setupAlphaNetwork();
577
+ // Legacy-shape inbox row: session_name set, network_id NULL.
578
+ db.run(
579
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id, acked)
580
+ VALUES (?1, ?2, 'task', 'normal', 'legacy-task', 'caller', NULL, 0)`,
581
+ [`inbox_legacy_${Date.now()}`, CHILD_A_ALIAS],
582
+ );
583
+ const tools = buildHandlers(USER_A_ID);
584
+ const r = await call(tools.stop_node, {
585
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
586
+ });
587
+ expect(r.ok).toBe(false);
588
+ expect(r.error).toBe("node_busy_in_flight");
589
+ expect(r.in_flight_count).toBeGreaterThanOrEqual(1);
590
+ });
591
+ });
592
+
593
+ // ─── RFC-027 PR1.1 — D8 atomicity failure-injection (real teeth) ──
594
+ //
595
+ // PR1's SF-2 fix introduced auditCreateNodeStrict (re-throws inside tx
596
+ // callback). The PR1 v3 test only verified happy path: both rows
597
+ // present after success. 通信龙's #345 ack flagged that as a shape-pin
598
+ // — could pass even if the strict variant were silently reverted. This
599
+ // PR1.1 test injects a real failure (monkey-patch db.run to throw at
600
+ // the audit_log INSERT moment) and asserts BOTH rollback halves:
601
+ // 1. nodes.lifecycle_state UNCHANGED (didn't transition to 'stopping')
602
+ // 2. node_stop_requests row absent (INSERT rolled back too)
603
+ describe("SF-2 D8 atomicity — REAL failure injection forces ROLLBACK", () => {
604
+ test("audit_log table dropped → audit INSERT throws inside tx → ROLLBACK leaves nodes + request untouched", async () => {
605
+ setupAlphaNetwork();
606
+ const tools = buildHandlers(USER_A_ID);
607
+ // Real failure injection: drop audit_log so the strict audit
608
+ // INSERT inside the tx throws a real SQLite "no such table"
609
+ // error. The whole tx must roll back per §4.5 D8. (Patching
610
+ // adapter.db.run via property overwrite proved unreliable under
611
+ // bun:sqlite's transaction wrapper in earlier draft; dropping
612
+ // the table is the simplest way to make the failure REAL — the
613
+ // error originates inside the SQL engine, not a JS mock.)
614
+ db.exec("DROP TABLE IF EXISTS audit_log_BACKUP");
615
+ db.exec("ALTER TABLE audit_log RENAME TO audit_log_BACKUP");
616
+ let r: Reply;
617
+ try {
618
+ r = await call(tools.stop_node, {
619
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A,
620
+ });
621
+ } finally {
622
+ // Restore so subsequent tests + cleanup work.
623
+ db.exec("DROP TABLE IF EXISTS audit_log");
624
+ db.exec("ALTER TABLE audit_log_BACKUP RENAME TO audit_log");
625
+ }
626
+ expect(r.ok).toBe(false);
627
+ // dispatch_tx_failed is the expected error. Print the actual reply
628
+ // if we got something else so the failure mode is diagnosable.
629
+ if (r.error !== "dispatch_tx_failed") {
630
+ console.error("[SF-2 unexpected reply]", JSON.stringify(r));
631
+ }
632
+ expect(r.error).toBe("dispatch_tx_failed");
633
+
634
+ // ROLLBACK observable:
635
+ // (a) lifecycle_state did NOT transition out of 'active'
636
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
637
+ // (b) no node_stop_requests row exists for this child
638
+ const reqs = db.all<{ request_id: string }>(
639
+ `SELECT request_id FROM node_stop_requests WHERE child_node_id = ?1`, [CHILD_A_ID],
640
+ );
641
+ expect(reqs.length).toBe(0);
642
+ // (c) no audit row was written
643
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
644
+ });
645
+ });
646
+
647
+ // ─── RFC-027 PR1.1 — inbox-enqueue lifecycle guard at routing layer ──
648
+ //
649
+ // 6 inbox INSERT sites in tools.ts (send_task / send_message / reply /
650
+ // retry_task / reassign_task / broadcast) now pre-check the target
651
+ // alias's nodes.lifecycle_state. Anything other than `active` refuses
652
+ // with `node_not_active`. Sample the highest-traffic path (send_task)
653
+ // for a stopping target — this catches the routing-after-SIGTERM
654
+ // window that RFC §2.3 explicitly closes.
655
+ describe("inbox-enqueue lifecycle_state guard (RFC-027 §2.3 race-free)", () => {
656
+ test("send_task to a stopping node → refused with node_not_active", async () => {
657
+ setupAlphaNetwork();
658
+ // Caller + target sessions rows (send_task's resolveDeliveryTarget
659
+ // looks up the target in sessions, not just nodes).
660
+ db.run(
661
+ `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
662
+ VALUES (?1, ?2, ?3, datetime('now'), 'idle', 4, 8, '10.0.0.99')`,
663
+ [`s_caller_${Date.now()}`, "sender-x", NET_A],
664
+ );
665
+ db.run(
666
+ `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
667
+ VALUES (?1, ?2, ?3, datetime('now'), 'idle', 4, 8, '10.0.0.5')`,
668
+ [`s_child_${Date.now()}`, CHILD_A_ALIAS, NET_A],
669
+ );
670
+ // Move target into stopping (mid-flight stop_node race window).
671
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopping' WHERE node_id = ?1`, [CHILD_A_ID]);
672
+
673
+ const tools = buildHandlers(USER_A_ID);
674
+ const r = await call(tools.send_task, {
675
+ alias: CHILD_A_ALIAS,
676
+ task: "racey post-SIGTERM task",
677
+ from_session: "sender-x",
678
+ network_id: NET_A,
679
+ });
680
+ expect(r.ok).toBe(false);
681
+ expect(r.error).toBe("node_not_active");
682
+ expect(r.lifecycle_state).toBe("stopping");
683
+ // Inbox unchanged (no row inserted for this alias post-stopping).
684
+ const inboxRows = db.all<{ id: string }>(
685
+ `SELECT id FROM inbox WHERE session_name = ?1 AND network_id = ?2`,
686
+ [CHILD_A_ALIAS, NET_A],
687
+ );
688
+ expect(inboxRows.length).toBe(0);
689
+ });
690
+ });
691
+
692
+ // ─── list_my_children (PR1.1 hub support for rebuild) ────────────
693
+ describe("list_my_children HANDLER (RFC-027 PR1.1)", () => {
694
+ test("daemon-bound caller gets its children list", async () => {
695
+ setupAlphaNetwork();
696
+ // Plant a create-request row simulating that DAEMON_A has spawned
697
+ // CHILD_A previously (the daemon's child must be in this table).
698
+ db.run(
699
+ `INSERT INTO node_create_requests
700
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
701
+ VALUES ('cr_lmc_test', ?1, ?2, ?3, 'claude-agent-sdk', 'x', '{}', '[]', 'succeeded', NULL, ?4, 'tok_test')`,
702
+ [DAEMON_A_ID, CHILD_A_ALIAS, NET_A, Date.now()],
703
+ );
704
+ // The child's nodes row must exist (setupAlphaNetwork seeds it),
705
+ // and the canonical child_node_id needs to match `node_${request_id.slice(3)}`
706
+ // We update the existing child row's id so the LEFT JOIN matches.
707
+ db.run(`UPDATE nodes SET node_id = 'node_lmc_test' WHERE alias = ?1 AND network_id = ?2`, [CHILD_A_ALIAS, NET_A]);
708
+
709
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
710
+ const r = await call(daemonTools.list_my_children, {});
711
+ expect(r.ok).toBe(true);
712
+ expect(Array.isArray(r.children)).toBe(true);
713
+ const aliases = (r.children as Array<{ alias: string }>).map(c => c.alias);
714
+ expect(aliases).toContain(CHILD_A_ALIAS);
715
+ });
716
+
717
+ test("non-daemon caller (utok) refused", async () => {
718
+ setupAlphaNetwork();
719
+ const userTools = buildHandlers(USER_A_ID); // utok, not daemon-bound
720
+ const r = await call(userTools.list_my_children, {});
721
+ expect(r.ok).toBe(false);
722
+ });
723
+ });
724
+
725
+ describe("create_node audit wording (#1362)", () => {
726
+ test("create_node records a dispatch attempt, not guaranteed SSE delivery", async () => {
727
+ setupAlphaNetwork();
728
+ const tools = buildHandlers(USER_A_ID);
729
+ const r = await call(tools.create_node, {
730
+ daemon_node_id: DAEMON_A_ID,
731
+ node_spec: {
732
+ name: "sd-new-child",
733
+ runtime: "claude-agent-sdk",
734
+ model: "x",
735
+ },
736
+ network_id: NET_A,
737
+ });
738
+
739
+ expect(r.ok).toBe(true);
740
+ expect(typeof r.request_id).toBe("string");
741
+ expect(readAudit("create_node_dispatch_attempted", NET_A).length).toBe(1);
742
+ expect(readAudit("create_node_dispatched", NET_A).length).toBe(0);
743
+ });
744
+ });
745
+
746
+ describe("list_my_pending_create_requests HANDLER (#1362 SSE reconnect compensation)", () => {
747
+ test("daemon-bound caller gets only its own pending create requests", async () => {
748
+ setupAlphaNetwork();
749
+ const otherDaemonId = "node_sd_daemon_other";
750
+ seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
751
+ db.run(
752
+ `INSERT INTO node_create_requests
753
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
754
+ VALUES
755
+ ('cr_pending_mine', ?1, 'child-pending-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?3, 'tok_test'),
756
+ ('cr_delivered_mine', ?1, 'child-delivered-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'delivered', NULL, ?4, 'tok_test'),
757
+ ('cr_pending_other', ?5, 'child-pending-other', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?6, 'tok_test')`,
758
+ [DAEMON_A_ID, NET_A, Date.now(), Date.now() + 1, otherDaemonId, Date.now() + 2],
759
+ );
760
+
761
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
762
+ const r = await call(daemonTools.list_my_pending_create_requests, {});
763
+
764
+ expect(r.ok).toBe(true);
765
+ expect(r.count).toBe(1);
766
+ expect(r.requests).toEqual([
767
+ expect.objectContaining({ request_id: "cr_pending_mine", child_name: "child-pending-mine" }),
768
+ ]);
769
+ });
770
+
771
+ test("non-daemon caller (utok) refused", async () => {
772
+ setupAlphaNetwork();
773
+ const userTools = buildHandlers(USER_A_ID);
774
+ const r = await call(userTools.list_my_pending_create_requests, {});
775
+ expect(r.ok).toBe(false);
776
+ });
777
+ });
778
+
779
+ describe("list_my_pending_lifecycle_requests HANDLER (#1448 SSE reconnect compensation)", () => {
780
+ function seedStop(reqId: string, daemonId: string, status: string, action: string, ageMs: number) {
781
+ db.run(
782
+ `INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
783
+ child_alias, action, delete_config, created_by_token, status, created_at)
784
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 'tok_test', ?7, ?8)`,
785
+ [reqId, NET_A, daemonId, CHILD_A_ID, CHILD_A_ALIAS, action, status, Date.now() - ageMs],
786
+ );
787
+ }
788
+ // child_node_id parameterized: node_start_requests has a partial-unique
789
+ // index on child_node_id WHERE status IN ('pending','delivered'), so the
790
+ // "mine" and "other" pending start rows must target different children.
791
+ function seedStart(reqId: string, daemonId: string, childId: string, status: string, ageMs: number) {
792
+ db.run(
793
+ `INSERT INTO node_start_requests (request_id, network_id, daemon_node_id, child_node_id,
794
+ child_alias, created_by_token, status, created_at)
795
+ VALUES (?1, ?2, ?3, ?4, ?5, 'tok_test', ?6, ?7)`,
796
+ [reqId, NET_A, daemonId, childId, CHILD_A_ALIAS, status, Date.now() - ageMs],
797
+ );
798
+ }
799
+
800
+ test("daemon-bound caller gets only its own PENDING stop/delete + start (excludes delivered + other daemon)", async () => {
801
+ setupAlphaNetwork();
802
+ const otherDaemonId = "node_sd_daemon_other";
803
+ seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
804
+ // mine — should be returned (stop + delete, oldest first)
805
+ seedStop("sr_pending_stop", DAEMON_A_ID, "pending", "stop", 30);
806
+ seedStop("sr_pending_delete", DAEMON_A_ID, "pending", "delete", 20);
807
+ seedStart("str_pending_mine", DAEMON_A_ID, CHILD_A_ID, "pending", 25);
808
+ // excluded — delivered (already pulled) + other daemon's pending
809
+ seedStop("sr_delivered_mine", DAEMON_A_ID, "delivered", "stop", 40);
810
+ seedStop("sr_pending_other", otherDaemonId, "pending", "stop", 10);
811
+ seedStart("str_pending_other", otherDaemonId, "node_sd_child_other", "pending", 10);
812
+
813
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
814
+ const res = await call(daemonTools.list_my_pending_lifecycle_requests, {});
815
+ expect(res.ok).toBe(true);
816
+ expect(res.stop_count).toBe(2);
817
+ expect((res.stop_requests as Array<{ request_id: string }>).map(x => x.request_id))
818
+ .toEqual(["sr_pending_stop", "sr_pending_delete"]); // oldest-first, action preserved
819
+ expect(res.start_count).toBe(1);
820
+ expect((res.start_requests as Array<{ request_id: string }>).map(x => x.request_id))
821
+ .toEqual(["str_pending_mine"]);
822
+ });
823
+
824
+ // Witnessed-red 的核心链:真实 stop_node 派发时 daemon 无 SSE 订阅者 →
825
+ // pushEvent 静默丢门铃 → 行卡 pending / 节点卡 stopping(缺陷态)。修复后这条
826
+ // pending 行会被 list_my_pending_lifecycle_requests 列出 → 重连 reconcile 能重放。
827
+ // 改前(无此工具)该行永远无人发现、节点永久卡 stopping。
828
+ test("stuck-pending after a dropped doorbell IS surfaced for reconnect replay", async () => {
829
+ setupAlphaNetwork();
830
+ const userTools = buildHandlers(USER_A_ID);
831
+ // 无 SSE 订阅者:dispatch 建 pending 行 + pushEvent 丢弃(测试进程里没有 client)
832
+ const disp = await call(userTools.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
833
+ expect(disp.ok).toBe(true);
834
+ const reqId = disp.request_id as string;
835
+ // 缺陷态:行 pending、节点 stopping(门铃没送达,无人推进)
836
+ const row = db.get<{ status: string }>("SELECT status FROM node_stop_requests WHERE request_id = ?1", reqId);
837
+ expect(row?.status).toBe("pending");
838
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
839
+ // 修复:pending 行经新工具对本 daemon 可见 → 重连补偿能拿到并重放
840
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
841
+ const r = await call(daemonTools.list_my_pending_lifecycle_requests, {});
842
+ expect(r.ok).toBe(true);
843
+ expect((r.stop_requests as Array<{ request_id: string }>).map(x => x.request_id)).toContain(reqId);
844
+ });
845
+
846
+ test("non-daemon caller (utok) refused", async () => {
847
+ setupAlphaNetwork();
848
+ const userTools = buildHandlers(USER_A_ID);
849
+ const r = await call(userTools.list_my_pending_lifecycle_requests, {});
850
+ expect(r.ok).toBe(false);
851
+ });
852
+ });
853
+
854
+ // ─── PR1.2a — restart_node resets lifecycle_state ──────────────────
855
+ //
856
+ // 通信龙 #346 ack latent: without this, a stopped node restart-ed via
857
+ // restart_node would still have lifecycle_state='stopped' in the
858
+ // nodes table → the 6 MCP + 2 REST inbox guards would refuse all
859
+ // routing → node silently unreachable until manual DB fix. Asserts
860
+ // the dispatch + state flip happen atomically (tx).
861
+ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", () => {
862
+ test("restart_node on a stopped node → lifecycle_state flips back to 'active' + dispatch row created", async () => {
863
+ setupAlphaNetwork();
864
+ // Move the child to 'stopped' (simulating prior stop_node completion).
865
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]);
866
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopped");
867
+
868
+ const tools = buildHandlers(USER_A_ID);
869
+ const r = await call(tools.restart_node, {
870
+ node_id: CHILD_A_ID,
871
+ network_id: NET_A,
872
+ });
873
+ expect(r.ok).toBe(true);
874
+ expect(r.apply_mode).toBe("restart_only");
875
+
876
+ // State flipped back.
877
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
878
+ // node_config_updates row exists (the dispatch).
879
+ const rows = db.all<{ update_id: string; status: string }>(
880
+ `SELECT update_id, status FROM node_config_updates WHERE node_id = ?1 ORDER BY created_at DESC LIMIT 1`,
881
+ [CHILD_A_ID],
882
+ );
883
+ expect(rows.length).toBe(1);
884
+ expect(rows[0].status).toBe("pending");
885
+ });
886
+
887
+ test("restart_node on an active node → still resets to active (idempotent, no-op state-wise)", async () => {
888
+ setupAlphaNetwork();
889
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
890
+ const tools = buildHandlers(USER_A_ID);
891
+ const r = await call(tools.restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
892
+ expect(r.ok).toBe(true);
893
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
894
+ });
895
+ });
896
+
897
+ // ── #1286 stale-deleting 重派出口 ──────────────────────────────────
898
+ //
899
+ // 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
900
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
901
+ // 出口三条件缺一不放行:force=true + 最近 delete 请求非终态 + 晾超 5 分钟。
902
+ describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
903
+ function seedDeletingChild() {
904
+ setupAlphaNetwork();
905
+ db.run(`UPDATE nodes SET lifecycle_state = 'deleting' WHERE node_id = ?1`, [CHILD_A_ID]);
906
+ }
907
+ function seedStopRequest(status: string, ageMs: number) {
908
+ db.run(
909
+ `INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
910
+ child_alias, action, delete_config, created_by_token, status, created_at)
911
+ VALUES (?1, ?2, ?3, ?4, ?5, 'delete', 1, 'tok_test', ?6, ?7)`,
912
+ [`sr_stale_${Date.now()}_${Math.floor(ageMs)}`, NET_A, DAEMON_A_ID, CHILD_A_ID,
913
+ CHILD_A_ALIAS, status, Date.now() - ageMs],
914
+ );
915
+ }
916
+ const base = { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS };
917
+
918
+ test("无 force:仍拒,且带 hint 指出口(不是让人反复撞墙)", async () => {
919
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
920
+ const r = await call(buildHandlers(USER_A_ID).delete_node, base);
921
+ expect(r.ok).toBe(false);
922
+ expect(r.error).toBe("node_already_deleting");
923
+ expect(typeof r.hint).toBe("string");
924
+ });
925
+
926
+ test("🔴 force 但请求还新鲜(1 分钟):仍拒 —— force 不能变成竞态放大器", async () => {
927
+ seedDeletingChild(); seedStopRequest("pending", 60_000);
928
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
929
+ expect(r.ok).toBe(false);
930
+ expect(r.error).toBe("node_already_deleting");
931
+ });
932
+
933
+ test("🔴 force 但上一条已终态(stopped):仍拒 —— daemon 刚 ack 过,行马上会转移", async () => {
934
+ seedDeletingChild(); seedStopRequest("stopped", 10 * 60_000);
935
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
936
+ expect(r.ok).toBe(false);
937
+ expect(r.error).toBe("node_already_deleting");
938
+ });
939
+
940
+ test("force + pending 晾超 5 分钟:放行,生成新 request_id", async () => {
941
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
942
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
943
+ expect(r.ok).toBe(true);
944
+ expect((r.request_id as string).startsWith("sr_")).toBe(true);
945
+ });
946
+
947
+ test("force + dispatched 晾超 5 分钟:同样放行", async () => {
948
+ seedDeletingChild(); seedStopRequest("dispatched", 10 * 60_000);
949
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
950
+ expect(r.ok).toBe(true);
951
+ });
952
+ });