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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.20",
3
+ "version": "0.9.0-preview.21",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -721,3 +721,110 @@ describe("SEC-1 — update lifecycle: single-flight + cross-network pending look
721
721
  expect(inFlight).toBeDefined();
722
722
  });
723
723
  });
724
+
725
+ // ── #260 P5 codex-catch regression: channels finalize ─────────────
726
+ describe("finalizePendingMatchingUpdates — #260 P5 channels content-match", () => {
727
+ function insertPendingUpdate(opts: {
728
+ update_id?: string;
729
+ node_id: string;
730
+ network_id: string;
731
+ patch: any;
732
+ apply_mode: "hot" | "restart" | "restart_only";
733
+ status?: "pending" | "restarting";
734
+ }): string {
735
+ const id = opts.update_id ?? `cu_${uuidv4()}`;
736
+ db.run(
737
+ `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, 0, ?6, ?7, 'test')`,
738
+ [id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()],
739
+ );
740
+ return id;
741
+ }
742
+
743
+ test("channels-only patch: snapshot BEFORE apply (first boot) must NOT finalize", () => {
744
+ const nid = insertNode({ alias: "n-chan-1", network_id: "netA" });
745
+ const uid = insertPendingUpdate({
746
+ node_id: nid, network_id: "netA",
747
+ // Note the empty flags object — the pre-fix bug was that this
748
+ // trivially matched the flags loop and hub falsely finalized.
749
+ patch: { flags: {}, channels: ["feishu"] },
750
+ apply_mode: "restart",
751
+ });
752
+ // Node's startup snapshot BEFORE it consumed the update — no
753
+ // channels forked yet, so buildConfigSnapshot emits channels=[].
754
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
755
+ expect(r.finalizedCount).toBe(0);
756
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
757
+ });
758
+
759
+ test("channels-only patch: snapshot AFTER apply (restart complete) finalizes", () => {
760
+ const nid = insertNode({ alias: "n-chan-2", network_id: "netA" });
761
+ const uid = insertPendingUpdate({
762
+ node_id: nid, network_id: "netA",
763
+ patch: { flags: {}, channels: ["feishu"] },
764
+ apply_mode: "restart",
765
+ });
766
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu"] });
767
+ expect(r.finalizedCount).toBe(1);
768
+ expect(r.finalizedIds).toContain(uid);
769
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
770
+ });
771
+
772
+ test("channels set-equality: order doesn't matter", () => {
773
+ const nid = insertNode({ alias: "n-chan-3", network_id: "netA" });
774
+ insertPendingUpdate({
775
+ node_id: nid, network_id: "netA",
776
+ patch: { flags: {}, channels: ["telegram", "feishu"] },
777
+ apply_mode: "restart",
778
+ });
779
+ // Snapshot order swapped — still matches.
780
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu", "telegram"] });
781
+ expect(r.finalizedCount).toBe(1);
782
+ });
783
+
784
+ test("channels: [] disable-all — finalize when snapshot also [] (post-restart)", () => {
785
+ const nid = insertNode({ alias: "n-chan-4", network_id: "netA" });
786
+ insertPendingUpdate({
787
+ node_id: nid, network_id: "netA",
788
+ patch: { flags: {}, channels: [] },
789
+ apply_mode: "restart",
790
+ });
791
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
792
+ expect(r.finalizedCount).toBe(1);
793
+ });
794
+
795
+ test("channels: [] disable-all — do NOT finalize when snapshot still has channels", () => {
796
+ const nid = insertNode({ alias: "n-chan-5", network_id: "netA" });
797
+ insertPendingUpdate({
798
+ node_id: nid, network_id: "netA",
799
+ patch: { flags: {}, channels: [] },
800
+ apply_mode: "restart",
801
+ });
802
+ // Node hasn't restarted yet — old telegram worker still forked.
803
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["telegram"] });
804
+ expect(r.finalizedCount).toBe(0);
805
+ });
806
+
807
+ test("snapshot omits channels field entirely (pre-P5 agent-node) → NOT finalized on channels-carrying patch", () => {
808
+ const nid = insertNode({ alias: "n-chan-6", network_id: "netA" });
809
+ insertPendingUpdate({
810
+ node_id: nid, network_id: "netA",
811
+ patch: { flags: {}, channels: ["feishu"] },
812
+ apply_mode: "restart",
813
+ });
814
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {} }); // no channels key
815
+ expect(r.finalizedCount).toBe(0);
816
+ });
817
+
818
+ test("model + channels compound match: both must satisfy", () => {
819
+ const nid = insertNode({ alias: "n-chan-7", network_id: "netA" });
820
+ insertPendingUpdate({
821
+ node_id: nid, network_id: "netA",
822
+ patch: { model: "opus-new", flags: {}, channels: ["telegram"] },
823
+ apply_mode: "restart",
824
+ });
825
+ // Wrong channels → not finalized even though model matches.
826
+ expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: [] }).finalizedCount).toBe(0);
827
+ // Both match → finalized.
828
+ expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: ["telegram"] }).finalizedCount).toBe(1);
829
+ });
830
+ });
@@ -9,9 +9,11 @@ import { describe, expect, test } from "bun:test";
9
9
  import {
10
10
  ALLOWED_FLAGS,
11
11
  SECURITY_SENSITIVE_FLAGS,
12
+ EDITABLE_CHANNELS,
12
13
  isAllowedToChangeFlag,
13
14
  computeApplyMode,
14
15
  validatePatch,
16
+ narrowChannelsPatch,
15
17
  } from "./config-apply-validate";
16
18
 
17
19
  describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
@@ -258,3 +260,93 @@ describe("validatePatch — combinations", () => {
258
260
  expect(r?.field).toBe("flags.maxTurns");
259
261
  });
260
262
  });
263
+
264
+ // ── #260 P5 — channels support (restart-tier, non-security-sensitive) ─
265
+ describe("EDITABLE_CHANNELS — mirrors agent-node runtime capability (cli.ts:671-676)", () => {
266
+ test("contains exactly telegram + feishu (channels agent-node actually forks workers for)", () => {
267
+ expect(EDITABLE_CHANNELS.size).toBe(2);
268
+ expect(EDITABLE_CHANNELS.has("telegram")).toBe(true);
269
+ expect(EDITABLE_CHANNELS.has("feishu")).toBe(true);
270
+ });
271
+ test("commhub is NOT editable — it's the RPC transport, not a per-node channel worker", () => {
272
+ expect(EDITABLE_CHANNELS.has("commhub")).toBe(false);
273
+ });
274
+ test("wechat is NOT editable — roadmap only on dashboard", () => {
275
+ expect(EDITABLE_CHANNELS.has("wechat")).toBe(false);
276
+ });
277
+ });
278
+
279
+ describe("narrowChannelsPatch — hostile input hygiene at the wire boundary", () => {
280
+ test("straight allow-listed input passes verbatim", () => {
281
+ expect(narrowChannelsPatch(["telegram", "feishu"])).toEqual(["telegram", "feishu"]);
282
+ });
283
+ test("empty array preserved — 'disable all editable channels' is a real state", () => {
284
+ expect(narrowChannelsPatch([])).toEqual([]);
285
+ });
286
+ test("non-array returns null (patch didn't touch channels)", () => {
287
+ expect(narrowChannelsPatch(undefined)).toBeNull();
288
+ expect(narrowChannelsPatch("telegram" as any)).toBeNull();
289
+ expect(narrowChannelsPatch({} as any)).toBeNull();
290
+ });
291
+ test("case-folded to lower", () => {
292
+ expect(narrowChannelsPatch(["FEISHU", "Telegram"])).toEqual(["feishu", "telegram"]);
293
+ });
294
+ test("duplicates deduped (Set)", () => {
295
+ expect(narrowChannelsPatch(["telegram", "telegram", "feishu", "TELEGRAM"])).toEqual(["telegram", "feishu"]);
296
+ });
297
+ test("unknown channel keys dropped silently (roadmap wechat, transport commhub, evil keys)", () => {
298
+ expect(narrowChannelsPatch(["wechat", "telegram", "evil-hacker", "commhub"])).toEqual(["telegram"]);
299
+ });
300
+ test("non-string entries dropped (numbers, objects, nulls)", () => {
301
+ expect(narrowChannelsPatch(["telegram", 42, {}, null, "feishu"] as any)).toEqual(["telegram", "feishu"]);
302
+ });
303
+ test("SQL-injection-shaped value dropped (not exact key match)", () => {
304
+ expect(narrowChannelsPatch(["telegram; drop table users;", "feishu"])).toEqual(["feishu"]);
305
+ });
306
+ test("whitespace around a key still trims to the key", () => {
307
+ expect(narrowChannelsPatch([" feishu "])).toEqual(["feishu"]);
308
+ });
309
+ });
310
+
311
+ describe("computeApplyMode — channels tier (restart)", () => {
312
+ test("channels-only patch → restart (boot forks per-channel workers)", () => {
313
+ expect(computeApplyMode(undefined, {}, ["feishu"])).toBe("restart");
314
+ });
315
+ test("empty-array channels → restart (still a state change)", () => {
316
+ expect(computeApplyMode(undefined, {}, [])).toBe("restart");
317
+ });
318
+ test("no channels key + no flags + no model → restart_only", () => {
319
+ expect(computeApplyMode(undefined, {}, undefined)).toBe("restart_only");
320
+ });
321
+ test("channels + hot-tier flag together stays restart (upgrade rules)", () => {
322
+ expect(computeApplyMode(undefined, { maxTurns: 5 }, ["telegram"])).toBe("restart");
323
+ });
324
+ test("channels + model → restart (model was already restart)", () => {
325
+ expect(computeApplyMode("gpt-5", {}, ["feishu"])).toBe("restart");
326
+ });
327
+ });
328
+
329
+ describe("validatePatch — channels defensive gate", () => {
330
+ test("valid channels array passes", () => {
331
+ expect(validatePatch(undefined, {}, ["telegram", "feishu"])).toBeNull();
332
+ expect(validatePatch(undefined, {}, [])).toBeNull();
333
+ expect(validatePatch(undefined, {}, ["telegram"])).toBeNull();
334
+ });
335
+ test("commhub rejected — RPC transport, not a per-node channel", () => {
336
+ const r = validatePatch(undefined, {}, ["commhub"]);
337
+ expect(r?.field).toBe("channels.commhub");
338
+ });
339
+ test("out-of-allowlist channel rejected (caller bypassed narrowing)", () => {
340
+ const r = validatePatch(undefined, {}, ["wechat"]);
341
+ expect(r?.field).toBe("channels.wechat");
342
+ expect(r?.reason).toMatch(/allowlist/);
343
+ });
344
+ test("non-string entry rejected", () => {
345
+ const r = validatePatch(undefined, {}, [42] as any);
346
+ expect(r?.field).toBe("channels");
347
+ });
348
+ test("non-array rejected", () => {
349
+ const r = validatePatch(undefined, {}, "telegram" as any);
350
+ expect(r?.field).toBe("channels");
351
+ });
352
+ });
@@ -57,6 +57,35 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
57
57
  "timeout",
58
58
  ]);
59
59
 
60
+ /**
61
+ * Channels the dashboard may enable/disable on a node via
62
+ * update_node_config. Intentionally narrow — the panel only exposes
63
+ * enable/disable, per-channel secrets stay in the node's local
64
+ * config.json (see #260 wire discipline).
65
+ *
66
+ * The list mirrors agent-node's actual runtime capability. See
67
+ * agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
68
+ * worker inits (initTelegramChannel + initFeishuChannel), and any
69
+ * other channel type triggers `process.exit(1)` at boot. So the
70
+ * hub-side allow-list here is telegram + feishu ONLY. `commhub` is
71
+ * the RPC transport that every node speaks unconditionally; treating
72
+ * it as a per-node channel would be a UX lie (toggle looks off but
73
+ * commhub is always on).
74
+ *
75
+ * Dashboard PR #31 currently includes `commhub` in its own
76
+ * EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
77
+ * the next dashboard rally, and until then hub silently drops any
78
+ * `commhub` entry via narrowChannelsPatch.
79
+ *
80
+ * Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
81
+ * channel on/off is a lifecycle-tier operation like `restart_node`, not
82
+ * a privilege elevation. Cross-tenant reach is already gated by SEC-1.
83
+ */
84
+ export const EDITABLE_CHANNELS = new Set<string>([
85
+ "telegram",
86
+ "feishu",
87
+ ]);
88
+
60
89
  /**
61
90
  * Role-gate for the patch's flag set. SEC-2 enforcement lives here.
62
91
  *
@@ -99,22 +128,53 @@ export function isAllowedToChangeFlag(
99
128
 
100
129
  /**
101
130
  * Classify the patch's required apply mode. Empty patch (model + flags
102
- * both empty) → "restart_only" (used by restart_node tool). Any
103
- * restart-required field present → "restart". Otherwise → "hot".
131
+ * both empty, no channels) → "restart_only" (used by restart_node tool).
132
+ * Any restart-required field present, OR a channels change → "restart"
133
+ * (channels is restart-tier by design: the node reads its channel set
134
+ * once at boot in agent-node/src/cli.ts and forks per-channel workers
135
+ * from it, so a channel enable/disable takes effect on process restart
136
+ * only — see #260 P5 wire discipline). Otherwise → "hot".
104
137
  */
105
138
  export function computeApplyMode(
106
139
  model: string | undefined,
107
140
  flags: Record<string, unknown>,
141
+ channels?: string[] | undefined,
108
142
  ): "hot" | "restart" | "restart_only" {
109
- const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
143
+ const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
110
144
  if (fieldCount === 0) return "restart_only";
111
145
  if (model !== undefined) return "restart";
146
+ if (channels !== undefined) return "restart";
112
147
  for (const key of Object.keys(flags)) {
113
148
  if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
114
149
  }
115
150
  return "hot";
116
151
  }
117
152
 
153
+ /**
154
+ * Narrow + allow-list `channels` from untrusted patch JSON. Returns the
155
+ * final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
156
+ * order-preserving), OR null if the input isn't an array. Non-strings,
157
+ * unknown keys, empty strings, and duplicates are silently dropped —
158
+ * same fail-shape as the dashboard route's whitelist so the two sides
159
+ * behave identically end-to-end. Callers pass `undefined` through when
160
+ * the patch didn't include a channels key at all (a distinct case from
161
+ * `channels: []` which means "disable all editable channels").
162
+ */
163
+ export function narrowChannelsPatch(raw: unknown): string[] | null {
164
+ if (!Array.isArray(raw)) return null;
165
+ const allow = EDITABLE_CHANNELS;
166
+ const seen = new Set<string>();
167
+ const out: string[] = [];
168
+ for (const v of raw) {
169
+ if (typeof v !== "string") continue;
170
+ const key = v.trim().toLowerCase();
171
+ if (!allow.has(key) || seen.has(key)) continue;
172
+ seen.add(key);
173
+ out.push(key);
174
+ }
175
+ return out;
176
+ }
177
+
118
178
  /**
119
179
  * Validate the patch shape — allowlist + per-field range / enum. Returns
120
180
  * null on pass, or `{ field, reason }` on reject. Caller wraps in error
@@ -123,12 +183,30 @@ export function computeApplyMode(
123
183
  export function validatePatch(
124
184
  model: string | undefined,
125
185
  flags: Record<string, unknown>,
186
+ channels?: string[] | undefined,
126
187
  ): { field: string; reason: string } | null {
127
188
  if (model !== undefined) {
128
189
  if (typeof model !== "string" || model.length === 0 || model.length > 200) {
129
190
  return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
130
191
  }
131
192
  }
193
+ if (channels !== undefined) {
194
+ // Zod already enforced string[], and narrowChannelsPatch dropped
195
+ // unknown keys before this point — but re-verify defensively so a
196
+ // future caller that skips narrowChannelsPatch can't smuggle
197
+ // unknown keys through validatePatch alone.
198
+ if (!Array.isArray(channels)) {
199
+ return { field: "channels", reason: "must be an array of strings" };
200
+ }
201
+ for (const c of channels) {
202
+ if (typeof c !== "string") {
203
+ return { field: "channels", reason: "must contain only strings" };
204
+ }
205
+ if (!EDITABLE_CHANNELS.has(c)) {
206
+ return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
207
+ }
208
+ }
209
+ }
132
210
  for (const [key, val] of Object.entries(flags)) {
133
211
  if (!ALLOWED_FLAGS.has(key)) {
134
212
  return { field: `flags.${key}`, reason: "not in allowlist" };
package/src/index.ts CHANGED
@@ -172,6 +172,11 @@ function normalizeRuntime(agent: unknown): string | null {
172
172
  if (agent === "claude-code") return "claude-code-cli";
173
173
  if (agent.startsWith("agent-node:codex")) return "codex-sdk";
174
174
  if (agent.startsWith("agent-node:claude")) return "claude-agent-sdk";
175
+ if (agent.startsWith("agent-node:grok")) return "grok-build-acp";
176
+ // RFC-029 — agent-node reports `agent-node:opencode` when RUNTIME
177
+ // bucket resolves to "opencode". Dashboard needs the canonical
178
+ // launcher name so its Runtime badge matches wizard choices.
179
+ if (agent.startsWith("agent-node:opencode")) return "opencode-cli";
175
180
  if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
176
181
  return null;
177
182
  }
package/src/tools.ts CHANGED
@@ -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";
@@ -1565,6 +1567,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1565
1567
  patch: z.object({
1566
1568
  model: z.string().max(200).optional(),
1567
1569
  flags: z.record(z.unknown()).optional(),
1570
+ // #260 P5 — channel enable/disable. Restart-tier field (agent-node
1571
+ // reads config.channels once at boot to fork per-channel workers,
1572
+ // so the swap takes effect via process restart). Not
1573
+ // SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
1574
+ // (network scope) only.
1575
+ //
1576
+ // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
1577
+ // `z.record(z.unknown())`): trust nothing at the wire boundary,
1578
+ // narrow the same way `narrowChannelsPatch` narrows raw untrusted
1579
+ // JSON (typeof + allowlist + dedup + case-fold). A strict
1580
+ // `z.array(z.string())` would fail-fast on a single non-string
1581
+ // entry, but the wire contract wants junk silently dropped so a
1582
+ // dashboard fat-finger doesn't turn into a 400 the user has to
1583
+ // interpret. validatePatch then re-rejects if the caller bypassed
1584
+ // narrowing.
1585
+ channels: z.array(z.unknown()).max(16).optional(),
1568
1586
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1569
1587
  network_id: z.string().max(200).optional(),
1570
1588
  },
@@ -1582,6 +1600,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1582
1600
 
1583
1601
  const model = typeof patch.model === "string" ? patch.model : undefined;
1584
1602
  const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
1603
+ // Narrow untrusted `channels` at the boundary (typeof + allowlist +
1604
+ // dedup + case-fold), and distinguish two very different cases
1605
+ // that both narrow to `[]`:
1606
+ // (a) `patch.channels === []` (explicit "disable all editable
1607
+ // channels"). Downstream must proceed and write channels=[]
1608
+ // so the node's next restart forks no workers.
1609
+ // (b) `patch.channels === ["commhub"]` or similar — the caller
1610
+ // sent items but every single one was invalid (dashboard PR
1611
+ // #31 still ships commhub, and a typo like "telegarm" would
1612
+ // hit the same path). Downstream MUST NOT treat this as
1613
+ // (a) — silently converting to disable-all would nuke the
1614
+ // user's existing telegram/feishu workers.
1615
+ //
1616
+ // The reject error is `channels_all_invalid` so the dashboard
1617
+ // can surface the mismatch instead of showing a false success.
1618
+ let channels: string[] | undefined = undefined;
1619
+ if (patch.channels !== undefined) {
1620
+ if (!Array.isArray(patch.channels)) {
1621
+ // Zod already permits arrays only; belt+braces if it drifts.
1622
+ return {
1623
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
1624
+ };
1625
+ }
1626
+ const narrowed = narrowChannelsPatch(patch.channels) ?? [];
1627
+ if (patch.channels.length === 0) {
1628
+ channels = []; // (a) explicit disable-all
1629
+ } else if (narrowed.length === 0) {
1630
+ // (b) every entry was invalid — refuse to write. Distinct
1631
+ // error so the dashboard can surface the failure.
1632
+ return {
1633
+ content: [{
1634
+ type: "text" as const,
1635
+ text: JSON.stringify({
1636
+ ok: false,
1637
+ error: "channels_all_invalid",
1638
+ requested: patch.channels,
1639
+ message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
1640
+ }),
1641
+ }],
1642
+ };
1643
+ } else {
1644
+ channels = narrowed;
1645
+ }
1646
+ }
1585
1647
 
1586
1648
  // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1587
1649
  // (permissionMode / dangerouslySkipPermissions / teammateMode)
@@ -1608,7 +1670,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1608
1670
  };
1609
1671
  }
1610
1672
 
1611
- const validationFail = validatePatch(model, flags);
1673
+ const validationFail = validatePatch(model, flags, channels);
1612
1674
  if (validationFail) {
1613
1675
  return {
1614
1676
  content: [{
@@ -1686,8 +1748,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1686
1748
 
1687
1749
  // Compute apply_mode + persist + push doorbell.
1688
1750
  const updateId = `cu_${uuidv4()}`;
1689
- const applyMode = computeApplyMode(model, flags);
1690
- const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
1751
+ const applyMode = computeApplyMode(model, flags, channels);
1752
+ const patchJson = JSON.stringify({
1753
+ ...(model !== undefined ? { model } : {}),
1754
+ flags,
1755
+ ...(channels !== undefined ? { channels } : {}),
1756
+ });
1691
1757
  const networkId = node.network_id || "default";
1692
1758
  db.run(
1693
1759
  `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 +3620,15 @@ export function finalizePendingMatchingUpdates(
3554
3620
 
3555
3621
  const snapModel: string | null | undefined = snapshot?.model;
3556
3622
  const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
3623
+ // #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
3624
+ // the node currently has forked. agent-node's buildConfigSnapshot
3625
+ // always emits it (even as []), so the "absent field" case here means
3626
+ // an older pre-#260-P5 agent-node — for those, don't finalize a
3627
+ // channels-carrying patch (would false-positive on the OTHER fields).
3628
+ const snapChannelsPresent = Array.isArray(snapshot?.channels);
3629
+ const snapChannels: string[] = snapChannelsPresent
3630
+ ? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
3631
+ : [];
3557
3632
 
3558
3633
  const finalizedIds: string[] = [];
3559
3634
 
@@ -3578,6 +3653,35 @@ export function finalizePendingMatchingUpdates(
3578
3653
  if (snapFlags[k] !== v) { matches = false; break; }
3579
3654
  }
3580
3655
  }
3656
+ // #260 P5 — channels field content-match. Without this the
3657
+ // node's very first startup report_status matches (patch.flags
3658
+ // is `{}` for a channels-only update, so the flags loop is
3659
+ // trivially satisfied) and hub prematurely marks the update
3660
+ // applied, deleting the pending row + bumping config_revision
3661
+ // BEFORE the doorbell reaches the child. Codex catch on PR #411.
3662
+ //
3663
+ // Set-equality is enough because the patch side comes from
3664
+ // narrowChannelsPatch → already deduped + case-folded + in
3665
+ // EDITABLE_CHANNELS iteration order; the snapshot side comes
3666
+ // from buildConfigSnapshot which mirrors the same shape.
3667
+ if (matches && Array.isArray(patch.channels)) {
3668
+ if (!snapChannelsPresent) {
3669
+ // Pre-#260-P5 agent-node — no channels field in snapshot,
3670
+ // so we can't prove the apply landed. Skip finalize; hub's
3671
+ // F-B reaper still supersedes this eventually.
3672
+ matches = false;
3673
+ } else {
3674
+ const wanted = new Set<string>(patch.channels);
3675
+ const have = new Set<string>(snapChannels);
3676
+ if (wanted.size !== have.size) {
3677
+ matches = false;
3678
+ } else {
3679
+ for (const w of wanted) {
3680
+ if (!have.has(w)) { matches = false; break; }
3681
+ }
3682
+ }
3683
+ }
3684
+ }
3581
3685
  }
3582
3686
 
3583
3687
  if (!matches) continue;