@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,267 @@
1
+ // 多用户账号与 Agent 权限 —— slice 3:剩下的面向 Agent 的路径。
2
+ //
3
+ // slice 1(agent-acl-http.test.ts)把默认作用域做成 fail-closed,并对核心路径逐条放行。
4
+ // 这里补:授权后 task_events 跟随授权 Agent 的时间线(#563)、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 follow X's timeline (#563); messages stay own traffic", () => {
108
+ test("task_events: every task on granted X's timeline is visible, including admin's (#563)", 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.length).toBe(1);
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_admin_x", "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,223 @@
1
+ // Agent 分组的性能守卫(RFC-038 §8):visibleAgents() 每个请求多做一次「组授权 → 组成员」展开,
2
+ // 这里量它的额外开销,并用 EXPLAIN QUERY PLAN 钉住展开查询走索引(不是全表扫)。
3
+ //
4
+ // 场景:NET 里 300 个节点;50 个组,每组 6 个节点(合起来覆盖全部 300 个)。
5
+ // baseline —— bob 直接授权这 300 个节点(没有组);
6
+ // groups —— alice 授权这 50 个组(没有直接授权),可见集合与 bob 完全相同。
7
+ // 两人看到的 Agent 一样多,差值就是「按组展开」本身的代价。团队要求 ≈2 ms 以内(本机实测 −0.5~+1.9 ms);
8
+ // 断言放宽到 max(5 ms, 基线的 15%)—— CI 容器比本机慢时两边一起变慢,绝对差也跟着放大。数字打印出来。
9
+ // #517:差值怎么量见 lowDelta*() —— 交替且每轮翻转先后、样本 40 对、比两边的 p20(不是每轮差值的中位数)、
10
+ // 一轮超标再重量最多 2 轮。界限本身(max(5 ms, 15%))没动。
11
+
12
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
13
+ import { mkdtempSync, rmSync } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+ import { register } from "./auth.js";
17
+ import { db } from "./db.js";
18
+ import { visibleAgents } from "./agent-access.js";
19
+
20
+ const DIR = mkdtempSync(join(tmpdir(), "anet-agent-groups-perf-"));
21
+ let BASE = "";
22
+ let hub: any = null;
23
+ const PW = "GroupPerfPassw0rd!x";
24
+ let NET = "";
25
+ let adminToken = "";
26
+ let adminId = "";
27
+ let alice = { token: "", id: "" };
28
+ let bob = { token: "", id: "" };
29
+ const NODES = 300;
30
+ const GROUPS = 50;
31
+ const PER_GROUP = NODES / GROUPS;
32
+ const BOUND_MS = 5;
33
+ const bound = (base: number) => Math.max(BOUND_MS, base * 0.15);
34
+ // CI 容器里一次 visibleAgents() 约 65 ms(本机约 28 ms):原来 40 次 × 两边再加预热,顶破了 bun 默认的 5 s 单测超时(#2131 CI 实测)。
35
+ // 性能用例另给显式超时(差值守卫最坏 3 轮 × 40 对,见 lowDelta)。
36
+ const WARMUP = 3;
37
+ const PERF_TIMEOUT_MS = 60_000;
38
+ // #517 差值守卫:每轮 DELTA_PAIRS 对、比两边的 LOW_P 分位、超标再量最多 RETRIES 轮。
39
+ const DELTA_PAIRS = 40, LOW_P = 0.2, RETRIES = 2;
40
+
41
+ const json = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
42
+ async function send(token: string, method: string, path: string, payload?: unknown) {
43
+ const res = await fetch(`${BASE}${path}`, { method, headers: json(token), body: payload === undefined ? undefined : JSON.stringify(payload) });
44
+ return { status: res.status, body: await res.json().catch(() => null) as any };
45
+ }
46
+ const median = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
47
+ /**
48
+ * 交替计时:每一轮先量 A 再量 B,取「B − A」的中位数(再附上两边各自的中位数给人看)。
49
+ * 不能 A 连量 N 次再 B 连量 N 次:test638 在同一台机器上并发跑两份 aggregate,负载随时间变,
50
+ * 两段分开量时差值被负载漂移主导(--cpus=1 实测差出 26–33 ms,真实差值 < 1 ms)。
51
+ */
52
+ function pairedDelta(a: () => void, b: () => void, n: number): { a: number; b: number; delta: number } {
53
+ for (let i = 0; i < WARMUP; i++) { a(); b(); }
54
+ const as: number[] = [], bs: number[] = [], ds: number[] = [];
55
+ for (let i = 0; i < n; i++) {
56
+ let t = performance.now(); a(); const ta = performance.now() - t;
57
+ t = performance.now(); b(); const tb = performance.now() - t;
58
+ as.push(ta); bs.push(tb); ds.push(tb - ta);
59
+ }
60
+ return { a: median(as), b: median(bs), delta: median(ds) };
61
+ }
62
+ const pct = (xs: number[], p: number) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
63
+ type LowDelta = { a: number; b: number; delta: number; aP50: number; bP50: number; ok: boolean };
64
+ /**
65
+ * #517 —— 「组授权比直接授权多出多少」的稳健量法。旧做法(15 对、每轮固定先 A 后 B、取每轮差值中位数)在 test638
66
+ * 两份 aggregate 并发时红过(本地 2 核 + 忙循环邻居实测 paired-delta 7.34 ms > 5 ms,同一轮两边中位数差出 10 ms)——
67
+ * 量的是负载,不是组展开。现在:
68
+ * - 每轮翻转先后(偶数轮 A→B,奇数轮 B→A),「第二个总吃前一个的 GC / 调度」这类顺序偏差两边对半分;
69
+ * - 每边 DELTA_PAIRS 个样本,比两边各自的 LOW_P 分位:调度噪声只会加时间,低尾最接近真实成本;
70
+ * - 一轮超标就整轮重量,最多 RETRIES 次;真回归(组路径每节点多查一次,#2133 那种 N+1)每一轮都超。
71
+ */
72
+ function lowDelta(a: () => void, b: () => void, bound: (base: number) => number): LowDelta {
73
+ let r: LowDelta | null = null;
74
+ for (let attempt = 0; attempt <= RETRIES; attempt++) {
75
+ for (let i = 0; i < WARMUP; i++) { a(); b(); }
76
+ const as: number[] = [], bs: number[] = [];
77
+ for (let i = 0; i < DELTA_PAIRS; i++) {
78
+ const time = (f: () => void) => { const t = performance.now(); f(); return performance.now() - t; };
79
+ if (i % 2 === 0) { as.push(time(a)); bs.push(time(b)); } else { bs.push(time(b)); as.push(time(a)); }
80
+ }
81
+ const lowA = pct(as, LOW_P), lowB = pct(bs, LOW_P);
82
+ r = { a: lowA, b: lowB, delta: lowB - lowA, aP50: pct(as, 0.5), bP50: pct(bs, 0.5), ok: lowB - lowA <= bound(lowA) };
83
+ if (r.ok) return r;
84
+ }
85
+ return r!;
86
+ }
87
+ async function lowDeltaAsync(a: () => Promise<void>, b: () => Promise<void>, bound: (base: number) => number, label: string): Promise<LowDelta> {
88
+ let r: LowDelta | null = null;
89
+ for (let attempt = 0; attempt <= RETRIES; attempt++) {
90
+ for (let i = 0; i < WARMUP; i++) { await a(); await b(); }
91
+ const as: number[] = [], bs: number[] = [];
92
+ const time = async (f: () => Promise<void>) => { const t = performance.now(); await f(); return performance.now() - t; };
93
+ for (let i = 0; i < DELTA_PAIRS; i++) {
94
+ if (i % 2 === 0) { as.push(await time(a)); bs.push(await time(b)); } else { bs.push(await time(b)); as.push(await time(a)); }
95
+ }
96
+ const lowA = pct(as, LOW_P), lowB = pct(bs, LOW_P);
97
+ r = { a: lowA, b: lowB, delta: lowB - lowA, aP50: pct(as, 0.5), bP50: pct(bs, 0.5), ok: lowB - lowA <= bound(lowA) };
98
+ console.log(`[perf] ${label} pass ${attempt + 1}: p${LOW_P * 100} direct-300=${lowA.toFixed(2)}ms groups-50x6=${lowB.toFixed(2)}ms delta=${r.delta.toFixed(2)}ms bound=${bound(lowA).toFixed(2)}ms (p50 ${r.aP50.toFixed(2)} / ${r.bP50.toFixed(2)}) ${r.ok ? "ok" : "OVER"}`);
99
+ if (r.ok) return r;
100
+ }
101
+ return r!;
102
+ }
103
+
104
+ beforeAll(async () => {
105
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
106
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
107
+ process.env.HOST = "127.0.0.1";
108
+ const admin = register(`perf_admin_${Date.now()}`, PW, undefined, "Admin");
109
+ expect(admin.ok).toBe(true);
110
+ adminToken = admin.token!;
111
+ adminId = admin.user!.user_id;
112
+ NET = admin.network_id!;
113
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
114
+ db.transaction(() => {
115
+ for (let i = 0; i < NODES; i++) {
116
+ const node = `node_perf_${i}`;
117
+ const alias = `perf-agent-${i}`;
118
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`, [node, alias, NET, adminId]);
119
+ 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'))`, [`resume_${node}`, alias, node, NET]);
120
+ }
121
+ });
122
+ const mod: any = await import("./server.js");
123
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
124
+ BASE = `http://127.0.0.1:${hub.port}`;
125
+
126
+ const mk = async (username: string) => {
127
+ const r = await send(adminToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
128
+ expect(r.status).toBe(200);
129
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
130
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
131
+ };
132
+ alice = await mk(`perf_alice_${Date.now()}`);
133
+ bob = await mk(`perf_bob_${Date.now()}`);
134
+
135
+ const groupIds: string[] = [];
136
+ for (let g = 0; g < GROUPS; g++) {
137
+ const nodeIds = Array.from({ length: PER_GROUP }, (_, k) => `node_perf_${g * PER_GROUP + k}`);
138
+ const r = await send(adminToken, "POST", `/api/networks/${NET}/agent-groups`, { name: `perf-group-${g}`, node_ids: nodeIds });
139
+ expect(r.status).toBe(200);
140
+ groupIds.push(r.body.group.group_id);
141
+ }
142
+ expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${alice.id}/agent-grants`, { group_grants: groupIds })).status).toBe(200);
143
+ expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${bob.id}/agent-grants`, { grants: Array.from({ length: NODES }, (_, i) => `node_perf_${i}`) })).status).toBe(200);
144
+ }, 60_000);
145
+
146
+ afterAll(() => {
147
+ try { hub?.stop?.(true); } catch {}
148
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
149
+ });
150
+
151
+ describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
152
+ test("两人可见集合相同(比较才公平)", () => {
153
+ const a = visibleAgents(alice.id, NET);
154
+ const b = visibleAgents(bob.id, NET);
155
+ expect(a.nodeIds.length).toBe(NODES);
156
+ expect([...a.nodeIds].sort()).toEqual([...b.nodeIds].sort());
157
+ });
158
+
159
+ test(`visibleAgents():组展开的额外开销 ≤ max(${BOUND_MS} ms, 基线 15%)(目标 ≈2 ms)`, () => {
160
+ const r = lowDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, bound);
161
+ console.log(`[perf] visibleAgents p${LOW_P * 100}: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms (p50 ${r.aP50.toFixed(2)} / ${r.bP50.toFixed(2)})`);
162
+ expect(r.delta).toBeLessThanOrEqual(bound(r.a));
163
+ }, PERF_TIMEOUT_MS);
164
+
165
+ // #2133:visibleAgents() 以前每个授权节点查一次 alias(300 条 ≈ 28–43 ms)。这两条钉住它不再回到 N+1:
166
+ // 查询条数(直接授权 1 + 组授权 1 + alias 1)是主判据,不受机器负载影响;绝对上限只兜底 ——
167
+ // 本机实测 ≈2.6–3.7 ms(负载 ~5),15 ms 给 test638 那种 --cpus=1 双份并发留足余量,仍远低于 N+1 的 28 ms。
168
+ const ABS_MS = 15;
169
+ test(`visibleAgents() at 300 grants stays under ${ABS_MS} ms (direct and via groups)`, () => {
170
+ const t = pairedDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, 40);
171
+ console.log(`[perf] visibleAgents absolute: direct-300=${t.a.toFixed(2)}ms groups-50x6=${t.b.toFixed(2)}ms bound=${ABS_MS}ms`);
172
+ expect(t.a).toBeLessThan(ABS_MS);
173
+ expect(t.b).toBeLessThan(ABS_MS);
174
+ });
175
+
176
+ test("visibleAgents() makes a fixed number of queries, not one per granted node (#2133)", () => {
177
+ for (const who of [bob, alice]) {
178
+ let queries = 0;
179
+ const all = db.all.bind(db), get = db.get.bind(db);
180
+ (db as any).all = (...a: any[]) => { queries++; return (all as any)(...a); };
181
+ (db as any).get = (...a: any[]) => { queries++; return (get as any)(...a); };
182
+ try { visibleAgents(who.id, NET); } finally { (db as any).all = all; (db as any).get = get; }
183
+ console.log(`[perf] visibleAgents queries (${who === bob ? "direct-300" : "groups-50x6"}): ${queries}`);
184
+ expect(queries).toBeLessThanOrEqual(3);
185
+ }
186
+ });
187
+
188
+ test(`受限成员 GET /api/status:组授权比等量直接授权多出 ≤ max(${BOUND_MS} ms, 基线 15%)`, async () => {
189
+ const hit = async (token: string) => {
190
+ const r = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: json(token) });
191
+ const body = await r.json() as any;
192
+ if ((body.sessions ?? []).length !== NODES) throw new Error(`expected ${NODES} sessions, got ${(body.sessions ?? []).length}`);
193
+ };
194
+ const r = await lowDeltaAsync(() => hit(bob.token), () => hit(alice.token), bound, "GET /api/status");
195
+ expect(r.delta).toBeLessThanOrEqual(bound(r.a));
196
+ }, PERF_TIMEOUT_MS);
197
+
198
+ test("EXPLAIN QUERY PLAN:组展开走索引,不全表扫描", () => {
199
+ const plan = db.all<{ detail: string }>(
200
+ `EXPLAIN QUERY PLAN
201
+ SELECT m.node_id, g.can_message
202
+ FROM network_member_group_grants g
203
+ JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
204
+ JOIN agent_group_members m ON m.group_id = g.group_id
205
+ JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
206
+ WHERE g.network_id = ?1 AND g.user_id = ?2`,
207
+ NET, alice.id,
208
+ ).map((r) => r.detail);
209
+ console.log(`[perf] plan:\n ${plan.join("\n ")}`);
210
+ // 四张表都是 SEARCH(按主键 / 索引定位),没有一张是 SCAN。
211
+ expect(plan.length).toBeGreaterThanOrEqual(4);
212
+ expect(plan.filter((d) => /^SCAN\b/.test(d))).toEqual([]);
213
+ expect(plan.every((d) => /^SEARCH\b/.test(d) && /(PRIMARY KEY|INDEX)/.test(d))).toBe(true);
214
+ });
215
+
216
+ test("EXPLAIN QUERY PLAN:按组找被授权成员(改组成员时断流用)走 group_id 索引", () => {
217
+ const plan = db.all<{ detail: string }>(
218
+ "EXPLAIN QUERY PLAN SELECT user_id FROM network_member_group_grants WHERE group_id = ?1", "x",
219
+ ).map((r) => r.detail);
220
+ console.log(`[perf] plan (usersGrantedGroup): ${plan.join(" | ")}`);
221
+ expect(plan.some((d) => /SEARCH .*idx_group_grants_group/.test(d))).toBe(true);
222
+ });
223
+ });
@@ -0,0 +1,48 @@
1
+ // Hub 以某个节点的名义发给一个用户的一条通知(#462 登录失效、#464 定时任务卡住)。
2
+ //
3
+ // 走 user_inbox + /events/users/me 的 desktop_message —— 与 send_desktop_message 同一张表、同一条推送,
4
+ // from_session = 节点别名,所以 App 把它显示在**与该节点的会话**里(主动消息)+ 未读角标 + 通知。不新增渠道。
5
+ // 只发给一个人;他不在本网络了就不发。推送在写库之后:收到推送的 App 立刻回读得到这一行。
6
+
7
+ import { db, uuidv4 } from "./db.js";
8
+ import { getUserNetworkRole } from "./auth.js";
9
+ import { pushUserEvent } from "./push.js";
10
+
11
+ export type AgentNotice = {
12
+ networkId: string;
13
+ userId: string;
14
+ /** 节点别名:通知出现在与它的会话里。 */
15
+ fromAlias: string;
16
+ kind: string;
17
+ title: string;
18
+ text: string;
19
+ severity?: "info" | "warning" | "error";
20
+ meta: Record<string, unknown>;
21
+ /** 消息 id 前缀(`dm_` 开头,与 App 的去重键同一套)。 */
22
+ idPrefix: string;
23
+ };
24
+
25
+ /** 返回 message_id;没发(收件人不在本网络)→ null。 */
26
+ export function sendAgentNotice(n: AgentNotice): string | null {
27
+ if (!getUserNetworkRole(n.userId, n.networkId)) return null;
28
+ const messageId = `${n.idPrefix}${uuidv4()}`;
29
+ const severity = n.severity ?? "warning";
30
+ db.run(
31
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
32
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
33
+ ON CONFLICT(message_id) DO NOTHING`,
34
+ [messageId, n.networkId, n.userId, n.fromAlias, n.kind, n.title, n.text, severity, JSON.stringify(n.meta)],
35
+ );
36
+ pushUserEvent(n.networkId, n.userId, {
37
+ type: "desktop_message",
38
+ message_id: messageId,
39
+ kind: n.kind,
40
+ from: n.fromAlias,
41
+ title: n.title,
42
+ message: n.text,
43
+ severity,
44
+ created_at: new Date().toISOString(),
45
+ meta: n.meta,
46
+ });
47
+ return messageId;
48
+ }
@@ -0,0 +1,98 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+
4
+ process.env.COMMHUB_DB ||= `${mkdtempSync('/tmp/team-whoami-')}/hub.db`;
5
+ let db: any, hub: any, base: string, owner: any, outside: any;
6
+ let selfToken: string, otherToken: string, outsideToken: string, unboundToken: string;
7
+ const SELF = 'node_team_whoami_self', OTHER = 'node_team_whoami_other', FOREIGN = 'node_team_whoami_foreign';
8
+ const TEAM = 'team_whoami_child', ROOT = 'team_whoami_root';
9
+
10
+ async function rpc(token: string, method = 'tools/call', params: unknown = { name: 'org_whoami', arguments: {} }) {
11
+ const response = await fetch(`${base}/mcp`, { method: 'POST', headers: {
12
+ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
13
+ Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-03-26',
14
+ }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
15
+ expect(response.status).toBe(200);
16
+ const text = await response.text();
17
+ const line = text.split('\n').filter(x => x.startsWith('data:')).at(-1);
18
+ const body = JSON.parse(line ? line.slice(5) : text);
19
+ expect(body.error).toBeUndefined();
20
+ return body.result;
21
+ }
22
+ async function who(token = selfToken, args: Record<string, unknown> = {}) {
23
+ const result = await rpc(token, 'tools/call', { name: 'org_whoami', arguments: args });
24
+ return JSON.parse(result.content[0].text);
25
+ }
26
+
27
+ beforeAll(async () => {
28
+ ({ db } = await import('./db.js'));
29
+ const { register, createNetworkTokenForNode } = await import('./auth.js');
30
+ owner = register('team_whoami_owner', 'Fixture-Passw0rd!xyz');
31
+ outside = register('team_whoami_outside', 'Fixture-Passw0rd!xyz');
32
+ db.run("UPDATE users SET display_name = 'Team owner' WHERE user_id = ?1", [owner.user.user_id]);
33
+ const mint = (u: any, alias: string, node?: string) => {
34
+ const r = createNetworkTokenForNode(u.user.user_id, u.network_id, alias, node);
35
+ expect(r.error ?? null).toBe(null); return r.token!;
36
+ };
37
+ selfToken = mint(owner, 'team-self', SELF);
38
+ otherToken = mint(owner, 'team-other', OTHER);
39
+ outsideToken = mint(outside, 'team-self', FOREIGN); // Same alias in a different network.
40
+ unboundToken = mint(owner, 'team-unregistered');
41
+ const { bootServer } = await import('./server.js');
42
+ hub = bootServer({ port: 0, hostname: '127.0.0.1' });
43
+ base = `http://127.0.0.1:${hub.port}`;
44
+ for (const [net, id, name, parent] of [
45
+ [owner.network_id, ROOT, 'Parent', null], [owner.network_id, TEAM, 'My team', ROOT],
46
+ [outside.network_id, TEAM, 'Foreign team', null],
47
+ ]) db.run('INSERT INTO network_agent_teams(network_id,team_id,name,parent_id) VALUES(?1,?2,?3,?4)', [net,id,name,parent]);
48
+ db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]);
49
+ for (const [net, node] of [[owner.network_id, SELF], [outside.network_id, FOREIGN]]) {
50
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [net,node,TEAM]);
51
+ }
52
+ // Even a stale/corrupt membership must not project a node from another network.
53
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,FOREIGN,TEAM]);
54
+ }, 60_000);
55
+ afterAll(() => hub?.stop(true));
56
+
57
+ test('exact node team, nearest-first ancestors, Agent lead and private human display name', async () => {
58
+ const r = await who();
59
+ expect(r.ok).toBe(true); expect(r.source).toBe('node'); expect(r.node_id).toBe(SELF);
60
+ expect(r.team).toEqual({ id: TEAM, name: 'My team' });
61
+ expect(r.ancestors).toEqual([{ id: ROOT, name: 'Parent' }]);
62
+ expect(r.lead.node_id).toBe(SELF);
63
+ expect(r.owner).toEqual({ user_id: owner.user.user_id, display_name: 'Team owner' });
64
+ expect(r.agents.map((n: any) => n.node_id)).toEqual([SELF]);
65
+ expect(r.truncated).toBe(false);
66
+ expect(JSON.stringify(r)).not.toContain('team_whoami_owner');
67
+ });
68
+ test('same-id foreign team and foreign node cannot cross network; supplied identity is ignored', async () => {
69
+ const r = await who(selfToken, { network_id: outside.network_id, node_id: FOREIGN, alias: 'team-other' });
70
+ expect(r.node_id).toBe(SELF); expect(r.team.name).toBe('My team');
71
+ expect(JSON.stringify(r)).not.toContain(FOREIGN);
72
+ const b = await who(outsideToken);
73
+ expect(b.team.name).toBe('Foreign team'); expect(b.node_id).toBe(FOREIGN);
74
+ expect(b.ancestors).toEqual([]); expect(JSON.stringify(b)).not.toContain(SELF);
75
+ });
76
+ test('unassigned node does not inherit its owner or another node team', async () => {
77
+ expect(await who(otherToken)).toEqual({ ok: true, source: 'none', node_id: OTHER, team: null,
78
+ ancestors: [], lead: null, owner: null, agents: [], truncated: false });
79
+ expect((await who(unboundToken)).error).toBe('node_identity_unbound');
80
+ });
81
+ test('user credentials cannot list or call; node credentials can list', async () => {
82
+ expect((await rpc(owner.token, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(false);
83
+ expect((await who(owner.token)).error).toBe('network_token_required');
84
+ expect((await rpc(selfToken, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(true);
85
+ });
86
+ test('foreign lead and human owner references are not returned', async () => {
87
+ db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [FOREIGN,outside.user.user_id,owner.network_id,TEAM]);
88
+ try { const r = await who(); expect(r.lead).toBe(null); expect(r.owner).toBe(null); }
89
+ finally { db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]); }
90
+ });
91
+ test('large teams are bounded to 50 and explicitly truncated', async () => {
92
+ for (let i = 0; i < 51; i++) {
93
+ const id = `node_team_whoami_cap_${i}`;
94
+ db.run('INSERT INTO nodes(node_id,node_name,alias,network_id) VALUES(?1,?1,?1,?2)', [id,owner.network_id]);
95
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,id,TEAM]);
96
+ }
97
+ const r = await who(); expect(r.agents.length).toBe(50); expect(r.truncated).toBe(true);
98
+ });