@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70

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 (162) 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/agent-access.ts +267 -0
  8. package/src/agent-acl-http.test.ts +510 -0
  9. package/src/agent-acl-slice3-http.test.ts +267 -0
  10. package/src/alias-filter.test.ts +72 -0
  11. package/src/alias-filter.ts +32 -0
  12. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  13. package/src/api-host-supervisors-fallback.test.ts +271 -0
  14. package/src/api-nodes-shape.test.ts +26 -10
  15. package/src/auth-sessions-http.test.ts +250 -0
  16. package/src/auth-validate.test.ts +5 -1
  17. package/src/auth.ts +348 -29
  18. package/src/avatar-validate.test.ts +163 -0
  19. package/src/avatar-validate.ts +103 -0
  20. package/src/blocked-exit-on-reply.test.ts +122 -0
  21. package/src/boot-side-effect.test.ts +124 -0
  22. package/src/config-apply-sec1.test.ts +107 -0
  23. package/src/config-apply-validate.test.ts +92 -0
  24. package/src/config-apply-validate.ts +81 -3
  25. package/src/create-node-blocked-gate.test.ts +356 -0
  26. package/src/create-node-tool-schema.test.ts +39 -0
  27. package/src/create-node-validate.test.ts +89 -1
  28. package/src/create-node-validate.ts +89 -7
  29. package/src/create-node-workdir.test.ts +255 -0
  30. package/src/create-node.ts +51 -1
  31. package/src/dashboard-slash-routing-http.test.ts +107 -0
  32. package/src/db-adapter-guard.test.ts +85 -0
  33. package/src/db-adapter.ts +64 -23
  34. package/src/db-timestamp.test.ts +38 -0
  35. package/src/db-timestamp.ts +15 -0
  36. package/src/db.ts +762 -33
  37. package/src/delivered-without-push.test.ts +140 -0
  38. package/src/desktop-message-seam.test.ts +150 -0
  39. package/src/desktop-message.test.ts +368 -0
  40. package/src/ext-token-shared.test.ts +60 -0
  41. package/src/ext-validation-regression.test.ts +131 -0
  42. package/src/external-schedule-edits-http.test.ts +321 -0
  43. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  44. package/src/external-schedule-edits.ts +327 -0
  45. package/src/file-download-authz-dev-open.test.ts +135 -0
  46. package/src/file-download-authz.test.ts +404 -0
  47. package/src/file-download-date-bucket.test.ts +436 -0
  48. package/src/file-network-scope-dev-open.test.ts +97 -0
  49. package/src/file-network-scope.test.ts +732 -0
  50. package/src/health-redaction.test.ts +642 -0
  51. package/src/http-gzip.test.ts +73 -0
  52. package/src/http-gzip.ts +60 -0
  53. package/src/hub-timestamp.test.ts +38 -0
  54. package/src/hub-timestamp.ts +27 -0
  55. package/src/human-dm.ts +149 -0
  56. package/src/inbox-count-sse.test.ts +310 -0
  57. package/src/inbox-count.ts +29 -0
  58. package/src/index.ts +15 -2331
  59. package/src/lifecycle-guard.ts +50 -0
  60. package/src/mcp-write-network-resolution.test.ts +429 -0
  61. package/src/messages-ack-by-agent-http.test.ts +142 -0
  62. package/src/network-name-http.test.ts +75 -0
  63. package/src/network-scope.ts +224 -0
  64. package/src/node-attrs-validate.ts +88 -0
  65. package/src/node-attrs.test.ts +217 -0
  66. package/src/node-create-request-status-http.test.ts +92 -0
  67. package/src/node-files-transport.test.ts +344 -0
  68. package/src/node-id-alias.test.ts +188 -0
  69. package/src/node-lifecycle-controllable.test.ts +64 -0
  70. package/src/node-lifecycle-controllable.ts +51 -0
  71. package/src/node-logs-transport.test.ts +263 -0
  72. package/src/node-request-retention.test.ts +281 -0
  73. package/src/node-request-retention.ts +120 -0
  74. package/src/node-skills-transport.test.ts +209 -0
  75. package/src/node-token-privilege-http.test.ts +149 -0
  76. package/src/observer-avatar-http.test.ts +429 -0
  77. package/src/observer-push.test.ts +252 -0
  78. package/src/package-version-consistency.test.ts +25 -0
  79. package/src/peer-reply-atomic.test.ts +455 -0
  80. package/src/peer-reply-capability.test.ts +70 -0
  81. package/src/push.ts +204 -7
  82. package/src/redact-tokens.test.ts +65 -0
  83. package/src/redact-tokens.ts +40 -0
  84. package/src/rename.ts +7 -2
  85. package/src/reply-target-derivation.test.ts +96 -0
  86. package/src/report-status-host-schema.test.ts +211 -0
  87. package/src/report-status-resume-id-handover.test.ts +150 -0
  88. package/src/require-explicit-test-db.ts +9 -0
  89. package/src/requirement-tags.ts +16 -0
  90. package/src/requirements-http.test.ts +708 -0
  91. package/src/requirements-mcp.test.ts +142 -0
  92. package/src/requirements-migrate.ts +83 -0
  93. package/src/requirements-priority-lowest.test.ts +114 -0
  94. package/src/requirements-upgrade-e2e.test.ts +126 -0
  95. package/src/requirements-upgrade-migration.test.ts +85 -0
  96. package/src/requirements.ts +865 -0
  97. package/src/resolve-port.test.ts +57 -0
  98. package/src/resolve-port.ts +44 -0
  99. package/src/response-charset.test.ts +130 -0
  100. package/src/rest-explicit-columns-http.test.ts +216 -0
  101. package/src/rest-identity.test.ts +129 -0
  102. package/src/rest-identity.ts +100 -0
  103. package/src/rest-projections.ts +68 -0
  104. package/src/rest-write-network-resolution.test.ts +133 -0
  105. package/src/restricted-files.ts +73 -0
  106. package/src/retention.ts +10 -1
  107. package/src/rules-file-duplicate-alias.test.ts +234 -0
  108. package/src/rules-file-session-target.test.ts +207 -0
  109. package/src/rules-file-transport.test.ts +237 -0
  110. package/src/runtime-label.test.ts +22 -0
  111. package/src/runtime-label.ts +21 -0
  112. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  113. package/src/scheduled-run-terminal.test.ts +268 -0
  114. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  115. package/src/scheduled-tasks-http.test.ts +574 -0
  116. package/src/scheduled-tasks.ts +596 -0
  117. package/src/send-dedup-request-id.test.ts +229 -0
  118. package/src/send-reply-agent-warning.test.ts +202 -0
  119. package/src/send-reply-alias-optional.test.ts +131 -0
  120. package/src/send-reply-attachments.test.ts +458 -0
  121. package/src/send_dedup.test.ts +54 -0
  122. package/src/send_dedup.ts +52 -6
  123. package/src/serve-error.test.ts +38 -0
  124. package/src/serve-error.ts +27 -0
  125. package/src/server.ts +4279 -0
  126. package/src/shared/external-schedule-contract.ts +60 -0
  127. package/src/side-thread-command-transport.test.ts +302 -0
  128. package/src/side-thread-command-transport.ts +318 -0
  129. package/src/side-thread-http-integration.test.ts +233 -0
  130. package/src/side-thread-http.ts +504 -0
  131. package/src/side-thread-production.test.ts +72 -0
  132. package/src/side-thread-production.ts +118 -0
  133. package/src/side-thread.test.ts +1067 -0
  134. package/src/side-thread.ts +2495 -0
  135. package/src/skillhub-http.test.ts +92 -0
  136. package/src/start-node.test.ts +109 -0
  137. package/src/stop-delete-node.test.ts +1164 -0
  138. package/src/task-auth-origin-http.test.ts +91 -0
  139. package/src/task-auth-origin.test.ts +24 -0
  140. package/src/task-auth-origin.ts +7 -0
  141. package/src/task-consumption.test.ts +482 -0
  142. package/src/task-diagnostic-http.test.ts +149 -0
  143. package/src/task-diagnostic.test.ts +115 -0
  144. package/src/task-diagnostic.ts +118 -0
  145. package/src/task-idempotency-mcp.test.ts +142 -0
  146. package/src/task-idempotency.test.ts +60 -0
  147. package/src/task-idempotency.ts +75 -0
  148. package/src/task-lifecycle-watcher.test.ts +204 -0
  149. package/src/task-lifecycle-watcher.ts +49 -0
  150. package/src/task-network-resolution-http.test.ts +141 -0
  151. package/src/tasks-pagination-http.test.ts +141 -0
  152. package/src/tools-list-json-schema.test.ts +66 -0
  153. package/src/tools.ts +3445 -257
  154. package/src/update-provider.test.ts +65 -0
  155. package/src/upload-early-reject-guard.test.ts +155 -0
  156. package/src/uploads-http.test.ts +97 -15
  157. package/src/uploads.test.ts +66 -0
  158. package/src/uploads.ts +130 -5
  159. package/src/user-inbox-by-agent-http.test.ts +161 -0
  160. package/src/user-inbox-netid-notnull.test.ts +122 -0
  161. package/src/user-inbox-read-http.test.ts +223 -0
  162. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,267 @@
1
+ // 多用户账号与 Agent 权限 —— slice 3:剩下的面向 Agent 的路径。
2
+ //
3
+ // slice 1(agent-acl-http.test.ts)把默认作用域做成 fail-closed,并对核心路径逐条放行。
4
+ // 这里补:授权后 task_events / messages 的「只看自己往来」、改名广播不外泄 alias、
5
+ // 排程在建的人被收权后不再代派、用户名与后注册的 Agent alias 撞名、以及
6
+ // rules / skills / files / logs 对受限成员(即使授权了)仍然拒绝 —— 这是一个明确的产品决定:
7
+ // 授权 = 能看见、能对话;不等于能管理这个 Agent。
8
+
9
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "fs";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+ import { register, addNetworkMember } from "./auth.js";
14
+ import { db } from "./db.js";
15
+ import { replaceAgentGrants } from "./agent-access.js";
16
+ import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
17
+
18
+ const DIR = mkdtempSync(join(tmpdir(), "anet-agent-acl3-"));
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ const PW = "AclSlice3Passw0rd!";
22
+ let adminToken = "", adminId = "", adminName = "", NET = "";
23
+ let carolToken = "", carolId = "";
24
+
25
+ const X = { alias: "acl3-agent-x", node: "node_acl3_x" };
26
+ const Z = { alias: "acl3-agent-z", node: "node_acl3_z" };
27
+ const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
28
+
29
+ async function get(token: string, path: string) {
30
+ const res = await fetch(`${BASE}${path}`, { headers: auth(token) });
31
+ return { status: res.status, body: await res.json().catch(() => null) as any };
32
+ }
33
+ async function send(token: string, method: string, path: string, payload?: unknown) {
34
+ const res = await fetch(`${BASE}${path}`, { method, headers: { ...auth(token), "Content-Type": "application/json" }, body: payload === undefined ? undefined : JSON.stringify(payload) });
35
+ return { status: res.status, body: await res.json().catch(() => null) as any };
36
+ }
37
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
38
+ const res = await fetch(`${BASE}/mcp`, {
39
+ method: "POST",
40
+ headers: { ...auth(token), "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
41
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
42
+ });
43
+ const raw = await res.text();
44
+ const data = raw.split("\n").filter(x => x.startsWith("data:"));
45
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
46
+ const text = payload.result.content[0].text;
47
+ // 参数没过 zod 的调用根本没进到处理函数(也就没进到权限判定),当成测试写错了而不是「被拒」。
48
+ if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
49
+ return JSON.parse(text);
50
+ }
51
+ function seedAgent(a: { alias: string; node: string }) {
52
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [a.node, a.alias, NET, adminId]);
53
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`, [`r_${a.node}`, a.alias, a.node, NET]);
54
+ }
55
+ function seedTask(id: string, from: string, to: string, content: string) {
56
+ db.run(`INSERT INTO tasks (task_id, from_name, to_name, status, content, network_id) VALUES (?1, ?2, ?3, 'replied', ?4, ?5)`, [id, from, to, content, NET]);
57
+ db.run(`INSERT INTO inbox (id, task_id, session_name, type, priority, content, from_session, network_id) VALUES (?1, ?2, ?3, 'task', 'normal', ?4, ?5, ?6)`, [`ib_${id}`, id, to, content, from, NET]);
58
+ db.run(`INSERT INTO task_events (task_id, to_status, actor, network_id) VALUES (?1, 'replied', 'test', ?2)`, [id, NET]);
59
+ }
60
+ async function openStream(path: string, token: string) {
61
+ const ctrl = new AbortController();
62
+ const res = await fetch(`${BASE}${path}`, { headers: auth(token), signal: ctrl.signal });
63
+ const reader = res.body!.getReader();
64
+ const decoder = new TextDecoder();
65
+ let buf = "";
66
+ let pending: ReturnType<typeof reader.read> | null = null;
67
+ const readFor = async (ms: number) => {
68
+ const until = Date.now() + ms;
69
+ while (Date.now() < until) {
70
+ pending ??= reader.read();
71
+ const chunk = await Promise.race([pending, new Promise<null>(r => setTimeout(() => r(null), until - Date.now()))]);
72
+ if (!chunk) break;
73
+ pending = null;
74
+ if (chunk.done) break;
75
+ buf += decoder.decode(chunk.value);
76
+ }
77
+ };
78
+ return { status: res.status, readFor, events: () => buf.split("\n").filter(l => l.startsWith("data:")).map(l => JSON.parse(l.slice(5))), close: () => ctrl.abort() };
79
+ }
80
+
81
+ beforeAll(async () => {
82
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
83
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
84
+ process.env.HOST = "127.0.0.1";
85
+ adminName = `acl3_admin_${Date.now()}`;
86
+ const admin = register(adminName, PW);
87
+ adminToken = admin.token!; adminId = admin.user!.user_id; NET = admin.network_id!;
88
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
89
+ seedAgent(X); seedAgent(Z);
90
+ const carol = register("acl3_carol", PW);
91
+ carolToken = carol.token!; carolId = carol.user!.user_id;
92
+ expect(addNetworkMember(NET, carolId, "member", adminId).ok).toBe(true);
93
+ expect(replaceAgentGrants({ networkId: NET, userId: carolId, grants: [{ node_id: X.node }], actorUserId: adminId }).ok).toBe(true);
94
+ seedTask("t3_admin_x", adminName, X.alias, "admin to X");
95
+ seedTask("t3_carol_x", "acl3_carol", X.alias, "carol to X");
96
+ seedTask("t3_x_carol", X.alias, "acl3_carol", "X to carol");
97
+ const mod: any = await import("./server.js");
98
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
99
+ BASE = `http://127.0.0.1:${hub.port}`;
100
+ }, 30_000);
101
+
102
+ afterAll(() => {
103
+ try { hub?.stop?.(true); } catch {}
104
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
105
+ });
106
+
107
+ describe("granted member: task_events and messages are limited to own traffic", () => {
108
+ test("task_events: own exchange with X visible, admin's task to X not", async () => {
109
+ const mine = await get(carolToken, `/api/task_events?task_id=t3_carol_x`);
110
+ expect(mine.body.events.length).toBe(1);
111
+ expect((await get(carolToken, `/api/task_events?task_id=t3_x_carol`)).body.events.length).toBe(1);
112
+ expect((await get(carolToken, `/api/task_events?task_id=t3_admin_x`)).body.events).toEqual([]);
113
+ const all = await get(carolToken, `/api/task_events?network_id=${NET}`);
114
+ expect(all.body.events.map((e: any) => e.task_id).sort()).toEqual(["t3_carol_x", "t3_x_carol"]);
115
+ });
116
+ test("messages (alias branch): own inbox rows with X only; admin's never", async () => {
117
+ const r = await get(carolToken, `/api/messages?network_id=${NET}&since=2000-01-01`);
118
+ expect(r.body.messages.map((m: any) => m.content).sort()).toEqual(["X to carol", "carol to X"].sort());
119
+ const x = await get(carolToken, `/api/messages?alias=${X.alias}&since=2000-01-01`);
120
+ expect(x.body.messages.map((m: any) => m.content)).toEqual(["carol to X"]);
121
+ expect(x.body.pending_count).toBe(1);
122
+ });
123
+ });
124
+
125
+ describe("decision: granted ≠ administer — rules / skills / files / logs / config stay refused", () => {
126
+ test("every read and write admin op on the GRANTED agent is refused", async () => {
127
+ for (const [name, args] of [
128
+ ["read_node_rules_file", { alias: X.alias, network_id: NET }],
129
+ ["write_node_rules_file", { alias: X.alias, content: "x", network_id: NET }],
130
+ ["list_node_skills", { alias: X.alias, network_id: NET }],
131
+ ["list_node_files", { alias: X.alias, network_id: NET }],
132
+ ["tail_node_logs", { alias: X.alias, network_id: NET }],
133
+ ["update_node_config", { node_id: X.node, base_revision: 0, patch: { model: "m" }, network_id: NET }],
134
+ ["restart_node", { node_id: X.node, network_id: NET }],
135
+ ] as const) {
136
+ expect((await tool(carolToken, name, args as any)).error).toBe("agent_access_restricted");
137
+ }
138
+ expect((await send(carolToken, "PUT", `/api/nodes/${X.node}/attrs?network_id=${NET}`, { display_name: "pwned" })).status).toBeGreaterThanOrEqual(403);
139
+ expect(db.get<{ display_name: string | null }>("SELECT display_name FROM nodes WHERE node_id = ?1", X.node)?.display_name ?? null).toBeNull();
140
+ });
141
+ });
142
+
143
+ describe("rename broadcast does not leak aliases to restricted members", () => {
144
+ test("renaming Z (not granted) sends nothing to carol; renaming X (granted) does", async () => {
145
+ const s = await openStream(`/events/acl3_carol?network_id=${NET}`, carolToken);
146
+ expect(s.status).toBe(200);
147
+ await s.readFor(100);
148
+ const renameTo = async (oldAlias: string, newAlias: string) => {
149
+ const p = await send(adminToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: oldAlias, new_alias: newAlias });
150
+ expect(p.body.ok).toBe(true);
151
+ expect((await send(adminToken, "POST", "/api/node-rename/commit", { txn_id: p.body.txn_id })).body.ok).toBe(true);
152
+ };
153
+ await renameTo(Z.alias, "acl3-agent-z2");
154
+ await renameTo(X.alias, "acl3-agent-x2");
155
+ await s.readFor(400);
156
+ s.close();
157
+ const renamed = s.events().filter(e => e.type === "node.renamed").map(e => e.data.new_alias);
158
+ expect(renamed).toEqual(["acl3-agent-x2"]);
159
+ // 改名后授权(按 node_id)仍然有效。
160
+ expect((await get(carolToken, `/api/status?network_id=${NET}`)).body.sessions.map((r: any) => r.alias)).toEqual(["acl3-agent-x2"]);
161
+ await renameTo("acl3-agent-z2", Z.alias);
162
+ await renameTo("acl3-agent-x2", X.alias);
163
+ });
164
+ });
165
+
166
+ describe("schedules created before a restriction stop dispatching", () => {
167
+ function schedule(createdBy: string, target: { alias: string; node: string }) {
168
+ const id = `sched_acl3_${createdBy}_${target.node}`;
169
+ db.run(
170
+ `INSERT INTO scheduled_tasks (schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, schedule_type, schedule_json, next_run_at)
171
+ VALUES (?1, ?2, ?3, 'n', ?4, ?5, 'scheduled work', 'interval', '{"type":"interval","minutes":60}', datetime('now'))`,
172
+ [id, NET, createdBy, target.node, target.alias],
173
+ );
174
+ return db.get<any>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", id);
175
+ }
176
+ test("carol (restricted) → Z (not granted): run fails creator_access_revoked, no task created", () => {
177
+ const row = schedule(carolId, Z);
178
+ const r = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
179
+ expect(r.status).toBe("failed");
180
+ expect(r.taskId).toBeUndefined();
181
+ expect(db.get<{ error_code: string }>("SELECT error_code FROM scheduled_task_runs WHERE run_id = ?1", r.runId)?.error_code).toBe("creator_access_revoked");
182
+ });
183
+ test("carol → X (granted, can_message): dispatches", () => {
184
+ const r = dispatchScheduledOccurrence(schedule(carolId, X), new Date(Date.now() + 1000).toISOString(), false);
185
+ expect(r.taskId).toBeTruthy();
186
+ });
187
+ test("owner-created schedules are unaffected", () => {
188
+ const r = dispatchScheduledOccurrence(schedule(adminId, Z), new Date(Date.now() + 2000).toISOString(), false);
189
+ expect(r.taskId).toBeTruthy();
190
+ });
191
+ });
192
+
193
+ describe("username collides with an agent alias registered later", () => {
194
+ test("carol's own channel and own traffic close for that network", async () => {
195
+ expect((await get(carolToken, `/api/tasks?network_id=${NET}`)).body.tasks.length).toBeGreaterThan(0);
196
+ db.run(`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at) VALUES ('r_collide', 'acl3_carol', 'idle', ?1, datetime('now'), datetime('now'))`, [NET]);
197
+ const s = await fetch(`${BASE}/events/acl3_carol?network_id=${NET}`, { headers: auth(carolToken) });
198
+ expect(s.status).toBe(403);
199
+ await s.body?.cancel();
200
+ expect((await get(carolToken, `/api/tasks?network_id=${NET}`)).body.tasks).toEqual([]);
201
+ expect((await get(carolToken, `/api/messages?network_id=${NET}&since=2000-01-01`)).body.messages).toEqual([]);
202
+ db.run("DELETE FROM sessions WHERE resume_id = 'r_collide'");
203
+ expect((await get(carolToken, `/api/tasks?network_id=${NET}`)).body.tasks.length).toBeGreaterThan(0);
204
+ });
205
+ });
206
+
207
+ describe("human ↔ human DM (/api/dm): restricted members can talk to people", () => {
208
+ let daveToken = "", daveId = "";
209
+ test("carol (restricted) DMs dave; dave sees it in thread + threads + user inbox and replies", async () => {
210
+ const dave = register("acl3_dave", PW);
211
+ daveToken = dave.token!; daveId = dave.user!.user_id;
212
+ expect(addNetworkMember(NET, daveId, "member", adminId).ok).toBe(true);
213
+ const sent = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "hi dave", client_request_id: "c1" });
214
+ expect(sent.status).toBe(200);
215
+ expect(sent.body.message.sender_user_id).toBe(carolId);
216
+ // 重试同一个气泡不产生第二条。
217
+ expect((await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "hi dave", client_request_id: "c1" })).body.message.message_id).toBe(sent.body.message.message_id);
218
+ const threads = await get(daveToken, `/api/dm/threads?network_id=${NET}`);
219
+ expect(threads.body.threads).toEqual([expect.objectContaining({ other_user_id: carolId, unread: 1 })]);
220
+ expect((await get(daveToken, `/api/messages?scope=user&network_id=${NET}`)).body.messages.map((m: any) => m.content)).toContain("hi dave");
221
+ expect((await send(daveToken, "POST", "/api/dm", { network_id: NET, to_username: "acl3_carol", message: "hi carol" })).status).toBe(200);
222
+ const thread = await get(carolToken, `/api/dm?network_id=${NET}&with=${daveId}`);
223
+ expect(thread.body.messages.map((m: any) => [m.direction, m.content])).toEqual([["in", "hi carol"], ["out", "hi dave"]]);
224
+ // 私信不进按 Agent 的未读(发信人不是 Agent)。
225
+ const inbox = await get(daveToken, `/api/messages?scope=user&network_id=${NET}`);
226
+ expect(inbox.body.unread_by_agent["acl3_carol"]).toBeUndefined();
227
+ const daveThread = await get(daveToken, `/api/dm?network_id=${NET}&with=${carolId}`);
228
+ expect(daveThread.body.messages.map((m: any) => m.direction)).toEqual(["out", "in"]);
229
+ });
230
+ test("sender identity comes from the token, not from the body", async () => {
231
+ await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "who am i", from: adminName, sender_user_id: adminId });
232
+ const row = db.get<{ sender_user_id: string; from_session: string }>("SELECT sender_user_id, from_session FROM user_inbox WHERE content = 'who am i'");
233
+ expect(row).toEqual({ sender_user_id: carolId, from_session: "acl3_carol" });
234
+ });
235
+ test("a third party cannot read someone else's thread", async () => {
236
+ const eve = register("acl3_eve", PW);
237
+ expect(addNetworkMember(NET, eve.user!.user_id, "member", adminId).ok).toBe(true);
238
+ // eve 查「她和 dave」的会话:空(carol↔dave 的不会出现)。
239
+ expect((await get(eve.token!, `/api/dm?network_id=${NET}&with=${daveId}`)).body.messages).toEqual([]);
240
+ expect((await get(eve.token!, `/api/dm/threads?network_id=${NET}`)).body.threads).toEqual([]);
241
+ });
242
+ test("non-members and outsiders are refused; unknown and outside users look the same", async () => {
243
+ const out = register("acl3_out", PW);
244
+ expect((await send(out.token!, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "x" })).status).toBe(403);
245
+ expect((await get(out.token!, `/api/dm/threads?network_id=${NET}`)).status).toBe(403);
246
+ const a = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: out.user!.user_id, message: "x" });
247
+ const b = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: "u_nobody", message: "x" });
248
+ expect(a).toEqual(b);
249
+ expect(a.status).toBe(404);
250
+ });
251
+ test("a restricted member cannot smuggle a file they cannot see into a DM", async () => {
252
+ const form = new FormData();
253
+ form.append("file", new Blob([new Uint8Array([1, 2, 3])]), "a.bin");
254
+ const up = await fetch(`${BASE}/api/upload?network_id=${NET}`, { method: "POST", body: form, headers: auth(adminToken) });
255
+ const fid = (await up.json()).file_id;
256
+ const r = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "f", attachments: [{ type: "file", file_id: fid }] });
257
+ expect(r.status).toBe(403);
258
+ expect(r.body.error).toBe("attachment_not_accessible");
259
+ // 管理员(不受限)可以附带本网络文件发给 carol;carol 因此能下载它。
260
+ expect((await send(adminToken, "POST", "/api/dm", { network_id: NET, to_user_id: carolId, message: "file for you", attachments: [{ type: "file", file_id: fid }] })).status).toBe(200);
261
+ expect((await fetch(`${BASE}/api/files/${fid}`, { headers: auth(carolToken) })).status).toBe(200);
262
+ });
263
+ test("node tokens cannot use the DM API", async () => {
264
+ const r = await fetch(`${BASE}/api/dm/threads?network_id=${NET}`, { headers: { Authorization: "Bearer ntok_x" } });
265
+ expect(r.status).toBeGreaterThanOrEqual(401);
266
+ });
267
+ });
@@ -0,0 +1,72 @@
1
+ import { expect, test } from "bun:test";
2
+ import { parseAliasFilter } from "./alias-filter";
3
+
4
+ test("no filter given means no filtering, not an empty match", () => {
5
+ for (const raw of [undefined, null, "", " ", ",", " , , "]) {
6
+ const f = parseAliasFilter(raw as any);
7
+ expect(f.aliases).toEqual([]);
8
+ expect(f.sql).toBe("");
9
+ }
10
+ });
11
+
12
+ test("a single alias produces one placeholder", () => {
13
+ const f = parseAliasFilter("节点甲");
14
+ expect(f.aliases).toEqual(["节点甲"]);
15
+ expect(f.sql).toBe(" AND alias IN (?)");
16
+ });
17
+
18
+ test("several aliases keep their order and count", () => {
19
+ const f = parseAliasFilter("A站内容,A站内容牛,hub");
20
+ expect(f.aliases).toEqual(["A站内容", "A站内容牛", "hub"]);
21
+ expect(f.sql).toBe(" AND alias IN (?,?,?)");
22
+ });
23
+
24
+ test("surrounding whitespace is trimmed", () => {
25
+ expect(parseAliasFilter(" a , b ").aliases).toEqual(["a", "b"]);
26
+ });
27
+
28
+ // The point of the module. A trailing comma must not become `alias = ''`,
29
+ // which matches nothing and reads exactly like "those nodes do not exist".
30
+ test("blank entries are dropped, never turned into a match-nothing term", () => {
31
+ const f = parseAliasFilter("a,,b,");
32
+ expect(f.aliases).toEqual(["a", "b"]);
33
+ expect(f.sql).toBe(" AND alias IN (?,?)");
34
+ expect(f.aliases).not.toContain("");
35
+ });
36
+
37
+ test("a filter of only commas is the same as no filter — it must not silently match zero rows", () => {
38
+ const f = parseAliasFilter(",,,");
39
+ expect(f.sql).toBe("");
40
+ });
41
+
42
+ test("placeholder count always equals alias count, so params can never misalign", () => {
43
+ for (const raw of ["a", "a,b", "a,,b", " a , b , c ", ",x,"]) {
44
+ const f = parseAliasFilter(raw);
45
+ expect((f.sql.match(/\?/g) ?? []).length).toBe(f.aliases.length);
46
+ }
47
+ });
48
+
49
+ test("aliases are passed through verbatim — no globbing, no case folding", () => {
50
+ const f = parseAliasFilter("A站内容,a站内容");
51
+ expect(f.aliases).toEqual(["A站内容", "a站内容"]);
52
+ expect(f.sql).not.toContain("LIKE");
53
+ });
54
+
55
+ // Wiring: the tool must actually use this module, and must say what its
56
+ // `summary` counted — a caller who asked about three aliases and gets back
57
+ // "idle: 96" can easily read the 96 as being about their three.
58
+ import { readFileSync } from "fs";
59
+ import { join } from "path";
60
+
61
+ test("get_all_status uses parseAliasFilter and declares what summary counted", () => {
62
+ const source = readFileSync(join(import.meta.dir, "tools.ts"), "utf8");
63
+ const a = source.indexOf('"get_all_status"');
64
+ expect(a).toBeGreaterThan(-1);
65
+ const body = source.slice(a, source.indexOf('server.tool(', a + 10));
66
+ expect(body).toContain("filter_alias");
67
+ expect(body).toContain("parseAliasFilter(filter_alias)");
68
+ expect(body).toContain("summary_scope");
69
+ expect(body).toContain("sessions_returned");
70
+ // The alias list must go through parameters, never be interpolated.
71
+ expect(body).not.toMatch(/alias IN \(\$\{/);
72
+ });
@@ -0,0 +1,32 @@
1
+ // Parse the `filter_alias` argument of `get_all_status` into an exact-match
2
+ // IN list.
3
+ //
4
+ // Why this exists at all: on a 222-session hub the unfiltered response is about
5
+ // 259 KB — past what an MCP client accepts in one result. A caller who wanted
6
+ // the status of three specific nodes could not get it from the tool, and had to
7
+ // go around it to the REST API. The filter is the fix; this module is the part
8
+ // of it worth pinning, because the interesting behaviour is what happens to the
9
+ // inputs that are not a clean alias.
10
+ //
11
+ // Blank entries are DROPPED rather than matched. A trailing comma would
12
+ // otherwise produce `alias = ''`, which matches no row — and "no rows" reads
13
+ // exactly like "those nodes do not exist". The failure and the true answer
14
+ // would be indistinguishable to the caller, which is the thing to avoid.
15
+
16
+ export interface AliasFilter {
17
+ /** Exact aliases to match. Empty means "no alias filtering". */
18
+ aliases: string[];
19
+ /** SQL fragment to append, or "" when there is nothing to filter on. */
20
+ sql: string;
21
+ }
22
+
23
+ export function parseAliasFilter(raw: string | undefined | null): AliasFilter {
24
+ const aliases = (raw ?? "")
25
+ .split(",")
26
+ .map(a => a.trim())
27
+ .filter(Boolean);
28
+ return {
29
+ aliases,
30
+ sql: aliases.length > 0 ? ` AND alias IN (${aliases.map(() => "?").join(",")})` : "",
31
+ };
32
+ }