@sleep2agi/commhub-server 0.9.0-preview.21 → 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/db.ts +10 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2385
- 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 +8 -1
- package/src/uploads-http.test.ts +11 -8
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
CommHub: MCP Streamable HTTP + SSE push + REST API for an AI agent network. Single-process Bun server, SQLite-backed, zero config when launched through `anet`.
|
|
9
9
|
|
|
10
|
-
The supported path is to install the `anet` CLI (`@sleep2agi/agent-network
|
|
10
|
+
The supported path is to install the `anet` CLI (`@sleep2agi/agent-network`) and run `anet hub start`, which wires up the port, default admin account, recovery admin `utok_`, and local config for you.
|
|
11
11
|
|
|
12
12
|
## Quick start (verified)
|
|
13
13
|
|
package/bin/commhub.ts
CHANGED
|
@@ -54,5 +54,9 @@ Examples:
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// Load the server
|
|
58
|
-
|
|
57
|
+
// Load the server module (side-effect-free since the #438 corrective:
|
|
58
|
+
// importing binds nothing) and start the hub EXPLICITLY. Never rely on
|
|
59
|
+
// import side effects or import.meta.main here — this bin is a dynamic
|
|
60
|
+
// importer, where import.meta.main is false inside the module.
|
|
61
|
+
const { startHub } = await import("../src/server.js");
|
|
62
|
+
startHub();
|
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.22",
|
|
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",
|
|
@@ -27,8 +27,10 @@ import { register, login, createNetwork } from "./auth.js";
|
|
|
27
27
|
import { db } from "./db.js";
|
|
28
28
|
|
|
29
29
|
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-hs-fallback-db-")) + "/commhub.db";
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// #438 corrective: private bootServer({ port: 0 }) instance (see
|
|
31
|
+
// uploads-http.test.ts for rationale).
|
|
32
|
+
let BASE = "";
|
|
33
|
+
let server: any = null;
|
|
32
34
|
|
|
33
35
|
// Three users:
|
|
34
36
|
// soloUser — single accessible network (fallback should work)
|
|
@@ -41,7 +43,6 @@ let soloDaemonAlias = "";
|
|
|
41
43
|
|
|
42
44
|
beforeAll(async () => {
|
|
43
45
|
process.env.COMMHUB_DB = SERVER_DB;
|
|
44
|
-
process.env.PORT = String(PORT);
|
|
45
46
|
process.env.HOST = "127.0.0.1";
|
|
46
47
|
|
|
47
48
|
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
@@ -53,6 +54,12 @@ beforeAll(async () => {
|
|
|
53
54
|
// case a false-negative for our fix contract.
|
|
54
55
|
const seed = register(`seed_admin_${suffix}`, password, undefined, "seed");
|
|
55
56
|
if (!seed.ok || !seed.token) throw new Error("seed admin failed: " + JSON.stringify(seed));
|
|
57
|
+
// "First registered user is auto-admin" only holds when this suite has
|
|
58
|
+
// the DB to itself. In an aggregate run another suite registers first,
|
|
59
|
+
// the seed lands as a plain user, and paths 7/8 silently test the
|
|
60
|
+
// wrong role. Promote explicitly — resolveToken reads users.role at
|
|
61
|
+
// request time, so promotion after token issuance is effective.
|
|
62
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`seed_admin_${suffix}`]);
|
|
56
63
|
// Save the admin token so path 7 can hit /api/host-supervisors as an
|
|
57
64
|
// admin directly (通信龙 review point 1: verify admin+no-query goes 400
|
|
58
65
|
// explicitly, not only implicitly via seed_admin's shadow presence).
|
|
@@ -108,8 +115,10 @@ beforeAll(async () => {
|
|
|
108
115
|
userIdForToken: login(`multi_${suffix}`, password).user!.user_id,
|
|
109
116
|
});
|
|
110
117
|
|
|
111
|
-
//
|
|
112
|
-
await import("./
|
|
118
|
+
// server.ts is side-effect-free to import; boot a private instance.
|
|
119
|
+
const { bootServer } = await import("./server.js");
|
|
120
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
121
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
113
122
|
await new Promise((r) => setTimeout(r, 100));
|
|
114
123
|
});
|
|
115
124
|
|
|
@@ -145,6 +154,7 @@ function seedHostSupervisorDaemon(opts: {
|
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
afterAll(() => {
|
|
157
|
+
try { server?.stop?.(true); } catch {}
|
|
148
158
|
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
149
159
|
});
|
|
150
160
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// #462 — unit tests for the avatar_url XSS boundary (avatar-validate.ts).
|
|
2
|
+
//
|
|
3
|
+
// The validator's whole job is to keep hostile values out of a dashboard
|
|
4
|
+
// <img src>; every rejection case here is a concrete injection shape.
|
|
5
|
+
// Live-gate check: replace validateAvatarUrl with `return { ok: true,
|
|
6
|
+
// value: String(raw) }` and the javascript:/data:/control-char tests go
|
|
7
|
+
// red.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { MAX_AVATAR_URL_LENGTH, validateAvatarUrl } from "./avatar-validate";
|
|
11
|
+
|
|
12
|
+
describe("#462 validateAvatarUrl", () => {
|
|
13
|
+
test("accepts plain https URL", () => {
|
|
14
|
+
const r = validateAvatarUrl("https://cdn.example.com/a/b.png");
|
|
15
|
+
expect(r).toEqual({ ok: true, value: "https://cdn.example.com/a/b.png" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("accepts http URL and trims surrounding whitespace", () => {
|
|
19
|
+
const r = validateAvatarUrl(" http://example.com/x.jpg ");
|
|
20
|
+
expect(r).toEqual({ ok: true, value: "http://example.com/x.jpg" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("accepts URL with query string", () => {
|
|
24
|
+
const r = validateAvatarUrl("https://example.com/img?size=64&v=2");
|
|
25
|
+
expect(r.ok).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("null / undefined / empty / whitespace-only → clear (value null)", () => {
|
|
29
|
+
expect(validateAvatarUrl(null)).toEqual({ ok: true, value: null });
|
|
30
|
+
expect(validateAvatarUrl(undefined)).toEqual({ ok: true, value: null });
|
|
31
|
+
expect(validateAvatarUrl("")).toEqual({ ok: true, value: null });
|
|
32
|
+
expect(validateAvatarUrl(" ")).toEqual({ ok: true, value: null });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects non-string types", () => {
|
|
36
|
+
expect(validateAvatarUrl(42).ok).toBe(false);
|
|
37
|
+
expect(validateAvatarUrl({ url: "https://x" }).ok).toBe(false);
|
|
38
|
+
expect(validateAvatarUrl(["https://x"]).ok).toBe(false);
|
|
39
|
+
expect(validateAvatarUrl(true).ok).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects javascript: pseudo-protocol", () => {
|
|
43
|
+
expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
|
|
44
|
+
expect(validateAvatarUrl("JavaScript:alert(1)").ok).toBe(false);
|
|
45
|
+
expect(validateAvatarUrl("JAVASCRIPT:alert(document.cookie)").ok).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects javascript: smuggled via embedded tab/newline (URL() would strip them)", () => {
|
|
49
|
+
expect(validateAvatarUrl("java\tscript:alert(1)").ok).toBe(false);
|
|
50
|
+
expect(validateAvatarUrl("java\nscript:alert(1)").ok).toBe(false);
|
|
51
|
+
expect(validateAvatarUrl("java\rscript:alert(1)").ok).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects data: / vbscript: / file: / blob: and other non-http protocols", () => {
|
|
55
|
+
expect(validateAvatarUrl("data:text/html,<script>alert(1)</script>").ok).toBe(false);
|
|
56
|
+
expect(validateAvatarUrl("data:image/png;base64,iVBORw0KGgo=").ok).toBe(false);
|
|
57
|
+
expect(validateAvatarUrl("vbscript:msgbox(1)").ok).toBe(false);
|
|
58
|
+
expect(validateAvatarUrl("file:///etc/passwd").ok).toBe(false);
|
|
59
|
+
expect(validateAvatarUrl("blob:https://example.com/uuid").ok).toBe(false);
|
|
60
|
+
expect(validateAvatarUrl("ftp://example.com/a.png").ok).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects relative / protocol-relative / non-URL strings", () => {
|
|
64
|
+
expect(validateAvatarUrl("/avatars/1.png").ok).toBe(false);
|
|
65
|
+
expect(validateAvatarUrl("//cdn.example.com/1.png").ok).toBe(false);
|
|
66
|
+
expect(validateAvatarUrl("not a url").ok).toBe(false);
|
|
67
|
+
expect(validateAvatarUrl("example.com/x.png").ok).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rejects embedded whitespace and control characters", () => {
|
|
71
|
+
expect(validateAvatarUrl("https://example.com/a b.png").ok).toBe(false);
|
|
72
|
+
expect(validateAvatarUrl("https://example.com/a\u0000.png").ok).toBe(false);
|
|
73
|
+
expect(validateAvatarUrl("https://example.com/a\u009f.png").ok).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("normalizes to parsed.href: markup-hostile chars come out percent-encoded (finding 3)", () => {
|
|
77
|
+
const r1 = validateAvatarUrl('https://example.com/a"b.png');
|
|
78
|
+
expect(r1).toEqual({ ok: true, value: "https://example.com/a%22b.png" });
|
|
79
|
+
const r2 = validateAvatarUrl("https://example.com/</script><script>.png");
|
|
80
|
+
expect(r2.ok).toBe(true);
|
|
81
|
+
if (r2.ok) {
|
|
82
|
+
expect(r2.value).not.toContain("<");
|
|
83
|
+
expect(r2.value).not.toContain(">");
|
|
84
|
+
}
|
|
85
|
+
const r3 = validateAvatarUrl("https://example.com/a`b.png");
|
|
86
|
+
expect(r3.ok).toBe(true);
|
|
87
|
+
if (r3.ok) expect(r3.value).not.toContain("`");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("rejects values whose href still carries attribute-breaking chars (single quote)", () => {
|
|
91
|
+
// WHATWG URL leaves ' un-encoded in the PATH component — href alone
|
|
92
|
+
// doesn't neutralize it there, so the validator must refuse.
|
|
93
|
+
expect(validateAvatarUrl("https://example.com/a'b.png").ok).toBe(false);
|
|
94
|
+
// In the QUERY component ' IS percent-encoded by href → accepted,
|
|
95
|
+
// but the stored value must carry no raw quote.
|
|
96
|
+
const q = validateAvatarUrl("https://example.com/x.png?a='1'");
|
|
97
|
+
expect(q.ok).toBe(true);
|
|
98
|
+
if (q.ok) expect(q.value).not.toContain("'");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rejects URLs with embedded credentials (userinfo)", () => {
|
|
102
|
+
expect(validateAvatarUrl("https://user:pass@example.com/a.png").ok).toBe(false);
|
|
103
|
+
expect(validateAvatarUrl("https://user@example.com/a.png").ok).toBe(false);
|
|
104
|
+
expect(validateAvatarUrl("http://:secret@example.com/a.png").ok).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("enforces the length cap", () => {
|
|
108
|
+
const base = "https://example.com/";
|
|
109
|
+
const ok = base + "a".repeat(MAX_AVATAR_URL_LENGTH - base.length);
|
|
110
|
+
expect(validateAvatarUrl(ok).ok).toBe(true);
|
|
111
|
+
expect(validateAvatarUrl(ok + "a").ok).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// #462 — validator for the user-set custom avatar URL (nodes.avatar_url).
|
|
2
|
+
//
|
|
3
|
+
// The value ends up in an <img src> on every dashboard client, so this is
|
|
4
|
+
// an XSS trust boundary: protocol MUST be http/https (rejects javascript:,
|
|
5
|
+
// data:, vbscript:, file:, blob:, …), no whitespace/control characters,
|
|
6
|
+
// bounded length. Pure function, unit-tested in avatar-validate.test.ts.
|
|
7
|
+
|
|
8
|
+
export const MAX_AVATAR_URL_LENGTH = 2048;
|
|
9
|
+
|
|
10
|
+
export type AvatarValidation =
|
|
11
|
+
| { ok: true; value: string | null }
|
|
12
|
+
| { ok: false; reason: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize + validate an untrusted avatar_url patch value.
|
|
16
|
+
*
|
|
17
|
+
* null / undefined / "" / whitespace-only → { ok, value: null } (clear)
|
|
18
|
+
* valid absolute http(s) URL → { ok, value: trimmed }
|
|
19
|
+
* anything else → { ok: false, reason }
|
|
20
|
+
*/
|
|
21
|
+
export function validateAvatarUrl(raw: unknown): AvatarValidation {
|
|
22
|
+
if (raw === null || raw === undefined) return { ok: true, value: null };
|
|
23
|
+
if (typeof raw !== "string") {
|
|
24
|
+
return { ok: false, reason: "avatar_url must be a string or null" };
|
|
25
|
+
}
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
if (trimmed.length === 0) return { ok: true, value: null };
|
|
28
|
+
if (trimmed.length > MAX_AVATAR_URL_LENGTH) {
|
|
29
|
+
return { ok: false, reason: `avatar_url must be ≤ ${MAX_AVATAR_URL_LENGTH} chars` };
|
|
30
|
+
}
|
|
31
|
+
// Reject embedded whitespace + C0/C1 control chars outright. URL()
|
|
32
|
+
// silently strips some of these (e.g. tabs/newlines), which would let
|
|
33
|
+
// "java\tscript:" style payloads normalize into a hostile protocol —
|
|
34
|
+
// so this check must run BEFORE parsing, on the raw trimmed string.
|
|
35
|
+
if (/[\u0000-\u001f\u007f-\u009f\s]/.test(trimmed)) {
|
|
36
|
+
return { ok: false, reason: "avatar_url must not contain whitespace or control characters" };
|
|
37
|
+
}
|
|
38
|
+
let parsed: URL;
|
|
39
|
+
try {
|
|
40
|
+
parsed = new URL(trimmed);
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false, reason: "avatar_url must be an absolute URL" };
|
|
43
|
+
}
|
|
44
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
45
|
+
return { ok: false, reason: "avatar_url protocol must be http or https" };
|
|
46
|
+
}
|
|
47
|
+
// Embedded credentials (https://user:pass@host/…) would be persisted,
|
|
48
|
+
// leaked to the avatar host on every render, and can spoof the visible
|
|
49
|
+
// origin in UIs. Reject outright rather than silently stripping — the
|
|
50
|
+
// caller should know their input carried secrets (通信龙 review round-2
|
|
51
|
+
// follow-up).
|
|
52
|
+
if (parsed.username || parsed.password) {
|
|
53
|
+
return { ok: false, reason: "avatar_url must not contain credentials (userinfo)" };
|
|
54
|
+
}
|
|
55
|
+
// Persist the NORMALIZED href, not the raw input: href percent-encodes
|
|
56
|
+
// markup-hostile characters (" < > ` → %22 %3C %3E %60), so what lands
|
|
57
|
+
// in the DB is already safe to interpolate into an <img src> attribute.
|
|
58
|
+
// The dashboard renderer lives in a different package this repo cannot
|
|
59
|
+
// audit — the server must not bet on it escaping (审查修复 per 通信龙
|
|
60
|
+
// #461/#462 review, finding 3).
|
|
61
|
+
const normalized = parsed.href;
|
|
62
|
+
// Belt-and-braces: reject anything href does NOT encode that could
|
|
63
|
+
// still break out of an attribute context (WHATWG URL leaves ' and \
|
|
64
|
+
// untouched in some positions).
|
|
65
|
+
if (/["'<>\\`]/.test(normalized)) {
|
|
66
|
+
return { ok: false, reason: "avatar_url contains characters not allowed in a URL" };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, value: normalized };
|
|
69
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// #438 corrective — regression gate for the two boot-path failure modes:
|
|
2
|
+
//
|
|
3
|
+
// 1. import side effect: `import("./server.js")` must NOT bind any port.
|
|
4
|
+
// Pre-corrective (7fa3ad35, when this module was still index.ts with
|
|
5
|
+
// module-level `export const server = bootServer()`) this fails:
|
|
6
|
+
// witnessed-red showed the probe port EADDRINUSE right after import.
|
|
7
|
+
// The run-entry shim src/index.ts is the ONLY module whose import
|
|
8
|
+
// boots — by design, documented there — and is deliberately not
|
|
9
|
+
// imported anywhere in the test suite.
|
|
10
|
+
// 2. silent no-op double start: `startHub()` is single-shot and must
|
|
11
|
+
// REJECT a second call observably — "I thought I started it but
|
|
12
|
+
// nothing happened" is the same failure family as the
|
|
13
|
+
// import.meta.main incident (#438 review).
|
|
14
|
+
//
|
|
15
|
+
// Test order inside this file matters: the no-side-effect probe must run
|
|
16
|
+
// BEFORE anything calls startHub in this process. bun executes tests in
|
|
17
|
+
// declaration order, and no other suite calls startHub.
|
|
18
|
+
|
|
19
|
+
import { describe, expect, test } from "bun:test";
|
|
20
|
+
|
|
21
|
+
const PROBE_PORT = 18500 + Math.floor(Math.random() * 500);
|
|
22
|
+
|
|
23
|
+
describe("#438 corrective — importing server.ts is side-effect-free", () => {
|
|
24
|
+
test("importing server.js binds nothing (probe port stays free)", async () => {
|
|
25
|
+
// Pin the module's default PORT to the probe so that IF an import
|
|
26
|
+
// side effect ever regresses, it binds exactly the port we probe.
|
|
27
|
+
process.env.PORT = process.env.PORT || String(PROBE_PORT);
|
|
28
|
+
const probe = Number(process.env.PORT) || PROBE_PORT;
|
|
29
|
+
await import("./server.js");
|
|
30
|
+
// If import had booted a server, this bind would throw EADDRINUSE.
|
|
31
|
+
let listener: any = null;
|
|
32
|
+
expect(() => {
|
|
33
|
+
listener = Bun.listen({ hostname: "127.0.0.1", port: probe, socket: { data() {} } });
|
|
34
|
+
}).not.toThrow();
|
|
35
|
+
listener?.stop?.(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("#476 — import alone must let the process exit", () => {
|
|
40
|
+
test("a subprocess that only imports server.js exits by itself (no module-level timers hold the loop)", () => {
|
|
41
|
+
// Witnessed-red: pre-#476 (main 541a80aa) this child is killed by the
|
|
42
|
+
// timeout with a non-zero exit — two module-level setIntervals
|
|
43
|
+
// (rate-limit sweep + DB-writing task patrol) held the event loop
|
|
44
|
+
// after a bare import. Post-#476 both live inside startHub, so the
|
|
45
|
+
// child exits 0 on its own. Runs in a subprocess because the parent
|
|
46
|
+
// test process has its own handles and can't observe loop-emptiness.
|
|
47
|
+
const child = Bun.spawnSync({
|
|
48
|
+
cmd: ["bun", "-e", 'await import("./src/server.js");'],
|
|
49
|
+
cwd: `${import.meta.dir}/..`,
|
|
50
|
+
env: { ...process.env, COMMHUB_DB: process.env.COMMHUB_DB || "/tmp/anet-476-probe.db" },
|
|
51
|
+
timeout: 10_000,
|
|
52
|
+
});
|
|
53
|
+
expect(child.exitCode).toBe(0);
|
|
54
|
+
}, 15_000);
|
|
55
|
+
|
|
56
|
+
test("the moved task patrol actually FIRES under startHub (not just registered-looking)", () => {
|
|
57
|
+
// Fake-gate guard for the #476 move: stub patrolExpiredTasks to a
|
|
58
|
+
// no-op (or forget to register it in startHub) and this goes red.
|
|
59
|
+
// Runs in a subprocess because startHub is single-shot per process
|
|
60
|
+
// and the patrol period is shrunk via env for observability.
|
|
61
|
+
const script = `
|
|
62
|
+
process.env.COMMHUB_TASK_PATROL_MS = "100";
|
|
63
|
+
const { startHub } = await import("./src/server.js");
|
|
64
|
+
const { db } = await import("./src/db.js");
|
|
65
|
+
const hub = startHub({ port: 0, hostname: "127.0.0.1" });
|
|
66
|
+
db.run("DELETE FROM tasks WHERE task_id = 't476_patrol'");
|
|
67
|
+
db.run("INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, created_at, expires_at) " +
|
|
68
|
+
"VALUES ('t476_patrol', 'a', 'b', 'normal', 'delivered', 'x', datetime('now'), datetime('now', '-1 minute'))");
|
|
69
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
70
|
+
const row = db.get("SELECT status FROM tasks WHERE task_id = 't476_patrol'");
|
|
71
|
+
console.log("PATROL_RESULT:" + (row ? row.status : "missing"));
|
|
72
|
+
db.run("DELETE FROM tasks WHERE task_id = 't476_patrol'");
|
|
73
|
+
hub.stop(true);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
`;
|
|
76
|
+
const child = Bun.spawnSync({
|
|
77
|
+
cmd: ["bun", "-e", script],
|
|
78
|
+
cwd: `${import.meta.dir}/..`,
|
|
79
|
+
env: { ...process.env, COMMHUB_DB: process.env.COMMHUB_DB || "/tmp/anet-476-patrol.db" },
|
|
80
|
+
timeout: 15_000,
|
|
81
|
+
});
|
|
82
|
+
const out = new TextDecoder().decode(child.stdout);
|
|
83
|
+
expect(out).toContain("PATROL_RESULT:expired");
|
|
84
|
+
}, 20_000);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("#438 corrective — startHub is explicit, single-shot, observable", () => {
|
|
88
|
+
test("startHub boots a live hub; second call throws with first boot's coordinates; bootServer stays available", async () => {
|
|
89
|
+
const { startHub, bootServer, getStartedHub } = await import("./server.js") as any;
|
|
90
|
+
expect(getStartedHub()).toBeNull();
|
|
91
|
+
|
|
92
|
+
const hub = startHub({ port: 0, hostname: "127.0.0.1" });
|
|
93
|
+
try {
|
|
94
|
+
expect(hub.port).toBeGreaterThan(0);
|
|
95
|
+
expect(getStartedHub()?.server).toBe(hub);
|
|
96
|
+
|
|
97
|
+
// Really listening — the production liveness probe, not just a
|
|
98
|
+
// non-null handle.
|
|
99
|
+
const health = await fetch(`http://127.0.0.1:${hub.port}/health`);
|
|
100
|
+
expect(health.status).toBe(200);
|
|
101
|
+
expect(((await health.json()) as any).ok).toBe(true);
|
|
102
|
+
|
|
103
|
+
// Second call must be an OBSERVABLE rejection, not a silent no-op,
|
|
104
|
+
// and must tell the caller where the first boot lives.
|
|
105
|
+
let thrown: Error | null = null;
|
|
106
|
+
try {
|
|
107
|
+
startHub({ port: 0 });
|
|
108
|
+
} catch (e: any) {
|
|
109
|
+
thrown = e;
|
|
110
|
+
}
|
|
111
|
+
expect(thrown).not.toBeNull();
|
|
112
|
+
expect(String(thrown!.message)).toContain("single-shot");
|
|
113
|
+
expect(String(thrown!.message)).toContain(String(hub.port));
|
|
114
|
+
|
|
115
|
+
// Throwaway instances stay available after startHub.
|
|
116
|
+
const extra = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
117
|
+
expect(extra.port).toBeGreaterThan(0);
|
|
118
|
+
expect(extra.port).not.toBe(hub.port);
|
|
119
|
+
extra.stop(true);
|
|
120
|
+
} finally {
|
|
121
|
+
hub.stop(true);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
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
|
+
});
|