@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.
@@ -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/db.ts CHANGED
@@ -338,6 +338,16 @@ try {
338
338
  if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
339
339
  }
340
340
 
341
+ // #462 — user-set custom avatar (nullable http/https image URL). Pure
342
+ // display attribute: deliberately NOT part of the RFC-024 config-apply
343
+ // pipeline (nothing for agent-node to consume, no revision/ack). Written
344
+ // only via PUT /api/nodes/:ref/avatar which validates the URL shape.
345
+ try {
346
+ db.exec(`ALTER TABLE nodes ADD COLUMN avatar_url TEXT`);
347
+ } catch (e: any) {
348
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
349
+ }
350
+
341
351
  // RFC-027 §2 — stop/delete request envelope. Mirrors
342
352
  // node_create_requests structure so the daemon-side pull/ack pattern
343
353
  // is symmetric with create_node. action='stop'|'delete' picks which
@@ -0,0 +1,176 @@
1
+ // #473 — /health information-leak redaction.
2
+ //
3
+ // Public-hub audit (通信龙, 2026-07-30) showed anonymous GET /health
4
+ // returning the full SSE key breakdown: `{networkId}:{alias}` for every
5
+ // live connection — network id + all 95 agent aliases on the audited
6
+ // hub. The fix keeps /health anonymous (HARD CONSTRAINT: the central
7
+ // watchdog curls it every minute; auth here would blind it) but strips
8
+ // the per-key detail down to aggregate counts; the detail moved behind
9
+ // auth at GET /api/stats/sse (master token or admin only).
10
+ //
11
+ // Witnessed-red: on pre-fix main (8c11edf5) the "no leak" test below is
12
+ // red — the /health body contains the connected alias and network id.
13
+
14
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "fs";
16
+ import { join } from "path";
17
+ import { tmpdir } from "os";
18
+ import { register } from "./auth.js";
19
+ import { db } from "./db.js";
20
+
21
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-health-red-db-")) + "/commhub.db";
22
+
23
+ let BASE = "";
24
+ let server: any = null;
25
+ let sseReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
26
+
27
+ let userToken = "";
28
+ let userNetworkId = "";
29
+ let userName = "";
30
+ let adminToken = "";
31
+
32
+ beforeAll(async () => {
33
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
34
+ process.env.HOST = "127.0.0.1";
35
+
36
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
37
+ const pw = "BootstrapPw123Aa!";
38
+
39
+ // Burn a seed registration first so our member is NOT auto-admin (the
40
+ // "first registered user → admin" rule only bites when this suite owns
41
+ // the DB; per-file runs need this guard). We set roles explicitly below.
42
+ register(`health_seed_${suffix}`, pw, undefined, "seed");
43
+
44
+ userName = `health_user_${suffix}`;
45
+ const r = register(userName, pw, undefined, "seed");
46
+ if (!r.ok || !r.token) throw new Error("register failed: " + JSON.stringify(r));
47
+ userToken = r.token;
48
+ userNetworkId = r.network_id ?? "";
49
+ if (!userNetworkId) throw new Error("no network for user");
50
+ // Pin to plain member — defend against any residual auto-admin.
51
+ db.run("UPDATE users SET role = 'user' WHERE username = ?1", [userName]);
52
+
53
+ const adminName = `health_admin_${suffix}`;
54
+ const a = register(adminName, pw, undefined, "seed");
55
+ if (!a.ok || !a.token) throw new Error("admin register failed: " + JSON.stringify(a));
56
+ adminToken = a.token;
57
+ // Explicit promotion — "first registered user is auto-admin" does not
58
+ // hold in aggregate runs (see api-host-supervisors-fallback.test.ts).
59
+ db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [adminName]);
60
+
61
+ const { bootServer } = await import("./server.js");
62
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
63
+ BASE = `http://127.0.0.1:${server.port}`;
64
+
65
+ // Open a real SSE connection so the stats have a key that WOULD leak:
66
+ // the dashboard-user channel (sessionName === own username, Path 3
67
+ // gate 4a) registers `${networkId}:${username}` in the clients map.
68
+ const res = await fetch(`${BASE}/events/${encodeURIComponent(userName)}?network_id=${userNetworkId}`, {
69
+ headers: { Authorization: `Bearer ${userToken}` },
70
+ });
71
+ if (res.status !== 200) throw new Error("SSE subscribe failed: " + res.status);
72
+ sseReader = res.body!.getReader();
73
+ await sseReader.read(); // consume the connected frame → registration done
74
+ });
75
+
76
+ afterAll(() => {
77
+ try { sseReader?.cancel(); } catch {}
78
+ try { server?.stop?.(true); } catch {}
79
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
80
+ });
81
+
82
+ describe("#473 anonymous /health — watchdog contract intact, no topology leak", () => {
83
+ test("stays anonymous 200 with parseable aggregates (watchdog judge: curl -sf gets 2xx)", async () => {
84
+ const res = await fetch(`${BASE}/health`);
85
+ expect(res.status).toBe(200);
86
+ const body = await res.json() as any;
87
+ expect(body.ok).toBe(true);
88
+ expect(typeof body.version).toBe("string");
89
+ expect(typeof body.sessions_count).toBe("number");
90
+ expect(typeof body.sse_connections).toBe("number");
91
+ expect(body.sse_connections).toBeGreaterThanOrEqual(1);
92
+ });
93
+
94
+ test("CLI count source stays truthful: health.sse_connections == /api/stats/sse total (no fake 0)", async () => {
95
+ // The CLI's `anet doctor` line reads health.sse_connections now
96
+ // (was Object.keys(sse_sessions).length). This pins that aggregate to
97
+ // the same number the auth-gated detail endpoint reports — so a live
98
+ // hub with N connections shows N, never the "0 active" regression the
99
+ // review caught. There is exactly one live SSE connection in setup.
100
+ const health = await (await fetch(`${BASE}/health`)).json() as any;
101
+ const detail = await (await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } })).json() as any;
102
+ expect(health.sse_connections).toBe(detail.total);
103
+ expect(health.sse_connections).toBeGreaterThanOrEqual(1);
104
+ });
105
+
106
+ test("does NOT expose SSE key detail: no sse_sessions field, no alias, no network id", async () => {
107
+ const res = await fetch(`${BASE}/health`);
108
+ const text = await res.text();
109
+ const body = JSON.parse(text);
110
+ expect("sse_sessions" in body).toBe(false);
111
+ // The live connection's identifying strings must not appear anywhere
112
+ // in the anonymous body.
113
+ expect(text).not.toContain(userName);
114
+ expect(text).not.toContain(userNetworkId);
115
+ });
116
+ });
117
+
118
+ describe("#473 GET /api/stats/sse — detail survives, behind ops auth", () => {
119
+ test("anonymous → 401", async () => {
120
+ const res = await fetch(`${BASE}/api/stats/sse`);
121
+ expect(res.status).toBe(401);
122
+ });
123
+
124
+ test("regular member token → 403 (detail spans every network)", async () => {
125
+ const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${userToken}` } });
126
+ expect(res.status).toBe(403);
127
+ });
128
+
129
+ test("admin → 200 with the full key breakdown (ops capability preserved)", async () => {
130
+ const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } });
131
+ expect(res.status).toBe(200);
132
+ const body = await res.json() as any;
133
+ expect(body.ok).toBe(true);
134
+ expect(typeof body.total).toBe("number");
135
+ expect(body.sessions[`${userNetworkId}:${userName}`]).toBeGreaterThanOrEqual(1);
136
+ });
137
+
138
+ test("legacy master token → 200 (ops path — restAuth null, isLegacyAuthToken)", () => {
139
+ // COMMHUB_AUTH_TOKEN is captured at module import, so this path needs
140
+ // a fresh process with the env set before boot (aggregate order can't
141
+ // guarantee this file imports server.ts first). Subprocess, like the
142
+ // patrol test. Reviewer flagged this path had code but no test.
143
+ const script = `
144
+ process.env.COMMHUB_AUTH_TOKEN = "master-secret-xyz";
145
+ const { startHub } = await import("./src/server.js");
146
+ const hub = startHub({ port: 0, hostname: "127.0.0.1" });
147
+ const base = "http://127.0.0.1:" + hub.port;
148
+ const anon = await fetch(base + "/api/stats/sse");
149
+ const master = await fetch(base + "/api/stats/sse", { headers: { Authorization: "Bearer master-secret-xyz" } });
150
+ const health = await fetch(base + "/health");
151
+ const hb = await health.json();
152
+ console.log("RESULT:" + JSON.stringify({
153
+ anon: anon.status,
154
+ master: master.status,
155
+ masterOk: (await master.json()).ok,
156
+ healthHasSseSessions: "sse_sessions" in hb,
157
+ }));
158
+ hub.stop(true);
159
+ process.exit(0);
160
+ `;
161
+ const child = Bun.spawnSync({
162
+ cmd: ["bun", "-e", script],
163
+ cwd: `${import.meta.dir}/..`,
164
+ env: { ...process.env, COMMHUB_DB: process.env.COMMHUB_DB || "/tmp/anet-473-master.db" },
165
+ timeout: 15_000,
166
+ });
167
+ const out = new TextDecoder().decode(child.stdout);
168
+ const m = out.match(/RESULT:(\{.*\})/);
169
+ expect(m).not.toBeNull();
170
+ const r = JSON.parse(m![1]);
171
+ expect(r.anon).toBe(401); // token configured → anonymous rejected
172
+ expect(r.master).toBe(200); // legacy master token → ops access
173
+ expect(r.masterOk).toBe(true);
174
+ expect(r.healthHasSseSessions).toBe(false); // /health still redacted with a token set
175
+ }, 20_000);
176
+ });