@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.8
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 +1 -1
- package/src/ack-create-request.test.ts +331 -0
- package/src/create-node.ts +6 -1
- package/src/db.ts +23 -0
- package/src/index.ts +104 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/tools.ts +214 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.8",
|
|
4
4
|
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,331 @@
|
|
|
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-026 §9.3 D2 / #338 PR3 — ack_create_request HANDLER tests.
|
|
7
|
+
//
|
|
8
|
+
// 通信龙 pre-review caught B1 + B2: my first cut of PR3 added a new daemon
|
|
9
|
+
// ack status `runtime_capability_check_failed` to create-node-daemon.ts but
|
|
10
|
+
// the hub's zod enum only accepted ["started", "failed", "rejected"] — the
|
|
11
|
+
// new status was silently zod-rejected on the hub side and the daemon's
|
|
12
|
+
// `.catch(()=>{})` swallowed the rejection. The PR body also claimed
|
|
13
|
+
// `daemon_capability_lied` audit was written when in fact the action wasn't
|
|
14
|
+
// in the audit enum and no insert ran.
|
|
15
|
+
//
|
|
16
|
+
// This handler-driven suite drives the registered MCP tool through
|
|
17
|
+
// registerTools() so a daemon's exact ack shape (status + runtime field)
|
|
18
|
+
// flows through the real zod schema + handler branch + audit insert. It
|
|
19
|
+
// would have caught the lane-recurring "single component passes but cross-
|
|
20
|
+
// component path stays broken" failure mode immediately.
|
|
21
|
+
|
|
22
|
+
const NET = "net_ack_t";
|
|
23
|
+
const DAEMON_NODE_ID = "node_ack_daemon";
|
|
24
|
+
const DAEMON_ALIAS = "ack-daemon";
|
|
25
|
+
const DAEMON_USER = "u_ack_owner";
|
|
26
|
+
const DAEMON_TOKEN_ID = "tok_ack_daemon";
|
|
27
|
+
const DAEMON_TOKEN_HASH = "hash_ack_daemon";
|
|
28
|
+
|
|
29
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
30
|
+
interface AckReply { ok?: boolean; error?: string; status?: string; }
|
|
31
|
+
|
|
32
|
+
function cleanup() {
|
|
33
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
39
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [DAEMON_USER]); } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(cleanup);
|
|
43
|
+
afterAll(cleanup);
|
|
44
|
+
|
|
45
|
+
function seedDaemonWorld() {
|
|
46
|
+
db.run(
|
|
47
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
48
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
49
|
+
[DAEMON_USER, DAEMON_USER],
|
|
50
|
+
);
|
|
51
|
+
db.run(
|
|
52
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
53
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
54
|
+
[NET, NET, DAEMON_USER],
|
|
55
|
+
);
|
|
56
|
+
db.run(
|
|
57
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
58
|
+
VALUES (?1, ?2, 'owner', datetime('now'))`,
|
|
59
|
+
[DAEMON_USER, NET],
|
|
60
|
+
);
|
|
61
|
+
// daemon node — must exist for resolveCallerDaemonTokenBound to bind.
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
|
|
64
|
+
VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
65
|
+
[DAEMON_NODE_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET],
|
|
66
|
+
);
|
|
67
|
+
// daemon ntok — bound to the daemon node via name='node:<alias>'.
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
70
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
71
|
+
[DAEMON_TOKEN_ID, DAEMON_USER, NET, `node:${DAEMON_ALIAS}`, DAEMON_TOKEN_HASH],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function seedCreateRequest(opts: {
|
|
76
|
+
request_id: string,
|
|
77
|
+
child_token_id?: string,
|
|
78
|
+
status?: "pending" | "delivered" | "succeeded" | "failed",
|
|
79
|
+
child_name?: string,
|
|
80
|
+
runtime?: string,
|
|
81
|
+
}) {
|
|
82
|
+
const childName = opts.child_name ?? `child_${opts.request_id}`;
|
|
83
|
+
if (opts.child_token_id) {
|
|
84
|
+
db.run(
|
|
85
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
86
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
87
|
+
[opts.child_token_id, DAEMON_USER, NET, `node:${childName}`, `hash_${opts.child_token_id}`],
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
db.run(
|
|
91
|
+
`INSERT INTO node_create_requests
|
|
92
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
93
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'x', '{}', '[]', ?6, ?7, ?8, ?9)`,
|
|
94
|
+
[
|
|
95
|
+
opts.request_id, DAEMON_NODE_ID, childName, NET,
|
|
96
|
+
opts.runtime ?? "claude-agent-sdk",
|
|
97
|
+
opts.status ?? "delivered",
|
|
98
|
+
opts.child_token_id ?? null,
|
|
99
|
+
Date.now(),
|
|
100
|
+
DAEMON_TOKEN_ID,
|
|
101
|
+
],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build a server with registerTools wired as if the daemon's ntok bound. */
|
|
106
|
+
function buildAckHandler(): ToolHandler {
|
|
107
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
108
|
+
const tools: Record<string, ToolHandler> = {};
|
|
109
|
+
const origTool = server.tool.bind(server);
|
|
110
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
111
|
+
tools[name] = handler;
|
|
112
|
+
return origTool(name, _desc, _schema, handler);
|
|
113
|
+
};
|
|
114
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
115
|
+
if (origRegisterTool) {
|
|
116
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
117
|
+
tools[name] = handler;
|
|
118
|
+
return origRegisterTool(name, _cfg, handler);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
registerTools(
|
|
122
|
+
server,
|
|
123
|
+
/* clientIP */ undefined,
|
|
124
|
+
/* enforceNetworkId */ NET,
|
|
125
|
+
/* enforceUserId */ DAEMON_USER,
|
|
126
|
+
/* callerAlias */ DAEMON_ALIAS,
|
|
127
|
+
/* callerTokenIsNetwork */ true,
|
|
128
|
+
/* callerTokenId */ DAEMON_TOKEN_ID,
|
|
129
|
+
);
|
|
130
|
+
const h = tools["ack_create_request"];
|
|
131
|
+
if (!h) throw new Error("ack_create_request tool not registered");
|
|
132
|
+
return h;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function callAck(handler: ToolHandler, args: any): Promise<AckReply> {
|
|
136
|
+
const r = await handler(args);
|
|
137
|
+
return JSON.parse(r.content[0].text) as AckReply;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readRequest(request_id: string) {
|
|
141
|
+
return db.get<{ status: string; error: string | null; acked_at: number | null }>(
|
|
142
|
+
`SELECT status, error, acked_at FROM node_create_requests WHERE request_id = ?1`,
|
|
143
|
+
request_id,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readToken(token_id: string) {
|
|
148
|
+
return db.get<{ revoked_at: string | null }>(
|
|
149
|
+
`SELECT revoked_at FROM api_tokens WHERE token_id = ?1`,
|
|
150
|
+
token_id,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readAuditRows(action: string, target_id: string) {
|
|
155
|
+
return db.all<{ action: string; network_id: string; target_id: string; detail: string }>(
|
|
156
|
+
`SELECT action, network_id, target_id, detail FROM audit_log
|
|
157
|
+
WHERE action = ?1 AND target_id = ?2 ORDER BY id DESC`,
|
|
158
|
+
[action, target_id],
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe("ack_create_request HANDLER — B1 schema accepts runtime_capability_check_failed", () => {
|
|
163
|
+
test("schema accepts runtime_capability_check_failed + runtime field (no zod reject)", async () => {
|
|
164
|
+
seedDaemonWorld();
|
|
165
|
+
const reqId = "cr_ack_b1_accept";
|
|
166
|
+
seedCreateRequest({ request_id: reqId });
|
|
167
|
+
const handler = buildAckHandler();
|
|
168
|
+
const reply = await callAck(handler, {
|
|
169
|
+
request_id: reqId,
|
|
170
|
+
status: "runtime_capability_check_failed",
|
|
171
|
+
error: "child died within 5000ms post-spawn",
|
|
172
|
+
runtime: "codex-sdk",
|
|
173
|
+
});
|
|
174
|
+
// PR3 v1 BUG: hub zod enum rejected this status → reply.ok=false /
|
|
175
|
+
// tool would even throw before getting here. Now must accept.
|
|
176
|
+
expect(reply.ok).toBe(true);
|
|
177
|
+
expect(reply.status).toBe("runtime_capability_check_failed");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("request flips to terminal runtime_capability_check_failed status", async () => {
|
|
181
|
+
seedDaemonWorld();
|
|
182
|
+
const reqId = "cr_ack_b1_terminal";
|
|
183
|
+
seedCreateRequest({ request_id: reqId, status: "delivered" });
|
|
184
|
+
const handler = buildAckHandler();
|
|
185
|
+
await callAck(handler, {
|
|
186
|
+
request_id: reqId,
|
|
187
|
+
status: "runtime_capability_check_failed",
|
|
188
|
+
error: "boom",
|
|
189
|
+
runtime: "codex-sdk",
|
|
190
|
+
});
|
|
191
|
+
const row = readRequest(reqId);
|
|
192
|
+
expect(row?.status).toBe("runtime_capability_check_failed");
|
|
193
|
+
expect(row?.error).toBe("boom");
|
|
194
|
+
expect(row?.acked_at).toBeGreaterThan(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("child ntok is revoked on capability-check-failed (same teardown as 'failed')", async () => {
|
|
198
|
+
seedDaemonWorld();
|
|
199
|
+
const reqId = "cr_ack_b1_revoke";
|
|
200
|
+
const childTok = "tok_ack_child_b1";
|
|
201
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
202
|
+
expect(readToken(childTok)?.revoked_at).toBeNull();
|
|
203
|
+
const handler = buildAckHandler();
|
|
204
|
+
await callAck(handler, {
|
|
205
|
+
request_id: reqId,
|
|
206
|
+
status: "runtime_capability_check_failed",
|
|
207
|
+
runtime: "codex-sdk",
|
|
208
|
+
});
|
|
209
|
+
expect(readToken(childTok)?.revoked_at).not.toBeNull();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("ack_create_request HANDLER — B2 audit_log daemon_capability_lied", () => {
|
|
214
|
+
test("writes daemon_capability_lied audit row with runtime + daemon + error in detail", async () => {
|
|
215
|
+
seedDaemonWorld();
|
|
216
|
+
const reqId = "cr_ack_b2_audit";
|
|
217
|
+
seedCreateRequest({ request_id: reqId });
|
|
218
|
+
const handler = buildAckHandler();
|
|
219
|
+
await callAck(handler, {
|
|
220
|
+
request_id: reqId,
|
|
221
|
+
status: "runtime_capability_check_failed",
|
|
222
|
+
error: "OPENAI_API_KEY unset",
|
|
223
|
+
runtime: "codex-sdk",
|
|
224
|
+
});
|
|
225
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
226
|
+
// PR3 v1 BUG: action wasn't in auditCreateNode enum → 0 rows + no
|
|
227
|
+
// surface for ops to spot lying daemons. Now must write exactly 1.
|
|
228
|
+
expect(rows.length).toBe(1);
|
|
229
|
+
expect(rows[0].network_id).toBe(NET);
|
|
230
|
+
const detail = JSON.parse(rows[0].detail);
|
|
231
|
+
expect(detail.runtime).toBe("codex-sdk");
|
|
232
|
+
expect(detail.daemon_node_id).toBe(DAEMON_NODE_ID);
|
|
233
|
+
expect(detail.error).toBe("OPENAI_API_KEY unset");
|
|
234
|
+
expect(typeof detail.acked_at).toBe("number");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("does NOT write daemon_capability_lied on generic 'failed' ack (status discriminates)", async () => {
|
|
238
|
+
seedDaemonWorld();
|
|
239
|
+
const reqId = "cr_ack_b2_no_audit_on_failed";
|
|
240
|
+
seedCreateRequest({ request_id: reqId });
|
|
241
|
+
const handler = buildAckHandler();
|
|
242
|
+
await callAck(handler, {
|
|
243
|
+
request_id: reqId,
|
|
244
|
+
status: "failed",
|
|
245
|
+
error: "child died immediately after spawn",
|
|
246
|
+
});
|
|
247
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
248
|
+
expect(rows.length).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("does NOT write daemon_capability_lied on 'started' (happy path silence)", async () => {
|
|
252
|
+
seedDaemonWorld();
|
|
253
|
+
const reqId = "cr_ack_b2_no_audit_on_started";
|
|
254
|
+
seedCreateRequest({ request_id: reqId });
|
|
255
|
+
const handler = buildAckHandler();
|
|
256
|
+
await callAck(handler, { request_id: reqId, status: "started", child_pid: 12345 });
|
|
257
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
258
|
+
expect(rows.length).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("runtime field nullable if daemon omitted it (older daemon back-compat)", async () => {
|
|
262
|
+
// Older daemon (preview.10 and earlier) wouldn't send `runtime`.
|
|
263
|
+
// The hub must still accept + audit, just with runtime=null.
|
|
264
|
+
seedDaemonWorld();
|
|
265
|
+
const reqId = "cr_ack_b2_nullable_runtime";
|
|
266
|
+
seedCreateRequest({ request_id: reqId });
|
|
267
|
+
const handler = buildAckHandler();
|
|
268
|
+
const reply = await callAck(handler, {
|
|
269
|
+
request_id: reqId,
|
|
270
|
+
status: "runtime_capability_check_failed",
|
|
271
|
+
error: "child died",
|
|
272
|
+
});
|
|
273
|
+
expect(reply.ok).toBe(true);
|
|
274
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
275
|
+
expect(rows.length).toBe(1);
|
|
276
|
+
const detail = JSON.parse(rows[0].detail);
|
|
277
|
+
expect(detail.runtime).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("error string is truncated to 500 chars in audit detail (defense vs giant payload)", async () => {
|
|
281
|
+
seedDaemonWorld();
|
|
282
|
+
const reqId = "cr_ack_b2_trunc";
|
|
283
|
+
seedCreateRequest({ request_id: reqId });
|
|
284
|
+
const handler = buildAckHandler();
|
|
285
|
+
const giant = "x".repeat(900);
|
|
286
|
+
await callAck(handler, {
|
|
287
|
+
request_id: reqId,
|
|
288
|
+
status: "runtime_capability_check_failed",
|
|
289
|
+
error: giant,
|
|
290
|
+
runtime: "codex-sdk",
|
|
291
|
+
});
|
|
292
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
293
|
+
expect(rows.length).toBe(1);
|
|
294
|
+
const detail = JSON.parse(rows[0].detail);
|
|
295
|
+
expect(detail.error.length).toBe(500);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("ack_create_request HANDLER — pre-existing happy-path stays green (regression)", () => {
|
|
300
|
+
test("status='started' still flips acked_at without terminal teardown", async () => {
|
|
301
|
+
seedDaemonWorld();
|
|
302
|
+
const reqId = "cr_ack_started_regression";
|
|
303
|
+
const childTok = "tok_ack_child_started";
|
|
304
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
305
|
+
const handler = buildAckHandler();
|
|
306
|
+
const reply = await callAck(handler, { request_id: reqId, status: "started", child_pid: 7777 });
|
|
307
|
+
expect(reply.ok).toBe(true);
|
|
308
|
+
expect(reply.status).toBe("awaiting_register");
|
|
309
|
+
const row = readRequest(reqId);
|
|
310
|
+
// 'started' doesn't flip to terminal; status stays 'delivered'.
|
|
311
|
+
expect(row?.status).toBe("delivered");
|
|
312
|
+
expect(row?.acked_at).toBeGreaterThan(0);
|
|
313
|
+
// child ntok is NOT revoked on 'started'.
|
|
314
|
+
expect(readToken(childTok)?.revoked_at).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("status='failed' still revokes child + flips to failed (no audit_log daemon_capability_lied)", async () => {
|
|
318
|
+
seedDaemonWorld();
|
|
319
|
+
const reqId = "cr_ack_failed_regression";
|
|
320
|
+
const childTok = "tok_ack_child_failed";
|
|
321
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
322
|
+
const handler = buildAckHandler();
|
|
323
|
+
const reply = await callAck(handler, { request_id: reqId, status: "failed", error: "fork failed" });
|
|
324
|
+
expect(reply.ok).toBe(true);
|
|
325
|
+
const row = readRequest(reqId);
|
|
326
|
+
expect(row?.status).toBe("failed");
|
|
327
|
+
expect(row?.error).toBe("fork failed");
|
|
328
|
+
expect(readToken(childTok)?.revoked_at).not.toBeNull();
|
|
329
|
+
expect(readAuditRows("daemon_capability_lied", reqId).length).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
});
|
package/src/create-node.ts
CHANGED
|
@@ -273,7 +273,12 @@ export function resolveCallerDaemonTokenBound(opts: {
|
|
|
273
273
|
// lifecycle event. Best-effort: never throw out (calling tool should
|
|
274
274
|
// continue even if audit insert fails for any reason).
|
|
275
275
|
export function auditCreateNode(input: {
|
|
276
|
-
action:
|
|
276
|
+
action:
|
|
277
|
+
| "create_node_dispatched"
|
|
278
|
+
| "create_node_rejected"
|
|
279
|
+
| "create_node_succeeded"
|
|
280
|
+
| "create_node_sweeper_revoked"
|
|
281
|
+
| "daemon_capability_lied"; // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving
|
|
277
282
|
user_id?: string | null;
|
|
278
283
|
username?: string | null;
|
|
279
284
|
network_id?: string | null;
|
package/src/db.ts
CHANGED
|
@@ -301,6 +301,29 @@ try {
|
|
|
301
301
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// RFC-026 §9 / #338 PR2 — daemon self-declare fields promoted from
|
|
305
|
+
// config_snapshot to first-class indexable columns. The hub
|
|
306
|
+
// `list_host_supervisors` MCP tool reads these directly without parsing
|
|
307
|
+
// the snapshot JSON on every list call.
|
|
308
|
+
//
|
|
309
|
+
// Both fields are JSON arrays of strings:
|
|
310
|
+
// runtimes_supported e.g. ["claude-agent-sdk","codex-sdk","grok-build-acp"]
|
|
311
|
+
// allowed_secret_keys e.g. ["ANTHROPIC_API_KEY","OPENAI_API_KEY"]
|
|
312
|
+
//
|
|
313
|
+
// Stored as TEXT (JSON-encoded) — SQLite has no native array type and
|
|
314
|
+
// the wrapper code is small. Both nullable for pre-PR2 daemons; the
|
|
315
|
+
// list endpoint falls back to [] when null.
|
|
316
|
+
try {
|
|
317
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN runtimes_supported TEXT`);
|
|
318
|
+
} catch (e: any) {
|
|
319
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN allowed_secret_keys TEXT`);
|
|
323
|
+
} catch (e: any) {
|
|
324
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
// `node_config_updates` — pending + history. One row per dashboard write.
|
|
305
328
|
// At most one row per node may be in a non-terminal state at a time
|
|
306
329
|
// (single-flight enforced at the tool layer; this table just stores).
|
package/src/index.ts
CHANGED
|
@@ -1962,6 +1962,110 @@ Bun.serve({
|
|
|
1962
1962
|
// stable, dashboard-facing fields only.
|
|
1963
1963
|
// Add a new column to this list explicitly when a real client needs
|
|
1964
1964
|
// it; do NOT switch back to SELECT *.
|
|
1965
|
+
// ── REST: list host_supervisor daemons (RFC-026 §9.2.2 / #338 PR2) ──
|
|
1966
|
+
// Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
|
|
1967
|
+
// Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
|
|
1968
|
+
// auth pipeline (restScope). Returns {ok, daemons:[...], count}.
|
|
1969
|
+
if (url.pathname === "/api/host-supervisors") {
|
|
1970
|
+
const netParam = url.searchParams.get("network_id");
|
|
1971
|
+
const effectiveNetId = restScope.networkId ?? netParam ?? null;
|
|
1972
|
+
if (!effectiveNetId) {
|
|
1973
|
+
return withCors(req, Response.json({ ok: false, error: "missing_network_id" }, { status: 400 }));
|
|
1974
|
+
}
|
|
1975
|
+
// Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
|
|
1976
|
+
let isPrivileged = false;
|
|
1977
|
+
if (restAuth?.userId) {
|
|
1978
|
+
const role = getUserNetworkRole(restAuth.userId, effectiveNetId);
|
|
1979
|
+
isPrivileged = role === "admin" || role === "owner";
|
|
1980
|
+
} else {
|
|
1981
|
+
// Network-token caller (no user) — treat as privileged within its network
|
|
1982
|
+
isPrivileged = true;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// EXISTS subquery handles BOTH revoked_at SET and DELETEd token
|
|
1986
|
+
// rows (revokeToken DELETEs the row in production — PR2 v1
|
|
1987
|
+
// SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
|
|
1988
|
+
// with rotated tokens.
|
|
1989
|
+
const sqlRows = db.all<Record<string, any>>(`
|
|
1990
|
+
SELECT
|
|
1991
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
1992
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
1993
|
+
n.created_at, n.updated_at,
|
|
1994
|
+
s.last_seen_at AS session_last_seen,
|
|
1995
|
+
s.cpu_cores AS session_cpu_cores,
|
|
1996
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
1997
|
+
s.ip AS session_ip,
|
|
1998
|
+
n.config_snapshot
|
|
1999
|
+
FROM nodes n
|
|
2000
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
2001
|
+
WHERE n.network_id = ?1
|
|
2002
|
+
AND EXISTS (
|
|
2003
|
+
SELECT 1 FROM api_tokens t
|
|
2004
|
+
WHERE t.network_id = n.network_id
|
|
2005
|
+
AND t.name = 'node:' || n.alias
|
|
2006
|
+
AND t.revoked_at IS NULL
|
|
2007
|
+
)
|
|
2008
|
+
ORDER BY n.updated_at DESC
|
|
2009
|
+
`, effectiveNetId);
|
|
2010
|
+
|
|
2011
|
+
const nowMs = Date.now();
|
|
2012
|
+
const ONLINE_MS = 60_000;
|
|
2013
|
+
const daemons = sqlRows
|
|
2014
|
+
.map(r => {
|
|
2015
|
+
let snapRole: string | null = null;
|
|
2016
|
+
if (r.config_snapshot) {
|
|
2017
|
+
try {
|
|
2018
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
2019
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
2020
|
+
} catch { /* malformed */ }
|
|
2021
|
+
}
|
|
2022
|
+
return { row: r, role: snapRole };
|
|
2023
|
+
})
|
|
2024
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
2025
|
+
.map(({ row: r }) => {
|
|
2026
|
+
let online = false;
|
|
2027
|
+
let lastSeenAt: string | null = null;
|
|
2028
|
+
if (r.session_last_seen) {
|
|
2029
|
+
lastSeenAt = r.session_last_seen;
|
|
2030
|
+
const t = Date.parse(r.session_last_seen);
|
|
2031
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
2032
|
+
}
|
|
2033
|
+
let runtimes: string[] = [];
|
|
2034
|
+
let secrets: string[] = [];
|
|
2035
|
+
try {
|
|
2036
|
+
if (r.runtimes_supported) {
|
|
2037
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
2038
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2039
|
+
}
|
|
2040
|
+
} catch {}
|
|
2041
|
+
try {
|
|
2042
|
+
if (r.allowed_secret_keys) {
|
|
2043
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
2044
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2045
|
+
}
|
|
2046
|
+
} catch {}
|
|
2047
|
+
const telemetry: Record<string, unknown> = {
|
|
2048
|
+
alert_level: online ? "green" : "gray",
|
|
2049
|
+
};
|
|
2050
|
+
if (isPrivileged) {
|
|
2051
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
2052
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
2053
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
2054
|
+
}
|
|
2055
|
+
return {
|
|
2056
|
+
daemon_node_id: r.node_id,
|
|
2057
|
+
alias: r.alias,
|
|
2058
|
+
hostname: r.hostname,
|
|
2059
|
+
online,
|
|
2060
|
+
last_seen_at: lastSeenAt,
|
|
2061
|
+
runtimes_supported: runtimes,
|
|
2062
|
+
allowed_secret_keys: secrets,
|
|
2063
|
+
host_telemetry: telemetry,
|
|
2064
|
+
};
|
|
2065
|
+
});
|
|
2066
|
+
return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1965
2069
|
if (url.pathname === "/api/nodes") {
|
|
1966
2070
|
const nodeId = url.searchParams.get("node_id");
|
|
1967
2071
|
const alias = url.searchParams.get("alias");
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db, hashToken, generateId } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// RFC-026 §9.2 / #338 PR2 v2 — list_host_supervisors HANDLER tests.
|
|
7
|
+
//
|
|
8
|
+
// PR2 v1 used a mirror-SQL pattern (re-implemented the query in the test
|
|
9
|
+
// itself) → trivially passed but didn't exercise the handler. 通信龙 audit
|
|
10
|
+
// caught: that style misses the SEC-1 scope-derivation bug
|
|
11
|
+
// (`getNetworkId(clientNetId)` returns the unchecked client-supplied
|
|
12
|
+
// network_id for utok_ callers when enforceNetworkId is null, leaking
|
|
13
|
+
// cross-tenant daemons). This rewrite drives the registered MCP tool
|
|
14
|
+
// through registerTools() so the auth context is real.
|
|
15
|
+
//
|
|
16
|
+
// Covers:
|
|
17
|
+
// SEC-1.1 — cross-tenant utok_ caller with foreign network_id: DENIED
|
|
18
|
+
// SEC-1.2 — admin caller with own network_id: returns own daemons
|
|
19
|
+
// SEC-1.3 — caller with no network_id: returns daemons in all member networks
|
|
20
|
+
// masking — non-admin viewer: host_telemetry IP/cpu/mem stripped, alert_level still surfaces
|
|
21
|
+
// revoked — DELETEd token row (revokeToken path): daemon excluded
|
|
22
|
+
// revoked — revoked_at SET token: daemon excluded
|
|
23
|
+
// self-declare — runtimes_supported/allowed_secret_keys surface from columns
|
|
24
|
+
// pre-PR2 daemon — empty arrays on NULL columns (back-compat)
|
|
25
|
+
|
|
26
|
+
const NET_ALPHA = "net_lhs_alpha";
|
|
27
|
+
const NET_BETA = "net_lhs_beta";
|
|
28
|
+
|
|
29
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
30
|
+
interface Reply { ok?: boolean; error?: string; daemons?: any[]; count?: number; }
|
|
31
|
+
|
|
32
|
+
function cleanup() {
|
|
33
|
+
for (const n of [NET_ALPHA, NET_BETA]) {
|
|
34
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [n]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [n]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
try { db.run("DELETE FROM users WHERE user_id LIKE 'u_lhs_%'"); } catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(cleanup);
|
|
44
|
+
afterAll(cleanup);
|
|
45
|
+
|
|
46
|
+
function seedUser(user_id: string) {
|
|
47
|
+
db.run(
|
|
48
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
49
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
50
|
+
[user_id, user_id],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
function seedNetwork(net_id: string, owner_id = "u_lhs_owner") {
|
|
54
|
+
db.run(
|
|
55
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
56
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
57
|
+
[net_id, net_id, owner_id],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
function seedMembership(user_id: string, net_id: string, role: "owner" | "admin" | "member" | "viewer" = "member") {
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
63
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
64
|
+
[user_id, net_id, role],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
function seedDaemon(opts: {
|
|
68
|
+
network_id: string, alias: string, node_id?: string,
|
|
69
|
+
role?: string,
|
|
70
|
+
runtimes?: string[],
|
|
71
|
+
secrets?: string[],
|
|
72
|
+
sessionAgeMs?: number,
|
|
73
|
+
mintToken?: boolean,
|
|
74
|
+
tokenRevokedAt?: boolean,
|
|
75
|
+
tokenDeleted?: boolean,
|
|
76
|
+
}) {
|
|
77
|
+
const node_id = opts.node_id || `node_${opts.network_id}_${opts.alias}`;
|
|
78
|
+
const snapshot = JSON.stringify({
|
|
79
|
+
model: "claude-opus", flags: {}, role: opts.role ?? "host_supervisor",
|
|
80
|
+
runtimes_supported: opts.runtimes ?? ["claude-agent-sdk"],
|
|
81
|
+
allowed_secret_keys: opts.secrets ?? [],
|
|
82
|
+
});
|
|
83
|
+
db.run(
|
|
84
|
+
`INSERT OR REPLACE INTO nodes (
|
|
85
|
+
node_id, node_name, alias, network_id,
|
|
86
|
+
runtimes_supported, allowed_secret_keys,
|
|
87
|
+
config_snapshot, hostname,
|
|
88
|
+
created_at, updated_at
|
|
89
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now'))`,
|
|
90
|
+
[node_id, opts.alias, opts.alias, opts.network_id,
|
|
91
|
+
JSON.stringify(opts.runtimes ?? ["claude-agent-sdk"]),
|
|
92
|
+
JSON.stringify(opts.secrets ?? []),
|
|
93
|
+
snapshot, `host-${opts.alias}`],
|
|
94
|
+
);
|
|
95
|
+
if (opts.sessionAgeMs !== undefined) {
|
|
96
|
+
const ts = new Date(Date.now() - opts.sessionAgeMs).toISOString().replace("T", " ").replace(/\..+/, "");
|
|
97
|
+
db.run(
|
|
98
|
+
`INSERT OR REPLACE INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
|
|
99
|
+
VALUES (?1, ?2, ?3, ?4, 'idle', 8, 16, '10.0.0.5')`,
|
|
100
|
+
[`s_${node_id}`, opts.alias, opts.network_id, ts],
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (opts.mintToken !== false && !opts.tokenDeleted) {
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT OR REPLACE INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
106
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, ?6)`,
|
|
107
|
+
[`tok_${node_id}`, `u_seed_${node_id}`, opts.network_id, `node:${opts.alias}`,
|
|
108
|
+
`hash_${node_id}`,
|
|
109
|
+
opts.tokenRevokedAt ? new Date().toISOString().replace("T", " ").replace(/\..+/, "") : null],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return node_id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build a server with registerTools wired to a specific enforceUserId. */
|
|
116
|
+
function buildToolHandler(enforceUserId: string | null): ToolHandler {
|
|
117
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
118
|
+
const tools: Record<string, ToolHandler> = {};
|
|
119
|
+
// Capture the registered tool function. The MCP SDK's server.tool stores
|
|
120
|
+
// the handler internally; we intercept by overriding before registerTools.
|
|
121
|
+
const origTool = server.tool.bind(server);
|
|
122
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
123
|
+
tools[name] = handler;
|
|
124
|
+
return origTool(name, _desc, _schema, handler);
|
|
125
|
+
};
|
|
126
|
+
// Some tools use registerTool; intercept that too
|
|
127
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
128
|
+
if (origRegisterTool) {
|
|
129
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
130
|
+
tools[name] = handler;
|
|
131
|
+
return origRegisterTool(name, _cfg, handler);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
registerTools(
|
|
135
|
+
server,
|
|
136
|
+
/* clientIP */ undefined,
|
|
137
|
+
/* enforceNetworkId */ null,
|
|
138
|
+
/* enforceUserId */ enforceUserId,
|
|
139
|
+
/* callerAlias */ null,
|
|
140
|
+
/* callerTokenIsNetwork */ false,
|
|
141
|
+
/* callerTokenId */ null,
|
|
142
|
+
);
|
|
143
|
+
const h = tools["list_host_supervisors"];
|
|
144
|
+
if (!h) throw new Error("list_host_supervisors tool not registered");
|
|
145
|
+
return h;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function callList(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
|
+
describe("list_host_supervisors HANDLER tests (RFC-026 §9.2, PR2 v2)", () => {
|
|
154
|
+
test("SEC-1.1: cross-tenant utok_ caller with foreign network_id → DENIED", async () => {
|
|
155
|
+
const userA = "u_lhs_alice"; // belongs to NET_ALPHA only
|
|
156
|
+
seedUser(userA);
|
|
157
|
+
seedNetwork(NET_ALPHA);
|
|
158
|
+
seedNetwork(NET_BETA);
|
|
159
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
160
|
+
seedDaemon({ network_id: NET_BETA, alias: "secret-beta-daemon" });
|
|
161
|
+
|
|
162
|
+
const handler = buildToolHandler(userA);
|
|
163
|
+
const reply = await callList(handler, { network_id: NET_BETA });
|
|
164
|
+
// PR2 v1 BUG: this returned ok:true with secret-beta-daemon in the list.
|
|
165
|
+
expect(reply.ok).toBe(false);
|
|
166
|
+
expect(reply.error).toMatch(/access denied|not a member/i);
|
|
167
|
+
// Belt: hostname not in error payload
|
|
168
|
+
expect(JSON.stringify(reply)).not.toContain("secret-beta-daemon");
|
|
169
|
+
expect(JSON.stringify(reply)).not.toContain("host-secret-beta-daemon");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("SEC-1.2: admin caller with own network_id returns own daemons", async () => {
|
|
173
|
+
const userA = "u_lhs_alice";
|
|
174
|
+
seedUser(userA);
|
|
175
|
+
seedNetwork(NET_ALPHA);
|
|
176
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
177
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alpha-daemon-1" });
|
|
178
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alpha-daemon-2" });
|
|
179
|
+
|
|
180
|
+
const handler = buildToolHandler(userA);
|
|
181
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
182
|
+
expect(reply.ok).toBe(true);
|
|
183
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
184
|
+
expect(aliases).toEqual(["alpha-daemon-1", "alpha-daemon-2"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("SEC-1.3: caller without network_id arg → returns daemons across member networks only", async () => {
|
|
188
|
+
const userA = "u_lhs_alice"; // member of NET_ALPHA only
|
|
189
|
+
seedUser(userA);
|
|
190
|
+
seedNetwork(NET_ALPHA);
|
|
191
|
+
seedNetwork(NET_BETA);
|
|
192
|
+
seedMembership(userA, NET_ALPHA, "member");
|
|
193
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "ok-daemon" });
|
|
194
|
+
seedDaemon({ network_id: NET_BETA, alias: "forbidden-daemon" });
|
|
195
|
+
|
|
196
|
+
const handler = buildToolHandler(userA);
|
|
197
|
+
const reply = await callList(handler);
|
|
198
|
+
expect(reply.ok).toBe(true);
|
|
199
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias);
|
|
200
|
+
expect(aliases).toContain("ok-daemon");
|
|
201
|
+
expect(aliases).not.toContain("forbidden-daemon");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("masking: member caller gets host_telemetry without IP/cpu/mem; admin gets full", async () => {
|
|
205
|
+
const userMember = "u_lhs_bob_member";
|
|
206
|
+
const userAdmin = "u_lhs_carol_admin";
|
|
207
|
+
seedUser(userMember);
|
|
208
|
+
seedUser(userAdmin);
|
|
209
|
+
seedNetwork(NET_ALPHA);
|
|
210
|
+
seedMembership(userMember, NET_ALPHA, "member");
|
|
211
|
+
seedMembership(userAdmin, NET_ALPHA, "admin");
|
|
212
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "telemetry-daemon", sessionAgeMs: 5_000 });
|
|
213
|
+
|
|
214
|
+
const memberHandler = buildToolHandler(userMember);
|
|
215
|
+
const memberReply = await callList(memberHandler, { network_id: NET_ALPHA });
|
|
216
|
+
expect(memberReply.daemons?.[0].host_telemetry.alert_level).toBeDefined();
|
|
217
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("cpu_cores");
|
|
218
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("mem_gb");
|
|
219
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("ip_internal");
|
|
220
|
+
|
|
221
|
+
const adminHandler = buildToolHandler(userAdmin);
|
|
222
|
+
const adminReply = await callList(adminHandler, { network_id: NET_ALPHA });
|
|
223
|
+
expect(adminReply.daemons?.[0].host_telemetry.cpu_cores).toBe(8);
|
|
224
|
+
expect(adminReply.daemons?.[0].host_telemetry.mem_gb).toBe(16);
|
|
225
|
+
expect(adminReply.daemons?.[0].host_telemetry.ip_internal).toBe("10.0.0.5");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("revoked: DELETEd token row (revokeToken path) excludes daemon", async () => {
|
|
229
|
+
const userA = "u_lhs_alice";
|
|
230
|
+
seedUser(userA);
|
|
231
|
+
seedNetwork(NET_ALPHA);
|
|
232
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
233
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alive-daemon" });
|
|
234
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "deleted-token-daemon", tokenDeleted: true });
|
|
235
|
+
|
|
236
|
+
const handler = buildToolHandler(userA);
|
|
237
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
238
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
239
|
+
expect(aliases).toContain("alive-daemon");
|
|
240
|
+
expect(aliases).not.toContain("deleted-token-daemon");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("revoked: token with revoked_at SET also excludes daemon", async () => {
|
|
244
|
+
const userA = "u_lhs_alice";
|
|
245
|
+
seedUser(userA);
|
|
246
|
+
seedNetwork(NET_ALPHA);
|
|
247
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
248
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alive-daemon" });
|
|
249
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "revoked-at-daemon", tokenRevokedAt: true });
|
|
250
|
+
|
|
251
|
+
const handler = buildToolHandler(userA);
|
|
252
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
253
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
254
|
+
expect(aliases).toContain("alive-daemon");
|
|
255
|
+
expect(aliases).not.toContain("revoked-at-daemon");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("role filter: regular member nodes (role=member) excluded after extraction", async () => {
|
|
259
|
+
const userA = "u_lhs_alice";
|
|
260
|
+
seedUser(userA);
|
|
261
|
+
seedNetwork(NET_ALPHA);
|
|
262
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
263
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "real-daemon", role: "host_supervisor" });
|
|
264
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "regular-node", role: "member" });
|
|
265
|
+
|
|
266
|
+
const handler = buildToolHandler(userA);
|
|
267
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
268
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
269
|
+
expect(aliases).toEqual(["real-daemon"]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("self-declare: runtimes_supported + allowed_secret_keys surface from columns", async () => {
|
|
273
|
+
const userA = "u_lhs_alice";
|
|
274
|
+
seedUser(userA);
|
|
275
|
+
seedNetwork(NET_ALPHA);
|
|
276
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
277
|
+
seedDaemon({
|
|
278
|
+
network_id: NET_ALPHA, alias: "rich-daemon",
|
|
279
|
+
runtimes: ["claude-agent-sdk", "codex-sdk", "grok-build-acp"],
|
|
280
|
+
secrets: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const handler = buildToolHandler(userA);
|
|
284
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
285
|
+
const d = reply.daemons?.[0];
|
|
286
|
+
expect(d.runtimes_supported).toEqual(["claude-agent-sdk", "codex-sdk", "grok-build-acp"]);
|
|
287
|
+
expect(d.allowed_secret_keys).toEqual(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("pre-PR2 daemon (NULL columns) extracts to empty arrays back-compat", async () => {
|
|
291
|
+
const userA = "u_lhs_alice";
|
|
292
|
+
seedUser(userA);
|
|
293
|
+
seedNetwork(NET_ALPHA);
|
|
294
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
295
|
+
db.run(
|
|
296
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, runtimes_supported, allowed_secret_keys, created_at, updated_at)
|
|
297
|
+
VALUES (?1, 'legacy', 'legacy', ?2, ?3, NULL, NULL, datetime('now'), datetime('now'))`,
|
|
298
|
+
["node_legacy_alpha", NET_ALPHA, JSON.stringify({ role: "host_supervisor" })],
|
|
299
|
+
);
|
|
300
|
+
// Seed active token so the EXISTS guard lets it through
|
|
301
|
+
db.run(
|
|
302
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash)
|
|
303
|
+
VALUES ('tok_legacy', 'u_seed_legacy', ?1, 'network', 'node:legacy', 'hash_legacy')`,
|
|
304
|
+
[NET_ALPHA],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const handler = buildToolHandler(userA);
|
|
308
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
309
|
+
const d = reply.daemons?.find(d => d.alias === "legacy");
|
|
310
|
+
expect(d).toBeTruthy();
|
|
311
|
+
expect(d.runtimes_supported).toEqual([]);
|
|
312
|
+
expect(d.allowed_secret_keys).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -298,6 +298,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
298
298
|
// discovery (#337 extracts this field). "host_supervisor" =
|
|
299
299
|
// anet daemon. Default-stripping zod would drop this otherwise.
|
|
300
300
|
role: z.string().max(64).optional().nullable(),
|
|
301
|
+
// RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
|
|
302
|
+
// `daemon_capabilities` (canonical shape per existing hub reads
|
|
303
|
+
// at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
|
|
304
|
+
// never saw them, max_concurrent_children stayed default + the
|
|
305
|
+
// allowlists stayed unenforced. PR3 nit ① per 通信龙).
|
|
306
|
+
// Soft caps avoid abuse via attacker daemon.
|
|
307
|
+
daemon_capabilities: z.object({
|
|
308
|
+
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
309
|
+
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
310
|
+
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
311
|
+
}).optional(),
|
|
301
312
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
302
313
|
},
|
|
303
314
|
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, config_snapshot: cfgSnap }) => {
|
|
@@ -1818,6 +1829,140 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1818
1829
|
throw e;
|
|
1819
1830
|
};
|
|
1820
1831
|
|
|
1832
|
+
// RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
|
|
1833
|
+
// Surfaces host_supervisor daemons in the caller's network with
|
|
1834
|
+
// online flag + declared capabilities. Replaces the prior `node_daemon_`
|
|
1835
|
+
// prefix heuristic (dashboard /api/anet/node-create) + the role-extract
|
|
1836
|
+
// path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
|
|
1837
|
+
// cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
|
|
1838
|
+
// to non-admin readers). Revoked daemon ntoks filtered out via
|
|
1839
|
+
// join on api_tokens.revoked_at.
|
|
1840
|
+
server.tool(
|
|
1841
|
+
"list_host_supervisors",
|
|
1842
|
+
"List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
|
|
1843
|
+
{
|
|
1844
|
+
network_id: z.string().max(200).optional(),
|
|
1845
|
+
},
|
|
1846
|
+
async ({ network_id: clientNetId }) => {
|
|
1847
|
+
// SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
|
|
1848
|
+
// for write-tool helpers and pairs with `canWrite`; READ tools that
|
|
1849
|
+
// bypass canWrite and trust getNetworkId leak across tenants
|
|
1850
|
+
// (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
|
|
1851
|
+
// enforceNetworkId=null could pass any network_id and read daemon
|
|
1852
|
+
// names + allowed_secret_keys for tenants they're not a member of).
|
|
1853
|
+
// resolveReadScope checks network_members for the user + denies
|
|
1854
|
+
// on non-membership, mirroring REST resolveRestNetworkScope.
|
|
1855
|
+
const readScope = resolveReadScope(clientNetId);
|
|
1856
|
+
if (readScope.denied) {
|
|
1857
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1858
|
+
}
|
|
1859
|
+
// Caller role for 脱敏 decision (admin/owner = full, others = masked).
|
|
1860
|
+
// Use the resolved scope's networkId (the verified one), not the
|
|
1861
|
+
// unchecked clientNetId.
|
|
1862
|
+
const scopedNetId = readScope.networkId ?? null;
|
|
1863
|
+
const callerRole = enforceUserId && scopedNetId
|
|
1864
|
+
? getUserNetworkRole(enforceUserId, scopedNetId)
|
|
1865
|
+
: null;
|
|
1866
|
+
// Network-bound tokens get full telemetry within their bound network
|
|
1867
|
+
// (already gate-locked by enforceNetworkId path of resolveReadScope).
|
|
1868
|
+
const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
|
|
1869
|
+
|
|
1870
|
+
// Pull candidate daemons. Active-token EXISTS subquery handles BOTH
|
|
1871
|
+
// revoked daemon ntoks (revoked_at set) AND DELETEd token rows
|
|
1872
|
+
// (the standard revokeToken path deletes the row, not flagging
|
|
1873
|
+
// revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
|
|
1874
|
+
// dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
|
|
1875
|
+
let sql = `
|
|
1876
|
+
SELECT
|
|
1877
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
1878
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
1879
|
+
n.created_at, n.updated_at,
|
|
1880
|
+
s.last_seen_at AS session_last_seen,
|
|
1881
|
+
s.status AS session_status,
|
|
1882
|
+
s.cpu_cores AS session_cpu_cores,
|
|
1883
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
1884
|
+
s.ip AS session_ip,
|
|
1885
|
+
n.config_snapshot
|
|
1886
|
+
FROM nodes n
|
|
1887
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
1888
|
+
WHERE EXISTS (
|
|
1889
|
+
SELECT 1 FROM api_tokens t
|
|
1890
|
+
WHERE t.network_id = n.network_id
|
|
1891
|
+
AND t.name = 'node:' || n.alias
|
|
1892
|
+
AND t.revoked_at IS NULL
|
|
1893
|
+
)
|
|
1894
|
+
`;
|
|
1895
|
+
const sqlParams: any[] = [];
|
|
1896
|
+
sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
|
|
1897
|
+
sql += ` ORDER BY n.updated_at DESC`;
|
|
1898
|
+
const rows = db.all<Record<string, any>>(sql, ...sqlParams);
|
|
1899
|
+
|
|
1900
|
+
// Filter to role=host_supervisor (read from config_snapshot;
|
|
1901
|
+
// schema-promoted columns runtimes_supported/allowed_secret_keys
|
|
1902
|
+
// are pre-extracted but role isn't a first-class column).
|
|
1903
|
+
const nowMs = Date.now();
|
|
1904
|
+
const ONLINE_MS = 60_000;
|
|
1905
|
+
const daemons = rows
|
|
1906
|
+
.map(r => {
|
|
1907
|
+
let snapRole: string | null = null;
|
|
1908
|
+
if (r.config_snapshot) {
|
|
1909
|
+
try {
|
|
1910
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
1911
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
1912
|
+
} catch { /* malformed snapshot — role stays null */ }
|
|
1913
|
+
}
|
|
1914
|
+
return { row: r, role: snapRole };
|
|
1915
|
+
})
|
|
1916
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
1917
|
+
.map(({ row: r }) => {
|
|
1918
|
+
// online = sessions.last_seen_at within ONLINE_MS
|
|
1919
|
+
let online = false;
|
|
1920
|
+
let lastSeenAt: string | null = null;
|
|
1921
|
+
if (r.session_last_seen) {
|
|
1922
|
+
lastSeenAt = r.session_last_seen;
|
|
1923
|
+
const t = Date.parse(r.session_last_seen);
|
|
1924
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
1925
|
+
}
|
|
1926
|
+
// Parse self-declare arrays (default to [] for pre-PR2 daemons)
|
|
1927
|
+
let runtimes: string[] = [];
|
|
1928
|
+
let secrets: string[] = [];
|
|
1929
|
+
try {
|
|
1930
|
+
if (r.runtimes_supported) {
|
|
1931
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
1932
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
1933
|
+
}
|
|
1934
|
+
} catch { /* malformed — empty */ }
|
|
1935
|
+
try {
|
|
1936
|
+
if (r.allowed_secret_keys) {
|
|
1937
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
1938
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
1939
|
+
}
|
|
1940
|
+
} catch { /* malformed — empty */ }
|
|
1941
|
+
// host_telemetry — member脱敏 drops IP/cpu/mem
|
|
1942
|
+
const telemetry: Record<string, unknown> = {
|
|
1943
|
+
alert_level: online ? "green" : "gray",
|
|
1944
|
+
};
|
|
1945
|
+
if (isPrivileged) {
|
|
1946
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
1947
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
1948
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
1949
|
+
}
|
|
1950
|
+
return {
|
|
1951
|
+
daemon_node_id: r.node_id,
|
|
1952
|
+
alias: r.alias,
|
|
1953
|
+
hostname: r.hostname,
|
|
1954
|
+
online,
|
|
1955
|
+
last_seen_at: lastSeenAt,
|
|
1956
|
+
runtimes_supported: runtimes,
|
|
1957
|
+
allowed_secret_keys: secrets,
|
|
1958
|
+
host_telemetry: telemetry,
|
|
1959
|
+
};
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
|
|
1963
|
+
},
|
|
1964
|
+
);
|
|
1965
|
+
|
|
1821
1966
|
// §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
|
|
1822
1967
|
// child-ntok, stash env_blob in pendingEnvBlobs Map, write request
|
|
1823
1968
|
// row (metadata only — no env_blob in SQL), pushEvent doorbell.
|
|
@@ -1860,21 +2005,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1860
2005
|
}
|
|
1861
2006
|
|
|
1862
2007
|
// Read daemon's host_supervisor capability + allowlist from its
|
|
1863
|
-
// last reported config_snapshot.
|
|
1864
|
-
// (
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
2008
|
+
// last reported config_snapshot. PR3 (#338) canonical path is
|
|
2009
|
+
// `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
|
|
2010
|
+
// Pre-PR3 daemons (preview.10 and earlier) place these at the
|
|
2011
|
+
// TOP level of the snapshot rather than nested — those reads
|
|
2012
|
+
// return undefined here and `daemonAllowedRuntimes` stays null
|
|
2013
|
+
// (permissive — no allowlist enforcement); they fall back to
|
|
2014
|
+
// §4.2.2 structural validation only, identical to pre-PR3
|
|
2015
|
+
// behavior. No regression on in-flight daemons.
|
|
1867
2016
|
let daemonAllowList = new Set<string>();
|
|
1868
2017
|
let daemonAllowedRuntimes: string[] | null = null;
|
|
1869
2018
|
try {
|
|
1870
2019
|
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
1871
|
-
|
|
1872
|
-
|
|
2020
|
+
const caps = snap?.daemon_capabilities;
|
|
2021
|
+
if (Array.isArray(caps?.allowed_secret_keys)) {
|
|
2022
|
+
daemonAllowList = new Set(caps.allowed_secret_keys);
|
|
1873
2023
|
}
|
|
1874
|
-
if (Array.isArray(
|
|
1875
|
-
daemonAllowedRuntimes =
|
|
2024
|
+
if (Array.isArray(caps?.runtimes_supported)) {
|
|
2025
|
+
daemonAllowedRuntimes = caps.runtimes_supported;
|
|
1876
2026
|
}
|
|
1877
|
-
} catch { /* permissive
|
|
2027
|
+
} catch { /* permissive fallback */ }
|
|
1878
2028
|
|
|
1879
2029
|
// §4.2.2 — structural validation (catches name/runtime/model/
|
|
1880
2030
|
// flag injection at the hub edge). Daemon repeats this; double
|
|
@@ -2104,14 +2254,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2104
2254
|
// record fork-side info.
|
|
2105
2255
|
server.tool(
|
|
2106
2256
|
"ack_create_request",
|
|
2107
|
-
"Daemon acks a create-node request (called after fork). status='started'
|
|
2257
|
+
"Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
|
|
2108
2258
|
{
|
|
2109
2259
|
request_id: z.string().min(1).max(200),
|
|
2110
|
-
|
|
2260
|
+
// RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
|
|
2261
|
+
// daemon spawned the child OK but it died within FAIL_FAST_MS
|
|
2262
|
+
// (5s in agent-node v2.5.0-preview.11+), indicating a
|
|
2263
|
+
// declaration↔reality gap on this daemon's runtimes_supported.
|
|
2264
|
+
// Treated terminal like 'failed' but fires a distinct audit_log
|
|
2265
|
+
// action so dashboards can highlight "lying daemons" separately
|
|
2266
|
+
// from generic spawn failures.
|
|
2267
|
+
status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
|
|
2111
2268
|
error: z.string().max(1000).optional(),
|
|
2112
2269
|
child_pid: z.number().int().optional(),
|
|
2270
|
+
runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
|
|
2113
2271
|
},
|
|
2114
|
-
async ({ request_id, status, error: ackError, child_pid: _pid }) => {
|
|
2272
|
+
async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
|
|
2115
2273
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2116
2274
|
if (!callerDaemon.ok) {
|
|
2117
2275
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
@@ -2137,7 +2295,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2137
2295
|
);
|
|
2138
2296
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
|
|
2139
2297
|
}
|
|
2140
|
-
// failed / rejected — revoke
|
|
2298
|
+
// failed / rejected / runtime_capability_check_failed — revoke
|
|
2299
|
+
// child-ntok + mark request terminal.
|
|
2141
2300
|
if (row.child_token_id) {
|
|
2142
2301
|
db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
|
|
2143
2302
|
}
|
|
@@ -2145,6 +2304,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2145
2304
|
`UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
|
|
2146
2305
|
[status, ackError || null, ackedAt, request_id],
|
|
2147
2306
|
);
|
|
2307
|
+
// RFC-026 §9.3 D2 — surface declaration↔reality gap on a
|
|
2308
|
+
// distinct audit_log action so dashboards / operators can spot
|
|
2309
|
+
// chronically-lying daemons separate from generic spawn-failed.
|
|
2310
|
+
if (status === "runtime_capability_check_failed") {
|
|
2311
|
+
auditCreateNode({
|
|
2312
|
+
action: "daemon_capability_lied",
|
|
2313
|
+
user_id: null,
|
|
2314
|
+
network_id: row.network_id,
|
|
2315
|
+
target_id: request_id,
|
|
2316
|
+
detail: {
|
|
2317
|
+
daemon_node_id: row.daemon_node_id,
|
|
2318
|
+
runtime: ackRuntime || null,
|
|
2319
|
+
error: ackError ? ackError.slice(0, 500) : null,
|
|
2320
|
+
acked_at: ackedAt,
|
|
2321
|
+
},
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2148
2324
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
2149
2325
|
},
|
|
2150
2326
|
);
|
|
@@ -2699,9 +2875,32 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2699
2875
|
],
|
|
2700
2876
|
);
|
|
2701
2877
|
if (input.config_snapshot) {
|
|
2878
|
+
// RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
|
|
2879
|
+
// to first-class indexable columns alongside the snapshot blob.
|
|
2880
|
+
// The snapshot stays the source of truth for non-list reads; the
|
|
2881
|
+
// columns exist so `list_host_supervisors` doesn't JSON.parse on
|
|
2882
|
+
// every call. typeof-narrow per
|
|
2883
|
+
// [[feedback_typeof_narrow_extracted_fields]] — zod narrowed but
|
|
2884
|
+
// input.config_snapshot is typed `unknown` here.
|
|
2885
|
+
//
|
|
2886
|
+
// PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
|
|
2887
|
+
// per RFC §9.3 + matches existing hub create_node reads at
|
|
2888
|
+
// tools.ts:2010/2075). PR2 ate from top-level keys, which the
|
|
2889
|
+
// hub create_node path never read → max_concurrent_children
|
|
2890
|
+
// backpressure was dead config + allowlist enforcement bypassed.
|
|
2891
|
+
const snap = input.config_snapshot as Record<string, unknown> | null;
|
|
2892
|
+
const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
|
|
2893
|
+
const runtimesRaw = caps?.runtimes_supported;
|
|
2894
|
+
const allowedRaw = caps?.allowed_secret_keys;
|
|
2895
|
+
const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
|
|
2896
|
+
? JSON.stringify(runtimesRaw)
|
|
2897
|
+
: null;
|
|
2898
|
+
const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
|
|
2899
|
+
? JSON.stringify(allowedRaw)
|
|
2900
|
+
: null;
|
|
2702
2901
|
db.run(
|
|
2703
|
-
`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?
|
|
2704
|
-
[JSON.stringify(input.config_snapshot), input.node_id],
|
|
2902
|
+
`UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
|
|
2903
|
+
[JSON.stringify(input.config_snapshot), runtimesJson, allowedJson, input.node_id],
|
|
2705
2904
|
);
|
|
2706
2905
|
// RFC-024 — finalize any pending/restarting update whose target
|
|
2707
2906
|
// patch is now reflected in the snapshot. Closes the
|