@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.
- package/package.json +2 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-host-supervisors-fallback.test.ts +262 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/db.ts +196 -0
- package/src/index.ts +216 -3
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +1776 -13
- package/src/update-provider.test.ts +234 -0
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +36 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -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
|
+
});
|