@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

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 (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,659 @@
1
+ // 多用户账号与 Agent 权限 —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 场景:Hub 管理员 admin 的网络 NET 里有 3 个 Agent(X、Y、Z)。admin 通过 POST /api/admin/users
4
+ // 建出 alice、bob 两个成员 —— 新成员默认 agent_access='granted',看不到任何 Agent。
5
+ // 每条面向 Agent 的路径都各测一次:受限成员拿到空 / 403,被授权后只看到 / 只能联系授权的那个。
6
+ // 末尾一组「不能被滥用」:猜 alias / node_id、订阅 SSE、下载文件、看日志、借网络令牌、塞别人的附件。
7
+
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { register } from "./auth.js";
13
+ import { db } from "./db.js";
14
+ import { hashToken, generateNetworkToken, generateId } from "./db.js";
15
+
16
+ const DIR = mkdtempSync(join(tmpdir(), "anet-agent-acl-"));
17
+ let BASE = "";
18
+ let hub: any = null;
19
+
20
+ const PW = "AclTestPassw0rd!x";
21
+ let adminToken = "";
22
+ let adminId = "";
23
+ let NET = "";
24
+ let aliceToken = "";
25
+ let aliceId = "";
26
+ let aliceOwnNet = "";
27
+ let bobToken = "";
28
+ let bobId = "";
29
+ let adminFileId = "";
30
+
31
+ const X = { alias: "acl-agent-x", node: "node_acl_x" };
32
+ const Y = { alias: "acl-agent-y", node: "node_acl_y" };
33
+ const Z = { alias: "acl-agent-z", node: "node_acl_z" };
34
+
35
+ const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
36
+ const json = (token: string) => ({ ...auth(token), "Content-Type": "application/json" });
37
+
38
+ async function get(token: string, path: string): Promise<{ status: number; body: any }> {
39
+ const res = await fetch(`${BASE}${path}`, { headers: auth(token) });
40
+ const text = await res.text();
41
+ let body: any = text;
42
+ try { body = JSON.parse(text); } catch {}
43
+ return { status: res.status, body };
44
+ }
45
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<{ status: number; body: any }> {
46
+ const res = await fetch(`${BASE}${path}`, { method, headers: json(token), body: payload === undefined ? undefined : JSON.stringify(payload) });
47
+ const text = await res.text();
48
+ let body: any = text;
49
+ try { body = JSON.parse(text); } catch {}
50
+ return { status: res.status, body };
51
+ }
52
+ async function tool(token: string, name: string, args: Record<string, unknown>): Promise<any> {
53
+ const res = await fetch(`${BASE}/mcp`, {
54
+ method: "POST",
55
+ headers: { ...json(token), Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
56
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
57
+ });
58
+ expect(res.status).toBe(200);
59
+ const raw = await res.text();
60
+ const dataLines = raw.split("\n").filter(x => x.startsWith("data:"));
61
+ const payload = dataLines.length ? JSON.parse(dataLines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
62
+ return JSON.parse(payload.result.content[0].text);
63
+ }
64
+ async function login(username: string): Promise<{ token: string; userId: string }> {
65
+ const r = await send("", "POST", "/api/auth/login", { username, password: PW });
66
+ expect(r.status).toBe(200);
67
+ return { token: r.body.token, userId: r.body.user.user_id };
68
+ }
69
+ const aliases = (rows: any[]) => rows.map((r: any) => r.alias).filter((a: any) => [X.alias, Y.alias, Z.alias].includes(a)).sort();
70
+ const grantsPath = (uid: string) => `/api/networks/${NET}/members/${uid}/agent-grants`;
71
+
72
+ function seedAgent(a: { alias: string; node: string }) {
73
+ db.run(
74
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
75
+ [a.node, a.alias, NET, adminId],
76
+ );
77
+ db.run(
78
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at)
79
+ VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`,
80
+ [`resume_${a.node}`, a.alias, a.node, NET],
81
+ );
82
+ }
83
+ function seedTask(id: string, from: string, to: string, content: string, meta?: unknown) {
84
+ const toNode = [X, Y, Z].find(a => a.alias === to)?.node ?? null;
85
+ const fromNode = [X, Y, Z].find(a => a.alias === from)?.node ?? null;
86
+ db.run(
87
+ `INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, status, content, network_id, meta_json)
88
+ VALUES (?1, ?2, ?3, ?4, ?5, 'replied', ?6, ?7, ?8)`,
89
+ [id, from, fromNode, to, toNode, content, NET, meta === undefined ? null : JSON.stringify(meta)],
90
+ );
91
+ db.run(
92
+ `INSERT INTO inbox (id, task_id, session_name, type, priority, content, from_session, network_id, meta_json)
93
+ VALUES (?1, ?2, ?3, 'task', 'normal', ?4, ?5, ?6, ?7)`,
94
+ [`ib_${id}`, id, to, content, from, NET, meta === undefined ? null : JSON.stringify(meta)],
95
+ );
96
+ }
97
+
98
+ beforeAll(async () => {
99
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
100
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
101
+ process.env.HOST = "127.0.0.1";
102
+
103
+ const admin = register(`acl_admin_${Date.now()}`, PW, undefined, "Admin");
104
+ expect(admin.ok).toBe(true);
105
+ adminToken = admin.token!;
106
+ adminId = admin.user!.user_id;
107
+ NET = admin.network_id!;
108
+ // 第一个注册的用户是 Hub 管理员;共享库里跑时不一定是第一个,显式设上。
109
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
110
+ for (const a of [X, Y, Z]) seedAgent(a);
111
+
112
+ const mod: any = await import("./server.js");
113
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
114
+ BASE = `http://127.0.0.1:${hub.port}`;
115
+
116
+ // admin 在 NET 里的私有流量:任何受限成员都不该看见。
117
+ seedTask("t_admin_to_x", admin.user!.username, X.alias, "admin secret for X");
118
+ seedTask("t_admin_to_y", admin.user!.username, Y.alias, "admin secret for Y");
119
+
120
+ const form = new FormData();
121
+ form.append("file", new Blob([new Uint8Array([7, 7, 7])], { type: "application/octet-stream" }), "secret.bin");
122
+ const up = await fetch(`${BASE}/api/upload?network_id=${NET}`, { method: "POST", body: form, headers: auth(adminToken) });
123
+ expect(up.status).toBe(200);
124
+ adminFileId = (await up.json()).file_id;
125
+ }, 30_000);
126
+
127
+ afterAll(() => {
128
+ try { hub?.stop?.(true); } catch {}
129
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
130
+ });
131
+
132
+ describe("POST/GET /api/admin/users", () => {
133
+ test("hub admin creates a restricted member in a network (no tokens handed to the admin)", async () => {
134
+ const r = await send(adminToken, "POST", "/api/admin/users", { username: "acl_alice", password: PW, display_name: "Alice", network_id: NET, role: "member" });
135
+ expect(r.status).toBe(200);
136
+ expect(r.body.ok).toBe(true);
137
+ expect(r.body.membership).toEqual({ network_id: NET, role: "member", agent_access: "granted" });
138
+ expect(r.body.token).toBeUndefined();
139
+ expect(r.body.network_token).toBeUndefined();
140
+ const b = await send(adminToken, "POST", "/api/admin/users", { username: "acl_bob", password: PW, network_id: NET });
141
+ expect(b.status).toBe(200);
142
+ ({ token: aliceToken, userId: aliceId } = await login("acl_alice"));
143
+ ({ token: bobToken, userId: bobId } = await login("acl_bob"));
144
+ aliceOwnNet = db.get<{ network_id: string }>("SELECT network_id FROM networks WHERE owner_id = ?1", aliceId)!.network_id;
145
+ });
146
+
147
+ test("password rules are register()'s: < 8 chars and common passwords rejected", async () => {
148
+ expect((await send(adminToken, "POST", "/api/admin/users", { username: "acl_short", password: "Ab1!" })).status).toBe(400);
149
+ expect((await send(adminToken, "POST", "/api/admin/users", { username: "acl_common", password: "password" })).status).toBe(400);
150
+ });
151
+
152
+ test("username equal to an agent alias in the target network → 409, and the account is NOT created", async () => {
153
+ const r = await send(adminToken, "POST", "/api/admin/users", { username: X.alias, password: PW, network_id: NET });
154
+ expect(r.status).toBe(409);
155
+ expect(r.body.error).toBe("username_collides_with_agent_alias");
156
+ expect(db.get("SELECT 1 FROM users WHERE username = ?1", X.alias)).toBeNull();
157
+ });
158
+
159
+ test("non-admin cannot create users or list them; audit log records creation", async () => {
160
+ expect((await send(aliceToken, "POST", "/api/admin/users", { username: "acl_evil", password: PW })).status).toBe(403);
161
+ expect((await send(aliceToken, "POST", "/api/admin/users", { username: "acl_evil", password: PW, network_id: NET })).status).toBe(403);
162
+ expect((await get(aliceToken, "/api/admin/users")).status).toBe(403);
163
+ const list = await get(adminToken, "/api/admin/users");
164
+ expect(list.status).toBe(200);
165
+ const alice = list.body.users.find((u: any) => u.username === "acl_alice");
166
+ expect(alice.networks.find((n: any) => n.network_id === NET)).toMatchObject({ role: "member", agent_access: "granted" });
167
+ expect(alice.password_hash).toBeUndefined();
168
+ expect(db.get("SELECT 1 FROM audit_log WHERE action = 'admin_user_created' AND target_id = ?1", aliceId)).toBeTruthy();
169
+ });
170
+ });
171
+
172
+ describe("restricted member with NO grants sees no agents (one test per path)", () => {
173
+ test("GET /api/status (explicit network and default scope)", async () => {
174
+ const r = await get(aliceToken, `/api/status?network_id=${NET}`);
175
+ expect(r.status).toBe(200);
176
+ expect(r.body.sessions).toEqual([]);
177
+ expect((await get(aliceToken, "/api/status?light=1")).body.sessions.filter((s: any) => s.network_id === NET)).toEqual([]);
178
+ });
179
+ test("GET /api/nodes", async () => {
180
+ expect((await get(aliceToken, `/api/nodes?network_id=${NET}`)).body.nodes).toEqual([]);
181
+ expect((await get(aliceToken, `/api/nodes?node_id=${X.node}`)).body.nodes).toEqual([]);
182
+ });
183
+ test("GET /api/servers, /api/stats, /api/completions, /api/host-supervisors", async () => {
184
+ expect((await get(aliceToken, `/api/servers?network_id=${NET}`)).body.servers ?? []).toEqual([]);
185
+ const stats = await get(aliceToken, `/api/stats?network_id=${NET}`);
186
+ expect(stats.body.nodes.total).toBe(0);
187
+ expect(stats.body.recent_tasks).toEqual([]);
188
+ expect((await get(aliceToken, `/api/completions?network_id=${NET}`)).body.completions).toEqual([]);
189
+ const hs = await get(aliceToken, `/api/host-supervisors?network_id=${NET}`);
190
+ expect(hs.body.daemons).toEqual([]);
191
+ });
192
+ test("GET /api/networks/:id stats count nothing", async () => {
193
+ const r = await get(aliceToken, `/api/networks/${NET}`);
194
+ expect(r.status).toBe(200);
195
+ expect(r.body.stats.nodes).toBe(0);
196
+ expect(r.body.stats.sessions).toBe(0);
197
+ expect(r.body.stats.tasks).toEqual([]);
198
+ });
199
+ test("GET /api/tasks, /api/tasks/:id, /api/task_events, /api/messages hide other people's agent traffic", async () => {
200
+ expect((await get(aliceToken, `/api/tasks?network_id=${NET}`)).body.tasks).toEqual([]);
201
+ expect((await get(aliceToken, `/api/tasks/t_admin_to_x`)).status).toBe(404);
202
+ expect((await get(aliceToken, `/api/task_events?task_id=t_admin_to_x`)).body.events).toEqual([]);
203
+ expect((await get(aliceToken, `/api/messages?network_id=${NET}&since=2000-01-01`)).body.messages).toEqual([]);
204
+ expect((await get(aliceToken, `/api/messages?alias=${X.alias}&since=2000-01-01`)).body.messages).toEqual([]);
205
+ });
206
+ test("POST /api/task to an agent → 403 agent_not_granted; nonexistent alias gets the identical response", async () => {
207
+ const real = await send(aliceToken, "POST", "/api/task", { alias: X.alias, task: "hi", network_id: NET });
208
+ const fake = await send(aliceToken, "POST", "/api/task", { alias: "no-such-agent", task: "hi", network_id: NET });
209
+ expect(real.status).toBe(403);
210
+ expect(real.body.error).toBe("agent_not_granted");
211
+ expect(fake).toEqual(real);
212
+ });
213
+ test("POST /api/broadcast → 403", async () => {
214
+ expect((await send(aliceToken, "POST", `/api/broadcast?network_id=${NET}`, { message: "hi" })).status).toBe(403);
215
+ });
216
+ test("node write endpoints (delete / config / attrs / avatar) → not found", async () => {
217
+ expect((await send(aliceToken, "DELETE", `/api/nodes/${X.node}?network_id=${NET}`)).status).toBeGreaterThanOrEqual(403);
218
+ expect((await get(aliceToken, `/api/nodes/${X.node}/config?network_id=${NET}`)).status).toBeGreaterThanOrEqual(403);
219
+ expect((await send(aliceToken, "PUT", `/api/nodes/${X.node}/avatar?network_id=${NET}`, { avatar_url: null })).status).toBeGreaterThanOrEqual(403);
220
+ expect(db.get("SELECT 1 FROM nodes WHERE node_id = ?1", X.node)).toBeTruthy();
221
+ });
222
+ test("MCP get_all_status / get_session_status / list_tasks / get_task", async () => {
223
+ const all = await tool(aliceToken, "get_all_status", { network_id: NET });
224
+ expect(all.sessions).toEqual([]);
225
+ const one = await tool(aliceToken, "get_session_status", { alias: X.alias });
226
+ expect(one.session ?? null).toBeNull();
227
+ expect((await tool(aliceToken, "list_tasks", { network_id: NET })).tasks).toEqual([]);
228
+ expect((await tool(aliceToken, "get_task", { task_id: "t_admin_to_x" })).ok).toBe(false);
229
+ });
230
+ test("MCP send_task / send_message → agent_not_granted", async () => {
231
+ expect((await tool(aliceToken, "send_task", { alias: X.alias, task: "hi", network_id: NET })).error).toBe("agent_not_granted");
232
+ expect((await tool(aliceToken, "send_message", { alias: X.alias, message: "hi", network_id: NET })).error).toBe("agent_not_granted");
233
+ });
234
+ test("MCP non-allowlisted agent tools → agent_access_restricted (with and without network_id)", async () => {
235
+ for (const [name, args] of [
236
+ ["tail_node_logs", { alias: X.alias, network_id: NET }],
237
+ ["list_node_files", { alias: X.alias, network_id: NET }],
238
+ ["broadcast", { message: "x", network_id: NET }],
239
+ ["cancel_task", { task_id: "t_admin_to_x", network_id: NET }],
240
+ ["tail_node_logs", { alias: X.alias }],
241
+ ] as const) {
242
+ expect((await tool(aliceToken, name, args as any)).error).toBe("agent_access_restricted");
243
+ }
244
+ });
245
+ test("requirements people list: humans only, no nodes", async () => {
246
+ const r = await get(aliceToken, `/api/requirements/people?network_id=${NET}`);
247
+ expect(r.status).toBe(200);
248
+ expect(r.body.people.filter((p: any) => p.kind === "node")).toEqual([]);
249
+ expect(r.body.people.filter((p: any) => p.kind === "user").map((p: any) => p.id).sort()).toEqual([adminId, aliceId, bobId].sort());
250
+ });
251
+ });
252
+
253
+ describe("humans: restricted members can still see and DM other humans", () => {
254
+ test("GET /api/networks/:id/humans lists only people (for any member)", async () => {
255
+ const r = await get(aliceToken, `/api/networks/${NET}/humans`);
256
+ expect(r.status).toBe(200);
257
+ expect(r.body.humans.map((h: any) => h.user_id).sort()).toEqual([adminId, aliceId, bobId].sort());
258
+ expect(Object.keys(r.body.humans[0]).sort()).toEqual(["avatar_url", "display_name", "last_seen_at", "online", "user_id", "username"]);
259
+ expect(r.body.humans.every((h: any) => h.avatar_url === null)).toBe(true);
260
+ // 非成员拿不到。
261
+ const outsider = register(`acl_out_${Date.now()}`, PW);
262
+ expect((await get(outsider.token!, `/api/networks/${NET}/humans`)).status).toBe(403);
263
+ });
264
+ test("alice → bob send_desktop_message works; bob reads it from his user inbox", async () => {
265
+ const r = await tool(aliceToken, "send_desktop_message", { to_username: "acl_bob", message: "hello bob", network_id: NET });
266
+ expect(r.ok).toBe(true);
267
+ const inbox = await get(bobToken, `/api/messages?scope=user&network_id=${NET}`);
268
+ expect(inbox.body.messages.map((m: any) => m.content)).toContain("hello bob");
269
+ expect(inbox.body.messages.find((m: any) => m.content === "hello bob").from_session).toBe("acl_alice");
270
+ });
271
+ test("a restricted member cannot DM under someone else's name", async () => {
272
+ const r = await tool(aliceToken, "send_desktop_message", { to_username: "acl_bob", message: "spoof", from_session: X.alias, network_id: NET });
273
+ expect(r.error).toBe("from_session_identity_mismatch");
274
+ });
275
+ });
276
+
277
+ describe("grant API", () => {
278
+ test("only network owner/admin (or hub admin) can read or change grants", async () => {
279
+ expect((await get(aliceToken, grantsPath(aliceId))).status).toBe(403);
280
+ expect((await send(aliceToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: X.node }] })).status).toBe(403);
281
+ expect((await send(bobToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: X.node }] })).status).toBe(403);
282
+ const r = await get(adminToken, grantsPath(aliceId));
283
+ expect(r.body).toMatchObject({ ok: true, agent_access: "granted", restricted: true, grants: [] });
284
+ });
285
+ test("unknown / foreign node ids are rejected whole-batch", async () => {
286
+ const r = await send(adminToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: X.node }, { node_id: "node_not_here" }] });
287
+ expect(r.status).toBe(400);
288
+ expect(r.body.error).toBe("agent_not_in_network");
289
+ expect((await get(adminToken, grantsPath(aliceId))).body.grants).toEqual([]);
290
+ });
291
+ test("admin grants alice X (can message) and Y (read-only); audited", async () => {
292
+ const r = await send(adminToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: X.node }, { node_id: Y.node, can_message: false }] });
293
+ expect(r.status).toBe(200);
294
+ expect(r.body.grants).toEqual([
295
+ { node_id: X.node, alias: null, can_message: true },
296
+ { node_id: Y.node, alias: null, can_message: false },
297
+ ]);
298
+ expect(db.get("SELECT 1 FROM audit_log WHERE action = 'member_agent_grants_changed' AND target_id = ?1", NET)).toBeTruthy();
299
+ });
300
+ });
301
+
302
+ describe("granted member sees and messages ONLY what was granted", () => {
303
+ test("/api/status and /api/nodes show X and Y, never Z", async () => {
304
+ expect(aliases((await get(aliceToken, `/api/status?network_id=${NET}`)).body.sessions)).toEqual([X.alias, Y.alias]);
305
+ expect(aliases((await get(aliceToken, `/api/nodes?network_id=${NET}`)).body.nodes)).toEqual([X.alias, Y.alias]);
306
+ expect((await get(aliceToken, `/api/nodes?node_id=${Z.node}`)).body.nodes).toEqual([]);
307
+ });
308
+ test("MCP get_all_status shows X and Y only; summary counts only them", async () => {
309
+ const r = await tool(aliceToken, "get_all_status", { network_id: NET });
310
+ expect(aliases(r.sessions)).toEqual([X.alias, Y.alias]);
311
+ expect(r.summary.reduce((n: number, s: any) => n + s.count, 0)).toBe(2);
312
+ });
313
+ test("REST send to X works and is recorded as alice; Y is read-only; Z is not granted", async () => {
314
+ const ok = await send(aliceToken, "POST", "/api/task", { alias: X.alias, task: "hello X", network_id: NET });
315
+ expect(ok.status).toBe(200);
316
+ const row = db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE to_name = ?1 AND content = 'hello X'", X.alias);
317
+ expect(row?.from_name).toBe("acl_alice");
318
+ expect((await send(aliceToken, "POST", "/api/task", { alias: Y.alias, task: "hi", network_id: NET })).body.error).toBe("agent_not_granted");
319
+ expect((await send(aliceToken, "POST", "/api/task", { alias: Z.alias, task: "hi", network_id: NET })).body.error).toBe("agent_not_granted");
320
+ });
321
+ test("MCP send_task to X works; to Z fails", async () => {
322
+ expect((await tool(aliceToken, "send_task", { alias: X.alias, task: "mcp hello X", network_id: NET })).ok).toBe(true);
323
+ expect((await tool(aliceToken, "send_task", { alias: Z.alias, task: "hi", network_id: NET })).error).toBe("agent_not_granted");
324
+ });
325
+ test("#563 tasks: alice sees X's and Y's whole timeline (incl. admin's history), never Z's", async () => {
326
+ seedTask("t_x_to_alice", X.alias, "acl_alice", "X answers alice");
327
+ const ids = (await get(aliceToken, `/api/tasks?network_id=${NET}`)).body.tasks.map((t: any) => t.content).sort();
328
+ // 与 admin 看同一个节点时看到的一样:admin 发给 X / Y(Y 是只读授权)的历史也在。
329
+ expect(ids).toEqual(["X answers alice", "hello X", "mcp hello X", "admin secret for X", "admin secret for Y"].sort());
330
+ expect((await get(aliceToken, `/api/tasks/t_admin_to_x`)).status).toBe(200);
331
+ expect((await get(aliceToken, `/api/tasks/t_x_to_alice`)).status).toBe(200);
332
+ expect((await tool(aliceToken, "list_tasks", { network_id: NET })).tasks.map((t: any) => t.content)).toContain("admin secret for X");
333
+ expect((await tool(aliceToken, "get_task", { task_id: "t_admin_to_x" })).ok).toBe(true);
334
+ });
335
+ test("requirements people now include X and Y nodes but not Z", async () => {
336
+ const nodes = (await get(aliceToken, `/api/requirements/people?network_id=${NET}`)).body.people.filter((p: any) => p.kind === "node").map((p: any) => p.id).sort();
337
+ expect(nodes).toEqual([X.node, Y.node].sort());
338
+ });
339
+ test("owner / hub admin are unaffected: admin still sees all three", async () => {
340
+ expect(aliases((await get(adminToken, `/api/status?network_id=${NET}`)).body.sessions)).toEqual([X.alias, Y.alias, Z.alias]);
341
+ });
342
+ test("bob (no grants) still sees nothing", async () => {
343
+ expect((await get(bobToken, `/api/status?network_id=${NET}`)).body.sessions).toEqual([]);
344
+ });
345
+ });
346
+
347
+ describe("#563 one node, one timeline: everyone who can see N sees the same history", () => {
348
+ const contents = async (token: string, q = "") => (await get(token, `/api/tasks?network_id=${NET}${q}`)).body.tasks.map((t: any) => t.content).sort();
349
+ test("seed: bob's history with X and traffic between X and the invisible Z", () => {
350
+ seedTask("t563_bob_to_x", "acl_bob", X.alias, "bob asked X earlier");
351
+ seedTask("t563_z_to_x", Z.alias, X.alias, "Z whispers to X");
352
+ seedTask("t563_x_to_z", X.alias, Z.alias, "X whispers to Z");
353
+ seedTask("t563_admin_to_z", "scheduler", Z.alias, "scheduler pokes Z");
354
+ });
355
+ test("chat window query (to_name=X): alice sees exactly what admin sees, minus rows from an agent she cannot see", async () => {
356
+ const adminView = await contents(adminToken, `&to_name=${X.alias}`);
357
+ const aliceView = await contents(aliceToken, `&to_name=${X.alias}`);
358
+ expect(adminView).toContain("Z whispers to X");
359
+ expect(aliceView).toEqual(adminView.filter((c: string) => c !== "Z whispers to X"));
360
+ expect(aliceView).toContain("admin secret for X");
361
+ expect(aliceView).toContain("bob asked X earlier");
362
+ });
363
+ test("rows between a visible and an invisible agent stay hidden in every direction; Z's timeline stays hidden", async () => {
364
+ const all = await contents(aliceToken);
365
+ expect(all).not.toContain("Z whispers to X");
366
+ expect(all).not.toContain("X whispers to Z");
367
+ expect(all).not.toContain("scheduler pokes Z");
368
+ expect((await get(aliceToken, `/api/tasks/t563_z_to_x`)).status).toBe(404);
369
+ expect((await get(aliceToken, `/api/tasks/t563_x_to_z`)).status).toBe(404);
370
+ expect((await get(aliceToken, `/api/task_events?task_id=t563_z_to_x`)).body.events).toEqual([]);
371
+ });
372
+ test("/api/tasks/:id: another member's task to X is 200 for granted alice, 404 for bob without a grant", async () => {
373
+ expect((await get(aliceToken, `/api/tasks/t563_bob_to_x`)).status).toBe(200);
374
+ expect((await get(aliceToken, `/api/tasks/t_admin_to_x`)).body.task.content).toBe("admin secret for X");
375
+ expect((await get(bobToken, `/api/tasks/t_admin_to_x`)).status).toBe(404);
376
+ expect((await get(bobToken, `/api/task_events?task_id=t_admin_to_x`)).body.events).toEqual([]);
377
+ expect(await contents(bobToken)).toEqual([]);
378
+ });
379
+ test("network stats count alice's visible timeline", async () => {
380
+ const r = await get(aliceToken, `/api/networks/${NET}`);
381
+ const n = r.body.stats.tasks.reduce((sum: number, row: any) => sum + row.count, 0);
382
+ expect(n).toBe((await contents(aliceToken)).length);
383
+ });
384
+ test("group grant: bob granted a group holding Z sees Z's history (incl. the scheduler row), not X's", async () => {
385
+ const g = await send(adminToken, "POST", `/api/networks/${NET}/agent-groups`, { name: "acl-563-z", node_ids: [Z.node] });
386
+ expect(g.status).toBe(200);
387
+ const groupId = g.body.group.group_id;
388
+ expect((await send(adminToken, "PUT", grantsPath(bobId), { group_grants: [{ group_id: groupId, can_message: false }] })).status).toBe(200);
389
+ try {
390
+ const bobView = await contents(bobToken);
391
+ expect(bobView).toContain("scheduler pokes Z");
392
+ expect(bobView).not.toContain("admin secret for X");
393
+ expect(bobView).not.toContain("Z whispers to X");
394
+ expect((await get(bobToken, `/api/tasks/t563_admin_to_z`)).status).toBe(200);
395
+ } finally {
396
+ await send(adminToken, "PUT", grantsPath(bobId), { group_grants: [] });
397
+ await send(adminToken, "DELETE", `/api/networks/${NET}/agent-groups/${groupId}`);
398
+ }
399
+ expect(await contents(bobToken)).toEqual([]);
400
+ });
401
+ test("agent_access=all and network admin see every row for X (unchanged); network owner without hub-admin too", async () => {
402
+ const adminView = await contents(adminToken, `&to_name=${X.alias}`);
403
+ await send(adminToken, "PUT", grantsPath(bobId), { agent_access: "all" });
404
+ try {
405
+ expect(await contents(bobToken, `&to_name=${X.alias}`)).toEqual(adminView);
406
+ } finally {
407
+ await send(adminToken, "PUT", grantsPath(bobId), { agent_access: "granted" });
408
+ }
409
+ db.run("UPDATE network_members SET role = 'admin' WHERE network_id = ?1 AND user_id = ?2", [NET, bobId]);
410
+ try {
411
+ expect(await contents(bobToken, `&to_name=${X.alias}`)).toEqual(adminView);
412
+ } finally {
413
+ db.run("UPDATE network_members SET role = 'member' WHERE network_id = ?1 AND user_id = ?2", [NET, bobId]);
414
+ }
415
+ db.run("UPDATE users SET role = 'user' WHERE user_id = ?1", [adminId]);
416
+ try {
417
+ expect(await contents(adminToken, `&to_name=${X.alias}`)).toEqual(adminView);
418
+ } finally {
419
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
420
+ }
421
+ expect(await contents(bobToken)).toEqual([]);
422
+ });
423
+ test("files: an attachment on admin's message to X opens for alice; on admin's message to Z it does not", async () => {
424
+ const upload = async () => {
425
+ const form = new FormData();
426
+ form.append("file", new Blob([new Uint8Array([5, 6, 3])], { type: "image/png" }), "shot.png");
427
+ const up = await fetch(`${BASE}/api/upload?network_id=${NET}`, { method: "POST", body: form, headers: auth(adminToken) });
428
+ expect(up.status).toBe(200);
429
+ return (await up.json()).file_id as string;
430
+ };
431
+ const toX = await upload();
432
+ const toZ = await upload();
433
+ const adminName = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", adminId)!.username;
434
+ seedTask("t563_img_x", adminName, X.alias, "look at this", { attachments: [{ type: "file", file_id: toX }] });
435
+ seedTask("t563_img_z", adminName, Z.alias, "look at this", { attachments: [{ type: "file", file_id: toZ }] });
436
+ try {
437
+ expect((await fetch(`${BASE}/api/files/${toX}`, { headers: auth(aliceToken) })).status).toBe(200);
438
+ expect((await fetch(`${BASE}/api/files/${toZ}`, { headers: auth(aliceToken) })).status).toBe(404);
439
+ expect((await fetch(`${BASE}/api/files/${toX}`, { headers: auth(bobToken) })).status).toBe(404);
440
+ } finally {
441
+ for (const id of ["t563_img_x", "t563_img_z"]) {
442
+ db.run("DELETE FROM tasks WHERE task_id = ?1", [id]);
443
+ db.run("DELETE FROM inbox WHERE task_id = ?1", [id]);
444
+ }
445
+ }
446
+ });
447
+ test("SSE: admin's new message to X and X's reply to admin wake alice's stream; Z traffic never does", async () => {
448
+ const { pushNetworkObserverEvent } = await import("./push.js");
449
+ const adminName = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", adminId)!.username;
450
+ const ctrl = new AbortController();
451
+ const res = await fetch(`${BASE}/events/network/${NET}`, { headers: auth(aliceToken), signal: ctrl.signal });
452
+ expect(res.status).toBe(200);
453
+ const reader = res.body!.getReader();
454
+ const decoder = new TextDecoder();
455
+ let buf = "";
456
+ let pending: ReturnType<typeof reader.read> | null = null;
457
+ const readFor = async (ms: number) => {
458
+ const until = Date.now() + ms;
459
+ while (Date.now() < until) {
460
+ pending ??= reader.read();
461
+ const chunk = await Promise.race([pending, new Promise<null>(r => setTimeout(() => r(null), until - Date.now()))]);
462
+ if (!chunk) break;
463
+ pending = null;
464
+ if (chunk.done) break;
465
+ buf += decoder.decode(chunk.value);
466
+ }
467
+ };
468
+ await readFor(150);
469
+ expect((await send(adminToken, "POST", "/api/task", { alias: X.alias, task: "admin live to X", network_id: NET })).status).toBe(200);
470
+ pushNetworkObserverEvent(NET, { type: "new_reply", task_id: "t_admin_to_x", message_id: "m563", from: X.alias, to: adminName, status: "replied" });
471
+ pushNetworkObserverEvent(NET, { type: "new_task", task_id: "t563_live_zx", from: Z.alias, to: X.alias, status: "delivered" });
472
+ pushNetworkObserverEvent(NET, { type: "new_reply", task_id: "t563_live_xz", message_id: "m563z", from: X.alias, to: Z.alias, status: "replied" });
473
+ pushNetworkObserverEvent(NET, { type: "new_task", task_id: "t563_live_az", from: adminName, to: Z.alias, status: "delivered" });
474
+ await readFor(400);
475
+ ctrl.abort();
476
+ const events = buf.split("\n").filter(l => l.startsWith("data:")).map(l => JSON.parse(l.slice(5)));
477
+ expect(events.some(e => e.type === "new_task" && e.from === adminName && e.to === X.alias)).toBe(true);
478
+ expect(events.some(e => e.type === "new_reply" && e.from === X.alias && e.to === adminName)).toBe(true);
479
+ expect(events.some(e => e.from === Z.alias || e.to === Z.alias)).toBe(false);
480
+ });
481
+ });
482
+
483
+ describe("requirements: agent references alice cannot see are hidden and protected", () => {
484
+ let reqId = "";
485
+ test("a card owned by Z shows agent_owner=null to alice, Z to admin", async () => {
486
+ // 这一组测的是「看不见的 Agent 引用被隐去、不能被改」,不是任务范围:alice 按 RFC-038 §9 之前的语义能改全网的卡。
487
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, aliceId]);
488
+ const c = await send(adminToken, "POST", "/api/requirements", { name: "card", network_id: NET, agent_owner: { kind: "node", id: Z.node }, participants: [{ kind: "node", id: Z.node }, { kind: "user", id: aliceId }] });
489
+ expect(c.status).toBe(201);
490
+ reqId = c.body.requirement.id;
491
+ const mine = await get(aliceToken, `/api/requirements/${reqId}?network_id=${NET}`);
492
+ expect(mine.body.requirement.agent_owner).toBeNull();
493
+ expect(mine.body.requirement.participants).toEqual([{ kind: "user", id: aliceId }]);
494
+ expect((await get(adminToken, `/api/requirements/${reqId}`)).body.requirement.agent_owner).toEqual({ kind: "node", id: Z.node });
495
+ });
496
+ test("alice cannot reassign or clear the hidden agent owner, nor assign an ungranted node", async () => {
497
+ expect((await send(aliceToken, "PATCH", `/api/requirements/${reqId}?network_id=${NET}`, { agent_owner: null })).body.error).toBe("agent_owner_not_granted");
498
+ const c = await send(aliceToken, "POST", "/api/requirements", { name: "mine", network_id: NET, agent_owner: { kind: "node", id: Z.node } });
499
+ expect(c.body.error).toBe("person_not_in_network");
500
+ });
501
+ test("saving participants keeps the hidden Z participant", async () => {
502
+ const r = await send(aliceToken, "PATCH", `/api/requirements/${reqId}?network_id=${NET}`, { participants: [{ kind: "user", id: aliceId }, { kind: "node", id: X.node }] });
503
+ expect(r.status).toBe(200);
504
+ const stored = JSON.parse(db.get<{ participants_json: string }>("SELECT participants_json FROM requirements WHERE requirement_id = ?1", reqId)!.participants_json);
505
+ expect(stored).toContainEqual({ kind: "node", id: Z.node });
506
+ expect(stored).toContainEqual({ kind: "node", id: X.node });
507
+ });
508
+ test("filtering the board by a hidden agent owner returns nothing", async () => {
509
+ const r = await get(aliceToken, `/api/requirements?network_id=${NET}&agent_owner=node:${Z.node}`);
510
+ expect(r.body.requirements).toEqual([]);
511
+ });
512
+ test("q= does not match on a hidden agent's name (no 'who owns this card' probe); admin's q= does", async () => {
513
+ const zName = db.get<{ name: string }>("SELECT COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE node_id = ?1", Z.node)!.name;
514
+ const ids = async (token: string, q: string) => (await get(token, `/api/requirements?network_id=${NET}&q=${encodeURIComponent(q)}`)).body.requirements.map((r: any) => r.id);
515
+ expect(await ids(aliceToken, zName)).not.toContain(reqId);
516
+ expect(await ids(adminToken, zName)).toContain(reqId);
517
+ // 看得见的字段照样能搜到(标题)
518
+ expect(await ids(aliceToken, "card")).toContain(reqId);
519
+ });
520
+ });
521
+
522
+ describe("cannot be abused", () => {
523
+ test("guessing node ids / aliases on every read path returns the same empty result", async () => {
524
+ for (const ref of [Z.alias, Z.node, "does-not-exist"]) {
525
+ expect((await get(aliceToken, `/api/nodes?alias=${ref}`)).body.nodes).toEqual([]);
526
+ expect((await get(aliceToken, `/api/nodes?node_id=${ref}`)).body.nodes).toEqual([]);
527
+ }
528
+ expect((await tool(aliceToken, "get_all_status", { network_id: NET, filter_alias: Z.alias })).sessions).toEqual([]);
529
+ });
530
+ test("SSE: agent channels are refused even for a granted agent; own channel is fine", async () => {
531
+ for (const alias of [Z.alias, X.alias]) {
532
+ const r = await fetch(`${BASE}/events/${encodeURIComponent(alias)}?network_id=${NET}`, { headers: auth(aliceToken) });
533
+ expect(r.status).toBe(403);
534
+ await r.body?.cancel();
535
+ }
536
+ const ctrl = new AbortController();
537
+ const own = await fetch(`${BASE}/events/acl_alice?network_id=${NET}`, { headers: auth(aliceToken), signal: ctrl.signal });
538
+ expect(own.status).toBe(200);
539
+ ctrl.abort();
540
+ });
541
+ test("SSE network observer stream only carries events alice is part of", async () => {
542
+ const ctrl = new AbortController();
543
+ const res = await fetch(`${BASE}/events/network/${NET}`, { headers: auth(aliceToken), signal: ctrl.signal });
544
+ expect(res.status).toBe(200);
545
+ const reader = res.body!.getReader();
546
+ const decoder = new TextDecoder();
547
+ let buf = "";
548
+ // 一次只挂一个 read():超时放弃的那个 read 仍在排队,下一轮必须接着等它,否则它拿走的块就丢了。
549
+ let pending: ReturnType<typeof reader.read> | null = null;
550
+ const readFor = async (ms: number) => {
551
+ const until = Date.now() + ms;
552
+ while (Date.now() < until) {
553
+ pending ??= reader.read();
554
+ const chunk = await Promise.race([pending, new Promise<null>(r => setTimeout(() => r(null), until - Date.now()))]);
555
+ if (!chunk) break;
556
+ pending = null;
557
+ if (chunk.done) break;
558
+ buf += decoder.decode(chunk.value);
559
+ }
560
+ };
561
+ await readFor(150);
562
+ // admin → Z:alice 不该收到任何路由事件。
563
+ expect((await send(adminToken, "POST", "/api/task", { alias: Z.alias, task: "admin to Z", network_id: NET })).status).toBe(200);
564
+ // alice → X:她自己的,应当收到。
565
+ expect((await send(aliceToken, "POST", "/api/task", { alias: X.alias, task: "alice to X again", network_id: NET })).status).toBe(200);
566
+ await readFor(400);
567
+ ctrl.abort();
568
+ const events = buf.split("\n").filter(l => l.startsWith("data:")).map(l => JSON.parse(l.slice(5)));
569
+ expect(events.some(e => e.type === "new_task" && e.to === X.alias && e.from === "acl_alice")).toBe(true);
570
+ expect(events.some(e => e.to === Z.alias)).toBe(false);
571
+ });
572
+ test("/health SSE topology omits the restricted network", async () => {
573
+ const ctrl = new AbortController();
574
+ const sub = await fetch(`${BASE}/events/${Z.alias}?network_id=${NET}`, { headers: auth(adminToken), signal: ctrl.signal });
575
+ expect(sub.status).toBe(200);
576
+ const h = await get(aliceToken, "/health");
577
+ ctrl.abort();
578
+ expect(JSON.stringify(h.body.sse_sessions ?? {})).not.toContain(NET);
579
+ });
580
+ test("files: admin's upload in the network is 404 for alice; smuggling its id as an attachment is refused", async () => {
581
+ const r = await fetch(`${BASE}/api/files/${adminFileId}`, { headers: auth(aliceToken) });
582
+ expect(r.status).toBe(404);
583
+ const t = await send(aliceToken, "POST", "/api/task", { alias: X.alias, task: "read this", network_id: NET, attachments: [{ type: "file", file_id: adminFileId }] });
584
+ expect(t.status).toBe(403);
585
+ expect(t.body.error).toBe("attachment_not_accessible");
586
+ const m = await tool(aliceToken, "send_task", { alias: X.alias, task: "read", network_id: NET, meta: { attachments: [{ type: "file", file_id: adminFileId }] } });
587
+ expect(m.error).toBe("attachment_not_accessible");
588
+ expect((await fetch(`${BASE}/api/files/${adminFileId}`, { headers: auth(aliceToken) })).status).toBe(404);
589
+ });
590
+ test("files: a file X sends TO alice becomes downloadable for her", async () => {
591
+ seedTask("t_x_file_to_alice", X.alias, "acl_alice", "here is a file", { attachments: [{ type: "file", file_id: adminFileId }] });
592
+ expect((await fetch(`${BASE}/api/files/${adminFileId}`, { headers: auth(aliceToken) })).status).toBe(200);
593
+ db.run("DELETE FROM tasks WHERE task_id = 't_x_file_to_alice'");
594
+ db.run("DELETE FROM inbox WHERE task_id = 't_x_file_to_alice'");
595
+ });
596
+ test("logs / files / rules via MCP are refused", async () => {
597
+ expect((await tool(aliceToken, "tail_node_logs", { alias: X.alias, network_id: NET })).error).toBe("agent_access_restricted");
598
+ expect((await tool(aliceToken, "read_node_rules_file", { alias: X.alias, network_id: NET })).error).toBe("agent_access_restricted");
599
+ });
600
+ test("no network tokens: node-token / tokens endpoints refuse, and an old ntok stops resolving once restricted", async () => {
601
+ expect((await send(aliceToken, "POST", "/api/auth/node-token", { network_id: NET, node_name: "alice-node" })).body.ok).toBe(false);
602
+ expect((await send(aliceToken, "POST", "/api/auth/tokens", { name: "x", network_id: NET })).body.ok).toBe(false);
603
+ // 一个在「受限」之前签发的网络令牌:
604
+ const ntok = generateNetworkToken();
605
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, 'legacy', 'network')", [generateId("tok"), hashToken(ntok), aliceId, NET]);
606
+ expect((await get(ntok, `/api/status`)).status).toBe(401);
607
+ const res = await fetch(`${BASE}/events/${Z.alias}?network_id=${NET}`, { headers: auth(ntok) });
608
+ expect(res.status).toBe(401);
609
+ await res.body?.cancel();
610
+ });
611
+ test("renaming an agent is refused", async () => {
612
+ const r = await send(aliceToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: X.alias, new_alias: "stolen" });
613
+ expect(r.body.ok).toBe(false);
614
+ });
615
+ test("from spoofing on REST send is refused", async () => {
616
+ const r = await send(aliceToken, "POST", "/api/task", { alias: X.alias, task: "spoof", network_id: NET, from: "admin-name" });
617
+ expect(r.status).toBe(403);
618
+ expect(r.body.error).toBe("from_session_identity_mismatch");
619
+ });
620
+ });
621
+
622
+ describe("lifecycle", () => {
623
+ test("agent_access=all lifts the restriction (legacy member); back to granted restores it", async () => {
624
+ expect((await send(adminToken, "PUT", grantsPath(bobId), { agent_access: "all" })).body.restricted).toBe(false);
625
+ expect(aliases((await get(bobToken, `/api/status?network_id=${NET}`)).body.sessions)).toEqual([X.alias, Y.alias, Z.alias]);
626
+ expect((await send(adminToken, "PUT", grantsPath(bobId), { agent_access: "granted" })).body.restricted).toBe(true);
627
+ expect((await get(bobToken, `/api/status?network_id=${NET}`)).body.sessions).toEqual([]);
628
+ });
629
+ test("revoking a grant takes effect on the next request", async () => {
630
+ expect((await send(adminToken, "PUT", grantsPath(aliceId), { grants: [] })).status).toBe(200);
631
+ expect((await get(aliceToken, `/api/status?network_id=${NET}`)).body.sessions).toEqual([]);
632
+ expect((await send(aliceToken, "POST", "/api/task", { alias: X.alias, task: "again", network_id: NET })).body.error).toBe("agent_not_granted");
633
+ });
634
+ test("removing a member drops their grants; re-adding starts from zero", async () => {
635
+ await send(adminToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: X.node }] });
636
+ expect((await send(adminToken, "DELETE", `/api/networks/${NET}/members/${aliceId}`)).status).toBe(200);
637
+ expect(db.get("SELECT 1 FROM network_member_agent_grants WHERE user_id = ?1", aliceId)).toBeNull();
638
+ expect((await send(adminToken, "POST", `/api/networks/${NET}/members`, { user_id: aliceId, role: "member" })).status).toBe(200);
639
+ expect((await get(adminToken, grantsPath(aliceId))).body).toMatchObject({ agent_access: "granted", grants: [] });
640
+ });
641
+ test("joining by invite code lands restricted and mints no network token", async () => {
642
+ const inv = await send(adminToken, "POST", `/api/networks/${NET}/invite`, { role: "member" });
643
+ const carol = register(`acl_carol_${Date.now()}`, PW);
644
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = ?1 AND network_id = ?2", carol.user!.user_id, NET)!.n;
645
+ const j = await send(carol.token!, "POST", "/api/networks/join", { invite_code: inv.body.invite_code });
646
+ expect(j.status).toBe(200);
647
+ expect((await get(carol.token!, `/api/status?network_id=${NET}`)).body.sessions).toEqual([]);
648
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = ?1 AND network_id = ?2", carol.user!.user_id, NET)!.n).toBe(before);
649
+ });
650
+ test("existing members from before the upgrade keep full access (column default 'all')", async () => {
651
+ const dave = register(`acl_dave_${Date.now()}`, PW);
652
+ db.run("INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'member')", [NET, dave.user!.user_id]);
653
+ expect(aliases((await get(dave.token!, `/api/status?network_id=${NET}`)).body.sessions)).toEqual([X.alias, Y.alias, Z.alias]);
654
+ });
655
+ test("alice still sees her own personal network normally", async () => {
656
+ const r = await get(aliceToken, `/api/status?network_id=${aliceOwnNet}`);
657
+ expect(r.status).toBe(200);
658
+ });
659
+ });