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

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 (304) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/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/codex-config-flags.ts +77 -0
  37. package/src/codex-fork-contract.ts +23 -0
  38. package/src/codex-fork-migration.test.ts +38 -0
  39. package/src/commhub-bin-args.test.ts +85 -0
  40. package/src/config-apply-sec1.test.ts +107 -0
  41. package/src/config-apply-validate.test.ts +134 -1
  42. package/src/config-apply-validate.ts +111 -5
  43. package/src/create-node-blocked-gate.test.ts +356 -0
  44. package/src/create-node-tool-schema.test.ts +48 -0
  45. package/src/create-node-unicode-name.test.ts +120 -0
  46. package/src/create-node-validate.test.ts +193 -7
  47. package/src/create-node-validate.ts +217 -15
  48. package/src/create-node-workdir.test.ts +289 -0
  49. package/src/create-node.ts +77 -23
  50. package/src/daemon-provider-snapshots.test.ts +256 -0
  51. package/src/daemon-provider-snapshots.ts +226 -0
  52. package/src/daemon-token-owner-http.test.ts +231 -0
  53. package/src/dashboard-slash-routing-http.test.ts +107 -0
  54. package/src/db-adapter-guard.test.ts +148 -0
  55. package/src/db-adapter-translate.test.ts +128 -0
  56. package/src/db-adapter.ts +323 -87
  57. package/src/db-timestamp.test.ts +38 -0
  58. package/src/db-timestamp.ts +15 -0
  59. package/src/db.ts +903 -35
  60. package/src/delivered-without-push.test.ts +140 -0
  61. package/src/department-groups-http.test.ts +209 -0
  62. package/src/department-groups-sync-http.test.ts +328 -0
  63. package/src/department-groups.ts +434 -0
  64. package/src/department-heads-http.test.ts +330 -0
  65. package/src/department-heads.ts +226 -0
  66. package/src/departments-http.test.ts +185 -0
  67. package/src/departments.ts +260 -0
  68. package/src/desktop-message-seam.test.ts +150 -0
  69. package/src/desktop-message.test.ts +368 -0
  70. package/src/dm-file-scope-http.test.ts +178 -0
  71. package/src/ext-token-shared.test.ts +60 -0
  72. package/src/ext-validation-regression.test.ts +131 -0
  73. package/src/external-schedule-edits-http.test.ts +321 -0
  74. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  75. package/src/external-schedule-edits.ts +327 -0
  76. package/src/file-download-authz-dev-open.test.ts +135 -0
  77. package/src/file-download-authz.test.ts +404 -0
  78. package/src/file-download-date-bucket.test.ts +436 -0
  79. package/src/file-network-scope-dev-open.test.ts +97 -0
  80. package/src/file-network-scope.test.ts +732 -0
  81. package/src/group-membership-events-http.test.ts +264 -0
  82. package/src/group-messages-http.test.ts +482 -0
  83. package/src/group-messages.ts +297 -0
  84. package/src/health-redaction.test.ts +642 -0
  85. package/src/http-gzip.test.ts +94 -0
  86. package/src/http-gzip.ts +112 -0
  87. package/src/hub-timestamp.test.ts +38 -0
  88. package/src/hub-timestamp.ts +27 -0
  89. package/src/human-dm.ts +215 -0
  90. package/src/inbox-count-sse.test.ts +310 -0
  91. package/src/inbox-count.ts +29 -0
  92. package/src/index.ts +15 -2356
  93. package/src/lifecycle-guard.ts +1 -1
  94. package/src/mcp-context-cost-http.test.ts +120 -0
  95. package/src/mcp-projects-events-http.test.ts +162 -0
  96. package/src/mcp-route-label-http.test.ts +120 -0
  97. package/src/mcp-write-network-resolution.test.ts +429 -0
  98. package/src/member-audit-network-id-http.test.ts +93 -0
  99. package/src/member-presence-http.test.ts +210 -0
  100. package/src/messages-ack-by-agent-http.test.ts +142 -0
  101. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  102. package/src/migrate-sqlite-to-pg.ts +263 -0
  103. package/src/model-auth-notify-http.test.ts +181 -0
  104. package/src/model-auth-notify.ts +116 -0
  105. package/src/network-name-http.test.ts +75 -0
  106. package/src/network-scope.ts +276 -0
  107. package/src/network-token-membership-http.test.ts +139 -0
  108. package/src/node-alias-control.ts +12 -0
  109. package/src/node-attrs-validate.ts +88 -0
  110. package/src/node-attrs.test.ts +217 -0
  111. package/src/node-caller-identity-http.test.ts +714 -0
  112. package/src/node-create-request-status-http.test.ts +105 -0
  113. package/src/node-daemon-bindings.test.ts +132 -0
  114. package/src/node-daemon-bindings.ts +130 -0
  115. package/src/node-files-transport.test.ts +344 -0
  116. package/src/node-health-codex-login.test.ts +120 -0
  117. package/src/node-health-dispatch-http.test.ts +279 -0
  118. package/src/node-health-guard.ts +105 -0
  119. package/src/node-health-passthrough.test.ts +115 -0
  120. package/src/node-health-store.ts +101 -0
  121. package/src/node-id-alias.test.ts +190 -0
  122. package/src/node-identity-conflict-http.test.ts +269 -0
  123. package/src/node-identity-conflict.ts +142 -0
  124. package/src/node-identity-mismatch.test.ts +74 -0
  125. package/src/node-lifecycle-controllable.test.ts +64 -0
  126. package/src/node-lifecycle-controllable.ts +53 -0
  127. package/src/node-lifecycle-read-http.test.ts +245 -0
  128. package/src/node-lifecycle-read.ts +121 -0
  129. package/src/node-logs-transport.test.ts +263 -0
  130. package/src/node-permissions-http.test.ts +276 -0
  131. package/src/node-permissions.ts +283 -0
  132. package/src/node-request-retention.test.ts +281 -0
  133. package/src/node-request-retention.ts +120 -0
  134. package/src/node-skills-transport.test.ts +209 -0
  135. package/src/node-token-ownership.ts +36 -0
  136. package/src/node-token-privilege-http.test.ts +158 -0
  137. package/src/observer-avatar-http.test.ts +431 -0
  138. package/src/observer-push.test.ts +252 -0
  139. package/src/package-version-consistency.test.ts +25 -0
  140. package/src/peer-reply-atomic.test.ts +455 -0
  141. package/src/peer-reply-capability.test.ts +70 -0
  142. package/src/pg-worker.ts +87 -0
  143. package/src/project-dir-warn-clear-http.test.ts +132 -0
  144. package/src/project-viewer-can-http.test.ts +114 -0
  145. package/src/push.ts +429 -10
  146. package/src/redact-tokens.test.ts +65 -0
  147. package/src/redact-tokens.ts +40 -0
  148. package/src/rename.ts +22 -2
  149. package/src/reply-target-derivation.test.ts +96 -0
  150. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  151. package/src/report-status-host-schema.test.ts +235 -0
  152. package/src/report-status-resume-id-handover.test.ts +150 -0
  153. package/src/require-explicit-test-db.ts +9 -0
  154. package/src/requirement-due-reminders-http.test.ts +620 -0
  155. package/src/requirement-due-reminders.ts +416 -0
  156. package/src/requirement-events-http.test.ts +268 -0
  157. package/src/requirement-events.test.ts +45 -0
  158. package/src/requirement-events.ts +144 -0
  159. package/src/requirement-notify.ts +165 -0
  160. package/src/requirement-participant-notify-http.test.ts +309 -0
  161. package/src/requirement-tag-ops-http.test.ts +266 -0
  162. package/src/requirement-tags.ts +89 -0
  163. package/src/requirements-abandoned-http.test.ts +236 -0
  164. package/src/requirements-comment-http.test.ts +167 -0
  165. package/src/requirements-completed-at-migration.test.ts +70 -0
  166. package/src/requirements-errors-mcp-http.test.ts +164 -0
  167. package/src/requirements-errors.ts +143 -0
  168. package/src/requirements-etag-http.test.ts +91 -0
  169. package/src/requirements-http.test.ts +709 -0
  170. package/src/requirements-last-event-http.test.ts +349 -0
  171. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  172. package/src/requirements-list-slim-http.test.ts +319 -0
  173. package/src/requirements-mcp.test.ts +163 -0
  174. package/src/requirements-migrate.ts +259 -0
  175. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  176. package/src/requirements-people-http.test.ts +114 -0
  177. package/src/requirements-people-mcp-http.test.ts +225 -0
  178. package/src/requirements-people.ts +127 -0
  179. package/src/requirements-priority-lowest.test.ts +114 -0
  180. package/src/requirements-search-http.test.ts +182 -0
  181. package/src/requirements-search.test.ts +32 -0
  182. package/src/requirements-search.ts +145 -0
  183. package/src/requirements-seq-http.test.ts +193 -0
  184. package/src/requirements-seq-migration.test.ts +63 -0
  185. package/src/requirements-start-date.test.ts +125 -0
  186. package/src/requirements-stats-http.test.ts +288 -0
  187. package/src/requirements-stats.ts +167 -0
  188. package/src/requirements-status-participants-http.test.ts +222 -0
  189. package/src/requirements-upgrade-e2e.test.ts +126 -0
  190. package/src/requirements-upgrade-migration.test.ts +98 -0
  191. package/src/requirements.ts +1806 -0
  192. package/src/resolve-port.test.ts +57 -0
  193. package/src/resolve-port.ts +44 -0
  194. package/src/response-charset.test.ts +130 -0
  195. package/src/rest-explicit-columns-http.test.ts +222 -0
  196. package/src/rest-identity.test.ts +129 -0
  197. package/src/rest-identity.ts +125 -0
  198. package/src/rest-projections.ts +68 -0
  199. package/src/rest-write-network-resolution.test.ts +133 -0
  200. package/src/restricted-files.ts +76 -0
  201. package/src/retention.ts +15 -3
  202. package/src/route-callers-http.test.ts +120 -0
  203. package/src/route-timing.test.ts +211 -0
  204. package/src/route-timing.ts +258 -0
  205. package/src/rules-file-duplicate-alias.test.ts +234 -0
  206. package/src/rules-file-session-target.test.ts +207 -0
  207. package/src/rules-file-transport.test.ts +237 -0
  208. package/src/runtime-label.test.ts +22 -0
  209. package/src/runtime-label.ts +21 -0
  210. package/src/runtime-readiness.ts +90 -0
  211. package/src/schedule-agent-mcp-http.test.ts +381 -0
  212. package/src/schedule-agent.ts +192 -0
  213. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  214. package/src/scheduled-failures-http.test.ts +233 -0
  215. package/src/scheduled-failures.ts +179 -0
  216. package/src/scheduled-offline.test.ts +131 -0
  217. package/src/scheduled-offline.ts +66 -0
  218. package/src/scheduled-reply-unread-http.test.ts +212 -0
  219. package/src/scheduled-run-terminal.test.ts +268 -0
  220. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  221. package/src/scheduled-stuck-http.test.ts +238 -0
  222. package/src/scheduled-stuck.ts +153 -0
  223. package/src/scheduled-tasks-http.test.ts +638 -0
  224. package/src/scheduled-tasks.ts +766 -0
  225. package/src/send-dedup-request-id.test.ts +229 -0
  226. package/src/send-reply-agent-warning.test.ts +202 -0
  227. package/src/send-reply-alias-optional.test.ts +131 -0
  228. package/src/send-reply-attachments.test.ts +458 -0
  229. package/src/send_dedup.test.ts +54 -0
  230. package/src/send_dedup.ts +52 -6
  231. package/src/serve-error.test.ts +38 -0
  232. package/src/serve-error.ts +27 -0
  233. package/src/server.ts +5253 -0
  234. package/src/session-task-on-dispatch.ts +14 -0
  235. package/src/shared/codex-inventory.ts +34 -0
  236. package/src/shared/daemon-provider-snapshot.ts +49 -0
  237. package/src/shared/external-schedule-contract.ts +60 -0
  238. package/src/shared/node-name.test.ts +62 -0
  239. package/src/shared/node-name.ts +161 -0
  240. package/src/shared/provider-application.ts +24 -0
  241. package/src/shared/reserved-env-drift.test.ts +10 -0
  242. package/src/shared/task-content-limit-drift.test.ts +28 -0
  243. package/src/shared/task-content-limit.ts +25 -0
  244. package/src/side-thread-command-transport.test.ts +312 -0
  245. package/src/side-thread-command-transport.ts +318 -0
  246. package/src/side-thread-http-integration.test.ts +233 -0
  247. package/src/side-thread-http.ts +504 -0
  248. package/src/side-thread-production.test.ts +72 -0
  249. package/src/side-thread-production.ts +118 -0
  250. package/src/side-thread.test.ts +1067 -0
  251. package/src/side-thread.ts +2499 -0
  252. package/src/skillhub-http.test.ts +108 -0
  253. package/src/sqlite-close-checkpoint.test.ts +78 -0
  254. package/src/start-node.test.ts +264 -0
  255. package/src/status-alias-filter-http.test.ts +106 -0
  256. package/src/status-alias-resolver-http.test.ts +163 -0
  257. package/src/status-alias-resolver.ts +19 -0
  258. package/src/status-node-id-filter-http.test.ts +161 -0
  259. package/src/status-read-cache-http.test.ts +229 -0
  260. package/src/status-read-cache.ts +122 -0
  261. package/src/stop-delete-node.test.ts +469 -1
  262. package/src/task-access-http.test.ts +402 -0
  263. package/src/task-access.ts +420 -0
  264. package/src/task-auth-origin-http.test.ts +91 -0
  265. package/src/task-auth-origin.test.ts +24 -0
  266. package/src/task-auth-origin.ts +7 -0
  267. package/src/task-consumption.test.ts +482 -0
  268. package/src/task-diagnostic-http.test.ts +149 -0
  269. package/src/task-diagnostic.test.ts +115 -0
  270. package/src/task-diagnostic.ts +118 -0
  271. package/src/task-expiry-notice-http.test.ts +287 -0
  272. package/src/task-expiry-notice.ts +315 -0
  273. package/src/task-idempotency-mcp.test.ts +142 -0
  274. package/src/task-idempotency.test.ts +60 -0
  275. package/src/task-idempotency.ts +75 -0
  276. package/src/task-late-replies.ts +54 -0
  277. package/src/task-late-reply-http.test.ts +152 -0
  278. package/src/task-lifecycle-watcher.test.ts +204 -0
  279. package/src/task-lifecycle-watcher.ts +49 -0
  280. package/src/task-network-resolution-http.test.ts +141 -0
  281. package/src/task-orphan-http.test.ts +287 -0
  282. package/src/task-orphan.ts +119 -0
  283. package/src/task-queue-ahead-http.test.ts +309 -0
  284. package/src/task-queue-ahead-scale.test.ts +236 -0
  285. package/src/task-queue-ahead.ts +186 -0
  286. package/src/task-rejection-log-http.test.ts +160 -0
  287. package/src/task-rejection-log.ts +49 -0
  288. package/src/task-stale-open-http.test.ts +290 -0
  289. package/src/task-stale-open.ts +103 -0
  290. package/src/tasks-pagination-http.test.ts +141 -0
  291. package/src/tool-audience-http.test.ts +222 -0
  292. package/src/tool-audience.ts +116 -0
  293. package/src/tools-list-json-schema.test.ts +67 -0
  294. package/src/tools.ts +3598 -313
  295. package/src/upload-early-reject-guard.test.ts +155 -0
  296. package/src/uploads-http.test.ts +97 -15
  297. package/src/uploads.test.ts +66 -0
  298. package/src/uploads.ts +138 -5
  299. package/src/user-avatar-http.test.ts +74 -0
  300. package/src/user-avatar-migration.test.ts +25 -0
  301. package/src/user-inbox-by-agent-http.test.ts +161 -0
  302. package/src/user-inbox-netid-notnull.test.ts +122 -0
  303. package/src/user-inbox-read-http.test.ts +223 -0
  304. package/src/user-inbox-schema.test.ts +54 -0
@@ -232,40 +232,91 @@ export type DaemonResolveResult =
232
232
  | { ok: true; daemonNodeId: string; daemonAlias: string; networkId: string }
233
233
  | { ok: false; error: string };
234
234
 
235
+ export type NodeCallerResolution =
236
+ | { ok: true; kind: "node"; nodeId: string; alias: string; networkId: string }
237
+ | { ok: true; kind: "unregistered"; alias: string; networkId: string }
238
+ | { ok: false; reason: "not_a_node_token" | "bound_node_missing" | "ambiguous" | "not_owner" };
239
+
240
+ type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
241
+
242
+ // nodes has no unique (network, alias). An unbound token that hits several
243
+ // rows is not rejected outright: the row owned by the token's issuer wins;
244
+ // if that is still not unique, the node_id on the latest session for the
245
+ // alias wins. Still not unique, then refuse.
246
+ function disambiguateUnboundAlias(
247
+ rows: NodeCallerRow[],
248
+ userId: string | null,
249
+ alias: string,
250
+ networkId: string,
251
+ ): NodeCallerRow | undefined {
252
+ const owned = userId ? rows.filter((row) => row.owner_user_id === userId) : [];
253
+ if (owned.length === 1) return owned[0]; // board679-unbound-dup
254
+ const pool = owned.length > 1 ? owned : rows;
255
+ const recent = db.get<{ node_id: string | null }>(
256
+ `SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
257
+ alias,
258
+ networkId,
259
+ );
260
+ const hit = recent?.node_id ? pool.filter((row) => row.node_id === recent.node_id) : [];
261
+ return hit.length === 1 ? hit[0] : undefined;
262
+ }
263
+
264
+ // One lookup for every caller-identity entry. The token name is a hint.
265
+ // bound_node_id is the row: if that row is gone, refuse — do not look up by name.
266
+ // An unbound token with no row in its network stays the token name. First
267
+ // registration (report_status) still depends on that; sending and subscribing
268
+ // do not get a second exception.
269
+ export function resolveNodeCaller(tokenId: string | null | undefined): NodeCallerResolution {
270
+ if (!tokenId) return { ok: false, reason: "not_a_node_token" };
271
+ const tokRow = db.get<{ name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number }>(
272
+ `SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
273
+ tokenId,
274
+ );
275
+ if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:") || !tokRow.network_id) {
276
+ return { ok: false, reason: "not_a_node_token" };
277
+ }
278
+ const tokenAlias = tokRow.name.slice(5);
279
+ type DaemonRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
280
+ const rows = tokRow.bound_node_id
281
+ ? db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`, tokRow.bound_node_id, tokRow.network_id)
282
+ : db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE alias = ?1 AND network_id = ?2`, tokenAlias, tokRow.network_id);
283
+ const nodeRow = rows.length === 1
284
+ ? rows[0]
285
+ : (!tokRow.bound_node_id && rows.length > 1
286
+ ? disambiguateUnboundAlias(rows, tokRow.user_id, tokenAlias, tokRow.network_id)
287
+ : undefined);
288
+ // board679-bound-row-missing: a bound token with no row is refused. Do not look up by name.
289
+ if (tokRow.bound_node_id && !nodeRow) return { ok: false, reason: "bound_node_missing" };
290
+ if (!nodeRow && rows.length === 0) {
291
+ return { ok: true, kind: "unregistered", alias: tokenAlias, networkId: tokRow.network_id };
292
+ }
293
+ if (!nodeRow) return { ok: false, reason: "ambiguous" };
294
+ // Legacy unbound tokens keep their owner's authority. A token name is only a hint.
295
+ const ownsNode = !!tokRow.user_id && tokRow.user_id === nodeRow.owner_user_id;
296
+ const boundToNode = tokRow.bound_node_id === nodeRow.node_id;
297
+ const legacyOwnerless = nodeRow.owner_user_id === null && tokRow.node_identity_epoch === 0;
298
+ if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false, reason: "not_owner" };
299
+ return { ok: true, kind: "node", nodeId: nodeRow.node_id, alias: nodeRow.alias, networkId: nodeRow.network_id };
300
+ }
301
+
235
302
  export function resolveCallerDaemonTokenBound(opts: {
236
303
  callerTokenIsNetwork: boolean;
237
304
  callerTokenId: string | null | undefined;
238
305
  enforceNetworkId: string | null | undefined;
239
306
  }): DaemonResolveResult {
240
- if (!opts.callerTokenIsNetwork || !opts.callerTokenId) {
241
- return { ok: false, error: "caller_not_a_daemon" };
242
- }
243
- if (!opts.enforceNetworkId) {
244
- return { ok: false, error: "caller_not_a_daemon" };
245
- }
246
- const tokRow = db.get<{ name: string; network_id: string | null }>(
247
- `SELECT name, network_id FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
248
- opts.callerTokenId,
249
- );
250
- if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
251
- return { ok: false, error: "caller_not_a_daemon" };
252
- }
253
- if (tokRow.network_id !== opts.enforceNetworkId) {
307
+ if (!opts.callerTokenIsNetwork || !opts.callerTokenId || !opts.enforceNetworkId) {
254
308
  return { ok: false, error: "caller_not_a_daemon" };
255
309
  }
256
- const tokenAlias = tokRow.name.slice(5);
257
- const nodeRow = db.get<{ node_id: string; alias: string; network_id: string }>(
258
- `SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2 LIMIT 1`,
259
- tokenAlias, tokRow.network_id,
260
- );
261
- if (!nodeRow) {
310
+ const resolved = resolveNodeCaller(opts.callerTokenId);
311
+ // No row yet is not a daemon. First registration stays on report_status.
312
+ if (!resolved.ok || resolved.kind !== "node" || resolved.networkId !== opts.enforceNetworkId) {
262
313
  return { ok: false, error: "caller_not_a_daemon" };
263
314
  }
264
315
  return {
265
316
  ok: true,
266
- daemonNodeId: nodeRow.node_id,
267
- daemonAlias: nodeRow.alias,
268
- networkId: nodeRow.network_id,
317
+ daemonNodeId: resolved.nodeId,
318
+ daemonAlias: resolved.alias,
319
+ networkId: resolved.networkId,
269
320
  };
270
321
  }
271
322
 
@@ -306,6 +357,7 @@ export function auditCreateNodeStrict(input: {
306
357
  export function auditCreateNode(input: {
307
358
  action:
308
359
  | "create_node_dispatched"
360
+ | "create_node_dispatch_attempted"
309
361
  | "create_node_rejected"
310
362
  | "create_node_succeeded"
311
363
  | "create_node_sweeper_revoked"
@@ -317,6 +369,8 @@ export function auditCreateNode(input: {
317
369
  // wider migration (see P1.1 issue).
318
370
  | "stop_node_dispatched"
319
371
  | "stop_node_completed"
372
+ | "start_node_dispatched"
373
+ | "start_node_completed"
320
374
  | "delete_node_dispatched"
321
375
  | "delete_node_completed"
322
376
  | "forced_stop_with_in_flight"
@@ -0,0 +1,256 @@
1
+ import "./require-explicit-test-db";
2
+ import { afterAll, beforeEach, expect, test } from "bun:test";
3
+ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createHash } from 'node:crypto';
7
+ import { db } from "./db";
8
+ import { registerTools } from "./tools";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { reconcileProviderSnapshots, readLocalProviderSnapshot } from "../../agent-node/src/runtime/daemon-provider-snapshot";
11
+ import { DaemonProviderStore } from "../../agent-network/src/daemon-provider-store";
12
+ import { register, createNetworkTokenForNode } from "./auth";
13
+
14
+ const net = "net_dps906", user = "u_dps906", node = "node_dps906", token = "token_dps906", alias = "daemon906";
15
+ const root = mkdtempSync(join(tmpdir(), "dps906-"));
16
+ function cleanup() {
17
+ db.run('DELETE FROM daemon_provider_applications WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
18
+ db.run('DELETE FROM daemon_provider_writes WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
19
+ db.run('DELETE FROM node_daemon_bindings WHERE network_id=?1', [net]);
20
+ for (const t of ["daemon_provider_snapshots", "nodes", "api_tokens", "network_members"]) db.run(`DELETE FROM ${t} WHERE network_id=?1`, [net]);
21
+ db.run("DELETE FROM networks WHERE network_id=?1", [net]);
22
+ db.run("DELETE FROM users WHERE user_id=?1", [user]);
23
+ }
24
+ beforeEach(() => {
25
+ cleanup();
26
+ db.run("INSERT INTO users (user_id,username,password_hash,role) VALUES (?1,?1,'x','user')", [user]);
27
+ db.run("INSERT INTO networks (network_id,network_name,owner_id) VALUES (?1,?1,?2)", [net, user]);
28
+ db.run("INSERT INTO network_members (user_id,network_id,role) VALUES (?1,?2,'owner')", [user, net]);
29
+ db.run("INSERT INTO nodes (node_id,node_name,alias,network_id,config_snapshot) VALUES (?1,?2,?2,?3,?4)", [node, alias, net, JSON.stringify({ role: "host_supervisor" })]);
30
+ db.run("INSERT INTO api_tokens (token_id,user_id,network_id,scope,name,token_hash) VALUES (?1,?2,?3,'network',?4,?5)", [token, user, net, `node:${alias}`, `hash_${token}`]);
31
+ });
32
+ afterAll(() => { cleanup(); rmSync(root, { recursive: true, force: true }); });
33
+ function client(kind = "human", network = net) {
34
+ const handlers: Record<string, any> = {};
35
+ // Capture the actual registerTools handlers, including production guards.
36
+ const server = new McpServer({ name: "test906", version: "0" }) as any;
37
+ const tool = server.tool.bind(server), register = server.registerTool.bind(server);
38
+ server.tool = (name: string, description: any, schema: any, handler: any) => { handlers[name] = handler; return tool(name, description, schema, handler); };
39
+ server.registerTool = (name: string, config: any, handler: any) => { handlers[name] = handler; return register(name, config, handler); };
40
+ registerTools(server, undefined, kind === "daemon" ? network : null, kind === "anonymous" ? null : user,
41
+ alias, kind === "daemon", kind === "daemon" ? token : null);
42
+ return async (name: string, args: any = {}) => JSON.parse((await handlers[name](args)).content[0].text);
43
+ }
44
+ const snapshot = () => ({ network_id: net, daemon_node_id: node, revision: 0, source: "daemon", providers: [] });
45
+ const request = () => client()("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node });
46
+
47
+ test('application transport: scoped intent, capability gate, exact public receipt and changed binding refusal', async () => {
48
+ const human = client(), daemon = client('daemon');
49
+ const child = 'node_apply906';
50
+ db.run("INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,'child906','child906',?2,'codex-app-server')", [child, net]);
51
+ db.run("INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES ('binding_apply906',?1,?2,?3,?4,?5,'active',1,1)", [net, child, node, root, user]);
52
+ const selection = { node_id: child, node_revision: 'a'.repeat(64), provider_id: 'custom', auth_id: 'work', model: 'model-a', confirm_restart: true };
53
+ const args = { action: 'apply', id: node, network_id: net, revision: 1, provider: selection };
54
+ expect((await human('daemon_provider_snapshot', { ...args, provider: { ...selection, key: 'TEST-ONLY-key' } })).error).toBe('invalid_application');
55
+ expect((await human('daemon_provider_snapshot', { ...args, provider: { ...selection, node_id: node } })).error).toBe('application_target_not_bound');
56
+ expect((await daemon('daemon_provider_snapshot', args)).ok).toBe(false);
57
+ const r = await human('daemon_provider_snapshot', args);
58
+ expect(r.ok).toBe(true); expect(r.request_id).toMatch(/^dpa_/);
59
+ expect((await daemon('pull_daemon_provider_snapshots', { accept_writes: true })).requests).toEqual([]);
60
+ const dispatch = await daemon('pull_daemon_provider_snapshots', { accept_applications: true });
61
+ expect(dispatch.requests[0]).toMatchObject({ request_id: r.request_id, operation: 'apply', alias: 'child906', expected_revision: 1, selection });
62
+ const receipt = { status: 'applied', request_id: r.request_id, node_id: child, provider_id: 'custom', auth_id: 'work', model: 'model-a',
63
+ runtime_provider: 'anet_' + createHash('sha256').update(JSON.stringify(['custom', 'work'])).digest('hex').slice(0, 20), node_revision: 'c'.repeat(64), provider_revision: 1, verification: 'runtime_confirmed', applied_at: Date.now() };
64
+ // This is a protocol fixture, NOT evidence a runtime has switched.
65
+ for (const bad of [{ ...receipt, model: 'wrong' }, { ...receipt, key: 'TEST-ONLY-key' }, { ...receipt, provider_revision: 2 }, { ...receipt, runtime_provider: 'anet_' + 'b'.repeat(20) }]) {
66
+ expect((await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, application: bad })).error).toBe('invalid_application_receipt');
67
+ }
68
+ expect((await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, snapshot: snapshot() })).ok).toBe(false);
69
+ expect((await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, application: receipt })).status).toBe('succeeded');
70
+ await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, failed: true });
71
+ const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
72
+ expect(read.status).toBe('succeeded'); expect(read.application).toEqual(receipt); expect(read.snapshot).toBeNull();
73
+ expect(db.get('SELECT * FROM daemon_provider_applications WHERE request_id=?1', r.request_id)).toBeNull();
74
+ const stale = await human('daemon_provider_snapshot', args);
75
+ db.run("UPDATE node_daemon_bindings SET daemon_node_id='other' WHERE request_id='binding_apply906'");
76
+ expect((await daemon('pull_daemon_provider_snapshots', { accept_applications: true })).requests).toEqual([]);
77
+ expect((await human('daemon_provider_snapshot', { action: 'read', id: stale.request_id, network_id: net })).error).toBe('application_target_not_bound');
78
+ });
79
+
80
+ test('write core chain: encrypted Hub request → target daemon CLI → durable replay → public readback', async () => {
81
+ process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32); // isolated fixture only
82
+ const work = mkdtempSync(join(root, 'write-'));
83
+ const dir = join(work, '.anet/nodes', alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
84
+ writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
85
+ const key = 'TEST-ONLY-private-write-key';
86
+ const provider = { id: 'deepseek', label: 'DeepSeek', enabled: true, runtimes: [{ runtime: 'codex-tui', auth: [
87
+ { id: 'work', kind: 'api_key', models: ['model-a'], baseUrl: 'https://api.example.test/v1', key },
88
+ ] }] };
89
+ const human = client(), daemon = client('daemon');
90
+ const r = await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 0, provider });
91
+ expect(r.ok).toBe(true); expect(JSON.stringify(r)).not.toContain(key);
92
+ const encrypted = db.get<any>('SELECT * FROM daemon_provider_writes WHERE request_id=?1', r.request_id);
93
+ expect(Buffer.from(encrypted.ciphertext).toString()).not.toContain(key);
94
+ expect((await daemon('pull_daemon_provider_snapshots')).requests).toEqual([]); // old/insecure readers receive no key
95
+ expect((await client('daemon', 'other')('pull_daemon_provider_snapshots', { accept_writes: true })).ok).toBe(false);
96
+ let drop = true;
97
+ const deps = { workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {},
98
+ callCommHub: async (name: string, args: any) => {
99
+ if (name === 'ack_daemon_provider_snapshot' && drop) { drop = false; throw Error('lost acknowledgement'); }
100
+ return daemon(name, args);
101
+ } };
102
+ await reconcileProviderSnapshots(deps);
103
+ expect(new DaemonProviderStore(dir).list().revision).toBe(1);
104
+ await reconcileProviderSnapshots(deps); // same request must not write revision 2 or lose success
105
+ const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
106
+ expect(read.status).toBe('succeeded'); expect(read.snapshot.revision).toBe(1);
107
+ expect(read.snapshot.providers[0].runtimes[0].auth[0].models).toEqual(['model-a']);
108
+ expect(JSON.stringify(read)).not.toContain(key);
109
+ expect(db.get('SELECT * FROM daemon_provider_writes WHERE request_id=?1', r.request_id)).toBeNull();
110
+ const conflict = await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 0, provider: { ...provider, label: 'must not overwrite' } });
111
+ await reconcileProviderSnapshots(deps);
112
+ expect((await human('daemon_provider_snapshot', { action: 'read', id: conflict.request_id, network_id: net })).status).toBe('failed');
113
+ expect(new DaemonProviderStore(dir).list().providers[0].label).toBe('DeepSeek');
114
+ db.run("UPDATE network_members SET role='member' WHERE user_id=?1 AND network_id=?2", [user, net]);
115
+ expect((await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 1, provider })).error).toBe('provider_admin_required');
116
+ });
117
+
118
+ test("human authorization precedes dispatch; unknown, nodes and non-admins cannot request", async () => {
119
+ expect((await client("anonymous")("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node })).error).toBe("user_token_required");
120
+ expect((await client("daemon")("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node })).ok).toBe(false);
121
+ db.run("UPDATE network_members SET role='member' WHERE user_id=?1 AND network_id=?2", [user, net]);
122
+ expect((await request()).error).toBe("provider_admin_required");
123
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM daemon_provider_snapshots WHERE network_id=?1", net)?.n).toBe(0);
124
+ });
125
+ test("normal nodes cannot impersonate a daemon; revoked token cannot pull or ack", async () => {
126
+ const r = await request(); expect(r.ok).toBe(true);
127
+ db.run("UPDATE nodes SET config_snapshot='{}' WHERE node_id=?1", [node]);
128
+ expect((await client("daemon")("pull_daemon_provider_snapshots")).error).toBe("caller_not_a_daemon");
129
+ db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), node]);
130
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", [token]);
131
+ expect((await client("daemon")("pull_daemon_provider_snapshots")).ok).toBe(false);
132
+ expect((await client("daemon")("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).ok).toBe(false);
133
+ });
134
+ test("request/pull/ack/read lifecycle; duplicate click and ack cannot rewrite receipt", async () => {
135
+ const r = await request(); expect((await request()).request_id).toBe(r.request_id);
136
+ const human = client(), daemon = client("daemon");
137
+ expect((await human("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).snapshot).toBeNull();
138
+ expect((await daemon("pull_daemon_provider_snapshots")).requests[0].request_id).toBe(r.request_id);
139
+ expect((await daemon("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).status).toBe("succeeded");
140
+ await daemon("ack_daemon_provider_snapshot", { request_id: r.request_id, failed: true });
141
+ const read = await human("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net });
142
+ expect(read.status).toBe("succeeded"); expect(read.snapshot).toEqual(snapshot()); expect(read.observed_at).toBeGreaterThan(0);
143
+ expect((await daemon("pull_daemon_provider_snapshots")).requests).toEqual([]);
144
+ });
145
+ test("network and daemon mismatch cannot read or acknowledge another scope", async () => {
146
+ const r = await request();
147
+ expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: "foreign" })).ok).toBe(false);
148
+ expect((await client("daemon", "foreign")("pull_daemon_provider_snapshots")).ok).toBe(false);
149
+ const d = client("daemon");
150
+ expect((await d("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: { ...snapshot(), daemon_node_id: "other" } })).error).toBe("invalid_provider_snapshot");
151
+ db.run("UPDATE daemon_provider_snapshots SET daemon_node_id='other' WHERE request_id=?1", [r.request_id]);
152
+ expect((await d("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).error).toBe("request_not_found");
153
+ });
154
+ test("timeout is explicit; late daemon reply cannot overwrite it", async () => {
155
+ const r = await request();
156
+ db.run("UPDATE daemon_provider_snapshots SET expires_at=?1 WHERE request_id=?2", [Date.now() - 1, r.request_id]);
157
+ expect((await client("daemon")("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).status).toBe("expired");
158
+ expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).snapshot).toBeNull();
159
+ });
160
+ test("secret fields / misleading verification are rejected, never saved in Hub DB", async () => {
161
+ const r = await request(); const secret = "TEST-ONLY-key-never-in-hub";
162
+ const auth = { id: "work", kind: "api_key", models: ["model-a"], baseUrl: "https://api.example.test/v1", credential_present: true, verification: "not_checked", application: "not_applied" };
163
+ const nested = (change: object) => ({ ...snapshot(), providers: [{ id: "deepseek", label: "DeepSeek", enabled: true, runtimes: [{ runtime: "codex-tui", auth: [{ ...auth, ...change }] }] }] });
164
+ for (const bad of [{ ...snapshot(), key: secret }, { ...snapshot(), source: "hub" }, { ...snapshot(), revision: -1 },
165
+ nested({ key: secret }), nested({ verification: "verified" }), nested({ application: "applied" }), nested({ baseUrl: `https://user:${secret}@api.example.test/v1` })]) {
166
+ const reply = await client("daemon")("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: bad });
167
+ expect(reply.error).toBe("invalid_provider_snapshot"); expect(JSON.stringify(reply)).not.toContain(secret);
168
+ }
169
+ expect(db.get<{ snapshot_json: string | null }>("SELECT snapshot_json FROM daemon_provider_snapshots WHERE request_id=?1", r.request_id)?.snapshot_json).toBeNull();
170
+ });
171
+ test("mirrored contract cannot drift", () => {
172
+ expect(readFileSync(new URL("./shared/daemon-provider-snapshot.ts", import.meta.url), "utf8"))
173
+ .toBe(readFileSync(new URL("../../agent-node/src/shared/daemon-provider-snapshot.ts", import.meta.url), "utf8"));
174
+ });
175
+ test("daemon reconnection executes actual pinned CLI against private store and acks redacted data", async () => {
176
+ const dir = join(root, ".anet/nodes", alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
177
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ role: "host_supervisor", network_id: net, node_id: node }), { mode: 0o600 });
178
+ const key = "TEST-ONLY-private-906";
179
+ new DaemonProviderStore(dir).upsert({ id: "deepseek", label: "DeepSeek", enabled: true, runtimes: [{ runtime: "codex-tui", auth: [{ id: "work", kind: "api_key", models: ["model-a"], baseUrl: "https://api.example.test/v1", key }] }] }, 0);
180
+ const deps = { workDir: root, alias, networkId: net, daemonId: node, callCommHub: client("daemon"), warn: () => { throw Error("unexpected_warning"); } };
181
+ // Atomic local read must pass before the Hub/daemon round trip.
182
+ expect((await readLocalProviderSnapshot(deps) as any).revision).toBe(1);
183
+ const r = await request();
184
+ await Promise.all([reconcileProviderSnapshots(deps), reconcileProviderSnapshots(deps)]);
185
+ const read = await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net });
186
+ expect(read.status).toBe("succeeded"); expect(read.snapshot.providers[0].runtimes[0].auth[0].credential_present).toBe(true);
187
+ expect(JSON.stringify(read)).not.toContain(key); expect(read.snapshot.providers[0].runtimes[0].auth[0].verification).toBe("not_checked");
188
+ });
189
+ test("daemon raw errors are replaced with fixed failure receipt", async () => {
190
+ const r = await request();
191
+ await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId: node, callCommHub: client("daemon"), warn: () => {},
192
+ readSnapshot: async () => { throw Error("TEST-ONLY-sensitive-error"); } });
193
+ const read = await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net });
194
+ expect(read.status).toBe("failed"); expect(read.error).toBe("provider_snapshot_unavailable"); expect(JSON.stringify(read)).not.toContain("TEST-ONLY-sensitive-error");
195
+ });
196
+ test("lost acknowledgement retries on reconnect, without swallowing a new doorbell", async () => {
197
+ const r = await request(); const real = client("daemon"); let loseAck = true, reads = 0;
198
+ const deps = { workDir: root, alias, networkId: net, daemonId: node, warn: () => {},
199
+ readSnapshot: async () => { reads++; return snapshot(); },
200
+ callCommHub: async (name: string, args: any) => {
201
+ if (name === "ack_daemon_provider_snapshot" && loseAck) { loseAck = false; throw Error("TEST-ONLY-transport-down"); }
202
+ return real(name, args);
203
+ } };
204
+ await reconcileProviderSnapshots(deps);
205
+ expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).status).toBe("pending");
206
+ await reconcileProviderSnapshots(deps);
207
+ expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).status).toBe("succeeded");
208
+ expect(reads).toBe(2);
209
+ });
210
+ test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public receipt", async () => {
211
+ process.env.COMMHUB_UPLOADS_DIR = join(root, "uploads");
212
+ const owner = register("provider906_http", "TestOnly-Provider906-Password!1");
213
+ expect(owner.ok).toBe(true);
214
+ const netId = owner.network_id!, nodeId = "node_dps_http", daemonAlias = "daemon-http";
215
+ const issued = createNetworkTokenForNode(owner.user!.user_id, netId, daemonAlias, nodeId);
216
+ expect(issued.ok).toBe(true);
217
+ db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), nodeId]);
218
+ const dir = join(root, ".anet/nodes", daemonAlias); mkdirSync(dir, { recursive: true, mode: 0o700 });
219
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ role: "host_supervisor", network_id: netId, node_id: nodeId }), { mode: 0o600 });
220
+ for (const child of ['bound', 'neighbour']) {
221
+ const childId = `n_scan_${child}`;
222
+ db.run('INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,?2,?2,?3,\'codex-app-server\')', [childId, child, netId]);
223
+ db.run('INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,?6,\'active\',1,1)',
224
+ [`binding_scan_${child}`, netId, childId, child === 'bound' ? nodeId : 'another-daemon', root, owner.user!.user_id]);
225
+ const home = join(root, '.anet/nodes', child, 'codex-home'); mkdirSync(home, { recursive: true, mode: 0o700 });
226
+ writeFileSync(join(home, '../config.json'), JSON.stringify({ node_id: childId, alias: child, runtime: 'codex-app-server', network_id: netId }), { mode: 0o600 });
227
+ writeFileSync(join(home, 'config.toml'), 'model_provider = "deepseek"\nmodel = "fixture-model"\n[model_providers.deepseek]\nenv_key = "TEST_API_KEY"\n', { mode: 0o600 });
228
+ }
229
+ const hub = (await import("./server")).bootServer({ port: 0, hostname: "127.0.0.1" });
230
+ const url = `http://127.0.0.1:${hub.port}/mcp`;
231
+ const rpc = (credential: string) => async (name: string, args: any) => {
232
+ const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${credential}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
233
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
234
+ expect(response.status).toBe(200);
235
+ const raw = await response.text(); const line = raw.split("\n").filter(x => x.startsWith("data:")).at(-1);
236
+ const message = JSON.parse(line ? line.slice(5) : raw);
237
+ expect(message.error).toBeUndefined();
238
+ return JSON.parse(message.result.content[0].text);
239
+ };
240
+ try {
241
+ const human = rpc(owner.token!), daemon = rpc(issued.token!);
242
+ const refresh = await human("daemon_provider_snapshot", { action: "refresh", id: nodeId, network_id: netId });
243
+ expect(refresh.status).toBe("pending");
244
+ expect((await human("pull_daemon_provider_snapshots", {})).ok).toBe(false);
245
+ expect((await daemon("daemon_provider_snapshot", { action: "refresh", id: nodeId, network_id: netId })).ok).toBe(false);
246
+ await reconcileProviderSnapshots({ workDir: root, alias: daemonAlias, networkId: netId, daemonId: nodeId, callCommHub: daemon,
247
+ warn: () => { throw Error("unexpected_HTTP_readback_failure"); } });
248
+ const read = await human("daemon_provider_snapshot", { action: "read", id: refresh.request_id, network_id: netId });
249
+ expect(read.status).toBe("succeeded"); expect(read.snapshot).toMatchObject({ network_id: netId, daemon_node_id: nodeId, revision: 0, source: "daemon", providers: [], codex_inventory: { scope: 'hub_bound_nodes' } });
250
+ expect(read.snapshot.codex_inventory.rows).toHaveLength(1);
251
+ expect(read.snapshot.codex_inventory.rows[0]).toMatchObject({ node_id: 'n_scan_bound', configured_provider: 'deepseek', configured_model: 'fixture-model', credential_status: 'unknown' });
252
+ const foreign = await human('daemon_provider_snapshot', { action: 'refresh', id: nodeId, network_id: netId });
253
+ const bad = structuredClone(read.snapshot); bad.codex_inventory.rows[0].node_id = 'n_scan_neighbour';
254
+ expect((await daemon('ack_daemon_provider_snapshot', { request_id: foreign.request_id, snapshot: bad })).ok).toBe(false);
255
+ } finally { hub.stop(true); }
256
+ });
@@ -0,0 +1,226 @@
1
+ // #906: human-requested readback of ONE daemon's private Provider metadata.
2
+ // Hub registry is not a fallback. No credential values enter this protocol.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { db } from "./db";
6
+ import { getUserNetworkRole } from "./auth";
7
+ import { validateDaemonProviderSnapshot } from "./shared/daemon-provider-snapshot";
8
+ import { encryptSecret, decryptSecret, VaultError } from './vault';
9
+ import { buildControllableMap } from './node-lifecycle-controllable';
10
+ import { validProviderApplicationSelection, validProviderApplicationReceipt, type ProviderApplicationSelection } from './shared/provider-application';
11
+
12
+ export function providerScanTargets(networkId: string, daemonId: string) {
13
+ const ownership = buildControllableMap(
14
+ db.all<{ request_id: string; daemon_node_id: string }>('SELECT request_id,daemon_node_id FROM node_create_requests WHERE network_id=?1', networkId),
15
+ db.all<{ node_id: string; daemon_node_id: string }>('SELECT node_id,daemon_node_id FROM node_daemon_bindings WHERE network_id=?1 AND status=\'active\'', networkId));
16
+ const targets = db.all<{ node_id: string; alias: string }>('SELECT node_id,alias FROM nodes WHERE network_id=?1 ORDER BY node_id', networkId)
17
+ .filter(n => ownership.get(n.node_id) === daemonId && typeof n.alias === 'string');
18
+ if (targets.length > 200) throw Error('inventory_limit'); // no silent incomplete denominator
19
+ return targets;
20
+ }
21
+
22
+ type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
23
+ type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
24
+ type Row = { request_id: string; network_id: string; daemon_node_id: string; status: string;
25
+ created_at: number; expires_at: number; observed_at: number | null; snapshot_json: string | null; error: string | null };
26
+ const denied = (error: string) => ({ ok: false as const, error });
27
+ function authorize(h: Human) {
28
+ if (h.isNode || !h.userId) return denied("user_token_required");
29
+ if (!h.networkId || !["owner", "admin"].includes(getUserNetworkRole(h.userId, h.networkId) || "")) return denied("provider_admin_required");
30
+ return null;
31
+ }
32
+ function daemonExists(networkId: string, nodeId: string): boolean {
33
+ const node = db.get<{ config_snapshot: string }>(
34
+ `SELECT n.config_snapshot FROM nodes n WHERE n.node_id=?1 AND n.network_id=?2
35
+ AND EXISTS (SELECT 1 FROM api_tokens t WHERE t.network_id=n.network_id
36
+ AND t.name='node:' || n.alias AND t.revoked_at IS NULL
37
+ AND (t.expires_at IS NULL OR t.expires_at > datetime('now')))`, nodeId, networkId);
38
+ try { return JSON.parse(node?.config_snapshot || "{}").role === "host_supervisor"; } catch { return false; }
39
+ }
40
+ function expire(networkId: string, daemonId?: string) {
41
+ db.run(`UPDATE daemon_provider_snapshots SET status='expired', error='request_expired'
42
+ WHERE network_id=?1 AND status='pending' AND expires_at <= ?2
43
+ AND (?3 IS NULL OR daemon_node_id=?3)`, [networkId, Date.now(), daemonId ?? null]);
44
+ db.run(`DELETE FROM daemon_provider_writes WHERE request_id IN
45
+ (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
46
+ db.run(`DELETE FROM daemon_provider_applications WHERE request_id IN
47
+ (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
48
+ db.run(`DELETE FROM daemon_provider_snapshots WHERE network_id=?1 AND expires_at < ?2`, [networkId, Date.now() - 7 * 86400_000]);
49
+ }
50
+ /** Validate the complete public projection before encrypting the private input. */
51
+ function validateWrite(provider: any, networkId: string, daemonId: string) {
52
+ if (Buffer.byteLength(JSON.stringify(provider) || '') > 64 * 1024) throw new Error('invalid_provider');
53
+ const publicProvider = { ...provider, runtimes: provider?.runtimes?.map((r: any) => ({ ...r,
54
+ auth: r.auth?.map((a: any) => {
55
+ const { key, ...rest } = a;
56
+ if (a.kind === 'chatgpt' && key !== undefined) throw new Error('invalid_provider');
57
+ if (key !== undefined && (typeof key !== 'string' || !key || key.length > 8192 || /[\s\x00-\x1f\x7f]/.test(key))) throw new Error('invalid_provider');
58
+ // Only CLI write fields may be supplied; public-state flags are not writable.
59
+ if (Object.keys(a).some(k => !['id','kind','models','baseUrl','key'].includes(k))) throw new Error('invalid_provider');
60
+ return { ...rest, credential_present: a.kind === 'api_key', verification: 'not_checked', application: 'not_applied' };
61
+ }),
62
+ })) };
63
+ validateDaemonProviderSnapshot({ network_id: networkId, daemon_node_id: daemonId, source: 'daemon', revision: 0, providers: [publicProvider] }, networkId, daemonId);
64
+ return publicProvider;
65
+ }
66
+ export function requestProviderWrite(h: Human, daemonId: string, expectedRevision: number, provider: unknown) {
67
+ const refusal = authorize(h); if (refusal) return refusal;
68
+ if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
69
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || expectedRevision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
70
+ try { validateWrite(provider, h.networkId!, daemonId); } catch { return denied('invalid_provider'); }
71
+ expire(h.networkId!, daemonId);
72
+ if (db.get('SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status=\'pending\'', h.networkId!, daemonId)) return denied('provider_snapshot_busy');
73
+ try {
74
+ const enc = encryptSecret(JSON.stringify({ network_id: h.networkId, daemon_node_id: daemonId, provider }));
75
+ const requestId = `dps_${randomUUID()}`, now = Date.now();
76
+ db.transaction(() => {
77
+ db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
78
+ VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
79
+ db.run(`INSERT INTO daemon_provider_writes (request_id,expected_revision,ciphertext,iv,tag) VALUES (?1,?2,?3,?4,?5)`,
80
+ [requestId, expectedRevision, enc.ciphertext, enc.iv, enc.tag]);
81
+ });
82
+ return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
83
+ } catch (error) { return denied(error instanceof VaultError ? error.code : 'provider_write_unavailable'); }
84
+ }
85
+ function privateWrite(requestId: string, networkId: string, daemonId: string) {
86
+ const row = db.get<{ expected_revision: number; ciphertext: any; iv: any; tag: any }>('SELECT * FROM daemon_provider_writes WHERE request_id=?1', requestId);
87
+ if (!row) return null;
88
+ const payload = JSON.parse(decryptSecret({ ciphertext: Buffer.from(row.ciphertext), iv: Buffer.from(row.iv), tag: Buffer.from(row.tag) }));
89
+ if (payload.network_id !== networkId || payload.daemon_node_id !== daemonId) throw new Error('scope_mismatch');
90
+ validateWrite(payload.provider, networkId, daemonId);
91
+ return { expected_revision: row.expected_revision, provider: payload.provider };
92
+ }
93
+ function applicationIntent(requestId: string) {
94
+ const row = db.get<{ expected_revision: number; selection_json: string }>('SELECT * FROM daemon_provider_applications WHERE request_id=?1', requestId);
95
+ if (!row) return null;
96
+ const selection = JSON.parse(row.selection_json);
97
+ if (!validProviderApplicationSelection(selection)) throw Error('invalid_application');
98
+ return { expected_revision: row.expected_revision, selection };
99
+ }
100
+ function boundApplicationTarget(networkId: string, daemonId: string, selection: ProviderApplicationSelection) {
101
+ return selection.node_id !== daemonId && providerScanTargets(networkId, daemonId).find(n => n.node_id === selection.node_id);
102
+ }
103
+ export function requestProviderApplication(h: Human, daemonId: string, revision: number, selection: unknown) {
104
+ const refusal = authorize(h); if (refusal) return refusal;
105
+ if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
106
+ if (!Number.isSafeInteger(revision) || revision < 0) return denied('invalid_revision');
107
+ if (!validProviderApplicationSelection(selection)) return denied('invalid_application');
108
+ try {
109
+ if (!boundApplicationTarget(h.networkId!, daemonId, selection)) return denied('application_target_not_bound');
110
+ expire(h.networkId!, daemonId);
111
+ const requestId = `dpa_${randomUUID()}`, now = Date.now();
112
+ db.transaction(() => {
113
+ db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
114
+ VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 600_000]);
115
+ db.run('INSERT INTO daemon_provider_applications (request_id,expected_revision,selection_json) VALUES (?1,?2,?3)',
116
+ [requestId, revision, JSON.stringify(selection)]);
117
+ });
118
+ return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 600_000 };
119
+ } catch { return denied('provider_application_unavailable'); }
120
+ }
121
+ export function requestProviderSnapshot(h: Human, daemonId: string) {
122
+ const refusal = authorize(h); if (refusal) return refusal;
123
+ if (!daemonExists(h.networkId!, daemonId)) return denied("daemon_not_found");
124
+ expire(h.networkId!, daemonId);
125
+ // Repeated refresh clicks share the pending request; pending never means ready.
126
+ const pending = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at DESC LIMIT 1`, h.networkId!, daemonId);
127
+ if (pending) return { ok: true as const, request_id: pending.request_id, status: "pending", expires_at: pending.expires_at };
128
+ const requestId = `dps_${randomUUID()}`, now = Date.now();
129
+ db.run(`INSERT INTO daemon_provider_snapshots
130
+ (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
131
+ VALUES (?1,?2,?3,?4,'pending',?5,?6) ON CONFLICT DO NOTHING`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
132
+ const accepted = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending'`, h.networkId!, daemonId);
133
+ if (!accepted) return denied("provider_snapshot_busy");
134
+ return { ok: true as const, request_id: accepted.request_id, status: "pending", expires_at: accepted.expires_at };
135
+ }
136
+ export function getProviderSnapshot(h: Human, requestId: string) {
137
+ const refusal = authorize(h); if (refusal) return refusal;
138
+ expire(h.networkId!);
139
+ const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2`, requestId, h.networkId!);
140
+ if (!row) return denied("request_not_found");
141
+ return { ok: true, request_id: row.request_id, daemon_node_id: row.daemon_node_id, status: row.status,
142
+ expires_at: row.expires_at, observed_at: row.observed_at, error: row.error,
143
+ snapshot: !row.request_id.startsWith('dpa_') && row.snapshot_json ? JSON.parse(row.snapshot_json) : null,
144
+ ...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
145
+ }
146
+ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false) {
147
+ if (!d.ok) return denied("caller_not_a_daemon");
148
+ if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
149
+ expire(d.networkId, d.daemonNodeId);
150
+ const requests = db.all<{ request_id: string; expires_at: number }>(`SELECT request_id,expires_at FROM daemon_provider_snapshots
151
+ WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at LIMIT 20`, d.networkId, d.daemonNodeId);
152
+ try { return { ok: true, requests: requests.flatMap<Record<string, unknown>>(r => {
153
+ if (r.request_id.startsWith('dpa_')) {
154
+ if (!acceptApplications) return []; // old daemons must not consume applications as scans
155
+ const application = applicationIntent(r.request_id);
156
+ const target = application && boundApplicationTarget(d.networkId, d.daemonNodeId, application.selection);
157
+ if (!target) {
158
+ db.run("UPDATE daemon_provider_snapshots SET status='failed',error='application_target_not_bound',observed_at=?1 WHERE request_id=?2 AND status='pending'", [Date.now(), r.request_id]);
159
+ db.run('DELETE FROM daemon_provider_applications WHERE request_id=?1', [r.request_id]);
160
+ return [];
161
+ }
162
+ return [{ ...r, operation: 'apply', ...application!, alias: target.alias }];
163
+ }
164
+ if (!acceptWrites && db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', r.request_id)) return [];
165
+ const write = privateWrite(r.request_id, d.networkId, d.daemonNodeId);
166
+ return [write ? { ...r, operation: 'put', ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId) }];
167
+ }) }; } catch { return denied('provider_write_unavailable'); }
168
+ }
169
+ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown) {
170
+ if (!d.ok || !daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
171
+ expire(d.networkId, d.daemonNodeId);
172
+ const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2 AND daemon_node_id=?3`, requestId, d.networkId, d.daemonNodeId);
173
+ if (!row) return denied("request_not_found");
174
+ // First accepted receipt wins; reconnect replay cannot rewrite prior evidence.
175
+ if (row.status !== "pending") return { ok: row.status === "succeeded" || row.status === "failed", status: row.status };
176
+ if (requestId.startsWith('dpa_')) {
177
+ if (snapshot !== undefined || appliedRevision !== undefined || (failed && application !== undefined)) return denied('invalid_application_receipt');
178
+ if (!failed) {
179
+ try {
180
+ const intent = applicationIntent(requestId);
181
+ if (!intent || !boundApplicationTarget(d.networkId, d.daemonNodeId, intent.selection)
182
+ || !validProviderApplicationReceipt(application, requestId, intent.expected_revision, intent.selection)
183
+ || (application as any).runtime_provider !== `anet_${createHash('sha256').update(JSON.stringify([intent.selection.provider_id, intent.selection.auth_id])).digest('hex').slice(0, 20)}`
184
+ || (application as any).applied_at < row.created_at - 30_000) return denied('invalid_application_receipt');
185
+ } catch { return denied('invalid_application_receipt'); }
186
+ }
187
+ db.run(`UPDATE daemon_provider_snapshots SET status=?1,snapshot_json=?2,error=?3,observed_at=?4
188
+ WHERE request_id=?5 AND status='pending'`, [failed ? 'failed' : 'succeeded', failed ? null : JSON.stringify(application),
189
+ failed ? 'provider_application_unavailable' : null, Date.now(), requestId]);
190
+ db.run('DELETE FROM daemon_provider_applications WHERE request_id=?1', [requestId]);
191
+ return { ok: true, status: failed ? 'failed' : 'succeeded' };
192
+ }
193
+ if (application !== undefined) return denied('invalid_application_receipt');
194
+ let safe: unknown = null;
195
+ if (failed && snapshot !== undefined) return denied("invalid_provider_snapshot");
196
+ if (!failed) {
197
+ try { safe = validateDaemonProviderSnapshot(snapshot, d.networkId, d.daemonNodeId); }
198
+ catch { return denied("invalid_provider_snapshot"); }
199
+ if ((safe as any).codex_inventory) {
200
+ try {
201
+ const targets = new Map(providerScanTargets(d.networkId, d.daemonNodeId).map(n => [n.node_id, n.alias]));
202
+ const rows = (safe as any).codex_inventory.rows;
203
+ if (rows.length !== targets.size || rows.some((r: any) => targets.get(r.node_id) !== r.alias)) return denied('invalid_provider_snapshot');
204
+ } catch { return denied('invalid_provider_snapshot'); }
205
+ }
206
+ try {
207
+ const write = privateWrite(requestId, d.networkId, d.daemonNodeId);
208
+ if (write) {
209
+ const actual = safe as any;
210
+ if (appliedRevision !== write.expected_revision + 1 || actual.revision !== appliedRevision) return denied('provider_write_unconfirmed');
211
+ const expected = validateWrite(write.provider, d.networkId, d.daemonNodeId);
212
+ const saved = actual.providers.find((p: any) => p.id === expected.id);
213
+ // CLI canonicalizes label and endpoint. No credential is compared or returned here.
214
+ expected.label = expected.label.trim();
215
+ for (const r of expected.runtimes) for (const a of r.auth) if (a.baseUrl) a.baseUrl = new URL(a.baseUrl).toString().replace(/\/$/, '');
216
+ if (!isDeepStrictEqual(saved, expected)) return denied('provider_write_unconfirmed');
217
+ }
218
+ } catch { return denied('provider_write_unconfirmed'); }
219
+ }
220
+ db.run(`UPDATE daemon_provider_snapshots SET status=?1, snapshot_json=?2, error=?3, observed_at=?4
221
+ WHERE request_id=?5 AND status='pending'`, [failed ? "failed" : "succeeded", safe ? JSON.stringify(safe) : null,
222
+ failed ? "provider_snapshot_unavailable" : null, Date.now(), requestId]);
223
+ const accepted = db.get<Row>("SELECT * FROM daemon_provider_snapshots WHERE request_id=?1", requestId);
224
+ db.run('DELETE FROM daemon_provider_writes WHERE request_id=?1', [requestId]);
225
+ return { ok: true, status: accepted!.status };
226
+ }