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

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
@@ -4,6 +4,13 @@ import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
4
4
  import { pushEvent } from "./push.js";
5
5
  import { getUserNetworkRole } from "./auth.js";
6
6
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
7
+ import {
8
+ ALLOWED_FLAGS,
9
+ SECURITY_SENSITIVE_FLAGS,
10
+ computeApplyMode,
11
+ validatePatch,
12
+ isAllowedToChangeFlag,
13
+ } from "./config-apply-validate.js";
7
14
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
8
15
 
9
16
  function ts(): string {
@@ -237,8 +244,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
237
244
  uptime_seconds: z.number().nullable().optional(),
238
245
  in_flight_count: z.number().nullable().optional(),
239
246
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
247
+ // RFC-024 B6 — masked snapshot of the node's effective config
248
+ // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
249
+ // shape (env._envRef stays on host); the dashboard reads this
250
+ // verbatim for the snapshot path without touching node files.
251
+ // config_update_capable signals whether the node runs under a
252
+ // supervisor wrapper that honours the sentinel-75 restart path
253
+ // (W1) — bare-spawned agent-nodes set this to false so dashboard
254
+ // can grey out remote-restart for them.
255
+ config_snapshot: z.object({
256
+ model: z.string().max(200).optional().nullable(),
257
+ flags: z.record(z.unknown()).optional(),
258
+ config_revision: z.number().int().min(0).optional(),
259
+ config_update_capable: z.boolean().optional(),
260
+ }).optional().describe("RFC-024 — masked node config snapshot"),
240
261
  },
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 }) => {
262
+ 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
263
  const effectiveNetId = getNetworkId(netId);
243
264
  const sessionNetId = effectiveNetId ?? "default";
244
265
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -391,27 +412,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
391
412
  } catch {}
392
413
  }
393
414
 
394
- // V2: upsert nodes table for persistent node identity
415
+ // V2: upsert nodes table for persistent node identity. SEC-1
416
+ // gate (PR A #287 follow-up, 通信牛 catch 2026-06-28): delegate
417
+ // to upsertNodeWithSec1Guard so production + test exercise the
418
+ // exact same code path. See helper below registerTools.
395
419
  if (node_id) {
396
420
  try {
397
- // Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
398
421
  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
- );
422
+ upsertNodeWithSec1Guard({
423
+ node_id,
424
+ callerNetworkId: effectiveNetId ?? null,
425
+ node_name: nn || effectiveAlias,
426
+ alias: effectiveAlias,
427
+ runtime: nodeRuntime,
428
+ model: mdl ?? null,
429
+ config_path: config_path ?? null,
430
+ channels: channels ?? null,
431
+ server: srv ?? null,
432
+ hostname: hn ?? null,
433
+ config_snapshot: cfgSnap ?? null,
434
+ });
415
435
  } catch {}
416
436
  }
417
437
 
@@ -501,20 +521,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
501
521
  // round5 F2: pass caller's effectiveNetId so the chain refuses
502
522
  // to write across tenants if some upstream parent links to a
503
523
  // foreign network.
524
+ //
525
+ // round5 follow-up (通信牛 SSE leak catch): gate the SSE push on
526
+ // `result.chained` — if the chain refused (cross-network), the
527
+ // subsequent SELECT of `parent.from_name` + `pushEvent(...,
528
+ // parent.task_id)` would leak the foreign parent's task_id into
529
+ // the caller's network via the SSE payload. Skip the push when
530
+ // the chain didn't actually write.
504
531
  if (updatedTaskId) {
505
532
  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]
533
+ const chainResult = chainReplyToParent(updatedTaskId, result, "replied", 5, effectiveNetId);
534
+ if (chainResult.chained) {
535
+ const parentChain = db.get<{ parent_task_id: string | null }>(
536
+ "SELECT parent_task_id FROM tasks WHERE task_id = ?1",
537
+ [updatedTaskId]
515
538
  );
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);
539
+ if (parentChain?.parent_task_id) {
540
+ const parent = db.get<{ from_name: string; task_id: string }>(
541
+ "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
542
+ [parentChain.parent_task_id]
543
+ );
544
+ if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
545
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias }, effectiveNetId);
546
+ }
518
547
  }
519
548
  }
520
549
  } catch (e: any) {
@@ -613,21 +642,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
613
642
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
614
643
  console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
615
644
 
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
- });
645
+ // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
646
+ // (background timer, ~60s cadence). Read path no longer fires UPDATE.
647
+ let sql = "SELECT * FROM sessions WHERE 1=1";
648
+ const params: any[] = [];
649
+ sql = addReadScope(sql, params, readScope);
650
+ if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
651
+ if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
652
+ sql += " ORDER BY updated_at DESC";
653
+ const sessions = db.all(sql, ...params);
631
654
 
632
655
  const summaryParams: any[] = [];
633
656
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
@@ -698,6 +721,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
698
721
  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
722
  const effectiveNetId = getNetworkId(netId);
700
723
  const metaJson = normalizeMetaJson(meta);
724
+
725
+ // Role check FIRST — round5 follow-up (通信牛 oracle catch):
726
+ // the explicit-parent verification below distinguishes
727
+ // `cross_network_parent` from `permission_denied`. If we ran the
728
+ // parent lookup before canWrite, a viewer role could probe parent
729
+ // existence + ownership of foreign parents via the difference in
730
+ // error codes. Run canWrite first so a viewer ALWAYS gets the
731
+ // same `permission_denied` regardless of parent state.
732
+ if (!canWrite(effectiveNetId)) {
733
+ return writeDeniedReply(effectiveNetId, "send_task");
734
+ }
735
+
701
736
  // Resolve parent_task_id: explicit > inferred (caller's most recent
702
737
  // delivered/started inbox task that's still open). Inference is the
703
738
  // safety net for when the LLM forgets to pass parent_task_id.
@@ -743,11 +778,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
743
778
  }
744
779
  }
745
780
 
746
- // Role check: viewer cannot send tasks
747
- if (!canWrite(effectiveNetId)) {
748
- return writeDeniedReply(effectiveNetId, "send_task");
749
- }
750
-
751
781
  // License check
752
782
  const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
753
783
  if (license?.expires_at) {
@@ -1017,21 +1047,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1017
1047
  // round5 F2: pass caller's effectiveNetId so the chain refuses
1018
1048
  // to write across tenants if some upstream parent links to a
1019
1049
  // foreign network.
1050
+ //
1051
+ // round5 follow-up (通信牛 SSE leak catch): gate the SSE push on
1052
+ // `result.chained`. See report_completion path above for the
1053
+ // full reasoning — same leak, same gate.
1020
1054
  if (replyLogged && in_reply_to) {
1021
1055
  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]
1056
+ const chainResult = chainReplyToParent(in_reply_to, text, replyStatus, 5, effectiveNetId);
1057
+ if (chainResult.chained) {
1058
+ const parentChain = db.get<{ parent_task_id: string | null; from_name: string }>(
1059
+ "SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?1",
1060
+ [in_reply_to]
1032
1061
  );
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);
1062
+ if (parentChain?.parent_task_id) {
1063
+ const parent = db.get<{ from_name: string; task_id: string }>(
1064
+ "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
1065
+ [parentChain.parent_task_id]
1066
+ );
1067
+ if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
1068
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias }, effectiveNetId);
1069
+ }
1035
1070
  }
1036
1071
  }
1037
1072
  } catch (e: any) {
@@ -1349,4 +1384,465 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1349
1384
  };
1350
1385
  }
1351
1386
  );
1387
+
1388
+ // ──────────────────────────────────────────────────────────────────
1389
+ // RFC-024 (2026-06-28) — node config-apply MCP tools.
1390
+ //
1391
+ // Three contract tools (update / get / ack) + one lifecycle tool
1392
+ // (restart_node, Vincent 2026-06-28 increment). All four enforce
1393
+ // SEC-1 (network-scoped, never trust upstream dashboard routing —
1394
+ // every tool re-checks caller→token→network. Mirrors the #275
1395
+ // cross-tenant write防护带 pattern). update_node_config additionally
1396
+ // enforces SEC-2 — security-sensitive flags (permissionMode,
1397
+ // dangerouslySkipPermissions, teammateMode) are fail-CLOSED pending
1398
+ // Vincent's policy decision (see SECURITY_SENSITIVE_FLAGS below).
1399
+ //
1400
+ // See docs/rfcs/RFC-024-dashboard-node-config-apply.md for the
1401
+ // contract + sequence diagrams + guards.
1402
+ // ──────────────────────────────────────────────────────────────────
1403
+
1404
+ // Helpers — ALLOWED_FLAGS, SECURITY_SENSITIVE_FLAGS, computeApplyMode,
1405
+ // validatePatch, isAllowedToChangeFlag live in
1406
+ // ./config-apply-validate.ts so the contract is unit-testable without
1407
+ // standing up an MCP server.
1408
+
1409
+ /**
1410
+ * Resolve the node row that update_node_config / restart_node targets.
1411
+ * Returns the row + the SEC-1 verdict (network match against caller).
1412
+ * Network mismatch is the cross-tenant write防护带 from #275 — every
1413
+ * tool re-checks this even if dashboard already did, because curl
1414
+ * can talk directly to /mcp.
1415
+ */
1416
+ const resolveTargetNode = (
1417
+ nodeId: string,
1418
+ callerNetworkId: string | null,
1419
+ ): { row: any | null; sec1Ok: boolean } => {
1420
+ const row = db.get<any>(
1421
+ "SELECT node_id, alias, network_id, config_revision FROM nodes WHERE node_id = ?1",
1422
+ nodeId,
1423
+ );
1424
+ if (!row) return { row: null, sec1Ok: false };
1425
+ // Use the same null/undefined → "default" normalization as the
1426
+ // report_status upsert guard (norm() helper) — `||` would also
1427
+ // coerce `""` to "default", which is unreachable in the V3 model
1428
+ // today but better aligned to avoid drift if any future migration
1429
+ // ever introduces empty-string network_ids. Single source of
1430
+ // truth: nullish-only.
1431
+ const nodeNet = row.network_id === null || row.network_id === undefined ? "default" : row.network_id;
1432
+ const callerNet = callerNetworkId === null || callerNetworkId === undefined ? "default" : callerNetworkId;
1433
+ return { row, sec1Ok: nodeNet === callerNet };
1434
+ };
1435
+
1436
+ server.tool(
1437
+ "update_node_config",
1438
+ "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.",
1439
+ {
1440
+ node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
1441
+ base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
1442
+ patch: z.object({
1443
+ model: z.string().max(200).optional(),
1444
+ flags: z.record(z.unknown()).optional(),
1445
+ }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1446
+ network_id: z.string().max(200).optional(),
1447
+ },
1448
+ async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
1449
+ const effectiveNetId = getNetworkId(clientNetId);
1450
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1451
+
1452
+ const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
1453
+ if (!node) {
1454
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
1455
+ }
1456
+ if (!sec1Ok) {
1457
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
1458
+ }
1459
+
1460
+ const model = typeof patch.model === "string" ? patch.model : undefined;
1461
+ const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
1462
+
1463
+ // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1464
+ // (permissionMode / dangerouslySkipPermissions / teammateMode)
1465
+ // require admin role on this network. Other flags fall through
1466
+ // to per-field validation. hub-side enforced (dashboard's UI
1467
+ // gate is not trusted; curl direct to /mcp is the attack
1468
+ // vector). See isAllowedToChangeFlag for the policy details.
1469
+ const callerRole = enforceUserId && effectiveNetId
1470
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
1471
+ : null;
1472
+ const secCheck = isAllowedToChangeFlag(callerRole, flags);
1473
+ if (secCheck) {
1474
+ return {
1475
+ content: [{
1476
+ type: "text" as const,
1477
+ text: JSON.stringify({
1478
+ ok: false,
1479
+ error: "insufficient_role_for_security_flag",
1480
+ field: secCheck.field,
1481
+ required_role: "admin", // or owner — both satisfy
1482
+ message: secCheck.reason,
1483
+ }),
1484
+ }],
1485
+ };
1486
+ }
1487
+
1488
+ const validationFail = validatePatch(model, flags);
1489
+ if (validationFail) {
1490
+ return {
1491
+ content: [{
1492
+ type: "text" as const,
1493
+ text: JSON.stringify({
1494
+ ok: false,
1495
+ error: "invalid_patch",
1496
+ field: validationFail.field,
1497
+ reason: validationFail.reason,
1498
+ }),
1499
+ }],
1500
+ };
1501
+ }
1502
+
1503
+ // Revision conflict.
1504
+ if ((node.config_revision || 0) !== baseRev) {
1505
+ return {
1506
+ content: [{
1507
+ type: "text" as const,
1508
+ text: JSON.stringify({
1509
+ ok: false,
1510
+ error: "revision_conflict",
1511
+ current_revision: node.config_revision || 0,
1512
+ base_revision: baseRev,
1513
+ }),
1514
+ }],
1515
+ };
1516
+ }
1517
+
1518
+ // F-B (CHANGE_REQ): single-flight with stale-update reaper.
1519
+ // Without TTL, a node that ack'd "restarting" then crashed (lost
1520
+ // power, OOM, killed) leaves a non-terminal row forever — every
1521
+ // subsequent update_node_config / restart_node returns
1522
+ // update_in_flight and the node is admin-bricked.
1523
+ //
1524
+ // Stale threshold = 60_000 ms (2× the §8-confirmed 30s apply ceiling,
1525
+ // chosen so a slow-but-alive node within its own deadline never
1526
+ // false-positives as stale). Stale rows are marked timeout +
1527
+ // superseded by the new update.
1528
+ //
1529
+ // Age anchor = COALESCE(acked_at, created_at) per 通信龙 polish:
1530
+ // a healthy-but-slow restart (drain 60s + respawn time) could
1531
+ // exceed the threshold if anchored on created_at alone (drain
1532
+ // cap and reaper threshold are both 60s — overlap). Anchoring
1533
+ // on acked_at means a node that ack'd "restarting" refreshes
1534
+ // the liveness clock, so an in-progress restart isn't falsely
1535
+ // reaped.
1536
+ const STALE_THRESHOLD_MS = 60_000;
1537
+ const inFlight = db.get<{ update_id: string; created_at: number; acked_at: number | null }>(
1538
+ "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",
1539
+ nodeId,
1540
+ );
1541
+ if (inFlight) {
1542
+ const ageAnchor = inFlight.acked_at ?? inFlight.created_at;
1543
+ const age = Date.now() - ageAnchor;
1544
+ if (age <= STALE_THRESHOLD_MS) {
1545
+ return {
1546
+ content: [{
1547
+ type: "text" as const,
1548
+ text: JSON.stringify({
1549
+ ok: false,
1550
+ error: "update_in_flight",
1551
+ existing_update_id: inFlight.update_id,
1552
+ age_ms: age,
1553
+ }),
1554
+ }],
1555
+ };
1556
+ }
1557
+ // Stale — supersede.
1558
+ db.run(
1559
+ "UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = ?2 WHERE update_id = ?3",
1560
+ [Date.now(), `superseded by new update after ${age}ms stale (> ${STALE_THRESHOLD_MS}ms threshold)`, inFlight.update_id],
1561
+ );
1562
+ }
1563
+
1564
+ // Compute apply_mode + persist + push doorbell.
1565
+ const updateId = `cu_${uuidv4()}`;
1566
+ const applyMode = computeApplyMode(model, flags);
1567
+ const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
1568
+ const networkId = node.network_id || "default";
1569
+ db.run(
1570
+ `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)`,
1571
+ [updateId, nodeId, networkId, patchJson, applyMode, baseRev, Date.now(), callerTokenId || "unknown"],
1572
+ );
1573
+
1574
+ pushEvent(node.alias, { type: "config_update", update_id: updateId }, networkId);
1575
+
1576
+ return {
1577
+ content: [{
1578
+ type: "text" as const,
1579
+ text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: applyMode }),
1580
+ }],
1581
+ };
1582
+ },
1583
+ );
1584
+
1585
+ server.tool(
1586
+ "get_config_update",
1587
+ "Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
1588
+ {},
1589
+ async () => {
1590
+ // F-A (CHANGE_REQ): require ntok_ + non-null enforceNetworkId.
1591
+ // Mirror report_status's guard (tools.ts:251-253) — utok_ has
1592
+ // enforceNetworkId=null and callerAlias=username, so without
1593
+ // this gate a utok_ whose username happens to match a node alias
1594
+ // could pull that node's pending update across network scope
1595
+ // (network filter would be silently dropped, since old code had
1596
+ // a conditional WHERE). Hub doesn't trust upstream gates — every
1597
+ // node-private tool must independently require a network-bound
1598
+ // token.
1599
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
1600
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
1601
+ }
1602
+ if (!callerAlias) {
1603
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
1604
+ }
1605
+ // Unconditional network_id filter (was previously conditional on
1606
+ // enforceNetworkId being set; the new ntok guard above guarantees
1607
+ // it's non-null so the filter is always applied).
1608
+ const node = db.get<any>(
1609
+ "SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1610
+ callerAlias,
1611
+ enforceNetworkId,
1612
+ );
1613
+ if (!node) {
1614
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
1615
+ }
1616
+ const update = db.get<any>(
1617
+ "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",
1618
+ node.node_id,
1619
+ );
1620
+ if (!update) {
1621
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
1622
+ }
1623
+ let patch: any = {};
1624
+ try { patch = JSON.parse(update.patch_json); } catch { patch = {}; }
1625
+ return {
1626
+ content: [{
1627
+ type: "text" as const,
1628
+ text: JSON.stringify({
1629
+ ok: true,
1630
+ update: {
1631
+ update_id: update.update_id,
1632
+ patch,
1633
+ apply_mode: update.apply_mode,
1634
+ base_revision: update.base_revision,
1635
+ },
1636
+ }),
1637
+ }],
1638
+ };
1639
+ },
1640
+ );
1641
+
1642
+ server.tool(
1643
+ "ack_config_update",
1644
+ "Node acknowledges a config update — applied / rejected / restarting / timeout. RFC-024.",
1645
+ {
1646
+ update_id: z.string().min(1).max(200),
1647
+ status: z.enum(["applied", "rejected", "restarting", "timeout"]),
1648
+ new_revision: z.number().int().min(0).optional(),
1649
+ error: z.string().max(2000).optional(),
1650
+ },
1651
+ async ({ update_id: updateId, status, new_revision: newRev, error: ackError }) => {
1652
+ // F-A (CHANGE_REQ): same ntok_ guard as get_config_update. Without
1653
+ // this, a utok_ whose username matches a node alias could ack
1654
+ // arbitrary updates within the alias-collision; the new ntok guard
1655
+ // closes that.
1656
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
1657
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
1658
+ }
1659
+ if (!callerAlias) {
1660
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
1661
+ }
1662
+ // Cross-tenant guard: the ack-er must own the update being acked.
1663
+ // Resolve node by caller's alias under the enforced network.
1664
+ // Network filter is unconditional (guard above guarantees non-null).
1665
+ const node = db.get<any>(
1666
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1667
+ callerAlias,
1668
+ enforceNetworkId,
1669
+ );
1670
+ if (!node) {
1671
+ // Silently ignore stale ack — return ok so the node doesn't retry forever.
1672
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
1673
+ }
1674
+ const update = db.get<any>(
1675
+ "SELECT update_id, node_id, status FROM node_config_updates WHERE update_id = ?1",
1676
+ updateId,
1677
+ );
1678
+ if (!update || update.node_id !== node.node_id) {
1679
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_update" }) }] };
1680
+ }
1681
+ // Reject ack for already-terminal updates (idempotency).
1682
+ if (update.status === "applied" || update.status === "rejected" || update.status === "timeout") {
1683
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: update.status }) }] };
1684
+ }
1685
+
1686
+ const ackedAt = Date.now();
1687
+ if (status === "applied") {
1688
+ // Promote the node's config_revision to the new revision, atomically.
1689
+ 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);
1690
+ db.run(
1691
+ `UPDATE node_config_updates SET status = 'applied', acked_at = ?1, new_revision = ?2 WHERE update_id = ?3`,
1692
+ [ackedAt, nextRev, updateId],
1693
+ );
1694
+ db.run(`UPDATE nodes SET config_revision = ?1 WHERE node_id = ?2`, [nextRev, node.node_id]);
1695
+ } else {
1696
+ db.run(
1697
+ `UPDATE node_config_updates SET status = ?1, acked_at = ?2, error = ?3 WHERE update_id = ?4`,
1698
+ [status, ackedAt, ackError || null, updateId],
1699
+ );
1700
+ }
1701
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
1702
+ },
1703
+ );
1704
+
1705
+ server.tool(
1706
+ "restart_node",
1707
+ "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).",
1708
+ {
1709
+ node_id: z.string().min(1).max(200),
1710
+ network_id: z.string().max(200).optional(),
1711
+ },
1712
+ async ({ node_id: nodeId, network_id: clientNetId }) => {
1713
+ const effectiveNetId = getNetworkId(clientNetId);
1714
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1715
+
1716
+ const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
1717
+ if (!node) {
1718
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
1719
+ }
1720
+ if (!sec1Ok) {
1721
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
1722
+ }
1723
+ // F-B reaper: same stale-supersede semantics as update_node_config,
1724
+ // with same acked_at-anchored liveness clock (see update_node_config
1725
+ // for the 通信龙 polish reasoning).
1726
+ const STALE_THRESHOLD_MS_R = 60_000;
1727
+ const inFlight = db.get<{ update_id: string; created_at: number; acked_at: number | null }>(
1728
+ "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",
1729
+ nodeId,
1730
+ );
1731
+ if (inFlight) {
1732
+ const ageAnchor = inFlight.acked_at ?? inFlight.created_at;
1733
+ const age = Date.now() - ageAnchor;
1734
+ if (age <= STALE_THRESHOLD_MS_R) {
1735
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "update_in_flight", existing_update_id: inFlight.update_id, age_ms: age }) }] };
1736
+ }
1737
+ db.run(
1738
+ "UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = ?2 WHERE update_id = ?3",
1739
+ [Date.now(), `superseded by restart_node after ${age}ms stale`, inFlight.update_id],
1740
+ );
1741
+ }
1742
+ const updateId = `cu_${uuidv4()}`;
1743
+ const networkId = node.network_id || "default";
1744
+ db.run(
1745
+ `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)`,
1746
+ [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
1747
+ );
1748
+ pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
1749
+ return {
1750
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
1751
+ };
1752
+ },
1753
+ );
1754
+ }
1755
+
1756
+ // ────────────────────────────────────────────────────────────────────
1757
+ // PR A SEC follow-up (#287 cross-tenant trust-root catch, 通信牛
1758
+ // 2026-06-28) — exported so the production report_status path AND
1759
+ // the regression test in config-apply-sec1.test.ts exercise the SAME
1760
+ // code. Per 通信龙 test-quality finding: an inline-mirror test that
1761
+ // re-implements the gate inside the test body provides zero
1762
+ // protection against guard drift. By forcing both code paths through
1763
+ // this single helper, deleting / weakening the gate fails the test.
1764
+ //
1765
+ // Returns a discriminated outcome so callers can log/route the refused
1766
+ // case (production: silent skip + console.warn; tests: assertion).
1767
+ // ────────────────────────────────────────────────────────────────────
1768
+ export interface UpsertNodeWithSec1GuardInput {
1769
+ node_id: string;
1770
+ callerNetworkId: string | null;
1771
+ node_name?: string | null;
1772
+ alias?: string | null;
1773
+ runtime?: string | null;
1774
+ model?: string | null;
1775
+ config_path?: string | null;
1776
+ channels?: string | null;
1777
+ server?: string | null;
1778
+ hostname?: string | null;
1779
+ config_snapshot?: unknown | null;
1780
+ }
1781
+ export type UpsertNodeOutcome =
1782
+ | { result: "inserted" | "updated"; node_id: string }
1783
+ | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
1784
+ | { result: "skipped"; reason: "missing_node_id" };
1785
+
1786
+ const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
1787
+
1788
+ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
1789
+ if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
1790
+ const existing = db.get<{ network_id: string | null }>(
1791
+ "SELECT network_id FROM nodes WHERE node_id = ?1",
1792
+ input.node_id,
1793
+ );
1794
+ const callerNet = input.callerNetworkId;
1795
+
1796
+ // Legacy / first-write paths: row missing OR network_id NULL → claim.
1797
+ const isLegacy = !existing
1798
+ || existing.network_id === null
1799
+ || existing.network_id === undefined;
1800
+ const sec1Ok = isLegacy || _norm(existing.network_id) === _norm(callerNet);
1801
+
1802
+ if (!sec1Ok) {
1803
+ console.warn(
1804
+ `[commhub] 🚫 report_status cross-network node upsert refused: caller-net=${callerNet ?? "default"} existing-net=${existing!.network_id} node_id=${input.node_id}`,
1805
+ );
1806
+ return {
1807
+ result: "refused",
1808
+ reason: "cross_network",
1809
+ existingNet: existing!.network_id,
1810
+ callerNet,
1811
+ };
1812
+ }
1813
+
1814
+ db.run(
1815
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, updated_at)
1816
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))
1817
+ ON CONFLICT(node_id) DO UPDATE SET
1818
+ node_name = COALESCE(?2, nodes.node_name),
1819
+ alias = COALESCE(?3, nodes.alias),
1820
+ runtime = COALESCE(?4, nodes.runtime),
1821
+ model = COALESCE(?5, nodes.model),
1822
+ config_path = COALESCE(?6, nodes.config_path),
1823
+ channels = COALESCE(?7, nodes.channels),
1824
+ server = COALESCE(?8, nodes.server),
1825
+ hostname = COALESCE(?9, nodes.hostname),
1826
+ network_id = COALESCE(?10, nodes.network_id),
1827
+ updated_at = datetime('now')`,
1828
+ [
1829
+ input.node_id,
1830
+ input.node_name ?? input.alias ?? null,
1831
+ input.alias ?? null,
1832
+ input.runtime ?? null,
1833
+ input.model ?? null,
1834
+ input.config_path ?? null,
1835
+ input.channels ?? null,
1836
+ input.server ?? null,
1837
+ input.hostname ?? null,
1838
+ callerNet ?? null,
1839
+ ],
1840
+ );
1841
+ if (input.config_snapshot) {
1842
+ db.run(
1843
+ `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
1844
+ [JSON.stringify(input.config_snapshot), input.node_id],
1845
+ );
1846
+ }
1847
+ return { result: existing ? "updated" : "inserted", node_id: input.node_id };
1352
1848
  }