@sleep2agi/commhub-server 0.9.0-preview.12 → 0.9.0-preview.120

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 (294) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -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/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 +658 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +223 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/agent-team-whoami-http.test.ts +98 -0
  16. package/src/agent-teams-http.test.ts +323 -0
  17. package/src/agent-teams.ts +244 -0
  18. package/src/alias-filter.test.ts +72 -0
  19. package/src/alias-filter.ts +42 -0
  20. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  21. package/src/api-host-supervisors-fallback.test.ts +271 -0
  22. package/src/api-host-supervisors-runtime-readiness.test.ts +182 -0
  23. package/src/api-nodes-shape.test.ts +26 -10
  24. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  25. package/src/auth-sessions-http.test.ts +250 -0
  26. package/src/auth-validate.test.ts +5 -1
  27. package/src/auth-weak-password-login.test.ts +176 -0
  28. package/src/auth.ts +594 -46
  29. package/src/avatar-validate.test.ts +163 -0
  30. package/src/avatar-validate.ts +103 -0
  31. package/src/binding-generation-http.test.ts +121 -0
  32. package/src/blocked-exit-on-reply.test.ts +122 -0
  33. package/src/boot-side-effect.test.ts +124 -0
  34. package/src/codex-fork-contract.ts +23 -0
  35. package/src/codex-fork-migration.test.ts +38 -0
  36. package/src/commhub-bin-args.test.ts +85 -0
  37. package/src/config-apply-sec1.test.ts +107 -0
  38. package/src/config-apply-validate.test.ts +101 -0
  39. package/src/config-apply-validate.ts +87 -5
  40. package/src/create-node-blocked-gate.test.ts +356 -0
  41. package/src/create-node-tool-schema.test.ts +48 -0
  42. package/src/create-node-unicode-name.test.ts +120 -0
  43. package/src/create-node-validate.test.ts +168 -7
  44. package/src/create-node-validate.ts +173 -15
  45. package/src/create-node-workdir.test.ts +276 -0
  46. package/src/create-node.ts +77 -23
  47. package/src/daemon-token-owner-http.test.ts +231 -0
  48. package/src/dashboard-slash-routing-http.test.ts +107 -0
  49. package/src/db-adapter-guard.test.ts +148 -0
  50. package/src/db-adapter-translate.test.ts +128 -0
  51. package/src/db-adapter.ts +323 -87
  52. package/src/db-timestamp.test.ts +38 -0
  53. package/src/db-timestamp.ts +15 -0
  54. package/src/db.ts +872 -35
  55. package/src/delivered-without-push.test.ts +140 -0
  56. package/src/department-groups-http.test.ts +209 -0
  57. package/src/department-groups-sync-http.test.ts +328 -0
  58. package/src/department-groups.ts +434 -0
  59. package/src/department-heads-http.test.ts +330 -0
  60. package/src/department-heads.ts +226 -0
  61. package/src/departments-http.test.ts +185 -0
  62. package/src/departments.ts +260 -0
  63. package/src/desktop-message-seam.test.ts +150 -0
  64. package/src/desktop-message.test.ts +368 -0
  65. package/src/dm-file-scope-http.test.ts +178 -0
  66. package/src/ext-token-shared.test.ts +60 -0
  67. package/src/ext-validation-regression.test.ts +131 -0
  68. package/src/external-schedule-edits-http.test.ts +321 -0
  69. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  70. package/src/external-schedule-edits.ts +327 -0
  71. package/src/file-download-authz-dev-open.test.ts +135 -0
  72. package/src/file-download-authz.test.ts +404 -0
  73. package/src/file-download-date-bucket.test.ts +436 -0
  74. package/src/file-network-scope-dev-open.test.ts +97 -0
  75. package/src/file-network-scope.test.ts +732 -0
  76. package/src/group-membership-events-http.test.ts +264 -0
  77. package/src/group-messages-http.test.ts +482 -0
  78. package/src/group-messages.ts +297 -0
  79. package/src/health-redaction.test.ts +642 -0
  80. package/src/http-gzip.test.ts +94 -0
  81. package/src/http-gzip.ts +112 -0
  82. package/src/hub-timestamp.test.ts +38 -0
  83. package/src/hub-timestamp.ts +27 -0
  84. package/src/human-dm.ts +215 -0
  85. package/src/inbox-count-sse.test.ts +310 -0
  86. package/src/inbox-count.ts +29 -0
  87. package/src/index.ts +15 -2356
  88. package/src/lifecycle-guard.ts +1 -1
  89. package/src/mcp-context-cost-http.test.ts +120 -0
  90. package/src/mcp-projects-events-http.test.ts +162 -0
  91. package/src/mcp-route-label-http.test.ts +120 -0
  92. package/src/mcp-write-network-resolution.test.ts +429 -0
  93. package/src/member-audit-network-id-http.test.ts +93 -0
  94. package/src/member-presence-http.test.ts +206 -0
  95. package/src/messages-ack-by-agent-http.test.ts +142 -0
  96. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  97. package/src/migrate-sqlite-to-pg.ts +263 -0
  98. package/src/model-auth-notify-http.test.ts +181 -0
  99. package/src/model-auth-notify.ts +116 -0
  100. package/src/network-name-http.test.ts +75 -0
  101. package/src/network-scope.ts +276 -0
  102. package/src/network-token-membership-http.test.ts +139 -0
  103. package/src/node-alias-control.ts +12 -0
  104. package/src/node-attrs-validate.ts +88 -0
  105. package/src/node-attrs.test.ts +217 -0
  106. package/src/node-caller-identity-http.test.ts +714 -0
  107. package/src/node-create-request-status-http.test.ts +105 -0
  108. package/src/node-daemon-bindings.test.ts +132 -0
  109. package/src/node-daemon-bindings.ts +130 -0
  110. package/src/node-files-transport.test.ts +344 -0
  111. package/src/node-health-codex-login.test.ts +120 -0
  112. package/src/node-health-dispatch-http.test.ts +279 -0
  113. package/src/node-health-guard.ts +105 -0
  114. package/src/node-health-passthrough.test.ts +115 -0
  115. package/src/node-health-store.ts +101 -0
  116. package/src/node-id-alias.test.ts +190 -0
  117. package/src/node-identity-conflict-http.test.ts +269 -0
  118. package/src/node-identity-conflict.ts +142 -0
  119. package/src/node-identity-mismatch.test.ts +74 -0
  120. package/src/node-lifecycle-controllable.test.ts +64 -0
  121. package/src/node-lifecycle-controllable.ts +53 -0
  122. package/src/node-lifecycle-read-http.test.ts +233 -0
  123. package/src/node-lifecycle-read.ts +115 -0
  124. package/src/node-logs-transport.test.ts +263 -0
  125. package/src/node-permissions-http.test.ts +276 -0
  126. package/src/node-permissions.ts +281 -0
  127. package/src/node-request-retention.test.ts +281 -0
  128. package/src/node-request-retention.ts +120 -0
  129. package/src/node-skills-transport.test.ts +209 -0
  130. package/src/node-token-ownership.ts +36 -0
  131. package/src/node-token-privilege-http.test.ts +158 -0
  132. package/src/observer-avatar-http.test.ts +431 -0
  133. package/src/observer-push.test.ts +252 -0
  134. package/src/package-version-consistency.test.ts +25 -0
  135. package/src/peer-reply-atomic.test.ts +455 -0
  136. package/src/peer-reply-capability.test.ts +70 -0
  137. package/src/pg-worker.ts +87 -0
  138. package/src/project-dir-warn-clear-http.test.ts +132 -0
  139. package/src/project-viewer-can-http.test.ts +114 -0
  140. package/src/push.ts +429 -10
  141. package/src/redact-tokens.test.ts +65 -0
  142. package/src/redact-tokens.ts +40 -0
  143. package/src/rename.ts +22 -2
  144. package/src/reply-target-derivation.test.ts +96 -0
  145. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  146. package/src/report-status-host-schema.test.ts +211 -0
  147. package/src/report-status-resume-id-handover.test.ts +150 -0
  148. package/src/require-explicit-test-db.ts +9 -0
  149. package/src/requirement-due-reminders-http.test.ts +620 -0
  150. package/src/requirement-due-reminders.ts +416 -0
  151. package/src/requirement-events-http.test.ts +268 -0
  152. package/src/requirement-events.test.ts +45 -0
  153. package/src/requirement-events.ts +144 -0
  154. package/src/requirement-notify.ts +165 -0
  155. package/src/requirement-participant-notify-http.test.ts +309 -0
  156. package/src/requirement-tag-ops-http.test.ts +266 -0
  157. package/src/requirement-tags.ts +89 -0
  158. package/src/requirements-abandoned-http.test.ts +236 -0
  159. package/src/requirements-comment-http.test.ts +167 -0
  160. package/src/requirements-completed-at-migration.test.ts +70 -0
  161. package/src/requirements-errors-mcp-http.test.ts +164 -0
  162. package/src/requirements-errors.ts +143 -0
  163. package/src/requirements-etag-http.test.ts +91 -0
  164. package/src/requirements-http.test.ts +709 -0
  165. package/src/requirements-last-event-http.test.ts +349 -0
  166. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  167. package/src/requirements-list-slim-http.test.ts +319 -0
  168. package/src/requirements-mcp.test.ts +163 -0
  169. package/src/requirements-migrate.ts +259 -0
  170. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  171. package/src/requirements-people-http.test.ts +114 -0
  172. package/src/requirements-people-mcp-http.test.ts +225 -0
  173. package/src/requirements-people.ts +127 -0
  174. package/src/requirements-priority-lowest.test.ts +114 -0
  175. package/src/requirements-search-http.test.ts +182 -0
  176. package/src/requirements-search.test.ts +32 -0
  177. package/src/requirements-search.ts +145 -0
  178. package/src/requirements-seq-http.test.ts +193 -0
  179. package/src/requirements-seq-migration.test.ts +63 -0
  180. package/src/requirements-start-date.test.ts +125 -0
  181. package/src/requirements-stats-http.test.ts +288 -0
  182. package/src/requirements-stats.ts +167 -0
  183. package/src/requirements-status-participants-http.test.ts +222 -0
  184. package/src/requirements-upgrade-e2e.test.ts +126 -0
  185. package/src/requirements-upgrade-migration.test.ts +98 -0
  186. package/src/requirements.ts +1806 -0
  187. package/src/resolve-port.test.ts +57 -0
  188. package/src/resolve-port.ts +44 -0
  189. package/src/response-charset.test.ts +130 -0
  190. package/src/rest-explicit-columns-http.test.ts +222 -0
  191. package/src/rest-identity.test.ts +129 -0
  192. package/src/rest-identity.ts +125 -0
  193. package/src/rest-projections.ts +68 -0
  194. package/src/rest-write-network-resolution.test.ts +133 -0
  195. package/src/restricted-files.ts +76 -0
  196. package/src/retention.ts +15 -3
  197. package/src/route-callers-http.test.ts +120 -0
  198. package/src/route-timing.test.ts +211 -0
  199. package/src/route-timing.ts +258 -0
  200. package/src/rules-file-duplicate-alias.test.ts +234 -0
  201. package/src/rules-file-session-target.test.ts +207 -0
  202. package/src/rules-file-transport.test.ts +237 -0
  203. package/src/runtime-label.test.ts +22 -0
  204. package/src/runtime-label.ts +21 -0
  205. package/src/runtime-readiness.ts +83 -0
  206. package/src/schedule-agent-mcp-http.test.ts +381 -0
  207. package/src/schedule-agent.ts +192 -0
  208. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  209. package/src/scheduled-failures-http.test.ts +233 -0
  210. package/src/scheduled-failures.ts +179 -0
  211. package/src/scheduled-offline.test.ts +131 -0
  212. package/src/scheduled-offline.ts +66 -0
  213. package/src/scheduled-reply-unread-http.test.ts +212 -0
  214. package/src/scheduled-run-terminal.test.ts +268 -0
  215. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  216. package/src/scheduled-stuck-http.test.ts +238 -0
  217. package/src/scheduled-stuck.ts +153 -0
  218. package/src/scheduled-tasks-http.test.ts +638 -0
  219. package/src/scheduled-tasks.ts +766 -0
  220. package/src/send-dedup-request-id.test.ts +229 -0
  221. package/src/send-reply-agent-warning.test.ts +202 -0
  222. package/src/send-reply-alias-optional.test.ts +131 -0
  223. package/src/send-reply-attachments.test.ts +458 -0
  224. package/src/send_dedup.test.ts +54 -0
  225. package/src/send_dedup.ts +52 -6
  226. package/src/serve-error.test.ts +38 -0
  227. package/src/serve-error.ts +27 -0
  228. package/src/server.ts +5217 -0
  229. package/src/session-task-on-dispatch.ts +14 -0
  230. package/src/shared/external-schedule-contract.ts +60 -0
  231. package/src/shared/node-name.test.ts +62 -0
  232. package/src/shared/node-name.ts +161 -0
  233. package/src/shared/reserved-env-drift.test.ts +10 -0
  234. package/src/shared/task-content-limit-drift.test.ts +28 -0
  235. package/src/shared/task-content-limit.ts +25 -0
  236. package/src/side-thread-command-transport.test.ts +312 -0
  237. package/src/side-thread-command-transport.ts +318 -0
  238. package/src/side-thread-http-integration.test.ts +233 -0
  239. package/src/side-thread-http.ts +504 -0
  240. package/src/side-thread-production.test.ts +72 -0
  241. package/src/side-thread-production.ts +118 -0
  242. package/src/side-thread.test.ts +1067 -0
  243. package/src/side-thread.ts +2499 -0
  244. package/src/skillhub-http.test.ts +108 -0
  245. package/src/sqlite-close-checkpoint.test.ts +78 -0
  246. package/src/start-node.test.ts +264 -0
  247. package/src/status-alias-filter-http.test.ts +106 -0
  248. package/src/status-alias-resolver-http.test.ts +163 -0
  249. package/src/status-alias-resolver.ts +19 -0
  250. package/src/status-node-id-filter-http.test.ts +161 -0
  251. package/src/status-read-cache-http.test.ts +229 -0
  252. package/src/status-read-cache.ts +122 -0
  253. package/src/stop-delete-node.test.ts +469 -1
  254. package/src/task-access-http.test.ts +402 -0
  255. package/src/task-access.ts +420 -0
  256. package/src/task-auth-origin-http.test.ts +91 -0
  257. package/src/task-auth-origin.test.ts +24 -0
  258. package/src/task-auth-origin.ts +7 -0
  259. package/src/task-consumption.test.ts +482 -0
  260. package/src/task-diagnostic-http.test.ts +149 -0
  261. package/src/task-diagnostic.test.ts +115 -0
  262. package/src/task-diagnostic.ts +118 -0
  263. package/src/task-expiry-notice-http.test.ts +287 -0
  264. package/src/task-expiry-notice.ts +315 -0
  265. package/src/task-idempotency-mcp.test.ts +142 -0
  266. package/src/task-idempotency.test.ts +60 -0
  267. package/src/task-idempotency.ts +75 -0
  268. package/src/task-late-replies.ts +54 -0
  269. package/src/task-late-reply-http.test.ts +152 -0
  270. package/src/task-lifecycle-watcher.test.ts +204 -0
  271. package/src/task-lifecycle-watcher.ts +49 -0
  272. package/src/task-network-resolution-http.test.ts +141 -0
  273. package/src/task-orphan-http.test.ts +287 -0
  274. package/src/task-orphan.ts +119 -0
  275. package/src/task-queue-ahead-http.test.ts +309 -0
  276. package/src/task-queue-ahead-scale.test.ts +236 -0
  277. package/src/task-queue-ahead.ts +186 -0
  278. package/src/task-rejection-log-http.test.ts +160 -0
  279. package/src/task-rejection-log.ts +49 -0
  280. package/src/task-stale-open-http.test.ts +290 -0
  281. package/src/task-stale-open.ts +103 -0
  282. package/src/tasks-pagination-http.test.ts +141 -0
  283. package/src/tool-audience-http.test.ts +210 -0
  284. package/src/tool-audience.ts +114 -0
  285. package/src/tools-list-json-schema.test.ts +67 -0
  286. package/src/tools.ts +3556 -304
  287. package/src/upload-early-reject-guard.test.ts +155 -0
  288. package/src/uploads-http.test.ts +97 -15
  289. package/src/uploads.test.ts +66 -0
  290. package/src/uploads.ts +138 -5
  291. package/src/user-inbox-by-agent-http.test.ts +161 -0
  292. package/src/user-inbox-netid-notnull.test.ts +122 -0
  293. package/src/user-inbox-read-http.test.ts +223 -0
  294. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,23 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const forkRecoveryRequestSchema = z.object({
4
+ kind: z.literal("fork_on_missing_ordinal"), confirmed: z.literal(true),
5
+ }).strict();
6
+ const threadId = z.string().length(36).regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
7
+ export const forkRecoveryResultSchema = z.discriminatedUnion("state", [
8
+ z.object({ state: z.literal("forked"), old_thread_id: threadId, new_thread_id: threadId }).strict(),
9
+ z.object({ state: z.literal("not_observed") }).strict(),
10
+ z.object({ state: z.literal("unknown"), reason: z.enum([
11
+ "invalid_request_id", "history_unreadable", "history_invalid", "request_ambiguous", "mapping_invalid",
12
+ "result_not_reported", "result_invalid",
13
+ ]) }).strict(),
14
+ ]).refine(r => r.state !== "forked" || r.old_thread_id !== r.new_thread_id);
15
+
16
+ // Read paths re-validate stored data, not just the write-time MCP schema.
17
+ export function publicForkResult(raw: unknown) {
18
+ try {
19
+ const parsed = forkRecoveryResultSchema.safeParse(typeof raw === "string" ? JSON.parse(raw) : raw);
20
+ if (parsed.success) return parsed.data;
21
+ } catch { /* corrupted/legacy storage must not expose arbitrary fields */ }
22
+ return { state: "unknown", reason: raw == null ? "result_not_reported" : "result_invalid" } as const;
23
+ }
@@ -0,0 +1,38 @@
1
+ import { expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ test("legacy start table upgrades twice without changing ordinary rows or losing fork receipts", () => {
10
+ const root = mkdtempSync(join(tmpdir(), "fork-migration-"));
11
+ const path = join(root, "legacy.db");
12
+ try {
13
+ let db = new Database(path);
14
+ db.exec(`CREATE TABLE node_start_requests (
15
+ request_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, daemon_node_id TEXT NOT NULL,
16
+ child_node_id TEXT NOT NULL, child_alias TEXT NOT NULL, created_by_token TEXT NOT NULL,
17
+ status TEXT NOT NULL DEFAULT 'pending', error TEXT, child_pid INTEGER,
18
+ created_at INTEGER NOT NULL, delivered_at INTEGER, acked_at INTEGER);
19
+ INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,created_at)
20
+ VALUES('str_legacy','net_fixture','node_daemon','node_child','fixture','fixture','started',1);`);
21
+ db.close();
22
+ const migrate = () => execFileSync(process.execPath, ["-e",
23
+ `await import(${JSON.stringify(fileURLToPath(new URL("./db.ts", import.meta.url)))}); process.exit(0);`],
24
+ { env: { ...process.env, COMMHUB_DB: path }, timeout: 15_000, stdio: "pipe" });
25
+ migrate();
26
+ db = new Database(path);
27
+ expect(db.query("SELECT status,fork_recovery_json,fork_result_json FROM node_start_requests WHERE request_id='str_legacy'").get())
28
+ .toEqual({ status: "started", fork_recovery_json: null, fork_result_json: null });
29
+ const receipt = JSON.stringify({ state: "not_observed" });
30
+ db.query("UPDATE node_start_requests SET fork_result_json=? WHERE request_id='str_legacy'").run(receipt);
31
+ db.close();
32
+ migrate();
33
+ db = new Database(path);
34
+ expect(db.query("SELECT fork_result_json FROM node_start_requests WHERE request_id='str_legacy'").get())
35
+ .toEqual({ fork_result_json: receipt });
36
+ db.close();
37
+ } finally { rmSync(root, { recursive: true, force: true }); }
38
+ });
@@ -0,0 +1,85 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // commhub-server must refuse what it does not understand instead of starting a
8
+ // Hub on the defaults (the live ~/.commhub/commhub.db). Every child gets a
9
+ // throwaway HOME and a throwaway PORT, so even a regression binds nothing real.
10
+ const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
11
+ const PKG_VERSION = JSON.parse(readFileSync(join(dirname(BIN), "..", "package.json"), "utf8")).version as string;
12
+ const home = mkdtempSync(join(tmpdir(), "anet-bin-args-"));
13
+ const PORT = String(26000 + Math.floor(Math.random() * 3000));
14
+
15
+ afterAll(() => rmSync(home, { recursive: true, force: true }));
16
+
17
+ function run(args: string[], extraEnv: Record<string, string> = {}, timeoutMs = 15_000) {
18
+ const proc = Bun.spawnSync(["bun", BIN, ...args], {
19
+ env: { PATH: process.env.PATH ?? "", HOME: home, PORT, HOST: "127.0.0.1", ...extraEnv },
20
+ stdout: "pipe", stderr: "pipe", timeout: timeoutMs,
21
+ });
22
+ return { code: proc.exitCode, stdout: proc.stdout.toString(), stderr: proc.stderr.toString() };
23
+ }
24
+
25
+ async function listening(port: string): Promise<boolean> {
26
+ try { await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(500) }); return true; } catch { return false; }
27
+ }
28
+
29
+ describe("commhub-server argument handling", () => {
30
+ for (const [args, message] of [
31
+ [["bogus"], "unknown command bogus"],
32
+ [["--port=29999"], "unknown option --port=29999"],
33
+ [["--bogus-flag"], "unknown option --bogus-flag"],
34
+ [["--port"], "--port needs a value"],
35
+ [["--db", "--dev-open"], "--db needs a value"],
36
+ ] as const) {
37
+ test(`${args.join(" ")} → exit 2, no database, nothing listening`, async () => {
38
+ const r = run([...args]);
39
+ expect(r.code).toBe(2);
40
+ expect(r.stderr).toContain(message);
41
+ expect(r.stderr).toContain("commhub-server --help");
42
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
43
+ expect(await listening(PORT)).toBe(false);
44
+ });
45
+ }
46
+
47
+ test("migrate-to-pg with missing or unknown arguments exits 2 and starts nothing", async () => {
48
+ const missing = run(["migrate-to-pg", "--from", "/x.db"]);
49
+ expect(missing.code).toBe(2);
50
+ expect(missing.stdout).toContain("commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>");
51
+ const unknown = run(["migrate-to-pg", "--from", "/x.db", "--to", "postgres://u@127.0.0.1/anet_x_test", "--bogus"]);
52
+ expect(unknown.code).toBe(2);
53
+ expect(unknown.stderr).toContain("unknown argument --bogus");
54
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
55
+ expect(await listening(PORT)).toBe(false);
56
+ });
57
+
58
+ test("--help and --version exit 0 without starting", async () => {
59
+ const help = run(["--help"]);
60
+ expect(help.code).toBe(0);
61
+ expect(help.stdout).toContain("--port, -p <port>");
62
+ const version = run(["--version"]);
63
+ expect(version.code).toBe(0);
64
+ expect(version.stdout.trim()).toBe(PKG_VERSION);
65
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
66
+ });
67
+
68
+ test("the known forms still start a Hub", async () => {
69
+ const db = join(home, "known.db");
70
+ const port = String(Number(PORT) + 1);
71
+ const child = Bun.spawn(["bun", BIN, "--port", port, "--host", "127.0.0.1", "--db", db, "--dev-open"], {
72
+ env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
73
+ });
74
+ try {
75
+ let up = false;
76
+ for (let i = 0; i < 100 && !up; i++) { up = await listening(port); if (!up) await Bun.sleep(100); }
77
+ expect(up).toBe(true);
78
+ expect(existsSync(db)).toBe(true);
79
+ expect(existsSync(join(home, ".commhub", "commhub.db"))).toBe(false);
80
+ } finally {
81
+ child.kill();
82
+ await child.exited;
83
+ }
84
+ }, 30_000);
85
+ });
@@ -721,3 +721,110 @@ describe("SEC-1 — update lifecycle: single-flight + cross-network pending look
721
721
  expect(inFlight).toBeDefined();
722
722
  });
723
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)", () => {
@@ -247,6 +249,15 @@ describe("validatePatch — per-flag enum / range", () => {
247
249
  expect(validatePatch(undefined, { timeout: 3_600_001 })?.field).toBe("flags.timeout");
248
250
  expect(validatePatch(undefined, { timeout: 60.5 })?.field).toBe("flags.timeout");
249
251
  });
252
+ test("timeout: 0 = no limit; 1..999 is seconds-shaped and rejected (#605)", () => {
253
+ expect(validatePatch(undefined, { timeout: 0 })).toBeNull();
254
+ expect(validatePatch(undefined, { timeout: 1000 })).toBeNull();
255
+ for (const v of [1, 600, 999]) {
256
+ const r = validatePatch(undefined, { timeout: v });
257
+ expect(r?.field).toBe("flags.timeout");
258
+ expect(r?.reason).toContain("milliseconds");
259
+ }
260
+ });
250
261
  });
251
262
 
252
263
  describe("validatePatch — combinations", () => {
@@ -258,3 +269,93 @@ describe("validatePatch — combinations", () => {
258
269
  expect(r?.field).toBe("flags.maxTurns");
259
270
  });
260
271
  });
272
+
273
+ // ── #260 P5 — channels support (restart-tier, non-security-sensitive) ─
274
+ describe("EDITABLE_CHANNELS — mirrors agent-node runtime capability (cli.ts:671-676)", () => {
275
+ test("contains exactly telegram + feishu (channels agent-node actually forks workers for)", () => {
276
+ expect(EDITABLE_CHANNELS.size).toBe(2);
277
+ expect(EDITABLE_CHANNELS.has("telegram")).toBe(true);
278
+ expect(EDITABLE_CHANNELS.has("feishu")).toBe(true);
279
+ });
280
+ test("commhub is NOT editable — it's the RPC transport, not a per-node channel worker", () => {
281
+ expect(EDITABLE_CHANNELS.has("commhub")).toBe(false);
282
+ });
283
+ test("wechat is NOT editable — roadmap only on dashboard", () => {
284
+ expect(EDITABLE_CHANNELS.has("wechat")).toBe(false);
285
+ });
286
+ });
287
+
288
+ describe("narrowChannelsPatch — hostile input hygiene at the wire boundary", () => {
289
+ test("straight allow-listed input passes verbatim", () => {
290
+ expect(narrowChannelsPatch(["telegram", "feishu"])).toEqual(["telegram", "feishu"]);
291
+ });
292
+ test("empty array preserved — 'disable all editable channels' is a real state", () => {
293
+ expect(narrowChannelsPatch([])).toEqual([]);
294
+ });
295
+ test("non-array returns null (patch didn't touch channels)", () => {
296
+ expect(narrowChannelsPatch(undefined)).toBeNull();
297
+ expect(narrowChannelsPatch("telegram" as any)).toBeNull();
298
+ expect(narrowChannelsPatch({} as any)).toBeNull();
299
+ });
300
+ test("case-folded to lower", () => {
301
+ expect(narrowChannelsPatch(["FEISHU", "Telegram"])).toEqual(["feishu", "telegram"]);
302
+ });
303
+ test("duplicates deduped (Set)", () => {
304
+ expect(narrowChannelsPatch(["telegram", "telegram", "feishu", "TELEGRAM"])).toEqual(["telegram", "feishu"]);
305
+ });
306
+ test("unknown channel keys dropped silently (roadmap wechat, transport commhub, evil keys)", () => {
307
+ expect(narrowChannelsPatch(["wechat", "telegram", "evil-hacker", "commhub"])).toEqual(["telegram"]);
308
+ });
309
+ test("non-string entries dropped (numbers, objects, nulls)", () => {
310
+ expect(narrowChannelsPatch(["telegram", 42, {}, null, "feishu"] as any)).toEqual(["telegram", "feishu"]);
311
+ });
312
+ test("SQL-injection-shaped value dropped (not exact key match)", () => {
313
+ expect(narrowChannelsPatch(["telegram; drop table users;", "feishu"])).toEqual(["feishu"]);
314
+ });
315
+ test("whitespace around a key still trims to the key", () => {
316
+ expect(narrowChannelsPatch([" feishu "])).toEqual(["feishu"]);
317
+ });
318
+ });
319
+
320
+ describe("computeApplyMode — channels tier (restart)", () => {
321
+ test("channels-only patch → restart (boot forks per-channel workers)", () => {
322
+ expect(computeApplyMode(undefined, {}, ["feishu"])).toBe("restart");
323
+ });
324
+ test("empty-array channels → restart (still a state change)", () => {
325
+ expect(computeApplyMode(undefined, {}, [])).toBe("restart");
326
+ });
327
+ test("no channels key + no flags + no model → restart_only", () => {
328
+ expect(computeApplyMode(undefined, {}, undefined)).toBe("restart_only");
329
+ });
330
+ test("channels + hot-tier flag together stays restart (upgrade rules)", () => {
331
+ expect(computeApplyMode(undefined, { maxTurns: 5 }, ["telegram"])).toBe("restart");
332
+ });
333
+ test("channels + model → restart (model was already restart)", () => {
334
+ expect(computeApplyMode("gpt-5", {}, ["feishu"])).toBe("restart");
335
+ });
336
+ });
337
+
338
+ describe("validatePatch — channels defensive gate", () => {
339
+ test("valid channels array passes", () => {
340
+ expect(validatePatch(undefined, {}, ["telegram", "feishu"])).toBeNull();
341
+ expect(validatePatch(undefined, {}, [])).toBeNull();
342
+ expect(validatePatch(undefined, {}, ["telegram"])).toBeNull();
343
+ });
344
+ test("commhub rejected — RPC transport, not a per-node channel", () => {
345
+ const r = validatePatch(undefined, {}, ["commhub"]);
346
+ expect(r?.field).toBe("channels.commhub");
347
+ });
348
+ test("out-of-allowlist channel rejected (caller bypassed narrowing)", () => {
349
+ const r = validatePatch(undefined, {}, ["wechat"]);
350
+ expect(r?.field).toBe("channels.wechat");
351
+ expect(r?.reason).toMatch(/allowlist/);
352
+ });
353
+ test("non-string entry rejected", () => {
354
+ const r = validatePatch(undefined, {}, [42] as any);
355
+ expect(r?.field).toBe("channels");
356
+ });
357
+ test("non-array rejected", () => {
358
+ const r = validatePatch(undefined, {}, "telegram" as any);
359
+ expect(r?.field).toBe("channels");
360
+ });
361
+ });
@@ -5,6 +5,8 @@
5
5
  // tools.ts wrap these with auth checks (SEC-1 network scope, ntok_/utok_
6
6
  // caller identity, role gates).
7
7
 
8
+ import { isValidTimeoutMs, TIMEOUT_MS_REASON } from "./create-node-validate.js";
9
+
8
10
  /**
9
11
  * Fields the dashboard may change. Anything not in this list is rejected
10
12
  * by hub-side validation regardless of role — the UI cannot smuggle
@@ -57,6 +59,35 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
57
59
  "timeout",
58
60
  ]);
59
61
 
62
+ /**
63
+ * Channels the dashboard may enable/disable on a node via
64
+ * update_node_config. Intentionally narrow — the panel only exposes
65
+ * enable/disable, per-channel secrets stay in the node's local
66
+ * config.json (see #260 wire discipline).
67
+ *
68
+ * The list mirrors agent-node's actual runtime capability. See
69
+ * agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
70
+ * worker inits (initTelegramChannel + initFeishuChannel), and any
71
+ * other channel type triggers `process.exit(1)` at boot. So the
72
+ * hub-side allow-list here is telegram + feishu ONLY. `commhub` is
73
+ * the RPC transport that every node speaks unconditionally; treating
74
+ * it as a per-node channel would be a UX lie (toggle looks off but
75
+ * commhub is always on).
76
+ *
77
+ * Dashboard PR #31 currently includes `commhub` in its own
78
+ * EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
79
+ * the next dashboard rally, and until then hub silently drops any
80
+ * `commhub` entry via narrowChannelsPatch.
81
+ *
82
+ * Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
83
+ * channel on/off is a lifecycle-tier operation like `restart_node`, not
84
+ * a privilege elevation. Cross-tenant reach is already gated by SEC-1.
85
+ */
86
+ export const EDITABLE_CHANNELS = new Set<string>([
87
+ "telegram",
88
+ "feishu",
89
+ ]);
90
+
60
91
  /**
61
92
  * Role-gate for the patch's flag set. SEC-2 enforcement lives here.
62
93
  *
@@ -99,22 +130,53 @@ export function isAllowedToChangeFlag(
99
130
 
100
131
  /**
101
132
  * 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".
133
+ * both empty, no channels) → "restart_only" (used by restart_node tool).
134
+ * Any restart-required field present, OR a channels change → "restart"
135
+ * (channels is restart-tier by design: the node reads its channel set
136
+ * once at boot in agent-node/src/cli.ts and forks per-channel workers
137
+ * from it, so a channel enable/disable takes effect on process restart
138
+ * only — see #260 P5 wire discipline). Otherwise → "hot".
104
139
  */
105
140
  export function computeApplyMode(
106
141
  model: string | undefined,
107
142
  flags: Record<string, unknown>,
143
+ channels?: string[] | undefined,
108
144
  ): "hot" | "restart" | "restart_only" {
109
- const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
145
+ const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
110
146
  if (fieldCount === 0) return "restart_only";
111
147
  if (model !== undefined) return "restart";
148
+ if (channels !== undefined) return "restart";
112
149
  for (const key of Object.keys(flags)) {
113
150
  if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
114
151
  }
115
152
  return "hot";
116
153
  }
117
154
 
155
+ /**
156
+ * Narrow + allow-list `channels` from untrusted patch JSON. Returns the
157
+ * final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
158
+ * order-preserving), OR null if the input isn't an array. Non-strings,
159
+ * unknown keys, empty strings, and duplicates are silently dropped —
160
+ * same fail-shape as the dashboard route's whitelist so the two sides
161
+ * behave identically end-to-end. Callers pass `undefined` through when
162
+ * the patch didn't include a channels key at all (a distinct case from
163
+ * `channels: []` which means "disable all editable channels").
164
+ */
165
+ export function narrowChannelsPatch(raw: unknown): string[] | null {
166
+ if (!Array.isArray(raw)) return null;
167
+ const allow = EDITABLE_CHANNELS;
168
+ const seen = new Set<string>();
169
+ const out: string[] = [];
170
+ for (const v of raw) {
171
+ if (typeof v !== "string") continue;
172
+ const key = v.trim().toLowerCase();
173
+ if (!allow.has(key) || seen.has(key)) continue;
174
+ seen.add(key);
175
+ out.push(key);
176
+ }
177
+ return out;
178
+ }
179
+
118
180
  /**
119
181
  * Validate the patch shape — allowlist + per-field range / enum. Returns
120
182
  * null on pass, or `{ field, reason }` on reject. Caller wraps in error
@@ -123,12 +185,30 @@ export function computeApplyMode(
123
185
  export function validatePatch(
124
186
  model: string | undefined,
125
187
  flags: Record<string, unknown>,
188
+ channels?: string[] | undefined,
126
189
  ): { field: string; reason: string } | null {
127
190
  if (model !== undefined) {
128
191
  if (typeof model !== "string" || model.length === 0 || model.length > 200) {
129
192
  return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
130
193
  }
131
194
  }
195
+ if (channels !== undefined) {
196
+ // Zod already enforced string[], and narrowChannelsPatch dropped
197
+ // unknown keys before this point — but re-verify defensively so a
198
+ // future caller that skips narrowChannelsPatch can't smuggle
199
+ // unknown keys through validatePatch alone.
200
+ if (!Array.isArray(channels)) {
201
+ return { field: "channels", reason: "must be an array of strings" };
202
+ }
203
+ for (const c of channels) {
204
+ if (typeof c !== "string") {
205
+ return { field: "channels", reason: "must contain only strings" };
206
+ }
207
+ if (!EDITABLE_CHANNELS.has(c)) {
208
+ return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
209
+ }
210
+ }
211
+ }
132
212
  for (const [key, val] of Object.entries(flags)) {
133
213
  if (!ALLOWED_FLAGS.has(key)) {
134
214
  return { field: `flags.${key}`, reason: "not in allowlist" };
@@ -161,8 +241,10 @@ export function validatePatch(
161
241
  }
162
242
  break;
163
243
  case "timeout":
164
- if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 3_600_000) {
165
- return { field: "flags.timeout", reason: "must be an integer ms in [0, 3_600_000]" };
244
+ // #605 —— 与 create_node 同一把尺子(create-node-validate.ts isValidTimeoutMs):
245
+ // 毫秒,0 或 1000..3_600_000;1..999 是「按秒填了」的形状,拒绝而不是给一个亚秒超时。
246
+ if (!isValidTimeoutMs(val)) {
247
+ return { field: "flags.timeout", reason: TIMEOUT_MS_REASON };
166
248
  }
167
249
  break;
168
250
  }