@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,139 @@
1
+ // #488 —— 网络令牌只在它的用户还是这个网络的成员时有效(RFC-041 §1)。
2
+ //
3
+ // 以前:被移出网络的成员留下的节点令牌照样解析 —— MCP 每个工具都拒(not a member),REST 读却拿得到整个网络
4
+ // (GET /api/status 返回全部名册)。受限成员的令牌本来就在 resolveToken 被拒,但 401 只写 unauthorized。
5
+ //
6
+ // 钉住:
7
+ // (i) 非成员的网络令牌:每个 REST GET 与 /mcp 都 401,reason = not_network_member(下面 GETS 是逐条探过的读路径)。
8
+ // (ii) removeNetworkMember 在同一个事务里吊销这个人在该网络的令牌(revoked_at),别的网络的令牌不动。
9
+ // (iii) 受限成员的节点令牌:401 带 reason = node_owner_restricted + hint;不认识的令牌只有 unauthorized。
10
+ // 成员的节点令牌、不受限的情况与以前完全一样。
11
+ //
12
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/network-token-membership-http.test.ts
13
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
14
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
+ import { addNetworkMember, createNetworkTokenForNode, createNetwork, register, removeNetworkMember } from "./auth.js";
16
+ import { replaceAgentGrants } from "./agent-access.js";
17
+ import { db } from "./db.js";
18
+
19
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
20
+ if (!activeDbPath) throw new Error("network-token-membership requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
21
+
22
+ const stamp = Date.now();
23
+ const PW = "TokenMember123!";
24
+ const OTHER = `other-${stamp}`; // 网络里别人的 Agent —— 出现在响应里 = 读到了不该读的
25
+ let server: any, base = "", net = "", net2 = "", taskId = "";
26
+ const tok: Record<string, string> = {};
27
+ const ids: Record<string, string> = {};
28
+
29
+ const get = (t: string, path: string) =>
30
+ fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${t}` } })
31
+ .then(async (r) => ({ status: r.status, text: await r.text() }));
32
+ const mcp = (t: string, name: string, args: Record<string, unknown>) =>
33
+ fetch(`${base}/mcp`, {
34
+ method: "POST",
35
+ headers: { Authorization: `Bearer ${t}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
36
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
37
+ }).then(async (r) => ({ status: r.status, text: await r.text() }));
38
+
39
+ // 拿网络令牌能调的 REST GET 读路径(server.ts 里 restScope 之后的每个 GET,加上之前自己解析令牌的 /api/auth/me、/api/networks)。
40
+ const GETS = () => [
41
+ "/api/auth/me", "/api/networks",
42
+ `/api/status?network_id=${net}`, `/api/status?light=1`, "/api/servers", "/api/server/unknown/health", "/api/server/unknown/agents",
43
+ "/api/messages", "/api/messages?scope=user", "/api/stats", "/api/stats/sse", "/api/stats/routes", "/api/server-logs", "/api/audit-log",
44
+ "/api/task_events", "/api/nodes", `/api/nodes/${ids.other}/config`, "/api/node-create-requests", "/api/host-supervisors",
45
+ `/api/task/${taskId}`, "/api/tasks", "/api/completions", "/api/dm/threads", "/api/requirements",
46
+ ];
47
+
48
+ beforeAll(async () => {
49
+ const boss = register(`tm_boss_${stamp}`, PW); net = boss.network_id!; ids.boss = boss.user!.user_id; tok.bossUser = boss.token!;
50
+ for (const k of ["exm", "rem", "mia", "stay"]) {
51
+ const u = register(`tm_${k}_${stamp}`, PW); ids[k] = u.user!.user_id;
52
+ expect(addNetworkMember(net, ids[k], "member", ids.boss, { agentAccess: "all" }).ok).toBe(true);
53
+ const t = createNetworkTokenForNode(ids[k], net, `${k}-${stamp}`, `n_tm_${k}_${stamp}`);
54
+ expect(t.ok).toBe(true);
55
+ tok[k] = t.token!;
56
+ }
57
+ // rem 在另一个网络里也有令牌 —— 被移出 net 时那枚不能被一起吊销。
58
+ const n2 = createNetwork(ids.rem, `tm-net2-${stamp}`); expect(n2.ok).toBe(true); net2 = n2.network_id!;
59
+ const t2 = createNetworkTokenForNode(ids.rem, net2, `rem2-${stamp}`, `n_tm_rem2_${stamp}`); tok.rem2 = t2.token!;
60
+
61
+ ids.other = `n_tm_other_${stamp}`;
62
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)", [ids.other, OTHER, net, ids.boss]);
63
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, server) VALUES (?1, ?2, 'idle', ?3, ?4, 'unknown')", [`r_${ids.other}`, OTHER, ids.other, net]);
64
+ taskId = `task_tm_${stamp}`;
65
+ db.run("INSERT INTO tasks (task_id, from_name, to_name, to_node_id, content, status, network_id) VALUES (?1, 'boss', ?2, ?3, 'secret plan', 'pending', ?4)", [taskId, OTHER, ids.other, net]);
66
+
67
+ // exm:老数据 —— 成员行没了,令牌还在(移出前的版本不吊销)。rem:走真实的 removeNetworkMember。
68
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [net, ids.exm]);
69
+ expect(removeNetworkMember(net, ids.rem).ok).toBe(true);
70
+ // mia:铸完令牌后被收窄成只看授权的 Agent。
71
+ expect(replaceAgentGrants({ networkId: net, userId: ids.mia, grants: [], agentAccess: "granted", actorUserId: ids.boss }).ok).toBe(true);
72
+
73
+ const mod: any = await import("./server.js");
74
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
75
+ base = `http://127.0.0.1:${server.port}`;
76
+ }, 60_000);
77
+ afterAll(() => { server?.stop?.(true); });
78
+
79
+ describe("#488 (i) a network token whose user left the network", () => {
80
+ test("every REST GET read is 401 not_network_member and leaks nothing", async () => {
81
+ const leaks: string[] = [];
82
+ for (const path of GETS()) {
83
+ const r = await get(tok.exm, path);
84
+ if (r.status !== 401 || r.text.includes(OTHER) || r.text.includes("secret plan")) leaks.push(`${path} → ${r.status}`);
85
+ else expect(JSON.parse(r.text).reason).toBe("not_network_member");
86
+ }
87
+ expect(leaks).toEqual([]);
88
+ });
89
+
90
+ test("/mcp is 401 too, with the reason", async () => {
91
+ const r = await mcp(tok.exm, "get_all_status", {});
92
+ expect(r.status).toBe(401);
93
+ expect(JSON.parse(r.text).reason).toBe("not_network_member");
94
+ expect(JSON.parse(r.text).hint).toContain("no longer a member");
95
+ });
96
+ });
97
+
98
+ describe("#488 (ii) removeNetworkMember revokes the member's tokens for that network", () => {
99
+ test("revoked_at is set for that network only, and the token is rejected", async () => {
100
+ const rows = db.all<{ network_id: string; revoked_at: string | null }>("SELECT network_id, revoked_at FROM api_tokens WHERE user_id = ?1 AND network_id IS NOT NULL", ids.rem);
101
+ expect(rows.filter((r) => r.network_id === net).length).toBeGreaterThan(0);
102
+ expect(rows.filter((r) => r.network_id === net).every((r) => !!r.revoked_at)).toBe(true);
103
+ expect(rows.filter((r) => r.network_id === net2).every((r) => !r.revoked_at)).toBe(true);
104
+ expect((await get(tok.rem, `/api/status?network_id=${net}`)).status).toBe(401);
105
+ expect((await get(tok.rem2, "/api/status")).status).toBe(200);
106
+ });
107
+ });
108
+
109
+ describe("#488 (iii) a restricted owner's node token says why it is refused", () => {
110
+ test("401 with reason node_owner_restricted + hint on REST and /mcp", async () => {
111
+ for (const r of [await get(tok.mia, "/api/status"), await mcp(tok.mia, "report_status", { resume_id: "r", alias: `mia-${stamp}`, status: "idle" })]) {
112
+ expect(r.status).toBe(401);
113
+ const body = JSON.parse(r.text);
114
+ expect(body.reason).toBe("node_owner_restricted");
115
+ expect(body.hint).toContain("Agent access");
116
+ }
117
+ const me = await get(tok.mia, "/api/auth/me");
118
+ expect(me.status).toBe(401);
119
+ expect(JSON.parse(me.text).reason).toBe("node_owner_restricted");
120
+ });
121
+
122
+ test("an unknown token gets no reason (nothing to explain, nothing leaked)", async () => {
123
+ const r = await get("ntok_doesnotexist", "/api/status");
124
+ expect(r.status).toBe(401);
125
+ expect(JSON.parse(r.text)).toEqual({ error: "unauthorized" });
126
+ });
127
+ });
128
+
129
+ describe("#488 unchanged for members", () => {
130
+ test("an all-access member's node token reads and heartbeats as before", async () => {
131
+ const st = await get(tok.stay, `/api/status?network_id=${net}`);
132
+ expect(st.status).toBe(200);
133
+ expect(st.text).toContain(OTHER);
134
+ const hb = await mcp(tok.stay, "report_status", { resume_id: `r_stay_${stamp}`, alias: `stay-${stamp}`, status: "idle" });
135
+ expect(hb.status).toBe(200);
136
+ expect(hb.text).toContain('\\"ok\\":true');
137
+ expect((await get(tok.bossUser, `/api/task/${taskId}`)).status).toBe(200);
138
+ });
139
+ });
@@ -0,0 +1,12 @@
1
+ // C0/C1 controls plus Unicode's two dedicated line separators. The latter are
2
+ // not General_Category=Cc, but they still split log/source-prefix lines.
3
+ const CONTROL_CHARACTER = /[\p{Cc}\u2028\u2029]/u;
4
+
5
+ export function aliasControlCharacter(alias: string): string | null {
6
+ return [...alias].find((char) => CONTROL_CHARACTER.test(char)) ?? null;
7
+ }
8
+
9
+ export function describeAliasControlCharacter(char: string): string {
10
+ const codePoint = char.codePointAt(0) ?? 0;
11
+ return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
12
+ }
@@ -0,0 +1,88 @@
1
+ // Boundary validation for node DISPLAY attributes (display_name / team /
2
+ // tags). Mirrors `avatar-validate.ts` in spirit: this is the only place
3
+ // that decides what a client is allowed to store, and it trusts nothing.
4
+ //
5
+ // Two different postures on purpose:
6
+ //
7
+ // display_name / team — SCALAR fields the user typed. A wrong TYPE here
8
+ // is a client bug, so it is REJECTED (400) rather than coerced: silently
9
+ // turning `{}` into "[object Object]" would persist garbage the user
10
+ // never typed and can't explain.
11
+ //
12
+ // tags — a LIST. Same reasoning as the `channels` patch in tools.ts: the
13
+ // wire contract wants junk dropped instead of failing the whole request,
14
+ // so one fat-fingered entry from a dashboard doesn't 400 the save. Each
15
+ // element is narrowed independently (typeof → trim → length cap →
16
+ // dedup), so nothing non-string can reach the database.
17
+
18
+ /** Max stored length of display_name / team. */
19
+ export const MAX_ATTR_LEN = 120;
20
+ /** Max stored length of a single tag. */
21
+ export const MAX_TAG_LEN = 64;
22
+ /** Max number of tags kept per node. */
23
+ export const MAX_TAGS = 16;
24
+
25
+ export type ScalarAttrResult =
26
+ | { ok: true; value: string | null }
27
+ | { ok: false; reason: string };
28
+
29
+ /**
30
+ * Narrow an optional scalar display attribute.
31
+ * undefined → not part of this patch (caller leaves the column alone)
32
+ * null / "" → explicit clear
33
+ * non-string → rejected (never coerced)
34
+ * over-long → rejected (the user should see it, not silently lose text)
35
+ */
36
+ export function validateScalarAttr(raw: unknown, field: string): ScalarAttrResult {
37
+ if (raw === undefined) return { ok: true, value: null };
38
+ if (raw === null) return { ok: true, value: null };
39
+ if (typeof raw !== "string") {
40
+ return { ok: false, reason: `${field} must be a string or null` };
41
+ }
42
+ const trimmed = raw.trim();
43
+ if (trimmed.length === 0) return { ok: true, value: null };
44
+ if (trimmed.length > MAX_ATTR_LEN) {
45
+ return { ok: false, reason: `${field} exceeds ${MAX_ATTR_LEN} characters` };
46
+ }
47
+ // Control characters would corrupt any log line / table cell that renders
48
+ // the value; strip rather than reject (they are almost always a paste
49
+ // artefact, not user intent).
50
+ // eslint-disable-next-line no-control-regex
51
+ const clean = trimmed.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
52
+ return { ok: true, value: clean.length > 0 ? clean : null };
53
+ }
54
+
55
+ /**
56
+ * Narrow an untrusted tags array. NEVER throws, never rejects the request:
57
+ * unusable entries are dropped. Returns the canonical list to store.
58
+ */
59
+ export function narrowTags(raw: unknown): string[] {
60
+ if (!Array.isArray(raw)) return [];
61
+ const out: string[] = [];
62
+ const seen = new Set<string>();
63
+ for (const item of raw) {
64
+ if (typeof item !== "string") continue; // numbers/objects/arrays/null
65
+ // eslint-disable-next-line no-control-regex
66
+ const t = item.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
67
+ if (t.length === 0) continue;
68
+ if (t.length > MAX_TAG_LEN) continue; // absurd entry — drop, don't truncate
69
+ const key = t.toLowerCase(); // case-fold for dedup, keep first spelling
70
+ if (seen.has(key)) continue;
71
+ seen.add(key);
72
+ out.push(t);
73
+ if (out.length >= MAX_TAGS) break;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Parse the stored tags column back into an array. Storage is JSON text;
79
+ * anything unparseable (hand-edited DB, older format) reads as empty so a
80
+ * dashboard can always `.map()` without a guard. */
81
+ export function parseStoredTags(stored: unknown): string[] {
82
+ if (typeof stored !== "string" || stored.length === 0) return [];
83
+ try {
84
+ return narrowTags(JSON.parse(stored));
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
@@ -0,0 +1,217 @@
1
+ // Node display attributes — team / tags / display_name (Vincent 目标第 3 条).
2
+ //
3
+ // These are HUB-SIDE DISPLAY attributes. Following the `avatar_url` (#462)
4
+ // precedent they are deliberately NOT part of the RFC-024 config-apply
5
+ // pipeline: agent-node has nothing to consume, so pushing them through
6
+ // config revision/ack would ring a doorbell for a field the node does not
7
+ // understand and could wedge the config revision.
8
+ //
9
+ // They DO need concurrency protection (two dashboards editing the same
10
+ // node), which `avatar_url` lacks. Hence a dedicated `attrs_revision` CAS
11
+ // that is independent of `config_revision` — editing a tag must never bump
12
+ // the node's *config* revision.
13
+ //
14
+ // Every test drives the REAL HTTP endpoint against a booted server, not a
15
+ // helper: the contract under test is the wire shape + status codes.
16
+ //
17
+ // NOTE (test isolation): this file boots its own Bun.serve, same pattern as
18
+ // uploads-http.test.ts / api-host-supervisors-fallback.test.ts. Those files
19
+ // are per-file runs by design (single-process module-singleton conflict,
20
+ // tracked in #434) — run this file on its own.
21
+
22
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
23
+ import { mkdtempSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+
27
+ const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
28
+ process.env.COMMHUB_DB ||= tmpDb;
29
+ const PORT = 23000 + Math.floor(Math.random() * 1500);
30
+ process.env.PORT = String(PORT);
31
+ process.env.HOST = "127.0.0.1";
32
+
33
+ const { db } = await import("./db.js");
34
+ const { issueUserToken } = await import("./auth.js");
35
+ const { bootServer } = await import("./server.js");
36
+
37
+ const BASE = `http://127.0.0.1:${PORT}`;
38
+ const NET = "net_attrs";
39
+ const USER = "u_attrs";
40
+ /** A node row seeded the way a PRE-MIGRATION row looks: none of the new
41
+ * columns are written, so they are NULL / default at read time. */
42
+ const LEGACY_NODE = "node_attrs_legacy";
43
+ const LEGACY_ALIAS = "attrs-legacy-node";
44
+
45
+ let token = "";
46
+ let server: { stop: (force?: boolean) => void } | null = null;
47
+
48
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
49
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
50
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
51
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
52
+ beforeAll(async () => {
53
+ db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
54
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
55
+ db.run(`INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')`, [NET, USER]);
56
+ // Legacy row: only the columns that existed before this change.
57
+ db.run(
58
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id)
59
+ VALUES (?1, 'attrs-legacy', ?2, 'claude-agent-sdk', 'claude-sonnet-4-6', ?3)`,
60
+ [LEGACY_NODE, LEGACY_ALIAS, NET],
61
+ );
62
+ token = issueUserToken(USER, "attrs-test").token;
63
+ server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
64
+ await new Promise((r) => setTimeout(r, 120));
65
+ }, 30_000);
66
+
67
+ afterAll(() => {
68
+ try { server?.stop(true); } catch { /* already down */ }
69
+ });
70
+
71
+ async function putAttrs(ref: string, body: unknown) {
72
+ const res = await fetch(`${BASE}/api/nodes/${encodeURIComponent(ref)}/attrs?network_id=${NET}`, {
73
+ method: "PUT",
74
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
75
+ body: JSON.stringify(body),
76
+ });
77
+ return { status: res.status, json: (await res.json()) as Record<string, unknown> };
78
+ }
79
+
80
+ async function listNodes() {
81
+ const res = await fetch(`${BASE}/api/nodes?network_id=${NET}`, {
82
+ headers: { Authorization: `Bearer ${token}` },
83
+ });
84
+ const j = (await res.json()) as { nodes?: Array<Record<string, unknown>> };
85
+ return j.nodes ?? [];
86
+ }
87
+
88
+ describe("node display attrs — backward compatibility with existing rows", () => {
89
+ test("a pre-migration node row still lists fine; new fields read as empty, not missing/throwing", async () => {
90
+ const rows = await listNodes();
91
+ const row = rows.find((r) => r.node_id === LEGACY_NODE);
92
+ expect(row).toBeDefined();
93
+ // Must not blow up and must not omit the keys — the dashboard reads
94
+ // them unconditionally.
95
+ expect(row!).toHaveProperty("display_name");
96
+ expect(row!).toHaveProperty("team");
97
+ expect(row!).toHaveProperty("tags");
98
+ expect(row!.display_name ?? null).toBeNull();
99
+ expect(row!.team ?? null).toBeNull();
100
+ // tags normalises to an empty array rather than null/undefined so the
101
+ // dashboard can `.map()` without a guard.
102
+ expect(row!.tags).toEqual([]);
103
+ expect(row!.attrs_revision).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe("node display attrs — write then read back", () => {
108
+ test("PUT sets display_name/team/tags, bumps attrs_revision, and GET /api/nodes reflects it", async () => {
109
+ const w = await putAttrs(LEGACY_ALIAS, {
110
+ base_attrs_revision: 0,
111
+ display_name: "研发一号机",
112
+ team: "platform",
113
+ tags: ["prod", "gpu"],
114
+ });
115
+ expect(w.status).toBe(200);
116
+ expect(w.json.ok).toBe(true);
117
+ expect(w.json.attrs_revision).toBe(1);
118
+
119
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
120
+ expect(row.display_name).toBe("研发一号机");
121
+ expect(row.team).toBe("platform");
122
+ expect(row.tags).toEqual(["prod", "gpu"]);
123
+ expect(row.attrs_revision).toBe(1);
124
+ });
125
+
126
+ test("editing attrs does NOT touch config_revision (display vs config are separate revisions)", async () => {
127
+ const before = db.get<{ config_revision: number }>(
128
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
129
+ const w = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: 1, team: "infra" });
130
+ expect(w.status).toBe(200);
131
+ const after = db.get<{ config_revision: number }>(
132
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
133
+ expect(after).toBe(before);
134
+ });
135
+
136
+ test("partial patch leaves untouched fields alone", async () => {
137
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
138
+ // team was just changed to 'infra'; display_name/tags must survive.
139
+ expect(row.team).toBe("infra");
140
+ expect(row.display_name).toBe("研发一号机");
141
+ expect(row.tags).toEqual(["prod", "gpu"]);
142
+ });
143
+ });
144
+
145
+ describe("node display attrs — CAS concurrency guard", () => {
146
+ test("stale base_attrs_revision → 409 with the current revision, and NO write happens", async () => {
147
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
148
+ const stale = await putAttrs(LEGACY_ALIAS, {
149
+ base_attrs_revision: 0, // deliberately stale
150
+ team: "should-not-land",
151
+ });
152
+ expect(stale.status).toBe(409);
153
+ expect(stale.json.ok).toBe(false);
154
+ expect(stale.json.error).toBe("attrs_revision_conflict");
155
+ expect(stale.json.current_attrs_revision).toBe(before.attrs_revision);
156
+
157
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
158
+ expect(after.team).toBe(before.team); // unchanged
159
+ expect(after.attrs_revision).toBe(before.attrs_revision);
160
+ });
161
+ });
162
+
163
+ describe("node display attrs — untrusted input is narrowed at the boundary", () => {
164
+ test("tags: non-strings dropped, blanks dropped, duplicates folded, count + length capped", async () => {
165
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
166
+ const w = await putAttrs(LEGACY_ALIAS, {
167
+ base_attrs_revision: cur,
168
+ tags: [
169
+ "keep",
170
+ "keep", // duplicate
171
+ " spaced ", // trimmed
172
+ "",
173
+ " ",
174
+ 42,
175
+ null,
176
+ { evil: true },
177
+ ["nested"],
178
+ "x".repeat(200), // over-long → dropped or truncated, never stored raw
179
+ ],
180
+ });
181
+ expect(w.status).toBe(200);
182
+ const tags = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.tags as string[];
183
+ expect(Array.isArray(tags)).toBe(true);
184
+ expect(tags).toContain("keep");
185
+ expect(tags).toContain("spaced");
186
+ // one "keep", no junk, nothing absurdly long
187
+ expect(tags.filter((t) => t === "keep")).toHaveLength(1);
188
+ expect(tags.every((t) => typeof t === "string" && t.length > 0 && t.length <= 64)).toBe(true);
189
+ expect(tags.some((t) => t.includes("evil") || t.includes("nested"))).toBe(false);
190
+ });
191
+
192
+ test("display_name/team: non-string values are rejected, not coerced", async () => {
193
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
194
+ const bad = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: cur, team: { not: "a string" } });
195
+ expect(bad.status).toBe(400);
196
+ expect(bad.json.ok).toBe(false);
197
+ });
198
+ });
199
+
200
+ describe("node display attrs — auth + scope", () => {
201
+ test("no token → 401, and the row is untouched", async () => {
202
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
203
+ const res = await fetch(`${BASE}/api/nodes/${LEGACY_ALIAS}/attrs?network_id=${NET}`, {
204
+ method: "PUT",
205
+ headers: { "Content-Type": "application/json" },
206
+ body: JSON.stringify({ base_attrs_revision: before.attrs_revision, team: "hijack" }),
207
+ });
208
+ expect(res.status).toBe(401);
209
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
210
+ expect(after.team).toBe(before.team);
211
+ });
212
+
213
+ test("unknown node ref → 404", async () => {
214
+ const r = await putAttrs("no-such-node-ref", { base_attrs_revision: 0, team: "x" });
215
+ expect(r.status).toBe(404);
216
+ });
217
+ });