@sleep2agi/commhub-server 0.9.0-preview.20 → 0.9.0-preview.22

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,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
3
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
4
- import { pushEvent } from "./push.js";
4
+ import { pushEvent, pushNetworkObserverEvent } from "./push.js";
5
5
  import { assertNodeActive } from "./lifecycle-guard.js";
6
6
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
7
7
  import {
@@ -47,8 +47,10 @@ import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias
47
47
  import {
48
48
  ALLOWED_FLAGS,
49
49
  SECURITY_SENSITIVE_FLAGS,
50
+ EDITABLE_CHANNELS,
50
51
  computeApplyMode,
51
52
  validatePatch,
53
+ narrowChannelsPatch,
52
54
  isAllowedToChangeFlag,
53
55
  } from "./config-apply-validate.js";
54
56
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
@@ -956,6 +958,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
956
958
  if (target.state === "online") {
957
959
  pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
958
960
  }
961
+ // #461 network observer summary — unconditional (task row exists
962
+ // even when the target is offline/queued), metadata only.
963
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: target.state === "online" ? "delivered" : "queued", priority });
959
964
 
960
965
  if (target.state === "offline") {
961
966
  return {
@@ -1183,6 +1188,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1183
1188
 
1184
1189
  const session = scopedSessionStatus(alias, effectiveNetId);
1185
1190
  pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1191
+ // #461 network observer summary — ids + routing only, no reply text.
1192
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: alias, status: replyStatus });
1186
1193
 
1187
1194
  return {
1188
1195
  content: [{
@@ -1265,6 +1272,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1265
1272
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
1266
1273
  // SSE push (unconditional — channel is keyed by alias, not network)
1267
1274
  pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
1275
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
1268
1276
  return {
1269
1277
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
1270
1278
  };
@@ -1417,6 +1425,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1417
1425
  });
1418
1426
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1419
1427
  pushEvent(reassignedAlias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
1428
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
1420
1429
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: reassignedAlias, ...(canonical.renamed ? { renamed_from: new_alias, renamed_to: reassignedAlias } : {}) }) }] };
1421
1430
  }
1422
1431
  );
@@ -1565,6 +1574,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1565
1574
  patch: z.object({
1566
1575
  model: z.string().max(200).optional(),
1567
1576
  flags: z.record(z.unknown()).optional(),
1577
+ // #260 P5 — channel enable/disable. Restart-tier field (agent-node
1578
+ // reads config.channels once at boot to fork per-channel workers,
1579
+ // so the swap takes effect via process restart). Not
1580
+ // SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
1581
+ // (network scope) only.
1582
+ //
1583
+ // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
1584
+ // `z.record(z.unknown())`): trust nothing at the wire boundary,
1585
+ // narrow the same way `narrowChannelsPatch` narrows raw untrusted
1586
+ // JSON (typeof + allowlist + dedup + case-fold). A strict
1587
+ // `z.array(z.string())` would fail-fast on a single non-string
1588
+ // entry, but the wire contract wants junk silently dropped so a
1589
+ // dashboard fat-finger doesn't turn into a 400 the user has to
1590
+ // interpret. validatePatch then re-rejects if the caller bypassed
1591
+ // narrowing.
1592
+ channels: z.array(z.unknown()).max(16).optional(),
1568
1593
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1569
1594
  network_id: z.string().max(200).optional(),
1570
1595
  },
@@ -1582,6 +1607,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1582
1607
 
1583
1608
  const model = typeof patch.model === "string" ? patch.model : undefined;
1584
1609
  const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
1610
+ // Narrow untrusted `channels` at the boundary (typeof + allowlist +
1611
+ // dedup + case-fold), and distinguish two very different cases
1612
+ // that both narrow to `[]`:
1613
+ // (a) `patch.channels === []` (explicit "disable all editable
1614
+ // channels"). Downstream must proceed and write channels=[]
1615
+ // so the node's next restart forks no workers.
1616
+ // (b) `patch.channels === ["commhub"]` or similar — the caller
1617
+ // sent items but every single one was invalid (dashboard PR
1618
+ // #31 still ships commhub, and a typo like "telegarm" would
1619
+ // hit the same path). Downstream MUST NOT treat this as
1620
+ // (a) — silently converting to disable-all would nuke the
1621
+ // user's existing telegram/feishu workers.
1622
+ //
1623
+ // The reject error is `channels_all_invalid` so the dashboard
1624
+ // can surface the mismatch instead of showing a false success.
1625
+ let channels: string[] | undefined = undefined;
1626
+ if (patch.channels !== undefined) {
1627
+ if (!Array.isArray(patch.channels)) {
1628
+ // Zod already permits arrays only; belt+braces if it drifts.
1629
+ return {
1630
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
1631
+ };
1632
+ }
1633
+ const narrowed = narrowChannelsPatch(patch.channels) ?? [];
1634
+ if (patch.channels.length === 0) {
1635
+ channels = []; // (a) explicit disable-all
1636
+ } else if (narrowed.length === 0) {
1637
+ // (b) every entry was invalid — refuse to write. Distinct
1638
+ // error so the dashboard can surface the failure.
1639
+ return {
1640
+ content: [{
1641
+ type: "text" as const,
1642
+ text: JSON.stringify({
1643
+ ok: false,
1644
+ error: "channels_all_invalid",
1645
+ requested: patch.channels,
1646
+ message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
1647
+ }),
1648
+ }],
1649
+ };
1650
+ } else {
1651
+ channels = narrowed;
1652
+ }
1653
+ }
1585
1654
 
1586
1655
  // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1587
1656
  // (permissionMode / dangerouslySkipPermissions / teammateMode)
@@ -1608,7 +1677,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1608
1677
  };
1609
1678
  }
1610
1679
 
1611
- const validationFail = validatePatch(model, flags);
1680
+ const validationFail = validatePatch(model, flags, channels);
1612
1681
  if (validationFail) {
1613
1682
  return {
1614
1683
  content: [{
@@ -1686,8 +1755,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1686
1755
 
1687
1756
  // Compute apply_mode + persist + push doorbell.
1688
1757
  const updateId = `cu_${uuidv4()}`;
1689
- const applyMode = computeApplyMode(model, flags);
1690
- const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
1758
+ const applyMode = computeApplyMode(model, flags, channels);
1759
+ const patchJson = JSON.stringify({
1760
+ ...(model !== undefined ? { model } : {}),
1761
+ flags,
1762
+ ...(channels !== undefined ? { channels } : {}),
1763
+ });
1691
1764
  const networkId = node.network_id || "default";
1692
1765
  db.run(
1693
1766
  `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)`,
@@ -3554,6 +3627,15 @@ export function finalizePendingMatchingUpdates(
3554
3627
 
3555
3628
  const snapModel: string | null | undefined = snapshot?.model;
3556
3629
  const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
3630
+ // #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
3631
+ // the node currently has forked. agent-node's buildConfigSnapshot
3632
+ // always emits it (even as []), so the "absent field" case here means
3633
+ // an older pre-#260-P5 agent-node — for those, don't finalize a
3634
+ // channels-carrying patch (would false-positive on the OTHER fields).
3635
+ const snapChannelsPresent = Array.isArray(snapshot?.channels);
3636
+ const snapChannels: string[] = snapChannelsPresent
3637
+ ? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
3638
+ : [];
3557
3639
 
3558
3640
  const finalizedIds: string[] = [];
3559
3641
 
@@ -3578,6 +3660,35 @@ export function finalizePendingMatchingUpdates(
3578
3660
  if (snapFlags[k] !== v) { matches = false; break; }
3579
3661
  }
3580
3662
  }
3663
+ // #260 P5 — channels field content-match. Without this the
3664
+ // node's very first startup report_status matches (patch.flags
3665
+ // is `{}` for a channels-only update, so the flags loop is
3666
+ // trivially satisfied) and hub prematurely marks the update
3667
+ // applied, deleting the pending row + bumping config_revision
3668
+ // BEFORE the doorbell reaches the child. Codex catch on PR #411.
3669
+ //
3670
+ // Set-equality is enough because the patch side comes from
3671
+ // narrowChannelsPatch → already deduped + case-folded + in
3672
+ // EDITABLE_CHANNELS iteration order; the snapshot side comes
3673
+ // from buildConfigSnapshot which mirrors the same shape.
3674
+ if (matches && Array.isArray(patch.channels)) {
3675
+ if (!snapChannelsPresent) {
3676
+ // Pre-#260-P5 agent-node — no channels field in snapshot,
3677
+ // so we can't prove the apply landed. Skip finalize; hub's
3678
+ // F-B reaper still supersedes this eventually.
3679
+ matches = false;
3680
+ } else {
3681
+ const wanted = new Set<string>(patch.channels);
3682
+ const have = new Set<string>(snapChannels);
3683
+ if (wanted.size !== have.size) {
3684
+ matches = false;
3685
+ } else {
3686
+ for (const w of wanted) {
3687
+ if (!have.has(w)) { matches = false; break; }
3688
+ }
3689
+ }
3690
+ }
3691
+ }
3581
3692
  }
3582
3693
 
3583
3694
  if (!matches) continue;
@@ -14,8 +14,11 @@ import { register, login } from "./auth.js";
14
14
 
15
15
  const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
16
16
  const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
17
- const PORT = 19000 + Math.floor(Math.random() * 1000);
18
- const BASE = `http://127.0.0.1:${PORT}`;
17
+ // #438 corrective: private bootServer({ port: 0 }) instance — the OS
18
+ // assigns the port and BASE is derived from the ACTUAL bound port, so
19
+ // this suite is immune to both the parent-side random-port TOCTOU and
20
+ // the aggregate module-cache no-op (import no longer boots anything).
21
+ let BASE = "";
19
22
 
20
23
  let server: any;
21
24
  let userToken = "";
@@ -27,7 +30,6 @@ beforeAll(async () => {
27
30
  // we want to validate the production code path).
28
31
  process.env.COMMHUB_DB = SERVER_DB;
29
32
  process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
30
- process.env.PORT = String(PORT);
31
33
  process.env.HOST = "127.0.0.1";
32
34
 
33
35
  // Use a unique username + strong password each run so we don't trip
@@ -50,16 +52,17 @@ beforeAll(async () => {
50
52
  }
51
53
  expect(userToken).toBeTruthy();
52
54
 
53
- // Import the server module — its top-level `Bun.serve` call binds
54
- // the port immediately. The import side effect IS the server start.
55
- await import("./index.js");
55
+ // Importing is side-effect-free now; boot a private instance and
56
+ // read the real port back.
57
+ const { bootServer } = await import("./server.js");
58
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
59
+ BASE = `http://127.0.0.1:${server.port}`;
56
60
  // Tiny settle so the listener is ready.
57
61
  await new Promise((r) => setTimeout(r, 100));
58
62
  });
59
63
 
60
64
  afterAll(() => {
61
- // Best-effort cleanup. The Bun.serve handle is created at import
62
- // time so we can't easily stop it — process exit handles that.
65
+ try { server?.stop?.(true); } catch {}
63
66
  try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
64
67
  try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
65
68
  });