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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,105 @@
1
+ // 桌面向导要看得到 daemon 回的失败原因:GET /api/node-create-requests?request_id=…
2
+ // 承重:① 只在调用者的网络作用域里能读到(别的网络 → 404,与不存在同形);② 无用户上下文 401;③ 缺参 400;
3
+ // ④ 回的是 daemon ack 写进去的 status/error 原文。
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-create-request-status-"));
10
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
11
+
12
+ let server: ReturnType<typeof Bun.serve>;
13
+ let base = "";
14
+ let aToken = "", bToken = "", aNet = "", bNet = "";
15
+ const MASTER_TOKEN = "legacy-master-token-for-test";
16
+
17
+ const get = async (path: string, token: string) => {
18
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
19
+ return { status: r.status, body: await r.json() as any };
20
+ };
21
+
22
+ beforeAll(async () => {
23
+ process.env.COMMHUB_AUTH_TOKEN ||= MASTER_TOKEN;
24
+ const { db } = await import("./db.js");
25
+ const { register } = await import("./auth.js");
26
+ const stamp = Date.now();
27
+ const admin = register(`cr_admin_${stamp}`, "CrAdmin-Strong-1!");
28
+ expect(admin.ok).toBe(true);
29
+ const a = register(`cr_a_${stamp}`, "CrA-Strong-1!");
30
+ const b = register(`cr_b_${stamp}`, "CrB-Strong-1!");
31
+ expect(a.ok && b.ok).toBe(true);
32
+ aToken = a.token!; bToken = b.token!; aNet = a.network_id!; bNet = b.network_id!;
33
+ const uid = (name: string) => db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", name)!.user_id;
34
+ for (const id of [uid(`cr_a_${stamp}`), uid(`cr_b_${stamp}`)]) {
35
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role).toBe("user");
36
+ }
37
+ db.run(
38
+ `INSERT INTO node_create_requests
39
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, acked_at, error)
40
+ VALUES ('cr_a_failed', 'node_daemon_a', 'vansin-go', ?1, 'opencode-cli', NULL, '{}', '[]', 'failed', NULL, ?2, 'tok', ?2,
41
+ 'opencode-cli package entrypoint verification currently requires Linux')`,
42
+ [aNet, Date.now()],
43
+ );
44
+ db.run(
45
+ `INSERT INTO node_create_requests
46
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
47
+ VALUES ('cr_b_pending', 'node_daemon_b', 'other', ?1, 'codex-app-server', NULL, '{}', '[]', 'pending', NULL, ?2, 'tok')`,
48
+ [bNet, Date.now()],
49
+ );
50
+ const mod = await import("./server.js");
51
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
52
+ base = `http://127.0.0.1:${server.port}`;
53
+ });
54
+
55
+ afterAll(() => {
56
+ try { server?.stop(true); } catch {}
57
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
58
+ });
59
+
60
+ describe("GET /api/node-create-requests?request_id=", () => {
61
+ test("A reads her own failed request with the daemon's error verbatim", async () => {
62
+ const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", aToken);
63
+ expect(status).toBe(200);
64
+ expect(body.ok).toBe(true);
65
+ expect(body.request).toMatchObject({
66
+ request_id: "cr_a_failed", child_name: "vansin-go", runtime: "opencode-cli", status: "failed",
67
+ error: "opencode-cli package entrypoint verification currently requires Linux",
68
+ });
69
+ });
70
+ test("🔴 B cannot read A's request — 404, same shape as nonexistent", async () => {
71
+ const other = await get("/api/node-create-requests?request_id=cr_a_failed", bToken);
72
+ const missing = await get("/api/node-create-requests?request_id=cr_nope", bToken);
73
+ expect(other.status).toBe(404);
74
+ expect(missing.status).toBe(404);
75
+ expect(other.body).toEqual(missing.body);
76
+ });
77
+ test("pending request reads as pending with no error", async () => {
78
+ const { body } = await get("/api/node-create-requests?request_id=cr_b_pending", bToken);
79
+ expect(body.request.status).toBe("pending");
80
+ expect(body.request.error).toBeNull();
81
+ expect(body.request.child_node_id).toBeNull();
82
+ expect(body.request.launch_verified_at).toBeNull();
83
+ });
84
+ test("registration timestamp is not launch proof; explicit proof and identity round-trip", async () => {
85
+ const { db } = await import('./db.js');
86
+ db.run(`UPDATE node_create_requests SET status='succeeded', child_node_id='node_verified', acked_at=1234 WHERE request_id='cr_b_pending'`);
87
+ const registered = await get('/api/node-create-requests?request_id=cr_b_pending', bToken);
88
+ expect(registered.body.request.acked_at).toBe(1234);
89
+ expect(registered.body.request.launch_verified_at).toBeNull();
90
+ db.run(`UPDATE node_create_requests SET launch_verified_at=5678 WHERE request_id='cr_b_pending'`);
91
+ const verified = await get('/api/node-create-requests?request_id=cr_b_pending', bToken);
92
+ expect(verified.body.request).toMatchObject({ child_node_id: 'node_verified', launch_verified_at: 5678 });
93
+ expect((await get('/api/node-create-requests?request_id=cr_b_pending', aToken)).status).toBe(404);
94
+ });
95
+ test("missing request_id → 400", async () => {
96
+ const { status, body } = await get("/api/node-create-requests", aToken);
97
+ expect(status).toBe(400);
98
+ expect(body.error).toBe("request_id_required");
99
+ });
100
+ test("legacy master token (no user context) → 401", async () => {
101
+ const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", MASTER_TOKEN);
102
+ expect(status).toBe(401);
103
+ expect(body.error).toBe("auth_required");
104
+ });
105
+ });
@@ -0,0 +1,132 @@
1
+ import { beforeEach, afterAll, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { registerTools } from "./tools.js";
5
+ import { buildControllableMap } from "./node-lifecycle-controllable.js";
6
+
7
+ const NET = "net_adoption_test", USER = "u_adoption_test", CHILD = "n_adoption_child", DAEMON = "node_adoption_daemon";
8
+ function cleanup() {
9
+ for (const table of ["node_daemon_bindings", "node_stop_requests", "node_start_requests", "node_create_requests", "audit_log", "sessions", "nodes", "api_tokens", "network_members", "networks"])
10
+ db.run(`DELETE FROM ${table} WHERE network_id=?1`, [NET]);
11
+ db.run("DELETE FROM users WHERE user_id=?1", [USER]);
12
+ }
13
+ beforeEach(() => {
14
+ cleanup();
15
+ db.run("INSERT INTO users(user_id,username,password_hash,role) VALUES(?1,?1,'x','user')", [USER]);
16
+ db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,?1,?2)", [NET, USER]);
17
+ db.run("INSERT INTO network_members(user_id,network_id,role) VALUES(?1,?2,'owner')", [USER, NET]);
18
+ for (const [id, alias, snap] of [[DAEMON, "adoption-daemon", { role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }], [CHILD, "adoption-child", {}]] as const) {
19
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state) VALUES(?1,?2,?2,?3,?4,'test-host',?5,'active')`, [id, alias, NET, USER, JSON.stringify(snap)]);
20
+ }
21
+ db.run("INSERT INTO sessions(resume_id,session_id,alias,network_id,status,last_seen_at) VALUES('adoption-session','adoption-session','adoption-daemon',?1,'idle',datetime('now'))", [NET]);
22
+ db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('adoption-token',?1,?2,'network','node:adoption-daemon','adoption-hash')", [USER, NET]);
23
+ });
24
+ afterAll(cleanup);
25
+ function handlers(kind: "user" | "daemon" | "stranger" = "user") {
26
+ const server = new McpServer({ name: "adoption-test", version: "0" }) as any;
27
+ const out: any = {}, original = server.tool.bind(server);
28
+ server.tool = (name: string, desc: string, schema: any, handler: any) => { out[name] = handler; return original(name, desc, schema, handler); };
29
+ registerTools(server, undefined, kind === "daemon" ? NET : null, kind === "user" ? USER : null, null, kind === "daemon", kind === "daemon" ? "adoption-token" : null);
30
+ return out;
31
+ }
32
+ const call = async (handler: any, args: any = {}) => JSON.parse((await handler(args)).content[0].text);
33
+ const request = () => call(handlers().request_adopt_node, { node_id: CHILD, daemon_node_id: DAEMON, workdir: "/workspace/project" });
34
+ async function adopt() {
35
+ const r = await request(); expect(r.ok).toBe(true);
36
+ expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
37
+ return r.request_id;
38
+ }
39
+
40
+ test("unbound node_ cannot use an explicitly supplied daemon (witnessed red on main)", async () => {
41
+ db.run("UPDATE nodes SET node_id='node_unbound_child' WHERE node_id=?1", [CHILD]);
42
+ expect(await call(handlers().stop_node, { child_node_id: "node_unbound_child", daemon_node_id: DAEMON, network_id: NET })).toMatchObject({ ok: false, error: "daemon_not_resolvable" });
43
+ expect(db.all("SELECT * FROM node_stop_requests WHERE network_id=?1", NET)).toHaveLength(0);
44
+ });
45
+ test("two-phase binding, daemon-bound pull, controllable map, adopted delete refused", async () => {
46
+ const r = await request(); expect(r.ok).toBe(true);
47
+ expect(await call(handlers().stop_node, { child_node_id: CHILD, daemon_node_id: DAEMON, network_id: NET })).toMatchObject({ ok: false });
48
+ expect(await call(handlers("daemon").get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: true, node_id: CHILD, alias: "adoption-child", workdir: "/workspace/project" });
49
+ expect(await call(handlers().get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: false });
50
+ expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true });
51
+ expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted", binding_request_id: r.request_id });
52
+ expect(buildControllableMap([], [{ node_id: CHILD, daemon_node_id: DAEMON }]).get(CHILD)).toBe(DAEMON);
53
+ expect(await call(handlers().delete_node, { child_node_id: CHILD, confirm_alias: "adoption-child", network_id: NET })).toMatchObject({ ok: false, error: "adopted_node_delete_unsupported" });
54
+ expect(db.get<any>("SELECT lifecycle_state FROM nodes WHERE node_id=?1", CHILD).lifecycle_state).toBe("active");
55
+ });
56
+ test("adopted stop/start complete through existing protocol; revocation removes authority", async () => {
57
+ await adopt(); const u = handlers(), d = handlers("daemon");
58
+ const stop = await call(u.stop_node, { child_node_id: CHILD, network_id: NET }); expect(stop.ok).toBe(true);
59
+ expect((await call(d.get_stop_request, { request_id: stop.request_id })).ok).toBe(true);
60
+ expect((await call(d.ack_stop_request, { request_id: stop.request_id, status: "stopped" })).ok).toBe(true);
61
+ expect((await call(d.list_my_children)).children[0].lifecycle_state).toBe("stopped");
62
+ const start = await call(u.start_node, { child_node_id: CHILD, network_id: NET }); expect(start.ok).toBe(true);
63
+ expect((await call(d.get_start_request, { request_id: start.request_id })).ok).toBe(true);
64
+ expect((await call(d.ack_start_request, { request_id: start.request_id, status: "started", child_pid: 4321 })).ok).toBe(true);
65
+ expect((await call(u.unadopt_node, { node_id: CHILD })).ok).toBe(true);
66
+ expect((await call(d.list_my_children)).children).toHaveLength(0);
67
+ expect((await call(u.stop_node, { child_node_id: CHILD, daemon_node_id: DAEMON, network_id: NET })).ok).toBe(false);
68
+ });
69
+ test("pending cancellation cannot be resurrected; duplicate pending refuses", async () => {
70
+ const r = await request(); expect(r.ok).toBe(true);
71
+ expect((await request()).error).toBe("node_already_managed");
72
+ expect((await call(handlers().unadopt_node, { node_id: CHILD })).ok).toBe(true);
73
+ expect((await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_pending");
74
+ });
75
+ test("human-only and owner/admin fail closed; admin exception audited", async () => {
76
+ const args = { node_id: CHILD, daemon_node_id: DAEMON, workdir: "/workspace/project" };
77
+ expect((await call(handlers("daemon").request_adopt_node, args)).ok).toBe(false);
78
+ expect((await call(handlers("stranger").request_adopt_node, args)).ok).toBe(false);
79
+ db.run("UPDATE network_members SET role='member' WHERE network_id=?1", [NET]);
80
+ db.run("UPDATE nodes SET owner_user_id=NULL WHERE node_id=?1", [CHILD]);
81
+ expect((await request()).error).toBe("adopt_forbidden");
82
+ db.run("UPDATE network_members SET role='owner' WHERE network_id=?1", [NET]);
83
+ expect((await request()).ok).toBe(true);
84
+ expect(db.get("SELECT action FROM audit_log WHERE action='adopt_by_admin' AND network_id=?1", NET)).not.toBeNull();
85
+ });
86
+ test("viewer owner cannot request; membership loss before ack cannot activate", async () => {
87
+ db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1", [NET]);
88
+ expect((await request()).error).toBe("adopt_forbidden");
89
+ db.run("UPDATE network_members SET role='owner' WHERE network_id=?1", [NET]);
90
+ const r = await request(); expect(r.ok).toBe(true);
91
+ db.run("DELETE FROM network_members WHERE network_id=?1", [NET]);
92
+ expect((await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("adopt_forbidden");
93
+ });
94
+ test("wrong daemon cannot pull/ack; refusal releases reservation; in-flight unadopt is refused", async () => {
95
+ const r = await request(); expect(r.ok).toBe(true);
96
+ db.run("UPDATE node_daemon_bindings SET daemon_node_id='other-daemon' WHERE request_id=?1", [r.request_id]);
97
+ const d = handlers("daemon");
98
+ expect((await call(d.get_adopt_request, { request_id: r.request_id })).error).toBe("request_not_found");
99
+ expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_found");
100
+ db.run("UPDATE node_daemon_bindings SET daemon_node_id=?1 WHERE request_id=?2", [DAEMON, r.request_id]);
101
+ expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "refused", error: "adopt_roots_not_configured" })).status).toBe("refused");
102
+ await adopt();
103
+ expect((await call(handlers().stop_node, { child_node_id: CHILD, network_id: NET })).ok).toBe(true);
104
+ expect((await call(handlers().unadopt_node, { node_id: CHILD })).error).toBe("node_lifecycle_in_flight");
105
+ });
106
+ test("old daemon, hostname mismatch, stale heartbeat and cross-network daemon rejected", async () => {
107
+ db.run("UPDATE nodes SET config_snapshot='null' WHERE node_id=?1", [DAEMON]);
108
+ expect((await request()).error).toBe("daemon_not_adopt_capable");
109
+ db.run("UPDATE nodes SET config_snapshot='{}' WHERE node_id=?1", [DAEMON]);
110
+ expect((await request()).error).toBe("daemon_not_adopt_capable");
111
+ db.run("UPDATE nodes SET config_snapshot=?1,hostname='different' WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }), DAEMON]);
112
+ expect((await request()).error).toBe("hostname_mismatch");
113
+ db.run("UPDATE nodes SET hostname='test-host' WHERE node_id=?1", [DAEMON]);
114
+ db.run("UPDATE sessions SET last_seen_at='2000-01-01 00:00:00' WHERE network_id=?1", [NET]);
115
+ expect((await request()).error).toBe("daemon_offline");
116
+ expect((await call(handlers().request_adopt_node, { node_id: CHILD, daemon_node_id: "other_network_daemon", workdir: "/workspace/project" })).error).toBe("daemon_not_found");
117
+ });
118
+ test("network boundaries apply to both human request and daemon pull/ack", async () => {
119
+ const r = await request(); expect(r.ok).toBe(true);
120
+ db.run("UPDATE node_daemon_bindings SET network_id='net_other_adoption_test' WHERE request_id=?1", [r.request_id]);
121
+ try {
122
+ const d = handlers("daemon");
123
+ expect((await call(d.get_adopt_request, { request_id: r.request_id })).error).toBe("request_not_found");
124
+ expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_found");
125
+ } finally { db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2", [NET, r.request_id]); }
126
+ db.run("UPDATE nodes SET network_id='net_other_adoption_test' WHERE node_id=?1", [DAEMON]);
127
+ try { expect((await request()).error).toBe("daemon_not_found"); }
128
+ finally { db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2", [NET, DAEMON]); }
129
+ db.run("UPDATE nodes SET network_id='net_other_adoption_test' WHERE node_id=?1", [CHILD]);
130
+ try { expect((await request()).error).toBe("adopt_forbidden"); }
131
+ finally { db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2", [NET, CHILD]); }
132
+ });
@@ -0,0 +1,130 @@
1
+ import { db, uuidv4 } from "./db.js";
2
+ import { getUserNetworkRole } from "./auth.js";
3
+ import { pushEvent } from "./push.js";
4
+ import { canRestWriteNetwork } from "./network-scope.js";
5
+ import { parseDbTimestampMs } from "./db-timestamp.js";
6
+
7
+ type Node = { node_id: string; alias: string; network_id: string; owner_user_id: string | null; hostname: string | null; config_snapshot: string | null };
8
+ type Binding = { request_id: string; network_id: string; node_id: string; daemon_node_id: string; workdir: string; requested_by: string; status: string; error: string | null };
9
+ type Human = { userId?: string | null; networkId?: string | null; isNode: boolean; canWrite: (networkId: string) => boolean };
10
+ type Daemon = { ok: true; daemonNodeId: string; networkId: string } | { ok: false; error: string };
11
+ const fail = (error: string) => ({ ok: false as const, error });
12
+ const snapshot = (node: Node) => {
13
+ try { const value = JSON.parse(node.config_snapshot || "{}"); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
14
+ catch { return {}; }
15
+ };
16
+
17
+ export function createdDaemon(nodeId: string): string | null {
18
+ if (!nodeId.startsWith("node_")) return null;
19
+ return db.get<{ daemon_node_id: string }>("SELECT daemon_node_id FROM node_create_requests WHERE request_id=?1", `cr_${nodeId.slice(5)}`)?.daemon_node_id ?? null;
20
+ }
21
+ export function activeBinding(nodeId: string): Binding | null {
22
+ return db.get<Binding>("SELECT * FROM node_daemon_bindings WHERE node_id=?1 AND status='active'", nodeId);
23
+ }
24
+ export function resolveManagedDaemon(nodeId: string): string | null {
25
+ return createdDaemon(nodeId) ?? activeBinding(nodeId)?.daemon_node_id ?? null;
26
+ }
27
+ function humanNode(ctx: Human, nodeId: string): Node | null {
28
+ if (ctx.isNode || !ctx.userId) return null;
29
+ const node = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", nodeId);
30
+ if (!node || (ctx.networkId && ctx.networkId !== node.network_id) || !ctx.canWrite(node.network_id)) return null;
31
+ const role = getUserNetworkRole(ctx.userId, node.network_id);
32
+ return node.owner_user_id === ctx.userId || role === "owner" || role === "admin" ? node : null;
33
+ }
34
+ function audit(binding: Binding, action: string, actor: string) {
35
+ db.run(`INSERT INTO audit_log(user_id,action,target_type,target_id,detail,network_id)
36
+ VALUES(?1,?2,'node_daemon_binding',?3,?4,?5)`,
37
+ [actor, action, binding.request_id, JSON.stringify({ node_id: binding.node_id, daemon_node_id: binding.daemon_node_id }), binding.network_id]);
38
+ }
39
+ function daemonReady(daemon: Node | null, node: Node): string | null {
40
+ if (!daemon || daemon.network_id !== node.network_id) return "daemon_not_found";
41
+ const snap = snapshot(daemon);
42
+ if (snap.role !== "host_supervisor" || snap.daemon_capabilities?.adopt_capable !== true) return "daemon_not_adopt_capable";
43
+ if (!node.hostname || daemon.hostname !== node.hostname) return "hostname_mismatch";
44
+ const session = db.get<{ last_seen_at: string }>(`SELECT last_seen_at FROM sessions WHERE alias=?1 AND network_id=?2`, daemon.alias, daemon.network_id);
45
+ const seen = parseDbTimestampMs(session?.last_seen_at ?? "");
46
+ if (!Number.isFinite(seen) || Date.now() - seen > 300_000) return "daemon_offline";
47
+ const token = db.get(`SELECT token_id FROM api_tokens WHERE name=?1 AND network_id=?2 AND revoked_at IS NULL`, `node:${daemon.alias}`, daemon.network_id);
48
+ return token ? null : "daemon_offline";
49
+ }
50
+
51
+ export function requestAdopt(ctx: Human, args: { node_id: string; daemon_node_id: string; workdir: string }) {
52
+ if (ctx.isNode || !ctx.userId) return fail("user_token_required");
53
+ if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
54
+ const node = humanNode(ctx, args.node_id);
55
+ if (!node) return fail("adopt_forbidden");
56
+ if (snapshot(node).role === "host_supervisor") return fail("cannot_adopt_daemon");
57
+ if (createdDaemon(node.node_id)) return fail("node_already_managed");
58
+ if (!args.workdir.trim() || /[\x00-\x1f]/.test(args.workdir)) return fail("invalid_workdir");
59
+ const daemon = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", args.daemon_node_id);
60
+ const refused = daemonReady(daemon, node);
61
+ if (refused) return fail(refused);
62
+ const now = Date.now();
63
+ const binding: Binding = { ...args, request_id: `adopt_${uuidv4()}`, network_id: node.network_id, requested_by: ctx.userId, status: "pending", error: null };
64
+ const inserted = db.transaction(() => {
65
+ const result = db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at)
66
+ VALUES(?1,?2,?3,?4,?5,?6,'pending',?7,?7) ON CONFLICT DO NOTHING`,
67
+ [binding.request_id, binding.network_id, node.node_id, args.daemon_node_id, args.workdir, ctx.userId, now]);
68
+ if (!result.changes) return false;
69
+ audit(binding, node.owner_user_id === ctx.userId ? "adopt_requested" : "adopt_by_admin", ctx.userId!);
70
+ return true;
71
+ });
72
+ if (!inserted) return fail("node_already_managed");
73
+ pushEvent(daemon!.alias, { type: "adopt_node", request_id: binding.request_id }, node.network_id);
74
+ return { ok: true, request_id: binding.request_id, status: "pending" };
75
+ }
76
+
77
+ function daemonBinding(caller: Daemon, requestId: string): Binding | null {
78
+ if (!caller.ok) return null;
79
+ return db.get<Binding>(`SELECT * FROM node_daemon_bindings WHERE request_id=?1 AND daemon_node_id=?2 AND network_id=?3`, requestId, caller.daemonNodeId, caller.networkId);
80
+ }
81
+ export function getAdopt(caller: Daemon, requestId: string) {
82
+ if (!caller.ok) return fail(caller.error);
83
+ const binding = daemonBinding(caller, requestId);
84
+ if (!binding) return fail("request_not_found");
85
+ if (binding.status !== "pending") return fail("request_not_pending");
86
+ const node = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1 AND network_id=?2", binding.node_id, binding.network_id);
87
+ if (!node) return fail("node_not_found");
88
+ return { ok: true, ...binding, alias: node.alias };
89
+ }
90
+ export function ackAdopt(caller: Daemon, args: { request_id: string; status: "adopted" | "refused"; error?: string }) {
91
+ if (!caller.ok) return fail(caller.error);
92
+ if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
93
+ const binding = daemonBinding(caller, args.request_id);
94
+ if (!binding) return fail("request_not_found");
95
+ return db.transaction(() => {
96
+ // Ownership/membership can change while local verification is in flight.
97
+ if (args.status === "adopted") {
98
+ const requester: Human = { userId: binding.requested_by, networkId: binding.network_id, isNode: false,
99
+ canWrite: networkId => canRestWriteNetwork({ userId: binding.requested_by, networkId: null }, networkId, false) };
100
+ const node = humanNode(requester, binding.node_id);
101
+ if (!node || createdDaemon(binding.node_id)) return fail("adopt_forbidden");
102
+ const refusal = daemonReady(db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", binding.daemon_node_id), node);
103
+ if (refusal) return fail(refusal);
104
+ }
105
+ const changed = db.run(`UPDATE node_daemon_bindings SET status=?1,error=?2,updated_at=?3 WHERE request_id=?4 AND status='pending'`,
106
+ [args.status === "adopted" ? "active" : "refused", args.error ?? null, Date.now(), args.request_id]);
107
+ if (!changed.changes) return fail("request_not_pending");
108
+ audit(binding, `adopt_${args.status}`, caller.daemonNodeId);
109
+ return { ok: true, status: args.status === "adopted" ? "active" : "refused" };
110
+ });
111
+ }
112
+ export function unadopt(ctx: Human, nodeId: string) {
113
+ if (ctx.isNode || !ctx.userId) return fail("user_token_required");
114
+ if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
115
+ const node = humanNode(ctx, nodeId);
116
+ if (!node) return fail("adopt_forbidden");
117
+ const binding = db.get<Binding>("SELECT * FROM node_daemon_bindings WHERE node_id=?1 AND status IN ('pending','active')", nodeId);
118
+ if (!binding) return fail("binding_not_found");
119
+ // Do not reassign authority while a daemon may already be executing a lifecycle command.
120
+ for (const table of ["node_stop_requests", "node_start_requests"]) {
121
+ if (db.get(`SELECT request_id FROM ${table} WHERE child_node_id=?1 AND network_id=?2 AND status IN ('pending','delivered')`, nodeId, node.network_id)) return fail("node_lifecycle_in_flight");
122
+ }
123
+ db.transaction(() => {
124
+ db.run("UPDATE node_daemon_bindings SET status='revoked',updated_at=?1 WHERE request_id=?2", [Date.now(), binding.request_id]);
125
+ audit(binding, "unadopted", ctx.userId!);
126
+ });
127
+ const daemon = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1 AND network_id=?2", binding.daemon_node_id, node.network_id);
128
+ if (daemon) pushEvent(daemon.alias, { type: "unadopt_node", node_id: nodeId, request_id: binding.request_id }, node.network_id);
129
+ return { ok: true, status: "revoked" };
130
+ }