@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +622 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message.test.ts +286 -0
  30. package/src/ext-token-shared.test.ts +60 -0
  31. package/src/ext-validation-regression.test.ts +131 -0
  32. package/src/external-schedule-edits-http.test.ts +316 -0
  33. package/src/external-schedule-edits.ts +314 -0
  34. package/src/file-download-authz-dev-open.test.ts +135 -0
  35. package/src/file-download-authz.test.ts +403 -0
  36. package/src/file-download-date-bucket.test.ts +436 -0
  37. package/src/file-network-scope-dev-open.test.ts +97 -0
  38. package/src/file-network-scope.test.ts +731 -0
  39. package/src/health-redaction.test.ts +637 -0
  40. package/src/inbox-count-sse.test.ts +310 -0
  41. package/src/inbox-count.ts +29 -0
  42. package/src/index.ts +15 -2187
  43. package/src/lifecycle-guard.ts +50 -0
  44. package/src/list-host-supervisors.test.ts +314 -0
  45. package/src/mcp-write-network-resolution.test.ts +429 -0
  46. package/src/network-name-http.test.ts +71 -0
  47. package/src/network-scope.ts +101 -0
  48. package/src/node-attrs-validate.ts +88 -0
  49. package/src/node-attrs.test.ts +213 -0
  50. package/src/node-id-alias.test.ts +147 -0
  51. package/src/node-lifecycle-controllable.test.ts +64 -0
  52. package/src/node-lifecycle-controllable.ts +51 -0
  53. package/src/observer-avatar-http.test.ts +424 -0
  54. package/src/observer-push.test.ts +252 -0
  55. package/src/package-version-consistency.test.ts +25 -0
  56. package/src/peer-reply-atomic.test.ts +455 -0
  57. package/src/peer-reply-capability.test.ts +70 -0
  58. package/src/probe-validate.test.ts +221 -0
  59. package/src/probe-validate.ts +104 -0
  60. package/src/probe.test.ts +80 -0
  61. package/src/probe.ts +200 -0
  62. package/src/push.ts +163 -7
  63. package/src/reply-target-derivation.test.ts +96 -0
  64. package/src/require-explicit-test-db.ts +9 -0
  65. package/src/resolve-port.test.ts +57 -0
  66. package/src/resolve-port.ts +44 -0
  67. package/src/response-charset.test.ts +130 -0
  68. package/src/rest-explicit-columns-http.test.ts +212 -0
  69. package/src/rest-identity.test.ts +129 -0
  70. package/src/rest-identity.ts +100 -0
  71. package/src/rest-projections.ts +67 -0
  72. package/src/rest-write-network-resolution.test.ts +133 -0
  73. package/src/runtime-label.test.ts +22 -0
  74. package/src/runtime-label.ts +21 -0
  75. package/src/scheduled-run-terminal.test.ts +268 -0
  76. package/src/scheduled-tasks-http.test.ts +479 -0
  77. package/src/scheduled-tasks.ts +549 -0
  78. package/src/send-reply-agent-warning.test.ts +202 -0
  79. package/src/send-reply-alias-optional.test.ts +131 -0
  80. package/src/send-reply-attachments.test.ts +437 -0
  81. package/src/server.ts +3591 -0
  82. package/src/shared/external-schedule-contract.ts +60 -0
  83. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  84. package/src/shared/probe-host-allowlist.ts +128 -0
  85. package/src/side-thread-command-transport.test.ts +302 -0
  86. package/src/side-thread-command-transport.ts +318 -0
  87. package/src/side-thread-http-integration.test.ts +229 -0
  88. package/src/side-thread-http.ts +504 -0
  89. package/src/side-thread-production.test.ts +72 -0
  90. package/src/side-thread-production.ts +118 -0
  91. package/src/side-thread.test.ts +1067 -0
  92. package/src/side-thread.ts +2495 -0
  93. package/src/skillhub-http.test.ts +88 -0
  94. package/src/start-node.test.ts +109 -0
  95. package/src/stop-delete-node.test.ts +952 -0
  96. package/src/task-auth-origin-http.test.ts +87 -0
  97. package/src/task-auth-origin.test.ts +24 -0
  98. package/src/task-auth-origin.ts +7 -0
  99. package/src/task-consumption.test.ts +471 -0
  100. package/src/task-diagnostic-http.test.ts +145 -0
  101. package/src/task-diagnostic.test.ts +115 -0
  102. package/src/task-diagnostic.ts +118 -0
  103. package/src/task-idempotency-mcp.test.ts +142 -0
  104. package/src/task-idempotency.test.ts +60 -0
  105. package/src/task-idempotency.ts +75 -0
  106. package/src/task-lifecycle-watcher.test.ts +200 -0
  107. package/src/task-lifecycle-watcher.ts +49 -0
  108. package/src/task-network-resolution-http.test.ts +137 -0
  109. package/src/tasks-pagination-http.test.ts +137 -0
  110. package/src/tools.ts +2952 -191
  111. package/src/update-provider.test.ts +234 -0
  112. package/src/upload-early-reject-guard.test.ts +155 -0
  113. package/src/uploads-http.test.ts +92 -14
  114. package/src/uploads.test.ts +66 -0
  115. package/src/uploads.ts +130 -5
  116. package/src/vault.test.ts +200 -0
  117. package/src/vault.ts +156 -0
@@ -0,0 +1,50 @@
1
+ // RFC-027 §2.3 race-free invariant — shared helper for the inbox-
2
+ // enqueue lifecycle guard.
3
+ //
4
+ // PR1.1 (#346) added an `assertNodeActive` helper inside the
5
+ // registerTools() closure and applied it at the 6 INSERT INTO inbox
6
+ // sites in server/src/tools.ts. PR1.1 review (通信龙 #346 ack) caught
7
+ // that the closure scope made the helper unreachable from REST
8
+ // handlers in server/src/index.ts — POST /api/task (:1631) and
9
+ // POST /api/broadcast (:1706) are dashboard's "Dispatch" entry
10
+ // points and bypassed the guard, leaving the §2.3 race open on the
11
+ // REST path.
12
+ //
13
+ // PR1.2a (#346 follow-up) extracts the helper to this module so both
14
+ // the MCP tools and the REST handlers can import the same code path,
15
+ // per team rule (grep every write site before adding a guard): any SQL-level
16
+ // guard's helper lives at module scope so every write site
17
+ // (MCP + REST + internal db.ts helpers) can use it.
18
+ //
19
+ // Behaviour:
20
+ // - no nodes row for the alias → ok:true (brand-new alias allowed;
21
+ // could be the first INSERT for a child about to register)
22
+ // - lifecycle_state = 'active' OR NULL (pre-RFC-027 row) → ok:true
23
+ // - anything else (stopping / stopped / stop_failed / deleting) →
24
+ // ok:false + structured `node_not_active` reply
25
+ //
26
+ // COALESCE on the WHERE keeps NULL-network inbox rows / legacy nodes
27
+ // rows correctly scoped (PR1 SF-5 lineage).
28
+
29
+ import { db } from "./db.js";
30
+
31
+ export type LifecycleGuardResult =
32
+ | { ok: true }
33
+ | { ok: false; error: "node_not_active"; lifecycle_state: string; alias: string };
34
+
35
+ export function assertNodeActive(
36
+ sessionAlias: string,
37
+ networkId: string | null,
38
+ ): LifecycleGuardResult {
39
+ if (!sessionAlias) return { ok: true };
40
+ const row = db.get<{ lifecycle_state: string | null }>(
41
+ networkId
42
+ ? `SELECT lifecycle_state FROM nodes WHERE alias = ?1 AND COALESCE(network_id, ?2) = ?2 LIMIT 1`
43
+ : `SELECT lifecycle_state FROM nodes WHERE alias = ?1 LIMIT 1`,
44
+ ...(networkId ? [sessionAlias, networkId] : [sessionAlias]),
45
+ );
46
+ if (!row) return { ok: true };
47
+ const st = row.lifecycle_state ?? "active";
48
+ if (st === "active") return { ok: true };
49
+ return { ok: false, error: "node_not_active", lifecycle_state: st, alias: sessionAlias };
50
+ }
@@ -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
+ });