@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
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
|
+
});
|
|
@@ -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
|
+
});
|