@sleep2agi/commhub-server 0.9.0-preview.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.7",
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
+ });
@@ -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: "create_node_dispatched" | "create_node_rejected" | "create_node_succeeded" | "create_node_sweeper_revoked";
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/tools.ts CHANGED
@@ -298,11 +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 — 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
+ // 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(),
306
312
  }).optional().describe("RFC-024 — masked node config snapshot"),
307
313
  },
308
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 }) => {
@@ -1999,21 +2005,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1999
2005
  }
2000
2006
 
2001
2007
  // Read daemon's host_supervisor capability + allowlist from its
2002
- // last reported config_snapshot. P1 simplification: we accept
2003
- // (allowed_runtimes empty / allowed_secret_keys absent) as
2004
- // "accept any in the global enum"; P2 will tighten when daemon
2005
- // self-publishes its own allowlist via daemon_capabilities.
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.
2006
2016
  let daemonAllowList = new Set<string>();
2007
2017
  let daemonAllowedRuntimes: string[] | null = null;
2008
2018
  try {
2009
2019
  const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
2010
- if (snap?.daemon_capabilities?.allowed_secret_keys) {
2011
- daemonAllowList = new Set(snap.daemon_capabilities.allowed_secret_keys);
2020
+ const caps = snap?.daemon_capabilities;
2021
+ if (Array.isArray(caps?.allowed_secret_keys)) {
2022
+ daemonAllowList = new Set(caps.allowed_secret_keys);
2012
2023
  }
2013
- if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
2014
- daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
2024
+ if (Array.isArray(caps?.runtimes_supported)) {
2025
+ daemonAllowedRuntimes = caps.runtimes_supported;
2015
2026
  }
2016
- } catch { /* permissive P1 fallback */ }
2027
+ } catch { /* permissive fallback */ }
2017
2028
 
2018
2029
  // §4.2.2 — structural validation (catches name/runtime/model/
2019
2030
  // flag injection at the hub edge). Daemon repeats this; double
@@ -2243,14 +2254,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2243
2254
  // record fork-side info.
2244
2255
  server.tool(
2245
2256
  "ack_create_request",
2246
- "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
2257
+ "Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
2247
2258
  {
2248
2259
  request_id: z.string().min(1).max(200),
2249
- status: z.enum(["started", "failed", "rejected"]),
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"]),
2250
2268
  error: z.string().max(1000).optional(),
2251
2269
  child_pid: z.number().int().optional(),
2270
+ runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
2252
2271
  },
2253
- async ({ request_id, status, error: ackError, child_pid: _pid }) => {
2272
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
2254
2273
  const callerDaemon = resolveCallerDaemonTokenBound();
2255
2274
  if (!callerDaemon.ok) {
2256
2275
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -2276,7 +2295,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2276
2295
  );
2277
2296
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2278
2297
  }
2279
- // failed / rejected — revoke child-ntok + mark request terminal
2298
+ // failed / rejected / runtime_capability_check_failed — revoke
2299
+ // child-ntok + mark request terminal.
2280
2300
  if (row.child_token_id) {
2281
2301
  db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2282
2302
  }
@@ -2284,6 +2304,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2284
2304
  `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2285
2305
  [status, ackError || null, ackedAt, request_id],
2286
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
+ }
2287
2324
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2288
2325
  },
2289
2326
  );
@@ -2838,16 +2875,23 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2838
2875
  ],
2839
2876
  );
2840
2877
  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).
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.
2848
2891
  const snap = input.config_snapshot as Record<string, unknown> | null;
2849
- const runtimesRaw = snap?.runtimes_supported;
2850
- const allowedRaw = snap?.allowed_secret_keys;
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;
2851
2895
  const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
2852
2896
  ? JSON.stringify(runtimesRaw)
2853
2897
  : null;