@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 +1 -1
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/index.ts +5 -0
- package/src/tools.ts +107 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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).
|
|
103
|
-
* restart-required field present
|
|
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({
|
|
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;
|