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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,124 @@
1
+ // Short-lived management lane, not task/message text and not a secret archive.
2
+ import { randomUUID } from 'node:crypto';
3
+ import { db } from './db';
4
+ import { getUserNetworkRole } from './auth';
5
+ import { providerScanTargets } from './daemon-provider-snapshots';
6
+ import { validScopedSecretKey } from './shared/scoped-secret-contract';
7
+ type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
8
+ type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
9
+ type Request = { request_id: string; network_id: string; daemon_node_id: string | null; node_id: string | null;
10
+ action: 'list' | 'put' | 'delete'; key?: string; value?: string; revision?: number;
11
+ status: 'pending' | 'succeeded' | 'failed' | 'expired'; expires_at: number; snapshot?: any; error?: string };
12
+ const requests = new Map<string, Request>();
13
+ const denied = (error: string) => ({ ok: false as const, error });
14
+ function authorize(h: Human) {
15
+ return h.isNode || !h.userId ? denied('user_token_required') : !h.networkId
16
+ || !['owner','admin'].includes(getUserNetworkRole(h.userId, h.networkId) ?? '') ? denied('secret_admin_required') : null;
17
+ }
18
+ function daemonSupported(network: string, daemon: string) {
19
+ const row = db.get<{ config_snapshot: string }>('SELECT config_snapshot FROM nodes WHERE network_id=?1 AND node_id=?2', network, daemon);
20
+ try { const s = JSON.parse(row?.config_snapshot ?? '{}'); return s.role === 'host_supervisor' && s.scoped_secrets_capable === true; } catch { return false; }
21
+ }
22
+ function expire() {
23
+ for (const [id,r] of requests) {
24
+ if (r.expires_at < Date.now() && r.status === 'pending') { r.status = 'expired'; delete r.value; }
25
+ if (r.expires_at + 600_000 < Date.now()) requests.delete(id);
26
+ }
27
+ }
28
+ const timer = setInterval(expire, 10_000); timer.unref();
29
+ function nodeSupported(network: string, node: string) {
30
+ const row = db.get<{ config_snapshot: string }>('SELECT config_snapshot FROM nodes WHERE network_id=?1 AND node_id=?2', network, node);
31
+ try { return JSON.parse(row?.config_snapshot ?? '{}').node_secrets_capable === true; } catch { return false; }
32
+ }
33
+ // Independent node lane: no supervisor lookup, binding, or Daemon credential.
34
+ export function requestNodeSecrets(h: Human, node: string, action: Request['action'], key?: string, value?: string, revision?: number) {
35
+ const refusal = authorize(h); if (refusal) return refusal;
36
+ if (!nodeSupported(h.networkId!, node)) return denied('node_secrets_upgrade_required');
37
+ if (action !== 'list' && (!validScopedSecretKey(key) || !Number.isSafeInteger(revision) || revision! < 0
38
+ || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
39
+ expire();
40
+ if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === null && r.node_id === node && r.status === 'pending')) return denied('secret_store_busy');
41
+ if (requests.size >= 1000) return denied('secret_queue_busy');
42
+ const r: Request = { request_id: `nsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: null,
43
+ node_id: node, action, ...(action === 'list' ? {} : { key, revision, ...(action === 'put' ? { value } : {}) }),
44
+ expires_at: Date.now() + 120_000, status: 'pending' };
45
+ requests.set(r.request_id, r);
46
+ return { ok: true as const, request_id: r.request_id, status: r.status, expires_at: r.expires_at };
47
+ }
48
+ export function pullNodeSecrets(d: Daemon) {
49
+ if (!d.ok || !nodeSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_secret_node');
50
+ expire();
51
+ return { ok: true, requests: [...requests.values()].filter(r => r.network_id === d.networkId && r.daemon_node_id === null
52
+ && r.node_id === d.daemonNodeId && r.status === 'pending').map(({ status, snapshot, error, ...input }) => input) };
53
+ }
54
+ export function ackNodeSecrets(d: Daemon, id: string, snapshot: any, failed = false) {
55
+ if (!d.ok || !nodeSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_secret_node');
56
+ expire(); const r = requests.get(id);
57
+ if (!r || r.network_id !== d.networkId || r.daemon_node_id !== null || r.node_id !== d.daemonNodeId) return denied('request_not_found');
58
+ if (r.status !== 'pending') return { ok: r.status === 'succeeded' || r.status === 'failed', status: r.status };
59
+ if (!failed) {
60
+ if (!snapshot || Object.keys(snapshot).some(k => !['network_id','daemon_node_id','node_id','revision','activation','keys'].includes(k))
61
+ || snapshot.network_id !== r.network_id || snapshot.daemon_node_id !== null || snapshot.node_id !== r.node_id
62
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || snapshot.activation !== 'next_start'
63
+ || !Array.isArray(snapshot.keys) || snapshot.keys.length > 128 || new Set(snapshot.keys.map((x:any)=>x?.key)).size !== snapshot.keys.length
64
+ || snapshot.keys.some((x:any)=>!x || Object.keys(x).some(k=>!['key','source'].includes(k)) || !validScopedSecretKey(x.key) || x.source !== 'node')
65
+ || (r.action !== 'list' && snapshot.revision !== r.revision! + 1)
66
+ || (r.action === 'put' && !snapshot.keys.some((x:any)=>x.key === r.key))
67
+ || (r.action === 'delete' && snapshot.keys.some((x:any)=>x.key === r.key))) return denied('invalid_secret_receipt');
68
+ r.snapshot = snapshot;
69
+ }
70
+ r.status = failed ? 'failed' : 'succeeded'; if (failed) r.error = 'secret_operation_unavailable'; delete r.value;
71
+ return { ok: true, status: r.status };
72
+ }
73
+ export function requestScopedSecrets(h: Human, daemon: string, node: string | null, action: Request['action'], key?: string, value?: string, revision?: number) {
74
+ const refusal = authorize(h); if (refusal) return refusal;
75
+ if (!daemonSupported(h.networkId!, daemon)) return denied('scoped_secrets_upgrade_required');
76
+ if (node !== null && !providerScanTargets(h.networkId!, daemon).some(t => t.node_id === node)) return denied('secret_target_not_bound');
77
+ if (action !== 'list' && (!validScopedSecretKey(key)
78
+ || !Number.isSafeInteger(revision) || revision! < 0 || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
79
+ expire();
80
+ if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === daemon && r.status === 'pending')) return denied('secret_store_busy');
81
+ if (requests.size >= 1000) return denied('secret_queue_busy');
82
+ const r: Request = { request_id: `dsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: daemon,
83
+ node_id: node, action, ...(action === 'list' ? {} : { key, revision, ...(action === 'put' ? { value } : {}) }),
84
+ expires_at: Date.now() + 120_000, status: 'pending' };
85
+ requests.set(r.request_id, r);
86
+ return { ok: true as const, request_id: r.request_id, status: r.status, expires_at: r.expires_at };
87
+ }
88
+ export function getScopedSecrets(h: Human, id: string) {
89
+ const refusal = authorize(h); if (refusal) return refusal; expire();
90
+ const r = requests.get(id);
91
+ if (!r || r.network_id !== h.networkId) return denied('request_not_found');
92
+ return { ok: true as const, request_id: id, daemon_node_id: r.daemon_node_id, node_id: r.node_id,
93
+ status: r.status, ...(r.snapshot ? { snapshot: r.snapshot } : {}), ...(r.error ? { error: r.error } : {}) };
94
+ }
95
+ export function pullScopedSecrets(d: Daemon) {
96
+ if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
97
+ const targets = providerScanTargets(d.networkId, d.daemonNodeId);
98
+ return { ok: true, requests: [...requests.values()].filter(r => r.network_id === d.networkId && r.daemon_node_id === d.daemonNodeId && r.status === 'pending')
99
+ .flatMap(r => {
100
+ if (r.node_id !== null && !targets.some(t => t.node_id === r.node_id)) { r.status = 'failed'; r.error = 'secret_target_not_bound'; delete r.value; return []; }
101
+ const { status, snapshot, error, ...input } = r;
102
+ return [{ ...input, targets }];
103
+ }) };
104
+ }
105
+ export function ackScopedSecrets(d: Daemon, id: string, snapshot: any, failed = false) {
106
+ if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
107
+ const r = requests.get(id);
108
+ if (!r || r.network_id !== d.networkId || r.daemon_node_id !== d.daemonNodeId) return denied('request_not_found');
109
+ if (r.status !== 'pending') return { ok: r.status === 'succeeded' || r.status === 'failed', status: r.status };
110
+ if (!failed) {
111
+ if (!snapshot || Object.keys(snapshot).some(k => !['network_id','daemon_node_id','node_id','revision','activation','keys'].includes(k))
112
+ || snapshot.network_id !== r.network_id || snapshot.daemon_node_id !== r.daemon_node_id || snapshot.node_id !== r.node_id
113
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || snapshot.activation !== 'next_start'
114
+ || !Array.isArray(snapshot.keys) || snapshot.keys.length > 256 || new Set(snapshot.keys.map((x:any)=>x.key)).size !== snapshot.keys.length
115
+ || snapshot.keys.some((x:any) => !x || Object.keys(x).some(k=>!['key','source'].includes(k)) || !validScopedSecretKey(x.key)
116
+ || !['daemon','node'].includes(x.source) || (r.node_id === null && x.source !== 'daemon'))
117
+ || (r.action !== 'list' && snapshot.revision !== r.revision! + 1)
118
+ || (r.action === 'put' && !snapshot.keys.some((x:any)=>x.key === r.key && x.source === (r.node_id ? 'node' : 'daemon')))) return denied('invalid_secret_receipt');
119
+ if (r.node_id !== null && !providerScanTargets(r.network_id,r.daemon_node_id).some(t=>t.node_id === r.node_id)) return denied('secret_target_not_bound');
120
+ r.snapshot = snapshot;
121
+ }
122
+ r.status = failed ? 'failed' : 'succeeded'; if (failed) r.error = 'secret_operation_unavailable'; delete r.value;
123
+ return { ok: true, status: r.status };
124
+ }
@@ -0,0 +1,229 @@
1
+ // Handler-level regression for the #212 dedup window swallowing human chat
2
+ // messages (see send_dedup.ts header, "Key selection").
3
+ //
4
+ // Before the fix, POST /api/task keyed the dedup window on
5
+ // (from, to, sha256(content)) only and ignored meta.client_request_id, so a
6
+ // user sending the same short text twice within 5 minutes got 200/429/429 and
7
+ // ONE stored row — and the app maps that 429 to "delivered". The table below
8
+ // is the repro, run against both transports:
9
+ //
10
+ // case | before (REST) | after
11
+ // ----------------------------------+---------------+-------------------------
12
+ // same text, 3 different ids | 1 row | 3 rows, 3 x ok
13
+ // same text, same id (retry) | 1 row | 1 row, 2 x replay (same id)
14
+ // different text, same id | 3 rows | 1 row, 2 x idempotency_conflict
15
+ // same text, no id (#212 agents) | 1 row | 1 row, 2 x duplicate_send
16
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
+ import { mkdtempSync, rmSync } from "fs";
18
+ import { join } from "path";
19
+ import { tmpdir } from "os";
20
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import { register } from "./auth.js";
22
+ import { db } from "./db.js";
23
+ import { registerTools } from "./tools.js";
24
+ import { sharedSendDedup } from "./send_dedup.js";
25
+
26
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-send-dedup-rid-"));
27
+ const TARGET = `dedup-rid-target-${process.pid}`;
28
+ const TARGET_NODE_ID = `node_dedup_rid_${process.pid}`;
29
+
30
+ let server: any;
31
+ let base = "";
32
+ let userToken = "";
33
+ let restNet = "";
34
+ let restUser = "";
35
+
36
+ const MCP_NET = `net_dedup_rid_${process.pid}`;
37
+ const MCP_USER = `user_dedup_rid_${process.pid}`;
38
+
39
+ function rid(n: number): string {
40
+ // App shape: dreq_ + 32 lowercase hex.
41
+ return `dreq_${n.toString(16).padStart(32, "0")}`;
42
+ }
43
+
44
+ function seedTarget(networkId: string, suffix: string) {
45
+ db.run(
46
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
47
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
48
+ [`resume_${TARGET_NODE_ID}_${suffix}`, TARGET, `${TARGET_NODE_ID}_${suffix}`, networkId],
49
+ );
50
+ db.run(
51
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
52
+ VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
53
+ [`${TARGET_NODE_ID}_${suffix}`, TARGET, networkId],
54
+ );
55
+ }
56
+
57
+ beforeAll(async () => {
58
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
59
+ restUser = `dedup_rid_${Date.now()}`;
60
+ const registered = register(restUser, "DedupRid123!", undefined, "seed");
61
+ expect(registered.ok).toBe(true);
62
+ userToken = registered.token!;
63
+ restNet = registered.network_id!;
64
+ seedTarget(restNet, "rest");
65
+
66
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [MCP_USER]);
67
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [MCP_NET, MCP_USER]);
68
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [MCP_USER, MCP_NET]);
69
+ seedTarget(MCP_NET, "mcp");
70
+
71
+ const mod: any = await import("./server.js");
72
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
73
+ base = `http://127.0.0.1:${server.port}`;
74
+ }, 30_000);
75
+
76
+ afterAll(() => {
77
+ try { server?.stop?.(true); } catch {}
78
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
79
+ });
80
+
81
+ beforeEach(() => {
82
+ for (const net of [restNet, MCP_NET]) {
83
+ if (!net) continue;
84
+ for (const table of ["tasks", "inbox", "task_events"]) {
85
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [net]); } catch {}
86
+ }
87
+ }
88
+ sharedSendDedup.clear();
89
+ });
90
+
91
+ function rowCount(networkId: string): number {
92
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1", [networkId])!.n;
93
+ }
94
+ function inboxCount(networkId: string): number {
95
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", [networkId])!.n;
96
+ }
97
+
98
+ // ── REST ────────────────────────────────────────────────────────────────
99
+
100
+ async function rest(task: string, requestId?: string): Promise<{ status: number; body: any }> {
101
+ const res = await fetch(`${base}/api/task`, {
102
+ method: "POST",
103
+ headers: { Authorization: `Bearer ${userToken}`, "Content-Type": "application/json" },
104
+ body: JSON.stringify({
105
+ alias: TARGET,
106
+ task,
107
+ priority: "normal",
108
+ network_id: restNet,
109
+ from: restUser,
110
+ meta: requestId
111
+ ? { source: "dashboard-chat", client_request_id: requestId }
112
+ : { source: "dashboard-chat" },
113
+ }),
114
+ });
115
+ return { status: res.status, body: await res.json() };
116
+ }
117
+
118
+ describe("POST /api/task dedup key selection", () => {
119
+ test("same text with 3 different request ids -> 3 rows (the reported data loss)", async () => {
120
+ const results = [await rest("好", rid(1)), await rest("好", rid(2)), await rest("好", rid(3))];
121
+ expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
122
+ const ids = results.map((r) => r.body.task_id);
123
+ expect(new Set(ids).size).toBe(3);
124
+ expect(rowCount(restNet)).toBe(3);
125
+ expect(inboxCount(restNet)).toBe(3);
126
+ });
127
+
128
+ test("same text with the same request id -> 1 row, retries replay the same task id", async () => {
129
+ const first = await rest("好", rid(7));
130
+ const second = await rest("好", rid(7));
131
+ const third = await rest("好", rid(7));
132
+ expect(first.status).toBe(200);
133
+ expect(second.status).toBe(200);
134
+ expect(third.status).toBe(200);
135
+ expect(second.body).toMatchObject({ ok: true, task_id: first.body.task_id, message_id: first.body.task_id, idempotent_replay: true });
136
+ expect(third.body.task_id).toBe(first.body.task_id);
137
+ expect(rowCount(restNet)).toBe(1);
138
+ expect(inboxCount(restNet)).toBe(1);
139
+ });
140
+
141
+ test("different text with the same request id -> 1 row, reuse fails closed (409)", async () => {
142
+ const first = await rest("第一条", rid(9));
143
+ const second = await rest("第二条", rid(9));
144
+ const third = await rest("第三条", rid(9));
145
+ expect(first.status).toBe(200);
146
+ expect(second.status).toBe(409);
147
+ expect(second.body.error).toBe("idempotency_conflict");
148
+ expect(third.status).toBe(409);
149
+ expect(rowCount(restNet)).toBe(1);
150
+ });
151
+
152
+ test("#212 guard: same text WITHOUT a request id is still deduped (429 duplicate_send)", async () => {
153
+ const results = [await rest("继续"), await rest("继续"), await rest("继续")];
154
+ expect(results.map((r) => r.status)).toEqual([200, 429, 429]);
155
+ expect(results[1].body.error).toBe("duplicate_send");
156
+ expect(rowCount(restNet)).toBe(1);
157
+ });
158
+
159
+ test("no request id, different text -> each goes through", async () => {
160
+ const results = [await rest("a"), await rest("b"), await rest("c")];
161
+ expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
162
+ expect(rowCount(restNet)).toBe(3);
163
+ });
164
+ });
165
+
166
+ // ── MCP send_task ──────────────────────────────────────────────────────
167
+
168
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
169
+
170
+ function mcpSendTask(): ToolHandler {
171
+ const mcp = new McpServer({ name: "dedup-rid-test", version: "0" }) as any;
172
+ let handler: ToolHandler | undefined;
173
+ const original = mcp.tool.bind(mcp);
174
+ mcp.tool = (name: string, description: string, schema: any, candidate: ToolHandler) => {
175
+ if (name === "send_task") handler = candidate;
176
+ return original(name, description, schema, candidate);
177
+ };
178
+ registerTools(mcp, undefined, MCP_NET, MCP_USER, MCP_USER, false, null);
179
+ if (!handler) throw new Error("send_task handler missing");
180
+ return handler;
181
+ }
182
+
183
+ async function mcp(handler: ToolHandler, task: string, requestId?: string): Promise<any> {
184
+ const result = await handler({
185
+ alias: TARGET,
186
+ task,
187
+ priority: "normal",
188
+ ...(requestId ? { meta: { source: "dashboard-chat", client_request_id: requestId } } : {}),
189
+ });
190
+ return JSON.parse(result.content[0].text);
191
+ }
192
+
193
+ describe("MCP send_task dedup key selection", () => {
194
+ test("same text with 3 different request ids -> 3 rows", async () => {
195
+ const h = mcpSendTask();
196
+ const results = [await mcp(h, "好", rid(11)), await mcp(h, "好", rid(12)), await mcp(h, "好", rid(13))];
197
+ expect(results.map((r) => r.ok)).toEqual([true, true, true]);
198
+ expect(new Set(results.map((r) => r.message_id)).size).toBe(3);
199
+ expect(rowCount(MCP_NET)).toBe(3);
200
+ });
201
+
202
+ test("same text with the same request id -> 1 row, replay returns the same task id", async () => {
203
+ const h = mcpSendTask();
204
+ const first = await mcp(h, "好", rid(17));
205
+ const second = await mcp(h, "好", rid(17));
206
+ const third = await mcp(h, "好", rid(17));
207
+ expect(first.ok).toBe(true);
208
+ expect(second).toMatchObject({ ok: true, task_id: first.message_id, idempotent_replay: true });
209
+ expect(third.task_id).toBe(first.message_id);
210
+ expect(rowCount(MCP_NET)).toBe(1);
211
+ });
212
+
213
+ test("different text with the same request id -> 1 row, idempotency_conflict", async () => {
214
+ const h = mcpSendTask();
215
+ expect((await mcp(h, "第一条", rid(19))).ok).toBe(true);
216
+ expect((await mcp(h, "第二条", rid(19))).error).toBe("idempotency_conflict");
217
+ expect((await mcp(h, "第三条", rid(19))).error).toBe("idempotency_conflict");
218
+ expect(rowCount(MCP_NET)).toBe(1);
219
+ });
220
+
221
+ test("#212 guard: same text WITHOUT a request id is still deduped", async () => {
222
+ const h = mcpSendTask();
223
+ const results = [await mcp(h, "继续"), await mcp(h, "继续"), await mcp(h, "继续")];
224
+ expect(results[0].ok).toBe(true);
225
+ expect(results[1].error).toBe("duplicate_send");
226
+ expect(results[2].error).toBe("duplicate_send");
227
+ expect(rowCount(MCP_NET)).toBe(1);
228
+ });
229
+ });
@@ -0,0 +1,202 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #498 compatibility warning remains load-bearing during the mixed-version
7
+ // rollout. Capability-negotiated agents use send_peer_reply; old send_reply
8
+ // callers targeting an agent get explicit migration guidance while Dashboard
9
+ // targets stay quiet.
10
+
11
+ const NET_ID = "net_498_warn";
12
+ const USER_ID = "u_498_warn";
13
+ const NODE_ID = "node_498_warn";
14
+ const AGENT_ALIAS = "peer-agent-498";
15
+ const DISPATCHER_NODE_ID = "node_498_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher";
17
+ const AGENT_TOK_ID = "tok_498_warn";
18
+
19
+ interface ToolHandler {
20
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
21
+ }
22
+ interface Reply {
23
+ ok?: boolean;
24
+ message_id?: string;
25
+ session_status?: string;
26
+ warning?: string;
27
+ error?: string;
28
+ [k: string]: unknown;
29
+ }
30
+
31
+ function cleanup() {
32
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
33
+ try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
34
+ try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
35
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
36
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
37
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
38
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
39
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
40
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
41
+ }
42
+
43
+ beforeEach(cleanup);
44
+ afterAll(cleanup);
45
+
46
+ function seed() {
47
+ db.run(
48
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
49
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
50
+ [USER_ID, USER_ID],
51
+ );
52
+ db.run(
53
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
54
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
55
+ [DISPATCHER_NODE_ID, DISPATCHER_ALIAS, DISPATCHER_ALIAS, NET_ID, `host-${DISPATCHER_ALIAS}`],
56
+ );
57
+ db.run(
58
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
59
+ VALUES (?1, ?2, ?3, datetime('now'))`,
60
+ [NET_ID, NET_ID, USER_ID],
61
+ );
62
+ db.run(
63
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
64
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
65
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
66
+ );
67
+ db.run(
68
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
69
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
70
+ [USER_ID, NET_ID],
71
+ );
72
+ db.run(
73
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
74
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
75
+ [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
76
+ );
77
+ db.run(
78
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
79
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
80
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
81
+ );
82
+ db.run(
83
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
84
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
85
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
86
+ );
87
+ }
88
+
89
+ function makeTask(from: string): string {
90
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
91
+ db.run(
92
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
93
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
94
+ [id, from, AGENT_ALIAS, NODE_ID, NET_ID],
95
+ );
96
+ return id;
97
+ }
98
+
99
+ function buildHandlers(): Record<string, ToolHandler> {
100
+ const server = new McpServer({ name: "test", version: "0" }) as any;
101
+ const tools: Record<string, ToolHandler> = {};
102
+ const origTool = server.tool.bind(server);
103
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
104
+ tools[name] = handler;
105
+ return origTool(name, _desc, _schema, handler);
106
+ };
107
+ const origRegisterTool = server.registerTool?.bind(server);
108
+ if (origRegisterTool) {
109
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
110
+ tools[name] = handler;
111
+ return origRegisterTool(name, _cfg, handler);
112
+ };
113
+ }
114
+ // Bind as a network token whose caller alias is the sender.
115
+ registerTools(
116
+ server,
117
+ undefined,
118
+ /* enforceNetworkId */ NET_ID,
119
+ /* enforceUserId */ USER_ID,
120
+ /* callerAlias */ AGENT_ALIAS,
121
+ /* callerTokenIsNetwork */ true,
122
+ /* callerTokenId */ AGENT_TOK_ID,
123
+ );
124
+ return tools;
125
+ }
126
+
127
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
128
+ const r = await handler(args);
129
+ return JSON.parse(r.content[0].text) as Reply;
130
+ }
131
+
132
+ describe("send_reply legacy/Dashboard compatibility (#698)", () => {
133
+ test("legacy call still closes the original and enqueues one no-response result", async () => {
134
+ seed();
135
+ const tools = buildHandlers();
136
+ const sendReply = tools["send_reply"];
137
+ expect(sendReply).toBeDefined();
138
+
139
+ const taskId = makeTask(DISPATCHER_ALIAS);
140
+ const reply = await call(sendReply, {
141
+ alias: DISPATCHER_ALIAS,
142
+ text: "peer reply (atomic)",
143
+ in_reply_to: taskId,
144
+ status: "replied",
145
+ from_session: AGENT_ALIAS,
146
+ });
147
+
148
+ expect(reply.ok).toBe(true);
149
+ expect(reply.warning).toContain("commhub_send_peer_reply");
150
+ expect(reply.warning).toContain("terminalized the original task");
151
+ expect(reply.warning).not.toContain("commhub_send_task");
152
+ expect(reply.warning).toContain("mixed-version");
153
+ expect(db.get<{ status: string; result: string }>(
154
+ "SELECT status, result FROM tasks WHERE task_id = ?1", taskId,
155
+ )).toEqual(expect.objectContaining({ status: "replied", result: "peer reply (atomic)" }));
156
+ expect(db.all<{ type: string; requires_response: string; session_name: string }>(
157
+ "SELECT type, requires_response, session_name FROM inbox WHERE in_reply_to = ?1", taskId,
158
+ )).toEqual([{ type: "reply", requires_response: "none", session_name: DISPATCHER_ALIAS }]);
159
+ expect(db.get<{ n: number }>(
160
+ "SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId,
161
+ )?.n).toBe(0);
162
+ });
163
+
164
+ test("target alias is `hub` (Dashboard path) → NO warning field", async () => {
165
+ seed();
166
+ const tools = buildHandlers();
167
+ const sendReply = tools["send_reply"];
168
+
169
+ const taskId = makeTask("hub");
170
+ const reply = await call(sendReply, {
171
+ alias: "hub",
172
+ text: "dashboard reply",
173
+ in_reply_to: taskId,
174
+ status: "replied",
175
+ from_session: AGENT_ALIAS,
176
+ });
177
+
178
+ expect(reply.ok).toBe(true);
179
+ // Silence-affirms the Dashboard path. Every reply carrying a
180
+ // warning would train the LLM to ignore it, so absence here is
181
+ // load-bearing.
182
+ expect(reply.warning).toBeUndefined();
183
+ });
184
+
185
+ test("target alias is `api` (Dashboard path) → NO warning field", async () => {
186
+ seed();
187
+ const tools = buildHandlers();
188
+ const sendReply = tools["send_reply"];
189
+
190
+ const taskId = makeTask("api");
191
+ const reply = await call(sendReply, {
192
+ alias: "api",
193
+ text: "dashboard reply via api",
194
+ in_reply_to: taskId,
195
+ status: "replied",
196
+ from_session: AGENT_ALIAS,
197
+ });
198
+
199
+ expect(reply.ok).toBe(true);
200
+ expect(reply.warning).toBeUndefined();
201
+ });
202
+ });