@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80

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 (202) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +478 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,478 @@
1
+ // 多用户账号与 Agent 权限 —— 「这个人在这个网络里能看见 / 能联系哪些 Agent」的唯一实现。
2
+ //
3
+ // 受限成员(restricted member):网络里 role 为 member/viewer、agent_access 不是 'all'、
4
+ // 且不是 Hub 管理员的用户。他只能看见 network_member_agent_grants 里授权给他的 Agent,
5
+ // 只能给其中 can_message=1 的发任务/消息;人类之间的私信不受影响。
6
+ //
7
+ // 判定是 fail-closed 的:agent_access 只有字面 'all' 才放开;role 只有 owner/admin 才豁免。
8
+ // 读到任何别的值(包括未来新增的角色)都按受限处理。
9
+ //
10
+ // 这里只回答「能不能」,不管「是不是成员」——非成员返回 false(不受限),
11
+ // 成员资格仍由调用方原有的 getUserNetworkRole 判定。两件事分开,别合并成一个判据。
12
+
13
+ import { db } from "./db.js";
14
+
15
+ const UNRESTRICTED_ROLES = new Set(["owner", "admin"]);
16
+
17
+ export type AgentAccessMode = "all" | "granted";
18
+
19
+ export type AgentGrant = {
20
+ node_id: string | null;
21
+ alias: string | null;
22
+ can_message: boolean;
23
+ };
24
+
25
+ /** 某受限成员在某网络里能看见 / 能联系的 Agent,已经展开成 alias 与 node_id 两份清单。 */
26
+ export type VisibleAgents = {
27
+ aliases: string[];
28
+ nodeIds: string[];
29
+ messageAliases: string[];
30
+ messageNodeIds: string[];
31
+ };
32
+
33
+ export function isHubAdmin(userId: string): boolean {
34
+ return db.get<{ role: string | null }>("SELECT role FROM users WHERE user_id = ?1", userId)?.role === "admin";
35
+ }
36
+
37
+ function restrictedRow(row: { member_role: string | null; agent_access: string | null; user_role: string | null } | null | undefined): boolean {
38
+ if (!row) return false;
39
+ if (row.user_role === "admin") return false;
40
+ if (UNRESTRICTED_ROLES.has(String(row.member_role))) return false;
41
+ return row.agent_access !== "all";
42
+ }
43
+
44
+ export function isAgentRestricted(userId: string, networkId: string | null | undefined): boolean {
45
+ if (!userId || !networkId) return false;
46
+ const row = db.get<{ member_role: string | null; agent_access: string | null; user_role: string | null }>(
47
+ `SELECT nm.role AS member_role, nm.agent_access, u.role AS user_role
48
+ FROM network_members nm JOIN users u ON u.user_id = nm.user_id
49
+ WHERE nm.network_id = ?1 AND nm.user_id = ?2`,
50
+ networkId, userId,
51
+ );
52
+ return restrictedRow(row);
53
+ }
54
+
55
+ /** 该用户所有「只看授权 Agent」的网络。Hub 管理员恒为空。 */
56
+ export function restrictedNetworkIds(userId: string): string[] {
57
+ if (!userId) return [];
58
+ return db.all<{ network_id: string; member_role: string | null; agent_access: string | null; user_role: string | null }>(
59
+ `SELECT nm.network_id, nm.role AS member_role, nm.agent_access, u.role AS user_role
60
+ FROM network_members nm
61
+ JOIN users u ON u.user_id = nm.user_id
62
+ JOIN networks n ON n.network_id = nm.network_id
63
+ WHERE nm.user_id = ?1`,
64
+ userId,
65
+ ).filter(restrictedRow).map((row) => row.network_id);
66
+ }
67
+
68
+ export function getAgentAccessMode(networkId: string, userId: string): AgentAccessMode | null {
69
+ const row = db.get<{ agent_access: string | null }>(
70
+ "SELECT agent_access FROM network_members WHERE network_id = ?1 AND user_id = ?2",
71
+ networkId, userId,
72
+ );
73
+ if (!row) return null;
74
+ return row.agent_access === "all" ? "all" : "granted";
75
+ }
76
+
77
+ export function listAgentGrants(networkId: string, userId: string): AgentGrant[] {
78
+ return db.all<{ node_id: string | null; alias: string | null; can_message: number }>(
79
+ `SELECT node_id, alias, can_message FROM network_member_agent_grants
80
+ WHERE network_id = ?1 AND user_id = ?2
81
+ ORDER BY COALESCE(node_id, alias)`,
82
+ networkId, userId,
83
+ ).map((row) => ({ node_id: row.node_id, alias: row.alias, can_message: row.can_message === 1 }));
84
+ }
85
+
86
+ /**
87
+ * 把授权展开成 alias / node_id 两份清单。
88
+ * node_id 授权同时带出:nodes 表里这个节点的 alias、sessions 表里同 node_id 的会话 alias
89
+ * (改名后 sessions 先于 nodes 更新的窗口里两者可能不同,两个都算)。都限定在同一网络。
90
+ */
91
+ /** visibleAgents 一次查询里最多放多少个节点 id(绑定参数上限;1000 个授权节点 = 2 次查询)。 */
92
+ export const VISIBLE_AGENTS_CHUNK = 500;
93
+
94
+ export function visibleAgents(userId: string, networkId: string): VisibleAgents {
95
+ const aliases = new Set<string>();
96
+ const nodeIds = new Set<string>();
97
+ const messageAliases = new Set<string>();
98
+ const messageNodeIds = new Set<string>();
99
+ // RFC-038 §8:组授权展开成「该组此刻的成员节点」—— 每次现算,组里新加的节点下一次请求就可见。
100
+ // 只并同网络、且 nodes 表里还在的节点;可对话取并集(直接授权或任一组给了就算)。
101
+ const grants: AgentGrant[] = [...listAgentGrants(networkId, userId), ...groupGrantNodes(networkId, userId)];
102
+ // #2133:所有授权节点的 alias 一次查完(原来每个节点单独查一次 nodes ∪ sessions:300 条授权 ≈ 300 次查询,
103
+ // 而受限成员的每次 /api/status 轮询都走这里)。结果与原实现逐字一致:授权按上面数组的顺序,
104
+ // 同一节点内的 alias 按 UNION 的去重升序 —— agent-access-batch.test.ts 在随机夹具上逐字比对。
105
+ // 分块只为绑定参数个数的上限;同一节点的所有 alias 必在同一块里,顺序不受分块影响。
106
+ const grantedNodeIds = [...new Set(grants.map((grant) => grant.node_id).filter((id): id is string => !!id))];
107
+ const aliasesByNode = new Map<string, string[]>();
108
+ for (let start = 0; start < grantedNodeIds.length; start += VISIBLE_AGENTS_CHUNK) {
109
+ const chunk = grantedNodeIds.slice(start, start + VISIBLE_AGENTS_CHUNK);
110
+ const inList = chunk.map((_, i) => `?${i + 2}`).join(", ");
111
+ for (const row of db.all<{ node_id: string; alias: string | null }>(
112
+ `SELECT node_id, alias FROM nodes WHERE network_id = ?1 AND node_id IN (${inList})
113
+ UNION SELECT node_id, alias FROM sessions WHERE network_id = ?1 AND node_id IN (${inList})
114
+ ORDER BY node_id, alias`,
115
+ networkId, ...chunk,
116
+ )) {
117
+ if (!row.alias) continue;
118
+ const list = aliasesByNode.get(row.node_id);
119
+ if (list) list.push(row.alias); else aliasesByNode.set(row.node_id, [row.alias]);
120
+ }
121
+ }
122
+ for (const grant of grants) {
123
+ let grantAliases: readonly string[] = [];
124
+ if (grant.node_id) {
125
+ nodeIds.add(grant.node_id);
126
+ if (grant.can_message) messageNodeIds.add(grant.node_id);
127
+ grantAliases = aliasesByNode.get(grant.node_id) ?? [];
128
+ } else if (grant.alias) {
129
+ grantAliases = [grant.alias];
130
+ }
131
+ for (const alias of grantAliases) {
132
+ aliases.add(alias);
133
+ if (grant.can_message) messageAliases.add(alias);
134
+ }
135
+ }
136
+ return {
137
+ aliases: [...aliases],
138
+ nodeIds: [...nodeIds],
139
+ messageAliases: [...messageAliases],
140
+ messageNodeIds: [...messageNodeIds],
141
+ };
142
+ }
143
+
144
+ // ══════════════ Agent 分组(RFC-038 §8) ══════════════
145
+
146
+ export type AgentGroupGrant = { group_id: string; name: string; can_message: boolean };
147
+ export type AgentGroup = { group_id: string; network_id: string; name: string; description: string | null; node_ids: string[]; member_count: number; granted_user_count: number; created_at: string; updated_at: string | null };
148
+
149
+ /** 该成员被授权的组,展开成节点级授权(只取同网络、仍存在的节点)。 */
150
+ function groupGrantNodes(networkId: string, userId: string): AgentGrant[] {
151
+ return db.all<{ node_id: string; can_message: number }>(
152
+ `SELECT m.node_id, g.can_message
153
+ FROM network_member_group_grants g
154
+ JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
155
+ JOIN agent_group_members m ON m.group_id = g.group_id
156
+ JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
157
+ WHERE g.network_id = ?1 AND g.user_id = ?2`,
158
+ networkId, userId,
159
+ ).map((row) => ({ node_id: row.node_id, alias: null, can_message: row.can_message === 1 }));
160
+ }
161
+
162
+ export function listGroupGrants(networkId: string, userId: string): AgentGroupGrant[] {
163
+ return db.all<{ group_id: string; name: string; can_message: number }>(
164
+ `SELECT g.group_id, ag.name, g.can_message
165
+ FROM network_member_group_grants g JOIN agent_groups ag ON ag.group_id = g.group_id
166
+ WHERE g.network_id = ?1 AND g.user_id = ?2 ORDER BY ag.name`,
167
+ networkId, userId,
168
+ ).map((row) => ({ group_id: row.group_id, name: row.name, can_message: row.can_message === 1 }));
169
+ }
170
+
171
+ export function listAgentGroups(networkId: string): AgentGroup[] {
172
+ const groups = db.all<{ group_id: string; network_id: string; name: string; description: string | null; created_at: string; updated_at: string | null }>(
173
+ "SELECT group_id, network_id, name, description, created_at, updated_at FROM agent_groups WHERE network_id = ?1 ORDER BY name",
174
+ networkId,
175
+ );
176
+ return groups.map((g) => {
177
+ const nodeIds = db.all<{ node_id: string }>(
178
+ `SELECT m.node_id FROM agent_group_members m JOIN nodes n ON n.node_id = m.node_id AND n.network_id = ?2
179
+ WHERE m.group_id = ?1 ORDER BY m.node_id`,
180
+ g.group_id, networkId,
181
+ ).map((row) => row.node_id);
182
+ const granted = db.get<{ c: number }>("SELECT COUNT(*) AS c FROM network_member_group_grants WHERE group_id = ?1", g.group_id)?.c ?? 0;
183
+ return { ...g, node_ids: nodeIds, member_count: nodeIds.length, granted_user_count: granted };
184
+ });
185
+ }
186
+
187
+ export function getAgentGroup(networkId: string, groupId: string): AgentGroup | null {
188
+ return listAgentGroups(networkId).find((g) => g.group_id === groupId) ?? null;
189
+ }
190
+
191
+ /** 这个组上有授权的成员(组变化时要断开他们的实时流,让其按新权限重连)。 */
192
+ export function usersGrantedGroup(groupId: string): string[] {
193
+ return db.all<{ user_id: string }>("SELECT user_id FROM network_member_group_grants WHERE group_id = ?1", groupId).map((r) => r.user_id);
194
+ }
195
+
196
+ const MAX_GROUP_NAME = 64;
197
+ const MAX_GROUP_MEMBERS = 1000;
198
+
199
+ export type GroupResult = { ok: true; group: AgentGroup } | { ok: false; error: string; status: number; detail?: unknown };
200
+
201
+ function normalizeName(raw: unknown): string | null {
202
+ if (typeof raw !== "string") return null;
203
+ const name = raw.trim();
204
+ if (!name || name.length > MAX_GROUP_NAME) return null;
205
+ return name;
206
+ }
207
+
208
+ /** node_ids 必须都是本网络里存在的节点;任何一个不合格 ⇒ 整批拒绝。去重。 */
209
+ function validateNodeIds(networkId: string, raw: unknown): { ok: true; ids: string[] } | { ok: false; error: string; status: number; detail?: unknown } {
210
+ if (!Array.isArray(raw)) return { ok: false, error: "node_ids_must_be_array", status: 400 };
211
+ if (raw.length > MAX_GROUP_MEMBERS) return { ok: false, error: "too_many_members", status: 400, detail: { limit: MAX_GROUP_MEMBERS } };
212
+ const ids: string[] = [];
213
+ const seen = new Set<string>();
214
+ for (const [index, item] of raw.entries()) {
215
+ const id = typeof item === "string" ? item.trim() : "";
216
+ if (!id) return { ok: false, error: "invalid_node_id", status: 400, detail: { index } };
217
+ if (!db.get("SELECT 1 FROM nodes WHERE node_id = ?1 AND network_id = ?2", id, networkId)) {
218
+ return { ok: false, error: "agent_not_in_network", status: 400, detail: { index, node_id: id } };
219
+ }
220
+ if (!seen.has(id)) { seen.add(id); ids.push(id); }
221
+ }
222
+ return { ok: true, ids };
223
+ }
224
+
225
+ export function createAgentGroup(input: { networkId: string; name: unknown; description?: unknown; nodeIds?: unknown; actorUserId: string }): GroupResult {
226
+ const name = normalizeName(input.name);
227
+ if (!name) return { ok: false, error: "invalid_group_name", status: 400, detail: { max_length: MAX_GROUP_NAME } };
228
+ if (db.get("SELECT 1 FROM agent_groups WHERE network_id = ?1 AND name = ?2", input.networkId, name)) {
229
+ return { ok: false, error: "group_name_taken", status: 409 };
230
+ }
231
+ let ids: string[] = [];
232
+ if (input.nodeIds !== undefined) {
233
+ const v = validateNodeIds(input.networkId, input.nodeIds);
234
+ if (!v.ok) return v;
235
+ ids = v.ids;
236
+ }
237
+ const description = typeof input.description === "string" ? input.description.trim().slice(0, 500) || null : null;
238
+ const groupId = `agrp_${crypto.randomUUID().replace(/-/g, "")}`;
239
+ db.transaction(() => {
240
+ db.run("INSERT INTO agent_groups (group_id, network_id, name, description, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
241
+ [groupId, input.networkId, name, description, input.actorUserId]);
242
+ for (const id of ids) db.run("INSERT INTO agent_group_members (group_id, node_id, added_by) VALUES (?1, ?2, ?3)", [groupId, id, input.actorUserId]);
243
+ });
244
+ return { ok: true, group: getAgentGroup(input.networkId, groupId)! };
245
+ }
246
+
247
+ export function updateAgentGroup(input: { networkId: string; groupId: string; name?: unknown; description?: unknown }): GroupResult {
248
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", input.groupId, input.networkId)) {
249
+ return { ok: false, error: "group_not_found", status: 404 };
250
+ }
251
+ if (input.name !== undefined) {
252
+ const name = normalizeName(input.name);
253
+ if (!name) return { ok: false, error: "invalid_group_name", status: 400, detail: { max_length: MAX_GROUP_NAME } };
254
+ if (db.get("SELECT 1 FROM agent_groups WHERE network_id = ?1 AND name = ?2 AND group_id != ?3", input.networkId, name, input.groupId)) {
255
+ return { ok: false, error: "group_name_taken", status: 409 };
256
+ }
257
+ db.run("UPDATE agent_groups SET name = ?1, updated_at = datetime('now') WHERE group_id = ?2", [name, input.groupId]);
258
+ }
259
+ if (input.description !== undefined) {
260
+ const description = typeof input.description === "string" ? input.description.trim().slice(0, 500) || null : null;
261
+ db.run("UPDATE agent_groups SET description = ?1, updated_at = datetime('now') WHERE group_id = ?2", [description, input.groupId]);
262
+ }
263
+ return { ok: true, group: getAgentGroup(input.networkId, input.groupId)! };
264
+ }
265
+
266
+ /** 整体替换组成员;返回增删 diff(审计只记 diff)。 */
267
+ export function replaceAgentGroupMembers(input: { networkId: string; groupId: string; nodeIds: unknown; actorUserId: string }):
268
+ { ok: true; group: AgentGroup; added: string[]; removed: string[] } | { ok: false; error: string; status: number; detail?: unknown } {
269
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", input.groupId, input.networkId)) {
270
+ return { ok: false, error: "group_not_found", status: 404 };
271
+ }
272
+ const v = validateNodeIds(input.networkId, input.nodeIds);
273
+ if (!v.ok) return v;
274
+ const before = new Set(db.all<{ node_id: string }>("SELECT node_id FROM agent_group_members WHERE group_id = ?1", input.groupId).map((r) => r.node_id));
275
+ const after = new Set(v.ids);
276
+ const added = v.ids.filter((id) => !before.has(id));
277
+ const removed = [...before].filter((id) => !after.has(id));
278
+ db.transaction(() => {
279
+ for (const id of removed) db.run("DELETE FROM agent_group_members WHERE group_id = ?1 AND node_id = ?2", [input.groupId, id]);
280
+ for (const id of added) db.run("INSERT INTO agent_group_members (group_id, node_id, added_by) VALUES (?1, ?2, ?3)", [input.groupId, id, input.actorUserId]);
281
+ db.run("UPDATE agent_groups SET updated_at = datetime('now') WHERE group_id = ?1", [input.groupId]);
282
+ });
283
+ return { ok: true, group: getAgentGroup(input.networkId, input.groupId)!, added, removed };
284
+ }
285
+
286
+ /** 删组:连同组成员与组上的全部授权。返回受影响的成员(调用方断开其实时流、写审计)。 */
287
+ export function deleteAgentGroup(networkId: string, groupId: string): { ok: true; affected_user_ids: string[] } | { ok: false; error: string; status: number } {
288
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", groupId, networkId)) {
289
+ return { ok: false, error: "group_not_found", status: 404 };
290
+ }
291
+ const affected = usersGrantedGroup(groupId);
292
+ db.transaction(() => {
293
+ db.run("DELETE FROM network_member_group_grants WHERE group_id = ?1", [groupId]);
294
+ db.run("DELETE FROM agent_group_members WHERE group_id = ?1", [groupId]);
295
+ db.run("DELETE FROM agent_groups WHERE group_id = ?1", [groupId]);
296
+ });
297
+ return { ok: true, affected_user_ids: affected };
298
+ }
299
+
300
+ /** 这个用户名在该网络里是否同时是某个 Agent 的 alias(sessions 或 nodes)。 */
301
+ export function usernameIsAgentAlias(networkId: string, username: string): boolean {
302
+ if (!username) return false;
303
+ return !!db.get(
304
+ `SELECT 1 WHERE EXISTS (SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2)
305
+ OR EXISTS (SELECT 1 FROM nodes WHERE network_id = ?1 AND alias = ?2)`,
306
+ networkId, username,
307
+ );
308
+ }
309
+
310
+ export type AgentRef = { alias?: string | null; nodeId?: string | null };
311
+
312
+ function refMatches(ref: AgentRef, aliases: string[], nodeIds: string[]): boolean {
313
+ if (ref.nodeId && nodeIds.includes(ref.nodeId)) return true;
314
+ if (ref.alias && aliases.includes(ref.alias)) return true;
315
+ return false;
316
+ }
317
+
318
+ /** 不受限的成员恒为 true;受限成员只对授权的 Agent 为 true。不判断成员资格。 */
319
+ export function canSeeAgent(userId: string, networkId: string | null | undefined, ref: AgentRef): boolean {
320
+ if (!networkId || !isAgentRestricted(userId, networkId)) return true;
321
+ const visible = visibleAgents(userId, networkId);
322
+ return refMatches(ref, visible.aliases, visible.nodeIds);
323
+ }
324
+
325
+ export function canMessageAgent(userId: string, networkId: string | null | undefined, ref: AgentRef): boolean {
326
+ if (!networkId || !isAgentRestricted(userId, networkId)) return true;
327
+ const visible = visibleAgents(userId, networkId);
328
+ return refMatches(ref, visible.messageAliases, visible.messageNodeIds);
329
+ }
330
+
331
+ export type AgentGrantInput = { node_id?: unknown; alias?: unknown; can_message?: unknown };
332
+
333
+ export type ReplaceGrantsResult =
334
+ | { ok: true; agent_access: AgentAccessMode; grants: AgentGrant[]; group_grants: AgentGroupGrant[] }
335
+ | { ok: false; error: string; status: number; detail?: unknown };
336
+
337
+ const MAX_GRANTS = 1000;
338
+
339
+ /**
340
+ * 整体替换某成员的授权(PUT 语义)。node_id 必须是本网络里存在的节点;alias 只用于没有
341
+ * node_id 的旧会话,也必须是本网络里存在的会话。任何一条不合法 ⇒ 整批拒绝,不做部分写入。
342
+ */
343
+ export function replaceAgentGrants(input: {
344
+ networkId: string;
345
+ userId: string;
346
+ grants: unknown;
347
+ agentAccess?: unknown;
348
+ /** RFC-038 §8:组授权。undefined = 保持原样(旧 app 的 PUT 不带它,不能把组授权清掉)。 */
349
+ groupGrants?: unknown;
350
+ actorUserId: string;
351
+ }): ReplaceGrantsResult {
352
+ const member = db.get<{ role: string }>(
353
+ "SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2",
354
+ input.networkId, input.userId,
355
+ );
356
+ if (!member) return { ok: false, error: "member_not_found", status: 404 };
357
+
358
+ let mode: AgentAccessMode | undefined;
359
+ if (input.agentAccess !== undefined) {
360
+ if (input.agentAccess !== "all" && input.agentAccess !== "granted") {
361
+ return { ok: false, error: "invalid_agent_access", status: 400 };
362
+ }
363
+ mode = input.agentAccess;
364
+ }
365
+
366
+ let rows: Array<{ node_id: string | null; alias: string | null; can_message: number }> | undefined;
367
+ if (input.grants !== undefined) {
368
+ if (!Array.isArray(input.grants)) return { ok: false, error: "grants_must_be_array", status: 400 };
369
+ if (input.grants.length > MAX_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_GRANTS } };
370
+ const seen = new Set<string>();
371
+ rows = [];
372
+ for (const [index, raw] of input.grants.entries()) {
373
+ // 兼容直接传 node_id 字符串数组。
374
+ const item: AgentGrantInput = typeof raw === "string" ? { node_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as AgentGrantInput : {});
375
+ const nodeId = typeof item.node_id === "string" && item.node_id.trim() ? item.node_id.trim() : null;
376
+ const alias = typeof item.alias === "string" && item.alias.trim() ? item.alias.trim() : null;
377
+ if ((nodeId === null) === (alias === null)) {
378
+ return { ok: false, error: "grant_needs_exactly_one_of_node_id_or_alias", status: 400, detail: { index } };
379
+ }
380
+ if (item.can_message !== undefined && typeof item.can_message !== "boolean") {
381
+ return { ok: false, error: "can_message_must_be_boolean", status: 400, detail: { index } };
382
+ }
383
+ const exists = nodeId
384
+ ? db.get("SELECT 1 FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, input.networkId)
385
+ : db.get("SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, input.networkId);
386
+ if (!exists) {
387
+ return { ok: false, error: "agent_not_in_network", status: 400, detail: { index, ...(nodeId ? { node_id: nodeId } : { alias }) } };
388
+ }
389
+ const key = nodeId ? `n:${nodeId}` : `a:${alias}`;
390
+ if (seen.has(key)) continue;
391
+ seen.add(key);
392
+ rows.push({ node_id: nodeId, alias, can_message: item.can_message === false ? 0 : 1 });
393
+ }
394
+ }
395
+
396
+ let groupRows: Array<{ group_id: string; can_message: number }> | undefined;
397
+ if (input.groupGrants !== undefined) {
398
+ if (!Array.isArray(input.groupGrants)) return { ok: false, error: "group_grants_must_be_array", status: 400 };
399
+ if (input.groupGrants.length > MAX_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_GRANTS } };
400
+ const seenGroups = new Set<string>();
401
+ groupRows = [];
402
+ for (const [index, raw] of input.groupGrants.entries()) {
403
+ const item = typeof raw === "string" ? { group_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as { group_id?: unknown; can_message?: unknown } : {});
404
+ const groupId = typeof item.group_id === "string" ? item.group_id.trim() : "";
405
+ if (!groupId) return { ok: false, error: "group_grant_needs_group_id", status: 400, detail: { index } };
406
+ if (item.can_message !== undefined && typeof item.can_message !== "boolean") {
407
+ return { ok: false, error: "can_message_must_be_boolean", status: 400, detail: { index } };
408
+ }
409
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", groupId, input.networkId)) {
410
+ return { ok: false, error: "group_not_in_network", status: 400, detail: { index, group_id: groupId } };
411
+ }
412
+ if (seenGroups.has(groupId)) continue;
413
+ seenGroups.add(groupId);
414
+ groupRows.push({ group_id: groupId, can_message: item.can_message === false ? 0 : 1 });
415
+ }
416
+ }
417
+
418
+ db.transaction(() => {
419
+ if (groupRows) {
420
+ db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
421
+ for (const row of groupRows) {
422
+ db.run(
423
+ `INSERT INTO network_member_group_grants (network_id, user_id, group_id, can_message, created_by) VALUES (?1, ?2, ?3, ?4, ?5)`,
424
+ [input.networkId, input.userId, row.group_id, row.can_message, input.actorUserId],
425
+ );
426
+ }
427
+ }
428
+ if (mode) {
429
+ db.run("UPDATE network_members SET agent_access = ?1 WHERE network_id = ?2 AND user_id = ?3", [mode, input.networkId, input.userId]);
430
+ }
431
+ if (rows) {
432
+ db.run("DELETE FROM network_member_agent_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
433
+ for (const row of rows) {
434
+ db.run(
435
+ `INSERT INTO network_member_agent_grants (network_id, user_id, node_id, alias, can_message, created_by)
436
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
437
+ [input.networkId, input.userId, row.node_id, row.alias, row.can_message, input.actorUserId],
438
+ );
439
+ }
440
+ }
441
+ });
442
+
443
+ return {
444
+ ok: true,
445
+ agent_access: getAgentAccessMode(input.networkId, input.userId) ?? "granted",
446
+ grants: listAgentGrants(input.networkId, input.userId),
447
+ group_grants: listGroupGrants(input.networkId, input.userId),
448
+ };
449
+ }
450
+
451
+ /** 成员被移出网络时一并清掉授权,重新加入时从零开始。 */
452
+ export function deleteAgentGrants(networkId: string, userId: string): void {
453
+ db.run("DELETE FROM network_member_agent_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
454
+ db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
455
+ }
456
+
457
+ // 多用户 Agent 权限 —— 受限成员(只看授权 Agent 的成员)能调的 MCP 工具。每一个都逐条审过:
458
+ // 要么自己按授权过滤 / 准入(get_all_status、get_session_status、get_task、list_tasks、send_task、
459
+ // send_message),要么是人类侧(send_desktop_message、需求看板),要么只读且对受限网络 fail-closed
460
+ // (get_completions)。其余工具:受限成员只能在**不受限的**网络里调(必须显式传 network_id),
461
+ // 在受限网络里、或没说是哪个网络时一律拒绝 —— 新增的工具默认落在拒绝这边。
462
+ export const RESTRICTED_MEMBER_TOOLS: ReadonlySet<string> = new Set([
463
+ "get_all_status",
464
+ "get_session_status",
465
+ "get_task",
466
+ "list_tasks",
467
+ "get_completions",
468
+ "send_task",
469
+ "send_message",
470
+ "send_desktop_message",
471
+ "requirements_list",
472
+ "requirements_get",
473
+ "requirements_create",
474
+ "requirements_update",
475
+ "requirements_checklist_toggle",
476
+ "requirements_upsert_by_external_ref",
477
+ "projects_list",
478
+ ]);