@sleep2agi/commhub-server 0.9.0-preview.145 → 0.9.0-preview.147

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.145",
3
+ "version": "0.9.0-preview.147",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,128 @@
1
+ // #1894 —— 组织架构的 MCP 面:Agent 用 API 就能建部门、把 Agent 归进去,
2
+ // 不用人手动点。这里跑的是真实的 in-process MCP transport(不是直接调函数),
3
+ // 因为要验的正是「工具真的注册上、参数真的能过 schema、权限真的按 caller 判」。
4
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { db } from "./db.js";
9
+ import { registerTools } from "./tools.js";
10
+
11
+ const NET = "net_dept_mcp";
12
+ const OWNER = "u_dept_mcp_owner";
13
+ const OTHER = "u_dept_mcp_other";
14
+ const AGENT_A = "n_dept_mcp_a";
15
+ const AGENT_B = "n_dept_mcp_b";
16
+
17
+ function cleanup() {
18
+ for (const [table, col] of [
19
+ ["audit_log", "network_id"], ["nodes", "network_id"], ["network_departments", "network_id"],
20
+ ["network_members", "network_id"], ["networks", "network_id"],
21
+ ] as const) {
22
+ try { db.run(`DELETE FROM ${table} WHERE ${col} = ?1`, [NET]); } catch {}
23
+ }
24
+ try { db.run("DELETE FROM users WHERE user_id IN (?1, ?2)", [OWNER, OTHER]); } catch {}
25
+ }
26
+
27
+ function seed() {
28
+ cleanup();
29
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [OWNER, OWNER]);
30
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [OTHER, OTHER]);
31
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))", [NET, NET, OWNER]);
32
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [OWNER, NET]);
33
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'member', datetime('now'))", [OTHER, NET]);
34
+ for (const [id, alias] of [[AGENT_A, "dept-agent-a"], [AGENT_B, "dept-agent-b"]] as const) {
35
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))", [id, alias, NET]);
36
+ }
37
+ }
38
+
39
+ async function connect(callerUserId: string, alias = "dept-agent-a") {
40
+ const server = new McpServer({ name: "dept-mcp-test", version: "1" });
41
+ registerTools(server, undefined, NET, callerUserId, alias, true, null);
42
+ const client = new Client({ name: "dept-mcp-client", version: "1" });
43
+ const [ct, st] = InMemoryTransport.createLinkedPair();
44
+ await server.connect(st);
45
+ await client.connect(ct);
46
+ return { client, server };
47
+ }
48
+
49
+ type Result = { content: Array<{ type: string; text?: string }>; isError?: boolean };
50
+ const json = (r: Result) => JSON.parse(String(r.content[0]?.text ?? "{}"));
51
+
52
+ beforeEach(seed);
53
+ afterAll(cleanup);
54
+
55
+ describe("#1894 departments over MCP", () => {
56
+ test("owner can list, create, assign and read back the counts", async () => {
57
+ const { client, server } = await connect(OWNER);
58
+ try {
59
+ const before = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
60
+ expect(before.ok).toBe(true);
61
+ expect(before.unassigned_agents).toBe(2);
62
+
63
+ const created = json(await client.callTool({
64
+ name: "departments", arguments: { action: "create", name: "TMAI", department_id: "dept_test_tmai" },
65
+ }) as Result);
66
+ expect(created.ok).toBe(true);
67
+ expect(created.department.id).toBe("dept_test_tmai");
68
+
69
+ const child = json(await client.callTool({
70
+ name: "departments", arguments: { action: "create", name: "TMAI-内容", parent_id: "dept_test_tmai" },
71
+ }) as Result);
72
+ expect(child.ok).toBe(true);
73
+ expect(child.department.parent_id).toBe("dept_test_tmai");
74
+
75
+ const assigned = json(await client.callTool({
76
+ name: "departments", arguments: { action: "assign_agent", alias: "dept-agent-a", department_id: "dept_test_tmai" },
77
+ }) as Result);
78
+ expect(assigned.ok).toBe(true);
79
+ expect(assigned.node_id).toBe(AGENT_A);
80
+
81
+ const after = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
82
+ expect(after.unassigned_agents).toBe(1);
83
+ const tmai = after.departments.find((d: any) => d.id === "dept_test_tmai");
84
+ expect(tmai.agent_count).toBe(1);
85
+ const agent = after.agents.find((a: any) => a.node_id === AGENT_A);
86
+ expect(agent.department_id).toBe("dept_test_tmai");
87
+
88
+ // 清空归属同样要能通过 API 做
89
+ const cleared = json(await client.callTool({
90
+ name: "departments", arguments: { action: "assign_agent", node_id: AGENT_A, department_id: null },
91
+ }) as Result);
92
+ expect(cleared.ok).toBe(true);
93
+ expect(cleared.department_id).toBeNull();
94
+ } finally {
95
+ await server.close();
96
+ }
97
+ });
98
+
99
+ test("a plain member cannot write departments", async () => {
100
+ const { client, server } = await connect(OTHER, "dept-agent-b");
101
+ try {
102
+ const read = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
103
+ expect(read.ok).toBe(true);
104
+ const created = json(await client.callTool({
105
+ name: "departments", arguments: { action: "create", name: "不该被建出来" },
106
+ }) as Result);
107
+ expect(created.ok).toBe(false);
108
+ const assigned = json(await client.callTool({
109
+ name: "departments", arguments: { action: "assign_agent", node_id: AGENT_B, department_id: null },
110
+ }) as Result);
111
+ expect(assigned.ok).toBe(false);
112
+ } finally {
113
+ await server.close();
114
+ }
115
+ });
116
+
117
+ test("deleting a department that still has agents is refused", async () => {
118
+ const { client, server } = await connect(OWNER);
119
+ try {
120
+ json(await client.callTool({ name: "departments", arguments: { action: "create", name: "B 站", department_id: "dept_test_b" } }) as Result);
121
+ json(await client.callTool({ name: "departments", arguments: { action: "assign_agent", node_id: AGENT_B, department_id: "dept_test_b" } }) as Result);
122
+ const refused = json(await client.callTool({ name: "departments", arguments: { action: "delete", department_id: "dept_test_b" } }) as Result);
123
+ expect(refused.ok).toBe(false);
124
+ } finally {
125
+ await server.close();
126
+ }
127
+ });
128
+ });
@@ -218,11 +218,14 @@ export function updateDepartment(networkId: string, id: string, body: Record<str
218
218
  }
219
219
 
220
220
  /** 只删空部门:有子部门 / 有成员 → 409(带数字,界面照着说)。 */
221
- export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number }) {
221
+ export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number; agents?: number }) {
222
222
  if (!one(networkId, id)) return fail(404, "department_not_found");
223
223
  const children = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id)?.n ?? 0;
224
224
  const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
225
- if (children || members) return { ...fail(409, "department_not_empty"), children, members };
225
+ // #1894 —— Agent 的部门归属是 nodes.department_id(显式归属线)。以前只数人类成员,
226
+ // 于是「部门里还有 300 个 Agent」也能被删掉,留下指向不存在部门的悬空归属。
227
+ const agents = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM nodes WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
228
+ if (children || members || agents) return { ...fail(409, "department_not_empty"), children, members, agents };
226
229
  // RFC-040:部门的项目授权(network_department_project_grants)和部门同一事务删掉。
227
230
  groupTx(() => {
228
231
  db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
@@ -640,3 +640,46 @@ describe("#506 cross-network isolation of /health sse_sessions (same alias, two
640
640
  expect(keys.some((k) => k.startsWith(`${aNet}:`))).toBe(false);
641
641
  });
642
642
  });
643
+
644
+ // #2149 —— 错误路径**不能回显凭据**。
645
+ //
646
+ // 背景:2026-10-23 TMA云鲸 对 CommHub MCP 做只读扫描时收到 502,代理生成的
647
+ // 非 JSON 错误页把本次请求的鉴权相关头回显在正文里。那个 502 页很可能来自
648
+ // 调用链上的代理(本仓没有 502 生成逻辑),但**这条防线必须在本仓也有**:
649
+ // 任何 4xx/5xx 的响应体都不该把调用方递上来的 `Authorization` / `Cookie` /
650
+ // query 里的 token 原样吐回去 —— 一旦有人写出这种错误文案,它就会进日志、
651
+ // 进回执、进诊断归档。
652
+ //
653
+ // 做法是**注入一个形状像真凭据的 canary**,然后打一圈错误路径,断言响应正文
654
+ // 里一次都不出现它。canary 用 `ntok_` 前缀,正是本仓 `redactTokens` 认得的形状。
655
+ describe("#2149 error paths never echo credentials", () => {
656
+ const CANARY = "ntok_LEAKCANARY_0123456789abcdef";
657
+ const COOKIE = "session=COOKIELEAKCANARY0123456789";
658
+
659
+ const paths: Array<{ method: string; path: string; body?: string }> = [
660
+ { method: "GET", path: "/api/nope-not-a-route" },
661
+ { method: "GET", path: "/api/requirements" },
662
+ { method: "GET", path: "/api/nodes" },
663
+ { method: "POST", path: "/mcp", body: "{not json" },
664
+ { method: "POST", path: "/api/auth/login", body: JSON.stringify({ username: "nobody", password: "wrong-password-Aa1!" }) },
665
+ { method: "POST", path: "/api/auth/me/avatar/upload" },
666
+ ];
667
+
668
+ test("no 4xx/5xx body contains the presented token, cookie or query secret", async () => {
669
+ for (const c of paths) {
670
+ const res = await fetch(`${BASE}${c.path}${c.path.includes("?") ? "&" : "?"}access_token=${CANARY}`, {
671
+ method: c.method,
672
+ headers: {
673
+ authorization: `Bearer ${CANARY}`,
674
+ cookie: COOKIE,
675
+ "content-type": "application/json",
676
+ },
677
+ body: c.body,
678
+ });
679
+ const text = await res.text();
680
+ expect(text.includes(CANARY)).toBe(false);
681
+ expect(text.includes("LEAKCANARY")).toBe(false);
682
+ expect(text.includes("COOKIELEAKCANARY")).toBe(false);
683
+ }
684
+ });
685
+ });
@@ -29,7 +29,11 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
29
29
  // Hub #938 clone_node is audience both (60 tools / 58,169 B). OpenCode V2
30
30
  // catalog adds read_opencode_catalog and get_opencode_catalog on that user
31
31
  // list. Measured together: 62 tools / 59,409 B. Ceiling keeps ~401 B.
32
- const TOOLS_LIST_MAX_BYTES = 59_810;
32
+ // #1894(2026-10-23):新增 `departments` 一个工具(组织架构的 list/create/update/
33
+ // delete/assign_agent 合并成一个,避免 5 个工具把上下文顶穿)。实测 63 个工具
34
+ // 60,089 B,比原上限高 279 B —— 这是**有意的功能增长**,不是描述悄悄变长,
35
+ // 所以显式抬到 60_500(留 411 B 余量)并在同一行写清理由。
36
+ const TOOLS_LIST_MAX_BYTES = 60_500;
33
37
  /** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
34
38
  const TOOL_MAX_BYTES = 9_000;
35
39
  /** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
@@ -269,6 +269,8 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
269
269
  broadcast: "broadcast",
270
270
  // 任务看板:在 requirements.ts 里按卡判(REST 与 MCP 同一处)
271
271
  requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
272
+ // #1894 —— 组织架构:读是常规读,写由工具内部再判网络 owner/admin(普通成员/节点写会被拒并 audit)。
273
+ departments: "requirements",
272
274
  requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
273
275
  requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
274
276
  // 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
@@ -312,13 +312,23 @@ describe("old clients unaffected", () => {
312
312
  });
313
313
 
314
314
  describe("scheduler re-checks the creating node as it is now", () => {
315
+ // #2115 —— `at` 原来是 `now - 5s - random(0..1000ms)`:两次调用只要「晚的毫秒差」正好等于两个
316
+ // 随机值之差,算出的 `at` 就完全相同,于是第二次按 `scheduled_for = at` 读到的是**上一轮那行**
317
+ // (于是 test638 L2 并发聚合偶发「期望 creator_node_gone、得到 creator_node_readonly」)。
318
+ // 注释里说「随机」的人以为碰撞概率是 1/1000,其实是「随机差 == 时间差」,慢机器上时间差几十毫秒
319
+ // 就落在这个窗口里。改成单调递推:每次减 1s,任何两次调用都不可能相等。
320
+ let dueSeq = 0;
315
321
  const dueAndRun = async (id: string) => {
316
- const at = new Date(Date.now() - 5_000 - Math.floor(Math.random() * 1000)).toISOString();
322
+ dueSeq += 1;
323
+ const at = new Date(Date.now() - 5_000 - dueSeq * 1_000).toISOString();
317
324
  db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [at, id]);
318
325
  const { runDueScheduledTasks } = await import("./scheduled-tasks.js");
319
326
  runDueScheduledTasks();
320
- return db.get<{ status: string; error_code: string | null; task_id: string | null }>(
321
- "SELECT status, error_code, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", id, at)!;
327
+ const row = db.get<{ status: string; error_code: string | null; task_id: string | null }>(
328
+ "SELECT status, error_code, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", id, at);
329
+ // 把「这一轮根本没跑出 run 行」和「跑出了但结论不对」分开报 —— 前者是夹具问题,后者才是行为问题。
330
+ expect(row).toBeTruthy();
331
+ return row!;
322
332
  };
323
333
  test("owner changes to a user not granted the target: send_task denied, and so is every scheduled run", async () => {
324
334
  const id = (await create("a", { target_node_id: node.b.id })).schedule.schedule_id;
package/src/server.ts CHANGED
@@ -99,7 +99,7 @@ import { handleSideThreadHttpRequest } from "./side-thread-http.js";
99
99
  import { createProductionSideThreadTransport } from "./side-thread-production.js";
100
100
  import { parseHubTimestamp } from "./hub-timestamp";
101
101
  import { logTaskRejection } from "./task-rejection-log.js";
102
- import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES } from "./user-avatar-image.js";
102
+ import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES, MAX_AVATAR_STORAGE_BYTES } from "./user-avatar-image.js";
103
103
 
104
104
  const PORT = resolvePort(process.env.PORT);
105
105
  const HOST = process.env.HOST || "127.0.0.1";
@@ -1553,6 +1553,14 @@ return Bun.serve({
1553
1553
  return withCors(req, Response.json({ ok: false, error: "invalid_avatar_body", reason: "expected exactly one `file` part" }, { status: 400 }));
1554
1554
  }
1555
1555
  if (file.size > MAX_AVATAR_BYTES) return withCors(req, Response.json({ ok: false, error: "too_large" }, { status: 413 }));
1556
+ // #2054 —— Hub-wide 总容量上界:公开注册可无限建账号,per-user 单条上限挡不住
1557
+ // 「轮换账号把主库撑大」。这里在**写入前**用当前总量 + 本次大小判一次;
1558
+ // 已存在的其他用户头像绝不删除(不回收别人的数据),只拒绝本次上传。
1559
+ const storedBytes = db.get<{ total: number }>("SELECT COALESCE(SUM(bytes), 0) AS total FROM user_avatar_uploads")?.total ?? 0;
1560
+ const ownBytes = db.get<{ bytes: number }>("SELECT bytes FROM user_avatar_uploads WHERE user_id = ?1", [resolved.user.user_id])?.bytes ?? 0;
1561
+ if (storedBytes - ownBytes + file.size > MAX_AVATAR_STORAGE_BYTES) {
1562
+ return withCors(req, Response.json({ ok: false, error: "avatar_storage_full" }, { status: 503 }));
1563
+ }
1556
1564
  const bytes = new Uint8Array(await file.arrayBuffer());
1557
1565
  const check = inspectAvatarImage(bytes);
1558
1566
  if (!check.ok) return withCors(req, Response.json({ ok: false, error: "invalid_image", reason: check.reason }, { status: 400 }));
@@ -207,7 +207,11 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
207
207
  // tools. Measured on this combined tree: all 95 / 85,069 B; node 62 / 63,281 B
208
208
  // unchanged (protocol tools stay off the node list); user 62 / 59,409 B.
209
209
  // Ceilings keep about 400 B and nothing else.
210
- const CEILINGS = { node: 63_700, user: 59_810, all: 85_500 } as const;
210
+ // #1894(2026-10-23):新增一个 `departments` MCP 工具(list/create/update/delete/
211
+ // assign_agent 合并成一个,尽量小)。实测 node 64,112 B / user 60,117 B,比原上限
212
+ // 分别高 412 / 279 B —— 这是有意的功能增长(组织架构可由 Agent 直接调),不是文案偷偷变长。
213
+ // 留约 400 B 余量;再要加工具时请先合并动作或砍描述,别连续抬上限。
214
+ const CEILINGS = { node: 64_500, user: 60_500, all: 86_500 } as const;
211
215
  test("node / user / all", async () => {
212
216
  const node = await listTools(T.node_normal);
213
217
  const user = await listTools(T.owner);
@@ -59,6 +59,8 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
59
59
  upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
60
60
  list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
61
61
  requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
62
+ // #1894 —— 组织架构:人和 Agent 都能用(Agent 用它归部门,人用它看树)。
63
+ departments: "both",
62
64
  requirements_update: "both", requirements_checklist_toggle: "both", requirements_upsert_by_external_ref: "both",
63
65
  requirements_events: "both", requirements_comment: "both", projects_list: "both",
64
66
  };
@@ -65,3 +65,30 @@ describe("#1756 tools/list", () => {
65
65
  } finally { await close(); }
66
66
  }, 20_000);
67
67
  });
68
+
69
+ // #1075 / #2662 —— `node_secrets_capable` 是**节点自述**的一格,运行时把它放在
70
+ // `config_snapshot` 里(agent-node/src/cli.ts:1894)。而 zod 对象默认**丢掉未声明
71
+ // 的键**:只要 `config_snapshot` 的形状里没有这一条,节点上报得再好,到 Hub 也被
72
+ // 静默剥掉 → 客户端「保存密钥」永远是灰的,日志里一个字都不会说。
73
+ //
74
+ // 所以钉在这里:`report_status.config_snapshot` 的形状里**必须有**这一格。
75
+ // 剥掉它的方式有很多种(重写对象、加 .strict()、换 z.record),这条断言对它们都红。
76
+ describe("#1075 node_secrets_capable survives report_status", () => {
77
+ test("config_snapshot's shape declares node_secrets_capable", async () => {
78
+ const { server, close } = await connect();
79
+ try {
80
+ const reg: Record<string, any> = (server as any)._registeredTools;
81
+ const shape = reg["report_status"]?.inputSchema?.shape ?? {};
82
+ const snapshotField = shape["config_snapshot"];
83
+ expect(snapshotField).toBeTruthy();
84
+ // config_snapshot 是 ZodOptional 包着 ZodObject:.unwrap() 之后才有 shape。
85
+ const unwrapped = (snapshotField as any)?.unwrap ? (snapshotField as any).unwrap() : snapshotField;
86
+ const inner = (unwrapped as any)?.shape ?? {};
87
+ expect(Object.keys(inner)).toContain("node_secrets_capable");
88
+ // 同族的另一格(daemon 级能力)也在这份形状里,顺手一起钉住。
89
+ expect(Object.keys(inner)).toContain("daemon_capabilities");
90
+ } finally {
91
+ await close();
92
+ }
93
+ });
94
+ });
package/src/tools.ts CHANGED
@@ -107,6 +107,7 @@ import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue
107
107
  import { parseHubTimestamp } from "./hub-timestamp";
108
108
  import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
109
109
  import { listTaskLateReplies } from "./task-late-replies.js";
110
+ import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setAgentDepartment, updateDepartment } from "./departments.js";
110
111
 
111
112
  function ts(): string {
112
113
  return new Date().toTimeString().slice(0, 8);
@@ -425,6 +426,96 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
425
426
  content: [{ type: "text" as const, text: JSON.stringify(value) }],
426
427
  });
427
428
 
429
+ // ── #1894 组织架构:Agent 用 API 就能建部门、把 Agent 归进去(admin 2026-10-23:
430
+ // 不要靠人手动点)。写权限 = 网络 owner/admin(实测 `canWrite` 对普通成员也是 true,
431
+ // 口径太宽,所以这里显式判角色);读权限 = 能读这个网络的人。
432
+ // 归属写的是 nodes.department_id,显式归属,绝不按负责人推断。
433
+ const deptReply = (value: Record<string, unknown>) => ({
434
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
435
+ });
436
+ const canManageDepartments = (netId: string | null): boolean => {
437
+ if (!netId) return false;
438
+ if (!enforceUserId) return canWrite(netId); // legacy open mode
439
+ const role = getUserNetworkRole(enforceUserId, netId);
440
+ return role === "owner" || role === "admin";
441
+ };
442
+ const deptFail = (result: any) =>
443
+ deptReply({ ok: false, error: result?.error ?? "department_failed", status: result?.status ?? 400, ...(result?.children || result?.members || result?.agents ? { children: result.children, members: result.members, agents: result.agents } : {}) });
444
+
445
+ server.tool(
446
+ "departments",
447
+ "Agent org structure: list / create / update / delete / assign_agent. Writes need network owner or admin; assign_agent sets nodes.department_id.",
448
+ {
449
+ action: z.enum(["list", "create", "update", "delete", "assign_agent"]),
450
+ department_id: z.string().max(64).nullable().optional(),
451
+ name: z.string().min(1).max(80).optional(),
452
+ parent_id: z.string().max(64).nullable().optional(),
453
+ node_id: z.string().max(200).optional(),
454
+ alias: z.string().max(200).optional(),
455
+ network_id: z.string().max(200).optional(),
456
+ },
457
+ async ({ action, department_id, name, parent_id, node_id, alias, network_id: clientNetId }) => {
458
+ const netId = getNetworkId(clientNetId);
459
+ if (!netId) return deptReply({ ok: false, error: "network_required" });
460
+ if (action === "list") {
461
+ if (!canManageDepartments(netId) && !canWriteHuman(netId)) return writeDeniedReply(netId, "read") as any;
462
+ const listed = listDepartments(netId);
463
+ const agents = db.all<{ node_id: string; alias: string; department_id: string | null }>(
464
+ "SELECT node_id, alias, department_id FROM nodes WHERE network_id = ?1 ORDER BY alias", netId,
465
+ );
466
+ const counts = new Map<string, number>();
467
+ let unassigned = 0;
468
+ for (const a of agents) {
469
+ if (a.department_id) counts.set(a.department_id, (counts.get(a.department_id) ?? 0) + 1);
470
+ else unassigned += 1;
471
+ }
472
+ return deptReply({
473
+ ok: true, network_id: netId, unassigned_agents: unassigned, agents,
474
+ departments: listed.departments.map((d) => ({ ...d, agent_count: counts.get(d.id) ?? 0 })),
475
+ });
476
+ }
477
+ if (!canManageDepartments(netId)) return writeDeniedReply(netId, "write") as any;
478
+ const actor = { user_id: enforceUserId || "node", username: callerAlias || "agent" };
479
+ if (action === "create") {
480
+ if (!name) return deptReply({ ok: false, error: "invalid_department_name" });
481
+ const result = createDepartment(netId, enforceUserId || callerAlias || "mcp", {
482
+ name, ...(parent_id ? { parent_id } : {}), ...(department_id ? { id: department_id } : {}),
483
+ }) as any;
484
+ if (result?.ok === false) return deptFail(result);
485
+ auditDepartment(actor, "department_create", netId, String(result.department?.id ?? ""));
486
+ return deptReply({ ok: true, department: result.department });
487
+ }
488
+ if (action === "update") {
489
+ if (!department_id) return deptReply({ ok: false, error: "department_required" });
490
+ const body: Record<string, unknown> = {};
491
+ if (name !== undefined) body.name = name;
492
+ if (parent_id !== undefined) body.parent_id = parent_id;
493
+ const result = updateDepartment(netId, department_id, body) as any;
494
+ if (result?.ok === false) return deptFail(result);
495
+ auditDepartment(actor, "department_update", netId, `id=${department_id}`);
496
+ return deptReply({ ok: true, department: result.department });
497
+ }
498
+ if (action === "delete") {
499
+ if (!department_id) return deptReply({ ok: false, error: "department_required" });
500
+ const result = deleteDepartment(netId, department_id) as any;
501
+ if (result?.ok === false) return deptFail(result);
502
+ auditDepartment(actor, "department_delete", netId, `id=${department_id}`);
503
+ return deptReply({ ok: true, deleted: department_id });
504
+ }
505
+ let target = node_id ?? "";
506
+ if (!target && alias) {
507
+ const row = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", netId, resolveCanonicalAlias(netId, alias).alias);
508
+ if (!row) return deptReply({ ok: false, error: "agent_not_found" });
509
+ target = row.node_id;
510
+ }
511
+ if (!target) return deptReply({ ok: false, error: "agent_required" });
512
+ const result = setAgentDepartment(netId, target, department_id === undefined ? null : department_id) as any;
513
+ if (result?.ok === false) return deptFail(result);
514
+ auditDepartment(actor, "department_assign_agent", netId, `${target} -> ${department_id ?? "null"}`);
515
+ return deptReply({ ok: true, node_id: result.node_id, department_id: result.department_id });
516
+ },
517
+ );
518
+
428
519
  // SkillHub is a network registry, not a public filesystem. Content is an
429
520
  // immutable SKILL.md snapshot; identity comes only from the authenticated
430
521
  // MCP principal (never from request fields).
@@ -10,6 +10,13 @@ import { inflateSync } from "node:zlib";
10
10
  // 校验强度:结构(签名/chunk/CRC)+ 真实解码(IDAT 解压且扫描行长度与 IHDR 相符)。
11
11
 
12
12
  export const MAX_AVATAR_BYTES = 8 * 1024 * 1024;
13
+
14
+ /**
15
+ * #2054 —— 单个 Hub 的头像总存储上界。公开注册可以持续创建账号,per-user 单条
16
+ * 上限(每人 1 条 × 8 MiB)挡不住轮换账号把主库撑大,所以再加一道总量边界。
17
+ * 超限时上传返回 503 `avatar_storage_full`;不回收/删除任何既有用户的头像。
18
+ */
19
+ export const MAX_AVATAR_STORAGE_BYTES = 1024 * 1024 * 1024;
13
20
  /** 客户端会把图重编码到最长边 512;服务端独立复核这条上限。 */
14
21
  export const MAX_AVATAR_EDGE = 512;
15
22
  /** version 只允许 URL 安全字符,长度有界(避免拼路径时引入 `.`、`/`)。 */
@@ -292,3 +292,57 @@ test("image inspector: signature, shape and size limits", () => {
292
292
  expect(inspectAvatarImage(jpeg)).toEqual({ ok: false, reason: "not_png" });
293
293
  expect(inspectAvatarImage(Buffer.alloc(MAX_AVATAR_BYTES + 1))).toEqual({ ok: false, reason: "too_large" });
294
294
  });
295
+
296
+ // #2054 —— Hub-wide 头像总容量上界:公开注册可无限建账号,per-user 单条上限挡不住
297
+ // 轮换账号把主库撑大。这里在库里塞一条接近上界的行,断言上传被 503 拒绝,
298
+ // 且**不删除**任何既有用户的头像(别人的数据不动),随后清掉该行恢复。
299
+ test("account avatar upload: a hub-wide storage cap rejects the write and keeps existing rows", async () => {
300
+ const { MAX_AVATAR_STORAGE_BYTES } = await import("./user-avatar-image.js");
301
+ const victim = register("avcap_alice", "AvatarCap1!");
302
+ const uploader = register("avcap_bob", "AvatarCap1!");
303
+ expect(victim.ok && uploader.ok).toBe(true);
304
+
305
+ const { bootServer } = await import("./server.js");
306
+ const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
307
+ const base = `http://127.0.0.1:${hub.port}`;
308
+ const upload = async (token: string, bytes: Buffer) => {
309
+ const form = new FormData();
310
+ form.append("file", new Blob([bytes], { type: "image/png" }), "a.png");
311
+ const res = await fetch(`${base}/api/auth/me/avatar/upload`, {
312
+ method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form,
313
+ });
314
+ return { status: res.status, body: (await res.json().catch(() => null)) as any };
315
+ };
316
+
317
+ const filler = Buffer.from("filler-avatar-bytes");
318
+ try {
319
+ // 先让 bob 正常上传一次,证明上界不是「谁都不能传」。
320
+ const ok = await upload(uploader.token!, PNG_1X1);
321
+ expect(ok.status).toBe(200);
322
+
323
+ // 塞一条「别人已经占满」的行:总量逼到上界之上。
324
+ db.run(
325
+ "INSERT INTO user_avatar_uploads (user_id, version, png, bytes, width, height, updated_at) VALUES (?1, ?2, ?3, ?4, 1, 1, datetime('now'))",
326
+ [victim.user!.user_id, "capver", filler, MAX_AVATAR_STORAGE_BYTES],
327
+ );
328
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_avatar_uploads")!.n;
329
+
330
+ const blocked = await upload(uploader.token!, PNG_1X1);
331
+ expect(blocked.status).toBe(503);
332
+ expect(blocked.body.error).toBe("avatar_storage_full");
333
+
334
+ // 不回收别人的数据:行数与那条 filler 都还在。
335
+ const after = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_avatar_uploads")!.n;
336
+ expect(after).toBe(before);
337
+ expect(db.get<any>("SELECT bytes FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id])?.bytes)
338
+ .toBe(MAX_AVATAR_STORAGE_BYTES);
339
+ // 自己的额度要扣掉:上限全算在自己头上时,替换**自己**的头像不应被拒。
340
+ db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id]);
341
+ db.run("UPDATE user_avatar_uploads SET bytes = ?1 WHERE user_id = ?2", [MAX_AVATAR_STORAGE_BYTES, uploader.user!.user_id]);
342
+ const replaced = await upload(uploader.token!, PNG_1X1);
343
+ expect(replaced.status).toBe(200);
344
+ } finally {
345
+ db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id]);
346
+ hub.stop?.();
347
+ }
348
+ });