@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/README.md +1 -1
- package/bin/commhub.ts +6 -2
- package/package.json +1 -1
- package/src/api-host-supervisors-fallback.test.ts +15 -5
- package/src/avatar-validate.test.ts +113 -0
- package/src/avatar-validate.ts +69 -0
- package/src/boot-side-effect.test.ts +124 -0
- 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/db.ts +10 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2380
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/push.ts +87 -7
- package/src/response-charset.test.ts +130 -0
- package/src/server.ts +2642 -0
- package/src/tools.ts +115 -4
- package/src/uploads-http.test.ts +11 -8
|
@@ -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/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
|
+
});
|