@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 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`, currently v2.2.10 at v0.10.11) and run `anet hub start`, which wires up the port, default admin account, recovery admin `utok_`, and local config for you.
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
- import("../src/index.js");
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.21",
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
- const PORT = 18000 + Math.floor(Math.random() * 1000);
31
- const BASE = `http://127.0.0.1:${PORT}`;
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
- // Import triggers Bun.serve at module load — this IS the server start.
112
- await import("./index.js");
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 { ...rest, role };
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: full key set is exactly the 13 contract fields
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", "channels", "config_path", "created_at", "hostname",
91
- "model", "network_id", "node_id", "node_name", "role", "runtime",
92
- "server", "updated_at",
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