@sleep2agi/commhub-server 0.9.0-preview.22 → 0.9.0-preview.24

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.
@@ -0,0 +1,213 @@
1
+ // Node display attributes — team / tags / display_name (Vincent 目标第 3 条).
2
+ //
3
+ // These are HUB-SIDE DISPLAY attributes. Following the `avatar_url` (#462)
4
+ // precedent they are deliberately NOT part of the RFC-024 config-apply
5
+ // pipeline: agent-node has nothing to consume, so pushing them through
6
+ // config revision/ack would ring a doorbell for a field the node does not
7
+ // understand and could wedge the config revision.
8
+ //
9
+ // They DO need concurrency protection (two dashboards editing the same
10
+ // node), which `avatar_url` lacks. Hence a dedicated `attrs_revision` CAS
11
+ // that is independent of `config_revision` — editing a tag must never bump
12
+ // the node's *config* revision.
13
+ //
14
+ // Every test drives the REAL HTTP endpoint against a booted server, not a
15
+ // helper: the contract under test is the wire shape + status codes.
16
+ //
17
+ // NOTE (test isolation): this file boots its own Bun.serve, same pattern as
18
+ // uploads-http.test.ts / api-host-supervisors-fallback.test.ts. Those files
19
+ // are per-file runs by design (single-process module-singleton conflict,
20
+ // tracked in #434) — run this file on its own.
21
+
22
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
23
+ import { mkdtempSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+
27
+ const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
28
+ process.env.COMMHUB_DB = tmpDb;
29
+ const PORT = 23000 + Math.floor(Math.random() * 1500);
30
+ process.env.PORT = String(PORT);
31
+ process.env.HOST = "127.0.0.1";
32
+
33
+ const { db } = await import("./db.js");
34
+ const { issueUserToken } = await import("./auth.js");
35
+ const { bootServer } = await import("./server.js");
36
+
37
+ const BASE = `http://127.0.0.1:${PORT}`;
38
+ const NET = "net_attrs";
39
+ const USER = "u_attrs";
40
+ /** A node row seeded the way a PRE-MIGRATION row looks: none of the new
41
+ * columns are written, so they are NULL / default at read time. */
42
+ const LEGACY_NODE = "node_attrs_legacy";
43
+ const LEGACY_ALIAS = "attrs-legacy-node";
44
+
45
+ let token = "";
46
+ let server: { stop: (force?: boolean) => void } | null = null;
47
+
48
+ beforeAll(async () => {
49
+ db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
50
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
51
+ db.run(`INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')`, [NET, USER]);
52
+ // Legacy row: only the columns that existed before this change.
53
+ db.run(
54
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id)
55
+ VALUES (?1, 'attrs-legacy', ?2, 'claude-agent-sdk', 'claude-sonnet-4-6', ?3)`,
56
+ [LEGACY_NODE, LEGACY_ALIAS, NET],
57
+ );
58
+ token = issueUserToken(USER, "attrs-test").token;
59
+ server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
60
+ await new Promise((r) => setTimeout(r, 120));
61
+ });
62
+
63
+ afterAll(() => {
64
+ try { server?.stop(true); } catch { /* already down */ }
65
+ });
66
+
67
+ async function putAttrs(ref: string, body: unknown) {
68
+ const res = await fetch(`${BASE}/api/nodes/${encodeURIComponent(ref)}/attrs?network_id=${NET}`, {
69
+ method: "PUT",
70
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
71
+ body: JSON.stringify(body),
72
+ });
73
+ return { status: res.status, json: (await res.json()) as Record<string, unknown> };
74
+ }
75
+
76
+ async function listNodes() {
77
+ const res = await fetch(`${BASE}/api/nodes?network_id=${NET}`, {
78
+ headers: { Authorization: `Bearer ${token}` },
79
+ });
80
+ const j = (await res.json()) as { nodes?: Array<Record<string, unknown>> };
81
+ return j.nodes ?? [];
82
+ }
83
+
84
+ describe("node display attrs — backward compatibility with existing rows", () => {
85
+ test("a pre-migration node row still lists fine; new fields read as empty, not missing/throwing", async () => {
86
+ const rows = await listNodes();
87
+ const row = rows.find((r) => r.node_id === LEGACY_NODE);
88
+ expect(row).toBeDefined();
89
+ // Must not blow up and must not omit the keys — the dashboard reads
90
+ // them unconditionally.
91
+ expect(row!).toHaveProperty("display_name");
92
+ expect(row!).toHaveProperty("team");
93
+ expect(row!).toHaveProperty("tags");
94
+ expect(row!.display_name ?? null).toBeNull();
95
+ expect(row!.team ?? null).toBeNull();
96
+ // tags normalises to an empty array rather than null/undefined so the
97
+ // dashboard can `.map()` without a guard.
98
+ expect(row!.tags).toEqual([]);
99
+ expect(row!.attrs_revision).toBe(0);
100
+ });
101
+ });
102
+
103
+ describe("node display attrs — write then read back", () => {
104
+ test("PUT sets display_name/team/tags, bumps attrs_revision, and GET /api/nodes reflects it", async () => {
105
+ const w = await putAttrs(LEGACY_ALIAS, {
106
+ base_attrs_revision: 0,
107
+ display_name: "研发一号机",
108
+ team: "platform",
109
+ tags: ["prod", "gpu"],
110
+ });
111
+ expect(w.status).toBe(200);
112
+ expect(w.json.ok).toBe(true);
113
+ expect(w.json.attrs_revision).toBe(1);
114
+
115
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
116
+ expect(row.display_name).toBe("研发一号机");
117
+ expect(row.team).toBe("platform");
118
+ expect(row.tags).toEqual(["prod", "gpu"]);
119
+ expect(row.attrs_revision).toBe(1);
120
+ });
121
+
122
+ test("editing attrs does NOT touch config_revision (display vs config are separate revisions)", async () => {
123
+ const before = db.get<{ config_revision: number }>(
124
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
125
+ const w = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: 1, team: "infra" });
126
+ expect(w.status).toBe(200);
127
+ const after = db.get<{ config_revision: number }>(
128
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
129
+ expect(after).toBe(before);
130
+ });
131
+
132
+ test("partial patch leaves untouched fields alone", async () => {
133
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
134
+ // team was just changed to 'infra'; display_name/tags must survive.
135
+ expect(row.team).toBe("infra");
136
+ expect(row.display_name).toBe("研发一号机");
137
+ expect(row.tags).toEqual(["prod", "gpu"]);
138
+ });
139
+ });
140
+
141
+ describe("node display attrs — CAS concurrency guard", () => {
142
+ test("stale base_attrs_revision → 409 with the current revision, and NO write happens", async () => {
143
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
144
+ const stale = await putAttrs(LEGACY_ALIAS, {
145
+ base_attrs_revision: 0, // deliberately stale
146
+ team: "should-not-land",
147
+ });
148
+ expect(stale.status).toBe(409);
149
+ expect(stale.json.ok).toBe(false);
150
+ expect(stale.json.error).toBe("attrs_revision_conflict");
151
+ expect(stale.json.current_attrs_revision).toBe(before.attrs_revision);
152
+
153
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
154
+ expect(after.team).toBe(before.team); // unchanged
155
+ expect(after.attrs_revision).toBe(before.attrs_revision);
156
+ });
157
+ });
158
+
159
+ describe("node display attrs — untrusted input is narrowed at the boundary", () => {
160
+ test("tags: non-strings dropped, blanks dropped, duplicates folded, count + length capped", async () => {
161
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
162
+ const w = await putAttrs(LEGACY_ALIAS, {
163
+ base_attrs_revision: cur,
164
+ tags: [
165
+ "keep",
166
+ "keep", // duplicate
167
+ " spaced ", // trimmed
168
+ "",
169
+ " ",
170
+ 42,
171
+ null,
172
+ { evil: true },
173
+ ["nested"],
174
+ "x".repeat(200), // over-long → dropped or truncated, never stored raw
175
+ ],
176
+ });
177
+ expect(w.status).toBe(200);
178
+ const tags = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.tags as string[];
179
+ expect(Array.isArray(tags)).toBe(true);
180
+ expect(tags).toContain("keep");
181
+ expect(tags).toContain("spaced");
182
+ // one "keep", no junk, nothing absurdly long
183
+ expect(tags.filter((t) => t === "keep")).toHaveLength(1);
184
+ expect(tags.every((t) => typeof t === "string" && t.length > 0 && t.length <= 64)).toBe(true);
185
+ expect(tags.some((t) => t.includes("evil") || t.includes("nested"))).toBe(false);
186
+ });
187
+
188
+ test("display_name/team: non-string values are rejected, not coerced", async () => {
189
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
190
+ const bad = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: cur, team: { not: "a string" } });
191
+ expect(bad.status).toBe(400);
192
+ expect(bad.json.ok).toBe(false);
193
+ });
194
+ });
195
+
196
+ describe("node display attrs — auth + scope", () => {
197
+ test("no token → 401, and the row is untouched", async () => {
198
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
199
+ const res = await fetch(`${BASE}/api/nodes/${LEGACY_ALIAS}/attrs?network_id=${NET}`, {
200
+ method: "PUT",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ base_attrs_revision: before.attrs_revision, team: "hijack" }),
203
+ });
204
+ expect(res.status).toBe(401);
205
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
206
+ expect(after.team).toBe(before.team);
207
+ });
208
+
209
+ test("unknown node ref → 404", async () => {
210
+ const r = await putAttrs("no-such-node-ref", { base_attrs_revision: 0, team: "x" });
211
+ expect(r.status).toBe(404);
212
+ });
213
+ });
package/src/push.ts CHANGED
@@ -110,6 +110,15 @@ function clientKey(sessionName: string, networkId?: string | null): string {
110
110
  // legacy ?network_id= path, and master token is root already.
111
111
  const OBSERVER_KEY_PREFIX = "\u0000netobs:";
112
112
 
113
+ /** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
114
+ * `printableKey()` (below) and therefore the shape callers see via
115
+ * `getSSEStats().sessions`. Exported so downstream consumers (e.g.
116
+ * `/health` filter in server.ts) key off the same literal instead of
117
+ * each side re-writing the escape sequence — CR3 audit 4703b0e7
118
+ * found server.ts had `"netobs:"` (no `\\0`) which never matched and
119
+ * silently over-filtered observer keys. */
120
+ export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
121
+
113
122
  function observerKey(networkId: string): string {
114
123
  return `${OBSERVER_KEY_PREFIX}${networkId}`;
115
124
  }
@@ -0,0 +1,202 @@
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
+ // #498 regression — the send_reply hub response gains a `warning` field
7
+ // pointing at commhub_send_task ONLY when the target alias resolves to
8
+ // a real agent node (i.e. not hub / not api / not an unbound alias).
9
+ // Silence-affirms the Dashboard path.
10
+ //
11
+ // Why this test exists (通信龙 2026-07-30 裁定): the warning is a pure
12
+ // hint — deleting it doesn't break any behavior, so it's exactly the
13
+ // kind of code a future refactor drops silently. This test is the
14
+ // tripwire: if the branch goes away, this test turns red.
15
+ //
16
+ // witnessed-red methodology (`feedback_witnessed_red_before_witnessed_green`):
17
+ // I confirmed both assertions fail cleanly when the `warning` branch is
18
+ // reverted (see docs/tests/p-498-reply-warning/witnessed-red.txt), so
19
+ // this suite genuinely catches regressions rather than green-when-empty.
20
+
21
+ const NET_ID = "net_498_warn";
22
+ const USER_ID = "u_498_warn";
23
+ const NODE_ID = "node_498_warn";
24
+ const AGENT_ALIAS = "peer-agent-498";
25
+ const AGENT_TOK_ID = "tok_498_warn";
26
+
27
+ interface ToolHandler {
28
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
29
+ }
30
+ interface Reply {
31
+ ok?: boolean;
32
+ message_id?: string;
33
+ session_status?: string;
34
+ warning?: string;
35
+ error?: string;
36
+ [k: string]: unknown;
37
+ }
38
+
39
+ function cleanup() {
40
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
41
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
42
+ try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
43
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
44
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
45
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
46
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
47
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
48
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
49
+ }
50
+
51
+ beforeEach(cleanup);
52
+ afterAll(cleanup);
53
+
54
+ function seed() {
55
+ db.run(
56
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
57
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
58
+ [USER_ID, USER_ID],
59
+ );
60
+ db.run(
61
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
62
+ VALUES (?1, ?2, ?3, datetime('now'))`,
63
+ [NET_ID, NET_ID, USER_ID],
64
+ );
65
+ db.run(
66
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
67
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
68
+ [USER_ID, NET_ID],
69
+ );
70
+ db.run(
71
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
72
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
73
+ [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
74
+ );
75
+ db.run(
76
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
77
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
78
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
79
+ );
80
+ db.run(
81
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
82
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
83
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
84
+ );
85
+ }
86
+
87
+ function makeTask(to: string): string {
88
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
89
+ db.run(
90
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
91
+ VALUES (?1, ?2, ?3, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?4)`,
92
+ [id, "dispatcher", to, NET_ID],
93
+ );
94
+ return id;
95
+ }
96
+
97
+ function buildHandlers(): Record<string, ToolHandler> {
98
+ const server = new McpServer({ name: "test", version: "0" }) as any;
99
+ const tools: Record<string, ToolHandler> = {};
100
+ const origTool = server.tool.bind(server);
101
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
102
+ tools[name] = handler;
103
+ return origTool(name, _desc, _schema, handler);
104
+ };
105
+ const origRegisterTool = server.registerTool?.bind(server);
106
+ if (origRegisterTool) {
107
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
108
+ tools[name] = handler;
109
+ return origRegisterTool(name, _cfg, handler);
110
+ };
111
+ }
112
+ // Bind as a network token whose caller alias is the sender.
113
+ registerTools(
114
+ server,
115
+ undefined,
116
+ /* enforceNetworkId */ NET_ID,
117
+ /* enforceUserId */ USER_ID,
118
+ /* callerAlias */ AGENT_ALIAS,
119
+ /* callerTokenIsNetwork */ true,
120
+ /* callerTokenId */ AGENT_TOK_ID,
121
+ );
122
+ return tools;
123
+ }
124
+
125
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
126
+ const r = await handler(args);
127
+ return JSON.parse(r.content[0].text) as Reply;
128
+ }
129
+
130
+ describe("send_reply warning field (#498)", () => {
131
+ test("target alias resolves to a real agent → response includes `warning` field", async () => {
132
+ seed();
133
+ const tools = buildHandlers();
134
+ const sendReply = tools["send_reply"];
135
+ expect(sendReply).toBeDefined();
136
+
137
+ const taskId = makeTask(AGENT_ALIAS);
138
+ const reply = await call(sendReply, {
139
+ alias: AGENT_ALIAS,
140
+ text: "peer reply (should trigger warning)",
141
+ in_reply_to: taskId,
142
+ status: "replied",
143
+ from_session: AGENT_ALIAS,
144
+ });
145
+
146
+ expect(reply.ok).toBe(true);
147
+ expect(reply.warning).toBeDefined();
148
+ expect(typeof reply.warning).toBe("string");
149
+ const w = reply.warning as string;
150
+ // Action-pointing content — witnessed-red methodology means each
151
+ // assertion here must be one a future revert would break, not a
152
+ // shape-only check that passes on empty string.
153
+ expect(w).toContain("commhub_send_task");
154
+ // Alias value is INLINED (not just "<alias>") so the LLM has a
155
+ // copy-pasteable next command. This is the specific design 通信龙
156
+ // called out on 2026-07-30.
157
+ expect(w).toContain(`alias="${AGENT_ALIAS}"`);
158
+ expect(w).toContain("agent peers");
159
+ expect(w).toContain("not see this reply in real time");
160
+ expect(w).toContain("RFC-030");
161
+ expect(w).toContain("全网规则");
162
+ });
163
+
164
+ test("target alias is `hub` (Dashboard path) → NO warning field", async () => {
165
+ seed();
166
+ const tools = buildHandlers();
167
+ const sendReply = tools["send_reply"];
168
+
169
+ const taskId = makeTask("hub");
170
+ const reply = await call(sendReply, {
171
+ alias: "hub",
172
+ text: "dashboard reply",
173
+ in_reply_to: taskId,
174
+ status: "replied",
175
+ from_session: AGENT_ALIAS,
176
+ });
177
+
178
+ expect(reply.ok).toBe(true);
179
+ // Silence-affirms the Dashboard path. Every reply carrying a
180
+ // warning would train the LLM to ignore it, so absence here is
181
+ // load-bearing.
182
+ expect(reply.warning).toBeUndefined();
183
+ });
184
+
185
+ test("target alias is `api` (Dashboard path) → NO warning field", async () => {
186
+ seed();
187
+ const tools = buildHandlers();
188
+ const sendReply = tools["send_reply"];
189
+
190
+ const taskId = makeTask("api");
191
+ const reply = await call(sendReply, {
192
+ alias: "api",
193
+ text: "dashboard reply via api",
194
+ in_reply_to: taskId,
195
+ status: "replied",
196
+ from_session: AGENT_ALIAS,
197
+ });
198
+
199
+ expect(reply.ok).toBe(true);
200
+ expect(reply.warning).toBeUndefined();
201
+ });
202
+ });