@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100

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 (273) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-kdf-migration.test.ts +378 -0
  21. package/src/auth-sessions-http.test.ts +250 -0
  22. package/src/auth-tokens.test.ts +18 -9
  23. package/src/auth-validate.test.ts +5 -1
  24. package/src/auth.ts +554 -38
  25. package/src/avatar-validate.test.ts +163 -0
  26. package/src/avatar-validate.ts +103 -0
  27. package/src/blocked-exit-on-reply.test.ts +122 -0
  28. package/src/boot-side-effect.test.ts +124 -0
  29. package/src/commhub-bin-args.test.ts +85 -0
  30. package/src/config-apply-sec1.test.ts +830 -0
  31. package/src/config-apply-validate.test.ts +352 -0
  32. package/src/config-apply-validate.ts +249 -0
  33. package/src/create-node-blocked-gate.test.ts +356 -0
  34. package/src/create-node-tool-schema.test.ts +39 -0
  35. package/src/create-node-validate.test.ts +275 -0
  36. package/src/create-node-validate.ts +288 -0
  37. package/src/create-node-workdir.test.ts +255 -0
  38. package/src/create-node.test.ts +354 -0
  39. package/src/create-node.ts +349 -0
  40. package/src/cross-tenant-injection.test.ts +110 -0
  41. package/src/dashboard-slash-routing-http.test.ts +107 -0
  42. package/src/db-adapter-guard.test.ts +148 -0
  43. package/src/db-adapter-translate.test.ts +128 -0
  44. package/src/db-adapter.ts +323 -87
  45. package/src/db-timestamp.test.ts +38 -0
  46. package/src/db-timestamp.ts +15 -0
  47. package/src/db.ts +1268 -42
  48. package/src/delivered-without-push.test.ts +140 -0
  49. package/src/department-groups-http.test.ts +209 -0
  50. package/src/department-groups-sync-http.test.ts +328 -0
  51. package/src/department-groups.ts +434 -0
  52. package/src/department-heads-http.test.ts +329 -0
  53. package/src/department-heads.ts +226 -0
  54. package/src/departments-http.test.ts +185 -0
  55. package/src/departments.ts +260 -0
  56. package/src/desktop-message-seam.test.ts +150 -0
  57. package/src/desktop-message.test.ts +368 -0
  58. package/src/dm-file-scope-http.test.ts +178 -0
  59. package/src/ext-token-shared.test.ts +60 -0
  60. package/src/ext-validation-regression.test.ts +131 -0
  61. package/src/external-schedule-edits-http.test.ts +321 -0
  62. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  63. package/src/external-schedule-edits.ts +327 -0
  64. package/src/file-download-authz-dev-open.test.ts +135 -0
  65. package/src/file-download-authz.test.ts +404 -0
  66. package/src/file-download-date-bucket.test.ts +436 -0
  67. package/src/file-network-scope-dev-open.test.ts +97 -0
  68. package/src/file-network-scope.test.ts +732 -0
  69. package/src/group-membership-events-http.test.ts +264 -0
  70. package/src/group-messages-http.test.ts +482 -0
  71. package/src/group-messages.ts +297 -0
  72. package/src/health-redaction.test.ts +642 -0
  73. package/src/http-gzip.test.ts +94 -0
  74. package/src/http-gzip.ts +112 -0
  75. package/src/hub-timestamp.test.ts +38 -0
  76. package/src/hub-timestamp.ts +27 -0
  77. package/src/human-dm.ts +215 -0
  78. package/src/inbox-count-sse.test.ts +310 -0
  79. package/src/inbox-count.ts +29 -0
  80. package/src/index.ts +15 -2128
  81. package/src/lifecycle-guard.ts +50 -0
  82. package/src/list-host-supervisors.test.ts +314 -0
  83. package/src/mcp-context-cost-http.test.ts +119 -0
  84. package/src/mcp-projects-events-http.test.ts +162 -0
  85. package/src/mcp-route-label-http.test.ts +120 -0
  86. package/src/mcp-write-network-resolution.test.ts +429 -0
  87. package/src/member-audit-network-id-http.test.ts +93 -0
  88. package/src/member-presence-http.test.ts +206 -0
  89. package/src/messages-ack-by-agent-http.test.ts +142 -0
  90. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  91. package/src/migrate-sqlite-to-pg.ts +263 -0
  92. package/src/model-auth-notify-http.test.ts +181 -0
  93. package/src/model-auth-notify.ts +116 -0
  94. package/src/network-name-http.test.ts +75 -0
  95. package/src/network-scope.ts +224 -0
  96. package/src/network-token-membership-http.test.ts +139 -0
  97. package/src/node-attrs-validate.ts +88 -0
  98. package/src/node-attrs.test.ts +217 -0
  99. package/src/node-create-request-status-http.test.ts +92 -0
  100. package/src/node-files-transport.test.ts +344 -0
  101. package/src/node-health-dispatch-http.test.ts +279 -0
  102. package/src/node-health-guard.ts +105 -0
  103. package/src/node-health-passthrough.test.ts +115 -0
  104. package/src/node-health-store.ts +72 -0
  105. package/src/node-id-alias.test.ts +188 -0
  106. package/src/node-identity-conflict-http.test.ts +269 -0
  107. package/src/node-identity-conflict.ts +142 -0
  108. package/src/node-identity-mismatch.test.ts +74 -0
  109. package/src/node-lifecycle-controllable.test.ts +64 -0
  110. package/src/node-lifecycle-controllable.ts +51 -0
  111. package/src/node-logs-transport.test.ts +263 -0
  112. package/src/node-permissions-http.test.ts +276 -0
  113. package/src/node-permissions.ts +242 -0
  114. package/src/node-request-retention.test.ts +281 -0
  115. package/src/node-request-retention.ts +120 -0
  116. package/src/node-skills-transport.test.ts +209 -0
  117. package/src/node-token-privilege-http.test.ts +149 -0
  118. package/src/observer-avatar-http.test.ts +431 -0
  119. package/src/observer-push.test.ts +252 -0
  120. package/src/package-version-consistency.test.ts +25 -0
  121. package/src/peer-reply-atomic.test.ts +455 -0
  122. package/src/peer-reply-capability.test.ts +70 -0
  123. package/src/pg-worker.ts +87 -0
  124. package/src/probe-validate.test.ts +221 -0
  125. package/src/probe-validate.ts +104 -0
  126. package/src/probe.test.ts +80 -0
  127. package/src/probe.ts +200 -0
  128. package/src/project-viewer-can-http.test.ts +114 -0
  129. package/src/push.ts +429 -10
  130. package/src/redact-tokens.test.ts +65 -0
  131. package/src/redact-tokens.ts +40 -0
  132. package/src/rename.ts +7 -2
  133. package/src/reply-target-derivation.test.ts +96 -0
  134. package/src/report-status-host-schema.test.ts +211 -0
  135. package/src/report-status-resume-id-handover.test.ts +150 -0
  136. package/src/require-explicit-test-db.ts +9 -0
  137. package/src/requirement-due-reminders-http.test.ts +390 -0
  138. package/src/requirement-due-reminders.ts +310 -0
  139. package/src/requirement-events-http.test.ts +257 -0
  140. package/src/requirement-events.test.ts +45 -0
  141. package/src/requirement-events.ts +144 -0
  142. package/src/requirement-notify.ts +165 -0
  143. package/src/requirement-participant-notify-http.test.ts +309 -0
  144. package/src/requirement-tag-ops-http.test.ts +266 -0
  145. package/src/requirement-tags.ts +89 -0
  146. package/src/requirements-comment-http.test.ts +165 -0
  147. package/src/requirements-completed-at-migration.test.ts +70 -0
  148. package/src/requirements-errors-mcp-http.test.ts +164 -0
  149. package/src/requirements-errors.ts +143 -0
  150. package/src/requirements-etag-http.test.ts +91 -0
  151. package/src/requirements-http.test.ts +709 -0
  152. package/src/requirements-last-event-http.test.ts +344 -0
  153. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  154. package/src/requirements-list-slim-http.test.ts +319 -0
  155. package/src/requirements-mcp.test.ts +163 -0
  156. package/src/requirements-migrate.ts +225 -0
  157. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  158. package/src/requirements-people-http.test.ts +114 -0
  159. package/src/requirements-people-mcp-http.test.ts +225 -0
  160. package/src/requirements-people.ts +127 -0
  161. package/src/requirements-priority-lowest.test.ts +114 -0
  162. package/src/requirements-search-http.test.ts +182 -0
  163. package/src/requirements-search.test.ts +32 -0
  164. package/src/requirements-search.ts +145 -0
  165. package/src/requirements-seq-http.test.ts +193 -0
  166. package/src/requirements-seq-migration.test.ts +63 -0
  167. package/src/requirements-start-date.test.ts +125 -0
  168. package/src/requirements-stats-http.test.ts +288 -0
  169. package/src/requirements-stats.ts +164 -0
  170. package/src/requirements-status-participants-http.test.ts +220 -0
  171. package/src/requirements-upgrade-e2e.test.ts +126 -0
  172. package/src/requirements-upgrade-migration.test.ts +98 -0
  173. package/src/requirements.ts +1761 -0
  174. package/src/resolve-port.test.ts +57 -0
  175. package/src/resolve-port.ts +44 -0
  176. package/src/response-charset.test.ts +130 -0
  177. package/src/rest-explicit-columns-http.test.ts +222 -0
  178. package/src/rest-identity.test.ts +129 -0
  179. package/src/rest-identity.ts +100 -0
  180. package/src/rest-projections.ts +68 -0
  181. package/src/rest-write-network-resolution.test.ts +133 -0
  182. package/src/restricted-files.ts +73 -0
  183. package/src/retention.test.ts +450 -0
  184. package/src/retention.ts +281 -0
  185. package/src/route-callers-http.test.ts +120 -0
  186. package/src/route-timing.test.ts +211 -0
  187. package/src/route-timing.ts +258 -0
  188. package/src/rules-file-duplicate-alias.test.ts +234 -0
  189. package/src/rules-file-session-target.test.ts +207 -0
  190. package/src/rules-file-transport.test.ts +237 -0
  191. package/src/runtime-label.test.ts +22 -0
  192. package/src/runtime-label.ts +21 -0
  193. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  194. package/src/scheduled-reply-unread-http.test.ts +212 -0
  195. package/src/scheduled-run-terminal.test.ts +268 -0
  196. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  197. package/src/scheduled-stuck-http.test.ts +238 -0
  198. package/src/scheduled-stuck.ts +153 -0
  199. package/src/scheduled-tasks-http.test.ts +638 -0
  200. package/src/scheduled-tasks.ts +641 -0
  201. package/src/send-dedup-request-id.test.ts +229 -0
  202. package/src/send-reply-agent-warning.test.ts +202 -0
  203. package/src/send-reply-alias-optional.test.ts +131 -0
  204. package/src/send-reply-attachments.test.ts +458 -0
  205. package/src/send_dedup.test.ts +54 -0
  206. package/src/send_dedup.ts +52 -6
  207. package/src/serve-error.test.ts +38 -0
  208. package/src/serve-error.ts +27 -0
  209. package/src/server.ts +5038 -0
  210. package/src/shared/external-schedule-contract.ts +60 -0
  211. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  212. package/src/shared/probe-host-allowlist.ts +128 -0
  213. package/src/shared/reserved-env-drift.test.ts +43 -0
  214. package/src/shared/reserved-env.test.ts +35 -0
  215. package/src/shared/reserved-env.ts +31 -0
  216. package/src/side-thread-command-transport.test.ts +312 -0
  217. package/src/side-thread-command-transport.ts +318 -0
  218. package/src/side-thread-http-integration.test.ts +233 -0
  219. package/src/side-thread-http.ts +504 -0
  220. package/src/side-thread-production.test.ts +72 -0
  221. package/src/side-thread-production.ts +118 -0
  222. package/src/side-thread.test.ts +1067 -0
  223. package/src/side-thread.ts +2499 -0
  224. package/src/skillhub-http.test.ts +108 -0
  225. package/src/sqlite-close-checkpoint.test.ts +78 -0
  226. package/src/stale-sweeper.test.ts +181 -0
  227. package/src/stale-sweeper.ts +114 -0
  228. package/src/start-node.test.ts +109 -0
  229. package/src/status-alias-filter-http.test.ts +106 -0
  230. package/src/status-alias-resolver-http.test.ts +163 -0
  231. package/src/status-alias-resolver.ts +19 -0
  232. package/src/status-node-id-filter-http.test.ts +161 -0
  233. package/src/status-read-cache-http.test.ts +229 -0
  234. package/src/status-read-cache.ts +122 -0
  235. package/src/stop-delete-node.test.ts +1164 -0
  236. package/src/task-access-http.test.ts +379 -0
  237. package/src/task-access.ts +413 -0
  238. package/src/task-auth-origin-http.test.ts +91 -0
  239. package/src/task-auth-origin.test.ts +24 -0
  240. package/src/task-auth-origin.ts +7 -0
  241. package/src/task-consumption.test.ts +482 -0
  242. package/src/task-diagnostic-http.test.ts +149 -0
  243. package/src/task-diagnostic.test.ts +115 -0
  244. package/src/task-diagnostic.ts +118 -0
  245. package/src/task-expiry-notice-http.test.ts +287 -0
  246. package/src/task-expiry-notice.ts +313 -0
  247. package/src/task-idempotency-mcp.test.ts +142 -0
  248. package/src/task-idempotency.test.ts +60 -0
  249. package/src/task-idempotency.ts +75 -0
  250. package/src/task-lifecycle-watcher.test.ts +204 -0
  251. package/src/task-lifecycle-watcher.ts +49 -0
  252. package/src/task-network-resolution-http.test.ts +141 -0
  253. package/src/task-queue-ahead-http.test.ts +309 -0
  254. package/src/task-queue-ahead-scale.test.ts +211 -0
  255. package/src/task-queue-ahead.ts +186 -0
  256. package/src/task-stale-open-http.test.ts +290 -0
  257. package/src/task-stale-open.ts +103 -0
  258. package/src/tasks-pagination-http.test.ts +141 -0
  259. package/src/tool-audience-http.test.ts +195 -0
  260. package/src/tool-audience.ts +109 -0
  261. package/src/tools-list-json-schema.test.ts +67 -0
  262. package/src/tools.ts +5426 -275
  263. package/src/update-provider.test.ts +234 -0
  264. package/src/upload-early-reject-guard.test.ts +155 -0
  265. package/src/uploads-http.test.ts +97 -15
  266. package/src/uploads.test.ts +66 -0
  267. package/src/uploads.ts +138 -5
  268. package/src/user-inbox-by-agent-http.test.ts +161 -0
  269. package/src/user-inbox-netid-notnull.test.ts +122 -0
  270. package/src/user-inbox-read-http.test.ts +223 -0
  271. package/src/user-inbox-schema.test.ts +54 -0
  272. package/src/vault.test.ts +200 -0
  273. package/src/vault.ts +156 -0
@@ -0,0 +1,352 @@
1
+ // Coverage for the RFC-024 patch validators / classifiers. Pinned at
2
+ // the helper layer so the contract (allowlist + security-flag gate +
3
+ // per-field range/enum + apply-mode classification) can't drift
4
+ // silently. SEC-2 fail-closed regression guard included — any future
5
+ // "convenience" change that lets non-admin remote-flip a security flag
6
+ // fails these tests.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import {
10
+ ALLOWED_FLAGS,
11
+ SECURITY_SENSITIVE_FLAGS,
12
+ EDITABLE_CHANNELS,
13
+ isAllowedToChangeFlag,
14
+ computeApplyMode,
15
+ validatePatch,
16
+ narrowChannelsPatch,
17
+ } from "./config-apply-validate";
18
+
19
+ describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
20
+ test("contains exactly the 5 fields from RFC-024 §4 (teammateMode dropped per #290 review)", () => {
21
+ // teammateMode was originally listed in the 6-flag scope but
22
+ // investigation found it's only consumed by the claude-code-cli
23
+ // spawn path (agent-network/bin/cli.ts), NOT by the agent-node-
24
+ // driven runtimes the config-apply pipeline targets. Including it
25
+ // would silently ack `applied` for no-op changes — same class as
26
+ // the BLOCKER 2 schema-mismatch issue. P2 to add a claude-code-cli
27
+ // config-apply path.
28
+ expect([...ALLOWED_FLAGS].sort()).toEqual([
29
+ "budget",
30
+ "dangerouslySkipPermissions",
31
+ "maxTurns",
32
+ "permissionMode",
33
+ "timeout",
34
+ ]);
35
+ });
36
+ });
37
+
38
+ describe("SECURITY_SENSITIVE_FLAGS — privilege-elevation surface", () => {
39
+ test("contains the 2 privilege-elevation flags (teammateMode dropped — see ALLOWED_FLAGS comment)", () => {
40
+ expect([...SECURITY_SENSITIVE_FLAGS].sort()).toEqual([
41
+ "dangerouslySkipPermissions",
42
+ "permissionMode",
43
+ ]);
44
+ });
45
+
46
+ test("every security-sensitive flag is also in ALLOWED_FLAGS (sanity)", () => {
47
+ for (const f of SECURITY_SENSITIVE_FLAGS) expect(ALLOWED_FLAGS.has(f)).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe("isAllowedToChangeFlag — SEC-2 admin-gate (final policy 2026-06-28)", () => {
52
+ // Security-sensitive flags require admin role on the caller's
53
+ // network. Any non-admin role is rejected. Harmless flags fall
54
+ // through (`canWrite` upstream handles role !== viewer).
55
+
56
+ test("member role + dangerouslySkipPermissions → reject", () => {
57
+ const r = isAllowedToChangeFlag("member", { dangerouslySkipPermissions: true });
58
+ expect(r).not.toBeNull();
59
+ expect(r?.field).toBe("flags.dangerouslySkipPermissions");
60
+ expect(r?.reason).toMatch(/admin or owner/i);
61
+ });
62
+
63
+ test("admin role + dangerouslySkipPermissions → pass (allowed)", () => {
64
+ expect(isAllowedToChangeFlag("admin", { dangerouslySkipPermissions: false })).toBeNull();
65
+ });
66
+
67
+ test("admin role + permissionMode → pass", () => {
68
+ expect(isAllowedToChangeFlag("admin", { permissionMode: "default" })).toBeNull();
69
+ });
70
+
71
+ test("teammateMode dropped from schema (P1 scope) — no role check fires for it", () => {
72
+ // teammateMode no longer in SECURITY_SENSITIVE_FLAGS, so SEC-2 gate
73
+ // doesn't fire. The patch-allowlist gate (validatePatch) rejects it
74
+ // separately as "not in allowlist", which is the correct surface.
75
+ expect(isAllowedToChangeFlag("admin", { teammateMode: true } as any)).toBeNull();
76
+ });
77
+
78
+ test("owner role + permissionMode → pass (owner is higher than admin in anet RBAC)", () => {
79
+ // owner > admin in the network_members hierarchy (auth.ts line 354
80
+ // explicitly treats owner as the highest tier; updateMemberRole
81
+ // refuses to assign owner). The SEC-2 gate uses an admin-or-above
82
+ // semantic so the highest-privilege user isn't accidentally locked
83
+ // out of a permission their subordinates can use.
84
+ expect(isAllowedToChangeFlag("owner", { permissionMode: "default" })).toBeNull();
85
+ });
86
+
87
+ test("owner role + dangerouslySkipPermissions → pass", () => {
88
+ expect(isAllowedToChangeFlag("owner", { dangerouslySkipPermissions: true })).toBeNull();
89
+ });
90
+
91
+ test("viewer role + any security flag → reject", () => {
92
+ expect(isAllowedToChangeFlag("viewer", { dangerouslySkipPermissions: true })).not.toBeNull();
93
+ });
94
+
95
+ test("null role (no network membership) + any security flag → reject", () => {
96
+ expect(isAllowedToChangeFlag(null, { dangerouslySkipPermissions: true })).not.toBeNull();
97
+ });
98
+
99
+ test("member role + mixed bag with security flag → reject (checks every flag, not just first)", () => {
100
+ const r = isAllowedToChangeFlag("member", { permissionMode: "default", dangerouslySkipPermissions: true });
101
+ expect(r).not.toBeNull();
102
+ });
103
+
104
+ test("admin role + mixed bag (security + harmless) → pass", () => {
105
+ expect(isAllowedToChangeFlag("admin", { dangerouslySkipPermissions: true, maxTurns: 50 })).toBeNull();
106
+ });
107
+
108
+ test("member role + only harmless flags (maxTurns) → pass", () => {
109
+ expect(isAllowedToChangeFlag("member", { maxTurns: 50 })).toBeNull();
110
+ });
111
+
112
+ test("member role + only harmless flags (maxTurns + budget) → pass", () => {
113
+ expect(isAllowedToChangeFlag("member", { maxTurns: 50, budget: 100 })).toBeNull();
114
+ });
115
+
116
+ test("any role + empty flag bag → pass (no flags to gate)", () => {
117
+ expect(isAllowedToChangeFlag(null, {})).toBeNull();
118
+ expect(isAllowedToChangeFlag("viewer", {})).toBeNull();
119
+ expect(isAllowedToChangeFlag("member", {})).toBeNull();
120
+ expect(isAllowedToChangeFlag("admin", {})).toBeNull();
121
+ });
122
+ });
123
+
124
+ describe("computeApplyMode — tier classifier (RFC-024 §4)", () => {
125
+ test("empty patch (model + flags both empty) → restart_only", () => {
126
+ expect(computeApplyMode(undefined, {})).toBe("restart_only");
127
+ });
128
+
129
+ test("model alone → restart (SDK reinit needed)", () => {
130
+ expect(computeApplyMode("claude-opus-4", {})).toBe("restart");
131
+ });
132
+
133
+ test("flags.permissionMode alone → restart", () => {
134
+ expect(computeApplyMode(undefined, { permissionMode: "auto" })).toBe("restart");
135
+ });
136
+
137
+ test("flags.dangerouslySkipPermissions alone → restart", () => {
138
+ expect(computeApplyMode(undefined, { dangerouslySkipPermissions: true })).toBe("restart");
139
+ });
140
+
141
+ test("teammateMode dropped from RESTART_REQUIRED_FLAGS (no longer in scope)", () => {
142
+ // teammateMode no longer in RESTART_REQUIRED_FLAGS. computeApplyMode
143
+ // sees only unknown-to-tier-table flags → returns "hot" by default.
144
+ // The patch-allowlist gate (validatePatch) rejects it separately.
145
+ expect(computeApplyMode(undefined, { teammateMode: false } as any)).toBe("hot");
146
+ });
147
+
148
+ test("flags.timeout alone → restart (per RFC-024 §4 — module-level const today)", () => {
149
+ expect(computeApplyMode(undefined, { timeout: 60000 })).toBe("restart");
150
+ });
151
+
152
+ test("flags.maxTurns alone → hot", () => {
153
+ expect(computeApplyMode(undefined, { maxTurns: 50 })).toBe("hot");
154
+ });
155
+
156
+ test("flags.budget alone → hot", () => {
157
+ expect(computeApplyMode(undefined, { budget: 100 })).toBe("hot");
158
+ });
159
+
160
+ test("mixed (model + maxTurns) → restart (strictest wins)", () => {
161
+ expect(computeApplyMode("gpt-5", { maxTurns: 50 })).toBe("restart");
162
+ });
163
+
164
+ test("mixed (only hot flags) → hot", () => {
165
+ expect(computeApplyMode(undefined, { maxTurns: 50, budget: 100 })).toBe("hot");
166
+ });
167
+
168
+ test("mixed (hot + one restart flag) → restart", () => {
169
+ expect(computeApplyMode(undefined, { maxTurns: 50, timeout: 60000 })).toBe("restart");
170
+ });
171
+ });
172
+
173
+ describe("validatePatch — model field", () => {
174
+ test("undefined model passes (no change)", () => {
175
+ expect(validatePatch(undefined, {})).toBeNull();
176
+ });
177
+ test("valid model string passes", () => {
178
+ expect(validatePatch("claude-opus-4", {})).toBeNull();
179
+ });
180
+ test("empty model string rejected", () => {
181
+ const r = validatePatch("", {});
182
+ expect(r?.field).toBe("model");
183
+ });
184
+ test("model >200 chars rejected", () => {
185
+ const r = validatePatch("x".repeat(201), {});
186
+ expect(r?.field).toBe("model");
187
+ });
188
+ test("non-string model rejected", () => {
189
+ const r = validatePatch(123 as any, {});
190
+ expect(r?.field).toBe("model");
191
+ });
192
+ });
193
+
194
+ describe("validatePatch — flag allowlist (anything not in ALLOWED_FLAGS rejected)", () => {
195
+ test("unknown flag rejected", () => {
196
+ const r = validatePatch(undefined, { mysteryFlag: 1 } as any);
197
+ expect(r?.field).toBe("flags.mysteryFlag");
198
+ expect(r?.reason).toMatch(/allowlist/);
199
+ });
200
+ test("typo of allowed flag rejected (no fuzzy match)", () => {
201
+ const r = validatePatch(undefined, { permission_mode: "auto" } as any);
202
+ expect(r?.field).toBe("flags.permission_mode");
203
+ });
204
+ });
205
+
206
+ describe("validatePatch — per-flag enum / range", () => {
207
+ test("permissionMode valid values", () => {
208
+ for (const v of ["default", "auto", "bypassPermissions", "acceptEdits", "plan"]) {
209
+ expect(validatePatch(undefined, { permissionMode: v })).toBeNull();
210
+ }
211
+ });
212
+ test("permissionMode invalid value rejected", () => {
213
+ const r = validatePatch(undefined, { permissionMode: "godmode" } as any);
214
+ expect(r?.field).toBe("flags.permissionMode");
215
+ });
216
+ test("dangerouslySkipPermissions must be boolean", () => {
217
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: true })).toBeNull();
218
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: false })).toBeNull();
219
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: "true" } as any)?.field).toBe("flags.dangerouslySkipPermissions");
220
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: 1 } as any)?.field).toBe("flags.dangerouslySkipPermissions");
221
+ });
222
+ test("teammateMode rejected as not-in-allowlist (dropped from P1 scope)", () => {
223
+ // teammateMode was originally allowed but dropped per #290 review
224
+ // (no consumer in agent-node runtimes). validatePatch now rejects
225
+ // it the same way it rejects any unknown flag.
226
+ const r = validatePatch(undefined, { teammateMode: true } as any);
227
+ expect(r?.field).toBe("flags.teammateMode");
228
+ expect(r?.reason).toMatch(/allowlist/);
229
+ });
230
+ test("maxTurns must be integer in [0, 10000]", () => {
231
+ expect(validatePatch(undefined, { maxTurns: 0 })).toBeNull();
232
+ expect(validatePatch(undefined, { maxTurns: 10000 })).toBeNull();
233
+ expect(validatePatch(undefined, { maxTurns: -1 })?.field).toBe("flags.maxTurns");
234
+ expect(validatePatch(undefined, { maxTurns: 10001 })?.field).toBe("flags.maxTurns");
235
+ expect(validatePatch(undefined, { maxTurns: 1.5 })?.field).toBe("flags.maxTurns");
236
+ expect(validatePatch(undefined, { maxTurns: "5" } as any)?.field).toBe("flags.maxTurns");
237
+ });
238
+ test("budget must be number in [0, 1_000_000]", () => {
239
+ expect(validatePatch(undefined, { budget: 0 })).toBeNull();
240
+ expect(validatePatch(undefined, { budget: 500.5 })).toBeNull();
241
+ expect(validatePatch(undefined, { budget: 1_000_000 })).toBeNull();
242
+ expect(validatePatch(undefined, { budget: -1 })?.field).toBe("flags.budget");
243
+ expect(validatePatch(undefined, { budget: 1_000_001 })?.field).toBe("flags.budget");
244
+ });
245
+ test("timeout must be integer ms in [0, 3_600_000]", () => {
246
+ expect(validatePatch(undefined, { timeout: 30000 })).toBeNull();
247
+ expect(validatePatch(undefined, { timeout: 3_600_000 })).toBeNull();
248
+ expect(validatePatch(undefined, { timeout: -1 })?.field).toBe("flags.timeout");
249
+ expect(validatePatch(undefined, { timeout: 3_600_001 })?.field).toBe("flags.timeout");
250
+ expect(validatePatch(undefined, { timeout: 60.5 })?.field).toBe("flags.timeout");
251
+ });
252
+ });
253
+
254
+ describe("validatePatch — combinations", () => {
255
+ test("valid model + valid flag bag passes", () => {
256
+ expect(validatePatch("claude-opus-4", { maxTurns: 50, budget: 100 })).toBeNull();
257
+ });
258
+ test("first invalid field surfaces (good model + bad flag)", () => {
259
+ const r = validatePatch("claude-opus-4", { maxTurns: -1 });
260
+ expect(r?.field).toBe("flags.maxTurns");
261
+ });
262
+ });
263
+
264
+ // ── #260 P5 — channels support (restart-tier, non-security-sensitive) ─
265
+ describe("EDITABLE_CHANNELS — mirrors agent-node runtime capability (cli.ts:671-676)", () => {
266
+ test("contains exactly telegram + feishu (channels agent-node actually forks workers for)", () => {
267
+ expect(EDITABLE_CHANNELS.size).toBe(2);
268
+ expect(EDITABLE_CHANNELS.has("telegram")).toBe(true);
269
+ expect(EDITABLE_CHANNELS.has("feishu")).toBe(true);
270
+ });
271
+ test("commhub is NOT editable — it's the RPC transport, not a per-node channel worker", () => {
272
+ expect(EDITABLE_CHANNELS.has("commhub")).toBe(false);
273
+ });
274
+ test("wechat is NOT editable — roadmap only on dashboard", () => {
275
+ expect(EDITABLE_CHANNELS.has("wechat")).toBe(false);
276
+ });
277
+ });
278
+
279
+ describe("narrowChannelsPatch — hostile input hygiene at the wire boundary", () => {
280
+ test("straight allow-listed input passes verbatim", () => {
281
+ expect(narrowChannelsPatch(["telegram", "feishu"])).toEqual(["telegram", "feishu"]);
282
+ });
283
+ test("empty array preserved — 'disable all editable channels' is a real state", () => {
284
+ expect(narrowChannelsPatch([])).toEqual([]);
285
+ });
286
+ test("non-array returns null (patch didn't touch channels)", () => {
287
+ expect(narrowChannelsPatch(undefined)).toBeNull();
288
+ expect(narrowChannelsPatch("telegram" as any)).toBeNull();
289
+ expect(narrowChannelsPatch({} as any)).toBeNull();
290
+ });
291
+ test("case-folded to lower", () => {
292
+ expect(narrowChannelsPatch(["FEISHU", "Telegram"])).toEqual(["feishu", "telegram"]);
293
+ });
294
+ test("duplicates deduped (Set)", () => {
295
+ expect(narrowChannelsPatch(["telegram", "telegram", "feishu", "TELEGRAM"])).toEqual(["telegram", "feishu"]);
296
+ });
297
+ test("unknown channel keys dropped silently (roadmap wechat, transport commhub, evil keys)", () => {
298
+ expect(narrowChannelsPatch(["wechat", "telegram", "evil-hacker", "commhub"])).toEqual(["telegram"]);
299
+ });
300
+ test("non-string entries dropped (numbers, objects, nulls)", () => {
301
+ expect(narrowChannelsPatch(["telegram", 42, {}, null, "feishu"] as any)).toEqual(["telegram", "feishu"]);
302
+ });
303
+ test("SQL-injection-shaped value dropped (not exact key match)", () => {
304
+ expect(narrowChannelsPatch(["telegram; drop table users;", "feishu"])).toEqual(["feishu"]);
305
+ });
306
+ test("whitespace around a key still trims to the key", () => {
307
+ expect(narrowChannelsPatch([" feishu "])).toEqual(["feishu"]);
308
+ });
309
+ });
310
+
311
+ describe("computeApplyMode — channels tier (restart)", () => {
312
+ test("channels-only patch → restart (boot forks per-channel workers)", () => {
313
+ expect(computeApplyMode(undefined, {}, ["feishu"])).toBe("restart");
314
+ });
315
+ test("empty-array channels → restart (still a state change)", () => {
316
+ expect(computeApplyMode(undefined, {}, [])).toBe("restart");
317
+ });
318
+ test("no channels key + no flags + no model → restart_only", () => {
319
+ expect(computeApplyMode(undefined, {}, undefined)).toBe("restart_only");
320
+ });
321
+ test("channels + hot-tier flag together stays restart (upgrade rules)", () => {
322
+ expect(computeApplyMode(undefined, { maxTurns: 5 }, ["telegram"])).toBe("restart");
323
+ });
324
+ test("channels + model → restart (model was already restart)", () => {
325
+ expect(computeApplyMode("gpt-5", {}, ["feishu"])).toBe("restart");
326
+ });
327
+ });
328
+
329
+ describe("validatePatch — channels defensive gate", () => {
330
+ test("valid channels array passes", () => {
331
+ expect(validatePatch(undefined, {}, ["telegram", "feishu"])).toBeNull();
332
+ expect(validatePatch(undefined, {}, [])).toBeNull();
333
+ expect(validatePatch(undefined, {}, ["telegram"])).toBeNull();
334
+ });
335
+ test("commhub rejected — RPC transport, not a per-node channel", () => {
336
+ const r = validatePatch(undefined, {}, ["commhub"]);
337
+ expect(r?.field).toBe("channels.commhub");
338
+ });
339
+ test("out-of-allowlist channel rejected (caller bypassed narrowing)", () => {
340
+ const r = validatePatch(undefined, {}, ["wechat"]);
341
+ expect(r?.field).toBe("channels.wechat");
342
+ expect(r?.reason).toMatch(/allowlist/);
343
+ });
344
+ test("non-string entry rejected", () => {
345
+ const r = validatePatch(undefined, {}, [42] as any);
346
+ expect(r?.field).toBe("channels");
347
+ });
348
+ test("non-array rejected", () => {
349
+ const r = validatePatch(undefined, {}, "telegram" as any);
350
+ expect(r?.field).toBe("channels");
351
+ });
352
+ });
@@ -0,0 +1,249 @@
1
+ // Pure validators / classifiers for RFC-024 node-config-apply. Extracted
2
+ // from server/src/tools.ts so the contract (allowlist, security-sensitive
3
+ // flag set, range/enum rules, apply-mode classification) is unit-testable
4
+ // without spinning up an MCP server. The corresponding tool handlers in
5
+ // tools.ts wrap these with auth checks (SEC-1 network scope, ntok_/utok_
6
+ // caller identity, role gates).
7
+
8
+ /**
9
+ * Fields the dashboard may change. Anything not in this list is rejected
10
+ * by hub-side validation regardless of role — the UI cannot smuggle
11
+ * extra keys past this gate.
12
+ */
13
+ export const ALLOWED_FLAGS = new Set<string>([
14
+ "permissionMode",
15
+ "dangerouslySkipPermissions",
16
+ "maxTurns",
17
+ "budget",
18
+ "timeout",
19
+ ]);
20
+ // NB on teammateMode (dropped from P1 scope per #290 cross-agent
21
+ // review): teammateMode is consumed ONLY by the claude-code-cli
22
+ // spawn path in agent-network/bin/cli.ts (passed as --teammate-mode
23
+ // CLI arg). The agent-node-driven runtimes (claude-agent-sdk /
24
+ // codex-sdk / grok-build-acp) that the config-apply pipeline targets
25
+ // do NOT consume it. Including it in the allowlist would silently
26
+ // ack `applied` for changes that have zero effect — same class as
27
+ // the BLOCKER 2 schema-mismatch issue. P2: add a claude-code-cli
28
+ // config-apply path that respawns the CLI process.
29
+
30
+ /**
31
+ * Security-sensitive flags — remote changes are privilege-elevation
32
+ * operations. Final policy (decided 2026-06-28 by 通信龙 per Vincent
33
+ * autonomy grant): **caller role must equal `admin` to flip these
34
+ * flags remotely.** Non-admin requests are rejected with
35
+ * `insufficient_role_for_security_flag`. Cross-network requests are
36
+ * also blocked but via SEC-1 (network scope), not this gate.
37
+ *
38
+ * Dashboard is expected to grey out these inputs for non-admin
39
+ * sessions, but hub does NOT trust the dashboard's UI gate — every
40
+ * tool call is re-checked here. `curl` direct to `/mcp` is a real
41
+ * attack vector.
42
+ */
43
+ export const SECURITY_SENSITIVE_FLAGS = new Set<string>([
44
+ "permissionMode",
45
+ "dangerouslySkipPermissions",
46
+ ]);
47
+
48
+ /**
49
+ * Flags that require a node process restart (not in-process hot reload).
50
+ * Used by computeApplyMode + the per-tool tier classifier. `timeout`
51
+ * sits here today because CLAUDE_TIMEOUT_MS is a module-level const at
52
+ * boot; P2 will move it to per-think read which would let it become hot.
53
+ */
54
+ export const RESTART_REQUIRED_FLAGS = new Set<string>([
55
+ "permissionMode",
56
+ "dangerouslySkipPermissions",
57
+ "timeout",
58
+ ]);
59
+
60
+ /**
61
+ * Channels the dashboard may enable/disable on a node via
62
+ * update_node_config. Intentionally narrow — the panel only exposes
63
+ * enable/disable, per-channel secrets stay in the node's local
64
+ * config.json (see #260 wire discipline).
65
+ *
66
+ * The list mirrors agent-node's actual runtime capability. See
67
+ * agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
68
+ * worker inits (initTelegramChannel + initFeishuChannel), and any
69
+ * other channel type triggers `process.exit(1)` at boot. So the
70
+ * hub-side allow-list here is telegram + feishu ONLY. `commhub` is
71
+ * the RPC transport that every node speaks unconditionally; treating
72
+ * it as a per-node channel would be a UX lie (toggle looks off but
73
+ * commhub is always on).
74
+ *
75
+ * Dashboard PR #31 currently includes `commhub` in its own
76
+ * EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
77
+ * the next dashboard rally, and until then hub silently drops any
78
+ * `commhub` entry via narrowChannelsPatch.
79
+ *
80
+ * Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
81
+ * channel on/off is a lifecycle-tier operation like `restart_node`, not
82
+ * a privilege elevation. Cross-tenant reach is already gated by SEC-1.
83
+ */
84
+ export const EDITABLE_CHANNELS = new Set<string>([
85
+ "telegram",
86
+ "feishu",
87
+ ]);
88
+
89
+ /**
90
+ * Role-gate for the patch's flag set. SEC-2 enforcement lives here.
91
+ *
92
+ * Returns null on pass, or `{ field, reason }` on reject. The reject
93
+ * payload shape matches the tool handler's error envelope so the
94
+ * caller can forward it without re-shaping.
95
+ *
96
+ * Policy (final, 2026-06-28):
97
+ * - security-sensitive flags → caller role MUST be admin-or-above
98
+ * (admin OR owner; owner > admin in the anet RBAC, per auth.ts
99
+ * network_members hierarchy — viewer < member < admin < owner)
100
+ * - harmless flags → handled by upstream `canWrite` (role !== viewer);
101
+ * this helper passes them through (no per-flag gate beyond
102
+ * allowlist/range validation)
103
+ *
104
+ * Caller must pass the role string resolved from the user's
105
+ * network_members row (not the token's bearer-level role). Owners
106
+ * are NOT included via "admin ⊇ owner" inheritance — they're a
107
+ * distinct higher tier; the check is explicit OR so any future role
108
+ * (e.g. "super_admin") needs an explicit allowlist update.
109
+ */
110
+ const SECURITY_ADMIN_ROLES = new Set<string>(["admin", "owner"]);
111
+
112
+ export function isAllowedToChangeFlag(
113
+ role: string | null,
114
+ patchFlags: Record<string, unknown>,
115
+ ): { field: string; reason: string } | null {
116
+ for (const key of Object.keys(patchFlags)) {
117
+ if (SECURITY_SENSITIVE_FLAGS.has(key)) {
118
+ if (!role || !SECURITY_ADMIN_ROLES.has(role)) {
119
+ return {
120
+ field: `flags.${key}`,
121
+ reason: "remote change of security-sensitive flags requires admin or owner role on this network",
122
+ };
123
+ }
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Classify the patch's required apply mode. Empty patch (model + flags
131
+ * both empty, no channels) → "restart_only" (used by restart_node tool).
132
+ * Any restart-required field present, OR a channels change → "restart"
133
+ * (channels is restart-tier by design: the node reads its channel set
134
+ * once at boot in agent-node/src/cli.ts and forks per-channel workers
135
+ * from it, so a channel enable/disable takes effect on process restart
136
+ * only — see #260 P5 wire discipline). Otherwise → "hot".
137
+ */
138
+ export function computeApplyMode(
139
+ model: string | undefined,
140
+ flags: Record<string, unknown>,
141
+ channels?: string[] | undefined,
142
+ ): "hot" | "restart" | "restart_only" {
143
+ const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
144
+ if (fieldCount === 0) return "restart_only";
145
+ if (model !== undefined) return "restart";
146
+ if (channels !== undefined) return "restart";
147
+ for (const key of Object.keys(flags)) {
148
+ if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
149
+ }
150
+ return "hot";
151
+ }
152
+
153
+ /**
154
+ * Narrow + allow-list `channels` from untrusted patch JSON. Returns the
155
+ * final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
156
+ * order-preserving), OR null if the input isn't an array. Non-strings,
157
+ * unknown keys, empty strings, and duplicates are silently dropped —
158
+ * same fail-shape as the dashboard route's whitelist so the two sides
159
+ * behave identically end-to-end. Callers pass `undefined` through when
160
+ * the patch didn't include a channels key at all (a distinct case from
161
+ * `channels: []` which means "disable all editable channels").
162
+ */
163
+ export function narrowChannelsPatch(raw: unknown): string[] | null {
164
+ if (!Array.isArray(raw)) return null;
165
+ const allow = EDITABLE_CHANNELS;
166
+ const seen = new Set<string>();
167
+ const out: string[] = [];
168
+ for (const v of raw) {
169
+ if (typeof v !== "string") continue;
170
+ const key = v.trim().toLowerCase();
171
+ if (!allow.has(key) || seen.has(key)) continue;
172
+ seen.add(key);
173
+ out.push(key);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * Validate the patch shape — allowlist + per-field range / enum. Returns
180
+ * null on pass, or `{ field, reason }` on reject. Caller wraps in error
181
+ * envelope.
182
+ */
183
+ export function validatePatch(
184
+ model: string | undefined,
185
+ flags: Record<string, unknown>,
186
+ channels?: string[] | undefined,
187
+ ): { field: string; reason: string } | null {
188
+ if (model !== undefined) {
189
+ if (typeof model !== "string" || model.length === 0 || model.length > 200) {
190
+ return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
191
+ }
192
+ }
193
+ if (channels !== undefined) {
194
+ // Zod already enforced string[], and narrowChannelsPatch dropped
195
+ // unknown keys before this point — but re-verify defensively so a
196
+ // future caller that skips narrowChannelsPatch can't smuggle
197
+ // unknown keys through validatePatch alone.
198
+ if (!Array.isArray(channels)) {
199
+ return { field: "channels", reason: "must be an array of strings" };
200
+ }
201
+ for (const c of channels) {
202
+ if (typeof c !== "string") {
203
+ return { field: "channels", reason: "must contain only strings" };
204
+ }
205
+ if (!EDITABLE_CHANNELS.has(c)) {
206
+ return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
207
+ }
208
+ }
209
+ }
210
+ for (const [key, val] of Object.entries(flags)) {
211
+ if (!ALLOWED_FLAGS.has(key)) {
212
+ return { field: `flags.${key}`, reason: "not in allowlist" };
213
+ }
214
+ switch (key) {
215
+ case "permissionMode":
216
+ if (
217
+ typeof val !== "string" ||
218
+ !["default", "auto", "bypassPermissions", "acceptEdits", "plan"].includes(val)
219
+ ) {
220
+ return {
221
+ field: "flags.permissionMode",
222
+ reason: "must be one of default/auto/bypassPermissions/acceptEdits/plan",
223
+ };
224
+ }
225
+ break;
226
+ case "dangerouslySkipPermissions":
227
+ if (typeof val !== "boolean") {
228
+ return { field: `flags.${key}`, reason: "must be boolean" };
229
+ }
230
+ break;
231
+ case "maxTurns":
232
+ if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 10000) {
233
+ return { field: "flags.maxTurns", reason: "must be an integer in [0, 10000]" };
234
+ }
235
+ break;
236
+ case "budget":
237
+ if (typeof val !== "number" || val < 0 || val > 1000000) {
238
+ return { field: "flags.budget", reason: "must be a number in [0, 1_000_000]" };
239
+ }
240
+ break;
241
+ case "timeout":
242
+ if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 3_600_000) {
243
+ return { field: "flags.timeout", reason: "must be an integer ms in [0, 3_600_000]" };
244
+ }
245
+ break;
246
+ }
247
+ }
248
+ return null;
249
+ }