@sleep2agi/commhub-server 0.9.0-preview.1 → 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 (267) 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-sessions-http.test.ts +250 -0
  21. package/src/auth-validate.test.ts +5 -1
  22. package/src/auth.ts +493 -35
  23. package/src/avatar-validate.test.ts +163 -0
  24. package/src/avatar-validate.ts +103 -0
  25. package/src/blocked-exit-on-reply.test.ts +122 -0
  26. package/src/boot-side-effect.test.ts +124 -0
  27. package/src/commhub-bin-args.test.ts +85 -0
  28. package/src/config-apply-sec1.test.ts +270 -1
  29. package/src/config-apply-validate.test.ts +92 -0
  30. package/src/config-apply-validate.ts +81 -3
  31. package/src/create-node-blocked-gate.test.ts +356 -0
  32. package/src/create-node-tool-schema.test.ts +39 -0
  33. package/src/create-node-validate.test.ts +275 -0
  34. package/src/create-node-validate.ts +288 -0
  35. package/src/create-node-workdir.test.ts +255 -0
  36. package/src/create-node.test.ts +354 -0
  37. package/src/create-node.ts +349 -0
  38. package/src/dashboard-slash-routing-http.test.ts +107 -0
  39. package/src/db-adapter-guard.test.ts +148 -0
  40. package/src/db-adapter-translate.test.ts +128 -0
  41. package/src/db-adapter.ts +323 -87
  42. package/src/db-timestamp.test.ts +38 -0
  43. package/src/db-timestamp.ts +15 -0
  44. package/src/db.ts +965 -34
  45. package/src/delivered-without-push.test.ts +140 -0
  46. package/src/department-groups-http.test.ts +209 -0
  47. package/src/department-groups-sync-http.test.ts +328 -0
  48. package/src/department-groups.ts +434 -0
  49. package/src/department-heads-http.test.ts +329 -0
  50. package/src/department-heads.ts +226 -0
  51. package/src/departments-http.test.ts +185 -0
  52. package/src/departments.ts +260 -0
  53. package/src/desktop-message-seam.test.ts +150 -0
  54. package/src/desktop-message.test.ts +368 -0
  55. package/src/dm-file-scope-http.test.ts +178 -0
  56. package/src/ext-token-shared.test.ts +60 -0
  57. package/src/ext-validation-regression.test.ts +131 -0
  58. package/src/external-schedule-edits-http.test.ts +321 -0
  59. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  60. package/src/external-schedule-edits.ts +327 -0
  61. package/src/file-download-authz-dev-open.test.ts +135 -0
  62. package/src/file-download-authz.test.ts +404 -0
  63. package/src/file-download-date-bucket.test.ts +436 -0
  64. package/src/file-network-scope-dev-open.test.ts +97 -0
  65. package/src/file-network-scope.test.ts +732 -0
  66. package/src/group-membership-events-http.test.ts +264 -0
  67. package/src/group-messages-http.test.ts +482 -0
  68. package/src/group-messages.ts +297 -0
  69. package/src/health-redaction.test.ts +642 -0
  70. package/src/http-gzip.test.ts +94 -0
  71. package/src/http-gzip.ts +112 -0
  72. package/src/hub-timestamp.test.ts +38 -0
  73. package/src/hub-timestamp.ts +27 -0
  74. package/src/human-dm.ts +215 -0
  75. package/src/inbox-count-sse.test.ts +310 -0
  76. package/src/inbox-count.ts +29 -0
  77. package/src/index.ts +15 -2172
  78. package/src/lifecycle-guard.ts +50 -0
  79. package/src/list-host-supervisors.test.ts +314 -0
  80. package/src/mcp-context-cost-http.test.ts +119 -0
  81. package/src/mcp-projects-events-http.test.ts +162 -0
  82. package/src/mcp-route-label-http.test.ts +120 -0
  83. package/src/mcp-write-network-resolution.test.ts +429 -0
  84. package/src/member-audit-network-id-http.test.ts +93 -0
  85. package/src/member-presence-http.test.ts +206 -0
  86. package/src/messages-ack-by-agent-http.test.ts +142 -0
  87. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  88. package/src/migrate-sqlite-to-pg.ts +263 -0
  89. package/src/model-auth-notify-http.test.ts +181 -0
  90. package/src/model-auth-notify.ts +116 -0
  91. package/src/network-name-http.test.ts +75 -0
  92. package/src/network-scope.ts +224 -0
  93. package/src/network-token-membership-http.test.ts +139 -0
  94. package/src/node-attrs-validate.ts +88 -0
  95. package/src/node-attrs.test.ts +217 -0
  96. package/src/node-create-request-status-http.test.ts +92 -0
  97. package/src/node-files-transport.test.ts +344 -0
  98. package/src/node-health-dispatch-http.test.ts +279 -0
  99. package/src/node-health-guard.ts +105 -0
  100. package/src/node-health-passthrough.test.ts +115 -0
  101. package/src/node-health-store.ts +72 -0
  102. package/src/node-id-alias.test.ts +188 -0
  103. package/src/node-identity-conflict-http.test.ts +269 -0
  104. package/src/node-identity-conflict.ts +142 -0
  105. package/src/node-identity-mismatch.test.ts +74 -0
  106. package/src/node-lifecycle-controllable.test.ts +64 -0
  107. package/src/node-lifecycle-controllable.ts +51 -0
  108. package/src/node-logs-transport.test.ts +263 -0
  109. package/src/node-permissions-http.test.ts +276 -0
  110. package/src/node-permissions.ts +242 -0
  111. package/src/node-request-retention.test.ts +281 -0
  112. package/src/node-request-retention.ts +120 -0
  113. package/src/node-skills-transport.test.ts +209 -0
  114. package/src/node-token-privilege-http.test.ts +149 -0
  115. package/src/observer-avatar-http.test.ts +431 -0
  116. package/src/observer-push.test.ts +252 -0
  117. package/src/package-version-consistency.test.ts +25 -0
  118. package/src/peer-reply-atomic.test.ts +455 -0
  119. package/src/peer-reply-capability.test.ts +70 -0
  120. package/src/pg-worker.ts +87 -0
  121. package/src/probe-validate.test.ts +221 -0
  122. package/src/probe-validate.ts +104 -0
  123. package/src/probe.test.ts +80 -0
  124. package/src/probe.ts +200 -0
  125. package/src/project-viewer-can-http.test.ts +114 -0
  126. package/src/push.ts +429 -10
  127. package/src/redact-tokens.test.ts +65 -0
  128. package/src/redact-tokens.ts +40 -0
  129. package/src/rename.ts +7 -2
  130. package/src/reply-target-derivation.test.ts +96 -0
  131. package/src/report-status-host-schema.test.ts +211 -0
  132. package/src/report-status-resume-id-handover.test.ts +150 -0
  133. package/src/require-explicit-test-db.ts +9 -0
  134. package/src/requirement-due-reminders-http.test.ts +390 -0
  135. package/src/requirement-due-reminders.ts +310 -0
  136. package/src/requirement-events-http.test.ts +257 -0
  137. package/src/requirement-events.test.ts +45 -0
  138. package/src/requirement-events.ts +144 -0
  139. package/src/requirement-notify.ts +165 -0
  140. package/src/requirement-participant-notify-http.test.ts +309 -0
  141. package/src/requirement-tag-ops-http.test.ts +266 -0
  142. package/src/requirement-tags.ts +89 -0
  143. package/src/requirements-comment-http.test.ts +165 -0
  144. package/src/requirements-completed-at-migration.test.ts +70 -0
  145. package/src/requirements-errors-mcp-http.test.ts +164 -0
  146. package/src/requirements-errors.ts +143 -0
  147. package/src/requirements-etag-http.test.ts +91 -0
  148. package/src/requirements-http.test.ts +709 -0
  149. package/src/requirements-last-event-http.test.ts +344 -0
  150. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  151. package/src/requirements-list-slim-http.test.ts +319 -0
  152. package/src/requirements-mcp.test.ts +163 -0
  153. package/src/requirements-migrate.ts +225 -0
  154. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  155. package/src/requirements-people-http.test.ts +114 -0
  156. package/src/requirements-people-mcp-http.test.ts +225 -0
  157. package/src/requirements-people.ts +127 -0
  158. package/src/requirements-priority-lowest.test.ts +114 -0
  159. package/src/requirements-search-http.test.ts +182 -0
  160. package/src/requirements-search.test.ts +32 -0
  161. package/src/requirements-search.ts +145 -0
  162. package/src/requirements-seq-http.test.ts +193 -0
  163. package/src/requirements-seq-migration.test.ts +63 -0
  164. package/src/requirements-start-date.test.ts +125 -0
  165. package/src/requirements-stats-http.test.ts +288 -0
  166. package/src/requirements-stats.ts +164 -0
  167. package/src/requirements-status-participants-http.test.ts +220 -0
  168. package/src/requirements-upgrade-e2e.test.ts +126 -0
  169. package/src/requirements-upgrade-migration.test.ts +98 -0
  170. package/src/requirements.ts +1761 -0
  171. package/src/resolve-port.test.ts +57 -0
  172. package/src/resolve-port.ts +44 -0
  173. package/src/response-charset.test.ts +130 -0
  174. package/src/rest-explicit-columns-http.test.ts +222 -0
  175. package/src/rest-identity.test.ts +129 -0
  176. package/src/rest-identity.ts +100 -0
  177. package/src/rest-projections.ts +68 -0
  178. package/src/rest-write-network-resolution.test.ts +133 -0
  179. package/src/restricted-files.ts +73 -0
  180. package/src/retention.ts +15 -3
  181. package/src/route-callers-http.test.ts +120 -0
  182. package/src/route-timing.test.ts +211 -0
  183. package/src/route-timing.ts +258 -0
  184. package/src/rules-file-duplicate-alias.test.ts +234 -0
  185. package/src/rules-file-session-target.test.ts +207 -0
  186. package/src/rules-file-transport.test.ts +237 -0
  187. package/src/runtime-label.test.ts +22 -0
  188. package/src/runtime-label.ts +21 -0
  189. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  190. package/src/scheduled-reply-unread-http.test.ts +212 -0
  191. package/src/scheduled-run-terminal.test.ts +268 -0
  192. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  193. package/src/scheduled-stuck-http.test.ts +238 -0
  194. package/src/scheduled-stuck.ts +153 -0
  195. package/src/scheduled-tasks-http.test.ts +638 -0
  196. package/src/scheduled-tasks.ts +641 -0
  197. package/src/send-dedup-request-id.test.ts +229 -0
  198. package/src/send-reply-agent-warning.test.ts +202 -0
  199. package/src/send-reply-alias-optional.test.ts +131 -0
  200. package/src/send-reply-attachments.test.ts +458 -0
  201. package/src/send_dedup.test.ts +54 -0
  202. package/src/send_dedup.ts +52 -6
  203. package/src/serve-error.test.ts +38 -0
  204. package/src/serve-error.ts +27 -0
  205. package/src/server.ts +5038 -0
  206. package/src/shared/external-schedule-contract.ts +60 -0
  207. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  208. package/src/shared/probe-host-allowlist.ts +128 -0
  209. package/src/shared/reserved-env-drift.test.ts +43 -0
  210. package/src/shared/reserved-env.test.ts +35 -0
  211. package/src/shared/reserved-env.ts +31 -0
  212. package/src/side-thread-command-transport.test.ts +312 -0
  213. package/src/side-thread-command-transport.ts +318 -0
  214. package/src/side-thread-http-integration.test.ts +233 -0
  215. package/src/side-thread-http.ts +504 -0
  216. package/src/side-thread-production.test.ts +72 -0
  217. package/src/side-thread-production.ts +118 -0
  218. package/src/side-thread.test.ts +1067 -0
  219. package/src/side-thread.ts +2499 -0
  220. package/src/skillhub-http.test.ts +108 -0
  221. package/src/sqlite-close-checkpoint.test.ts +78 -0
  222. package/src/start-node.test.ts +109 -0
  223. package/src/status-alias-filter-http.test.ts +106 -0
  224. package/src/status-alias-resolver-http.test.ts +163 -0
  225. package/src/status-alias-resolver.ts +19 -0
  226. package/src/status-node-id-filter-http.test.ts +161 -0
  227. package/src/status-read-cache-http.test.ts +229 -0
  228. package/src/status-read-cache.ts +122 -0
  229. package/src/stop-delete-node.test.ts +1164 -0
  230. package/src/task-access-http.test.ts +379 -0
  231. package/src/task-access.ts +413 -0
  232. package/src/task-auth-origin-http.test.ts +91 -0
  233. package/src/task-auth-origin.test.ts +24 -0
  234. package/src/task-auth-origin.ts +7 -0
  235. package/src/task-consumption.test.ts +482 -0
  236. package/src/task-diagnostic-http.test.ts +149 -0
  237. package/src/task-diagnostic.test.ts +115 -0
  238. package/src/task-diagnostic.ts +118 -0
  239. package/src/task-expiry-notice-http.test.ts +287 -0
  240. package/src/task-expiry-notice.ts +313 -0
  241. package/src/task-idempotency-mcp.test.ts +142 -0
  242. package/src/task-idempotency.test.ts +60 -0
  243. package/src/task-idempotency.ts +75 -0
  244. package/src/task-lifecycle-watcher.test.ts +204 -0
  245. package/src/task-lifecycle-watcher.ts +49 -0
  246. package/src/task-network-resolution-http.test.ts +141 -0
  247. package/src/task-queue-ahead-http.test.ts +309 -0
  248. package/src/task-queue-ahead-scale.test.ts +211 -0
  249. package/src/task-queue-ahead.ts +186 -0
  250. package/src/task-stale-open-http.test.ts +290 -0
  251. package/src/task-stale-open.ts +103 -0
  252. package/src/tasks-pagination-http.test.ts +141 -0
  253. package/src/tool-audience-http.test.ts +195 -0
  254. package/src/tool-audience.ts +109 -0
  255. package/src/tools-list-json-schema.test.ts +67 -0
  256. package/src/tools.ts +4955 -300
  257. package/src/update-provider.test.ts +234 -0
  258. package/src/upload-early-reject-guard.test.ts +155 -0
  259. package/src/uploads-http.test.ts +97 -15
  260. package/src/uploads.test.ts +66 -0
  261. package/src/uploads.ts +138 -5
  262. package/src/user-inbox-by-agent-http.test.ts +161 -0
  263. package/src/user-inbox-netid-notnull.test.ts +122 -0
  264. package/src/user-inbox-read-http.test.ts +223 -0
  265. package/src/user-inbox-schema.test.ts +54 -0
  266. package/src/vault.test.ts +200 -0
  267. package/src/vault.ts +156 -0
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { describe, expect, test, beforeEach } from "bun:test";
18
18
  import { db, uuidv4 } from "./db.js";
19
- import { upsertNodeWithSec1Guard } from "./tools.js";
19
+ import { upsertNodeWithSec1Guard, finalizePendingMatchingUpdates } from "./tools.js";
20
20
 
21
21
  function insertNode(opts: { node_id?: string; alias: string; network_id: string | null }): string {
22
22
  const id = opts.node_id ?? uuidv4();
@@ -445,6 +445,168 @@ describe("SEC trust-root — REAL driver via upsertNodeWithSec1Guard (catches gu
445
445
  });
446
446
  });
447
447
 
448
+ // ─────────────────────────────────────────────────────────────────────
449
+ // finalizePendingMatchingUpdates — Option A restart-finalize matcher
450
+ // (通信牛 #290 final CHANGE_REQ 2026-06-28). Real-driver tests: drive
451
+ // the helper that production report_status delegates to + assert on
452
+ // the persisted state. Same single-source-of-truth pattern as
453
+ // upsertNodeWithSec1Guard (prevents inline-mirror test drift).
454
+ // ─────────────────────────────────────────────────────────────────────
455
+ describe("finalizePendingMatchingUpdates — Option A restart-finalize via content match", () => {
456
+ function insertPendingUpdate(opts: {
457
+ update_id?: string;
458
+ node_id: string;
459
+ network_id: string;
460
+ patch: any;
461
+ apply_mode: "hot" | "restart" | "restart_only";
462
+ status?: "pending" | "restarting";
463
+ }): string {
464
+ const id = opts.update_id ?? `cu_${uuidv4()}`;
465
+ db.run(
466
+ `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, 'test')`,
467
+ [id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()],
468
+ );
469
+ return id;
470
+ }
471
+
472
+ test("empty patch (restart_only) → finalizes on ANY snapshot (restart itself = goal)", () => {
473
+ const nid = insertNode({ alias: "n1", network_id: "netA" });
474
+ const uid = insertPendingUpdate({
475
+ node_id: nid, network_id: "netA", patch: {}, apply_mode: "restart_only", status: "restarting",
476
+ });
477
+ const r = finalizePendingMatchingUpdates(nid, { model: "any", flags: {} });
478
+ expect(r.finalizedCount).toBe(1);
479
+ expect(r.finalizedIds).toContain(uid);
480
+ const row = db.get<any>("SELECT status, new_revision FROM node_config_updates WHERE update_id = ?1", uid);
481
+ expect(row.status).toBe("applied");
482
+ expect(row.new_revision).toBe(1);
483
+ const node = db.get<any>("SELECT config_revision FROM nodes WHERE node_id = ?1", nid);
484
+ expect(node.config_revision).toBe(1);
485
+ });
486
+
487
+ test("model patch + snapshot.model matches → finalized", () => {
488
+ const nid = insertNode({ alias: "n2", network_id: "netA" });
489
+ const uid = insertPendingUpdate({
490
+ node_id: nid, network_id: "netA",
491
+ patch: { model: "claude-opus-4-new", flags: {} },
492
+ apply_mode: "restart",
493
+ });
494
+ const r = finalizePendingMatchingUpdates(nid, {
495
+ model: "claude-opus-4-new", flags: { maxTurns: 50 },
496
+ });
497
+ expect(r.finalizedCount).toBe(1);
498
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
499
+ });
500
+
501
+ test("model patch + snapshot.model DIFFERENT → NOT finalized (left pending)", () => {
502
+ const nid = insertNode({ alias: "n3", network_id: "netA" });
503
+ const uid = insertPendingUpdate({
504
+ node_id: nid, network_id: "netA",
505
+ patch: { model: "claude-opus-4-new", flags: {} },
506
+ apply_mode: "restart",
507
+ });
508
+ const r = finalizePendingMatchingUpdates(nid, {
509
+ model: "claude-sonnet-3-old", flags: {},
510
+ });
511
+ expect(r.finalizedCount).toBe(0);
512
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
513
+ });
514
+
515
+ test("flags patch + every flag matches snapshot → finalized", () => {
516
+ const nid = insertNode({ alias: "n4", network_id: "netA" });
517
+ const uid = insertPendingUpdate({
518
+ node_id: nid, network_id: "netA",
519
+ patch: { flags: { maxTurns: 99, budget: 500 } },
520
+ apply_mode: "hot",
521
+ });
522
+ const r = finalizePendingMatchingUpdates(nid, {
523
+ flags: { maxTurns: 99, budget: 500, dangerouslySkipPermissions: true },
524
+ });
525
+ expect(r.finalizedCount).toBe(1);
526
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
527
+ });
528
+
529
+ test("flags patch + ONE flag mismatch → NOT finalized (any field absent = no match)", () => {
530
+ const nid = insertNode({ alias: "n5", network_id: "netA" });
531
+ const uid = insertPendingUpdate({
532
+ node_id: nid, network_id: "netA",
533
+ patch: { flags: { maxTurns: 99, budget: 500 } },
534
+ apply_mode: "hot",
535
+ });
536
+ const r = finalizePendingMatchingUpdates(nid, {
537
+ flags: { maxTurns: 99, budget: 999 }, // budget wrong
538
+ });
539
+ expect(r.finalizedCount).toBe(0);
540
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
541
+ });
542
+
543
+ test("flags patch + flag absent from snapshot → NOT finalized", () => {
544
+ const nid = insertNode({ alias: "n6", network_id: "netA" });
545
+ const uid = insertPendingUpdate({
546
+ node_id: nid, network_id: "netA",
547
+ patch: { flags: { permissionMode: "auto" } },
548
+ apply_mode: "restart",
549
+ });
550
+ const r = finalizePendingMatchingUpdates(nid, {
551
+ flags: { maxTurns: 50 }, // permissionMode missing entirely
552
+ });
553
+ expect(r.finalizedCount).toBe(0);
554
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
555
+ });
556
+
557
+ test("no pending update → no-op (idempotent on routine heartbeats)", () => {
558
+ const nid = insertNode({ alias: "n7", network_id: "netA" });
559
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: { maxTurns: 50 } });
560
+ expect(r.finalizedCount).toBe(0);
561
+ });
562
+
563
+ test("restarting status finalizes too (not just pending)", () => {
564
+ const nid = insertNode({ alias: "n8", network_id: "netA" });
565
+ const uid = insertPendingUpdate({
566
+ node_id: nid, network_id: "netA",
567
+ patch: { model: "new" },
568
+ apply_mode: "restart",
569
+ status: "restarting",
570
+ });
571
+ const r = finalizePendingMatchingUpdates(nid, { model: "new", flags: {} });
572
+ expect(r.finalizedCount).toBe(1);
573
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
574
+ });
575
+
576
+ test("two nodes with separate pending updates → finalize only matching one", () => {
577
+ const nidA = insertNode({ alias: "match-this", network_id: "netA" });
578
+ const nidB = insertNode({ alias: "leave-alone", network_id: "netA" });
579
+ const uidA = insertPendingUpdate({
580
+ node_id: nidA, network_id: "netA",
581
+ patch: { model: "matched-model" },
582
+ apply_mode: "restart",
583
+ });
584
+ const uidB = insertPendingUpdate({
585
+ node_id: nidB, network_id: "netA",
586
+ patch: { model: "different-model" },
587
+ apply_mode: "restart",
588
+ });
589
+ finalizePendingMatchingUpdates(nidA, { model: "matched-model", flags: {} });
590
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uidA).status).toBe("applied");
591
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uidB).status).toBe("pending");
592
+ });
593
+
594
+ test("config_revision bumps to base+1 for single finalize (multi-pending blocked by F-C index)", () => {
595
+ // F-C partial unique index (db.ts) prevents two non-terminal updates
596
+ // for the same node, so the multi-finalize scenario is unreachable
597
+ // at the DB layer. This test pins that ONE pending finalize → revision = base + 1.
598
+ const nid = insertNode({ alias: "n10", network_id: "netA" });
599
+ insertPendingUpdate({
600
+ node_id: nid, network_id: "netA",
601
+ patch: { model: "v1" },
602
+ apply_mode: "restart",
603
+ });
604
+ const r = finalizePendingMatchingUpdates(nid, { model: "v1", flags: {} });
605
+ expect(r.finalizedCount).toBe(1);
606
+ expect(db.get<any>("SELECT config_revision FROM nodes WHERE node_id = ?1", nid).config_revision).toBe(1);
607
+ });
608
+ });
609
+
448
610
  describe("F-B polish — reaper anchors on COALESCE(acked_at, created_at), not created_at", () => {
449
611
  // 通信龙 catch: reaper threshold = 60s and drain hard-cap = 60s overlap.
450
612
  // A healthy-but-slow restart (drain 60s + respawn time) could exceed
@@ -559,3 +721,110 @@ describe("SEC-1 — update lifecycle: single-flight + cross-network pending look
559
721
  expect(inFlight).toBeDefined();
560
722
  });
561
723
  });
724
+
725
+ // ── #260 P5 codex-catch regression: channels finalize ─────────────
726
+ describe("finalizePendingMatchingUpdates — #260 P5 channels content-match", () => {
727
+ function insertPendingUpdate(opts: {
728
+ update_id?: string;
729
+ node_id: string;
730
+ network_id: string;
731
+ patch: any;
732
+ apply_mode: "hot" | "restart" | "restart_only";
733
+ status?: "pending" | "restarting";
734
+ }): string {
735
+ const id = opts.update_id ?? `cu_${uuidv4()}`;
736
+ db.run(
737
+ `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, 'test')`,
738
+ [id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()],
739
+ );
740
+ return id;
741
+ }
742
+
743
+ test("channels-only patch: snapshot BEFORE apply (first boot) must NOT finalize", () => {
744
+ const nid = insertNode({ alias: "n-chan-1", network_id: "netA" });
745
+ const uid = insertPendingUpdate({
746
+ node_id: nid, network_id: "netA",
747
+ // Note the empty flags object — the pre-fix bug was that this
748
+ // trivially matched the flags loop and hub falsely finalized.
749
+ patch: { flags: {}, channels: ["feishu"] },
750
+ apply_mode: "restart",
751
+ });
752
+ // Node's startup snapshot BEFORE it consumed the update — no
753
+ // channels forked yet, so buildConfigSnapshot emits channels=[].
754
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
755
+ expect(r.finalizedCount).toBe(0);
756
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
757
+ });
758
+
759
+ test("channels-only patch: snapshot AFTER apply (restart complete) finalizes", () => {
760
+ const nid = insertNode({ alias: "n-chan-2", network_id: "netA" });
761
+ const uid = insertPendingUpdate({
762
+ node_id: nid, network_id: "netA",
763
+ patch: { flags: {}, channels: ["feishu"] },
764
+ apply_mode: "restart",
765
+ });
766
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu"] });
767
+ expect(r.finalizedCount).toBe(1);
768
+ expect(r.finalizedIds).toContain(uid);
769
+ expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
770
+ });
771
+
772
+ test("channels set-equality: order doesn't matter", () => {
773
+ const nid = insertNode({ alias: "n-chan-3", network_id: "netA" });
774
+ insertPendingUpdate({
775
+ node_id: nid, network_id: "netA",
776
+ patch: { flags: {}, channels: ["telegram", "feishu"] },
777
+ apply_mode: "restart",
778
+ });
779
+ // Snapshot order swapped — still matches.
780
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu", "telegram"] });
781
+ expect(r.finalizedCount).toBe(1);
782
+ });
783
+
784
+ test("channels: [] disable-all — finalize when snapshot also [] (post-restart)", () => {
785
+ const nid = insertNode({ alias: "n-chan-4", network_id: "netA" });
786
+ insertPendingUpdate({
787
+ node_id: nid, network_id: "netA",
788
+ patch: { flags: {}, channels: [] },
789
+ apply_mode: "restart",
790
+ });
791
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
792
+ expect(r.finalizedCount).toBe(1);
793
+ });
794
+
795
+ test("channels: [] disable-all — do NOT finalize when snapshot still has channels", () => {
796
+ const nid = insertNode({ alias: "n-chan-5", network_id: "netA" });
797
+ insertPendingUpdate({
798
+ node_id: nid, network_id: "netA",
799
+ patch: { flags: {}, channels: [] },
800
+ apply_mode: "restart",
801
+ });
802
+ // Node hasn't restarted yet — old telegram worker still forked.
803
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["telegram"] });
804
+ expect(r.finalizedCount).toBe(0);
805
+ });
806
+
807
+ test("snapshot omits channels field entirely (pre-P5 agent-node) → NOT finalized on channels-carrying patch", () => {
808
+ const nid = insertNode({ alias: "n-chan-6", network_id: "netA" });
809
+ insertPendingUpdate({
810
+ node_id: nid, network_id: "netA",
811
+ patch: { flags: {}, channels: ["feishu"] },
812
+ apply_mode: "restart",
813
+ });
814
+ const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {} }); // no channels key
815
+ expect(r.finalizedCount).toBe(0);
816
+ });
817
+
818
+ test("model + channels compound match: both must satisfy", () => {
819
+ const nid = insertNode({ alias: "n-chan-7", network_id: "netA" });
820
+ insertPendingUpdate({
821
+ node_id: nid, network_id: "netA",
822
+ patch: { model: "opus-new", flags: {}, channels: ["telegram"] },
823
+ apply_mode: "restart",
824
+ });
825
+ // Wrong channels → not finalized even though model matches.
826
+ expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: [] }).finalizedCount).toBe(0);
827
+ // Both match → finalized.
828
+ expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: ["telegram"] }).finalizedCount).toBe(1);
829
+ });
830
+ });
@@ -9,9 +9,11 @@ import { describe, expect, test } from "bun:test";
9
9
  import {
10
10
  ALLOWED_FLAGS,
11
11
  SECURITY_SENSITIVE_FLAGS,
12
+ EDITABLE_CHANNELS,
12
13
  isAllowedToChangeFlag,
13
14
  computeApplyMode,
14
15
  validatePatch,
16
+ narrowChannelsPatch,
15
17
  } from "./config-apply-validate";
16
18
 
17
19
  describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
@@ -258,3 +260,93 @@ describe("validatePatch — combinations", () => {
258
260
  expect(r?.field).toBe("flags.maxTurns");
259
261
  });
260
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
+ });
@@ -57,6 +57,35 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
57
57
  "timeout",
58
58
  ]);
59
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
+
60
89
  /**
61
90
  * Role-gate for the patch's flag set. SEC-2 enforcement lives here.
62
91
  *
@@ -99,22 +128,53 @@ export function isAllowedToChangeFlag(
99
128
 
100
129
  /**
101
130
  * Classify the patch's required apply mode. Empty patch (model + flags
102
- * both empty) → "restart_only" (used by restart_node tool). Any
103
- * restart-required field present → "restart". Otherwise → "hot".
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".
104
137
  */
105
138
  export function computeApplyMode(
106
139
  model: string | undefined,
107
140
  flags: Record<string, unknown>,
141
+ channels?: string[] | undefined,
108
142
  ): "hot" | "restart" | "restart_only" {
109
- const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
143
+ const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
110
144
  if (fieldCount === 0) return "restart_only";
111
145
  if (model !== undefined) return "restart";
146
+ if (channels !== undefined) return "restart";
112
147
  for (const key of Object.keys(flags)) {
113
148
  if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
114
149
  }
115
150
  return "hot";
116
151
  }
117
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
+
118
178
  /**
119
179
  * Validate the patch shape — allowlist + per-field range / enum. Returns
120
180
  * null on pass, or `{ field, reason }` on reject. Caller wraps in error
@@ -123,12 +183,30 @@ export function computeApplyMode(
123
183
  export function validatePatch(
124
184
  model: string | undefined,
125
185
  flags: Record<string, unknown>,
186
+ channels?: string[] | undefined,
126
187
  ): { field: string; reason: string } | null {
127
188
  if (model !== undefined) {
128
189
  if (typeof model !== "string" || model.length === 0 || model.length > 200) {
129
190
  return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
130
191
  }
131
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
+ }
132
210
  for (const [key, val] of Object.entries(flags)) {
133
211
  if (!ALLOWED_FLAGS.has(key)) {
134
212
  return { field: `flags.${key}`, reason: "not in allowlist" };