@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.140

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 (341) 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-department-http.test.ts +148 -0
  16. package/src/agent-groups-perf.test.ts +223 -0
  17. package/src/agent-notice.ts +48 -0
  18. package/src/agent-team-whoami-http.test.ts +98 -0
  19. package/src/agent-teams-http.test.ts +323 -0
  20. package/src/agent-teams.ts +244 -0
  21. package/src/alias-filter.test.ts +72 -0
  22. package/src/alias-filter.ts +42 -0
  23. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  24. package/src/api-host-supervisors-fallback.test.ts +271 -0
  25. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  26. package/src/api-nodes-shape.test.ts +26 -10
  27. package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
  28. package/src/auth-sessions-http.test.ts +250 -0
  29. package/src/auth-validate.test.ts +5 -1
  30. package/src/auth-weak-password-login.test.ts +176 -0
  31. package/src/auth.ts +604 -51
  32. package/src/avatar-validate.test.ts +163 -0
  33. package/src/avatar-validate.ts +103 -0
  34. package/src/binding-generation-http.test.ts +121 -0
  35. package/src/blocked-exit-on-reply.test.ts +122 -0
  36. package/src/boot-side-effect.test.ts +124 -0
  37. package/src/chain-attempt-dedup.test.ts +447 -0
  38. package/src/clone-node.test.ts +248 -0
  39. package/src/clone-node.ts +289 -0
  40. package/src/codex-config-flags.ts +77 -0
  41. package/src/codex-fork-contract.ts +23 -0
  42. package/src/codex-fork-migration.test.ts +38 -0
  43. package/src/commhub-bin-args.test.ts +85 -0
  44. package/src/config-apply-sec1.test.ts +107 -0
  45. package/src/config-apply-validate.test.ts +134 -1
  46. package/src/config-apply-validate.ts +111 -5
  47. package/src/create-node-blocked-gate.test.ts +356 -0
  48. package/src/create-node-network-id.test.ts +79 -0
  49. package/src/create-node-tool-schema.test.ts +49 -0
  50. package/src/create-node-unicode-name.test.ts +120 -0
  51. package/src/create-node-validate.test.ts +199 -7
  52. package/src/create-node-validate.ts +232 -15
  53. package/src/create-node-workdir.test.ts +289 -0
  54. package/src/create-node.ts +88 -23
  55. package/src/daemon-provider-snapshots.test.ts +421 -0
  56. package/src/daemon-provider-snapshots.ts +327 -0
  57. package/src/daemon-token-owner-http.test.ts +236 -0
  58. package/src/dashboard-slash-routing-http.test.ts +109 -0
  59. package/src/db-adapter-guard.test.ts +148 -0
  60. package/src/db-adapter-translate.test.ts +128 -0
  61. package/src/db-adapter.ts +323 -87
  62. package/src/db-timestamp.test.ts +38 -0
  63. package/src/db-timestamp.ts +15 -0
  64. package/src/db.ts +1111 -42
  65. package/src/delivered-without-push.test.ts +140 -0
  66. package/src/department-groups-http.test.ts +209 -0
  67. package/src/department-groups-sync-http.test.ts +328 -0
  68. package/src/department-groups.ts +434 -0
  69. package/src/department-heads-http.test.ts +330 -0
  70. package/src/department-heads.ts +226 -0
  71. package/src/departments-http.test.ts +185 -0
  72. package/src/departments.ts +276 -0
  73. package/src/desktop-message-seam.test.ts +150 -0
  74. package/src/desktop-message.test.ts +368 -0
  75. package/src/dm-file-scope-http.test.ts +178 -0
  76. package/src/env-transport-gate-http.test.ts +146 -0
  77. package/src/env-transport-gate.ts +74 -0
  78. package/src/env-transport-policy.ts +21 -0
  79. package/src/ext-token-shared.test.ts +60 -0
  80. package/src/ext-validation-regression.test.ts +131 -0
  81. package/src/external-schedule-edits-http.test.ts +321 -0
  82. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  83. package/src/external-schedule-edits.ts +327 -0
  84. package/src/file-download-authz-dev-open.test.ts +135 -0
  85. package/src/file-download-authz.test.ts +404 -0
  86. package/src/file-download-date-bucket.test.ts +436 -0
  87. package/src/file-network-scope-dev-open.test.ts +97 -0
  88. package/src/file-network-scope.test.ts +732 -0
  89. package/src/group-membership-events-http.test.ts +264 -0
  90. package/src/group-messages-http.test.ts +482 -0
  91. package/src/group-messages.ts +297 -0
  92. package/src/health-redaction.test.ts +642 -0
  93. package/src/host-supervisor-identity-http.test.ts +152 -0
  94. package/src/host-supervisor-identity.ts +92 -0
  95. package/src/http-gzip.test.ts +94 -0
  96. package/src/http-gzip.ts +112 -0
  97. package/src/hub-catalog-read-http.test.ts +187 -0
  98. package/src/hub-provider-config.test.ts +139 -0
  99. package/src/hub-provider-config.ts +105 -0
  100. package/src/hub-timestamp.test.ts +38 -0
  101. package/src/hub-timestamp.ts +27 -0
  102. package/src/human-dm.ts +215 -0
  103. package/src/human-inbox.ts +42 -0
  104. package/src/inbox-caller.ts +49 -0
  105. package/src/inbox-count-sse.test.ts +310 -0
  106. package/src/inbox-count.ts +29 -0
  107. package/src/index.ts +15 -2362
  108. package/src/lifecycle-guard.ts +1 -1
  109. package/src/list-host-supervisors.test.ts +5 -4
  110. package/src/mcp-context-cost-http.test.ts +123 -0
  111. package/src/mcp-projects-events-http.test.ts +162 -0
  112. package/src/mcp-route-label-http.test.ts +120 -0
  113. package/src/mcp-write-network-resolution.test.ts +432 -0
  114. package/src/member-audit-network-id-http.test.ts +93 -0
  115. package/src/member-presence-http.test.ts +210 -0
  116. package/src/messages-ack-by-agent-http.test.ts +142 -0
  117. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  118. package/src/migrate-sqlite-to-pg.ts +263 -0
  119. package/src/model-auth-notify-http.test.ts +181 -0
  120. package/src/model-auth-notify.ts +116 -0
  121. package/src/network-name-http.test.ts +75 -0
  122. package/src/network-scope.ts +276 -0
  123. package/src/network-token-membership-http.test.ts +139 -0
  124. package/src/node-alias-control.ts +12 -0
  125. package/src/node-attrs-validate.ts +88 -0
  126. package/src/node-attrs.test.ts +217 -0
  127. package/src/node-caller-identity-http.test.ts +1083 -0
  128. package/src/node-create-request-status-http.test.ts +105 -0
  129. package/src/node-daemon-bindings.test.ts +132 -0
  130. package/src/node-daemon-bindings.ts +135 -0
  131. package/src/node-files-transport.test.ts +344 -0
  132. package/src/node-health-codex-login.test.ts +120 -0
  133. package/src/node-health-dispatch-http.test.ts +279 -0
  134. package/src/node-health-guard.ts +105 -0
  135. package/src/node-health-passthrough.test.ts +115 -0
  136. package/src/node-health-store.ts +101 -0
  137. package/src/node-id-alias.test.ts +190 -0
  138. package/src/node-identity-conflict-http.test.ts +269 -0
  139. package/src/node-identity-conflict.ts +142 -0
  140. package/src/node-identity-mismatch.test.ts +74 -0
  141. package/src/node-lifecycle-controllable.test.ts +64 -0
  142. package/src/node-lifecycle-controllable.ts +53 -0
  143. package/src/node-lifecycle-read-http.test.ts +245 -0
  144. package/src/node-lifecycle-read.ts +121 -0
  145. package/src/node-logs-transport.test.ts +263 -0
  146. package/src/node-permissions-http.test.ts +276 -0
  147. package/src/node-permissions.ts +285 -0
  148. package/src/node-request-retention.test.ts +281 -0
  149. package/src/node-request-retention.ts +120 -0
  150. package/src/node-secret-rest-http.test.ts +190 -0
  151. package/src/node-skills-transport.test.ts +209 -0
  152. package/src/node-token-identity.ts +35 -0
  153. package/src/node-token-ownership.ts +51 -0
  154. package/src/node-token-privilege-http.test.ts +158 -0
  155. package/src/observer-avatar-http.test.ts +442 -0
  156. package/src/observer-push.test.ts +252 -0
  157. package/src/opencode-v2-catalog.test.ts +268 -0
  158. package/src/opencode-v2-catalog.ts +352 -0
  159. package/src/package-version-consistency.test.ts +25 -0
  160. package/src/password-network-tokens.ts +25 -0
  161. package/src/peer-reply-atomic.test.ts +455 -0
  162. package/src/peer-reply-capability.test.ts +70 -0
  163. package/src/pg-worker.ts +87 -0
  164. package/src/project-dir-warn-clear-http.test.ts +132 -0
  165. package/src/project-viewer-can-http.test.ts +114 -0
  166. package/src/push.ts +429 -10
  167. package/src/redact-tokens.test.ts +65 -0
  168. package/src/redact-tokens.ts +40 -0
  169. package/src/rename.ts +38 -10
  170. package/src/reply-target-derivation.test.ts +96 -0
  171. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  172. package/src/report-status-host-schema.test.ts +235 -0
  173. package/src/report-status-resume-id-handover.test.ts +184 -0
  174. package/src/report-status-secret-keys.test.ts +140 -0
  175. package/src/require-explicit-test-db.ts +9 -0
  176. package/src/requirement-due-reminders-http.test.ts +620 -0
  177. package/src/requirement-due-reminders.ts +416 -0
  178. package/src/requirement-events-http.test.ts +268 -0
  179. package/src/requirement-events.test.ts +45 -0
  180. package/src/requirement-events.ts +144 -0
  181. package/src/requirement-notify.ts +165 -0
  182. package/src/requirement-participant-notify-http.test.ts +309 -0
  183. package/src/requirement-tag-ops-http.test.ts +266 -0
  184. package/src/requirement-tags.ts +89 -0
  185. package/src/requirements-abandoned-http.test.ts +236 -0
  186. package/src/requirements-comment-http.test.ts +167 -0
  187. package/src/requirements-completed-at-migration.test.ts +70 -0
  188. package/src/requirements-errors-mcp-http.test.ts +164 -0
  189. package/src/requirements-errors.ts +143 -0
  190. package/src/requirements-etag-http.test.ts +91 -0
  191. package/src/requirements-http.test.ts +709 -0
  192. package/src/requirements-last-event-http.test.ts +349 -0
  193. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  194. package/src/requirements-list-slim-http.test.ts +319 -0
  195. package/src/requirements-mcp.test.ts +163 -0
  196. package/src/requirements-migrate.ts +259 -0
  197. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  198. package/src/requirements-people-http.test.ts +114 -0
  199. package/src/requirements-people-mcp-http.test.ts +225 -0
  200. package/src/requirements-people.ts +131 -0
  201. package/src/requirements-priority-lowest.test.ts +114 -0
  202. package/src/requirements-search-http.test.ts +182 -0
  203. package/src/requirements-search.test.ts +32 -0
  204. package/src/requirements-search.ts +145 -0
  205. package/src/requirements-seq-http.test.ts +193 -0
  206. package/src/requirements-seq-migration.test.ts +63 -0
  207. package/src/requirements-start-date.test.ts +125 -0
  208. package/src/requirements-stats-http.test.ts +357 -0
  209. package/src/requirements-stats.ts +195 -0
  210. package/src/requirements-status-participants-http.test.ts +222 -0
  211. package/src/requirements-upgrade-e2e.test.ts +126 -0
  212. package/src/requirements-upgrade-migration.test.ts +98 -0
  213. package/src/requirements.ts +1806 -0
  214. package/src/resolve-port.test.ts +57 -0
  215. package/src/resolve-port.ts +44 -0
  216. package/src/response-charset.test.ts +130 -0
  217. package/src/rest-explicit-columns-http.test.ts +222 -0
  218. package/src/rest-identity.test.ts +130 -0
  219. package/src/rest-identity.ts +141 -0
  220. package/src/rest-projections.ts +70 -0
  221. package/src/rest-write-network-resolution.test.ts +133 -0
  222. package/src/restricted-files.ts +76 -0
  223. package/src/retention.ts +15 -3
  224. package/src/route-callers-http.test.ts +120 -0
  225. package/src/route-timing.test.ts +211 -0
  226. package/src/route-timing.ts +258 -0
  227. package/src/rules-file-duplicate-alias.test.ts +234 -0
  228. package/src/rules-file-session-target.test.ts +207 -0
  229. package/src/rules-file-transport.test.ts +237 -0
  230. package/src/runtime-label.test.ts +22 -0
  231. package/src/runtime-label.ts +21 -0
  232. package/src/runtime-readiness.ts +90 -0
  233. package/src/schedule-agent-mcp-http.test.ts +381 -0
  234. package/src/schedule-agent.ts +192 -0
  235. package/src/scheduled-backoff-http.test.ts +193 -0
  236. package/src/scheduled-backoff.ts +64 -0
  237. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  238. package/src/scheduled-failures-http.test.ts +233 -0
  239. package/src/scheduled-failures.ts +179 -0
  240. package/src/scheduled-offline.test.ts +131 -0
  241. package/src/scheduled-offline.ts +66 -0
  242. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  243. package/src/scheduled-outcome-guidance.ts +9 -0
  244. package/src/scheduled-reply-unread-http.test.ts +212 -0
  245. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  246. package/src/scheduled-requirement-wake.ts +125 -0
  247. package/src/scheduled-run-terminal.test.ts +268 -0
  248. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  249. package/src/scheduled-stuck-http.test.ts +312 -0
  250. package/src/scheduled-stuck.ts +186 -0
  251. package/src/scheduled-tasks-http.test.ts +638 -0
  252. package/src/scheduled-tasks.ts +819 -0
  253. package/src/scheduled-work-outcome-http.test.ts +154 -0
  254. package/src/scheduled-work-outcome.ts +50 -0
  255. package/src/scoped-secrets.ts +139 -0
  256. package/src/send-dedup-request-id.test.ts +229 -0
  257. package/src/send-reply-agent-warning.test.ts +202 -0
  258. package/src/send-reply-alias-optional.test.ts +131 -0
  259. package/src/send-reply-attachments.test.ts +458 -0
  260. package/src/send_dedup.test.ts +54 -0
  261. package/src/send_dedup.ts +52 -6
  262. package/src/serve-error.test.ts +38 -0
  263. package/src/serve-error.ts +27 -0
  264. package/src/server.ts +5398 -0
  265. package/src/session-task-on-dispatch.ts +14 -0
  266. package/src/session-token-ownership-http.test.ts +122 -0
  267. package/src/shared/codex-inventory.ts +41 -0
  268. package/src/shared/daemon-provider-login.ts +31 -0
  269. package/src/shared/daemon-provider-snapshot.ts +52 -0
  270. package/src/shared/external-schedule-contract.ts +60 -0
  271. package/src/shared/node-name.test.ts +62 -0
  272. package/src/shared/node-name.ts +161 -0
  273. package/src/shared/provider-application.ts +29 -0
  274. package/src/shared/provider-collection.ts +14 -0
  275. package/src/shared/provider-initialization.ts +8 -0
  276. package/src/shared/reserved-env-drift.test.ts +10 -0
  277. package/src/shared/scoped-secret-contract.ts +5 -0
  278. package/src/shared/task-content-limit-drift.test.ts +28 -0
  279. package/src/shared/task-content-limit.ts +25 -0
  280. package/src/side-thread-command-transport.test.ts +312 -0
  281. package/src/side-thread-command-transport.ts +318 -0
  282. package/src/side-thread-http-integration.test.ts +233 -0
  283. package/src/side-thread-http.ts +504 -0
  284. package/src/side-thread-production.test.ts +72 -0
  285. package/src/side-thread-production.ts +118 -0
  286. package/src/side-thread.test.ts +1067 -0
  287. package/src/side-thread.ts +2499 -0
  288. package/src/skillhub-http.test.ts +108 -0
  289. package/src/sqlite-close-checkpoint.test.ts +78 -0
  290. package/src/start-node.test.ts +264 -0
  291. package/src/status-alias-filter-http.test.ts +106 -0
  292. package/src/status-alias-resolver-http.test.ts +163 -0
  293. package/src/status-alias-resolver.ts +19 -0
  294. package/src/status-node-id-filter-http.test.ts +161 -0
  295. package/src/status-read-cache-http.test.ts +229 -0
  296. package/src/status-read-cache.ts +122 -0
  297. package/src/stop-delete-node.test.ts +509 -2
  298. package/src/task-access-http.test.ts +402 -0
  299. package/src/task-access.ts +420 -0
  300. package/src/task-activity.ts +60 -0
  301. package/src/task-auth-origin-http.test.ts +93 -0
  302. package/src/task-auth-origin.test.ts +24 -0
  303. package/src/task-auth-origin.ts +7 -0
  304. package/src/task-consumption.test.ts +482 -0
  305. package/src/task-diagnostic-http.test.ts +149 -0
  306. package/src/task-diagnostic.test.ts +115 -0
  307. package/src/task-diagnostic.ts +118 -0
  308. package/src/task-expiry-notice-http.test.ts +287 -0
  309. package/src/task-expiry-notice.ts +315 -0
  310. package/src/task-idempotency-mcp.test.ts +142 -0
  311. package/src/task-idempotency.test.ts +60 -0
  312. package/src/task-idempotency.ts +75 -0
  313. package/src/task-late-replies.ts +54 -0
  314. package/src/task-late-reply-http.test.ts +152 -0
  315. package/src/task-lifecycle-watcher.test.ts +204 -0
  316. package/src/task-lifecycle-watcher.ts +49 -0
  317. package/src/task-network-resolution-http.test.ts +140 -0
  318. package/src/task-orphan-http.test.ts +287 -0
  319. package/src/task-orphan.ts +119 -0
  320. package/src/task-queue-ahead-http.test.ts +309 -0
  321. package/src/task-queue-ahead-scale.test.ts +236 -0
  322. package/src/task-queue-ahead.ts +186 -0
  323. package/src/task-rejection-log-http.test.ts +160 -0
  324. package/src/task-rejection-log.ts +49 -0
  325. package/src/task-sender-http.test.ts +143 -0
  326. package/src/task-stale-open-http.test.ts +290 -0
  327. package/src/task-stale-open.ts +103 -0
  328. package/src/tasks-pagination-http.test.ts +141 -0
  329. package/src/tool-audience-http.test.ts +220 -0
  330. package/src/tool-audience.ts +118 -0
  331. package/src/tools-list-json-schema.test.ts +67 -0
  332. package/src/tools.ts +4008 -355
  333. package/src/upload-early-reject-guard.test.ts +155 -0
  334. package/src/uploads-http.test.ts +97 -15
  335. package/src/uploads.ts +103 -6
  336. package/src/user-avatar-http.test.ts +74 -0
  337. package/src/user-avatar-migration.test.ts +25 -0
  338. package/src/user-inbox-by-agent-http.test.ts +161 -0
  339. package/src/user-inbox-netid-notnull.test.ts +122 -0
  340. package/src/user-inbox-read-http.test.ts +223 -0
  341. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,154 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createNetworkTokenForNode, register } from "./auth.js";
3
+ import { db, syncScheduledRunForTask } from "./db.js";
4
+ import { runDueScheduledTasks } from "./scheduled-tasks.js";
5
+
6
+ if (!process.env.COMMHUB_DB) throw new Error("test1274 requires an explicit disposable SQLite COMMHUB_DB");
7
+ let server: any, base = "", network = "", ownerToken = "", ownerId = "";
8
+ const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
9
+ async function rest(token: string, method: string, path: string, body?: unknown) {
10
+ const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
11
+ "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
12
+ return { status: r.status, body: await r.json() as any };
13
+ }
14
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
15
+ const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
16
+ "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
17
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
18
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
19
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
20
+ try { return JSON.parse(envelope.result.content[0].text); }
21
+ catch { return { ok: false, transportError: true }; }
22
+ }
23
+ const readRun = (taskId: string) => db.get<any>(
24
+ "SELECT run_id, schedule_id, status, work_outcome, work_outcome_node_id, schedule_revision FROM scheduled_task_runs WHERE task_id = ?1 AND network_id = ?2", taskId, network)!;
25
+ const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
26
+ const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
27
+ async function fire() {
28
+ const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
29
+ name: `test1274-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "fixture1274 work",
30
+ schedule: { type: "interval", every_seconds: 120 }, timezone: "UTC" });
31
+ expect(created.status).toBe(201);
32
+ const scheduleId = created.body.schedule.schedule_id;
33
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
34
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
35
+ await runDueScheduledTasks();
36
+ const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
37
+ expect(run.task_id).toBeTruthy();
38
+ return { taskId: run.task_id as string, scheduleId };
39
+ }
40
+ const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
41
+ mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
42
+ const cadence = (id: string) => db.get<any>("SELECT next_run_at, last_run_at, revision, schedule_json, target_node_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
43
+
44
+ beforeAll(async () => {
45
+ const owner = register(`work_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
46
+ network = owner.network_id!; ownerId = owner.user!.user_id;
47
+ for (const key of ["a", "b"]) {
48
+ const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
49
+ const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
50
+ nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
51
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
52
+ }
53
+ const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
54
+ base = `http://127.0.0.1:${server.port}`;
55
+ }, 30_000);
56
+ afterAll(() => server?.stop?.(true));
57
+
58
+ describe("#1274 exact successful scheduler work outcomes over real HTTP/MCP", () => {
59
+ test("explicit no_changes persists with the same successful run and is readable; cadence stays unchanged", async () => {
60
+ const { taskId, scheduleId } = await fire(), before = cadence(scheduleId);
61
+ expect(readRun(taskId).schedule_revision).toBe(before.revision);
62
+ const out = await reply(taskId, { scheduled_work_outcome: "no_changes", scheduled_run_id: "forged-and-ignored" });
63
+ expect(out.ok).toBe(true); expect(out.scheduled_work_outcome).toBe("no_changes");
64
+ expect(readTask(taskId).status).toBe("replied");
65
+ expect(readRun(taskId)).toMatchObject({ status: "replied", work_outcome: "no_changes", work_outcome_node_id: nodes.a.id });
66
+ expect(cadence(scheduleId)).toEqual(before);
67
+ const history = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}/runs`);
68
+ expect(history.status).toBe(200);
69
+ expect(history.body.runs[0]).toMatchObject({ task_id: taskId, work_outcome: "no_changes", schedule_revision: before.revision, work_outcome_node_id: nodes.a.id });
70
+ });
71
+ test("changed is explicit even when the reply's natural-language text says no changes", async () => {
72
+ const { taskId } = await fire(); expect((await reply(taskId, { scheduled_work_outcome: "changed" })).ok).toBe(true);
73
+ expect(readRun(taskId).work_outcome).toBe("changed");
74
+ });
75
+ test("legacy success and empty-looking text remain unknown with unchanged response shape", async () => {
76
+ const { taskId } = await fire(), out = await reply(taskId);
77
+ expect(out.ok).toBe(true); expect(out.scheduled_work_outcome).toBeUndefined();
78
+ expect(readRun(taskId)).toMatchObject({ status: "replied", work_outcome: null, work_outcome_node_id: null });
79
+ });
80
+ test("malformed, failed and cancelled signals do not write; ordinary terminal failures stay unknown", async () => {
81
+ for (const [status, value] of [["replied", null], ["replied", "empty"], ["failed", "no_changes"], ["cancelled", "no_changes"]]) {
82
+ const { taskId } = await fire(); expect((await reply(taskId, { scheduled_work_outcome: value }, status!)).ok).toBe(false);
83
+ expect(readTask(taskId).status).toBe("delivered"); expect(countReplies(taskId)).toBe(0);
84
+ expect((await reply(taskId, undefined, status!)).ok).toBe(true); expect(readRun(taskId).work_outcome).toBeNull();
85
+ }
86
+ });
87
+ test("sibling node and a human token cannot classify the assigned node's run", async () => {
88
+ const { taskId } = await fire();
89
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", nodes.b.token)).ok).toBe(false);
90
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", ownerToken)).ok).toBe(false);
91
+ expect(readTask(taskId).status).toBe("delivered"); expect(readRun(taskId).work_outcome).toBeNull(); expect(countReplies(taskId)).toBe(0);
92
+ });
93
+ test("a foreign network token cannot classify or terminalize the run", async () => {
94
+ const { taskId } = await fire(), foreign = register(`work_foreign_${crypto.randomUUID()}`, "Fixture1274Only!x");
95
+ const token = createNetworkTokenForNode(foreign.user!.user_id, foreign.network_id!, `foreign-${crypto.randomUUID()}`, `n_${crypto.randomUUID()}`);
96
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", token.token!)).ok).toBe(false);
97
+ expect(readTask(taskId).status).toBe("delivered"); expect(countReplies(taskId)).toBe(0);
98
+ });
99
+ test("a legacy unbound node token can still reply normally but cannot report a work signal", async () => {
100
+ const { taskId } = await fire();
101
+ db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1 AND network_id = ?2", [nodes.a.tokenId, network]);
102
+ try {
103
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(false);
104
+ expect((await reply(taskId)).ok).toBe(true); expect(readRun(taskId).work_outcome).toBeNull();
105
+ } finally { db.run("UPDATE api_tokens SET bound_node_id = ?1 WHERE token_id = ?2 AND network_id = ?3", [nodes.a.id, nodes.a.tokenId, network]); }
106
+ });
107
+ test("forged schedule metadata on an ordinary task cannot become an authoritative occurrence", async () => {
108
+ const { scheduleId } = await fire(); const sent = await rest(ownerToken, "POST", "/api/task", { network_id: network, alias: nodes.a.alias, task: "ordinary1274" });
109
+ expect(sent.status).toBe(200);
110
+ expect((await reply(sent.body.task_id, { scheduled_work_outcome: "no_changes", scheduled_task_id: scheduleId })).ok).toBe(false);
111
+ expect(readTask(sent.body.task_id).status).toBe("delivered"); expect(countReplies(sent.body.task_id)).toBe(0);
112
+ });
113
+ test("multiple authoritative bindings refuse instead of fanning one outcome into two runs", async () => {
114
+ const { taskId, scheduleId } = await fire(), run = readRun(taskId), duplicate = `srun_dup_${crypto.randomUUID()}`;
115
+ db.run("INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, task_id, status) VALUES (?1, ?2, ?3, ?4, ?5, 'delivered')", [duplicate, scheduleId, network, new Date(Date.now() + 86400_000).toISOString(), taskId]);
116
+ try { expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).error).toBe("scheduled_work_outcome_run_unresolved");
117
+ expect(readTask(taskId).status).toBe("delivered"); expect(countReplies(taskId)).toBe(0); }
118
+ finally { db.run("DELETE FROM scheduled_task_runs WHERE run_id = ?1", duplicate); }
119
+ expect(readRun(taskId).run_id).toBe(run.run_id); expect(readRun(taskId).work_outcome).toBeNull();
120
+ });
121
+ test("expired/late receipts never classify a run or rewrite its original terminal state", async () => {
122
+ const { taskId } = await fire(), thread_id = `thread_${crypto.randomUUID()}`, turn_id = `turn_${crypto.randomUUID()}`;
123
+ db.run("UPDATE tasks SET status = 'expired', thread_id = ?1, turn_id = ?2, completed_at = datetime('now') WHERE task_id = ?3", [thread_id, turn_id, taskId]); syncScheduledRunForTask(taskId, network);
124
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", nodes.a.token, { thread_id, turn_id })).error).toBe("scheduled_work_outcome_terminal");
125
+ expect((await reply(taskId, undefined, "replied", nodes.a.token, { thread_id, turn_id })).late).toBe(true);
126
+ expect(readTask(taskId).status).toBe("expired"); expect(readRun(taskId)).toMatchObject({ status: "expired", work_outcome: null });
127
+ });
128
+ test("success cannot retry; an expired fixture task clears stale outcome on retry and later failure", async () => {
129
+ const { taskId } = await fire(); expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
130
+ expect((await mcp(ownerToken, "retry_task", { task_id: taskId, network_id: network })).ok).toBe(false);
131
+ // Fixture-only legacy/corrupt terminal injection: preserve the stale run
132
+ // signal deliberately so the retry clearing branch is load-bearing.
133
+ db.run("UPDATE tasks SET status = 'expired' WHERE task_id = ?1", taskId);
134
+ expect((await mcp(ownerToken, "retry_task", { task_id: taskId, network_id: network })).ok).toBe(true);
135
+ expect(readRun(taskId)).toMatchObject({ status: "delivered", work_outcome: null, work_outcome_node_id: null });
136
+ expect((await reply(taskId, undefined, "failed")).ok).toBe(true); expect(readRun(taskId)).toMatchObject({ status: "failed", work_outcome: null });
137
+ });
138
+ test("injected outcome write failure rolls back task, inbox and run atomically", async () => {
139
+ const { taskId } = await fire(), run = readRun(taskId);
140
+ db.exec(`CREATE TRIGGER test1274_outcome_fault BEFORE UPDATE OF work_outcome ON scheduled_task_runs WHEN NEW.run_id = '${run.run_id}' AND NEW.work_outcome IS NOT NULL BEGIN SELECT RAISE(ABORT, 'test1274_fault'); END`);
141
+ try { expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).not.toBe(true); }
142
+ finally { db.exec("DROP TRIGGER test1274_outcome_fault"); }
143
+ expect(readTask(taskId)).toMatchObject({ status: "delivered", result: null });
144
+ expect(readRun(taskId)).toMatchObject({ status: "delivered", work_outcome: null }); expect(countReplies(taskId)).toBe(0);
145
+ expect((await reply(taskId, { scheduled_work_outcome: "changed" })).ok).toBe(true);
146
+ });
147
+ test("a later schedule edit cannot relabel the dispatch revision; history alone grants no backoff", async () => {
148
+ const { taskId, scheduleId } = await fire(), revision = readRun(taskId).schedule_revision;
149
+ const edit = await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision, name: "edited1274" }); expect(edit.status).toBe(200);
150
+ const before = cadence(scheduleId); expect(before.revision).toBe(revision + 1);
151
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
152
+ expect(readRun(taskId).schedule_revision).toBe(revision); expect(cadence(scheduleId)).toEqual(before);
153
+ });
154
+ });
@@ -0,0 +1,50 @@
1
+ import { db } from "./db.js";
2
+
3
+ export type ScheduledWorkOutcome = "changed" | "no_changes";
4
+ export function parseScheduledWorkOutcome(meta: unknown, status: string):
5
+ { ok: true; outcome?: ScheduledWorkOutcome } | { ok: false; error: string } {
6
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)
7
+ || !Object.hasOwn(meta, "scheduled_work_outcome")) return { ok: true };
8
+ const outcome = (meta as Record<string, unknown>).scheduled_work_outcome;
9
+ if (outcome !== "changed" && outcome !== "no_changes")
10
+ return { ok: false, error: "invalid_scheduled_work_outcome" };
11
+ if (status !== "replied") return { ok: false, error: "scheduled_work_outcome_requires_success" };
12
+ return { ok: true, outcome };
13
+ }
14
+
15
+ type Binding = { run_id: string; schedule_id: string; task_id: string; network_id: string; node_id: string };
16
+ /** Called within the normal reply transaction, after the existing sender and
17
+ * recipient checks. Caller metadata never supplies schedule/run bindings. */
18
+ export function qualifyScheduledWorkOutcome(taskId: string, networkId: string, nodeId: string | null):
19
+ { ok: true; binding: Binding } | { ok: false; error: string } {
20
+ if (!nodeId) return { ok: false, error: "scheduled_work_outcome_bound_node_required" };
21
+ if (db.transactionalFeaturesRefusal !== null)
22
+ return { ok: false, error: "scheduled_work_outcome_backend_unsupported" };
23
+ const task = db.get<{ to_node_id: string | null; from_name: string; status: string }>(
24
+ "SELECT to_node_id, from_name, status FROM tasks WHERE task_id = ?1 AND network_id = ?2", taskId, networkId);
25
+ if (!task || task.to_node_id !== nodeId || task.from_name !== "scheduler"
26
+ || !["created", "delivered", "acked", "running"].includes(task.status))
27
+ return { ok: false, error: "scheduled_work_outcome_task_not_owned" };
28
+ const runs = db.all<{ run_id: string; schedule_id: string; status: string; work_outcome: string | null }>(
29
+ `SELECT r.run_id, r.schedule_id, r.status, r.work_outcome FROM scheduled_task_runs r
30
+ WHERE r.task_id = ?1 AND r.network_id = ?2`, taskId, networkId);
31
+ if (runs.length !== 1 || !["queued", "delivered"].includes(runs[0].status) || runs[0].work_outcome !== null)
32
+ return { ok: false, error: "scheduled_work_outcome_run_unresolved" };
33
+ if (!db.get("SELECT schedule_id FROM scheduled_tasks WHERE schedule_id = ?1 AND network_id = ?2", runs[0].schedule_id, networkId))
34
+ return { ok: false, error: "scheduled_work_outcome_run_unresolved" };
35
+ return { ok: true, binding: { run_id: runs[0].run_id, schedule_id: runs[0].schedule_id,
36
+ task_id: taskId, network_id: networkId, node_id: nodeId } };
37
+ }
38
+
39
+ /** The task has just reached replied in this same transaction. An outcome
40
+ * write failure must roll back task, inbox and run together. No cadence write. */
41
+ export function saveScheduledWorkOutcome(binding: Binding, outcome: ScheduledWorkOutcome): void {
42
+ const result = db.run(
43
+ `UPDATE scheduled_task_runs SET work_outcome = ?1, work_outcome_node_id = ?6
44
+ WHERE run_id = ?2 AND schedule_id = ?3 AND task_id = ?4 AND network_id = ?5
45
+ AND work_outcome IS NULL AND status IN ('queued', 'delivered')
46
+ AND EXISTS (SELECT 1 FROM tasks t WHERE t.task_id = ?4 AND t.network_id = ?5
47
+ AND t.to_node_id = ?6 AND t.from_name = 'scheduler' AND t.status = 'replied')`,
48
+ [outcome, binding.run_id, binding.schedule_id, binding.task_id, binding.network_id, binding.node_id]);
49
+ if (result.changes !== 1) throw new Error("scheduled_work_outcome_atomic_write_failed");
50
+ }
@@ -0,0 +1,139 @@
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
+ import { observeSecretWrite, transportOf } from './env-transport-gate.js';
8
+ import { refusesPlainTransport } from './env-transport-policy.js';
9
+ type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
10
+ type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
11
+ type Request = { request_id: string; network_id: string; daemon_node_id: string | null; node_id: string | null;
12
+ action: 'list' | 'put' | 'delete'; key?: string; value?: string; revision?: number;
13
+ status: 'pending' | 'succeeded' | 'failed' | 'expired'; expires_at: number; snapshot?: any; error?: string };
14
+ const requests = new Map<string, Request>();
15
+ const denied = (error: string) => ({ ok: false as const, error });
16
+ function authorize(h: Human) {
17
+ return h.isNode || !h.userId ? denied('user_token_required') : !h.networkId
18
+ || !['owner','admin'].includes(getUserNetworkRole(h.userId, h.networkId) ?? '') ? denied('secret_admin_required') : null;
19
+ }
20
+ function daemonSupported(network: string, daemon: string) {
21
+ const row = db.get<{ config_snapshot: string }>('SELECT config_snapshot FROM nodes WHERE network_id=?1 AND node_id=?2', network, daemon);
22
+ try { const s = JSON.parse(row?.config_snapshot ?? '{}'); return s.role === 'host_supervisor' && s.scoped_secrets_capable === true; } catch { return false; }
23
+ }
24
+ function expire() {
25
+ for (const [id,r] of requests) {
26
+ if (r.expires_at < Date.now() && r.status === 'pending') { r.status = 'expired'; delete r.value; }
27
+ if (r.expires_at + 600_000 < Date.now()) requests.delete(id);
28
+ }
29
+ }
30
+ const timer = setInterval(expire, 10_000); timer.unref();
31
+ function nodeSupported(network: string, node: string) {
32
+ const row = db.get<{ config_snapshot: string }>('SELECT config_snapshot FROM nodes WHERE network_id=?1 AND node_id=?2', network, node);
33
+ try { return JSON.parse(row?.config_snapshot ?? '{}').node_secrets_capable === true; } catch { return false; }
34
+ }
35
+ // Independent node lane: no supervisor lookup, binding, or Daemon credential.
36
+ export function requestNodeSecrets(h: Human, node: string, action: Request['action'], key?: string, value?: string, revision?: number) {
37
+ const refusal = authorize(h); if (refusal) return refusal;
38
+ if (!nodeSupported(h.networkId!, node)) return denied('node_secrets_upgrade_required');
39
+ if (action !== 'list' && (!validScopedSecretKey(key) || !Number.isSafeInteger(revision) || revision! < 0
40
+ || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
41
+ expire();
42
+ // #1510 — transport gate (log-only by default). A `put` carries the secret, so
43
+ // that is the only action worth observing; delete/list leak nothing.
44
+ if (action === 'put') {
45
+ const transport = transportOf(h.networkId!, node);
46
+ if (refusesPlainTransport(transport)) return denied('secret_transport_plain');
47
+ observeSecretWrite(h.networkId!, node, `node_secret_${action}`);
48
+ }
49
+ 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');
50
+ if (requests.size >= 1000) return denied('secret_queue_busy');
51
+ const r: Request = { request_id: `nsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: null,
52
+ node_id: node, action, ...(action === 'list' ? {} : { key, revision, ...(action === 'put' ? { value } : {}) }),
53
+ expires_at: Date.now() + 120_000, status: 'pending' };
54
+ requests.set(r.request_id, r);
55
+ return { ok: true as const, request_id: r.request_id, status: r.status, expires_at: r.expires_at };
56
+ }
57
+ export function pullNodeSecrets(d: Daemon) {
58
+ if (!d.ok || !nodeSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_secret_node');
59
+ expire();
60
+ return { ok: true, requests: [...requests.values()].filter(r => r.network_id === d.networkId && r.daemon_node_id === null
61
+ && r.node_id === d.daemonNodeId && r.status === 'pending').map(({ status, snapshot, error, ...input }) => input) };
62
+ }
63
+ export function ackNodeSecrets(d: Daemon, id: string, snapshot: any, failed = false) {
64
+ if (!d.ok || !nodeSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_secret_node');
65
+ expire(); const r = requests.get(id);
66
+ if (!r || r.network_id !== d.networkId || r.daemon_node_id !== null || r.node_id !== d.daemonNodeId) return denied('request_not_found');
67
+ if (r.status !== 'pending') return { ok: r.status === 'succeeded' || r.status === 'failed', status: r.status };
68
+ if (!failed) {
69
+ if (!snapshot || Object.keys(snapshot).some(k => !['network_id','daemon_node_id','node_id','revision','activation','keys'].includes(k))
70
+ || snapshot.network_id !== r.network_id || snapshot.daemon_node_id !== null || snapshot.node_id !== r.node_id
71
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || snapshot.activation !== 'next_start'
72
+ || !Array.isArray(snapshot.keys) || snapshot.keys.length > 128 || new Set(snapshot.keys.map((x:any)=>x?.key)).size !== snapshot.keys.length
73
+ || snapshot.keys.some((x:any)=>!x || Object.keys(x).some(k=>!['key','source'].includes(k)) || !validScopedSecretKey(x.key) || x.source !== 'node')
74
+ || (r.action !== 'list' && snapshot.revision !== r.revision! + 1)
75
+ || (r.action === 'put' && !snapshot.keys.some((x:any)=>x.key === r.key))
76
+ || (r.action === 'delete' && snapshot.keys.some((x:any)=>x.key === r.key))) return denied('invalid_secret_receipt');
77
+ r.snapshot = snapshot;
78
+ }
79
+ r.status = failed ? 'failed' : 'succeeded'; if (failed) r.error = 'secret_operation_unavailable'; delete r.value;
80
+ return { ok: true, status: r.status };
81
+ }
82
+ export function requestScopedSecrets(h: Human, daemon: string, node: string | null, action: Request['action'], key?: string, value?: string, revision?: number) {
83
+ const refusal = authorize(h); if (refusal) return refusal;
84
+ if (!daemonSupported(h.networkId!, daemon)) return denied('scoped_secrets_upgrade_required');
85
+ if (node !== null && !providerScanTargets(h.networkId!, daemon).some(t => t.node_id === node)) return denied('secret_target_not_bound');
86
+ if (action !== 'list' && (!validScopedSecretKey(key)
87
+ || !Number.isSafeInteger(revision) || revision! < 0 || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
88
+ expire();
89
+ // #1510 — same observation on the daemon lane (the daemon applies the value).
90
+ if (action === 'put') {
91
+ const transport = transportOf(h.networkId!, daemon);
92
+ if (refusesPlainTransport(transport)) return denied('secret_transport_plain');
93
+ observeSecretWrite(h.networkId!, daemon, `secret_${action}`);
94
+ }
95
+ if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === daemon && r.status === 'pending')) return denied('secret_store_busy');
96
+ if (requests.size >= 1000) return denied('secret_queue_busy');
97
+ const r: Request = { request_id: `dsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: daemon,
98
+ node_id: node, action, ...(action === 'list' ? {} : { key, revision, ...(action === 'put' ? { value } : {}) }),
99
+ expires_at: Date.now() + 120_000, status: 'pending' };
100
+ requests.set(r.request_id, r);
101
+ return { ok: true as const, request_id: r.request_id, status: r.status, expires_at: r.expires_at };
102
+ }
103
+ export function getScopedSecrets(h: Human, id: string) {
104
+ const refusal = authorize(h); if (refusal) return refusal; expire();
105
+ const r = requests.get(id);
106
+ if (!r || r.network_id !== h.networkId) return denied('request_not_found');
107
+ return { ok: true as const, request_id: id, daemon_node_id: r.daemon_node_id, node_id: r.node_id,
108
+ status: r.status, ...(r.snapshot ? { snapshot: r.snapshot } : {}), ...(r.error ? { error: r.error } : {}) };
109
+ }
110
+ export function pullScopedSecrets(d: Daemon) {
111
+ if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
112
+ const targets = providerScanTargets(d.networkId, d.daemonNodeId);
113
+ return { ok: true, requests: [...requests.values()].filter(r => r.network_id === d.networkId && r.daemon_node_id === d.daemonNodeId && r.status === 'pending')
114
+ .flatMap(r => {
115
+ 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 []; }
116
+ const { status, snapshot, error, ...input } = r;
117
+ return [{ ...input, targets }];
118
+ }) };
119
+ }
120
+ export function ackScopedSecrets(d: Daemon, id: string, snapshot: any, failed = false) {
121
+ if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
122
+ const r = requests.get(id);
123
+ if (!r || r.network_id !== d.networkId || r.daemon_node_id !== d.daemonNodeId) return denied('request_not_found');
124
+ if (r.status !== 'pending') return { ok: r.status === 'succeeded' || r.status === 'failed', status: r.status };
125
+ if (!failed) {
126
+ if (!snapshot || Object.keys(snapshot).some(k => !['network_id','daemon_node_id','node_id','revision','activation','keys'].includes(k))
127
+ || snapshot.network_id !== r.network_id || snapshot.daemon_node_id !== r.daemon_node_id || snapshot.node_id !== r.node_id
128
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || snapshot.activation !== 'next_start'
129
+ || !Array.isArray(snapshot.keys) || snapshot.keys.length > 256 || new Set(snapshot.keys.map((x:any)=>x.key)).size !== snapshot.keys.length
130
+ || snapshot.keys.some((x:any) => !x || Object.keys(x).some(k=>!['key','source'].includes(k)) || !validScopedSecretKey(x.key)
131
+ || !['daemon','node'].includes(x.source) || (r.node_id === null && x.source !== 'daemon'))
132
+ || (r.action !== 'list' && snapshot.revision !== r.revision! + 1)
133
+ || (r.action === 'put' && !snapshot.keys.some((x:any)=>x.key === r.key && x.source === (r.node_id ? 'node' : 'daemon')))) return denied('invalid_secret_receipt');
134
+ 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');
135
+ r.snapshot = snapshot;
136
+ }
137
+ r.status = failed ? 'failed' : 'succeeded'; if (failed) r.error = 'secret_operation_unavailable'; delete r.value;
138
+ return { ok: true, status: r.status };
139
+ }
@@ -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
+ });