@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.6",
3
+ "version": "0.9.0-preview.7",
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",
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,11 @@ 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 — daemon self-declare. Promoted to first-class
302
+ // columns by PR2 so list_host_supervisors reads them directly
303
+ // (no per-call snapshot JSON parse). Soft caps avoid abuse.
304
+ runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
305
+ allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
301
306
  }).optional().describe("RFC-024 — masked node config snapshot"),
302
307
  },
303
308
  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 +1823,140 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1818
1823
  throw e;
1819
1824
  };
1820
1825
 
1826
+ // RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
1827
+ // Surfaces host_supervisor daemons in the caller's network with
1828
+ // online flag + declared capabilities. Replaces the prior `node_daemon_`
1829
+ // prefix heuristic (dashboard /api/anet/node-create) + the role-extract
1830
+ // path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
1831
+ // cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
1832
+ // to non-admin readers). Revoked daemon ntoks filtered out via
1833
+ // join on api_tokens.revoked_at.
1834
+ server.tool(
1835
+ "list_host_supervisors",
1836
+ "List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
1837
+ {
1838
+ network_id: z.string().max(200).optional(),
1839
+ },
1840
+ async ({ network_id: clientNetId }) => {
1841
+ // SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
1842
+ // for write-tool helpers and pairs with `canWrite`; READ tools that
1843
+ // bypass canWrite and trust getNetworkId leak across tenants
1844
+ // (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
1845
+ // enforceNetworkId=null could pass any network_id and read daemon
1846
+ // names + allowed_secret_keys for tenants they're not a member of).
1847
+ // resolveReadScope checks network_members for the user + denies
1848
+ // on non-membership, mirroring REST resolveRestNetworkScope.
1849
+ const readScope = resolveReadScope(clientNetId);
1850
+ if (readScope.denied) {
1851
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1852
+ }
1853
+ // Caller role for 脱敏 decision (admin/owner = full, others = masked).
1854
+ // Use the resolved scope's networkId (the verified one), not the
1855
+ // unchecked clientNetId.
1856
+ const scopedNetId = readScope.networkId ?? null;
1857
+ const callerRole = enforceUserId && scopedNetId
1858
+ ? getUserNetworkRole(enforceUserId, scopedNetId)
1859
+ : null;
1860
+ // Network-bound tokens get full telemetry within their bound network
1861
+ // (already gate-locked by enforceNetworkId path of resolveReadScope).
1862
+ const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
1863
+
1864
+ // Pull candidate daemons. Active-token EXISTS subquery handles BOTH
1865
+ // revoked daemon ntoks (revoked_at set) AND DELETEd token rows
1866
+ // (the standard revokeToken path deletes the row, not flagging
1867
+ // revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
1868
+ // dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
1869
+ let sql = `
1870
+ SELECT
1871
+ n.node_id, n.alias, n.hostname, n.network_id,
1872
+ n.runtimes_supported, n.allowed_secret_keys,
1873
+ n.created_at, n.updated_at,
1874
+ s.last_seen_at AS session_last_seen,
1875
+ s.status AS session_status,
1876
+ s.cpu_cores AS session_cpu_cores,
1877
+ s.mem_total_gb AS session_mem_total_gb,
1878
+ s.ip AS session_ip,
1879
+ n.config_snapshot
1880
+ FROM nodes n
1881
+ LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
1882
+ WHERE EXISTS (
1883
+ SELECT 1 FROM api_tokens t
1884
+ WHERE t.network_id = n.network_id
1885
+ AND t.name = 'node:' || n.alias
1886
+ AND t.revoked_at IS NULL
1887
+ )
1888
+ `;
1889
+ const sqlParams: any[] = [];
1890
+ sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
1891
+ sql += ` ORDER BY n.updated_at DESC`;
1892
+ const rows = db.all<Record<string, any>>(sql, ...sqlParams);
1893
+
1894
+ // Filter to role=host_supervisor (read from config_snapshot;
1895
+ // schema-promoted columns runtimes_supported/allowed_secret_keys
1896
+ // are pre-extracted but role isn't a first-class column).
1897
+ const nowMs = Date.now();
1898
+ const ONLINE_MS = 60_000;
1899
+ const daemons = rows
1900
+ .map(r => {
1901
+ let snapRole: string | null = null;
1902
+ if (r.config_snapshot) {
1903
+ try {
1904
+ const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
1905
+ snapRole = typeof parsed?.role === "string" ? parsed.role : null;
1906
+ } catch { /* malformed snapshot — role stays null */ }
1907
+ }
1908
+ return { row: r, role: snapRole };
1909
+ })
1910
+ .filter(({ role }) => role === "host_supervisor")
1911
+ .map(({ row: r }) => {
1912
+ // online = sessions.last_seen_at within ONLINE_MS
1913
+ let online = false;
1914
+ let lastSeenAt: string | null = null;
1915
+ if (r.session_last_seen) {
1916
+ lastSeenAt = r.session_last_seen;
1917
+ const t = Date.parse(r.session_last_seen);
1918
+ if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
1919
+ }
1920
+ // Parse self-declare arrays (default to [] for pre-PR2 daemons)
1921
+ let runtimes: string[] = [];
1922
+ let secrets: string[] = [];
1923
+ try {
1924
+ if (r.runtimes_supported) {
1925
+ const parsed = JSON.parse(r.runtimes_supported);
1926
+ runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
1927
+ }
1928
+ } catch { /* malformed — empty */ }
1929
+ try {
1930
+ if (r.allowed_secret_keys) {
1931
+ const parsed = JSON.parse(r.allowed_secret_keys);
1932
+ secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
1933
+ }
1934
+ } catch { /* malformed — empty */ }
1935
+ // host_telemetry — member脱敏 drops IP/cpu/mem
1936
+ const telemetry: Record<string, unknown> = {
1937
+ alert_level: online ? "green" : "gray",
1938
+ };
1939
+ if (isPrivileged) {
1940
+ telemetry.cpu_cores = r.session_cpu_cores ?? null;
1941
+ telemetry.mem_gb = r.session_mem_total_gb ?? null;
1942
+ telemetry.ip_internal = r.session_ip ?? null;
1943
+ }
1944
+ return {
1945
+ daemon_node_id: r.node_id,
1946
+ alias: r.alias,
1947
+ hostname: r.hostname,
1948
+ online,
1949
+ last_seen_at: lastSeenAt,
1950
+ runtimes_supported: runtimes,
1951
+ allowed_secret_keys: secrets,
1952
+ host_telemetry: telemetry,
1953
+ };
1954
+ });
1955
+
1956
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
1957
+ },
1958
+ );
1959
+
1821
1960
  // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
1822
1961
  // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
1823
1962
  // row (metadata only — no env_blob in SQL), pushEvent doorbell.
@@ -2699,9 +2838,25 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2699
2838
  ],
2700
2839
  );
2701
2840
  if (input.config_snapshot) {
2841
+ // RFC-026 §9.3 / #338 PR2 — promote daemon self-declare fields to
2842
+ // first-class indexable columns alongside the snapshot blob. The
2843
+ // snapshot stays the source of truth for non-list reads; the columns
2844
+ // exist so `list_host_supervisors` doesn't JSON.parse on every call.
2845
+ // typeof-narrow the array fields (don't trust shape per
2846
+ // [[feedback_typeof_narrow_extracted_fields]]; zod has already
2847
+ // narrowed, but the input.config_snapshot type is `unknown` here).
2848
+ const snap = input.config_snapshot as Record<string, unknown> | null;
2849
+ const runtimesRaw = snap?.runtimes_supported;
2850
+ const allowedRaw = snap?.allowed_secret_keys;
2851
+ const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
2852
+ ? JSON.stringify(runtimesRaw)
2853
+ : null;
2854
+ const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
2855
+ ? JSON.stringify(allowedRaw)
2856
+ : null;
2702
2857
  db.run(
2703
- `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
2704
- [JSON.stringify(input.config_snapshot), input.node_id],
2858
+ `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
2859
+ [JSON.stringify(input.config_snapshot), runtimesJson, allowedJson, input.node_id],
2705
2860
  );
2706
2861
  // RFC-024 — finalize any pending/restarting update whose target
2707
2862
  // patch is now reflected in the snapshot. Closes the