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

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 (318) 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 +933 -36
  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 +130 -0
  205. package/src/rest-identity.ts +139 -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 +93 -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 +140 -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-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,181 @@
1
+ // #462 —— 节点模型登录失效 → 通知节点主人一次。
2
+ //
3
+ // 钉住:
4
+ // 1. 翻转判定(纯函数):进入 revoked/expired 发一次;坏→坏不再发(含 revoked⇄expired);回到 ok 才重新上膛;
5
+ // unknown / 没报既不发也不上膛。
6
+ // 2. 真 report_status(节点令牌):ok→expired→expired→ok→expired = 恰好 2 条,只给节点主人;
7
+ // 网络 owner 和别的成员 0 条。正文带别名、原因、`codex login` 修法,不教人拷别人的 auth.json。
8
+ // 3. 别的节点 / 用户令牌替它报坏状态 → 不发(与 #448 同一条「只有本节点令牌能替自己说健康」)。
9
+ // 4. Hub 重启(内存标记清空)后第一眼看到坏状态:主人还有没读的同类消息 → 不再发;读过了 → 发。
10
+ //
11
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/model-auth-notify-http.test.ts
12
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
21
+ import { db } from "./db.js";
22
+ import { registerTools } from "./tools.js";
23
+ import { clearNodeHealthStore } from "./node-health-store.js";
24
+ import { __resetModelAuthNoticesForTest, decideModelAuthNotice, MODEL_AUTH_NOTICE_KIND, modelAuthNoticeText } from "./model-auth-notify.js";
25
+
26
+ const dir = mkdtempSync(join(tmpdir(), "anet-model-auth-notify-"));
27
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
28
+ if (!activeDbPath) throw new Error("model-auth-notify requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
29
+
30
+ const PW = "ModelAuthNotify123!";
31
+ const stamp = Date.now();
32
+ const NODE = `ma-node-${stamp}`;
33
+ const NODE_ID = `n_ma_node_${stamp}`;
34
+ const PEER = `ma-peer-${stamp}`;
35
+ const PEER_ID = `n_ma_peer_${stamp}`;
36
+ let netId = "";
37
+ let bossId = "", noraId = "", ottoId = "";
38
+ let nodeTokenId = "", peerTokenId = "";
39
+
40
+ async function connectAs(alias: string, userId: string, tokenId: string, isNetworkToken = true) {
41
+ const s = new McpServer({ name: "model-auth-notify", version: "1" });
42
+ registerTools(s, undefined, netId, userId, alias, isNetworkToken, tokenId);
43
+ const client = new Client({ name: "model-auth-notify-client", version: "1" });
44
+ const [ct, st] = InMemoryTransport.createLinkedPair();
45
+ await s.connect(st);
46
+ await client.connect(ct);
47
+ const report = async (model_auth: string | undefined, asAlias = NODE, nodeId = NODE_ID) => {
48
+ const r: any = await client.callTool({
49
+ name: "report_status",
50
+ arguments: { resume_id: `sdk-${nodeId}`, alias: asAlias, status: "idle", node_id: nodeId, network_id: netId, health: { bridge: "ok", ...(model_auth ? { model_auth } : {}) } },
51
+ });
52
+ expect(r.isError === true).toBe(false);
53
+ };
54
+ return { report, close: async () => { await client.close(); await s.close(); } };
55
+ }
56
+
57
+ const notices = (userId: string) => db.all<{ message_id: string; from_session: string; title: string; content: string; severity: string; meta_json: string; acked: number }>(
58
+ "SELECT message_id, from_session, title, content, severity, meta_json, acked FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
59
+ userId, netId, MODEL_AUTH_NOTICE_KIND,
60
+ );
61
+ const clearNotices = () => db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [netId, MODEL_AUTH_NOTICE_KIND]);
62
+
63
+ beforeAll(() => {
64
+ process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
65
+ const boss = register(`ma_boss_${stamp}`, PW);
66
+ expect(boss.ok).toBe(true);
67
+ bossId = boss.user!.user_id; netId = boss.network_id!;
68
+ const nora = register(`ma_nora_${stamp}`, PW);
69
+ const otto = register(`ma_otto_${stamp}`, PW);
70
+ noraId = nora.user!.user_id; ottoId = otto.user!.user_id;
71
+ addNetworkMember(netId, noraId, "member", bossId, { agentAccess: "all" });
72
+ addNetworkMember(netId, ottoId, "member", bossId, { agentAccess: "all" });
73
+ // 节点主人是 nora(她铸的节点令牌 → nodes.owner_user_id),不是网络 owner boss。
74
+ const minted = createNetworkTokenForNode(noraId, netId, NODE, NODE_ID);
75
+ expect(minted.ok).toBe(true);
76
+ nodeTokenId = minted.token_id!;
77
+ const peer = createNetworkTokenForNode(ottoId, netId, PEER, PEER_ID);
78
+ expect(peer.ok).toBe(true);
79
+ peerTokenId = peer.token_id!;
80
+ });
81
+ beforeEach(() => { __resetModelAuthNoticesForTest(); clearNodeHealthStore(); clearNotices(); });
82
+ afterAll(() => { clearNotices(); __resetModelAuthNoticesForTest(); clearNodeHealthStore(); });
83
+
84
+ describe("#462 transition logic", () => {
85
+ test("bad once per entry, re-armed only by ok", () => {
86
+ const seq: Array<Parameters<typeof decideModelAuthNotice>[1]> = ["ok", "expired", "expired", "revoked", "unknown", undefined, "expired", "ok", "unknown", "revoked", "revoked"];
87
+ let mark: ReturnType<typeof decideModelAuthNotice>["mark"];
88
+ const fired: boolean[] = [];
89
+ for (const s of seq) { const d = decideModelAuthNotice(mark, s); fired.push(d.notify); mark = d.mark; }
90
+ expect(fired).toEqual([false, true, false, false, false, false, false, false, false, true, false]);
91
+ });
92
+
93
+ test("first sight is bad → notify; unknown/missing never arms", () => {
94
+ expect(decideModelAuthNotice(undefined, "revoked")).toEqual({ notify: true, mark: "notified" });
95
+ expect(decideModelAuthNotice(undefined, "unknown")).toEqual({ notify: false, mark: undefined });
96
+ expect(decideModelAuthNotice("notified", "unknown")).toEqual({ notify: false, mark: "notified" });
97
+ expect(decideModelAuthNotice("notified", undefined)).toEqual({ notify: false, mark: "notified" });
98
+ expect(decideModelAuthNotice("notified", "ok")).toEqual({ notify: false, mark: "armed" });
99
+ });
100
+
101
+ test("text names the node, the cause and the per-node re-login; never says to copy credentials", () => {
102
+ for (const s of ["revoked", "expired"] as const) {
103
+ const { title, text } = modelAuthNoticeText("node-a", s);
104
+ expect(title).toBe("节点登录失效");
105
+ expect(text).toContain("节点 node-a");
106
+ expect(text).toContain("codex login");
107
+ expect(text).toContain("CODEX_HOME=");
108
+ expect(text).toContain("不要拷别的节点的 auth.json");
109
+ }
110
+ expect(modelAuthNoticeText("node-a", "revoked").text).toContain("作废");
111
+ expect(modelAuthNoticeText("node-a", "expired").text).toContain("过期");
112
+ });
113
+ });
114
+
115
+ describe("#462 report_status → owner notice", () => {
116
+ test("ok → expired → expired → ok → expired = exactly 2 notices, owner only", async () => {
117
+ const n = await connectAs(NODE, noraId, nodeTokenId);
118
+ try {
119
+ for (const s of ["ok", "expired", "expired", "ok", "expired"]) await n.report(s);
120
+ } finally { await n.close(); }
121
+ const mine = notices(noraId);
122
+ expect(mine.length).toBe(2);
123
+ for (const m of mine) {
124
+ expect(m.from_session).toBe(NODE);
125
+ expect(m.title).toBe("节点登录失效");
126
+ expect(m.severity).toBe("warning");
127
+ expect(m.content).toContain(`节点 ${NODE}`);
128
+ expect(m.content).toContain("codex login");
129
+ expect(JSON.parse(m.meta_json)).toEqual({ model_auth_notice: { alias: NODE, state: "expired" } });
130
+ }
131
+ expect(notices(bossId).length).toBe(0);
132
+ expect(notices(ottoId).length).toBe(0);
133
+ });
134
+
135
+ test("a node that only ever reports ok / unknown / nothing gets no notice", async () => {
136
+ const n = await connectAs(NODE, noraId, nodeTokenId);
137
+ try {
138
+ for (const s of ["unknown", "ok", undefined, "unknown", "ok"]) await n.report(s);
139
+ } finally { await n.close(); }
140
+ expect(notices(noraId).length).toBe(0);
141
+ });
142
+
143
+ test("another node or a user login cannot trigger a notice for this node", async () => {
144
+ const p = await connectAs(PEER, ottoId, peerTokenId);
145
+ try { await p.report("revoked", NODE, NODE_ID); } finally { await p.close(); }
146
+ const u = await connectAs("", bossId, "", false);
147
+ try { await u.report("revoked", NODE, NODE_ID); } finally { await u.close(); }
148
+ expect(notices(noraId).length).toBe(0);
149
+ expect(notices(bossId).length).toBe(0);
150
+ expect(notices(ottoId).length).toBe(0);
151
+ });
152
+
153
+ test("hub restart: an unread notice suppresses the repeat; once read, the next sight notifies", async () => {
154
+ const n = await connectAs(NODE, noraId, nodeTokenId);
155
+ try {
156
+ await n.report("revoked");
157
+ expect(notices(noraId).length).toBe(1);
158
+ __resetModelAuthNoticesForTest(); // = Hub 重启,内存标记没了
159
+ await n.report("revoked");
160
+ expect(notices(noraId).length).toBe(1);
161
+ db.run("UPDATE user_inbox SET acked = 1 WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3", [noraId, netId, MODEL_AUTH_NOTICE_KIND]);
162
+ __resetModelAuthNoticesForTest();
163
+ await n.report("revoked");
164
+ expect(notices(noraId).length).toBe(2);
165
+ await n.report("revoked");
166
+ expect(notices(noraId).length).toBe(2);
167
+ } finally { await n.close(); }
168
+ });
169
+
170
+ test("owner no longer in the network → nobody is notified", async () => {
171
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [netId, noraId]);
172
+ try {
173
+ const n = await connectAs(NODE, noraId, nodeTokenId);
174
+ try { await n.report("expired"); } finally { await n.close(); }
175
+ expect(notices(noraId).length).toBe(0);
176
+ expect(notices(bossId).length).toBe(0);
177
+ } finally {
178
+ addNetworkMember(netId, noraId, "member", bossId, { agentAccess: "all" });
179
+ }
180
+ });
181
+ });
@@ -0,0 +1,116 @@
1
+ // #462(#448 的子项)—— 节点模型登录失效时通知节点主人。
2
+ //
3
+ // 现场:codex 节点的 refresh token 被作废 / 过期后,节点自己知道(report_status.health.model_auth),
4
+ // Hub 也会拒绝给它派活(#460 node-health-guard.ts),但**没有人被告诉** —— 主人要等到某次派活被 409
5
+ // 才发现。这里在 report_status 收下健康报告的那一刻看 model_auth 的翻转:进入 revoked / expired 时
6
+ // 给节点主人发**一条**消息,说是哪个节点、出了什么事、怎么重新登录。
7
+ //
8
+ // 🔴 只检测 + 通知。不自动换号、不拷别的节点的 auth.json、不重新 stage 凭据(owner 定的边界)。
9
+ //
10
+ // 去重:每次「进入坏状态」只发一条;回到 ok 才重新上膛。unknown / 没报 model_auth 既不发也不上膛
11
+ // (一次模型调用都还没跑的节点报 unknown,不能把它当「恢复了」,否则 expired→unknown→expired 会刷屏)。
12
+ // revoked ⇄ expired 互相切换不算新的翻转 —— 修法是同一条命令。
13
+ // 状态在内存里(同 node-health-store.ts);Hub 重启后第一次看到坏状态时,先查收件人有没有**还没读**的同类
14
+ // 消息,有就不再发 —— 否则每次升级 Hub 都会给还没修好的节点主人再发一遍。
15
+ //
16
+ // 收件人 = 节点主人:上报令牌绑定的 nodes.owner_user_id(RFC-036,建节点时定死),没有绑定(老令牌)就取
17
+ // 铸这个节点令牌的用户(api_tokens.user_id —— 新令牌里两者相同)。只发给这一个人;不在本网络了就不发。
18
+ // 怎么送:agent-notice.ts(user_inbox + /events/users/me,from_session = 节点别名)。
19
+
20
+ import { db } from "./db.js";
21
+ import { getUserNetworkRole } from "./auth.js";
22
+ import { sendAgentNotice } from "./agent-notice.js";
23
+ import { degradedLayers } from "./node-health-guard.js";
24
+ import type { NodeHealth } from "./node-health-store.js";
25
+
26
+ export const MODEL_AUTH_NOTICE_KIND = "node_model_auth";
27
+ const MAX_TRACKED = 5_000;
28
+
29
+ type ModelAuth = NodeHealth["model_auth"];
30
+ /** armed = 下一次进入坏状态要发;notified = 这一段坏状态已经发过。 */
31
+ export type NoticeMark = "armed" | "notified";
32
+
33
+ export const isBadModelAuth = (s: ModelAuth): s is "revoked" | "expired" => s === "revoked" || s === "expired";
34
+
35
+ /**
36
+ * 纯函数:上一份标记 + 这一份 model_auth → 发不发、新标记。
37
+ * prev = undefined 表示这个 Hub 进程还没见过这个节点(调用方另查库里有没有未读的旧通知)。
38
+ */
39
+ export function decideModelAuthNotice(prev: NoticeMark | undefined, state: ModelAuth): { notify: boolean; mark: NoticeMark | undefined } {
40
+ if (state === "ok") return { notify: false, mark: "armed" };
41
+ if (isBadModelAuth(state)) {
42
+ if (prev === "notified") return { notify: false, mark: "notified" };
43
+ return { notify: true, mark: "notified" };
44
+ }
45
+ return { notify: false, mark: prev }; // unknown / 没报:保持原样
46
+ }
47
+
48
+ export function modelAuthNoticeText(alias: string, state: "revoked" | "expired"): { title: string; text: string } {
49
+ const what = state === "revoked"
50
+ ? "模型登录凭据已被作废(refresh token revoked),需要重新登录"
51
+ : "模型登录已过期,且没能自动刷新";
52
+ // 修法与派发被拒时(#460)给的同一句,别出现两套说法。
53
+ const hint = degradedLayers({ model_auth: state })[0]?.hint ?? "";
54
+ return {
55
+ title: "节点登录失效",
56
+ text: `节点 ${alias} 的${what}。在它恢复之前,派给它的任务会被拒绝。\n\n怎么修:${hint}。\n登录好之后节点会自动报告恢复,不用再做别的。`,
57
+ };
58
+ }
59
+
60
+ const marks = new Map<string, NoticeMark>();
61
+ const keyOf = (networkId: string, alias: string) => `${networkId}\0${alias}`;
62
+
63
+ export function __resetModelAuthNoticesForTest(): void { marks.clear(); }
64
+
65
+ /** 上报令牌对应的节点主人。 */
66
+ function ownerOfNodeToken(tokenId: string, networkId: string): string | null {
67
+ const row = db.get<{ user_id: string | null; owner_user_id: string | null }>(
68
+ `SELECT t.user_id, n.owner_user_id FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
69
+ WHERE t.token_id = ?1 AND t.network_id = ?2`,
70
+ tokenId, networkId,
71
+ );
72
+ return row?.owner_user_id || row?.user_id || null;
73
+ }
74
+
75
+ function hasUnreadNotice(userId: string, networkId: string, alias: string): boolean {
76
+ return !!db.get(
77
+ "SELECT 1 AS hit FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND from_session = ?3 AND kind = ?4 AND acked = 0 LIMIT 1",
78
+ userId, networkId, alias, MODEL_AUTH_NOTICE_KIND,
79
+ );
80
+ }
81
+
82
+ /**
83
+ * report_status 收下一份(已验过是本节点令牌报的)健康报告之后调用。返回这次通知到的 user_id(测试用;没发 = [])。
84
+ * 任何异常都吞掉:这是附带的通知,不能让一份正常的 report_status 失败。
85
+ */
86
+ export function noteModelAuthHealth(input: { networkId: string; alias: string; tokenId: string; health: NodeHealth }): string[] {
87
+ try {
88
+ const { networkId, alias, tokenId, health } = input;
89
+ const state = health.model_auth;
90
+ const key = keyOf(networkId, alias);
91
+ const prev = marks.get(key);
92
+ const { notify, mark } = decideModelAuthNotice(prev, state);
93
+ if (mark) {
94
+ marks.delete(key);
95
+ marks.set(key, mark);
96
+ if (marks.size > MAX_TRACKED) marks.delete(marks.keys().next().value!);
97
+ }
98
+ if (!notify || !isBadModelAuth(state)) return [];
99
+
100
+ const owner = ownerOfNodeToken(tokenId, networkId);
101
+ if (!owner || !getUserNetworkRole(owner, networkId)) return [];
102
+ // Hub 重启后的第一眼:主人还有一条没读的同类消息 → 这是同一段坏状态,不再发。
103
+ if (prev === undefined && hasUnreadNotice(owner, networkId, alias)) return [];
104
+
105
+ const { title, text } = modelAuthNoticeText(alias, state);
106
+ const sent = sendAgentNotice({
107
+ networkId, userId: owner, fromAlias: alias, kind: MODEL_AUTH_NOTICE_KIND, title, text,
108
+ meta: { model_auth_notice: { alias, state } }, idPrefix: "dm_auth_",
109
+ });
110
+ if (!sent) return [];
111
+ return [owner];
112
+ } catch (e: any) {
113
+ console.error(`[model-auth-notify] ${input.alias}: ${e?.message ?? e}`);
114
+ return [];
115
+ }
116
+ }
@@ -0,0 +1,75 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { createNetworkTokenForNode, register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-network-name-http-"));
9
+ let server: any;
10
+ let base = "";
11
+ let userToken = "";
12
+ let nodeToken = "";
13
+ let networkId = "";
14
+ let username = "";
15
+
16
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
17
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
18
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
19
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
20
+ beforeAll(async () => {
21
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
22
+ username = `network_name_${Date.now()}_${process.pid}`;
23
+ const registered = register(username, "NetworkName123!", undefined, "seed");
24
+ expect(registered.ok).toBe(true);
25
+ userToken = registered.token!;
26
+ networkId = registered.network_id!;
27
+ const userId = db.get<{ user_id: string }>(
28
+ "SELECT user_id FROM users WHERE username = ?1",
29
+ [username],
30
+ )!.user_id;
31
+ const minted = createNetworkTokenForNode(userId, networkId, "network-name-node");
32
+ expect(minted.ok).toBe(true);
33
+ nodeToken = minted.token!;
34
+ const mod: any = await import("./server.js");
35
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
36
+ base = `http://127.0.0.1:${server.port}`;
37
+ }, 30_000);
38
+
39
+ afterAll(() => {
40
+ try { server?.stop?.(true); } catch {}
41
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
42
+ });
43
+
44
+ async function networks(token: string): Promise<any[]> {
45
+ const response = await fetch(`${base}/api/networks`, {
46
+ headers: { Authorization: `Bearer ${token}` },
47
+ });
48
+ expect(response.status).toBe(200);
49
+ const body = await response.json() as any;
50
+ expect(body.ok).toBe(true);
51
+ return body.networks;
52
+ }
53
+
54
+ describe("GET /api/networks name compatibility (#449)", () => {
55
+ test("utok rows expose equal non-null name and network_name", async () => {
56
+ const rows = await networks(userToken);
57
+ expect(rows).toHaveLength(1);
58
+ expect(rows[0].network_id).toBe(networkId);
59
+ // #449 is about `name` and `network_name` agreeing and being non-null.
60
+ // Pin them to the registered username rather than the literal "default":
61
+ // the auto-created network is named after its owner, so hardcoding the
62
+ // old literal would test the naming scheme instead of the compat shim.
63
+ expect(rows[0].network_name).toBe(username);
64
+ expect(rows[0].name).toBe(username);
65
+ });
66
+
67
+ test("network-bound ntok rows expose the same alias", async () => {
68
+ const rows = await networks(nodeToken);
69
+ expect(rows).toHaveLength(1);
70
+ expect(rows[0].network_id).toBe(networkId);
71
+ expect(rows[0].network_name).toBe(username);
72
+ expect(rows[0].name).toBe(rows[0].network_name);
73
+ expect(rows[0].name).not.toBeNull();
74
+ });
75
+ });
@@ -0,0 +1,276 @@
1
+ // Network-scope resolution shared by BOTH transports.
2
+ //
3
+ // #517: REST (server.ts) and MCP (tools.ts) each carried their own copy of
4
+ // the "which network does this caller write to" rule. REST grew a
5
+ // single-network fallback (POST /api/task); MCP never did — so a utok_
6
+ // caller could read everything but write nothing, with a misleading
7
+ // permission_denied. Extracted here (same pattern as lifecycle-guard.ts)
8
+ // so the rule has exactly ONE implementation. If you change write-scope
9
+ // semantics, this module is the only place — do NOT re-inline a copy in
10
+ // server.ts or tools.ts.
11
+
12
+ import { db } from "./db.js";
13
+ import { getUserNetworkRole } from "./auth.js";
14
+ import { isAgentRestricted, restrictedNetworkIds, usernameIsAgentAlias, visibleAgents } from "./agent-access.js";
15
+
16
+ export type RestNetworkScope = {
17
+ networkId: string | null;
18
+ networkIds: string[] | null;
19
+ denied?: string;
20
+ // 多用户 Agent 权限:scope 里哪些网络对这个调用者是「只看授权 Agent」的。
21
+ // addNetworkScope 对这些网络 fail-closed(整网不返回);要按授权放行的路径
22
+ // 显式改用 addAgentNetworkScope / addOwnTrafficScope,人类数据用 addHumanNetworkScope。
23
+ agentRestriction?: { userId: string; username: string; networkIds: string[] };
24
+ };
25
+
26
+ type ScopeAuthCtx = { userId: string; networkId: string | null; username?: string | null };
27
+
28
+ function usernameOf(userId: string, known?: string | null): string {
29
+ if (known) return known;
30
+ return db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", userId)?.username ?? "";
31
+ }
32
+
33
+ function withAgentRestriction(scope: RestNetworkScope, authCtx: ScopeAuthCtx): RestNetworkScope {
34
+ const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
35
+ if (inScope.length === 0) return scope;
36
+ const restricted = restrictedNetworkIds(authCtx.userId).filter((id) => inScope.includes(id));
37
+ if (restricted.length === 0) return scope;
38
+ return { ...scope, agentRestriction: { userId: authCtx.userId, username: usernameOf(authCtx.userId, authCtx.username), networkIds: restricted } };
39
+ }
40
+
41
+ export function getUserNetworkIds(userId: string): string[] {
42
+ // JOIN networks: deleteNetwork historically left network_members rows
43
+ // behind (fixed alongside PR #519, but legacy DBs still carry strays).
44
+ // A ghost membership must neither make a single-network user look
45
+ // ambiguous nor let the write fallback resolve INTO a deleted network.
46
+ return db.all<{ network_id: string }>(
47
+ `SELECT m.network_id FROM network_members m
48
+ JOIN networks n ON n.network_id = m.network_id
49
+ WHERE m.user_id = ?1`,
50
+ userId
51
+ ).map((row) => row.network_id);
52
+ }
53
+
54
+ export function resolveRestNetworkScope(requested: string | null, authCtx: ScopeAuthCtx | null, isAdmin: boolean): RestNetworkScope {
55
+ // Legacy global token or open dev mode keeps the old global behavior.
56
+ if (!authCtx) return { networkId: requested || null, networkIds: null };
57
+
58
+ // Network tokens are forcibly scoped to their bound network.
59
+ if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
60
+
61
+ // System admins may intentionally inspect all networks.
62
+ if (isAdmin) return { networkId: requested || null, networkIds: null };
63
+
64
+ if (requested) {
65
+ const role = getUserNetworkRole(authCtx.userId, requested);
66
+ if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
67
+ return withAgentRestriction({ networkId: requested, networkIds: null }, authCtx);
68
+ }
69
+
70
+ return withAgentRestriction({ networkId: null, networkIds: getUserNetworkIds(authCtx.userId) }, authCtx);
71
+ }
72
+
73
+ function unrestrictedNetworks(scope: RestNetworkScope): string[] | null {
74
+ const excluded = scope.agentRestriction?.networkIds ?? [];
75
+ if (scope.networkId) return excluded.includes(scope.networkId) ? [] : [scope.networkId];
76
+ if (scope.networkIds) return scope.networkIds.filter((id) => !excluded.includes(id));
77
+ return null;
78
+ }
79
+
80
+ function placeholders(params: any[], values: string[]): string {
81
+ const start = params.length;
82
+ params.push(...values);
83
+ return values.map((_, i) => `?${start + i + 1}`).join(", ");
84
+ }
85
+
86
+ /**
87
+ * 默认的网络作用域 —— 对受限成员的受限网络 **fail-closed**:整网不返回。
88
+ * 没被逐条审过、不知道按授权怎么放行的查询,默认走这里,受限成员什么都看不到。
89
+ */
90
+ export function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
91
+ if (!scope.agentRestriction) return addHumanNetworkScope(sql, params, scope, column);
92
+ const allowed = unrestrictedNetworks(scope) ?? [];
93
+ if (allowed.length === 0) return `${sql} AND 1=0`;
94
+ return `${sql} AND ${column} IN (${placeholders(params, allowed)})`;
95
+ }
96
+
97
+ /**
98
+ * 人类数据(自己的私信收件箱、需求看板、项目、网络成员)的作用域:受限成员照常看得到
99
+ * 自己所在网络。只给已确认不含 Agent 内容的查询用。
100
+ */
101
+ export function addHumanNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
102
+ if (scope.networkId) {
103
+ sql += ` AND ${column} = ?${params.length + 1}`;
104
+ params.push(scope.networkId);
105
+ } else if (scope.networkIds) {
106
+ if (scope.networkIds.length === 0) {
107
+ sql += " AND 1=0";
108
+ } else {
109
+ const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
110
+ sql += ` AND ${column} IN (${placeholders})`;
111
+ params.push(...scope.networkIds);
112
+ }
113
+ }
114
+ return sql;
115
+ }
116
+
117
+ type AgentColumns = { network?: string; alias: string; nodeId?: string };
118
+
119
+ /**
120
+ * Agent 行(sessions / nodes)的作用域:不受限的网络整网放行;受限网络只放行授权的 Agent。
121
+ * mode="message" 时只放行 can_message 的授权。
122
+ */
123
+ export function addAgentNetworkScope(sql: string, params: any[], scope: RestNetworkScope, cols: AgentColumns, mode: "see" | "message" = "see"): string {
124
+ const restriction = scope.agentRestriction;
125
+ if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
126
+ const net = cols.network ?? "network_id";
127
+ const parts: string[] = [];
128
+ const allowed = unrestrictedNetworks(scope) ?? [];
129
+ if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
130
+ for (const networkId of restriction.networkIds) {
131
+ const visible = visibleAgents(restriction.userId, networkId);
132
+ const aliases = mode === "message" ? visible.messageAliases : visible.aliases;
133
+ const nodeIds = mode === "message" ? visible.messageNodeIds : visible.nodeIds;
134
+ const conds: string[] = [];
135
+ if (aliases.length) conds.push(`${cols.alias} IN (${placeholders(params, aliases)})`);
136
+ if (cols.nodeId && nodeIds.length) conds.push(`${cols.nodeId} IN (${placeholders(params, nodeIds)})`);
137
+ if (conds.length === 0) continue;
138
+ parts.push(`(${net} = ${placeholders(params, [networkId])} AND (${conds.join(" OR ")}))`);
139
+ }
140
+ return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
141
+ }
142
+
143
+ type TrafficColumns = { network?: string; from: string; to: string; fromNodeId?: string; toNodeId?: string };
144
+
145
+ /**
146
+ * 任务 / inbox 这类「谁发给谁」的行:受限网络里只放行**自己**与**授权 Agent**之间的往来
147
+ * (from=自己 且 to=授权 Agent,或反过来)。别人(包括 owner)和同一个 Agent 的对话看不到。
148
+ */
149
+ export function addOwnTrafficScope(sql: string, params: any[], scope: RestNetworkScope, cols: TrafficColumns): string {
150
+ const restriction = scope.agentRestriction;
151
+ if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
152
+ const net = cols.network ?? "network_id";
153
+ const parts: string[] = [];
154
+ const allowed = unrestrictedNetworks(scope) ?? [];
155
+ if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
156
+ if (restriction.username) {
157
+ for (const networkId of restriction.networkIds) {
158
+ // 用户名与本网络某个 Agent 的 alias 撞名时(入网时拦过,但 Agent 之后注册的 alias 拦不住),
159
+ // 「发给我的」无法和「发给那个 Agent 的」区分开 —— 这个网络的往来一律不放行。
160
+ if (usernameIsAgentAlias(networkId, restriction.username)) continue;
161
+ const visible = visibleAgents(restriction.userId, networkId);
162
+ // 先判空再生成占位符:side() 会往 params 里推值,推了又丢弃会让参数个数对不上。
163
+ const useNodeIds = !!(cols.fromNodeId && cols.toNodeId) && visible.nodeIds.length > 0;
164
+ if (visible.aliases.length === 0 && !useNodeIds) continue;
165
+ const side = (aliasCol: string, nodeCol?: string): string | null => {
166
+ const conds: string[] = [];
167
+ if (visible.aliases.length) conds.push(`${aliasCol} IN (${placeholders(params, visible.aliases)})`);
168
+ if (nodeCol && useNodeIds) conds.push(`${nodeCol} IN (${placeholders(params, visible.nodeIds)})`);
169
+ return conds.length ? `(${conds.join(" OR ")})` : null;
170
+ };
171
+ const toAgent = side(cols.to, cols.toNodeId)!;
172
+ const fromAgent = side(cols.from, cols.fromNodeId)!;
173
+ const me = placeholders(params, [restriction.username]);
174
+ parts.push(`(${net} = ${placeholders(params, [networkId])} AND ((${cols.from} = ${me} AND ${toAgent}) OR (${cols.to} = ${me} AND ${fromAgent})))`);
175
+ }
176
+ }
177
+ return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
178
+ }
179
+
180
+ /**
181
+ * #563 —— 「不同用户看到同一个节点,消息应该是一样的」。
182
+ *
183
+ * tasks 行(聊天窗口的历史 + 回复都在这张表)的作用域:受限网络里,只要成员**看得见** Agent N
184
+ * (直接授权或经组授权,见 visibleAgents),N 的整条时间线他都看得见 —— 不论是谁发的(包括 owner、
185
+ * 其他成员),也包括授权之前的历史。时间线 = 一端是看得见的 Agent,另一端是:
186
+ * - 另一个看得见的 Agent;或
187
+ * - 不是本网络任何 Agent 的名字(人类用户 / scheduler / hub 这类系统发送方)。
188
+ * 另一端是**看不见**的 Agent 的行一律不放行(不借时间线暴露看不见的节点)。
189
+ * 没有任何授权的成员什么都看不到(与之前一样);能不能**发**仍由 canMessageAgent 管。
190
+ * Agent 主动发给某个人的 user_inbox 私信不在这张表里,不受影响。
191
+ *
192
+ * 与 addOwnTrafficScope 的区别只在受限网络:那里只放行「自己 ↔ 授权 Agent」。inbox(投递队列)
193
+ * 仍走 addOwnTrafficScope。用户名与 Agent alias 撞名的网络照旧整网不放行(fail-closed)。
194
+ */
195
+ export function addAgentTimelineScope(sql: string, params: any[], scope: RestNetworkScope, cols: Required<Pick<TrafficColumns, "from" | "to" | "fromNodeId" | "toNodeId">> & { network?: string }): string {
196
+ const restriction = scope.agentRestriction;
197
+ if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
198
+ const net = cols.network ?? "network_id";
199
+ const parts: string[] = [];
200
+ const allowed = unrestrictedNetworks(scope) ?? [];
201
+ if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
202
+ if (restriction.username) {
203
+ for (const networkId of restriction.networkIds) {
204
+ if (usernameIsAgentAlias(networkId, restriction.username)) continue;
205
+ const visible = visibleAgents(restriction.userId, networkId);
206
+ // 先判空再推参数:推了又不用的绑定参数会让 PG 报错(SQLite 会忽略)。
207
+ if (visible.aliases.length === 0 && visible.nodeIds.length === 0) continue;
208
+ const aliasIn = visible.aliases.length ? placeholders(params, visible.aliases) : null;
209
+ const nodeIn = visible.nodeIds.length ? placeholders(params, visible.nodeIds) : null;
210
+ const netP = placeholders(params, [networkId]);
211
+ const isVisible = (aliasCol: string, nodeCol: string): string => {
212
+ const conds: string[] = [];
213
+ if (aliasIn) conds.push(`${aliasCol} IN (${aliasIn})`);
214
+ if (nodeIn) conds.push(`${nodeCol} IN (${nodeIn})`);
215
+ return `(${conds.join(" OR ")})`;
216
+ };
217
+ // 「不是本网络任何 Agent」:没有 node_id,且 alias 不在本网络的 sessions / nodes 里。
218
+ const notAnAgent = (aliasCol: string, nodeCol: string): string =>
219
+ `(${nodeCol} IS NULL`
220
+ + ` AND ${aliasCol} NOT IN (SELECT alias FROM sessions WHERE network_id = ${netP} AND alias IS NOT NULL)`
221
+ + ` AND ${aliasCol} NOT IN (SELECT alias FROM nodes WHERE network_id = ${netP} AND alias IS NOT NULL))`;
222
+ const toV = isVisible(cols.to, cols.toNodeId);
223
+ const fromV = isVisible(cols.from, cols.fromNodeId);
224
+ const otherFrom = `(${fromV} OR ${notAnAgent(cols.from, cols.fromNodeId)})`;
225
+ const otherTo = `(${toV} OR ${notAnAgent(cols.to, cols.toNodeId)})`;
226
+ parts.push(`(${net} = ${netP} AND ((${toV} AND ${otherFrom}) OR (${fromV} AND ${otherTo})))`);
227
+ }
228
+ }
229
+ return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
230
+ }
231
+
232
+ export function singleNetworkId(scope: RestNetworkScope): string | null {
233
+ if (scope.networkId) return scope.networkId;
234
+ if (scope.networkIds?.length === 1) return scope.networkIds[0];
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Resolve one unambiguous network for a REST write.
240
+ *
241
+ * Admin read scope is intentionally global (`networkIds=null`), so it cannot
242
+ * by itself express that an admin happens to have exactly one membership.
243
+ * Writes must not inherit that ambiguity: use the sole real membership when
244
+ * one exists, and keep requiring an explicit network for zero or 2+.
245
+ */
246
+ export function resolveRestWriteNetworkId(
247
+ scope: RestNetworkScope,
248
+ authCtx: { userId: string; networkId: string | null } | null,
249
+ isAdmin: boolean,
250
+ ): string | null {
251
+ const scoped = singleNetworkId(scope);
252
+ if (scoped) return scoped;
253
+ if (!authCtx || !isAdmin) return null;
254
+ const memberships = getUserNetworkIds(authCtx.userId);
255
+ return memberships.length === 1 ? memberships[0] : null;
256
+ }
257
+
258
+ /**
259
+ * 能不能往这个网络写**面向 Agent 的东西**(派任务、改节点、排程……)。
260
+ * 受限成员在受限网络里恒为 false(fail-closed);给授权 Agent 发任务的路径另用
261
+ * canMessageAgent 单独放行,人类侧的写入(私信、需求、上传)用 canRestWriteNetworkAsHuman。
262
+ */
263
+ export function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
264
+ if (!canRestWriteNetworkAsHuman(authCtx, networkId, isAdmin)) return false;
265
+ if (!authCtx || isAdmin || !networkId) return true;
266
+ return !isAgentRestricted(authCtx.userId, networkId);
267
+ }
268
+
269
+ /** 旧的「成员且不是 viewer」判据,不看 Agent 授权。只给人类侧写入用。 */
270
+ export function canRestWriteNetworkAsHuman(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
271
+ if (!authCtx) return true; // legacy global token or open dev mode
272
+ if (isAdmin) return true;
273
+ if (!networkId) return false;
274
+ const role = getUserNetworkRole(authCtx.userId, networkId);
275
+ return !!role && role !== "viewer";
276
+ }