@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12

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/src/tools.ts CHANGED
@@ -1,9 +1,55 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
- import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
3
+ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
4
4
  import { pushEvent } from "./push.js";
5
- import { getUserNetworkRole } from "./auth.js";
5
+ import { assertNodeActive } from "./lifecycle-guard.js";
6
+ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
7
+ import {
8
+ buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
9
+ validateName as validateChildName,
10
+ validateRuntime,
11
+ validateModel,
12
+ validateChannelsP1,
13
+ validateEnvRefs,
14
+ FLAG_KEYS,
15
+ validateFlagValue,
16
+ ValidationError,
17
+ } from "./create-node-validate.js";
18
+ import {
19
+ putPendingEnvBlob,
20
+ takePendingEnvBlob,
21
+ newRequestId,
22
+ finalizeCreateOnFirstRegister,
23
+ startPendingEnvGcTimer,
24
+ startSweeperTimer,
25
+ auditCreateNode,
26
+ auditCreateNodeStrict,
27
+ resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
28
+ } from "./create-node.js";
29
+ import {
30
+ vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
31
+ VaultError,
32
+ } from "./vault.js";
33
+ import {
34
+ validateBaseUrl as _validateBaseUrl,
35
+ SUPPORTED_VENDORS,
36
+ ProbeValidationError,
37
+ } from "./probe-validate.js";
38
+ import {
39
+ putPendingProbeSecret,
40
+ newProbeId,
41
+ finalizeProbeAck,
42
+ startPendingProbeGcTimer,
43
+ startProbeSweeperTimer,
44
+ } from "./probe.js";
6
45
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
46
+ import {
47
+ ALLOWED_FLAGS,
48
+ SECURITY_SENSITIVE_FLAGS,
49
+ computeApplyMode,
50
+ validatePatch,
51
+ isAllowedToChangeFlag,
52
+ } from "./config-apply-validate.js";
7
53
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
8
54
 
9
55
  function ts(): string {
@@ -97,6 +143,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
97
143
  return { networkId: null, networkIds: getReadableNetworkIds() };
98
144
  };
99
145
 
146
+ // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
147
+ // server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
148
+ // can use the SAME code path. PR1.1 had it inline here; PR1.2a
149
+ // (#346 review catch) extracted because the closure scope made it
150
+ // unreachable from REST and left the §2.3 race open on dashboard
151
+ // Dispatch (POST /api/task + /api/broadcast). Per
152
+ // [[feedback_grep_all_sites_before_apply_guard]].
153
+
100
154
  const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
101
155
  if (scope.networkId) {
102
156
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -237,8 +291,37 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
237
291
  uptime_seconds: z.number().nullable().optional(),
238
292
  in_flight_count: z.number().nullable().optional(),
239
293
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
294
+ // RFC-024 B6 — masked snapshot of the node's effective config
295
+ // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
296
+ // shape (env._envRef stays on host); the dashboard reads this
297
+ // verbatim for the snapshot path without touching node files.
298
+ // config_update_capable signals whether the node runs under a
299
+ // supervisor wrapper that honours the sentinel-75 restart path
300
+ // (W1) — bare-spawned agent-nodes set this to false so dashboard
301
+ // can grey out remote-restart for them.
302
+ config_snapshot: z.object({
303
+ model: z.string().max(200).optional().nullable(),
304
+ flags: z.record(z.unknown()).optional(),
305
+ config_revision: z.number().int().min(0).optional(),
306
+ config_update_capable: z.boolean().optional(),
307
+ // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
308
+ // discovery (#337 extracts this field). "host_supervisor" =
309
+ // anet daemon. Default-stripping zod would drop this otherwise.
310
+ role: z.string().max(64).optional().nullable(),
311
+ // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
312
+ // `daemon_capabilities` (canonical shape per existing hub reads
313
+ // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
314
+ // never saw them, max_concurrent_children stayed default + the
315
+ // allowlists stayed unenforced. PR3 nit ① per 通信龙).
316
+ // Soft caps avoid abuse via attacker daemon.
317
+ daemon_capabilities: z.object({
318
+ runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
319
+ allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
320
+ max_concurrent_children: z.number().int().min(1).max(1000).optional(),
321
+ }).optional(),
322
+ }).optional().describe("RFC-024 — masked node config snapshot"),
240
323
  },
241
- 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 }) => {
324
+ 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 }) => {
242
325
  const effectiveNetId = getNetworkId(netId);
243
326
  const sessionNetId = effectiveNetId ?? "default";
244
327
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -391,27 +474,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
391
474
  } catch {}
392
475
  }
393
476
 
394
- // V2: upsert nodes table for persistent node identity
477
+ // V2: upsert nodes table for persistent node identity. SEC-1
478
+ // gate (PR A #287 follow-up, 通信牛 catch 2026-06-28): delegate
479
+ // to upsertNodeWithSec1Guard so production + test exercise the
480
+ // exact same code path. See helper below registerTools.
395
481
  if (node_id) {
396
482
  try {
397
- // Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
398
483
  const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
399
- db.run(
400
- `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, updated_at)
401
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))
402
- ON CONFLICT(node_id) DO UPDATE SET
403
- node_name = COALESCE(?2, nodes.node_name),
404
- alias = COALESCE(?3, nodes.alias),
405
- runtime = COALESCE(?4, nodes.runtime),
406
- model = COALESCE(?5, nodes.model),
407
- config_path = COALESCE(?6, nodes.config_path),
408
- channels = COALESCE(?7, nodes.channels),
409
- server = COALESCE(?8, nodes.server),
410
- hostname = COALESCE(?9, nodes.hostname),
411
- network_id = COALESCE(?10, nodes.network_id),
412
- updated_at = datetime('now')`,
413
- [node_id, nn || effectiveAlias, effectiveAlias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
414
- );
484
+ upsertNodeWithSec1Guard({
485
+ node_id,
486
+ callerNetworkId: effectiveNetId ?? null,
487
+ node_name: nn || effectiveAlias,
488
+ alias: effectiveAlias,
489
+ runtime: nodeRuntime,
490
+ model: mdl ?? null,
491
+ config_path: config_path ?? null,
492
+ channels: channels ?? null,
493
+ server: srv ?? null,
494
+ hostname: hn ?? null,
495
+ config_snapshot: cfgSnap ?? null,
496
+ });
415
497
  } catch {}
416
498
  }
417
499
 
@@ -501,20 +583,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
501
583
  // round5 F2: pass caller's effectiveNetId so the chain refuses
502
584
  // to write across tenants if some upstream parent links to a
503
585
  // foreign network.
586
+ //
587
+ // round5 follow-up (通信牛 SSE leak catch): gate the SSE push on
588
+ // `result.chained` — if the chain refused (cross-network), the
589
+ // subsequent SELECT of `parent.from_name` + `pushEvent(...,
590
+ // parent.task_id)` would leak the foreign parent's task_id into
591
+ // the caller's network via the SSE payload. Skip the push when
592
+ // the chain didn't actually write.
504
593
  if (updatedTaskId) {
505
594
  try {
506
- chainReplyToParent(updatedTaskId, result, "replied", 5, effectiveNetId);
507
- const parentChain = db.get<{ parent_task_id: string | null }>(
508
- "SELECT parent_task_id FROM tasks WHERE task_id = ?1",
509
- [updatedTaskId]
510
- );
511
- if (parentChain?.parent_task_id) {
512
- const parent = db.get<{ from_name: string; task_id: string }>(
513
- "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
514
- [parentChain.parent_task_id]
595
+ const chainResult = chainReplyToParent(updatedTaskId, result, "replied", 5, effectiveNetId);
596
+ if (chainResult.chained) {
597
+ const parentChain = db.get<{ parent_task_id: string | null }>(
598
+ "SELECT parent_task_id FROM tasks WHERE task_id = ?1",
599
+ [updatedTaskId]
515
600
  );
516
- if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
517
- pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias }, effectiveNetId);
601
+ if (parentChain?.parent_task_id) {
602
+ const parent = db.get<{ from_name: string; task_id: string }>(
603
+ "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
604
+ [parentChain.parent_task_id]
605
+ );
606
+ if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
607
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias }, effectiveNetId);
608
+ }
518
609
  }
519
610
  }
520
611
  } catch (e: any) {
@@ -613,21 +704,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
613
704
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
614
705
  console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
615
706
 
616
- const sessions = db.transaction(() => {
617
- const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
618
- const staleParams: any[] = [cutoff];
619
- let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
620
- staleSql = addReadScope(staleSql, staleParams, readScope);
621
- db.run(staleSql, staleParams);
622
-
623
- let sql = "SELECT * FROM sessions WHERE 1=1";
624
- const params: any[] = [];
625
- sql = addReadScope(sql, params, readScope);
626
- if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
627
- if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
628
- sql += " ORDER BY updated_at DESC";
629
- return db.all(sql, ...params);
630
- });
707
+ // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
708
+ // (background timer, ~60s cadence). Read path no longer fires UPDATE.
709
+ let sql = "SELECT * FROM sessions WHERE 1=1";
710
+ const params: any[] = [];
711
+ sql = addReadScope(sql, params, readScope);
712
+ if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
713
+ if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
714
+ sql += " ORDER BY updated_at DESC";
715
+ const sessions = db.all(sql, ...params);
631
716
 
632
717
  const summaryParams: any[] = [];
633
718
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
@@ -698,6 +783,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
698
783
  async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
699
784
  const effectiveNetId = getNetworkId(netId);
700
785
  const metaJson = normalizeMetaJson(meta);
786
+
787
+ // Role check FIRST — round5 follow-up (通信牛 oracle catch):
788
+ // the explicit-parent verification below distinguishes
789
+ // `cross_network_parent` from `permission_denied`. If we ran the
790
+ // parent lookup before canWrite, a viewer role could probe parent
791
+ // existence + ownership of foreign parents via the difference in
792
+ // error codes. Run canWrite first so a viewer ALWAYS gets the
793
+ // same `permission_denied` regardless of parent state.
794
+ if (!canWrite(effectiveNetId)) {
795
+ return writeDeniedReply(effectiveNetId, "send_task");
796
+ }
797
+
701
798
  // Resolve parent_task_id: explicit > inferred (caller's most recent
702
799
  // delivered/started inbox task that's still open). Inference is the
703
800
  // safety net for when the LLM forgets to pass parent_task_id.
@@ -743,11 +840,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
743
840
  }
744
841
  }
745
842
 
746
- // Role check: viewer cannot send tasks
747
- if (!canWrite(effectiveNetId)) {
748
- return writeDeniedReply(effectiveNetId, "send_task");
749
- }
750
-
751
843
  // License check
752
844
  const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
753
845
  if (license?.expires_at) {
@@ -785,6 +877,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
785
877
  return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
786
878
  }
787
879
 
880
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
881
+ {
882
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
883
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
884
+ }
885
+
788
886
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
789
887
  const id = uuidv4();
790
888
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
@@ -880,6 +978,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
880
978
  const targetAlias = canonical.alias;
881
979
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
882
980
  if (target.state === "not_found") return deliveryTargetReply(target)!;
981
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
982
+ {
983
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
984
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
985
+ }
883
986
  console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
884
987
  const id = uuidv4();
885
988
  db.run(
@@ -971,6 +1074,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
971
1074
  }
972
1075
  }
973
1076
  const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
1077
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1078
+ {
1079
+ const lc = assertNodeActive(alias, effectiveNetId ?? null);
1080
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1081
+ }
974
1082
  const replyLogged = db.transaction(() => {
975
1083
  db.run(
976
1084
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
@@ -1017,21 +1125,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1017
1125
  // round5 F2: pass caller's effectiveNetId so the chain refuses
1018
1126
  // to write across tenants if some upstream parent links to a
1019
1127
  // foreign network.
1128
+ //
1129
+ // round5 follow-up (通信牛 SSE leak catch): gate the SSE push on
1130
+ // `result.chained`. See report_completion path above for the
1131
+ // full reasoning — same leak, same gate.
1020
1132
  if (replyLogged && in_reply_to) {
1021
1133
  try {
1022
- chainReplyToParent(in_reply_to, text, replyStatus, 5, effectiveNetId);
1023
- // Push SSE event for parent originator if there is a chain.
1024
- const parentChain = db.get<{ parent_task_id: string | null; from_name: string }>(
1025
- "SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?1",
1026
- [in_reply_to]
1027
- );
1028
- if (parentChain?.parent_task_id) {
1029
- const parent = db.get<{ from_name: string; task_id: string }>(
1030
- "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
1031
- [parentChain.parent_task_id]
1134
+ const chainResult = chainReplyToParent(in_reply_to, text, replyStatus, 5, effectiveNetId);
1135
+ if (chainResult.chained) {
1136
+ const parentChain = db.get<{ parent_task_id: string | null; from_name: string }>(
1137
+ "SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?1",
1138
+ [in_reply_to]
1032
1139
  );
1033
- if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
1034
- pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias }, effectiveNetId);
1140
+ if (parentChain?.parent_task_id) {
1141
+ const parent = db.get<{ from_name: string; task_id: string }>(
1142
+ "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
1143
+ [parentChain.parent_task_id]
1144
+ );
1145
+ if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
1146
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias }, effectiveNetId);
1147
+ }
1035
1148
  }
1036
1149
  }
1037
1150
  } catch (e: any) {
@@ -1100,6 +1213,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1100
1213
  if (!["failed", "expired", "cancelled"].includes(task.status)) {
1101
1214
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
1102
1215
  }
1216
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
1217
+ {
1218
+ const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
1219
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1220
+ }
1103
1221
  db.transaction(() => {
1104
1222
  // Reset task status
1105
1223
  const updateParams: any[] = [task_id];
@@ -1247,6 +1365,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1247
1365
  const reassignedAlias = canonical.alias;
1248
1366
  const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1249
1367
  const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
1368
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
1369
+ {
1370
+ const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1371
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1372
+ }
1250
1373
  db.transaction(() => {
1251
1374
  // Ack old inbox to prevent original agent from picking it up
1252
1375
  const inboxParams: any[] = [task_id];
@@ -1292,6 +1415,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1292
1415
  const ids: string[] = [];
1293
1416
 
1294
1417
  for (const t of targets) {
1418
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
1419
+ // Broadcast skips non-active recipients silently rather than
1420
+ // failing the entire send — broadcast semantics are best-effort
1421
+ // per-recipient and a stopped node simply gets nothing.
1422
+ const lc = assertNodeActive(t.alias, effectiveNetId ?? t.network_id ?? null);
1423
+ if (!lc.ok) continue;
1295
1424
  const id = uuidv4();
1296
1425
  db.run(
1297
1426
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
@@ -1349,4 +1478,2096 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1349
1478
  };
1350
1479
  }
1351
1480
  );
1481
+
1482
+ // ──────────────────────────────────────────────────────────────────
1483
+ // RFC-024 (2026-06-28) — node config-apply MCP tools.
1484
+ //
1485
+ // Three contract tools (update / get / ack) + one lifecycle tool
1486
+ // (restart_node, Vincent 2026-06-28 increment). All four enforce
1487
+ // SEC-1 (network-scoped, never trust upstream dashboard routing —
1488
+ // every tool re-checks caller→token→network. Mirrors the #275
1489
+ // cross-tenant write防护带 pattern). update_node_config additionally
1490
+ // enforces SEC-2 — security-sensitive flags (permissionMode,
1491
+ // dangerouslySkipPermissions, teammateMode) are fail-CLOSED pending
1492
+ // Vincent's policy decision (see SECURITY_SENSITIVE_FLAGS below).
1493
+ //
1494
+ // See docs/rfcs/RFC-024-dashboard-node-config-apply.md for the
1495
+ // contract + sequence diagrams + guards.
1496
+ // ──────────────────────────────────────────────────────────────────
1497
+
1498
+ // Helpers — ALLOWED_FLAGS, SECURITY_SENSITIVE_FLAGS, computeApplyMode,
1499
+ // validatePatch, isAllowedToChangeFlag live in
1500
+ // ./config-apply-validate.ts so the contract is unit-testable without
1501
+ // standing up an MCP server.
1502
+
1503
+ /**
1504
+ * Resolve the node row that update_node_config / restart_node targets.
1505
+ * Returns the row + the SEC-1 verdict (network match against caller).
1506
+ * Network mismatch is the cross-tenant write防护带 from #275 — every
1507
+ * tool re-checks this even if dashboard already did, because curl
1508
+ * can talk directly to /mcp.
1509
+ */
1510
+ const resolveTargetNode = (
1511
+ nodeId: string,
1512
+ callerNetworkId: string | null,
1513
+ ): { row: any | null; sec1Ok: boolean } => {
1514
+ const row = db.get<any>(
1515
+ "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
1516
+ nodeId,
1517
+ );
1518
+ if (!row) return { row: null, sec1Ok: false };
1519
+ // Use the same null/undefined → "default" normalization as the
1520
+ // report_status upsert guard (norm() helper) — `||` would also
1521
+ // coerce `""` to "default", which is unreachable in the V3 model
1522
+ // today but better aligned to avoid drift if any future migration
1523
+ // ever introduces empty-string network_ids. Single source of
1524
+ // truth: nullish-only.
1525
+ const nodeNet = row.network_id === null || row.network_id === undefined ? "default" : row.network_id;
1526
+ const callerNet = callerNetworkId === null || callerNetworkId === undefined ? "default" : callerNetworkId;
1527
+ return { row, sec1Ok: nodeNet === callerNet };
1528
+ };
1529
+
1530
+ server.tool(
1531
+ "update_node_config",
1532
+ "Set the desired per-node config (model + flags) and push a doorbell to the node. The node pulls + validates + applies (hot or restart per field tier). RFC-024.",
1533
+ {
1534
+ node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
1535
+ base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
1536
+ patch: z.object({
1537
+ model: z.string().max(200).optional(),
1538
+ flags: z.record(z.unknown()).optional(),
1539
+ }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1540
+ network_id: z.string().max(200).optional(),
1541
+ },
1542
+ async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
1543
+ const effectiveNetId = getNetworkId(clientNetId);
1544
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1545
+
1546
+ const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
1547
+ if (!node) {
1548
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
1549
+ }
1550
+ if (!sec1Ok) {
1551
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
1552
+ }
1553
+
1554
+ const model = typeof patch.model === "string" ? patch.model : undefined;
1555
+ const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
1556
+
1557
+ // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1558
+ // (permissionMode / dangerouslySkipPermissions / teammateMode)
1559
+ // require admin role on this network. Other flags fall through
1560
+ // to per-field validation. hub-side enforced (dashboard's UI
1561
+ // gate is not trusted; curl direct to /mcp is the attack
1562
+ // vector). See isAllowedToChangeFlag for the policy details.
1563
+ const callerRole = enforceUserId && effectiveNetId
1564
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
1565
+ : null;
1566
+ const secCheck = isAllowedToChangeFlag(callerRole, flags);
1567
+ if (secCheck) {
1568
+ return {
1569
+ content: [{
1570
+ type: "text" as const,
1571
+ text: JSON.stringify({
1572
+ ok: false,
1573
+ error: "insufficient_role_for_security_flag",
1574
+ field: secCheck.field,
1575
+ required_role: "admin", // or owner — both satisfy
1576
+ message: secCheck.reason,
1577
+ }),
1578
+ }],
1579
+ };
1580
+ }
1581
+
1582
+ const validationFail = validatePatch(model, flags);
1583
+ if (validationFail) {
1584
+ return {
1585
+ content: [{
1586
+ type: "text" as const,
1587
+ text: JSON.stringify({
1588
+ ok: false,
1589
+ error: "invalid_patch",
1590
+ field: validationFail.field,
1591
+ reason: validationFail.reason,
1592
+ }),
1593
+ }],
1594
+ };
1595
+ }
1596
+
1597
+ // Revision conflict.
1598
+ if ((node.config_revision || 0) !== baseRev) {
1599
+ return {
1600
+ content: [{
1601
+ type: "text" as const,
1602
+ text: JSON.stringify({
1603
+ ok: false,
1604
+ error: "revision_conflict",
1605
+ current_revision: node.config_revision || 0,
1606
+ base_revision: baseRev,
1607
+ }),
1608
+ }],
1609
+ };
1610
+ }
1611
+
1612
+ // F-B (CHANGE_REQ): single-flight with stale-update reaper.
1613
+ // Without TTL, a node that ack'd "restarting" then crashed (lost
1614
+ // power, OOM, killed) leaves a non-terminal row forever — every
1615
+ // subsequent update_node_config / restart_node returns
1616
+ // update_in_flight and the node is admin-bricked.
1617
+ //
1618
+ // Stale threshold = 60_000 ms (2× the §8-confirmed 30s apply ceiling,
1619
+ // chosen so a slow-but-alive node within its own deadline never
1620
+ // false-positives as stale). Stale rows are marked timeout +
1621
+ // superseded by the new update.
1622
+ //
1623
+ // Age anchor = COALESCE(acked_at, created_at) per 通信龙 polish:
1624
+ // a healthy-but-slow restart (drain 60s + respawn time) could
1625
+ // exceed the threshold if anchored on created_at alone (drain
1626
+ // cap and reaper threshold are both 60s — overlap). Anchoring
1627
+ // on acked_at means a node that ack'd "restarting" refreshes
1628
+ // the liveness clock, so an in-progress restart isn't falsely
1629
+ // reaped.
1630
+ const STALE_THRESHOLD_MS = 60_000;
1631
+ const inFlight = db.get<{ update_id: string; created_at: number; acked_at: number | null }>(
1632
+ "SELECT update_id, created_at, acked_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
1633
+ nodeId,
1634
+ );
1635
+ if (inFlight) {
1636
+ const ageAnchor = inFlight.acked_at ?? inFlight.created_at;
1637
+ const age = Date.now() - ageAnchor;
1638
+ if (age <= STALE_THRESHOLD_MS) {
1639
+ return {
1640
+ content: [{
1641
+ type: "text" as const,
1642
+ text: JSON.stringify({
1643
+ ok: false,
1644
+ error: "update_in_flight",
1645
+ existing_update_id: inFlight.update_id,
1646
+ age_ms: age,
1647
+ }),
1648
+ }],
1649
+ };
1650
+ }
1651
+ // Stale — supersede.
1652
+ db.run(
1653
+ "UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = ?2 WHERE update_id = ?3",
1654
+ [Date.now(), `superseded by new update after ${age}ms stale (> ${STALE_THRESHOLD_MS}ms threshold)`, inFlight.update_id],
1655
+ );
1656
+ }
1657
+
1658
+ // Compute apply_mode + persist + push doorbell.
1659
+ const updateId = `cu_${uuidv4()}`;
1660
+ const applyMode = computeApplyMode(model, flags);
1661
+ const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
1662
+ const networkId = node.network_id || "default";
1663
+ db.run(
1664
+ `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8)`,
1665
+ [updateId, nodeId, networkId, patchJson, applyMode, baseRev, Date.now(), callerTokenId || "unknown"],
1666
+ );
1667
+
1668
+ pushEvent(node.alias, { type: "config_update", update_id: updateId }, networkId);
1669
+
1670
+ return {
1671
+ content: [{
1672
+ type: "text" as const,
1673
+ text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: applyMode }),
1674
+ }],
1675
+ };
1676
+ },
1677
+ );
1678
+
1679
+ server.tool(
1680
+ "get_config_update",
1681
+ "Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
1682
+ {},
1683
+ async () => {
1684
+ // F-A (CHANGE_REQ): require ntok_ + non-null enforceNetworkId.
1685
+ // Mirror report_status's guard (tools.ts:251-253) — utok_ has
1686
+ // enforceNetworkId=null and callerAlias=username, so without
1687
+ // this gate a utok_ whose username happens to match a node alias
1688
+ // could pull that node's pending update across network scope
1689
+ // (network filter would be silently dropped, since old code had
1690
+ // a conditional WHERE). Hub doesn't trust upstream gates — every
1691
+ // node-private tool must independently require a network-bound
1692
+ // token.
1693
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
1694
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
1695
+ }
1696
+ if (!callerAlias) {
1697
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
1698
+ }
1699
+ // Unconditional network_id filter (was previously conditional on
1700
+ // enforceNetworkId being set; the new ntok guard above guarantees
1701
+ // it's non-null so the filter is always applied).
1702
+ const node = db.get<any>(
1703
+ "SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1704
+ callerAlias,
1705
+ enforceNetworkId,
1706
+ );
1707
+ if (!node) {
1708
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
1709
+ }
1710
+ const update = db.get<any>(
1711
+ "SELECT update_id, patch_json, apply_mode, base_revision FROM node_config_updates WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1",
1712
+ node.node_id,
1713
+ );
1714
+ if (!update) {
1715
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
1716
+ }
1717
+ let patch: any = {};
1718
+ try { patch = JSON.parse(update.patch_json); } catch { patch = {}; }
1719
+ return {
1720
+ content: [{
1721
+ type: "text" as const,
1722
+ text: JSON.stringify({
1723
+ ok: true,
1724
+ update: {
1725
+ update_id: update.update_id,
1726
+ patch,
1727
+ apply_mode: update.apply_mode,
1728
+ base_revision: update.base_revision,
1729
+ },
1730
+ }),
1731
+ }],
1732
+ };
1733
+ },
1734
+ );
1735
+
1736
+ server.tool(
1737
+ "ack_config_update",
1738
+ "Node acknowledges a config update — applied / rejected / restarting / timeout. RFC-024.",
1739
+ {
1740
+ update_id: z.string().min(1).max(200),
1741
+ status: z.enum(["applied", "rejected", "restarting", "timeout"]),
1742
+ new_revision: z.number().int().min(0).optional(),
1743
+ error: z.string().max(2000).optional(),
1744
+ },
1745
+ async ({ update_id: updateId, status, new_revision: newRev, error: ackError }) => {
1746
+ // F-A (CHANGE_REQ): same ntok_ guard as get_config_update. Without
1747
+ // this, a utok_ whose username matches a node alias could ack
1748
+ // arbitrary updates within the alias-collision; the new ntok guard
1749
+ // closes that.
1750
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
1751
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
1752
+ }
1753
+ if (!callerAlias) {
1754
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
1755
+ }
1756
+ // Cross-tenant guard: the ack-er must own the update being acked.
1757
+ // Resolve node by caller's alias under the enforced network.
1758
+ // Network filter is unconditional (guard above guarantees non-null).
1759
+ const node = db.get<any>(
1760
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1761
+ callerAlias,
1762
+ enforceNetworkId,
1763
+ );
1764
+ if (!node) {
1765
+ // Silently ignore stale ack — return ok so the node doesn't retry forever.
1766
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
1767
+ }
1768
+ const update = db.get<any>(
1769
+ "SELECT update_id, node_id, status FROM node_config_updates WHERE update_id = ?1",
1770
+ updateId,
1771
+ );
1772
+ if (!update || update.node_id !== node.node_id) {
1773
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_update" }) }] };
1774
+ }
1775
+ // Reject ack for already-terminal updates (idempotency).
1776
+ if (update.status === "applied" || update.status === "rejected" || update.status === "timeout") {
1777
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: update.status }) }] };
1778
+ }
1779
+
1780
+ const ackedAt = Date.now();
1781
+ if (status === "applied") {
1782
+ // Promote the node's config_revision to the new revision, atomically.
1783
+ const nextRev = (typeof newRev === "number" && newRev > 0) ? newRev : ((db.get<{ config_revision: number }>("SELECT config_revision FROM nodes WHERE node_id = ?1", node.node_id)?.config_revision || 0) + 1);
1784
+ db.run(
1785
+ `UPDATE node_config_updates SET status = 'applied', acked_at = ?1, new_revision = ?2 WHERE update_id = ?3`,
1786
+ [ackedAt, nextRev, updateId],
1787
+ );
1788
+ db.run(`UPDATE nodes SET config_revision = ?1 WHERE node_id = ?2`, [nextRev, node.node_id]);
1789
+ } else {
1790
+ db.run(
1791
+ `UPDATE node_config_updates SET status = ?1, acked_at = ?2, error = ?3 WHERE update_id = ?4`,
1792
+ [status, ackedAt, ackError || null, updateId],
1793
+ );
1794
+ }
1795
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
1796
+ },
1797
+ );
1798
+
1799
+ server.tool(
1800
+ "restart_node",
1801
+ "Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
1802
+ {
1803
+ node_id: z.string().min(1).max(200),
1804
+ network_id: z.string().max(200).optional(),
1805
+ },
1806
+ async ({ node_id: nodeId, network_id: clientNetId }) => {
1807
+ const effectiveNetId = getNetworkId(clientNetId);
1808
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1809
+
1810
+ const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
1811
+ if (!node) {
1812
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
1813
+ }
1814
+ if (!sec1Ok) {
1815
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
1816
+ }
1817
+ // F-B reaper: same stale-supersede semantics as update_node_config,
1818
+ // with same acked_at-anchored liveness clock (see update_node_config
1819
+ // for the 通信龙 polish reasoning).
1820
+ const STALE_THRESHOLD_MS_R = 60_000;
1821
+ const inFlight = db.get<{ update_id: string; created_at: number; acked_at: number | null }>(
1822
+ "SELECT update_id, created_at, acked_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
1823
+ nodeId,
1824
+ );
1825
+ if (inFlight) {
1826
+ const ageAnchor = inFlight.acked_at ?? inFlight.created_at;
1827
+ const age = Date.now() - ageAnchor;
1828
+ if (age <= STALE_THRESHOLD_MS_R) {
1829
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "update_in_flight", existing_update_id: inFlight.update_id, age_ms: age }) }] };
1830
+ }
1831
+ db.run(
1832
+ "UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = ?2 WHERE update_id = ?3",
1833
+ [Date.now(), `superseded by restart_node after ${age}ms stale`, inFlight.update_id],
1834
+ );
1835
+ }
1836
+ const updateId = `cu_${uuidv4()}`;
1837
+ const networkId = node.network_id || "default";
1838
+ // RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
1839
+ // lifecycle_state to 'active'. Otherwise a node that was previously
1840
+ // stop_node'd → 'stopped' would, after restart, stay marked
1841
+ // 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
1842
+ // would refuse every routing attempt → node silently unreachable
1843
+ // (no error to operator, just no traffic). Pair the schema flip
1844
+ // with the config_updates INSERT inside one tx so we don't
1845
+ // half-commit if the dispatch INSERT throws.
1846
+ db.transaction(() => {
1847
+ db.run(
1848
+ `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{}', 'restart_only', ?4, 'pending', ?5, ?6)`,
1849
+ [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
1850
+ );
1851
+ db.run(
1852
+ `UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
1853
+ [nodeId],
1854
+ );
1855
+ });
1856
+ pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
1857
+ return {
1858
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
1859
+ };
1860
+ },
1861
+ );
1862
+
1863
+ // ── RFC-026 P1 — create-node + host-daemon (3 MCP tools) ──────────
1864
+ // Background timers (GC + sweeper) are idempotent + cheap; safe to
1865
+ // call on every registerTools invocation (statless mode re-registers
1866
+ // per request). They unref themselves so don't hold the event loop.
1867
+ startPendingEnvGcTimer();
1868
+ startSweeperTimer();
1869
+
1870
+ // §4.1.4 C2 — caller daemon resolved via token-bound identity (NOT
1871
+ // alias). Thin closure over the module-level helper so callers in
1872
+ // this scope can use the captured request-level vars. The pure
1873
+ // helper lives in create-node.ts so unit tests call exactly the
1874
+ // same code path the tools do (per 通信龙 PR #299 nit 1 — no inline-
1875
+ // mirror SQL in tests).
1876
+ const resolveCallerDaemonTokenBound = () =>
1877
+ _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
1878
+
1879
+ // Helper — map a ValidationError thrown from create-node-validate
1880
+ // into the MCP-tool-call JSON reply shape.
1881
+ const validationFailReply = (e: unknown) => {
1882
+ if (e instanceof ValidationError) {
1883
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
1884
+ }
1885
+ throw e;
1886
+ };
1887
+
1888
+ // RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
1889
+ // Surfaces host_supervisor daemons in the caller's network with
1890
+ // online flag + declared capabilities. Replaces the prior `node_daemon_`
1891
+ // prefix heuristic (dashboard /api/anet/node-create) + the role-extract
1892
+ // path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
1893
+ // cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
1894
+ // to non-admin readers). Revoked daemon ntoks filtered out via
1895
+ // join on api_tokens.revoked_at.
1896
+ server.tool(
1897
+ "list_host_supervisors",
1898
+ "List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
1899
+ {
1900
+ network_id: z.string().max(200).optional(),
1901
+ },
1902
+ async ({ network_id: clientNetId }) => {
1903
+ // SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
1904
+ // for write-tool helpers and pairs with `canWrite`; READ tools that
1905
+ // bypass canWrite and trust getNetworkId leak across tenants
1906
+ // (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
1907
+ // enforceNetworkId=null could pass any network_id and read daemon
1908
+ // names + allowed_secret_keys for tenants they're not a member of).
1909
+ // resolveReadScope checks network_members for the user + denies
1910
+ // on non-membership, mirroring REST resolveRestNetworkScope.
1911
+ const readScope = resolveReadScope(clientNetId);
1912
+ if (readScope.denied) {
1913
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1914
+ }
1915
+ // Caller role for 脱敏 decision (admin/owner = full, others = masked).
1916
+ // Use the resolved scope's networkId (the verified one), not the
1917
+ // unchecked clientNetId.
1918
+ const scopedNetId = readScope.networkId ?? null;
1919
+ const callerRole = enforceUserId && scopedNetId
1920
+ ? getUserNetworkRole(enforceUserId, scopedNetId)
1921
+ : null;
1922
+ // Network-bound tokens get full telemetry within their bound network
1923
+ // (already gate-locked by enforceNetworkId path of resolveReadScope).
1924
+ const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
1925
+
1926
+ // Pull candidate daemons. Active-token EXISTS subquery handles BOTH
1927
+ // revoked daemon ntoks (revoked_at set) AND DELETEd token rows
1928
+ // (the standard revokeToken path deletes the row, not flagging
1929
+ // revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
1930
+ // dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
1931
+ let sql = `
1932
+ SELECT
1933
+ n.node_id, n.alias, n.hostname, n.network_id,
1934
+ n.runtimes_supported, n.allowed_secret_keys,
1935
+ n.created_at, n.updated_at,
1936
+ s.last_seen_at AS session_last_seen,
1937
+ s.status AS session_status,
1938
+ s.cpu_cores AS session_cpu_cores,
1939
+ s.mem_total_gb AS session_mem_total_gb,
1940
+ s.ip AS session_ip,
1941
+ n.config_snapshot
1942
+ FROM nodes n
1943
+ LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
1944
+ WHERE EXISTS (
1945
+ SELECT 1 FROM api_tokens t
1946
+ WHERE t.network_id = n.network_id
1947
+ AND t.name = 'node:' || n.alias
1948
+ AND t.revoked_at IS NULL
1949
+ )
1950
+ `;
1951
+ const sqlParams: any[] = [];
1952
+ sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
1953
+ sql += ` ORDER BY n.updated_at DESC`;
1954
+ const rows = db.all<Record<string, any>>(sql, ...sqlParams);
1955
+
1956
+ // Filter to role=host_supervisor (read from config_snapshot;
1957
+ // schema-promoted columns runtimes_supported/allowed_secret_keys
1958
+ // are pre-extracted but role isn't a first-class column).
1959
+ const nowMs = Date.now();
1960
+ const ONLINE_MS = 60_000;
1961
+ const daemons = rows
1962
+ .map(r => {
1963
+ let snapRole: string | null = null;
1964
+ if (r.config_snapshot) {
1965
+ try {
1966
+ const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
1967
+ snapRole = typeof parsed?.role === "string" ? parsed.role : null;
1968
+ } catch { /* malformed snapshot — role stays null */ }
1969
+ }
1970
+ return { row: r, role: snapRole };
1971
+ })
1972
+ .filter(({ role }) => role === "host_supervisor")
1973
+ .map(({ row: r }) => {
1974
+ // online = sessions.last_seen_at within ONLINE_MS
1975
+ let online = false;
1976
+ let lastSeenAt: string | null = null;
1977
+ if (r.session_last_seen) {
1978
+ lastSeenAt = r.session_last_seen;
1979
+ const t = Date.parse(r.session_last_seen);
1980
+ if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
1981
+ }
1982
+ // Parse self-declare arrays (default to [] for pre-PR2 daemons)
1983
+ let runtimes: string[] = [];
1984
+ let secrets: string[] = [];
1985
+ try {
1986
+ if (r.runtimes_supported) {
1987
+ const parsed = JSON.parse(r.runtimes_supported);
1988
+ runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
1989
+ }
1990
+ } catch { /* malformed — empty */ }
1991
+ try {
1992
+ if (r.allowed_secret_keys) {
1993
+ const parsed = JSON.parse(r.allowed_secret_keys);
1994
+ secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
1995
+ }
1996
+ } catch { /* malformed — empty */ }
1997
+ // host_telemetry — member脱敏 drops IP/cpu/mem
1998
+ const telemetry: Record<string, unknown> = {
1999
+ alert_level: online ? "green" : "gray",
2000
+ };
2001
+ if (isPrivileged) {
2002
+ telemetry.cpu_cores = r.session_cpu_cores ?? null;
2003
+ telemetry.mem_gb = r.session_mem_total_gb ?? null;
2004
+ telemetry.ip_internal = r.session_ip ?? null;
2005
+ }
2006
+ return {
2007
+ daemon_node_id: r.node_id,
2008
+ alias: r.alias,
2009
+ hostname: r.hostname,
2010
+ online,
2011
+ last_seen_at: lastSeenAt,
2012
+ runtimes_supported: runtimes,
2013
+ allowed_secret_keys: secrets,
2014
+ host_telemetry: telemetry,
2015
+ };
2016
+ });
2017
+
2018
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
2019
+ },
2020
+ );
2021
+
2022
+ // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
2023
+ // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
2024
+ // row (metadata only — no env_blob in SQL), pushEvent doorbell.
2025
+ server.tool(
2026
+ "create_node",
2027
+ "Create a node on a host-daemon and start it. Daemon forks `anet node create + start` on the target machine; child reports back to hub. RFC-026.",
2028
+ {
2029
+ daemon_node_id: z.string().min(1).max(200),
2030
+ node_spec: z.object({
2031
+ name: z.string().min(1).max(64),
2032
+ runtime: z.string().min(1).max(64),
2033
+ model: z.string().min(1).max(100),
2034
+ flags: z.record(z.unknown()).optional(),
2035
+ env_refs: z.array(z.string().max(64)).optional(),
2036
+ channels: z.array(z.unknown()).optional(),
2037
+ }),
2038
+ network_id: z.string().max(200).optional(),
2039
+ },
2040
+ async ({ daemon_node_id, node_spec, network_id: clientNetId }) => {
2041
+ const effectiveNetId = getNetworkId(clientNetId);
2042
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2043
+
2044
+ // §4.1.1 — admin+ for create_node (creating a node = pulling new
2045
+ // resource + burning API quota, one level above edit single flag)
2046
+ const callerRole = enforceUserId && effectiveNetId
2047
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
2048
+ : null;
2049
+ if (callerRole !== "admin" && callerRole !== "owner") {
2050
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
2051
+ }
2052
+
2053
+ // Daemon must exist + must be in caller's network + must be
2054
+ // online with role=host_supervisor capability.
2055
+ const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
2056
+ if (!daemon) {
2057
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id }) }] };
2058
+ }
2059
+ if (!sec1Ok) {
2060
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
2061
+ }
2062
+
2063
+ // Read daemon's host_supervisor capability + allowlist from its
2064
+ // last reported config_snapshot. PR3 (#338) canonical path is
2065
+ // `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
2066
+ // Pre-PR3 daemons (preview.10 and earlier) place these at the
2067
+ // TOP level of the snapshot rather than nested — those reads
2068
+ // return undefined here and `daemonAllowedRuntimes` stays null
2069
+ // (permissive — no allowlist enforcement); they fall back to
2070
+ // §4.2.2 structural validation only, identical to pre-PR3
2071
+ // behavior. No regression on in-flight daemons.
2072
+ let daemonAllowList = new Set<string>();
2073
+ let daemonAllowedRuntimes: string[] | null = null;
2074
+ try {
2075
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
2076
+ const caps = snap?.daemon_capabilities;
2077
+ if (Array.isArray(caps?.allowed_secret_keys)) {
2078
+ daemonAllowList = new Set(caps.allowed_secret_keys);
2079
+ }
2080
+ if (Array.isArray(caps?.runtimes_supported)) {
2081
+ daemonAllowedRuntimes = caps.runtimes_supported;
2082
+ }
2083
+ } catch { /* permissive fallback */ }
2084
+
2085
+ // §4.2.2 — structural validation (catches name/runtime/model/
2086
+ // flag injection at the hub edge). Daemon repeats this; double
2087
+ // layer per RFC §4.2.2.
2088
+ try {
2089
+ validateChildName(node_spec.name);
2090
+ validateRuntime(node_spec.runtime);
2091
+ validateModel(node_spec.model);
2092
+ validateChannelsP1((node_spec as any).channels);
2093
+ for (const [k, v] of Object.entries(node_spec.flags || {})) {
2094
+ if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
2095
+ validateFlagValue(k, v);
2096
+ }
2097
+ } catch (e) {
2098
+ return validationFailReply(e);
2099
+ }
2100
+
2101
+ // P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
2102
+ // enforce at hub for fast-fail; daemon repeats.
2103
+ if (daemonAllowedRuntimes && !daemonAllowedRuntimes.includes(node_spec.runtime)) {
2104
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_not_in_local_allowlist", runtime: node_spec.runtime, allowed: daemonAllowedRuntimes }) }] };
2105
+ }
2106
+
2107
+ // §4.4.7 — env_refs strict (7-step gate) + resolve to env_blob.
2108
+ let envBlob: Record<string, string> = {};
2109
+ const envRefs = (node_spec as any).env_refs;
2110
+ try {
2111
+ envBlob = validateEnvRefs(envRefs, {
2112
+ callerNetworkId: effectiveNetId || "default",
2113
+ daemonAllowList,
2114
+ networkSecretsGet: (_net: string, _key: string) => undefined, // P1: no vault yet — see note below
2115
+ });
2116
+ } catch (e) {
2117
+ return validationFailReply(e);
2118
+ }
2119
+ // P1 NOTE — network_secrets vault is RFC §4.4 / §2.4 future
2120
+ // work. For now if dashboard sends env_refs we'll reject as
2121
+ // not-in-vault (above). When the vault lands, replace the
2122
+ // `networkSecretsGet: () => undefined` line with the real DB
2123
+ // lookup; nothing else in this tool needs to change.
2124
+
2125
+ // Single-flight per (daemon, child_name): partial unique index
2126
+ // uniq_ncr_inflight already prevents racing INSERT, but we
2127
+ // surface a friendly error rather than letting the DB constraint
2128
+ // raise.
2129
+ const existing = db.get<{ request_id: string; status: string }>(
2130
+ `SELECT request_id, status FROM node_create_requests WHERE daemon_node_id = ?1 AND child_name = ?2 AND status IN ('pending', 'delivered') ORDER BY created_at DESC LIMIT 1`,
2131
+ daemon_node_id, node_spec.name,
2132
+ );
2133
+ if (existing) {
2134
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_name_conflict", existing_request_id: existing.request_id, existing_status: existing.status }) }] };
2135
+ }
2136
+
2137
+ // §4.2.4 — daemon_max_children backpressure (best-effort: count
2138
+ // currently-active children for this daemon).
2139
+ const maxChildren = (() => {
2140
+ try {
2141
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
2142
+ const m = snap?.daemon_capabilities?.max_concurrent_children;
2143
+ return (typeof m === "number" && m > 0) ? m : 20;
2144
+ } catch { return 20; }
2145
+ })();
2146
+ const childCount = db.get<{ n: number }>(
2147
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered', 'succeeded')`,
2148
+ daemon_node_id,
2149
+ )?.n || 0;
2150
+ if (childCount >= maxChildren) {
2151
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_max_children", current: childCount, max: maxChildren }) }] };
2152
+ }
2153
+
2154
+ // §2.5 step 2 + §4.4 F1 — mint child-ntok + stash env_blob in
2155
+ // Map (NOT in DB). Token row marked role='child' + request_id
2156
+ // for sweeper traceability per §4.4.8 impl note.
2157
+ const childToken = generateNetworkToken();
2158
+ const childTokenId = generateId("tok");
2159
+ const networkIdForChild = daemon.network_id || effectiveNetId || "default";
2160
+ const requestId = newRequestId();
2161
+ // Use the dashboard caller's user_id as the token's user_id so
2162
+ // resolveToken returns sane role / network on the child's side
2163
+ // (audit trail = whoever created it).
2164
+ if (!enforceUserId) {
2165
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
2166
+ }
2167
+ db.run(
2168
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
2169
+ [childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
2170
+ );
2171
+
2172
+ putPendingEnvBlob({
2173
+ request_id: requestId,
2174
+ daemon_node_id,
2175
+ env_blob: envBlob,
2176
+ child_token: childToken,
2177
+ child_token_id: childTokenId,
2178
+ });
2179
+
2180
+ // Write metadata-only row. env_blob field deliberately ABSENT
2181
+ // from the schema (see db.ts CREATE TABLE) — F1 lock.
2182
+ const envKeys = Object.keys(envBlob);
2183
+ db.run(
2184
+ `INSERT INTO node_create_requests
2185
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
2186
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
2187
+ [
2188
+ requestId, daemon_node_id, node_spec.name, networkIdForChild,
2189
+ node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
2190
+ JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
2191
+ ],
2192
+ );
2193
+
2194
+ // SSE doorbell — daemon will pull via get_create_request.
2195
+ // Payload carries ONLY request_id (no secret); daemon current
2196
+ // SSE handler resolves the rest via MCP call.
2197
+ pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
2198
+
2199
+ // §4.5 audit — dispatch succeeded
2200
+ auditCreateNode({
2201
+ action: "create_node_dispatched",
2202
+ user_id: enforceUserId,
2203
+ network_id: networkIdForChild,
2204
+ target_id: requestId,
2205
+ detail: {
2206
+ daemon_node_id,
2207
+ child_name: node_spec.name,
2208
+ runtime: node_spec.runtime,
2209
+ model: node_spec.model,
2210
+ flag_keys: Object.keys(node_spec.flags || {}),
2211
+ env_keys: envKeys,
2212
+ },
2213
+ });
2214
+
2215
+ return {
2216
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId }) }],
2217
+ };
2218
+ },
2219
+ );
2220
+
2221
+ // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
2222
+ // child_ntok in one shot. Map evicted on take (one-shot consume).
2223
+ //
2224
+ // §4.1.4 C2 token-bound daemon resolution (PR #299 BLOCKER #1, 通信牛):
2225
+ // We MUST resolve the caller daemon via token-bound identity, NOT
2226
+ // alias. alias is NOT a security boundary — two daemons with the same
2227
+ // alias in different networks (or attacker-named-itself-the-same)
2228
+ // would otherwise resolve to the wrong row. Same class as the prior
2229
+ // report_status cross-tenant re-home bug.
2230
+ //
2231
+ // Resolution chain: caller's ntok (callerTokenId + callerTokenIsNetwork)
2232
+ // → api_tokens row → joins to nodes via name='node:<alias>' AND
2233
+ // network_id matches → unique daemon node row scoped to caller's
2234
+ // network. If the ntok isn't bound to a node, or the joined node
2235
+ // isn't a host_supervisor, reject.
2236
+ server.tool(
2237
+ "get_create_request",
2238
+ "Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
2239
+ {
2240
+ request_id: z.string().min(1).max(200),
2241
+ },
2242
+ async ({ request_id }) => {
2243
+ const callerDaemon = resolveCallerDaemonTokenBound();
2244
+ if (!callerDaemon.ok) {
2245
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2246
+ }
2247
+
2248
+ const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
2249
+ `SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
2250
+ request_id,
2251
+ );
2252
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2253
+ // §4.1.4 — strict daemon binding by token-derived node_id.
2254
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2255
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2256
+ }
2257
+ // Additional network-scope guard (defense in depth: row's
2258
+ // network_id MUST equal caller's network).
2259
+ if (row.network_id !== callerDaemon.networkId) {
2260
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2261
+ }
2262
+ if (row.status !== "pending") {
2263
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_pending", current_status: row.status }) }] };
2264
+ }
2265
+
2266
+ // Take env_blob from Map; this is the one-shot consume per F1.
2267
+ // takePendingEnvBlob ALSO checks daemon binding (belt+braces).
2268
+ const blob = takePendingEnvBlob(request_id, callerDaemon.daemonNodeId);
2269
+ if (!blob) {
2270
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
2271
+ }
2272
+ // Hydrate spec from row.
2273
+ const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
2274
+ `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
2275
+ request_id,
2276
+ );
2277
+ if (!specRow) {
2278
+ // Should never happen given the earlier row read.
2279
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_vanished" }) }] };
2280
+ }
2281
+ // Mark delivered (so sweeper distinguishes F-1 from F-2).
2282
+ db.run(
2283
+ `UPDATE node_create_requests SET status = 'delivered', delivered_at = ?1 WHERE request_id = ?2 AND status = 'pending'`,
2284
+ [Date.now(), request_id],
2285
+ );
2286
+
2287
+ return {
2288
+ content: [{ type: "text" as const, text: JSON.stringify({
2289
+ ok: true,
2290
+ request_id,
2291
+ node_spec: {
2292
+ name: specRow.child_name,
2293
+ runtime: specRow.runtime,
2294
+ model: specRow.model,
2295
+ flags: JSON.parse(specRow.flags_json),
2296
+ channels: [],
2297
+ },
2298
+ child_token: blob.child_token,
2299
+ env_blob: blob.env_blob,
2300
+ }) }],
2301
+ };
2302
+ },
2303
+ );
2304
+
2305
+ // §2.5 step 4 — daemon reports outcome. Note: 'succeeded' is set
2306
+ // automatically by hub when the child first registers (content-match
2307
+ // in upsertNodeWithSec1Guard); daemon's ack here is for explicit
2308
+ // failures (fork crashed, etc.). Daemon should still call this on
2309
+ // success too — it's a useful idempotent confirmation + lets hub
2310
+ // record fork-side info.
2311
+ server.tool(
2312
+ "ack_create_request",
2313
+ "Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
2314
+ {
2315
+ request_id: z.string().min(1).max(200),
2316
+ // RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
2317
+ // daemon spawned the child OK but it died within FAIL_FAST_MS
2318
+ // (5s in agent-node v2.5.0-preview.11+), indicating a
2319
+ // declaration↔reality gap on this daemon's runtimes_supported.
2320
+ // Treated terminal like 'failed' but fires a distinct audit_log
2321
+ // action so dashboards can highlight "lying daemons" separately
2322
+ // from generic spawn failures.
2323
+ status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
2324
+ error: z.string().max(1000).optional(),
2325
+ child_pid: z.number().int().optional(),
2326
+ runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
2327
+ },
2328
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
2329
+ const callerDaemon = resolveCallerDaemonTokenBound();
2330
+ if (!callerDaemon.ok) {
2331
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2332
+ }
2333
+ const row = db.get<{ daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
2334
+ `SELECT daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
2335
+ request_id,
2336
+ );
2337
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2338
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2339
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2340
+ }
2341
+ if (row.network_id !== callerDaemon.networkId) {
2342
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2343
+ }
2344
+ const ackedAt = Date.now();
2345
+ if (status === "started") {
2346
+ // Don't flip to 'succeeded' here — that happens via content-
2347
+ // match when the child actually registers. We just stamp ack.
2348
+ db.run(
2349
+ `UPDATE node_create_requests SET acked_at = ?1 WHERE request_id = ?2 AND status IN ('delivered', 'pending')`,
2350
+ [ackedAt, request_id],
2351
+ );
2352
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2353
+ }
2354
+ // failed / rejected / runtime_capability_check_failed — revoke
2355
+ // child-ntok + mark request terminal.
2356
+ if (row.child_token_id) {
2357
+ db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2358
+ }
2359
+ db.run(
2360
+ `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2361
+ [status, ackError || null, ackedAt, request_id],
2362
+ );
2363
+ // RFC-026 §9.3 D2 — surface declaration↔reality gap on a
2364
+ // distinct audit_log action so dashboards / operators can spot
2365
+ // chronically-lying daemons separate from generic spawn-failed.
2366
+ if (status === "runtime_capability_check_failed") {
2367
+ auditCreateNode({
2368
+ action: "daemon_capability_lied",
2369
+ user_id: null,
2370
+ network_id: row.network_id,
2371
+ target_id: request_id,
2372
+ detail: {
2373
+ daemon_node_id: row.daemon_node_id,
2374
+ runtime: ackRuntime || null,
2375
+ error: ackError ? ackError.slice(0, 500) : null,
2376
+ acked_at: ackedAt,
2377
+ },
2378
+ });
2379
+ }
2380
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2381
+ },
2382
+ );
2383
+
2384
+ // ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
2385
+ //
2386
+ // Two user-facing MCP tools (stop_node, delete_node) + two daemon-
2387
+ // facing tools (get_stop_request, ack_stop_request). State machine
2388
+ // per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
2389
+ // deleting}. row-gone implies deleted. Security per §4:
2390
+ // §4.1 SEC-1: trust-root SQL join, not getNetworkId
2391
+ // §4.2 D6: delete_node refuses target.role==host_supervisor
2392
+ // §4.3 D4: in-flight inbox default-refuse + force+audit
2393
+ // §4.4 D7: daemon writes backup chmod 700, sweeper真删
2394
+ // §4.5 D8: audit_log in same SQLite tx as state UPDATE
2395
+ //
2396
+ // The dispatcher logic shared by stop_node + delete_node lives in
2397
+ // dispatchStopOrDelete; the two tool handlers thin-wrap it with
2398
+ // their respective action discriminator.
2399
+ type DispatchAction = "stop" | "delete";
2400
+ type DispatchArgs = {
2401
+ action: DispatchAction;
2402
+ child_node_id: string;
2403
+ daemon_node_id: string;
2404
+ force: boolean;
2405
+ grace_seconds: number;
2406
+ delete_config: boolean;
2407
+ confirm_alias?: string;
2408
+ };
2409
+ // RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
2410
+ // The dashboard (and most callers of stop_node / delete_node) shouldn't
2411
+ // need to track the daemon→child mapping themselves; the hub has it on
2412
+ // node_create_requests at child creation time. Returns null when no
2413
+ // creation record exists (orphan node row OR pre-RFC-026 node) — caller
2414
+ // must then pass daemon_node_id explicitly. node_id derivation:
2415
+ // `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
2416
+ // and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
2417
+ const resolveDaemonForChild = (child_node_id: string): string | null => {
2418
+ if (!child_node_id.startsWith("node_")) return null;
2419
+ const requestId = `cr_${child_node_id.slice(5)}`;
2420
+ const row = db.get<{ daemon_node_id: string }>(
2421
+ `SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
2422
+ requestId,
2423
+ );
2424
+ return row?.daemon_node_id ?? null;
2425
+ };
2426
+
2427
+ const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
2428
+ // §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
2429
+ // caller's scope. resolveReadScope returns 'denied' if the caller
2430
+ // doesn't belong to clientNetId; for the row-existence test we
2431
+ // re-join with member's networks so an attacker can't probe by
2432
+ // node_id from a network they don't belong to.
2433
+ const scope = resolveReadScope(clientNetId);
2434
+ if (scope.denied) {
2435
+ return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
2436
+ }
2437
+ const nodeQ: any[] = [args.child_node_id];
2438
+ let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
2439
+ FROM nodes WHERE node_id = ?1`;
2440
+ nodeSql = addReadScope(nodeSql, nodeQ, scope);
2441
+ const node = db.get<{
2442
+ node_id: string;
2443
+ alias: string;
2444
+ network_id: string;
2445
+ lifecycle_state: string | null;
2446
+ config_snapshot: string | null;
2447
+ }>(nodeSql, ...nodeQ);
2448
+ if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
2449
+
2450
+ // Caller must hold a write-capable role on the resolved network
2451
+ // (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
2452
+ if (!canWrite(node.network_id)) {
2453
+ return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
2454
+ }
2455
+
2456
+ // §4.2 D6 — delete refuses targets whose role is host_supervisor.
2457
+ // Stop is allowed against any node per RFC table (host_supervisor's
2458
+ // stop is functionally a no-op anyway: the daemon's children_map
2459
+ // only tracks its child nodes, never the daemon itself).
2460
+ //
2461
+ // PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
2462
+ // parsed config_snapshot and treated parse-fail / missing-field as
2463
+ // role=null which slipped through the gate. A daemon row with a
2464
+ // corrupt snapshot would then be deletable via delete_node. Fix:
2465
+ // - read role via two independent paths (snapshot.role + the
2466
+ // indexed `runtimes_supported` column whose presence ≈ daemon)
2467
+ // - if EITHER path indicates host_supervisor → refuse
2468
+ // - if snapshot parsed but role missing AND the node has any
2469
+ // daemon-shape evidence (non-null runtimes_supported column,
2470
+ // present in any node_create_requests as daemon_node_id) →
2471
+ // refuse defensively. The defense-in-depth daemon-side
2472
+ // children_map miss is the second layer; the hub gate must
2473
+ // itself fail-closed when role is ambiguous.
2474
+ if (args.action === "delete") {
2475
+ let role: string | null = null;
2476
+ let snapshotParseFailed = false;
2477
+ try {
2478
+ const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
2479
+ role = typeof snap?.role === "string" ? snap.role : null;
2480
+ } catch { snapshotParseFailed = true; }
2481
+ const ambiguous = snapshotParseFailed || (role == null);
2482
+ // Independent corroborating check: nodes flagged as daemons by
2483
+ // create_node dispatchers will show up as daemon_node_id in
2484
+ // node_create_requests. Costs one indexed query.
2485
+ let looksLikeDaemon = false;
2486
+ if (ambiguous) {
2487
+ const m = db.get<{ n: number }>(
2488
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
2489
+ node.node_id,
2490
+ );
2491
+ if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
2492
+ }
2493
+ if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
2494
+ return {
2495
+ ok: false, error: "cannot_delete_daemon_via_delete_node",
2496
+ message: snapshotParseFailed
2497
+ ? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
2498
+ : "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
2499
+ };
2500
+ }
2501
+ }
2502
+
2503
+ // delete_node confirm_alias gate: dashboard's二次确认 input must
2504
+ // match the actual alias byte-for-byte. Hub rejects mismatched
2505
+ // input even if dashboard UI claims it's disabled.
2506
+ if (args.action === "delete") {
2507
+ if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
2508
+ return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
2509
+ }
2510
+ }
2511
+
2512
+ // Daemon must exist + be in the same network. We do NOT enforce
2513
+ // daemon.role==host_supervisor here: a child whose creator daemon
2514
+ // got demoted should still be stoppable. The daemon will refuse
2515
+ // with noop_not_my_child if children_map doesn't know it.
2516
+ //
2517
+ // PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
2518
+ // `${networkId}:${sessionName}`, and agent-node registers its SSE
2519
+ // connection under its ALIAS (not its node_id). The create_node
2520
+ // doorbell at line ~2197 correctly pushes via `daemon.alias`;
2521
+ // stop_node was pushing via `daemon_node_id` so every doorbell
2522
+ // missed every SSE client → daemon never woke up → request_id
2523
+ // stayed 'pending' forever → child never reaped. The 24 unit tests
2524
+ // in stop-delete-node.test.ts all mock pushEvent and inject the
2525
+ // post-doorbell handler call directly, so single-unit coverage
2526
+ // couldn't surface this — exactly the failure class 通信龙 said
2527
+ // the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
2528
+ // alias along with node_id + network_id and dispatch to the alias.
2529
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
2530
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
2531
+ );
2532
+ if (!daemon) return { ok: false, error: "daemon_not_found" };
2533
+ if (daemon.network_id !== node.network_id) {
2534
+ // Cross-tenant child↔daemon mismatch shouldn't be possible in
2535
+ // normal flow but is a SEC-1 hardening — refuse loud.
2536
+ return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
2537
+ }
2538
+
2539
+ // State machine gate.
2540
+ const state = node.lifecycle_state ?? "active";
2541
+ if (args.action === "stop" && state !== "active") {
2542
+ return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
2543
+ }
2544
+ if (args.action === "delete" && state === "deleting") {
2545
+ return { ok: false, error: "node_already_deleting", current_state: state };
2546
+ }
2547
+ if (args.action === "delete" && state === "stopping") {
2548
+ // Can't start delete while a stop is mid-flight — race-prone.
2549
+ return { ok: false, error: "node_stopping_in_progress", current_state: state };
2550
+ }
2551
+
2552
+ // §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
2553
+ // this node's alias (inbox routing uses session_name == alias).
2554
+ // Default refuse with the count surfaced; force=true overrides
2555
+ // and triggers the forced_stop_with_in_flight audit row.
2556
+ // SF-5 (#345 review): legacy inbox rows may have network_id=NULL
2557
+ // and would silently miss a network_id=?2 strict equality match.
2558
+ // Use COALESCE so a NULL inbox row is counted against the same
2559
+ // network as the target node (which is the only safe default
2560
+ // — pre-multi-network rows existed in single-network mode).
2561
+ const inFlightRow = db.get<{ n: number }>(
2562
+ `SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
2563
+ AND COALESCE(network_id, ?2) = ?2`,
2564
+ node.alias, node.network_id,
2565
+ );
2566
+ const inFlight = inFlightRow?.n ?? 0;
2567
+ if (inFlight > 0 && !args.force) {
2568
+ return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
2569
+ }
2570
+
2571
+ // ── all gates passed; create request + transition state + push doorbell, transactionally ──
2572
+ const requestId = generateId("sr");
2573
+ const now = Date.now();
2574
+ const newState = args.action === "stop" ? "stopping" : "deleting";
2575
+
2576
+ // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
2577
+ // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
2578
+ // native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
2579
+ // shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
2580
+ // SF-2 failure-injection test (mock db.run throw inside callback)
2581
+ // reliable across both backends.
2582
+ try {
2583
+ db.transaction(() => {
2584
+ db.run(
2585
+ `INSERT INTO node_stop_requests
2586
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias, action,
2587
+ delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
2588
+ status, created_at)
2589
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'pending', ?12)`,
2590
+ [
2591
+ requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, args.action,
2592
+ args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
2593
+ callerTokenId || "unknown", now,
2594
+ ],
2595
+ );
2596
+ db.run(
2597
+ `UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
2598
+ [newState, node.node_id],
2599
+ );
2600
+ // §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
2601
+ // failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
2602
+ // review catch — auditCreateNode's swallow-and-warn would have
2603
+ // committed the lifecycle UPDATE without an audit row, leaving
2604
+ // exactly the "deleted but no audit" window §4.5 closes).
2605
+ auditCreateNodeStrict({
2606
+ action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
2607
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
2608
+ detail: {
2609
+ child_node_id: node.node_id, child_alias: node.alias,
2610
+ daemon_node_id: args.daemon_node_id, action: args.action,
2611
+ force: args.force, delete_config: args.delete_config,
2612
+ grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
2613
+ lifecycle_state_before: state, lifecycle_state_after: newState,
2614
+ ts_request: now,
2615
+ },
2616
+ });
2617
+ if (args.force && inFlight > 0) {
2618
+ auditCreateNodeStrict({
2619
+ action: "forced_stop_with_in_flight",
2620
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
2621
+ detail: { in_flight_count: inFlight, child_alias: node.alias },
2622
+ });
2623
+ }
2624
+ }); // end db.transaction
2625
+ } catch (e: any) {
2626
+ return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
2627
+ }
2628
+
2629
+ // SSE doorbell — daemon will pull via get_stop_request.
2630
+ // PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
2631
+ // BLOCKER explanation). Mirrors create_node's pushEvent target at
2632
+ // line ~2197.
2633
+ pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
2634
+ return {
2635
+ ok: true, request_id: requestId, action: args.action,
2636
+ lifecycle_state: newState, in_flight_at_dispatch: inFlight,
2637
+ };
2638
+ };
2639
+
2640
+ server.tool(
2641
+ "stop_node",
2642
+ "Stop the agent-node child process; keep config dir intact. Reversible via restart_node. RFC-027 §2.2. daemon_node_id is auto-resolved from child_node_id when omitted (looked up in node_create_requests).",
2643
+ {
2644
+ child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
2645
+ // PR2 prereq: dashboard rarely knows daemon_node_id directly.
2646
+ // When omitted, hub resolves from the original creation record
2647
+ // (node_create_requests.daemon_node_id keyed by this child).
2648
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
2649
+ force: z.boolean().optional().default(false),
2650
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
2651
+ network_id: z.string().max(200).optional(),
2652
+ },
2653
+ async ({ child_node_id, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
2654
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
2655
+ if (!resolved) {
2656
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2657
+ ok: false, error: "daemon_not_resolvable",
2658
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
2659
+ }) }] };
2660
+ }
2661
+ const r = dispatchStopOrDelete(
2662
+ { action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
2663
+ grace_seconds: grace_seconds ?? 10, delete_config: false },
2664
+ clientNetId,
2665
+ );
2666
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
2667
+ },
2668
+ );
2669
+
2670
+ server.tool(
2671
+ "delete_node",
2672
+ "Stop child + revoke ntok + delete hub row + (default) backup config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must equal the node's alias. daemon_node_id is auto-resolved when omitted. RFC-027 §2.2.",
2673
+ {
2674
+ child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
2675
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
2676
+ confirm_alias: z.string().min(1).max(200),
2677
+ force: z.boolean().optional().default(false),
2678
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
2679
+ delete_config: z.boolean().optional().default(true),
2680
+ network_id: z.string().max(200).optional(),
2681
+ },
2682
+ async ({ child_node_id, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
2683
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
2684
+ if (!resolved) {
2685
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2686
+ ok: false, error: "daemon_not_resolvable",
2687
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
2688
+ }) }] };
2689
+ }
2690
+ const r = dispatchStopOrDelete(
2691
+ { action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
2692
+ grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
2693
+ clientNetId,
2694
+ );
2695
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
2696
+ },
2697
+ );
2698
+
2699
+ server.tool(
2700
+ "get_stop_request",
2701
+ "Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
2702
+ {
2703
+ request_id: z.string().min(1).max(200),
2704
+ },
2705
+ async ({ request_id }) => {
2706
+ const callerDaemon = resolveCallerDaemonTokenBound();
2707
+ if (!callerDaemon.ok) {
2708
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2709
+ }
2710
+ const row = db.get<{
2711
+ daemon_node_id: string; status: string; network_id: string;
2712
+ child_node_id: string; child_alias: string; action: string;
2713
+ delete_config: number; grace_seconds: number; force: number;
2714
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
2715
+ delete_config, grace_seconds, force
2716
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
2717
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2718
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2719
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2720
+ }
2721
+ if (row.network_id !== callerDaemon.networkId) {
2722
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2723
+ }
2724
+ // Stamp delivered_at on first pull (idempotent — only if still pending).
2725
+ db.run(
2726
+ `UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
2727
+ WHERE request_id = ?2 AND status = 'pending'`,
2728
+ [Date.now(), request_id],
2729
+ );
2730
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2731
+ ok: true,
2732
+ request_id,
2733
+ child_node_id: row.child_node_id,
2734
+ child_alias: row.child_alias,
2735
+ action: row.action,
2736
+ delete_config: row.delete_config === 1,
2737
+ grace_seconds: row.grace_seconds,
2738
+ force: row.force === 1,
2739
+ }) }] };
2740
+ },
2741
+ );
2742
+
2743
+ server.tool(
2744
+ "ack_stop_request",
2745
+ "Daemon reports stop/delete completion (or per-status failure). On 'stopped' status finalizes the lifecycle: stop→stopped + keep config; delete→DB row gone + revoke ntok. RFC-027 §2.3 + §4.5.",
2746
+ {
2747
+ request_id: z.string().min(1).max(200),
2748
+ status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
2749
+ exit_signal: z.string().max(16).optional(),
2750
+ backup_path: z.string().max(500).optional(),
2751
+ error: z.string().max(1000).optional(),
2752
+ },
2753
+ async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
2754
+ const callerDaemon = resolveCallerDaemonTokenBound();
2755
+ if (!callerDaemon.ok) {
2756
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2757
+ }
2758
+ const row = db.get<{
2759
+ daemon_node_id: string; status: string; network_id: string;
2760
+ child_node_id: string; child_alias: string; action: string;
2761
+ in_flight_at_dispatch: number; force: number;
2762
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
2763
+ in_flight_at_dispatch, force
2764
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
2765
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2766
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2767
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2768
+ }
2769
+ if (row.network_id !== callerDaemon.networkId) {
2770
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2771
+ }
2772
+ const now = Date.now();
2773
+
2774
+ // §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
2775
+ // (+ DELETE for delete action) atomic via db.transaction().
2776
+ // PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
2777
+ // SQLite both get correct rollback semantics from one code path.
2778
+ try {
2779
+ db.transaction(() => {
2780
+ db.run(
2781
+ `UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
2782
+ backup_path = ?4, acked_at = ?5
2783
+ WHERE request_id = ?6`,
2784
+ [status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
2785
+ );
2786
+ if (status === "stopped" && row.action === "stop") {
2787
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
2788
+ auditCreateNodeStrict({
2789
+ action: "stop_node_completed",
2790
+ user_id: null, network_id: row.network_id, target_id: request_id,
2791
+ detail: {
2792
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
2793
+ exit_signal: exit_signal || null, ts_daemon_ack: now,
2794
+ lifecycle_state_after: "stopped",
2795
+ },
2796
+ });
2797
+ } else if (status === "stopped" && row.action === "delete") {
2798
+ // Revoke the child's ntok (name='node:<alias>') in the daemon's
2799
+ // network, then DELETE the nodes row. Token revoke pattern mirrors
2800
+ // ack_create_request's terminal path.
2801
+ db.run(
2802
+ `UPDATE api_tokens SET revoked_at = datetime('now')
2803
+ WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
2804
+ [row.network_id, `node:${row.child_alias}`],
2805
+ );
2806
+ db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
2807
+ auditCreateNodeStrict({
2808
+ action: "delete_node_completed",
2809
+ user_id: null, network_id: row.network_id, target_id: request_id,
2810
+ detail: {
2811
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
2812
+ exit_signal: exit_signal || null, backup_path: backup_path || null,
2813
+ ts_daemon_ack: now, lifecycle_state_after: "deleted",
2814
+ },
2815
+ });
2816
+ } else if (status === "stop_failed") {
2817
+ db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
2818
+ // No completion audit; the failure error is on the request row.
2819
+ }
2820
+ // noop_not_my_child: leave lifecycle_state as-is; the hub-side
2821
+ // sweeper / next reconciliation will pick it up. Don't transition.
2822
+ });
2823
+ } catch (e: any) {
2824
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
2825
+ }
2826
+
2827
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2828
+ },
2829
+ );
2830
+
2831
+ // RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
2832
+ // {child_node_id, alias, lifecycle_state} tuples for nodes whose
2833
+ // active create-request has this daemon as daemon_node_id. Used at
2834
+ // daemon boot to rebuild the in-memory childrenMap (PR1 dropped
2835
+ // every entry on daemon restart → stop/delete silently no-op'd).
2836
+ //
2837
+ // Network-scope is the daemon's own (resolveCallerDaemonTokenBound
2838
+ // already binds tokenIsNetwork to a single network). No SEC-1 leak:
2839
+ // returns ONLY children whose request landed under this daemon's
2840
+ // token + network.
2841
+ server.tool(
2842
+ "list_my_children",
2843
+ "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
2844
+ {},
2845
+ async () => {
2846
+ const callerDaemon = resolveCallerDaemonTokenBound();
2847
+ if (!callerDaemon.ok) {
2848
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2849
+ }
2850
+ // node_create_requests carries the canonical authoritative
2851
+ // child→daemon mapping (the row id IS the request, and the
2852
+ // child node_id derives from it deterministically via
2853
+ // `node_${request_id.replace(/^cr_/, "")}`). Filter to children
2854
+ // that completed registration (have a nodes row) so we don't
2855
+ // ask the daemon to pgrep for children that never came up.
2856
+ const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
2857
+ `SELECT ncr.request_id, ncr.child_name,
2858
+ ('node_' || substr(ncr.request_id, 4)) AS child_node_id,
2859
+ n.lifecycle_state
2860
+ FROM node_create_requests ncr
2861
+ LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
2862
+ WHERE ncr.daemon_node_id = ?1
2863
+ AND ncr.network_id = ?2
2864
+ AND ncr.status IN ('succeeded', 'delivered')
2865
+ AND n.node_id IS NOT NULL
2866
+ AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
2867
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
2868
+ );
2869
+ const children = rows.map(r => ({
2870
+ child_node_id: r.child_node_id,
2871
+ alias: r.child_name,
2872
+ lifecycle_state: r.lifecycle_state ?? "active",
2873
+ }));
2874
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
2875
+ },
2876
+ );
2877
+
2878
+ // ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
2879
+ // Background timers (probe GC + sweeper) idempotent; safe to call
2880
+ // per registerTools.
2881
+ startPendingProbeGcTimer();
2882
+ startProbeSweeperTimer();
2883
+
2884
+ // Helper: zod to JSON-RPC reply for ProbeValidationError + VaultError
2885
+ const probeFailReply = (e: unknown) => {
2886
+ if (e instanceof ProbeValidationError) {
2887
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
2888
+ }
2889
+ if (e instanceof VaultError) {
2890
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, message: e.message }) }] };
2891
+ }
2892
+ throw e;
2893
+ };
2894
+
2895
+ // §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
2896
+ server.tool(
2897
+ "upsert_network_secret",
2898
+ "Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
2899
+ {
2900
+ key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
2901
+ value: z.string().min(1).max(16 * 1024),
2902
+ network_id: z.string().max(200).optional(),
2903
+ },
2904
+ async ({ key, value, network_id: clientNetId }) => {
2905
+ const effectiveNetId = getNetworkId(clientNetId);
2906
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2907
+ const callerRole = enforceUserId && effectiveNetId
2908
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
2909
+ : null;
2910
+ if (callerRole !== "owner") {
2911
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_owner_only", required_role: "owner", caller_role: callerRole }) }] };
2912
+ }
2913
+ try {
2914
+ vaultUpsert(effectiveNetId || "default", key, value);
2915
+ auditCreateNode({
2916
+ action: "create_node_dispatched", // reusing audit_log shape (provider follow-up logs)
2917
+ user_id: enforceUserId, network_id: effectiveNetId, target_id: null,
2918
+ detail: { op: "vault_upsert", key, network_id: effectiveNetId },
2919
+ });
2920
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, key }) }] };
2921
+ } catch (e) {
2922
+ return probeFailReply(e);
2923
+ }
2924
+ },
2925
+ );
2926
+
2927
+ // §2.3.4b — list_network_secrets (key names only; viewer+).
2928
+ server.tool(
2929
+ "list_network_secrets",
2930
+ "List vault key NAMES (NEVER values) for a network. RFC-028.",
2931
+ { network_id: z.string().max(200).optional() },
2932
+ async ({ network_id: clientNetId }) => {
2933
+ const effectiveNetId = getNetworkId(clientNetId);
2934
+ if (!canWrite(effectiveNetId) && enforceUserId) {
2935
+ // viewer-level: allow if caller has any role in network
2936
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
2937
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
2938
+ }
2939
+ const keys = vaultListKeys(effectiveNetId || "default");
2940
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, keys }) }] };
2941
+ },
2942
+ );
2943
+
2944
+ // §2.3.1 — upsert_provider (admin+).
2945
+ server.tool(
2946
+ "upsert_provider",
2947
+ "Create or update a provider (vendor + base_url + secret_key_ref + initial models). Admin+. RFC-028.",
2948
+ {
2949
+ name: z.string().min(1).max(100),
2950
+ vendor: z.string().min(1).max(64),
2951
+ base_url: z.string().min(1).max(500),
2952
+ secret_key_ref: z.string().min(1).max(64),
2953
+ models: z.array(z.object({
2954
+ model_name: z.string().min(1).max(100),
2955
+ display_name: z.string().max(100).optional(),
2956
+ context_window: z.number().int().min(0).optional(),
2957
+ supports_vision: z.boolean().optional(),
2958
+ })).optional(),
2959
+ network_id: z.string().max(200).optional(),
2960
+ },
2961
+ async ({ name, vendor, base_url, secret_key_ref, models, network_id: clientNetId }) => {
2962
+ const effectiveNetId = getNetworkId(clientNetId);
2963
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2964
+ const callerRole = enforceUserId && effectiveNetId
2965
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
2966
+ : null;
2967
+ if (callerRole !== "admin" && callerRole !== "owner") {
2968
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
2969
+ }
2970
+ try {
2971
+ _validateBaseUrl(vendor, base_url);
2972
+ } catch (e) {
2973
+ return probeFailReply(e);
2974
+ }
2975
+ // Verify vault key exists (best-effort; just lists names)
2976
+ const vaultKeys = vaultListKeys(effectiveNetId || "default");
2977
+ if (!vaultKeys.includes(secret_key_ref)) {
2978
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: secret_key_ref, hint: "upsert_network_secret first" }) }] };
2979
+ }
2980
+ const providerId = `prov_${uuidv4()}`;
2981
+ try {
2982
+ db.run(
2983
+ `INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
2984
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)`,
2985
+ [providerId, effectiveNetId || "default", name, vendor, base_url, secret_key_ref, Date.now(), enforceUserId || "unknown"],
2986
+ );
2987
+ } catch (e: any) {
2988
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
2989
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name }) }] };
2990
+ }
2991
+ throw e;
2992
+ }
2993
+ const modelIds: string[] = [];
2994
+ if (models) {
2995
+ for (const m of models) {
2996
+ const mid = `pm_${uuidv4()}`;
2997
+ db.run(
2998
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
2999
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
3000
+ [mid, providerId, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
3001
+ );
3002
+ modelIds.push(mid);
3003
+ }
3004
+ }
3005
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id: providerId, model_ids: modelIds }) }] };
3006
+ },
3007
+ );
3008
+
3009
+ // §2.3.2 — update_provider (admin+; patch semantics). RFC-028 P1.5.
3010
+ // Single tool for editing existing providers — patch model: name/base_url/
3011
+ // models/enabled all optional, at least one required. Vendor and
3012
+ // secret_key_ref are IMMUTABLE via this tool (vendor change = create+delete;
3013
+ // secret change goes through upsert_network_secret first). network_id is
3014
+ // immutable (cross-tenant move forbidden).
3015
+ //
3016
+ // base_url change re-runs validateBaseUrl with vendor read from DB row
3017
+ // (NOT from patch) — defends against trying to widen host allowlist by
3018
+ // smuggling a new vendor name. zod .strict() on the wrapping object
3019
+ // surfaces extras as -32602 at MCP boundary (R3 lock, same as
3020
+ // ack_probe_request per #308 fold-in).
3021
+ //
3022
+ // Audit: every successful update writes audit_log with before/after diff
3023
+ // of the changed fields ONLY (no secret values — secret isn't a patch
3024
+ // field, no leak path).
3025
+ server.registerTool(
3026
+ "update_provider",
3027
+ {
3028
+ description: "Edit existing provider (name/base_url/models/enabled). Patch semantics — at least one field. Vendor/secret/network_id immutable. Admin+. RFC-028 P1.5.",
3029
+ inputSchema: z.object({
3030
+ provider_id: z.string().regex(/^prov_[a-zA-Z0-9_-]+$/).max(200),
3031
+ network_id: z.string().max(200).optional(),
3032
+ patch: z.object({
3033
+ name: z.string().min(1).max(100).optional(),
3034
+ base_url: z.string().min(1).max(500).optional(),
3035
+ models: z.array(z.object({
3036
+ model_name: z.string().min(1).max(100),
3037
+ display_name: z.string().max(100).optional(),
3038
+ context_window: z.number().int().min(0).optional(),
3039
+ supports_vision: z.boolean().optional(),
3040
+ })).optional(),
3041
+ enabled: z.boolean().optional(),
3042
+ }).strict(),
3043
+ }).strict() as any,
3044
+ },
3045
+ async (args: any) => {
3046
+ const { provider_id, network_id: clientNetId, patch } = args;
3047
+ const effectiveNetId = getNetworkId(clientNetId);
3048
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
3049
+ const callerRole = enforceUserId && effectiveNetId
3050
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
3051
+ : null;
3052
+ if (callerRole !== "admin" && callerRole !== "owner") {
3053
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
3054
+ }
3055
+
3056
+ // Empty patch — surface noop_no_changes (don't silently succeed)
3057
+ const patchKeys = Object.keys(patch || {});
3058
+ if (patchKeys.length === 0) {
3059
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "noop_no_changes", hint: "patch must include at least one of: name, base_url, models, enabled" }) }] };
3060
+ }
3061
+
3062
+ // SEC-1: row must exist within caller's network (SQL-level enforcement)
3063
+ const row = db.get<{ provider_id: string; vendor: string; name: string; base_url: string; enabled: number }>(
3064
+ `SELECT provider_id, vendor, name, base_url, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
3065
+ provider_id, effectiveNetId || "default",
3066
+ );
3067
+ if (!row) {
3068
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
3069
+ }
3070
+
3071
+ // base_url change → re-run validateBaseUrl with vendor from DB (not patch)
3072
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
3073
+ try {
3074
+ _validateBaseUrl(row.vendor, patch.base_url);
3075
+ } catch (e) {
3076
+ return probeFailReply(e);
3077
+ }
3078
+ }
3079
+
3080
+ // Diff staging — collect before/after for audit (NO secret values; secret
3081
+ // isn't a patch field). Skip fields that didn't actually change.
3082
+ const diff: Record<string, { before: unknown; after: unknown }> = {};
3083
+ const sets: string[] = [];
3084
+ const params: unknown[] = [];
3085
+ let paramIdx = 1;
3086
+ if (patch.name !== undefined && patch.name !== row.name) {
3087
+ sets.push(`name = ?${paramIdx++}`); params.push(patch.name);
3088
+ diff.name = { before: row.name, after: patch.name };
3089
+ }
3090
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
3091
+ sets.push(`base_url = ?${paramIdx++}`); params.push(patch.base_url);
3092
+ diff.base_url = { before: row.base_url, after: patch.base_url };
3093
+ }
3094
+ if (patch.enabled !== undefined) {
3095
+ const newEnabled = patch.enabled ? 1 : 0;
3096
+ if (newEnabled !== row.enabled) {
3097
+ sets.push(`enabled = ?${paramIdx++}`); params.push(newEnabled);
3098
+ diff.enabled = { before: row.enabled === 1, after: patch.enabled };
3099
+ }
3100
+ }
3101
+
3102
+ // Replace models list (if supplied) atomically with the providers UPDATE
3103
+ const willReplaceModels = patch.models !== undefined;
3104
+ const newModelIds: string[] = [];
3105
+
3106
+ // No-op early return (通信牛 nit): all patch fields matched the
3107
+ // existing row values + no models supplied → no real change to
3108
+ // persist. Returning here means we don't write an empty-diff
3109
+ // audit row (audit noise). Audit_log INSERT must only fire when
3110
+ // something actually changed.
3111
+ if (sets.length === 0 && !willReplaceModels) {
3112
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
3113
+ }
3114
+
3115
+ try {
3116
+ db.exec("BEGIN");
3117
+ if (sets.length > 0) {
3118
+ params.push(provider_id);
3119
+ try {
3120
+ db.run(`UPDATE providers SET ${sets.join(", ")} WHERE provider_id = ?${paramIdx}`, params);
3121
+ } catch (e: any) {
3122
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
3123
+ db.exec("ROLLBACK");
3124
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name: patch.name }) }] };
3125
+ }
3126
+ throw e;
3127
+ }
3128
+ }
3129
+ if (willReplaceModels) {
3130
+ db.run(`DELETE FROM provider_models WHERE provider_id = ?1`, [provider_id]);
3131
+ for (const m of patch.models) {
3132
+ const mid = `pm_${uuidv4()}`;
3133
+ db.run(
3134
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
3135
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
3136
+ [mid, provider_id, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
3137
+ );
3138
+ newModelIds.push(mid);
3139
+ }
3140
+ diff.models = { before: "(prior list)", after: `${patch.models.length} models replaced` };
3141
+ }
3142
+ db.run(
3143
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
3144
+ VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
3145
+ [enforceUserId || null, callerAlias || null, provider_id, JSON.stringify({ diff, fields_changed: Object.keys(diff) }), effectiveNetId || null],
3146
+ );
3147
+ db.exec("COMMIT");
3148
+ } catch (e: any) {
3149
+ try { db.exec("ROLLBACK"); } catch { /* ok */ }
3150
+ throw e;
3151
+ }
3152
+
3153
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3154
+ ok: true, provider_id,
3155
+ fields_changed: Object.keys(diff),
3156
+ models_replaced: willReplaceModels ? newModelIds.length : null,
3157
+ }) }] };
3158
+ },
3159
+ );
3160
+
3161
+ // §2.3.3 — list_providers (viewer+; never returns secret VALUES).
3162
+ server.tool(
3163
+ "list_providers",
3164
+ "List providers + models in the caller's network. Never returns secret VALUES. RFC-028.",
3165
+ { network_id: z.string().max(200).optional() },
3166
+ async ({ network_id: clientNetId }) => {
3167
+ const effectiveNetId = getNetworkId(clientNetId);
3168
+ // viewer access: caller must be member of network (read access)
3169
+ if (enforceUserId) {
3170
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
3171
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
3172
+ }
3173
+ const providers = db.all<any>(
3174
+ `SELECT provider_id, name, vendor, base_url, secret_key_ref, enabled FROM providers WHERE network_id = ?1 AND enabled = 1 ORDER BY name`,
3175
+ effectiveNetId || "default",
3176
+ );
3177
+ const out = providers.map(p => {
3178
+ const models = db.all<any>(
3179
+ `SELECT model_id, model_name, display_name, context_window, supports_vision, enabled FROM provider_models WHERE provider_id = ?1 AND enabled = 1 ORDER BY model_name`,
3180
+ p.provider_id,
3181
+ );
3182
+ return { ...p, in_vault: true, models }; // in_vault: true since we required it on upsert
3183
+ });
3184
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, providers: out }) }] };
3185
+ },
3186
+ );
3187
+
3188
+ // §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
3189
+ server.tool(
3190
+ "probe_provider_model",
3191
+ "Dispatch a connectivity probe to a daemon. Mints ephemeral secret blob; daemon pulls via get_probe_request. Admin+. RFC-028.",
3192
+ {
3193
+ provider_id: z.string().min(1).max(200),
3194
+ model_name: z.string().min(1).max(100),
3195
+ daemon_node_id: z.string().min(1).max(200),
3196
+ network_id: z.string().max(200).optional(),
3197
+ },
3198
+ async ({ provider_id, model_name, daemon_node_id, network_id: clientNetId }) => {
3199
+ const effectiveNetId = getNetworkId(clientNetId);
3200
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
3201
+ const callerRole = enforceUserId && effectiveNetId
3202
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
3203
+ : null;
3204
+ if (callerRole !== "admin" && callerRole !== "owner") {
3205
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_probe", required_role: "admin", caller_role: callerRole }) }] };
3206
+ }
3207
+ // Resolve provider + model (network-scoped). Distinguish "not found"
3208
+ // from "disabled" so the dashboard can render the right error
3209
+ // (P1.5 dashboard编辑/停用 needs this to explain why probe rejected).
3210
+ const provider = db.get<any>(
3211
+ `SELECT provider_id, vendor, base_url, secret_key_ref, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
3212
+ provider_id, effectiveNetId || "default",
3213
+ );
3214
+ if (!provider) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
3215
+ if (provider.enabled !== 1) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_disabled", provider_id, hint: "update_provider with enabled:true to re-enable" }) }] };
3216
+ const model = db.get<any>(
3217
+ `SELECT model_id, model_name FROM provider_models WHERE provider_id = ?1 AND model_name = ?2 AND enabled = 1`,
3218
+ provider_id, model_name,
3219
+ );
3220
+ if (!model) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "model_not_found", model_name }) }] };
3221
+ // Resolve daemon (must be in caller network)
3222
+ const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
3223
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
3224
+ if (!sec1Ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
3225
+ // Vault decrypt the API key (may throw VaultError)
3226
+ let apiKey: string;
3227
+ try {
3228
+ const v = vaultGet(effectiveNetId || "default", provider.secret_key_ref);
3229
+ if (!v) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: provider.secret_key_ref }) }] };
3230
+ apiKey = v;
3231
+ } catch (e) { return probeFailReply(e); }
3232
+ // Mint probe row + stash ephemeral blob
3233
+ const probeId = newProbeId();
3234
+ db.run(
3235
+ `INSERT INTO probe_results (probe_id, provider_id, model_name, daemon_node_id, network_id, status, probed_at, probed_by_user)
3236
+ VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
3237
+ [probeId, provider_id, model_name, daemon_node_id, effectiveNetId || "default", Date.now(), enforceUserId || null],
3238
+ );
3239
+ putPendingProbeSecret({
3240
+ probe_id: probeId,
3241
+ daemon_node_id,
3242
+ provider_id,
3243
+ vendor: provider.vendor,
3244
+ base_url: provider.base_url,
3245
+ model_name,
3246
+ api_key: apiKey,
3247
+ network_id: effectiveNetId || "default",
3248
+ });
3249
+ pushEvent(daemon.alias, { type: "probe_provider", probe_id: probeId }, daemon.network_id || effectiveNetId || "default");
3250
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, probe_id: probeId }) }] };
3251
+ },
3252
+ );
3253
+
3254
+ // §2.3.6 — get_probe_results (viewer+; matrix renderer source).
3255
+ server.tool(
3256
+ "get_probe_results",
3257
+ "Query probe history (optionally filtered by provider/model/daemon). Used by dashboard reachability matrix. RFC-028.",
3258
+ {
3259
+ provider_id: z.string().max(200).optional(),
3260
+ model_name: z.string().max(100).optional(),
3261
+ daemon_node_id: z.string().max(200).optional(),
3262
+ limit: z.number().int().min(1).max(500).optional(),
3263
+ network_id: z.string().max(200).optional(),
3264
+ },
3265
+ async ({ provider_id, model_name, daemon_node_id, limit, network_id: clientNetId }) => {
3266
+ const effectiveNetId = getNetworkId(clientNetId);
3267
+ if (enforceUserId) {
3268
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
3269
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
3270
+ }
3271
+ const where: string[] = ["network_id = ?1"];
3272
+ const params: any[] = [effectiveNetId || "default"];
3273
+ if (provider_id) { where.push(`provider_id = ?${params.length + 1}`); params.push(provider_id); }
3274
+ if (model_name) { where.push(`model_name = ?${params.length + 1}`); params.push(model_name); }
3275
+ if (daemon_node_id) { where.push(`daemon_node_id = ?${params.length + 1}`); params.push(daemon_node_id); }
3276
+ const sql = `SELECT probe_id, provider_id, model_name, daemon_node_id, status, latency_ms, error_label, probed_at, raw_status_code FROM probe_results WHERE ${where.join(" AND ")} ORDER BY probed_at DESC LIMIT ${limit ?? 100}`;
3277
+ const rows = db.all<any>(sql, ...params);
3278
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, results: rows }) }] };
3279
+ },
3280
+ );
3281
+
3282
+ // §2.3.7 daemon-facing — get_probe_request.
3283
+ server.tool(
3284
+ "get_probe_request",
3285
+ "Daemon pulls a pending probe request (called when SSE probe_provider doorbell arrives). RFC-028.",
3286
+ { probe_id: z.string().min(1).max(200) },
3287
+ async ({ probe_id }) => {
3288
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
3289
+ if (!callerDaemon.ok) {
3290
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3291
+ }
3292
+ // takePendingProbeSecret enforces daemon binding + evicts
3293
+ const blob = (await import("./probe.js")).takePendingProbeSecret(probe_id, callerDaemon.daemonNodeId);
3294
+ if (!blob) {
3295
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "probe_request_unavailable", reason: "not_found_or_wrong_daemon_or_expired" }) }] };
3296
+ }
3297
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3298
+ ok: true,
3299
+ probe_id: blob.probe_id,
3300
+ vendor: blob.vendor,
3301
+ base_url: blob.base_url,
3302
+ model_name: blob.model_name,
3303
+ api_key: blob.api_key, // ephemeral; daemon writes to .env.local or in-memory only
3304
+ }) }] };
3305
+ },
3306
+ );
3307
+
3308
+ // §2.3.8 daemon-facing — ack_probe_request (STRICT whitelist via zod).
3309
+ // R3 LOCK (RFC-028 v3): ack payload has EXACTLY 4 keys. We pass a
3310
+ // ZodObject with .strict() so the MCP SDK's validateToolInput surfaces
3311
+ // any extra field (e.g. an attacker smuggling `error_message` to leak a
3312
+ // secret) as a -32602 Invalid params error at the protocol boundary
3313
+ // BEFORE our handler runs. The SDK invokes z.object(shape) by default
3314
+ // which is strip-mode; passing a fully-constructed strict object
3315
+ // bypasses that and enforces unknownKeys='strict'.
3316
+ server.registerTool(
3317
+ "ack_probe_request",
3318
+ {
3319
+ description: "Daemon acks a probe. Schema is STRICT whitelist (no error_message; v3 R3 LOCK). RFC-028.",
3320
+ // Strict-mode ZodObject so the MCP SDK's validateToolInput surfaces
3321
+ // any extra field as a -32602 Invalid params error BEFORE the
3322
+ // handler runs. server.tool() accepts only ZodRawShape (default
3323
+ // strip-mode); registerTool() accepts a constructed ZodObject and
3324
+ // honors its unknownKeys policy.
3325
+ inputSchema: z.object({
3326
+ probe_id: z.string().min(1).max(200),
3327
+ status: z.enum(["ok", "auth_fail", "quota", "rate_limit", "network_error", "timeout", "redirect_forbidden", "vendor_5xx", "other_4xx", "tls_error", "probe_resolve_unsafe_ip", "probe_target_forbidden"]),
3328
+ raw_status_code: z.number().int().min(100).max(599).optional(),
3329
+ latency_ms: z.number().int().min(0).max(60_000),
3330
+ }).strict() as any,
3331
+ },
3332
+ async (args: any) => {
3333
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
3334
+ if (!callerDaemon.ok) {
3335
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3336
+ }
3337
+ try {
3338
+ const r = finalizeProbeAck(args, { network_id: callerDaemon.networkId, daemon_node_id: callerDaemon.daemonNodeId });
3339
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
3340
+ } catch (e) {
3341
+ return probeFailReply(e);
3342
+ }
3343
+ },
3344
+ );
3345
+ }
3346
+
3347
+ // ────────────────────────────────────────────────────────────────────
3348
+ // PR A SEC follow-up (#287 cross-tenant trust-root catch, 通信牛
3349
+ // 2026-06-28) — exported so the production report_status path AND
3350
+ // the regression test in config-apply-sec1.test.ts exercise the SAME
3351
+ // code. Per 通信龙 test-quality finding: an inline-mirror test that
3352
+ // re-implements the gate inside the test body provides zero
3353
+ // protection against guard drift. By forcing both code paths through
3354
+ // this single helper, deleting / weakening the gate fails the test.
3355
+ //
3356
+ // Returns a discriminated outcome so callers can log/route the refused
3357
+ // case (production: silent skip + console.warn; tests: assertion).
3358
+ // ────────────────────────────────────────────────────────────────────
3359
+ export interface UpsertNodeWithSec1GuardInput {
3360
+ node_id: string;
3361
+ callerNetworkId: string | null;
3362
+ node_name?: string | null;
3363
+ alias?: string | null;
3364
+ runtime?: string | null;
3365
+ model?: string | null;
3366
+ config_path?: string | null;
3367
+ channels?: string | null;
3368
+ server?: string | null;
3369
+ hostname?: string | null;
3370
+ config_snapshot?: unknown | null;
3371
+ }
3372
+ export type UpsertNodeOutcome =
3373
+ | { result: "inserted" | "updated"; node_id: string }
3374
+ | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
3375
+ | { result: "skipped"; reason: "missing_node_id" };
3376
+
3377
+ const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
3378
+
3379
+ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
3380
+ if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
3381
+ const existing = db.get<{ network_id: string | null }>(
3382
+ "SELECT network_id FROM nodes WHERE node_id = ?1",
3383
+ input.node_id,
3384
+ );
3385
+ const callerNet = input.callerNetworkId;
3386
+
3387
+ // Legacy / first-write paths: row missing OR network_id NULL → claim.
3388
+ const isLegacy = !existing
3389
+ || existing.network_id === null
3390
+ || existing.network_id === undefined;
3391
+ const sec1Ok = isLegacy || _norm(existing.network_id) === _norm(callerNet);
3392
+
3393
+ if (!sec1Ok) {
3394
+ console.warn(
3395
+ `[commhub] 🚫 report_status cross-network node upsert refused: caller-net=${callerNet ?? "default"} existing-net=${existing!.network_id} node_id=${input.node_id}`,
3396
+ );
3397
+ return {
3398
+ result: "refused",
3399
+ reason: "cross_network",
3400
+ existingNet: existing!.network_id,
3401
+ callerNet,
3402
+ };
3403
+ }
3404
+
3405
+ db.run(
3406
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, updated_at)
3407
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))
3408
+ ON CONFLICT(node_id) DO UPDATE SET
3409
+ node_name = COALESCE(?2, nodes.node_name),
3410
+ alias = COALESCE(?3, nodes.alias),
3411
+ runtime = COALESCE(?4, nodes.runtime),
3412
+ model = COALESCE(?5, nodes.model),
3413
+ config_path = COALESCE(?6, nodes.config_path),
3414
+ channels = COALESCE(?7, nodes.channels),
3415
+ server = COALESCE(?8, nodes.server),
3416
+ hostname = COALESCE(?9, nodes.hostname),
3417
+ network_id = COALESCE(?10, nodes.network_id),
3418
+ updated_at = datetime('now')`,
3419
+ [
3420
+ input.node_id,
3421
+ input.node_name ?? input.alias ?? null,
3422
+ input.alias ?? null,
3423
+ input.runtime ?? null,
3424
+ input.model ?? null,
3425
+ input.config_path ?? null,
3426
+ input.channels ?? null,
3427
+ input.server ?? null,
3428
+ input.hostname ?? null,
3429
+ callerNet ?? null,
3430
+ ],
3431
+ );
3432
+ if (input.config_snapshot) {
3433
+ // RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
3434
+ // to first-class indexable columns alongside the snapshot blob.
3435
+ // The snapshot stays the source of truth for non-list reads; the
3436
+ // columns exist so `list_host_supervisors` doesn't JSON.parse on
3437
+ // every call. typeof-narrow per
3438
+ // [[feedback_typeof_narrow_extracted_fields]] — zod narrowed but
3439
+ // input.config_snapshot is typed `unknown` here.
3440
+ //
3441
+ // PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
3442
+ // per RFC §9.3 + matches existing hub create_node reads at
3443
+ // tools.ts:2010/2075). PR2 ate from top-level keys, which the
3444
+ // hub create_node path never read → max_concurrent_children
3445
+ // backpressure was dead config + allowlist enforcement bypassed.
3446
+ const snap = input.config_snapshot as Record<string, unknown> | null;
3447
+ const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
3448
+ const runtimesRaw = caps?.runtimes_supported;
3449
+ const allowedRaw = caps?.allowed_secret_keys;
3450
+ const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
3451
+ ? JSON.stringify(runtimesRaw)
3452
+ : null;
3453
+ const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
3454
+ ? JSON.stringify(allowedRaw)
3455
+ : null;
3456
+ db.run(
3457
+ `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
3458
+ [JSON.stringify(input.config_snapshot), runtimesJson, allowedJson, input.node_id],
3459
+ );
3460
+ // RFC-024 — finalize any pending/restarting update whose target
3461
+ // patch is now reflected in the snapshot. Closes the
3462
+ // restart-required-never-reaches-applied gap that 通信牛 caught:
3463
+ // the old child acks `restarting` + exits 75; the new child boots,
3464
+ // reads the new config, reports status — but never had the
3465
+ // update_id to call ack_config_update(applied) itself. Hub does
3466
+ // it on the new child's behalf by content-matching the patch
3467
+ // against the reported snapshot.
3468
+ finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
3469
+ }
3470
+ // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
3471
+ // close out any pending create_node request whose child_name matches
3472
+ // this incoming alias. Same content-match pattern as RFC-024's
3473
+ // finalizePendingMatchingUpdates; the new child doesn't have the
3474
+ // request_id, so hub does the matching on its behalf.
3475
+ try {
3476
+ finalizeCreateOnFirstRegister({
3477
+ node_id: input.node_id,
3478
+ alias: input.alias || input.node_name || "",
3479
+ network_id: input.callerNetworkId ?? null,
3480
+ });
3481
+ } catch (e: any) {
3482
+ // create-node finalize is best-effort — never block report_status
3483
+ // on it. Log + continue.
3484
+ console.warn(`[commhub] create-node finalize on report_status failed: ${e?.message || e}`);
3485
+ }
3486
+ return { result: existing ? "updated" : "inserted", node_id: input.node_id };
3487
+ }
3488
+
3489
+ /**
3490
+ * RFC-024 restart-finalize (Option A per 通信牛 final review).
3491
+ *
3492
+ * Called from `upsertNodeWithSec1Guard` after writing a fresh
3493
+ * `config_snapshot` for the node. For each pending/restarting update
3494
+ * row, parse the patch and compare every field against the snapshot.
3495
+ * If everything in the patch is now reflected in the live snapshot,
3496
+ * mark the update applied + bump `nodes.config_revision`. The new
3497
+ * child doesn't need to know the update_id — content-matching against
3498
+ * the live state IS the proof that the apply landed.
3499
+ *
3500
+ * Edge cases:
3501
+ * - Empty patch (apply_mode=restart_only from `restart_node` tool) →
3502
+ * ANY snapshot matches (the restart itself was the goal).
3503
+ * - Patch field absent from snapshot → not a match (snapshot
3504
+ * post-dates the apply only when every requested field shows up).
3505
+ * - Multiple concurrent pending rows → impossible by single-flight,
3506
+ * but defensive: finalize OLDEST first so the chain stays linear.
3507
+ *
3508
+ * Pure-ish: takes a snapshot blob (TS shape, see config-snapshot type)
3509
+ * and runs only db.run / db.get. No external IO.
3510
+ */
3511
+ export function finalizePendingMatchingUpdates(
3512
+ nodeId: string,
3513
+ snapshot: any,
3514
+ ): { finalizedCount: number; finalizedIds: string[] } {
3515
+ const pending = db.all<{
3516
+ update_id: string;
3517
+ patch_json: string;
3518
+ apply_mode: string;
3519
+ base_revision: number;
3520
+ }>(
3521
+ "SELECT update_id, patch_json, apply_mode, base_revision FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at ASC",
3522
+ nodeId,
3523
+ );
3524
+ if (pending.length === 0) return { finalizedCount: 0, finalizedIds: [] };
3525
+
3526
+ const snapModel: string | null | undefined = snapshot?.model;
3527
+ const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
3528
+
3529
+ const finalizedIds: string[] = [];
3530
+
3531
+ for (const row of pending) {
3532
+ let patch: any = {};
3533
+ try { patch = JSON.parse(row.patch_json); } catch { continue; }
3534
+
3535
+ let matches = true;
3536
+ // restart_only / empty patch → any snapshot proves the restart
3537
+ // happened, finalize unconditionally.
3538
+ if (row.apply_mode !== "restart_only") {
3539
+ if (patch.model !== undefined) {
3540
+ if (snapModel !== patch.model) { matches = false; }
3541
+ }
3542
+ if (matches && patch.flags && typeof patch.flags === "object") {
3543
+ for (const [k, v] of Object.entries(patch.flags as Record<string, unknown>)) {
3544
+ // Canonical-key fallback for legacy aliases. The dashboard
3545
+ // schema uses `budget` and `timeout`; node-side config may
3546
+ // also carry the older `maxBudgetUsd` / `claudeTimeoutMs` /
3547
+ // `codexTimeoutMs` keys. agent-node's buildConfigSnapshot
3548
+ // only reports the canonical key, so we just compare on `k`.
3549
+ if (snapFlags[k] !== v) { matches = false; break; }
3550
+ }
3551
+ }
3552
+ }
3553
+
3554
+ if (!matches) continue;
3555
+
3556
+ // Promote nodes.config_revision atomically with the update row.
3557
+ const nextRev = (db.get<{ config_revision: number }>(
3558
+ "SELECT config_revision FROM nodes WHERE node_id = ?1",
3559
+ nodeId,
3560
+ )?.config_revision || 0) + 1;
3561
+ db.run(
3562
+ "UPDATE node_config_updates SET status = 'applied', acked_at = ?1, new_revision = ?2 WHERE update_id = ?3 AND status IN ('pending', 'restarting')",
3563
+ [Date.now(), nextRev, row.update_id],
3564
+ );
3565
+ db.run("UPDATE nodes SET config_revision = ?1 WHERE node_id = ?2", [nextRev, nodeId]);
3566
+ finalizedIds.push(row.update_id);
3567
+ console.log(
3568
+ `[commhub] ✓ finalize update ${row.update_id} via report_status content-match: node=${nodeId} new_revision=${nextRev} apply_mode=${row.apply_mode}`,
3569
+ );
3570
+ }
3571
+
3572
+ return { finalizedCount: finalizedIds.length, finalizedIds };
1352
3573
  }