@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.60

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.
Files changed (140) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +89 -1
  24. package/src/create-node-validate.ts +55 -7
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +710 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/http-gzip.test.ts +73 -0
  47. package/src/http-gzip.ts +60 -0
  48. package/src/hub-timestamp.test.ts +38 -0
  49. package/src/hub-timestamp.ts +27 -0
  50. package/src/inbox-count-sse.test.ts +310 -0
  51. package/src/inbox-count.ts +29 -0
  52. package/src/index.ts +15 -2227
  53. package/src/lifecycle-guard.ts +50 -0
  54. package/src/list-host-supervisors.test.ts +314 -0
  55. package/src/mcp-write-network-resolution.test.ts +429 -0
  56. package/src/messages-ack-by-agent-http.test.ts +142 -0
  57. package/src/network-name-http.test.ts +75 -0
  58. package/src/network-scope.ts +101 -0
  59. package/src/node-attrs-validate.ts +88 -0
  60. package/src/node-attrs.test.ts +217 -0
  61. package/src/node-create-request-status-http.test.ts +92 -0
  62. package/src/node-files-transport.test.ts +344 -0
  63. package/src/node-id-alias.test.ts +188 -0
  64. package/src/node-lifecycle-controllable.test.ts +64 -0
  65. package/src/node-lifecycle-controllable.ts +51 -0
  66. package/src/node-request-retention.test.ts +281 -0
  67. package/src/node-request-retention.ts +104 -0
  68. package/src/node-skills-transport.test.ts +209 -0
  69. package/src/observer-avatar-http.test.ts +428 -0
  70. package/src/observer-push.test.ts +252 -0
  71. package/src/package-version-consistency.test.ts +25 -0
  72. package/src/peer-reply-atomic.test.ts +455 -0
  73. package/src/peer-reply-capability.test.ts +70 -0
  74. package/src/push.ts +163 -7
  75. package/src/redact-tokens.test.ts +65 -0
  76. package/src/redact-tokens.ts +40 -0
  77. package/src/reply-target-derivation.test.ts +96 -0
  78. package/src/report-status-host-schema.test.ts +211 -0
  79. package/src/report-status-resume-id-handover.test.ts +150 -0
  80. package/src/require-explicit-test-db.ts +9 -0
  81. package/src/resolve-port.test.ts +57 -0
  82. package/src/resolve-port.ts +44 -0
  83. package/src/response-charset.test.ts +130 -0
  84. package/src/rest-explicit-columns-http.test.ts +216 -0
  85. package/src/rest-identity.test.ts +129 -0
  86. package/src/rest-identity.ts +100 -0
  87. package/src/rest-projections.ts +67 -0
  88. package/src/rest-write-network-resolution.test.ts +133 -0
  89. package/src/retention.ts +10 -1
  90. package/src/rules-file-duplicate-alias.test.ts +234 -0
  91. package/src/rules-file-session-target.test.ts +207 -0
  92. package/src/rules-file-transport.test.ts +237 -0
  93. package/src/runtime-label.test.ts +22 -0
  94. package/src/runtime-label.ts +21 -0
  95. package/src/scheduled-run-terminal.test.ts +268 -0
  96. package/src/scheduled-tasks-http.test.ts +483 -0
  97. package/src/scheduled-tasks.ts +549 -0
  98. package/src/send-reply-agent-warning.test.ts +202 -0
  99. package/src/send-reply-alias-optional.test.ts +131 -0
  100. package/src/send-reply-attachments.test.ts +458 -0
  101. package/src/serve-error.test.ts +38 -0
  102. package/src/serve-error.ts +27 -0
  103. package/src/server.ts +3842 -0
  104. package/src/shared/external-schedule-contract.ts +60 -0
  105. package/src/side-thread-command-transport.test.ts +302 -0
  106. package/src/side-thread-command-transport.ts +318 -0
  107. package/src/side-thread-http-integration.test.ts +233 -0
  108. package/src/side-thread-http.ts +504 -0
  109. package/src/side-thread-production.test.ts +72 -0
  110. package/src/side-thread-production.ts +118 -0
  111. package/src/side-thread.test.ts +1067 -0
  112. package/src/side-thread.ts +2495 -0
  113. package/src/skillhub-http.test.ts +92 -0
  114. package/src/start-node.test.ts +109 -0
  115. package/src/stop-delete-node.test.ts +952 -0
  116. package/src/task-auth-origin-http.test.ts +91 -0
  117. package/src/task-auth-origin.test.ts +24 -0
  118. package/src/task-auth-origin.ts +7 -0
  119. package/src/task-consumption.test.ts +482 -0
  120. package/src/task-diagnostic-http.test.ts +149 -0
  121. package/src/task-diagnostic.test.ts +115 -0
  122. package/src/task-diagnostic.ts +118 -0
  123. package/src/task-idempotency-mcp.test.ts +142 -0
  124. package/src/task-idempotency.test.ts +60 -0
  125. package/src/task-idempotency.ts +75 -0
  126. package/src/task-lifecycle-watcher.test.ts +204 -0
  127. package/src/task-lifecycle-watcher.ts +49 -0
  128. package/src/task-network-resolution-http.test.ts +141 -0
  129. package/src/tasks-pagination-http.test.ts +141 -0
  130. package/src/tools-list-json-schema.test.ts +66 -0
  131. package/src/tools.ts +3231 -204
  132. package/src/update-provider.test.ts +65 -0
  133. package/src/upload-early-reject-guard.test.ts +155 -0
  134. package/src/uploads-http.test.ts +97 -15
  135. package/src/uploads.test.ts +66 -0
  136. package/src/uploads.ts +130 -5
  137. package/src/user-inbox-by-agent-http.test.ts +161 -0
  138. package/src/user-inbox-netid-notnull.test.ts +122 -0
  139. package/src/user-inbox-read-http.test.ts +223 -0
  140. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,122 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #1548 —— 一个活着、能收发任务的节点可以在名册里永远显示 `blocked`:
7
+ // 只有 report_completion 会把 sessions.status 拉回 idle,而现在大多数 agent 用
8
+ // send_reply(终态)结束任务、用 send_task 派活 —— 这两条路径此前都不碰 sessions.status。
9
+ // 「它刚回了我一条终态消息 / 刚派了一条任务」本身就是最强的活性证据。
10
+ // 只出 `blocked`;`working` 可能真在忙别的任务,不碰(保守)。
11
+ const NET_ID = "net_1548_blocked";
12
+ const USER_ID = "u_1548";
13
+ const NODE_ID = "node_1548_agent";
14
+ const AGENT_ALIAS = "agent-1548";
15
+ const DISPATCHER_NODE_ID = "node_1548_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher-1548";
17
+ const AGENT_TOK_ID = "tok_1548";
18
+
19
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
20
+
21
+ function cleanup() {
22
+ for (const [sql, params] of [
23
+ ["DELETE FROM tasks WHERE network_id = ?1", [NET_ID]],
24
+ ["DELETE FROM inbox WHERE network_id = ?1", [NET_ID]],
25
+ ["DELETE FROM task_events WHERE network_id = ?1", [NET_ID]],
26
+ ["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
27
+ ["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
28
+ ["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
29
+ ["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
30
+ ["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
31
+ ["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
32
+ ] as const) {
33
+ try { db.run(sql, params as any); } catch {}
34
+ }
35
+ }
36
+ beforeEach(cleanup);
37
+ afterAll(cleanup);
38
+
39
+ function seed(agentStatus: string) {
40
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
41
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
42
+ db.run(
43
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
44
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
45
+ [USER_ID, NET_ID],
46
+ );
47
+ for (const [nodeId, alias] of [[DISPATCHER_NODE_ID, DISPATCHER_ALIAS], [NODE_ID, AGENT_ALIAS]] as const) {
48
+ db.run(
49
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
50
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
51
+ [nodeId, alias, alias, NET_ID, `host-${alias}`],
52
+ );
53
+ }
54
+ db.run(
55
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
56
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
57
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
58
+ );
59
+ db.run(
60
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
61
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
62
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, agentStatus, NODE_ID, NET_ID],
63
+ );
64
+ db.run(
65
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
66
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
67
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
68
+ );
69
+ }
70
+ function makeTask(): string {
71
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
72
+ db.run(
73
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
74
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
75
+ [id, DISPATCHER_ALIAS, AGENT_ALIAS, NODE_ID, NET_ID],
76
+ );
77
+ return id;
78
+ }
79
+ function buildHandlers(): Record<string, ToolHandler> {
80
+ const server = new McpServer({ name: "test", version: "0" }) as any;
81
+ const tools: Record<string, ToolHandler> = {};
82
+ const origTool = server.tool.bind(server);
83
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
84
+ const origRegisterTool = server.registerTool?.bind(server);
85
+ if (origRegisterTool) {
86
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
87
+ }
88
+ registerTools(server, undefined, NET_ID, USER_ID, AGENT_ALIAS, true, AGENT_TOK_ID);
89
+ return tools;
90
+ }
91
+ async function call(handler: ToolHandler, args: any): Promise<any> {
92
+ const r = await handler(args);
93
+ return JSON.parse(r.content[0]!.text);
94
+ }
95
+ function agentStatus(): string {
96
+ return (db.get("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", [AGENT_ALIAS, NET_ID]) as { status: string }).status;
97
+ }
98
+
99
+ describe("#1548 blocked has an exit: a terminal reply or an outbound task proves the agent is alive", () => {
100
+ test("terminal send_reply from a blocked agent puts it back to idle", async () => {
101
+ seed("blocked");
102
+ const tools = buildHandlers();
103
+ const taskId = makeTask();
104
+ const r = await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
105
+ expect(r.ok, JSON.stringify(r)).toBe(true);
106
+ expect(agentStatus()).toBe("idle");
107
+ });
108
+ test("send_task from a blocked agent puts it back to idle", async () => {
109
+ seed("blocked");
110
+ const tools = buildHandlers();
111
+ const r = await call(tools.send_task!, { alias: DISPATCHER_ALIAS, task: "ping", priority: "normal", from_session: AGENT_ALIAS, network_id: NET_ID });
112
+ expect(r.ok, JSON.stringify(r)).toBe(true);
113
+ expect(agentStatus()).toBe("idle");
114
+ });
115
+ test("a working agent is left alone (it may be busy with another task)", async () => {
116
+ seed("working");
117
+ const tools = buildHandlers();
118
+ const taskId = makeTask();
119
+ await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
120
+ expect(agentStatus()).toBe("working");
121
+ });
122
+ });
@@ -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
+ });
@@ -57,6 +57,35 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
57
57
  "timeout",
58
58
  ]);
59
59
 
60
+ /**
61
+ * Channels the dashboard may enable/disable on a node via
62
+ * update_node_config. Intentionally narrow — the panel only exposes
63
+ * enable/disable, per-channel secrets stay in the node's local
64
+ * config.json (see #260 wire discipline).
65
+ *
66
+ * The list mirrors agent-node's actual runtime capability. See
67
+ * agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
68
+ * worker inits (initTelegramChannel + initFeishuChannel), and any
69
+ * other channel type triggers `process.exit(1)` at boot. So the
70
+ * hub-side allow-list here is telegram + feishu ONLY. `commhub` is
71
+ * the RPC transport that every node speaks unconditionally; treating
72
+ * it as a per-node channel would be a UX lie (toggle looks off but
73
+ * commhub is always on).
74
+ *
75
+ * Dashboard PR #31 currently includes `commhub` in its own
76
+ * EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
77
+ * the next dashboard rally, and until then hub silently drops any
78
+ * `commhub` entry via narrowChannelsPatch.
79
+ *
80
+ * Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
81
+ * channel on/off is a lifecycle-tier operation like `restart_node`, not
82
+ * a privilege elevation. Cross-tenant reach is already gated by SEC-1.
83
+ */
84
+ export const EDITABLE_CHANNELS = new Set<string>([
85
+ "telegram",
86
+ "feishu",
87
+ ]);
88
+
60
89
  /**
61
90
  * Role-gate for the patch's flag set. SEC-2 enforcement lives here.
62
91
  *
@@ -99,22 +128,53 @@ export function isAllowedToChangeFlag(
99
128
 
100
129
  /**
101
130
  * Classify the patch's required apply mode. Empty patch (model + flags
102
- * both empty) → "restart_only" (used by restart_node tool). Any
103
- * restart-required field present → "restart". Otherwise → "hot".
131
+ * both empty, no channels) → "restart_only" (used by restart_node tool).
132
+ * Any restart-required field present, OR a channels change → "restart"
133
+ * (channels is restart-tier by design: the node reads its channel set
134
+ * once at boot in agent-node/src/cli.ts and forks per-channel workers
135
+ * from it, so a channel enable/disable takes effect on process restart
136
+ * only — see #260 P5 wire discipline). Otherwise → "hot".
104
137
  */
105
138
  export function computeApplyMode(
106
139
  model: string | undefined,
107
140
  flags: Record<string, unknown>,
141
+ channels?: string[] | undefined,
108
142
  ): "hot" | "restart" | "restart_only" {
109
- const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
143
+ const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
110
144
  if (fieldCount === 0) return "restart_only";
111
145
  if (model !== undefined) return "restart";
146
+ if (channels !== undefined) return "restart";
112
147
  for (const key of Object.keys(flags)) {
113
148
  if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
114
149
  }
115
150
  return "hot";
116
151
  }
117
152
 
153
+ /**
154
+ * Narrow + allow-list `channels` from untrusted patch JSON. Returns the
155
+ * final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
156
+ * order-preserving), OR null if the input isn't an array. Non-strings,
157
+ * unknown keys, empty strings, and duplicates are silently dropped —
158
+ * same fail-shape as the dashboard route's whitelist so the two sides
159
+ * behave identically end-to-end. Callers pass `undefined` through when
160
+ * the patch didn't include a channels key at all (a distinct case from
161
+ * `channels: []` which means "disable all editable channels").
162
+ */
163
+ export function narrowChannelsPatch(raw: unknown): string[] | null {
164
+ if (!Array.isArray(raw)) return null;
165
+ const allow = EDITABLE_CHANNELS;
166
+ const seen = new Set<string>();
167
+ const out: string[] = [];
168
+ for (const v of raw) {
169
+ if (typeof v !== "string") continue;
170
+ const key = v.trim().toLowerCase();
171
+ if (!allow.has(key) || seen.has(key)) continue;
172
+ seen.add(key);
173
+ out.push(key);
174
+ }
175
+ return out;
176
+ }
177
+
118
178
  /**
119
179
  * Validate the patch shape — allowlist + per-field range / enum. Returns
120
180
  * null on pass, or `{ field, reason }` on reject. Caller wraps in error
@@ -123,12 +183,30 @@ export function computeApplyMode(
123
183
  export function validatePatch(
124
184
  model: string | undefined,
125
185
  flags: Record<string, unknown>,
186
+ channels?: string[] | undefined,
126
187
  ): { field: string; reason: string } | null {
127
188
  if (model !== undefined) {
128
189
  if (typeof model !== "string" || model.length === 0 || model.length > 200) {
129
190
  return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
130
191
  }
131
192
  }
193
+ if (channels !== undefined) {
194
+ // Zod already enforced string[], and narrowChannelsPatch dropped
195
+ // unknown keys before this point — but re-verify defensively so a
196
+ // future caller that skips narrowChannelsPatch can't smuggle
197
+ // unknown keys through validatePatch alone.
198
+ if (!Array.isArray(channels)) {
199
+ return { field: "channels", reason: "must be an array of strings" };
200
+ }
201
+ for (const c of channels) {
202
+ if (typeof c !== "string") {
203
+ return { field: "channels", reason: "must contain only strings" };
204
+ }
205
+ if (!EDITABLE_CHANNELS.has(c)) {
206
+ return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
207
+ }
208
+ }
209
+ }
132
210
  for (const [key, val] of Object.entries(flags)) {
133
211
  if (!ALLOWED_FLAGS.has(key)) {
134
212
  return { field: `flags.${key}`, reason: "not in allowlist" };