@sleep2agi/commhub-server 0.9.0-preview.21 → 0.9.0-preview.23
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/api-nodes-shape.test.ts +26 -10
- 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 +42 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +349 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2385
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- 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/send-reply-agent-warning.test.ts +202 -0
- package/src/server.ts +2831 -0
- package/src/tools.ts +26 -3
- 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.23",
|
|
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
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { db } from "./db.js";
|
|
3
|
+
import { parseStoredTags } from "./node-attrs-validate.js";
|
|
3
4
|
|
|
4
5
|
// Regression test for SELECT * fragility on GET /api/nodes.
|
|
5
6
|
//
|
|
@@ -41,7 +42,12 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
41
42
|
catch { /* malformed */ }
|
|
42
43
|
}
|
|
43
44
|
const { config_snapshot, ...rest } = r;
|
|
44
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
...rest,
|
|
47
|
+
tags: parseStoredTags(r.tags),
|
|
48
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
|
+
role,
|
|
50
|
+
};
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
@@ -66,7 +72,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
66
72
|
`SELECT node_id, node_name, alias, runtime, model,
|
|
67
73
|
config_path, channels, server, hostname,
|
|
68
74
|
network_id, created_at, updated_at,
|
|
69
|
-
config_snapshot
|
|
75
|
+
config_snapshot, display_name, team, tags, attrs_revision
|
|
70
76
|
FROM nodes WHERE node_id = ?1`,
|
|
71
77
|
TEST_NODE,
|
|
72
78
|
);
|
|
@@ -85,11 +91,17 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
85
91
|
expect(row).not.toHaveProperty("config_snapshot");
|
|
86
92
|
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
87
93
|
expect(row.role).toBe("host_supervisor");
|
|
88
|
-
// Belt:
|
|
94
|
+
// Belt: the key set is exactly the dashboard-facing contract.
|
|
95
|
+
// NOTE: this file MIRRORS the handler's SQL rather than calling it, so
|
|
96
|
+
// it can drift — it already lagged behind `lifecycle_state` /
|
|
97
|
+
// `avatar_url`, which the handler selects but this mirror does not.
|
|
98
|
+
// Treat a diff here as "re-read the handler", not as proof of the wire
|
|
99
|
+
// shape; node-attrs.test.ts asserts the real HTTP response.
|
|
89
100
|
expect(Object.keys(row).sort()).toEqual([
|
|
90
|
-
"alias", "
|
|
91
|
-
"
|
|
92
|
-
"server", "
|
|
101
|
+
"alias", "attrs_revision", "channels", "config_path", "created_at",
|
|
102
|
+
"display_name", "hostname", "model", "network_id", "node_id",
|
|
103
|
+
"node_name", "role", "runtime", "server", "tags", "team",
|
|
104
|
+
"updated_at",
|
|
93
105
|
]);
|
|
94
106
|
});
|
|
95
107
|
|
|
@@ -102,7 +114,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
102
114
|
);
|
|
103
115
|
let row = mapRow(db.get<Record<string, unknown>>(
|
|
104
116
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
105
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
117
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
118
|
+
display_name, team, tags, attrs_revision
|
|
106
119
|
FROM nodes WHERE node_id = ?1`,
|
|
107
120
|
TEST_NODE,
|
|
108
121
|
)!);
|
|
@@ -113,7 +126,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
113
126
|
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
114
127
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
115
128
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
116
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
129
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
130
|
+
display_name, team, tags, attrs_revision
|
|
117
131
|
FROM nodes WHERE node_id = ?1`,
|
|
118
132
|
TEST_NODE,
|
|
119
133
|
)!);
|
|
@@ -124,7 +138,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
124
138
|
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
125
139
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
126
140
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
127
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
141
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
142
|
+
display_name, team, tags, attrs_revision
|
|
128
143
|
FROM nodes WHERE node_id = ?1`,
|
|
129
144
|
TEST_NODE,
|
|
130
145
|
)!);
|
|
@@ -141,7 +156,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
141
156
|
);
|
|
142
157
|
const rows = db.all<Record<string, unknown>>(
|
|
143
158
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
144
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
159
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
160
|
+
display_name, team, tags, attrs_revision
|
|
145
161
|
FROM nodes WHERE network_id = ?1`,
|
|
146
162
|
TEST_NETWORK,
|
|
147
163
|
).map(mapRow);
|
|
@@ -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,48 @@ 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
|
+
|
|
351
|
+
// Node DISPLAY attributes (Vincent 目标第 3 条 — dashboard/app 多维度编辑).
|
|
352
|
+
// Same posture as avatar_url above: hub-side presentation only, NOT part of
|
|
353
|
+
// the RFC-024 config-apply pipeline (agent-node consumes none of them, so a
|
|
354
|
+
// doorbell/ack round-trip would be meaningless and could wedge the config
|
|
355
|
+
// revision). Written only via PUT /api/nodes/:ref/attrs.
|
|
356
|
+
//
|
|
357
|
+
// display_name — human label shown in the UI. Distinct from `alias`,
|
|
358
|
+
// which is the ROUTING key (messages address the alias)
|
|
359
|
+
// and from `node_name`, which is an addressing key
|
|
360
|
+
// (`WHERE node_id OR node_name OR alias`). Renaming
|
|
361
|
+
// either of those must go through the rename 2PC
|
|
362
|
+
// (/api/node-rename/*), never through an attrs write.
|
|
363
|
+
// team, tags — organisation metadata; tags stored as JSON array text.
|
|
364
|
+
// attrs_revision — optimistic-lock counter for these display fields ONLY.
|
|
365
|
+
// Deliberately separate from config_revision so that
|
|
366
|
+
// editing a tag never bumps the node's *config* revision
|
|
367
|
+
// (which would perturb the config ack protocol).
|
|
368
|
+
//
|
|
369
|
+
// All nullable / defaulted so the ~100 rows already in flight keep reading.
|
|
370
|
+
for (const ddl of [
|
|
371
|
+
`ALTER TABLE nodes ADD COLUMN display_name TEXT`,
|
|
372
|
+
`ALTER TABLE nodes ADD COLUMN team TEXT`,
|
|
373
|
+
`ALTER TABLE nodes ADD COLUMN tags TEXT`,
|
|
374
|
+
`ALTER TABLE nodes ADD COLUMN attrs_revision INTEGER NOT NULL DEFAULT 0`,
|
|
375
|
+
]) {
|
|
376
|
+
try {
|
|
377
|
+
db.exec(ddl);
|
|
378
|
+
} catch (e: any) {
|
|
379
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
341
383
|
// RFC-027 §2 — stop/delete request envelope. Mirrors
|
|
342
384
|
// node_create_requests structure so the daemon-side pull/ack pattern
|
|
343
385
|
// is symmetric with create_node. action='stop'|'delete' picks which
|