@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,263 @@
1
+ // Node run-log view — tail_node_logs rides the rules-file queue + doorbell (op logs_tail).
2
+ //
3
+ // Hub-side contract verified here:
4
+ // 1. report_status { logs_capable: true } is sticky and set only by the node's
5
+ // own token; /api/status-shaped rows expose it.
6
+ // 2. The pulled request carries only the normalised filter JSON — no path field
7
+ // exists anywhere in the tool schema; unknown args are dropped.
8
+ // 3. Who may ask: user logins only (node tokens refused); the network owner/admin,
9
+ // or the node's owner (nodes.owner_user_id). A member who does not own the
10
+ // node, a viewer, and a user of another network are refused before any row.
11
+ // 4. Who may read: only the token that asked (like the project folder).
12
+ // 5. Storage: the result is handed out ONCE — the first terminal read purges
13
+ // result_content and the filter JSON; the second read says content_purged.
14
+ // An unread terminal result is purged by the sweep after LOGS_CONTENT_TTL_MS.
15
+ // 6. Own single-flight lane; logs acks may carry up to 1 MiB.
16
+ //
17
+ // 跑法:cd server && COMMHUB_DB=/tmp/logs-transport.db bun test src/node-logs-transport.test.ts
18
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
21
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import { db } from "./db.js";
23
+ import { registerTools } from "./tools.js";
24
+ import { LOGS_CONTENT_TTL_MS, sweepNodeRequestContent } from "./node-request-retention.js";
25
+
26
+ const NET = "net_logs_t";
27
+ const OTHER_NET = "net_logs_t_other";
28
+ const OWNER = "u_logs_net_owner";
29
+ const MEMBER = "u_logs_member_owns_a";
30
+ const MEMBER2 = "u_logs_member_plain";
31
+ const VIEWER = "u_logs_viewer";
32
+ const OUTSIDER = "u_logs_outsider";
33
+ const NODE_A = "logs-node-a";
34
+ const NODE_B = "logs-node-b";
35
+ const NODE_A_ID = "node_logs_a";
36
+ const NODE_B_ID = "node_logs_b";
37
+ const T_A = "tok_logs_node_a";
38
+ const T_B = "tok_logs_node_b";
39
+
40
+ function cleanup() {
41
+ for (const net of [NET, OTHER_NET]) {
42
+ for (const t of ["node_rules_requests", "sessions", "nodes", "api_tokens", "network_members", "networks"]) {
43
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [net]); } catch {}
44
+ }
45
+ }
46
+ for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
47
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
48
+ }
49
+ }
50
+
51
+ function seedWorld() {
52
+ for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
53
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [u, u]);
54
+ }
55
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
56
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [OTHER_NET, OTHER_NET, OUTSIDER]);
57
+ const member = (u: string, net: string, role: string) =>
58
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
59
+ member(OWNER, NET, "owner");
60
+ member(MEMBER, NET, "member");
61
+ member(MEMBER2, NET, "member");
62
+ member(VIEWER, NET, "viewer");
63
+ member(OUTSIDER, OTHER_NET, "owner");
64
+ const node = (id: string, alias: string, owner: string | null) => db.run(
65
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
66
+ [id, alias, alias, NET, owner],
67
+ );
68
+ node(NODE_A_ID, NODE_A, MEMBER);
69
+ node(NODE_B_ID, NODE_B, OWNER);
70
+ const tok = (id: string, alias: string, bound: string) => db.run(
71
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
72
+ [id, OWNER, NET, `node:${alias}`, `hash_${id}`, bound],
73
+ );
74
+ tok(T_A, NODE_A, NODE_A_ID);
75
+ tok(T_B, NODE_B, NODE_B_ID);
76
+ }
77
+
78
+ type Identity = { net: string | null; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
79
+ const asOwner: Identity = { net: null, user: OWNER, alias: OWNER, isNetworkToken: false, tokenId: "tok_logs_owner_login" };
80
+ const asOwnerOtherLogin: Identity = { ...asOwner, tokenId: "tok_logs_owner_login_2" };
81
+ const asMember: Identity = { net: null, user: MEMBER, alias: MEMBER, isNetworkToken: false, tokenId: "tok_logs_member_login" };
82
+ const asMember2: Identity = { net: null, user: MEMBER2, alias: MEMBER2, isNetworkToken: false, tokenId: "tok_logs_member2_login" };
83
+ const asViewer: Identity = { net: null, user: VIEWER, alias: VIEWER, isNetworkToken: false, tokenId: "tok_logs_viewer_login" };
84
+ const asOutsider: Identity = { net: null, user: OUTSIDER, alias: OUTSIDER, isNetworkToken: false, tokenId: "tok_logs_outsider_login" };
85
+ const asA: Identity = { net: NET, user: OWNER, alias: NODE_A, isNetworkToken: true, tokenId: T_A };
86
+ const asB: Identity = { net: NET, user: OWNER, alias: NODE_B, isNetworkToken: true, tokenId: T_B };
87
+
88
+ async function connect(id: Identity) {
89
+ const server = new McpServer({ name: "logs-test", version: "1" });
90
+ registerTools(server, undefined, id.net, id.user, id.alias, id.isNetworkToken, id.tokenId);
91
+ const client = new Client({ name: "logs-client", version: "1" });
92
+ const [ct, st] = InMemoryTransport.createLinkedPair();
93
+ await server.connect(st);
94
+ await client.connect(ct);
95
+ const call = async (name: string, args: Record<string, unknown>) => {
96
+ const r: any = await client.callTool({ name, arguments: args });
97
+ const first = r.content?.[0];
98
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
99
+ try { return JSON.parse(first.text); } catch { return { raw: first.text, isError: r.isError === true }; }
100
+ };
101
+ const close = async () => { await client.close(); await server.close(); };
102
+ return { call, close };
103
+ }
104
+
105
+ const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
106
+ const row = (id: string) => db.get<any>("SELECT op, content, result_content, content_purged_at FROM node_rules_requests WHERE request_id = ?1", id);
107
+ const SAMPLE = JSON.stringify({ files: ["2026-09-29.log"], lines: [{ ts: 1, level: "info", text: "[10:00:00] [INFO ] [x] hello", key: "2026-09-29.log:0" }], truncated: false, matched: 1, now_ts: 2 });
108
+
109
+ beforeEach(() => { cleanup(); seedWorld(); });
110
+ afterAll(cleanup);
111
+
112
+ describe("logs_capable", () => {
113
+ test("set by the node's own token, sticky, not settable by a user login", async () => {
114
+ const a = await connect(asA);
115
+ try {
116
+ await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, logs_capable: true });
117
+ await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID });
118
+ } finally { await a.close(); }
119
+ expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.c).toBe(1);
120
+ const u = await connect(asOwner);
121
+ try {
122
+ await u.call("report_status", { resume_id: "user-logs-report", alias: NODE_B, status: "idle", network_id: NET, logs_capable: true });
123
+ } finally { await u.close(); }
124
+ expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_B, NET)?.c ?? 0).toBe(0);
125
+ }, 20_000);
126
+ });
127
+
128
+ describe("round trip", () => {
129
+ test("the node pulls only the normalised filter JSON (no path), acks, the asker reads it once", async () => {
130
+ const u = await connect(asOwner);
131
+ const a = await connect(asA);
132
+ try {
133
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 99999 > 2000 ? 2000 : 1, level: "warn", grep: "boom", since_ts: 5, path: "/etc/passwd" });
134
+ expect(enq).toMatchObject({ ok: true, op: "logs_tail" });
135
+ const pulled = (await a.call("get_rules_file_request", {})).request;
136
+ expect(pulled.op).toBe("logs_tail");
137
+ expect(JSON.parse(pulled.content)).toEqual({ lines: 2000, level: "warn", grep: "boom", since_ts: 5 });
138
+ expect(pulled.content).not.toContain("passwd");
139
+ expect(await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "logs", exists: true, content: SAMPLE })).toMatchObject({ ok: true, status: "done" });
140
+
141
+ const first = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
142
+ expect(first).toMatchObject({ ok: true, op: "logs_tail", status: "done", content: SAMPLE });
143
+ // Read-once: purged in the same call, including the filter JSON.
144
+ expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
145
+ expect(row(enq.request_id).content_purged_at).toBeGreaterThan(0);
146
+ const second = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
147
+ expect(second.content).toBeUndefined();
148
+ expect(second.content_purged).toBe(true);
149
+ } finally { await u.close(); await a.close(); }
150
+ }, 20_000);
151
+
152
+ test("lines above 2000 are refused by the schema; bad level refused", async () => {
153
+ const u = await connect(asOwner);
154
+ try {
155
+ expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 2001 })).ok).not.toBe(true);
156
+ expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, level: "debug" })).ok).not.toBe(true);
157
+ expect(rowCount()).toBe(0);
158
+ } finally { await u.close(); }
159
+ }, 20_000);
160
+ });
161
+
162
+ describe("who may ask", () => {
163
+ test("node tokens are refused — for other nodes and for themselves", async () => {
164
+ const a = await connect(asA);
165
+ try {
166
+ expect(await a.call("tail_node_logs", { node_id: NODE_B_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
167
+ expect(await a.call("tail_node_logs", { node_id: NODE_A_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
168
+ expect(rowCount()).toBe(0);
169
+ } finally { await a.close(); }
170
+ }, 20_000);
171
+
172
+ test("node owner yes; plain member no; viewer no; network owner yes", async () => {
173
+ const m = await connect(asMember);
174
+ const m2 = await connect(asMember2);
175
+ const v = await connect(asViewer);
176
+ const o = await connect(asOwner);
177
+ try {
178
+ expect(await m.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
179
+ expect(await m.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
180
+ expect(await m2.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
181
+ expect((await v.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).ok).toBe(false);
182
+ expect(await o.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: true });
183
+ expect(rowCount()).toBe(2);
184
+ } finally { await m.close(); await m2.close(); await v.close(); await o.close(); }
185
+ }, 20_000);
186
+
187
+ test("a user of another network is refused (cross network)", async () => {
188
+ const x = await connect(asOutsider);
189
+ try {
190
+ const r1 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
191
+ expect(r1.ok).toBe(false);
192
+ const r2 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: OTHER_NET });
193
+ expect(r2).toMatchObject({ ok: false, error: "cross_network_node" });
194
+ expect(rowCount()).toBe(0);
195
+ } finally { await x.close(); }
196
+ }, 20_000);
197
+ });
198
+
199
+ describe("who may read", () => {
200
+ test("only the login that asked; another login of the same user, node tokens and the serving node cannot", async () => {
201
+ const u = await connect(asOwner);
202
+ const other = await connect(asOwnerOtherLogin);
203
+ const b = await connect(asB);
204
+ const a = await connect(asA);
205
+ try {
206
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
207
+ await a.call("get_rules_file_request", {});
208
+ await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
209
+ expect(await other.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ ok: false, error: "request_not_found" });
210
+ expect(await b.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
211
+ expect(await a.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
212
+ // Those refusals did not consume the read-once result.
213
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ status: "done", content: SAMPLE });
214
+ } finally { await u.close(); await other.close(); await b.close(); await a.close(); }
215
+ }, 20_000);
216
+
217
+ test("node B never pulls or acks node A's logs request", async () => {
218
+ const u = await connect(asOwner);
219
+ const a = await connect(asA);
220
+ const b = await connect(asB);
221
+ try {
222
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
223
+ expect((await b.call("get_rules_file_request", {})).request).toBeNull();
224
+ expect(await b.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{\"forged\":true}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
225
+ expect((await a.call("get_rules_file_request", {})).request.request_id).toBe(enq.request_id);
226
+ } finally { await u.close(); await a.close(); await b.close(); }
227
+ }, 20_000);
228
+ });
229
+
230
+ describe("storage and lanes", () => {
231
+ test("an unread terminal result is purged by the sweep after the logs TTL; other ops keep theirs", async () => {
232
+ const u = await connect(asOwner);
233
+ const a = await connect(asA);
234
+ try {
235
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
236
+ const rules = await u.call("read_node_rules_file", { node_id: NODE_A_ID, network_id: NET });
237
+ await a.call("get_rules_file_request", {});
238
+ await a.call("get_rules_file_request", {});
239
+ await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
240
+ await a.call("ack_rules_file_request", { request_id: rules.request_id, status: "done", content: "# rules" });
241
+ sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS - 5_000);
242
+ expect(row(enq.request_id).result_content).toBe(SAMPLE);
243
+ sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS + 5_000);
244
+ expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
245
+ expect(row(rules.request_id).result_content).toBe("# rules");
246
+ } finally { await u.close(); await a.close(); }
247
+ }, 20_000);
248
+
249
+ test("own single-flight lane; a 1 MiB-class ack is accepted", async () => {
250
+ const u = await connect(asOwner);
251
+ const a = await connect(asA);
252
+ try {
253
+ expect(await u.call("list_node_files", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
254
+ const first = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
255
+ expect(first).toMatchObject({ ok: true });
256
+ expect(await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
257
+ await a.call("get_rules_file_request", {});
258
+ await a.call("get_rules_file_request", {});
259
+ const big = "x".repeat(700 * 1024);
260
+ expect(await a.call("ack_rules_file_request", { request_id: first.request_id, status: "done", content: big })).toMatchObject({ ok: true, status: "done" });
261
+ } finally { await u.close(); await a.close(); }
262
+ }, 20_000);
263
+ });
@@ -0,0 +1,276 @@
1
+ // RFC-041 第一阶段(#487)—— 节点自己的权限:先记录、后执行。全部经真实 HTTP 入口(bootServer + fetch REST / /mcp / SSE)。
2
+ //
3
+ // 钉住:
4
+ // 1. log(默认):正常模式的节点「本来会被拒」的写照常放行,只按 (节点, 路由, 原因, 小时) 合并记一行。
5
+ // 2. 显式模式不看开关、立刻生效:只读写什么都拒、回复 / 心跳照常;受限只看只改派给它的卡,广播 / 订阅别人的推送都拒。
6
+ // 3. enforce:同一组请求按规则拒(node_permission_denied + reason + hint),含只有人能做的事(human_only)。
7
+ // 4. off:不判也不记(显式模式仍生效)。
8
+ // 5. 改模式:节点主人 / 网络 owner、admin 能;别的成员 403、节点令牌 403、不在网络里 404。
9
+ // 6. 报表:owner/admin 看到过去 7 天每个节点「本来会拦」的计数;成员 / 节点令牌 403。
10
+ // 7. 每个注册的 MCP 工具都归了类(新工具忘了归类会红)。8. 记录有上限、会清理。
11
+ //
12
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-permissions-http.test.ts
13
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
14
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
15
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
19
+ import { replaceTaskGrants } from "./task-access.js";
20
+ import { db } from "./db.js";
21
+ import { NODE_TOOL_CLASS, pruneNodePermissionLog, __resetNodePermissionLogStateForTest } from "./node-permissions.js";
22
+ import { registerTools } from "./tools.js";
23
+
24
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
25
+ if (!activeDbPath) throw new Error("node-permissions requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
26
+
27
+ const stamp = Date.now();
28
+ const PW = "NodePerm123!";
29
+ let server: any, base = "", net = "";
30
+ const user: Record<string, { id: string; token: string }> = {};
31
+ const node: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
32
+ const card: Record<string, string> = {};
33
+
34
+ async function call(token: string, method: string, path: string, body?: unknown) {
35
+ const r = await fetch(`${base}${path}`, {
36
+ method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
37
+ body: body === undefined ? undefined : JSON.stringify(body),
38
+ });
39
+ const text = await r.text();
40
+ let json: any = null; try { json = JSON.parse(text); } catch {}
41
+ return { status: r.status, body: json, text };
42
+ }
43
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
44
+ const r = await fetch(`${base}/mcp`, {
45
+ method: "POST",
46
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
47
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
48
+ });
49
+ const text = await r.text();
50
+ const data = text.split("\n").find((l) => l.startsWith("data: "));
51
+ const msg = JSON.parse(data ? data.slice(6) : text);
52
+ const inner = msg.result?.content?.[0]?.text;
53
+ try { return JSON.parse(inner); } catch { return { raw: inner ?? msg }; }
54
+ }
55
+ const sse = async (token: string, path: string) => {
56
+ const ac = new AbortController();
57
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` }, signal: ac.signal });
58
+ const status = r.status;
59
+ const body = status === 200 ? null : await r.json().catch(() => null);
60
+ ac.abort();
61
+ return { status, body };
62
+ };
63
+ const logRows = (nodeId: string) => db.all<{ route: string; reason: string; hits: number | string }>(
64
+ "SELECT route, reason, hits FROM node_permission_log WHERE network_id = ?1 AND node_id = ?2 ORDER BY route, reason", net, nodeId);
65
+ const hitsOf = (nodeId: string, route: string, reason: string) =>
66
+ Number(logRows(nodeId).find((r) => r.route === route && r.reason === reason)?.hits ?? 0);
67
+ const attrsRev = (nodeId: string) => Number(db.get<{ r: number | string | null }>("SELECT attrs_revision AS r FROM nodes WHERE node_id = ?1", nodeId)?.r ?? 0);
68
+ const setMode = (nodeKey: string, mode: string) => db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, node[nodeKey].id]);
69
+ const newCard = async (name: string, extra: Record<string, unknown> = {}) => {
70
+ const r = await call(user.boss.token, "POST", "/api/requirements", { name, network_id: net, ...extra });
71
+ expect(r.status).toBe(201);
72
+ return r.body.requirement.id as string;
73
+ };
74
+
75
+ beforeAll(async () => {
76
+ const boss = register(`np_boss_${stamp}`, PW); net = boss.network_id!; user.boss = { id: boss.user!.user_id, token: boss.token! };
77
+ for (const k of ["sco", "mem", "out"]) {
78
+ const u = register(`np_${k}_${stamp}`, PW); user[k] = { id: u.user!.user_id, token: u.token! };
79
+ if (k !== "out") expect(addNetworkMember(net, user[k].id, "member", user.boss.id, { agentAccess: "all" }).ok).toBe(true);
80
+ }
81
+ // sco:只看相关任务的成员(task_access=scoped)—— 他的节点在正常模式下会碰到「主人看不见」的卡。
82
+ expect(replaceTaskGrants({ networkId: net, userId: user.sco.id, taskAccess: "scoped", actorUserId: user.boss.id }).ok).toBe(true);
83
+ for (const [key, owner] of [["nN", "boss"], ["nR", "boss"], ["nRO", "boss"], ["nS", "sco"], ["other", "boss"]] as const) {
84
+ const alias = `${key.toLowerCase()}-${stamp}`, id = `n_np_${key.toLowerCase()}_${stamp}`;
85
+ const t = createNetworkTokenForNode(user[owner].id, net, alias, id);
86
+ expect(t.ok).toBe(true);
87
+ node[key] = { id, alias, token: t.token!, tokenId: t.token_id! };
88
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, net]);
89
+ }
90
+ const mod: any = await import("./server.js");
91
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
92
+ base = `http://127.0.0.1:${server.port}`;
93
+ card.assigned = await newCard("assigned to nR", { agent_owner: { kind: "node", id: node.nR.id } });
94
+ card.child = await newCard("child of assigned", { parent_id: card.assigned });
95
+ card.boss = await newCard("boss only");
96
+ card.scoPart = await newCard("sco participates", { participants: [{ kind: "user", id: user.sco.id }] });
97
+ setMode("nR", "restricted");
98
+ setMode("nRO", "readonly");
99
+ }, 60_000);
100
+ afterEach(() => { delete process.env.COMMHUB_NODE_PERMISSIONS; });
101
+ afterAll(() => { server?.stop?.(true); });
102
+
103
+ describe("log (default): never blocks a normal node, records would-be denials", () => {
104
+ test("a write beyond the owner's visibility goes through and is logged once per hour, hits merged", async () => {
105
+ const route = "PATCH /api/requirements/:id";
106
+ const before = hitsOf(node.nS.id, route, "beyond_owner_visibility");
107
+ expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "via scoped owner's node" })).status).toBe(200);
108
+ expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "again" })).status).toBe(200);
109
+ expect(hitsOf(node.nS.id, route, "beyond_owner_visibility")).toBe(before + 2);
110
+ expect(logRows(node.nS.id).filter((r) => r.route === route).length).toBe(1);
111
+ });
112
+
113
+ test("a list read that includes cards the owner cannot see is returned in full and logged", async () => {
114
+ const r = await call(node.nS.token, "GET", `/api/requirements?network_id=${net}`);
115
+ expect(r.status).toBe(200);
116
+ expect(r.body.requirements.map((x: any) => x.id)).toContain(card.boss);
117
+ expect(hitsOf(node.nS.id, "GET /api/requirements", "beyond_owner_visibility")).toBeGreaterThan(0);
118
+ });
119
+
120
+ test("human-only actions are logged, not blocked by this layer", async () => {
121
+ const r = await call(node.nN.token, "PUT", `/api/nodes/${node.other.id}/attrs`, { display_name: "renamed by a node", base_attrs_revision: attrsRev(node.other.id) });
122
+ expect(r.status).toBe(200);
123
+ expect(hitsOf(node.nN.id, "PUT /api/nodes/:id/attrs", "human_only")).toBe(1);
124
+ const p = await mcp(node.nN.token, "projects_create", { network_id: net, name: `p-${stamp}` });
125
+ expect(p.error).not.toBe("node_permission_denied");
126
+ expect(hitsOf(node.nN.id, "mcp:projects_create", "human_only")).toBe(1);
127
+ });
128
+
129
+ test("an all-access owner's normal node does its usual work with zero log rows", async () => {
130
+ expect((await mcp(node.nN.token, "send_task", { alias: node.other.alias, task: "hello" })).ok).toBe(true);
131
+ expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { column: "doing" })).status).toBe(200);
132
+ expect(logRows(node.nN.id).filter((r) => r.reason !== "human_only")).toEqual([]);
133
+ });
134
+ });
135
+
136
+ describe("explicit modes are enforced immediately (even under log)", () => {
137
+ test("read-only: writes and dispatch refused with a hint; heartbeat, replies and reads still work", async () => {
138
+ const w = await call(node.nRO.token, "PATCH", `/api/requirements/${card.boss}`, { description: "nope" });
139
+ expect(w.status).toBe(403);
140
+ expect(w.body).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
141
+ expect(w.body.hint).toContain("read-only");
142
+ expect((await mcp(node.nRO.token, "send_task", { alias: node.other.alias, task: "x" })).reason).toBe("mode_readonly");
143
+ expect((await call(node.nRO.token, "POST", "/api/task", { alias: node.other.alias, task: "x", network_id: net })).body.reason).toBe("mode_readonly");
144
+ expect((await mcp(node.nRO.token, "report_status", { resume_id: `r_${node.nRO.id}`, alias: node.nRO.alias, status: "idle" })).ok).toBe(true);
145
+ const t = await mcp(node.nN.token, "send_task", { alias: node.nRO.alias, task: "please answer" });
146
+ const reply = await mcp(node.nRO.token, "send_reply", { in_reply_to: t.task_id ?? t.message_id, text: "done", status: "replied" });
147
+ expect(reply.ok).not.toBe(false);
148
+ expect((await call(node.nRO.token, "GET", `/api/requirements?network_id=${net}`)).status).toBe(200);
149
+ });
150
+
151
+ test("restricted: sees and edits only cards assigned to it (and their subtasks)", async () => {
152
+ const list = await call(node.nR.token, "GET", `/api/requirements?network_id=${net}`);
153
+ expect(list.body.requirements.map((x: any) => x.id).sort()).toEqual([card.assigned, card.child].sort());
154
+ expect((await call(node.nR.token, "GET", `/api/requirements/${card.boss}`)).status).toBe(404);
155
+ expect((await call(node.nR.token, "PATCH", `/api/requirements/${card.assigned}`, { column: "doing" })).status).toBe(200);
156
+ expect((await call(node.nR.token, "PATCH", `/api/requirements/${card.child}`, { column: "doing" })).status).toBe(200);
157
+ expect((await call(node.nR.token, "POST", `/api/requirements/${card.assigned}/comments`, { text: "progress" })).status).toBe(201);
158
+ const mine = await call(node.nR.token, "POST", "/api/requirements", { name: "mine", network_id: net, agent_owner: { kind: "node", id: node.nR.id } });
159
+ expect(mine.status).toBe(201);
160
+ const notMine = await call(node.nR.token, "POST", "/api/requirements", { name: "not mine", network_id: net });
161
+ expect(notMine.status).toBe(403);
162
+ expect(notMine.body.reason).toBe("mode_restricted_not_assigned");
163
+ const viaMcp = await mcp(node.nR.token, "requirements_list", { network_id: net });
164
+ expect((viaMcp.requirements ?? []).map((x: any) => x.id)).not.toContain(card.boss);
165
+ });
166
+
167
+ test("restricted: no broadcast, no network stream, no other session's stream; its own stream works", async () => {
168
+ expect((await mcp(node.nR.token, "broadcast", { message: "hi all" })).reason).toBe("mode_restricted_not_assigned");
169
+ expect((await call(node.nR.token, "POST", "/api/broadcast", { message: "hi", network_id: net })).body.reason).toBe("mode_restricted_not_assigned");
170
+ expect((await sse(node.nR.token, `/events/network/${net}`)).status).toBe(403);
171
+ expect((await sse(node.nR.token, `/events/${encodeURIComponent(node.other.alias)}?network_id=${net}`)).status).toBe(403);
172
+ expect((await sse(node.nR.token, `/events/${encodeURIComponent(node.nR.alias)}?network_id=${net}`)).status).toBe(200);
173
+ });
174
+ });
175
+
176
+ describe("enforce", () => {
177
+ test("the same requests that were only logged are refused with node_permission_denied", async () => {
178
+ process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
179
+ // 主人看不见的卡:与不存在同一个 404(按主人的可见范围读)。
180
+ expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "now refused" })).status).toBe(404);
181
+ // 主人看得见(他是参与人)但改不了的卡:node_permission_denied。
182
+ const w = await call(node.nS.token, "PATCH", `/api/requirements/${card.scoPart}`, { description: "now refused" });
183
+ expect(w.status).toBe(403);
184
+ expect(w.body).toMatchObject({ error: "node_permission_denied", reason: "beyond_owner_visibility" });
185
+ const list = await call(node.nS.token, "GET", `/api/requirements?network_id=${net}`);
186
+ expect(list.body.requirements.map((x: any) => x.id)).not.toContain(card.boss);
187
+ });
188
+
189
+ test("human-only actions are denied (REST and MCP); a node can still write its own attrs", async () => {
190
+ process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
191
+ const r = await call(node.nN.token, "PUT", `/api/nodes/${node.other.id}/attrs`, { display_name: "nope", base_attrs_revision: attrsRev(node.other.id) });
192
+ expect(r.status).toBe(403);
193
+ expect(r.body.reason).toBe("human_only");
194
+ expect((await mcp(node.nN.token, "projects_create", { network_id: net, name: `q-${stamp}` })).reason).toBe("human_only");
195
+ expect((await call(node.nN.token, "PUT", `/api/nodes/${node.nN.id}/attrs`, { display_name: "me", base_attrs_revision: attrsRev(node.nN.id) })).status).toBe(200);
196
+ expect((await mcp(node.nN.token, "send_task", { alias: node.other.alias, task: "still fine" })).ok).toBe(true);
197
+ });
198
+
199
+ test("off: nothing is evaluated or logged for normal nodes; explicit modes still apply", async () => {
200
+ process.env.COMMHUB_NODE_PERMISSIONS = "off";
201
+ const before = JSON.stringify(logRows(node.nS.id));
202
+ expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "off" })).status).toBe(200);
203
+ expect(JSON.stringify(logRows(node.nS.id))).toBe(before);
204
+ expect((await call(node.nRO.token, "PATCH", `/api/requirements/${card.boss}`, { description: "still read-only" })).status).toBe(403);
205
+ });
206
+ });
207
+
208
+ describe("PUT /api/nodes/:id/permission-mode", () => {
209
+ test("owner and network owner/admin can set it and it applies on the next request; others cannot", async () => {
210
+ expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).body).toMatchObject({ ok: true, permission_mode: "readonly", previous: "normal" });
211
+ expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { description: "mode flip" })).body.reason).toBe("mode_readonly");
212
+ expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).status).toBe(200);
213
+ expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { description: "mode flip" })).status).toBe(200);
214
+ expect((await call(user.sco.token, "PUT", `/api/nodes/${node.nS.id}/permission-mode`, { mode: "restricted" })).status).toBe(200); // sco 是 nS 的主人
215
+ expect((await call(user.sco.token, "PUT", `/api/nodes/${node.nS.id}/permission-mode`, { mode: "normal" })).status).toBe(200);
216
+ expect((await call(user.mem.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).status).toBe(403);
217
+ expect((await call(node.nN.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).body.error).toBe("user_token_required");
218
+ expect((await call(user.out.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).status).toBe(404);
219
+ expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "admin" })).status).toBe(400);
220
+ expect(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'node_permission_mode_changed' AND target_id = ?1", node.nN.id)!.n).toBeTruthy();
221
+ });
222
+ });
223
+
224
+ describe("GET /api/nodes viewer_can.permission_mode (#489)", () => {
225
+ test("true exactly for the callers PUT …/permission-mode accepts; node tokens get false; /health advertises it", async () => {
226
+ const canFor = async (token: string, nodeKey: string) =>
227
+ (await call(token, "GET", `/api/nodes?node_id=${node[nodeKey].id}&network_id=${net}`)).body.nodes?.[0]?.viewer_can?.permission_mode;
228
+ expect(await canFor(user.boss.token, "nN")).toBe(true); // 网络 owner
229
+ expect(await canFor(user.sco.token, "nS")).toBe(true); // 节点主人(普通成员)
230
+ expect(await canFor(user.sco.token, "nN")).toBe(false); // 成员,不是主人
231
+ expect(await canFor(user.mem.token, "nN")).toBe(false);
232
+ expect(await canFor(node.nN.token, "nN")).toBe(false); // 节点令牌永远不能改
233
+ const list = await call(user.boss.token, "GET", `/api/nodes?network_id=${net}`);
234
+ expect(list.body.nodes.every((n: any) => !("owner_user_id" in n))).toBe(true);
235
+ const health = await fetch(`${base}/health`).then((r) => r.json()) as any;
236
+ expect(health.capabilities).toContain("node_permission_mode");
237
+ // 与写接口一致:viewer_can=false 的人 PUT 得到 403
238
+ expect((await call(user.mem.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).status).toBe(403);
239
+ });
240
+ });
241
+
242
+ describe("GET /api/networks/:id/node-permission-report", () => {
243
+ test("owner sees would-have-blocked counts per node; members and node tokens are refused", async () => {
244
+ const r = await call(user.boss.token, "GET", `/api/networks/${net}/node-permission-report`);
245
+ expect(r.status).toBe(200);
246
+ const nS = r.body.nodes.find((n: any) => n.node_id === node.nS.id);
247
+ expect(nS.by_reason.beyond_owner_visibility).toBeGreaterThanOrEqual(2);
248
+ expect(nS.alias).toBe(node.nS.alias);
249
+ expect(r.body.total).toBe(r.body.nodes.reduce((n: number, e: any) => n + e.total, 0));
250
+ expect((await call(user.mem.token, "GET", `/api/networks/${net}/node-permission-report`)).status).toBe(403);
251
+ expect((await call(node.nN.token, "GET", `/api/networks/${net}/node-permission-report`)).status).toBe(403);
252
+ expect((await call(user.boss.token, "GET", `/api/networks/${net}/node-permission-report?since=${encodeURIComponent(new Date(Date.now() + 3_600_000).toISOString())}`)).body.total).toBe(0);
253
+ });
254
+ });
255
+
256
+ describe("bookkeeping", () => {
257
+ test("every MCP tool a node can call has a permission class", async () => {
258
+ const s = new McpServer({ name: "np", version: "1" });
259
+ registerTools(s, undefined, net, user.boss.id, node.nN.alias, true, node.nN.tokenId);
260
+ const client = new Client({ name: "np-client", version: "1" });
261
+ const [ct, st] = InMemoryTransport.createLinkedPair();
262
+ await s.connect(st); await client.connect(ct);
263
+ const names = (await client.listTools()).tools.map((t) => t.name);
264
+ await client.close(); await s.close();
265
+ expect(names.length).toBeGreaterThan(50);
266
+ expect(names.filter((n) => !NODE_TOOL_CLASS[n])).toEqual([]);
267
+ });
268
+
269
+ test("log rows older than the retention window are pruned", () => {
270
+ const old = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 13);
271
+ db.run("INSERT INTO node_permission_log (network_id, node_id, route, reason, hour, hits) VALUES (?1, ?2, 'old', 'human_only', ?3, 5)", [net, node.nN.id, old]);
272
+ pruneNodePermissionLog();
273
+ __resetNodePermissionLogStateForTest();
274
+ expect(db.get("SELECT 1 AS hit FROM node_permission_log WHERE route = 'old'")).toBeFalsy();
275
+ });
276
+ });