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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-department-http.test.ts +148 -0
  16. package/src/agent-groups-perf.test.ts +223 -0
  17. package/src/agent-notice.ts +48 -0
  18. package/src/agent-team-whoami-http.test.ts +98 -0
  19. package/src/agent-teams-http.test.ts +323 -0
  20. package/src/agent-teams.ts +244 -0
  21. package/src/alias-filter.test.ts +72 -0
  22. package/src/alias-filter.ts +42 -0
  23. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  24. package/src/api-host-supervisors-fallback.test.ts +271 -0
  25. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  26. package/src/api-nodes-shape.test.ts +26 -10
  27. package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
  28. package/src/auth-sessions-http.test.ts +250 -0
  29. package/src/auth-validate.test.ts +5 -1
  30. package/src/auth-weak-password-login.test.ts +176 -0
  31. package/src/auth.ts +604 -51
  32. package/src/avatar-validate.test.ts +163 -0
  33. package/src/avatar-validate.ts +103 -0
  34. package/src/binding-generation-http.test.ts +121 -0
  35. package/src/blocked-exit-on-reply.test.ts +122 -0
  36. package/src/boot-side-effect.test.ts +124 -0
  37. package/src/chain-attempt-dedup.test.ts +447 -0
  38. package/src/clone-node.test.ts +248 -0
  39. package/src/clone-node.ts +289 -0
  40. package/src/codex-config-flags.ts +77 -0
  41. package/src/codex-fork-contract.ts +23 -0
  42. package/src/codex-fork-migration.test.ts +38 -0
  43. package/src/commhub-bin-args.test.ts +85 -0
  44. package/src/config-apply-sec1.test.ts +107 -0
  45. package/src/config-apply-validate.test.ts +134 -1
  46. package/src/config-apply-validate.ts +111 -5
  47. package/src/create-node-blocked-gate.test.ts +356 -0
  48. package/src/create-node-network-id.test.ts +79 -0
  49. package/src/create-node-tool-schema.test.ts +49 -0
  50. package/src/create-node-unicode-name.test.ts +120 -0
  51. package/src/create-node-validate.test.ts +199 -7
  52. package/src/create-node-validate.ts +232 -15
  53. package/src/create-node-workdir.test.ts +289 -0
  54. package/src/create-node.ts +88 -23
  55. package/src/daemon-provider-snapshots.test.ts +421 -0
  56. package/src/daemon-provider-snapshots.ts +327 -0
  57. package/src/daemon-token-owner-http.test.ts +236 -0
  58. package/src/dashboard-slash-routing-http.test.ts +109 -0
  59. package/src/db-adapter-guard.test.ts +148 -0
  60. package/src/db-adapter-translate.test.ts +128 -0
  61. package/src/db-adapter.ts +323 -87
  62. package/src/db-timestamp.test.ts +38 -0
  63. package/src/db-timestamp.ts +15 -0
  64. package/src/db.ts +1119 -42
  65. package/src/delivered-without-push.test.ts +140 -0
  66. package/src/department-groups-http.test.ts +209 -0
  67. package/src/department-groups-sync-http.test.ts +328 -0
  68. package/src/department-groups.ts +434 -0
  69. package/src/department-heads-http.test.ts +330 -0
  70. package/src/department-heads.ts +226 -0
  71. package/src/departments-http.test.ts +185 -0
  72. package/src/departments.ts +276 -0
  73. package/src/desktop-message-seam.test.ts +150 -0
  74. package/src/desktop-message.test.ts +368 -0
  75. package/src/dm-file-scope-http.test.ts +178 -0
  76. package/src/env-transport-gate-http.test.ts +146 -0
  77. package/src/env-transport-gate.ts +74 -0
  78. package/src/env-transport-policy.ts +21 -0
  79. package/src/ext-token-shared.test.ts +60 -0
  80. package/src/ext-validation-regression.test.ts +131 -0
  81. package/src/external-schedule-edits-http.test.ts +321 -0
  82. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  83. package/src/external-schedule-edits.ts +327 -0
  84. package/src/file-download-authz-dev-open.test.ts +135 -0
  85. package/src/file-download-authz.test.ts +404 -0
  86. package/src/file-download-date-bucket.test.ts +436 -0
  87. package/src/file-network-scope-dev-open.test.ts +97 -0
  88. package/src/file-network-scope.test.ts +732 -0
  89. package/src/group-membership-events-http.test.ts +264 -0
  90. package/src/group-messages-http.test.ts +482 -0
  91. package/src/group-messages.ts +297 -0
  92. package/src/health-redaction.test.ts +642 -0
  93. package/src/host-supervisor-identity-http.test.ts +152 -0
  94. package/src/host-supervisor-identity.ts +92 -0
  95. package/src/http-gzip.test.ts +94 -0
  96. package/src/http-gzip.ts +112 -0
  97. package/src/hub-catalog-read-http.test.ts +187 -0
  98. package/src/hub-provider-config.test.ts +139 -0
  99. package/src/hub-provider-config.ts +105 -0
  100. package/src/hub-timestamp.test.ts +38 -0
  101. package/src/hub-timestamp.ts +27 -0
  102. package/src/human-dm.ts +215 -0
  103. package/src/human-inbox.ts +42 -0
  104. package/src/inbox-caller.ts +49 -0
  105. package/src/inbox-count-sse.test.ts +310 -0
  106. package/src/inbox-count.ts +29 -0
  107. package/src/index.ts +15 -2362
  108. package/src/lifecycle-guard.ts +1 -1
  109. package/src/list-host-supervisors.test.ts +5 -4
  110. package/src/mcp-context-cost-http.test.ts +123 -0
  111. package/src/mcp-projects-events-http.test.ts +162 -0
  112. package/src/mcp-route-label-http.test.ts +120 -0
  113. package/src/mcp-write-network-resolution.test.ts +432 -0
  114. package/src/member-audit-network-id-http.test.ts +93 -0
  115. package/src/member-presence-http.test.ts +210 -0
  116. package/src/messages-ack-by-agent-http.test.ts +142 -0
  117. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  118. package/src/migrate-sqlite-to-pg.ts +263 -0
  119. package/src/model-auth-notify-http.test.ts +181 -0
  120. package/src/model-auth-notify.ts +116 -0
  121. package/src/network-name-http.test.ts +75 -0
  122. package/src/network-scope.ts +276 -0
  123. package/src/network-token-membership-http.test.ts +139 -0
  124. package/src/node-alias-control.ts +12 -0
  125. package/src/node-attrs-validate.ts +88 -0
  126. package/src/node-attrs.test.ts +217 -0
  127. package/src/node-caller-identity-http.test.ts +1083 -0
  128. package/src/node-create-request-status-http.test.ts +105 -0
  129. package/src/node-daemon-bindings.test.ts +132 -0
  130. package/src/node-daemon-bindings.ts +135 -0
  131. package/src/node-files-transport.test.ts +344 -0
  132. package/src/node-health-codex-login.test.ts +120 -0
  133. package/src/node-health-dispatch-http.test.ts +279 -0
  134. package/src/node-health-guard.ts +105 -0
  135. package/src/node-health-passthrough.test.ts +115 -0
  136. package/src/node-health-store.ts +101 -0
  137. package/src/node-id-alias.test.ts +190 -0
  138. package/src/node-identity-conflict-http.test.ts +269 -0
  139. package/src/node-identity-conflict.ts +142 -0
  140. package/src/node-identity-mismatch.test.ts +74 -0
  141. package/src/node-lifecycle-controllable.test.ts +64 -0
  142. package/src/node-lifecycle-controllable.ts +53 -0
  143. package/src/node-lifecycle-read-http.test.ts +245 -0
  144. package/src/node-lifecycle-read.ts +121 -0
  145. package/src/node-logs-transport.test.ts +263 -0
  146. package/src/node-permissions-http.test.ts +276 -0
  147. package/src/node-permissions.ts +285 -0
  148. package/src/node-request-retention.test.ts +281 -0
  149. package/src/node-request-retention.ts +120 -0
  150. package/src/node-secret-rest-http.test.ts +190 -0
  151. package/src/node-skills-transport.test.ts +209 -0
  152. package/src/node-token-identity.ts +35 -0
  153. package/src/node-token-ownership.ts +51 -0
  154. package/src/node-token-privilege-http.test.ts +158 -0
  155. package/src/observer-avatar-http.test.ts +442 -0
  156. package/src/observer-push.test.ts +252 -0
  157. package/src/opencode-v2-catalog.test.ts +268 -0
  158. package/src/opencode-v2-catalog.ts +352 -0
  159. package/src/package-version-consistency.test.ts +25 -0
  160. package/src/password-network-tokens.ts +25 -0
  161. package/src/peer-reply-atomic.test.ts +455 -0
  162. package/src/peer-reply-capability.test.ts +70 -0
  163. package/src/pg-worker.ts +87 -0
  164. package/src/project-dir-warn-clear-http.test.ts +132 -0
  165. package/src/project-viewer-can-http.test.ts +114 -0
  166. package/src/push.ts +429 -10
  167. package/src/redact-tokens.test.ts +65 -0
  168. package/src/redact-tokens.ts +40 -0
  169. package/src/rename.ts +38 -10
  170. package/src/reply-target-derivation.test.ts +96 -0
  171. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  172. package/src/report-status-host-schema.test.ts +235 -0
  173. package/src/report-status-resume-id-handover.test.ts +184 -0
  174. package/src/report-status-secret-keys.test.ts +140 -0
  175. package/src/require-explicit-test-db.ts +9 -0
  176. package/src/requirement-due-reminders-http.test.ts +620 -0
  177. package/src/requirement-due-reminders.ts +416 -0
  178. package/src/requirement-events-http.test.ts +268 -0
  179. package/src/requirement-events.test.ts +45 -0
  180. package/src/requirement-events.ts +144 -0
  181. package/src/requirement-notify.ts +165 -0
  182. package/src/requirement-participant-notify-http.test.ts +309 -0
  183. package/src/requirement-tag-ops-http.test.ts +266 -0
  184. package/src/requirement-tags.ts +89 -0
  185. package/src/requirements-abandoned-http.test.ts +236 -0
  186. package/src/requirements-comment-http.test.ts +167 -0
  187. package/src/requirements-completed-at-migration.test.ts +70 -0
  188. package/src/requirements-errors-mcp-http.test.ts +164 -0
  189. package/src/requirements-errors.ts +143 -0
  190. package/src/requirements-etag-http.test.ts +91 -0
  191. package/src/requirements-http.test.ts +709 -0
  192. package/src/requirements-last-event-http.test.ts +349 -0
  193. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  194. package/src/requirements-list-slim-http.test.ts +319 -0
  195. package/src/requirements-mcp.test.ts +163 -0
  196. package/src/requirements-migrate.ts +259 -0
  197. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  198. package/src/requirements-people-http.test.ts +114 -0
  199. package/src/requirements-people-mcp-http.test.ts +225 -0
  200. package/src/requirements-people.ts +131 -0
  201. package/src/requirements-priority-lowest.test.ts +114 -0
  202. package/src/requirements-search-http.test.ts +182 -0
  203. package/src/requirements-search.test.ts +32 -0
  204. package/src/requirements-search.ts +145 -0
  205. package/src/requirements-seq-http.test.ts +193 -0
  206. package/src/requirements-seq-migration.test.ts +63 -0
  207. package/src/requirements-start-date.test.ts +125 -0
  208. package/src/requirements-stats-http.test.ts +357 -0
  209. package/src/requirements-stats.ts +195 -0
  210. package/src/requirements-status-participants-http.test.ts +222 -0
  211. package/src/requirements-upgrade-e2e.test.ts +126 -0
  212. package/src/requirements-upgrade-migration.test.ts +98 -0
  213. package/src/requirements.ts +1806 -0
  214. package/src/resolve-port.test.ts +57 -0
  215. package/src/resolve-port.ts +44 -0
  216. package/src/response-charset.test.ts +130 -0
  217. package/src/rest-explicit-columns-http.test.ts +222 -0
  218. package/src/rest-identity.test.ts +130 -0
  219. package/src/rest-identity.ts +141 -0
  220. package/src/rest-projections.ts +70 -0
  221. package/src/rest-write-network-resolution.test.ts +133 -0
  222. package/src/restricted-files.ts +76 -0
  223. package/src/retention.ts +15 -3
  224. package/src/route-callers-http.test.ts +120 -0
  225. package/src/route-timing.test.ts +211 -0
  226. package/src/route-timing.ts +258 -0
  227. package/src/rules-file-duplicate-alias.test.ts +234 -0
  228. package/src/rules-file-session-target.test.ts +207 -0
  229. package/src/rules-file-transport.test.ts +237 -0
  230. package/src/runtime-label.test.ts +22 -0
  231. package/src/runtime-label.ts +21 -0
  232. package/src/runtime-readiness.ts +90 -0
  233. package/src/schedule-agent-mcp-http.test.ts +381 -0
  234. package/src/schedule-agent.ts +192 -0
  235. package/src/scheduled-backoff-http.test.ts +193 -0
  236. package/src/scheduled-backoff.ts +64 -0
  237. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  238. package/src/scheduled-failures-http.test.ts +233 -0
  239. package/src/scheduled-failures.ts +179 -0
  240. package/src/scheduled-offline.test.ts +131 -0
  241. package/src/scheduled-offline.ts +66 -0
  242. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  243. package/src/scheduled-outcome-guidance.ts +9 -0
  244. package/src/scheduled-reply-unread-http.test.ts +212 -0
  245. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  246. package/src/scheduled-requirement-wake.ts +125 -0
  247. package/src/scheduled-run-terminal.test.ts +268 -0
  248. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  249. package/src/scheduled-stuck-http.test.ts +312 -0
  250. package/src/scheduled-stuck.ts +186 -0
  251. package/src/scheduled-tasks-http.test.ts +679 -0
  252. package/src/scheduled-tasks.ts +885 -0
  253. package/src/scheduled-work-outcome-http.test.ts +154 -0
  254. package/src/scheduled-work-outcome.ts +50 -0
  255. package/src/scoped-secrets.ts +139 -0
  256. package/src/send-dedup-request-id.test.ts +229 -0
  257. package/src/send-reply-agent-warning.test.ts +202 -0
  258. package/src/send-reply-alias-optional.test.ts +131 -0
  259. package/src/send-reply-attachments.test.ts +458 -0
  260. package/src/send_dedup.test.ts +54 -0
  261. package/src/send_dedup.ts +52 -6
  262. package/src/serve-error.test.ts +38 -0
  263. package/src/serve-error.ts +27 -0
  264. package/src/server.ts +5398 -0
  265. package/src/session-task-on-dispatch.ts +14 -0
  266. package/src/session-token-ownership-http.test.ts +122 -0
  267. package/src/shared/codex-inventory.ts +41 -0
  268. package/src/shared/daemon-provider-login.ts +31 -0
  269. package/src/shared/daemon-provider-snapshot.ts +52 -0
  270. package/src/shared/external-schedule-contract.ts +60 -0
  271. package/src/shared/node-name.test.ts +62 -0
  272. package/src/shared/node-name.ts +161 -0
  273. package/src/shared/provider-application.ts +29 -0
  274. package/src/shared/provider-collection.ts +14 -0
  275. package/src/shared/provider-initialization.ts +8 -0
  276. package/src/shared/reserved-env-drift.test.ts +10 -0
  277. package/src/shared/scoped-secret-contract.ts +5 -0
  278. package/src/shared/task-content-limit-drift.test.ts +28 -0
  279. package/src/shared/task-content-limit.ts +25 -0
  280. package/src/side-thread-command-transport.test.ts +312 -0
  281. package/src/side-thread-command-transport.ts +318 -0
  282. package/src/side-thread-http-integration.test.ts +233 -0
  283. package/src/side-thread-http.ts +504 -0
  284. package/src/side-thread-production.test.ts +72 -0
  285. package/src/side-thread-production.ts +118 -0
  286. package/src/side-thread.test.ts +1067 -0
  287. package/src/side-thread.ts +2499 -0
  288. package/src/skillhub-http.test.ts +108 -0
  289. package/src/sqlite-close-checkpoint.test.ts +78 -0
  290. package/src/start-node.test.ts +264 -0
  291. package/src/status-alias-filter-http.test.ts +106 -0
  292. package/src/status-alias-resolver-http.test.ts +163 -0
  293. package/src/status-alias-resolver.ts +19 -0
  294. package/src/status-node-id-filter-http.test.ts +161 -0
  295. package/src/status-read-cache-http.test.ts +229 -0
  296. package/src/status-read-cache.ts +122 -0
  297. package/src/stop-delete-node.test.ts +509 -2
  298. package/src/task-access-http.test.ts +402 -0
  299. package/src/task-access.ts +420 -0
  300. package/src/task-activity.ts +60 -0
  301. package/src/task-auth-origin-http.test.ts +93 -0
  302. package/src/task-auth-origin.test.ts +24 -0
  303. package/src/task-auth-origin.ts +7 -0
  304. package/src/task-consumption.test.ts +482 -0
  305. package/src/task-diagnostic-http.test.ts +149 -0
  306. package/src/task-diagnostic.test.ts +115 -0
  307. package/src/task-diagnostic.ts +118 -0
  308. package/src/task-expiry-notice-http.test.ts +287 -0
  309. package/src/task-expiry-notice.ts +315 -0
  310. package/src/task-idempotency-mcp.test.ts +142 -0
  311. package/src/task-idempotency.test.ts +60 -0
  312. package/src/task-idempotency.ts +75 -0
  313. package/src/task-late-replies.ts +54 -0
  314. package/src/task-late-reply-http.test.ts +152 -0
  315. package/src/task-lifecycle-watcher.test.ts +204 -0
  316. package/src/task-lifecycle-watcher.ts +49 -0
  317. package/src/task-network-resolution-http.test.ts +140 -0
  318. package/src/task-orphan-http.test.ts +287 -0
  319. package/src/task-orphan.ts +119 -0
  320. package/src/task-queue-ahead-http.test.ts +309 -0
  321. package/src/task-queue-ahead-scale.test.ts +236 -0
  322. package/src/task-queue-ahead.ts +186 -0
  323. package/src/task-rejection-log-http.test.ts +160 -0
  324. package/src/task-rejection-log.ts +49 -0
  325. package/src/task-sender-http.test.ts +143 -0
  326. package/src/task-stale-open-http.test.ts +290 -0
  327. package/src/task-stale-open.ts +103 -0
  328. package/src/tasks-pagination-http.test.ts +141 -0
  329. package/src/tool-audience-http.test.ts +220 -0
  330. package/src/tool-audience.ts +118 -0
  331. package/src/tools-list-json-schema.test.ts +67 -0
  332. package/src/tools.ts +4008 -355
  333. package/src/upload-early-reject-guard.test.ts +155 -0
  334. package/src/uploads-http.test.ts +97 -15
  335. package/src/uploads.ts +103 -6
  336. package/src/user-avatar-http.test.ts +74 -0
  337. package/src/user-avatar-migration.test.ts +25 -0
  338. package/src/user-inbox-by-agent-http.test.ts +161 -0
  339. package/src/user-inbox-netid-notnull.test.ts +122 -0
  340. package/src/user-inbox-read-http.test.ts +223 -0
  341. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,1083 @@
1
+ // Caller identity goes through one resolver (resolveNodeCaller).
2
+ //
3
+ // Real HTTP, both SQLite and PostgreSQL:
4
+ // cd server && COMMHUB_DB=/tmp/x.db bun test src/node-caller-identity-http.test.ts
5
+ // PG: COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1
6
+ // (tests/node-caller-identity runs both, plus six mutations)
7
+ //
8
+ // An unbound token with zero rows keeps the name in the token. First
9
+ // registration still depends on that. A bound token whose row is gone is
10
+ // refused and must not fall back to the name. Several rows with one alias
11
+ // are not refused outright for an unbound token: the issuer's own row wins,
12
+ // then the node_id on the latest session. Still not unique, then refuse.
13
+ // The mutation harness deletes the shared owner check and expects every
14
+ // "caller identity boundary:" test to go assertion-red together (12). A
15
+ // second mutation restores a name lookup for a missing bound row and expects
16
+ // "bound row missing does not fall back to the name" to go red. A third
17
+ // drops the single-owner shortcut and expects the duplicate-alias heartbeat
18
+ // to go red. A fourth treats every failed ntok_ resolve as impersonation
19
+ // and expects "registration network token signs as the username" to go red.
20
+ // A fifth drops the ownerless-row compatibility and expects "duplicate
21
+ // ownerless row keeps an unbound epoch 0 token online" to go red. A sixth
22
+ // treats not_a_node_token on REST as impersonation and expects "unreported
23
+ // registration token signs as api" to go red. New tests must not use the
24
+ // "caller identity boundary:" prefix.
25
+ import { afterAll, beforeAll, expect, test } from "bun:test";
26
+ import { mkdtempSync, rmSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
30
+ import { db, generateNetworkToken, hashToken } from "./db.js";
31
+ import { createTaskRuntimeEvidenceReporter, taskActivityPayload } from "../../agent-node/src/task-runtime-evidence";
32
+
33
+ const dir = mkdtempSync(join(tmpdir(), "anet-caller-identity-"));
34
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
35
+ if (!activeDbPath) throw new Error("node-caller-identity requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
36
+
37
+ const stamp = Date.now().toString(36);
38
+ let server: any;
39
+ let base = "";
40
+ let ownerToken = "";
41
+ let ownerId = "";
42
+ let outsiderId = "";
43
+ let NET = "";
44
+ let NET2 = "";
45
+
46
+ const victimAlias = `idvvic${stamp}`;
47
+ const otherAlias = `idvoth${stamp}`;
48
+ const sinkAlias = `idvsnk${stamp}`;
49
+ const sharedAlias = `idvshr${stamp}`;
50
+ const sink2Alias = `idvsk2${stamp}`;
51
+ const freshAlias = `idvfrs${stamp}`;
52
+ const ownerlessAlias = `idvoln${stamp}`;
53
+ const dupAlias = `idvdup${stamp}`;
54
+ const victimId = `n_idv_vic_${stamp}`;
55
+ const otherId = `n_idv_oth_${stamp}`;
56
+ const sinkId = `n_idv_snk_${stamp}`;
57
+ const sharedId = `n_idv_shr_${stamp}`;
58
+ const shared2Id = `n_idv_sh2_${stamp}`;
59
+ const sink2Id = `n_idv_sk2_${stamp}`;
60
+ const freshId = `n_idv_frs_${stamp}`;
61
+ const ownerlessId = `n_idv_oln_${stamp}`;
62
+ const missingBoundId = `n_idv_gone_${stamp}`;
63
+
64
+ type Tok = { token: string; tokenId: string };
65
+ let victimTok: Tok;
66
+ let otherTok: Tok;
67
+ let squatter: Tok;
68
+ let boundMissing: Tok;
69
+ let freshTok: Tok;
70
+ let ownerlessTok: Tok;
71
+ let dupTok: Tok;
72
+ let sharedHome: Tok;
73
+ let sharedForeign: Tok;
74
+ let p10Owner: Tok;
75
+
76
+ type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
77
+ const openSubs: Sub[] = [];
78
+
79
+ for (const transport of ["mcp", "rest"] as const) {
80
+ for (const shape of ["bound duplicate", "unbound duplicate", "no session", "other network"] as const) {
81
+ test(`node attribution: ${transport} ${shape} retains the credential's node ID`, async () => {
82
+ const key = `${transport}-${shape.replaceAll(" ", "-")}-${stamp}`;
83
+ const alias = `idvsender-${key}`;
84
+ const mine = `n_idv_sender_${key}`;
85
+ const member = register(`idvmem-${key}`, "CallerMember123!", undefined, "seed");
86
+ if (!member.ok || !member.user) throw new Error("member fixture failed");
87
+ const memberId = member.user.user_id;
88
+ const joined = addNetworkMember(NET, memberId, "member", ownerId, { agentAccess: "all" });
89
+ if (!joined.ok) throw new Error(joined.error);
90
+ seedNode(mine, alias, NET, memberId);
91
+ if (shape.includes("duplicate")) {
92
+ const ownerNode = `n_idv_owner_${key}`;
93
+ seedNode(ownerNode, alias, NET, ownerId);
94
+ seedSession(ownerNode, alias, NET); // the active session belongs to a different row
95
+ }
96
+ if (shape === "other network") {
97
+ const foreignNode = `n_idv_foreign_${key}`;
98
+ seedNode(foreignNode, alias, NET2, outsiderId);
99
+ seedSession(foreignNode, alias, NET2);
100
+ }
101
+ const tok = mint(memberId, NET, shape === "bound duplicate" ? victimAlias : alias,
102
+ shape === "unbound duplicate" ? 0 : 2, shape === "unbound duplicate" ? null : mine);
103
+ const content = `sender identity ${key}`;
104
+ const sent = transport === "mcp"
105
+ ? await mcpSend(tok.token, content, undefined)
106
+ : (await api(tok.token, "POST", "/api/task", { alias: sinkAlias, task: content, network_id: NET })).body;
107
+ expect(sent.ok).toBe(true);
108
+ const row = db.get("SELECT from_name, from_node_id, network_id FROM tasks WHERE content = ?1", content);
109
+ expect(row).toEqual({ from_name: alias, from_node_id: mine, network_id: NET });
110
+ });
111
+ }
112
+ }
113
+
114
+ for (const owner of ["own", "another"] as const) {
115
+ test(`rename registry: prepare rejects ${owner} node without a session`, async () => {
116
+ const target = `idvreserved-${owner}-${stamp}`;
117
+ seedNode(`n_idv_reserved_${owner}_${stamp}`, target, NET, owner === "own" ? ownerId : outsiderId);
118
+ const before = db.get("SELECT COUNT(*) AS n FROM rename_txn");
119
+ const result = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
120
+ expect(result.status).toBe(400);
121
+ expect(result.body.ok).toBe(false);
122
+ expect(db.get("SELECT COUNT(*) AS n FROM rename_txn")).toEqual(before);
123
+ expect(sessionSnap(victimAlias, NET)?.node_id).toBe(victimId);
124
+ });
125
+ }
126
+
127
+ test("rename registry: commit rejects a node created after prepare", async () => {
128
+ const target = `idvrace-${stamp}`;
129
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
130
+ expect(prepared.body.ok).toBe(true);
131
+ const nodeId = `n_idv_race_${stamp}`;
132
+ seedNode(nodeId, target, NET, outsiderId);
133
+ const result = await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepared.body.txn_id });
134
+ expect(result.status).toBe(400);
135
+ expect(result.body.ok).toBe(false);
136
+ expect(sessionSnap(victimAlias, NET)?.node_id).toBe(victimId);
137
+ expect(db.get("SELECT alias FROM nodes WHERE node_id = ?1", nodeId)).toEqual({ alias: target });
138
+ expect(db.get("SELECT status FROM rename_txn WHERE txn_id = ?1", prepared.body.txn_id)).toEqual({ status: "prepared" });
139
+ await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: prepared.body.txn_id });
140
+ });
141
+
142
+ test("rename registry: another network's node does not reserve the local alias", async () => {
143
+ const target = `idvforeign-target-${stamp}`;
144
+ seedNode(`n_idv_foreign_target_${stamp}`, target, NET2, outsiderId);
145
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
146
+ expect(prepared.body.ok).toBe(true);
147
+ expect((await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: prepared.body.txn_id })).body.ok).toBe(true);
148
+ });
149
+
150
+ test("rename registry: a free alias commits and preserves node identity", async () => {
151
+ const alias = `idvrename-old-${stamp}`;
152
+ const target = `idvrename-new-${stamp}`;
153
+ const nodeId = `n_idv_rename_${stamp}`;
154
+ seedNode(nodeId, alias, NET, ownerId);
155
+ seedSession(nodeId, alias, NET);
156
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: alias, new_alias: target });
157
+ expect(prepared.body.ok).toBe(true);
158
+ const committed = await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepared.body.txn_id });
159
+ expect(committed.body.ok).toBe(true);
160
+ expect(sessionSnap(target, NET)?.node_id).toBe(nodeId);
161
+ expect(sessionSnap(alias, NET)).toBeNull();
162
+ expect(db.get("SELECT alias FROM nodes WHERE node_id = ?1", nodeId)).toEqual({ alias: target });
163
+ });
164
+
165
+ function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
166
+ const ctrl = new AbortController();
167
+ const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
168
+ let markReady!: () => void;
169
+ sub.ready = new Promise<void>((r) => { markReady = r; });
170
+ openSubs.push(sub);
171
+ (async () => {
172
+ try {
173
+ const res = await fetch(`${base}${path}`, {
174
+ headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", ...headers },
175
+ signal: ctrl.signal,
176
+ });
177
+ if (!res.ok || !res.body) throw new Error(`subscribe ${path} → ${res.status}`);
178
+ const reader = res.body.getReader();
179
+ const dec = new TextDecoder();
180
+ let buf = "";
181
+ while (true) {
182
+ const { done, value } = await reader.read();
183
+ if (done) break;
184
+ buf += dec.decode(value, { stream: true });
185
+ const lines = buf.split("\n");
186
+ buf = lines.pop() || "";
187
+ for (const line of lines) {
188
+ if (!line.startsWith("data: ")) continue;
189
+ const ev = JSON.parse(line.slice(6));
190
+ sub.events.push(ev);
191
+ if (ev.type === "connected") markReady();
192
+ }
193
+ }
194
+ } catch { /* aborted */ }
195
+ sub.done = true;
196
+ markReady();
197
+ })();
198
+ return sub;
199
+ }
200
+
201
+ async function until(cond: () => boolean, what: string, ms = 3000) {
202
+ const t0 = Date.now();
203
+ while (!cond()) {
204
+ if (Date.now() - t0 > ms) throw new Error(`timed out waiting for: ${what}`);
205
+ await new Promise((r) => setTimeout(r, 20));
206
+ }
207
+ }
208
+
209
+ async function api(token: string, method: string, path: string, body?: unknown) {
210
+ const res = await fetch(`${base}${path}`, {
211
+ method,
212
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
213
+ body: body === undefined ? undefined : JSON.stringify(body),
214
+ });
215
+ return { status: res.status, body: await res.json().catch(() => null) as any };
216
+ }
217
+
218
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
219
+ const res = await fetch(`${base}/mcp`, {
220
+ method: "POST",
221
+ headers: {
222
+ Authorization: `Bearer ${token}`,
223
+ "Content-Type": "application/json",
224
+ Accept: "application/json, text/event-stream",
225
+ "MCP-Protocol-Version": "2025-03-26",
226
+ },
227
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
228
+ });
229
+ const raw = await res.text();
230
+ const data = raw.split("\n").filter((x) => x.startsWith("data:"));
231
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
232
+ const text = payload.result?.content?.[0]?.text;
233
+ if (typeof text !== "string" || !text.startsWith("{")) throw new Error(`${name}: ${raw.slice(0, 500)}`);
234
+ return JSON.parse(text);
235
+ }
236
+
237
+ let mintSeq = 0;
238
+ function mint(userId: string, networkId: string, alias: string, epoch: number, boundNodeId: string | null): Tok {
239
+ const token = generateNetworkToken();
240
+ const tokenId = `tok_idv_${++mintSeq}_${stamp}`;
241
+ db.run(
242
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id, node_identity_epoch)
243
+ VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6, ?7)`,
244
+ [tokenId, hashToken(token), userId, networkId, `node:${alias}`, boundNodeId, epoch],
245
+ );
246
+ return { token, tokenId };
247
+ }
248
+
249
+ function minted(userId: string, networkId: string, alias: string, nodeId: string): Tok {
250
+ const row = createNetworkTokenForNode(userId, networkId, alias, nodeId);
251
+ if (!row.ok || !row.token || !row.token_id) throw new Error(`mint ${alias}: ${row.error ?? "no token"}`);
252
+ return { token: row.token, tokenId: row.token_id };
253
+ }
254
+
255
+ function seedNode(nodeId: string, alias: string, networkId: string, userId: string) {
256
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id)
257
+ VALUES (?1, ?2, ?2, ?3, ?4)`, [nodeId, alias, networkId, userId]);
258
+ }
259
+
260
+ function seedSession(nodeId: string, alias: string, networkId: string) {
261
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, network_id, status)
262
+ VALUES (?1, ?2, ?3, ?4, 'idle')`, [`sdk-${nodeId}`, alias, nodeId, networkId]);
263
+ }
264
+
265
+ function taskCount(content: string): number {
266
+ return Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM tasks WHERE content = ?1", content)?.n ?? 0);
267
+ }
268
+
269
+ function sessionSnap(alias: string, networkId: string) {
270
+ return db.get(
271
+ `SELECT resume_id, alias, status, task, output, node_id, network_id, updated_at
272
+ FROM sessions WHERE alias = ?1 AND network_id = ?2`,
273
+ alias, networkId,
274
+ );
275
+ }
276
+
277
+ function tokenName(tokenId: string): string | null {
278
+ return db.get<{ name: string }>("SELECT name FROM api_tokens WHERE token_id = ?1", tokenId)?.name ?? null;
279
+ }
280
+
281
+ const report = (tok: Tok, alias: string, nodeId: string, networkId: string, extra: Record<string, unknown> = {}) =>
282
+ tool(tok.token, "report_status", {
283
+ resume_id: `sdk-${alias}`,
284
+ alias,
285
+ status: "idle",
286
+ output: "steady",
287
+ node_id: nodeId,
288
+ network_id: networkId,
289
+ ...extra,
290
+ });
291
+
292
+ function lifecycleFixture(key: string) {
293
+ const daemonId = `node_life_daemon_${key}_${stamp}`, daemonAlias = `life-daemon-${key}-${stamp}`;
294
+ const childId = `node_life_${key}_${stamp}`, childAlias = `life-child-${key}-${stamp}`;
295
+ seedNode(daemonId, daemonAlias, NET, ownerId); seedNode(childId, childAlias, NET, ownerId);
296
+ seedSession(daemonId, daemonAlias, NET);
297
+ db.run("UPDATE nodes SET hostname='lifecycle-host',config_snapshot=?1 WHERE node_id=?2",
298
+ [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }), daemonId]);
299
+ db.run("UPDATE nodes SET hostname='lifecycle-host',config_snapshot='{}' WHERE node_id=?1", [childId]);
300
+ db.run("UPDATE sessions SET last_seen_at=datetime('now') WHERE node_id=?1", [daemonId]);
301
+ return { daemonId, daemonAlias, childId, childAlias };
302
+ }
303
+
304
+ test("token lifecycle: adoption rejects a different node's same-name credential", async () => {
305
+ const f = lifecycleFixture("adopt-denied");
306
+ const otherNode = `n_life_other_${stamp}`;
307
+ seedNode(otherNode, `life-other-${stamp}`, NET, outsiderId);
308
+ mint(outsiderId, NET, f.daemonAlias, 2, otherNode);
309
+ mint(outsiderId, NET, f.daemonAlias, 0, null);
310
+ const result = await tool(ownerToken, "request_adopt_node", { node_id: f.childId, daemon_node_id: f.daemonId, workdir: "/workspace/fixture" });
311
+ expect(result.error).toBe("daemon_offline");
312
+ expect(db.get("SELECT request_id FROM node_daemon_bindings WHERE node_id=?1", f.childId)).toBeNull();
313
+ });
314
+
315
+ for (const shape of ["bound", "unbound"] as const) {
316
+ test(`token lifecycle: adoption accepts verified ${shape} credentials`, async () => {
317
+ const f = lifecycleFixture(`adopt-${shape}`);
318
+ mint(ownerId, NET, shape === "bound" ? victimAlias : f.daemonAlias, shape === "bound" ? 2 : 0, shape === "bound" ? f.daemonId : null);
319
+ const result = await tool(ownerToken, "request_adopt_node", { node_id: f.childId, daemon_node_id: f.daemonId, workdir: "/workspace/fixture" });
320
+ expect(result.ok).toBe(true);
321
+ });
322
+ }
323
+
324
+ test("token lifecycle: another node's heartbeat cannot make the daemon ready", async () => {
325
+ const f=lifecycleFixture("heartbeat-denied");
326
+ mint(ownerId,NET,f.daemonAlias,2,f.daemonId);
327
+ const other=`node_life_heartbeat_other_${stamp}`;
328
+ seedNode(other,f.daemonAlias,NET,outsiderId);
329
+ db.run("DELETE FROM sessions WHERE node_id=?1",[f.daemonId]);
330
+ seedSession(other,f.daemonAlias,NET);
331
+ db.run("UPDATE sessions SET last_seen_at=datetime('now') WHERE node_id=?1",[other]);
332
+ const result=await tool(ownerToken,"request_adopt_node",{node_id:f.childId,daemon_node_id:f.daemonId,workdir:"/workspace/fixture"});
333
+ expect(result.error).toBe("daemon_offline");
334
+ });
335
+
336
+ test("token lifecycle: a unique legacy null-ID heartbeat remains supported", async () => {
337
+ const f=lifecycleFixture("heartbeat-legacy");
338
+ mint(ownerId,NET,f.daemonAlias,0,null);
339
+ db.run("UPDATE sessions SET node_id=NULL WHERE node_id=?1",[f.daemonId]);
340
+ expect((await tool(ownerToken,"request_adopt_node",{node_id:f.childId,daemon_node_id:f.daemonId,workdir:"/workspace/fixture"})).ok).toBe(true);
341
+ });
342
+
343
+ for (const shape of ["frozen", "legacy null", "wrong frozen", "same alias rebuild"] as const) {
344
+ test(`token lifecycle: delete ${shape} revokes only the target credential`, async () => {
345
+ const key = shape.replaceAll(" ", "-"); const f = lifecycleFixture(key);
346
+ const daemonTok = mint(ownerId, NET, f.daemonAlias, 2, f.daemonId);
347
+ const wrong = mint(outsiderId, NET, f.childAlias, 0, null);
348
+ const childTok = mint(ownerId, NET, shape === "frozen" ? victimAlias : f.childAlias, 2, f.childId);
349
+ db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,child_node_id,network_id,runtime,flags_json,env_keys,status,child_token_id,created_at,created_by_token)
350
+ VALUES(?1,?2,?3,?4,?5,'fixture','{}','[]','started',?6,?7,'fixture')`,
351
+ [`cr_${f.childId.slice(5)}`, f.daemonId, f.childAlias, f.childId, NET, childTok.tokenId, Date.now()]);
352
+ const dispatched = await tool(ownerToken, "delete_node", { child_node_id: f.childId, daemon_node_id: f.daemonId, network_id: NET, confirm_alias: f.childAlias });
353
+ expect(dispatched.ok).toBe(true);
354
+ expect(db.get<{ child_token_id: string }>("SELECT child_token_id FROM node_stop_requests WHERE request_id=?1", dispatched.request_id)?.child_token_id).toBe(childTok.tokenId);
355
+ if (shape === "legacy null" || shape === "wrong frozen") db.run("UPDATE node_stop_requests SET child_token_id=?1 WHERE request_id=?2", [shape === "wrong frozen" ? wrong.tokenId : null, dispatched.request_id]);
356
+ let rebuilt: Tok | null = null;
357
+ if (shape === "same alias rebuild") {
358
+ db.run("DELETE FROM nodes WHERE node_id=?1", [f.childId]);
359
+ const newId = `n_life_rebuilt_${stamp}`;seedNode(newId, f.childAlias, NET, ownerId);
360
+ rebuilt = mint(ownerId, NET, f.childAlias, 2, newId);
361
+ }
362
+ const ack = await tool(daemonTok.token, "ack_stop_request", { request_id: dispatched.request_id, status: "stopped" });
363
+ expect(ack.ok).toBe(true);
364
+ const revoked = (id: string) => db.get<{ revoked_at: string | null }>("SELECT revoked_at FROM api_tokens WHERE token_id=?1", id)?.revoked_at;
365
+ expect(revoked(childTok.tokenId)).not.toBeNull();
366
+ expect(revoked(wrong.tokenId)).toBeNull();
367
+ if (rebuilt) expect(revoked(rebuilt.tokenId)).toBeNull();
368
+ expect(db.get("SELECT node_id FROM nodes WHERE node_id=?1", f.childId)).toBeNull();
369
+ });
370
+ }
371
+
372
+ test("token lifecycle: rename updates verified labels and preserves unrelated credentials", async () => {
373
+ const alias = `life-rename-${stamp}`, nodeId = `n_life_rename_${stamp}`, target = `life-renamed-${stamp}`;
374
+ seedNode(nodeId, alias, NET, ownerId);seedSession(nodeId, alias, NET);
375
+ const unrelated = mint(outsiderId, NET, alias, 0, null);
376
+ const bound = mint(ownerId, NET, victimAlias, 2, nodeId);
377
+ const legacy = mint(ownerId, NET, alias, 0, null);
378
+ const foreign = mint(outsiderId, NET2, alias, 0, null);
379
+ const before = [unrelated,foreign].map(t => tokenName(t.tokenId));
380
+ const prepare = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: alias, new_alias: target });
381
+ expect(prepare.body.ok).toBe(true);
382
+ expect((await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepare.body.txn_id })).body.ok).toBe(true);
383
+ expect(tokenName(bound.tokenId)).toBe(`node:${target}`);
384
+ expect(tokenName(legacy.tokenId)).toBe(`node:${target}`);
385
+ expect([unrelated,foreign].map(t => tokenName(t.tokenId))).toEqual(before);
386
+ });
387
+
388
+ test("token lifecycle: rename refuses ambiguous node rows before and after prepare", async () => {
389
+ const alias=`life-dup-rename-${stamp}`, nodeId=`n_life_dupr_${stamp}`, target=`life-dup-target-${stamp}`;
390
+ seedNode(nodeId,alias,NET,ownerId);seedSession(nodeId,alias,NET);
391
+ const mine=mint(ownerId,NET,alias,2,nodeId);
392
+ const prepared=await api(ownerToken,"POST","/api/node-rename/prepare",{network_id:NET,old_alias:alias,new_alias:target});expect(prepared.body.ok).toBe(true);
393
+ seedNode(`n_life_dupo_${stamp}`,alias,NET,outsiderId);
394
+ expect((await api(ownerToken,"POST","/api/node-rename/commit",{txn_id:prepared.body.txn_id})).body.error).toBe("node_alias_ambiguous");
395
+ expect((await api(ownerToken,"POST","/api/node-rename/prepare",{network_id:NET,old_alias:alias,new_alias:target})).body.error).toBe("node_alias_ambiguous");
396
+ expect(tokenName(mine.tokenId)).toBe(`node:${alias}`);
397
+ expect(db.get("SELECT status FROM rename_txn WHERE txn_id=?1",prepared.body.txn_id)).toEqual({status:"prepared"});
398
+ await api(ownerToken,"POST","/api/node-rename/abort",{txn_id:prepared.body.txn_id});
399
+ });
400
+
401
+ beforeAll(async () => {
402
+ process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
403
+ process.env.HOST = "127.0.0.1";
404
+ const owner = register(`idvown${stamp}`, "CallerIdentOwner123!", undefined, "seed");
405
+ if (!owner.ok || !owner.token || !owner.user || !owner.network_id) throw new Error(owner.error || "owner register failed");
406
+ ownerToken = owner.token;
407
+ ownerId = owner.user.user_id;
408
+ NET = owner.network_id;
409
+ const outsider = register(`idvadm${stamp}`, "CallerIdentAdmin123!", undefined, "seed");
410
+ if (!outsider.ok || !outsider.user || !outsider.network_id) throw new Error(outsider.error || "admin register failed");
411
+ outsiderId = outsider.user.user_id;
412
+ NET2 = outsider.network_id;
413
+ const joined = addNetworkMember(NET, outsiderId, "admin", ownerId);
414
+ if (!joined.ok) throw new Error(joined.error || "add member failed");
415
+
416
+ victimTok = minted(ownerId, NET, victimAlias, victimId);
417
+ otherTok = minted(ownerId, NET, otherAlias, otherId);
418
+ db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${victimAlias}`, otherTok.tokenId]);
419
+ minted(ownerId, NET, sinkAlias, sinkId);
420
+ minted(ownerId, NET, sharedAlias, sharedId);
421
+ sharedHome = minted(outsiderId, NET2, sharedAlias, shared2Id);
422
+ minted(outsiderId, NET2, sink2Alias, sink2Id);
423
+ db.run(
424
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
425
+ VALUES (?1, ?2, ?2, ?3, NULL, datetime('now'))`,
426
+ [ownerlessId, ownerlessAlias, NET],
427
+ );
428
+ db.run(
429
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
430
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
431
+ [`n_idv_dup_a_${stamp}`, dupAlias, NET, ownerId],
432
+ );
433
+ db.run(
434
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
435
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
436
+ [`n_idv_dup_b_${stamp}`, dupAlias, NET, ownerId],
437
+ );
438
+
439
+ squatter = mint(outsiderId, NET, victimAlias, 2, null);
440
+ boundMissing = mint(ownerId, NET, victimAlias, 2, missingBoundId);
441
+ freshTok = mint(ownerId, NET, freshAlias, 2, null);
442
+ ownerlessTok = mint(outsiderId, NET, ownerlessAlias, 0, null);
443
+ dupTok = mint(ownerId, NET, dupAlias, 2, null);
444
+ sharedForeign = mint(outsiderId, NET, sharedAlias, 2, null);
445
+ p10Owner = mint(ownerId, NET, victimAlias, 2, null);
446
+
447
+ const mod: any = await import("./server.js");
448
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
449
+ base = `http://127.0.0.1:${server.port}`;
450
+ db.run("UPDATE nodes SET permission_mode = 'readonly' WHERE node_id = ?1", [otherId]);
451
+ for (const row of [
452
+ [victimTok, victimAlias, victimId, NET],
453
+ [otherTok, otherAlias, otherId, NET],
454
+ [sharedHome, sharedAlias, shared2Id, NET2],
455
+ ] as const) {
456
+ const reported = await report(row[0], row[1], row[2], row[3]);
457
+ if (!reported.ok) throw new Error(`report ${row[1]}: ${JSON.stringify(reported)}`);
458
+ }
459
+ const sinkReport = await report(minted(ownerId, NET, sinkAlias, sinkId), sinkAlias, sinkId, NET);
460
+ if (!sinkReport.ok) throw new Error(`report sink: ${JSON.stringify(sinkReport)}`);
461
+ const sink2Tok = minted(outsiderId, NET2, sink2Alias, sink2Id);
462
+ const sink2Report = await report(sink2Tok, sink2Alias, sink2Id, NET2);
463
+ if (!sink2Report.ok) throw new Error(`report sink2: ${JSON.stringify(sink2Report)}`);
464
+ }, 30_000);
465
+
466
+ afterAll(() => {
467
+ for (const sub of openSubs) sub.abort();
468
+ try { server?.stop?.(true); } catch {}
469
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
470
+ });
471
+
472
+ async function mcpSend(token: string, task: string, from: string | undefined) {
473
+ const args: Record<string, unknown> = { alias: sinkAlias, task, network_id: NET };
474
+ if (from !== undefined) args.from_session = from;
475
+ return tool(token, "send_task", args);
476
+ }
477
+
478
+ test("inbox recipient: bound identity wins over the token name", async () => {
479
+ const id = `inbox-bound-${stamp}`;
480
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own inbox', ?3)", [id, otherAlias, NET]);
481
+ const read = await tool(otherTok.token, "get_inbox", { alias: otherAlias });
482
+ expect(read.ok).toBe(true);
483
+ expect(read.messages.some((row: any) => row.id === id)).toBe(true);
484
+ expect((await tool(otherTok.token, "ack_inbox", { alias: otherAlias, message_id: id })).ok).toBe(true);
485
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(1);
486
+ });
487
+
488
+ test("inbox recipient: another node and invalid node identities cannot read or ACK", async () => {
489
+ const id = `inbox-denied-${stamp}`;
490
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private inbox', ?3)", [id, victimAlias, NET]);
491
+ for (const caller of [otherTok, squatter, boundMissing]) {
492
+ const read = await tool(caller.token, "get_inbox", { alias: victimAlias });
493
+ expect(read.ok).toBe(false);
494
+ expect(read.error).toBe("inbox_identity_mismatch");
495
+ expect(read.messages).toBeUndefined();
496
+ const ack = await tool(caller.token, "ack_inbox", { alias: victimAlias, message_id: id });
497
+ expect(ack.ok).toBe(false);
498
+ expect(ack.error).toBe("inbox_identity_mismatch");
499
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
500
+ }
501
+ expect((await tool(victimTok.token, "ack_inbox", { alias: victimAlias, message_id: id })).ok).toBe(true);
502
+ });
503
+
504
+ test("inbox recipient: same alias stays inside the token network", async () => {
505
+ const home = `inbox-net-home-${stamp}`, foreign = `inbox-net-foreign-${stamp}`;
506
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'home', ?3)", [home, sharedAlias, NET2]);
507
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'foreign', ?3)", [foreign, sharedAlias, NET]);
508
+ const read = await tool(sharedHome.token, "get_inbox", { alias: sharedAlias, network_id: NET });
509
+ expect(read.ok).toBe(true);
510
+ expect(read.messages.some((row: any) => row.id === home)).toBe(true);
511
+ expect(read.messages.some((row: any) => row.id === foreign)).toBe(false);
512
+ expect((await tool(sharedHome.token, "ack_inbox", { alias: sharedAlias, message_id: foreign, network_id: NET })).ok).toBe(false);
513
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", foreign)?.acked).toBe(0);
514
+ });
515
+
516
+ test("inbox recipient: legacy ownerless identity and authorized human access remain", async () => {
517
+ const id = `inbox-legacy-${stamp}`;
518
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'legacy', ?3)", [id, ownerlessAlias, NET]);
519
+ expect((await tool(ownerlessTok.token, "get_inbox", { alias: ownerlessAlias })).ok).toBe(true);
520
+ expect((await tool(ownerToken, "get_inbox", { alias: ownerlessAlias, network_id: NET })).ok).toBe(true);
521
+ expect((await tool(ownerToken, "ack_inbox", { alias: ownerlessAlias, message_id: id, network_id: NET })).ok).toBe(true);
522
+ });
523
+
524
+ test("inbox recipient: a full-access member cannot act as another owner's node", async () => {
525
+ const member = register(`inboxmem${stamp}`, "InboxMember123!", undefined, "seed");
526
+ expect(member.ok).toBe(true);
527
+ expect(addNetworkMember(NET, member.user!.user_id, "member", ownerId).ok).toBe(true);
528
+ db.run("UPDATE network_members SET agent_access = 'all' WHERE user_id = ?1 AND network_id = ?2", [member.user!.user_id, NET]);
529
+ const id = `inbox-member-${stamp}`;
530
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private', ?3)", [id, victimAlias, NET]);
531
+ for (const name of ["get_inbox", "ack_inbox"]) {
532
+ const args: Record<string, unknown> = { alias: victimAlias, network_id: NET };
533
+ if (name === "ack_inbox") args.message_id = id;
534
+ const result = await tool(member.token!, name, args);
535
+ expect(result.ok).toBe(false);
536
+ expect(result.error).toBe("inbox_identity_mismatch");
537
+ }
538
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
539
+ const own = `inbox-member-own-${stamp}`;
540
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own', ?3)", [own, member.user!.username, NET]);
541
+ expect((await tool(member.token!, "get_inbox", { alias: member.user!.username, network_id: NET })).ok).toBe(true);
542
+ expect((await tool(member.token!, "ack_inbox", { alias: member.user!.username, message_id: own, network_id: NET })).ok).toBe(true);
543
+ });
544
+
545
+ test("caller identity boundary: mcp send omits from_session", async () => {
546
+ const task = `boundary omit ${stamp}`;
547
+ const sent = await mcpSend(squatter.token, task, undefined);
548
+ expect(sent.ok).toBe(false);
549
+ expect(sent.error).toBe("from_session_identity_mismatch");
550
+ expect(taskCount(task)).toBe(0);
551
+ });
552
+
553
+ test("caller identity boundary: mcp send names the victim", async () => {
554
+ const task = `boundary named ${stamp}`;
555
+ const sent = await mcpSend(squatter.token, task, victimAlias);
556
+ expect(sent.ok).toBe(false);
557
+ expect(sent.error).toBe("from_session_identity_mismatch");
558
+ expect(sent.token_alias).toBe("");
559
+ expect(taskCount(task)).toBe(0);
560
+ });
561
+
562
+ test("caller identity boundary: mcp send names hub", async () => {
563
+ const task = `boundary hub ${stamp}`;
564
+ const sent = await mcpSend(squatter.token, task, "hub");
565
+ expect(sent.ok).toBe(false);
566
+ expect(sent.error).toBe("from_session_identity_mismatch");
567
+ expect(sent.token_alias).toBe("");
568
+ expect(taskCount(task)).toBe(0);
569
+ });
570
+
571
+ test("caller identity boundary: rest send omits from", async () => {
572
+ const task = `boundary rest omit ${stamp}`;
573
+ const sent = await api(squatter.token, "POST", "/api/task", { alias: sinkAlias, task, network_id: NET });
574
+ expect(sent.status).toBe(403);
575
+ expect(sent.body.error).toBe("from_session_identity_mismatch");
576
+ expect(taskCount(task)).toBe(0);
577
+ });
578
+
579
+ test("caller identity boundary: rest send names the victim", async () => {
580
+ const task = `boundary rest named ${stamp}`;
581
+ const sent = await api(squatter.token, "POST", "/api/task", { alias: sinkAlias, task, from: victimAlias, network_id: NET });
582
+ expect(sent.status).toBe(403);
583
+ expect(sent.body.error).toBe("from_session_identity_mismatch");
584
+ expect(sent.body.token_alias).toBe("");
585
+ expect(taskCount(task)).toBe(0);
586
+ });
587
+
588
+ test("caller identity boundary: rest send names hub", async () => {
589
+ const task = `boundary rest hub ${stamp}`;
590
+ const sent = await api(squatter.token, "POST", "/api/task", { alias: sinkAlias, task, from: "hub", network_id: NET });
591
+ expect(sent.status).toBe(403);
592
+ expect(sent.body.token_alias).toBe("");
593
+ expect(taskCount(task)).toBe(0);
594
+ });
595
+
596
+ test("caller identity boundary: rename refuses the victim alias", async () => {
597
+ const sent = await api(squatter.token, "POST", "/api/node-rename/prepare", {
598
+ network_id: NET,
599
+ old_alias: victimAlias,
600
+ new_alias: `idvren${stamp}`,
601
+ });
602
+ expect(sent.status).toBe(403);
603
+ expect(sent.body.error).toBe("user_token_required");
604
+ if (sent.body?.txn_id) await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: sent.body.txn_id });
605
+ });
606
+
607
+ test("caller identity boundary: auth me hides the victim alias", async () => {
608
+ const me = await api(squatter.token, "GET", "/api/auth/me");
609
+ expect(me.status).toBe(200);
610
+ expect(me.body.credential.kind).toBe("node");
611
+ expect(me.body.credential.node_alias).toBeNull();
612
+ });
613
+
614
+ test("caller identity boundary: requirement actor is the token", async () => {
615
+ const created = await api(squatter.token, "POST", "/api/requirements", { name: `boundary ${stamp}` });
616
+ expect(created.status).toBe(201);
617
+ expect(created.body.requirement.created_by).toEqual({ kind: "node", id: `token:${squatter.tokenId}` });
618
+ });
619
+
620
+ test("caller identity boundary: sse does not supersede the victim", async () => {
621
+ const real = subscribe(`/events/${encodeURIComponent(victimAlias)}`, victimTok.token, { "X-Anet-Instance-Id": `inst-a-${stamp}` });
622
+ await real.ready;
623
+ const squat = subscribe(`/events/${encodeURIComponent(victimAlias)}`, squatter.token, { "X-Anet-Instance-Id": `inst-s-${stamp}` });
624
+ await squat.ready;
625
+ const t0 = Date.now();
626
+ while (!real.events.some((e) => e.type === "node_connection_superseded") && Date.now() - t0 < 400) {
627
+ await new Promise((r) => setTimeout(r, 20));
628
+ }
629
+ expect(real.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
630
+ expect(real.done).toBe(false);
631
+ const again = subscribe(`/events/${encodeURIComponent(victimAlias)}`, victimTok.token, { "X-Anet-Instance-Id": `inst-b-${stamp}` });
632
+ await again.ready;
633
+ await until(() => real.done, "the node's own second connection to close the first");
634
+ const notice = real.events.find((e) => e.type === "node_connection_superseded");
635
+ expect(notice?.reason).toBe("superseded_by_new_connection");
636
+ expect(squat.done).toBe(false);
637
+ real.abort();
638
+ squat.abort();
639
+ again.abort();
640
+ }, 15_000);
641
+
642
+ test("caller identity boundary: daemon list does not bind the victim", async () => {
643
+ const listed = await tool(squatter.token, "list_my_pending_create_requests", {});
644
+ expect(listed.ok).toBe(false);
645
+ expect(listed.error).toBe("caller_not_a_daemon");
646
+ });
647
+
648
+ test("caller identity boundary: other network token does not write here", async () => {
649
+ const task = `boundary foreign ${stamp}`;
650
+ const sent = await mcpSend(sharedForeign.token, task, undefined);
651
+ expect(sent.ok).toBe(false);
652
+ expect(sent.error).toBe("from_session_identity_mismatch");
653
+ expect(taskCount(task)).toBe(0);
654
+ });
655
+
656
+ test("bound row missing does not fall back to the name", async () => {
657
+ const me = await api(boundMissing.token, "GET", "/api/auth/me");
658
+ expect(me.status).toBe(200);
659
+ expect(me.body.credential.node_alias).toBeNull();
660
+ const task = `boundary missing-bound ${stamp}`;
661
+ const sent = await mcpSend(boundMissing.token, task, undefined);
662
+ expect(sent.ok).toBe(false);
663
+ expect(sent.error).toBe("from_session_identity_mismatch");
664
+ expect(taskCount(task)).toBe(0);
665
+ });
666
+
667
+ test("bound token speaks as the bound node when the token name disagrees", async () => {
668
+ const me = await api(otherTok.token, "GET", "/api/auth/me");
669
+ expect(me.body.credential.node_alias).toBe(otherAlias);
670
+ const selfTask = `boundary other self ${stamp}`;
671
+ const self = await tool(otherTok.token, "send_task", { alias: otherAlias, task: selfTask, network_id: NET });
672
+ expect(self.ok).toBe(true);
673
+ const row = db.get<{ from_name: string; network_id: string }>("SELECT from_name, network_id FROM tasks WHERE content = ?1", selfTask);
674
+ expect(row?.from_name).toBe(otherAlias);
675
+ expect(row?.network_id).toBe(NET);
676
+ const stolen = `boundary other stolen ${stamp}`;
677
+ const sent = await tool(otherTok.token, "send_task", { alias: victimAlias, task: stolen, network_id: NET });
678
+ expect(sent.ok).toBe(false);
679
+ expect(sent.error).toBe("node_permission_denied");
680
+ expect(sent.reason).toBe("mode_readonly");
681
+ expect(taskCount(stolen)).toBe(0);
682
+ const real = subscribe(`/events/${encodeURIComponent(victimAlias)}`, victimTok.token, { "X-Anet-Instance-Id": `inst-r-${stamp}` });
683
+ await real.ready;
684
+ const other = subscribe(`/events/${encodeURIComponent(victimAlias)}`, otherTok.token, { "X-Anet-Instance-Id": `inst-o-${stamp}` });
685
+ await other.ready;
686
+ await new Promise((r) => setTimeout(r, 200));
687
+ expect(real.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
688
+ expect(real.done).toBe(false);
689
+ real.abort();
690
+ other.abort();
691
+ }, 15_000);
692
+
693
+ test("ownerless epoch 0 token still speaks as that alias and does not claim the row", async () => {
694
+ const task = `boundary ownerless ${stamp}`;
695
+ const sent = await tool(ownerlessTok.token, "send_task", { alias: sinkAlias, task, network_id: NET });
696
+ expect(sent.ok).toBe(true);
697
+ const row = db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task);
698
+ expect(row?.from_name).toBe(ownerlessAlias);
699
+ const owner = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", ownerlessId);
700
+ expect(owner?.owner_user_id ?? null).toBeNull();
701
+ });
702
+
703
+ test("two node rows with one alias stay unresolved", async () => {
704
+ const me = await api(dupTok.token, "GET", "/api/auth/me");
705
+ expect(me.body.credential.node_alias).toBeNull();
706
+ const task = `boundary dup ${stamp}`;
707
+ const sent = await mcpSend(dupTok.token, task, undefined);
708
+ expect(sent.ok).toBe(false);
709
+ expect(sent.error).toBe("from_session_identity_mismatch");
710
+ expect(taskCount(task)).toBe(0);
711
+ });
712
+
713
+ test("unbound token with no row keeps its name and first report_status binds it", async () => {
714
+ const before = await api(freshTok.token, "GET", "/api/auth/me");
715
+ expect(before.body.credential.node_alias).toBe(freshAlias);
716
+ const early = `boundary fresh send ${stamp}`;
717
+ const sent = await tool(freshTok.token, "send_task", { alias: sinkAlias, task: early, network_id: NET });
718
+ expect(sent.ok).toBe(true);
719
+ expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", early)?.from_name).toBe(freshAlias);
720
+ expect(db.get("SELECT node_id FROM nodes WHERE node_id = ?1", freshId)).toBeNull();
721
+ const reported = await report(freshTok, freshAlias, freshId, NET);
722
+ expect(reported.ok).toBe(true);
723
+ const node = db.get<{ alias: string; network_id: string }>("SELECT alias, network_id FROM nodes WHERE node_id = ?1", freshId);
724
+ expect(node?.alias).toBe(freshAlias);
725
+ expect(node?.network_id).toBe(NET);
726
+ const bound = db.get<{ bound_node_id: string | null; name: string }>(
727
+ "SELECT bound_node_id, name FROM api_tokens WHERE token_id = ?1",
728
+ freshTok.tokenId,
729
+ );
730
+ expect(bound?.bound_node_id).toBe(freshId);
731
+ expect(bound?.name).toBe(`node:${freshAlias}`);
732
+ const after = await api(freshTok.token, "GET", "/api/auth/me");
733
+ expect(after.body.credential.node_alias).toBe(freshAlias);
734
+ });
735
+
736
+ test("a copied node_id does not relabel the row and a squatter still cannot heartbeat", async () => {
737
+ const boundName = tokenName(victimTok.tokenId);
738
+ const unboundName = tokenName(p10Owner.tokenId);
739
+ const claimId = `n_idv_claim_${stamp}`;
740
+ expect(sessionSnap(victimAlias, NET)).toBeTruthy();
741
+ // Legitimate owner, unbound token, a node_id that is not the row. The
742
+ // heartbeat succeeds and the row stays; the session must not keep the claim.
743
+ const mismatch = await tool(p10Owner.token, "report_status", {
744
+ resume_id: `sdk-${victimAlias}`,
745
+ alias: victimAlias,
746
+ status: "working",
747
+ output: "mutated-output",
748
+ node_id: claimId,
749
+ network_id: NET,
750
+ });
751
+ expect(mismatch.ok).toBe(true);
752
+ expect(db.get("SELECT node_id FROM nodes WHERE node_id = ?1", claimId)).toBeNull();
753
+ expect(db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id = ?1", victimId)?.alias).toBe(victimAlias);
754
+ const cleared = sessionSnap(victimAlias, NET) as { node_id: string | null } | null;
755
+ expect(cleared).toBeTruthy();
756
+ expect(cleared?.node_id ?? null).toBeNull();
757
+ expect(tokenName(p10Owner.tokenId)).toBe(unboundName);
758
+ // Bound token reporting another node's id: row stays, heartbeat still ok.
759
+ const wrongNode = await tool(victimTok.token, "report_status", {
760
+ resume_id: `sdk-${victimAlias}`,
761
+ alias: victimAlias,
762
+ status: "working",
763
+ output: "mutated-output",
764
+ node_id: otherId,
765
+ network_id: NET,
766
+ });
767
+ expect(wrongNode.ok).toBe(true);
768
+ expect(db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id = ?1", otherId)?.alias).toBe(otherAlias);
769
+ expect(db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id = ?1", victimId)?.alias).toBe(victimAlias);
770
+ expect(tokenName(victimTok.tokenId)).toBe(boundName);
771
+ const afterCollision = sessionSnap(victimAlias, NET);
772
+ const squat = await tool(squatter.token, "report_status", {
773
+ resume_id: `sdk-squat-${stamp}`,
774
+ alias: victimAlias,
775
+ status: "working",
776
+ output: "squatter-output",
777
+ node_id: victimId,
778
+ network_id: NET,
779
+ });
780
+ expect(squat.ok).toBe(false);
781
+ expect(squat.error).toBe("alias_identity_mismatch");
782
+ expect(sessionSnap(victimAlias, NET)).toEqual(afterCollision);
783
+ expect(db.get("SELECT resume_id FROM sessions WHERE resume_id = ?1", `sdk-squat-${stamp}`)).toBeNull();
784
+ });
785
+
786
+ test("duplicate alias unbound token keeps the owning node online", async () => {
787
+ // Epoch 0 is the old unbound shape. Epoch 2 would still trip first-binding
788
+ // and clear the session node_id; that is a heartbeat success, not this case.
789
+ const alias = `idvlive${stamp}`;
790
+ const mine = `n_idv_live_${stamp}`;
791
+ const other = `n_idv_liveo_${stamp}`;
792
+ db.run(
793
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
794
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
795
+ [mine, alias, NET, ownerId],
796
+ );
797
+ db.run(
798
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
799
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
800
+ [other, alias, NET, outsiderId],
801
+ );
802
+ const tok = mint(ownerId, NET, alias, 0, null);
803
+ const reported = await report(tok, alias, mine, NET, { hostname: "idv-live-host" });
804
+ expect(reported.ok).toBe(true);
805
+ expect(db.get<{ alias: string; hostname: string | null }>(
806
+ "SELECT alias, hostname FROM nodes WHERE node_id = ?1", mine,
807
+ )).toEqual({ alias, hostname: "idv-live-host" });
808
+ expect(db.get<{ alias: string; hostname: string | null }>(
809
+ "SELECT alias, hostname FROM nodes WHERE node_id = ?1", other,
810
+ )).toEqual({ alias, hostname: null });
811
+ expect(db.get<{ node_id: string | null }>(
812
+ "SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET,
813
+ )?.node_id).toBe(mine);
814
+ const task = `dup live ${stamp}`;
815
+ const sent = await tool(tok.token, "send_task", { alias: sinkAlias, task, network_id: NET });
816
+ expect(sent.ok).toBe(true);
817
+ expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task)?.from_name).toBe(alias);
818
+ });
819
+
820
+ test("duplicate alias unbound token follows the latest session node", async () => {
821
+ const alias = `idvsess${stamp}`;
822
+ const rowA = `n_idv_sesa_${stamp}`;
823
+ const rowB = `n_idv_sesb_${stamp}`;
824
+ const requestId = `cr_idv_ses_${stamp}`;
825
+ for (const id of [rowA, rowB]) {
826
+ db.run(
827
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
828
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
829
+ [id, alias, NET, ownerId],
830
+ );
831
+ }
832
+ db.run(
833
+ `INSERT INTO sessions (resume_id, alias, node_id, network_id, status, updated_at)
834
+ VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
835
+ [`sdk-${alias}`, alias, rowB, NET],
836
+ );
837
+ db.run(
838
+ `INSERT INTO node_create_requests (request_id, daemon_node_id, child_name, network_id, runtime, flags_json, env_keys, status, created_at, created_by_token)
839
+ VALUES (?1, ?2, ?3, ?4, 'fixture', '{}', '[]', 'pending', ?5, 'fixture')`,
840
+ [requestId, rowB, `child-${stamp}`, NET, Date.now()],
841
+ );
842
+ const tok = mint(ownerId, NET, alias, 0, null);
843
+ const listed = await tool(tok.token, "list_my_pending_create_requests", {});
844
+ expect(listed.ok).toBe(true);
845
+ expect(listed.count).toBe(1);
846
+ expect(listed.requests[0].request_id).toBe(requestId);
847
+ });
848
+
849
+ test("home network of a shared alias still accepts its own token", async () => {
850
+ const task = `boundary home ${stamp}`;
851
+ const sent = await tool(sharedHome.token, "send_task", { alias: sink2Alias, task, network_id: NET2 });
852
+ expect(sent.ok).toBe(true);
853
+ const row = db.get<{ from_name: string; network_id: string }>("SELECT from_name, network_id FROM tasks WHERE content = ?1", task);
854
+ expect(row).toEqual({ from_name: sharedAlias, network_id: NET2 });
855
+ });
856
+
857
+ test("a resolved node token can list its own daemon requests", async () => {
858
+ const listed = await tool(victimTok.token, "list_my_pending_create_requests", {});
859
+ expect(listed.ok).toBe(true);
860
+ expect(listed.count).toBe(0);
861
+ });
862
+
863
+ test("unreported registration token signs as api", async () => {
864
+ const username = `idvure${stamp}`;
865
+ const created = register(username, "CallerIdentUre123!", undefined, "seed");
866
+ if (!created.ok || !created.network_token || !created.network_id) {
867
+ throw new Error(created.error || "unreported network token register failed");
868
+ }
869
+ const net = created.network_id;
870
+ const tokenRow = db.get<{ name: string }>(
871
+ "SELECT name FROM api_tokens WHERE token_hash = ?1",
872
+ hashToken(created.network_token),
873
+ );
874
+ expect(tokenRow?.name).toBe("default-network");
875
+ const sink = `idvurk${stamp}`;
876
+ db.run(
877
+ `INSERT INTO sessions (resume_id, alias, network_id, status, updated_at)
878
+ VALUES (?1, ?2, ?3, 'idle', datetime('now'))`,
879
+ [`sdk-${sink}`, sink, net],
880
+ );
881
+ const open = `unreported rest open ${stamp}`;
882
+ const sent = await api(created.network_token, "POST", "/api/task", { alias: sink, task: open, network_id: net });
883
+ expect(sent.status).toBe(200);
884
+ expect(sent.body.ok).toBe(true);
885
+ expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", open)?.from_name).toBe("api");
886
+ const claimed = `unreported rest claim ${stamp}`;
887
+ const other = `idvuro${stamp}`;
888
+ const refused = await api(created.network_token, "POST", "/api/task", {
889
+ alias: sink, task: claimed, from: other, network_id: net,
890
+ });
891
+ expect(refused.status).toBe(403);
892
+ expect(refused.body.error).toBe("from_session_identity_mismatch");
893
+ expect(refused.body.token_alias).toBe("");
894
+ expect(taskCount(claimed)).toBe(0);
895
+ expect(db.get<{ name: string }>(
896
+ "SELECT name FROM api_tokens WHERE token_hash = ?1",
897
+ hashToken(created.network_token),
898
+ )?.name).toBe("default-network");
899
+ });
900
+
901
+ test("registration network token signs as the username", async () => {
902
+ const username = `idvreg${stamp}`;
903
+ const created = register(username, "CallerIdentReg123!", undefined, "seed");
904
+ if (!created.ok || !created.network_token || !created.network_id || !created.user) {
905
+ throw new Error(created.error || "plain network token register failed");
906
+ }
907
+ const net = created.network_id;
908
+ const tokenRow = db.get<{ name: string }>(
909
+ "SELECT name FROM api_tokens WHERE token_hash = ?1",
910
+ hashToken(created.network_token),
911
+ );
912
+ expect(tokenRow?.name).toBe("default-network");
913
+ const sink = `idvrgk${stamp}`;
914
+ db.run(
915
+ `INSERT INTO sessions (resume_id, alias, network_id, status, updated_at)
916
+ VALUES (?1, ?2, ?3, 'idle', datetime('now'))`,
917
+ [`sdk-${sink}`, sink, net],
918
+ );
919
+ const task = `plain ntok send ${stamp}`;
920
+ const sent = await tool(created.network_token, "send_task", { alias: sink, task, network_id: net });
921
+ expect(sent.ok).toBe(true);
922
+ expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task)?.from_name).toBe(username);
923
+ const reported = await tool(created.network_token, "report_status", {
924
+ resume_id: `sdk-${username}`,
925
+ alias: username,
926
+ status: "idle",
927
+ output: "steady",
928
+ network_id: net,
929
+ });
930
+ expect(reported.ok).toBe(true);
931
+ expect(db.get<{ name: string }>(
932
+ "SELECT name FROM api_tokens WHERE token_hash = ?1",
933
+ hashToken(created.network_token),
934
+ )?.name).toBe(`node:${username}`);
935
+ });
936
+
937
+ test("duplicate ownerless row keeps an unbound epoch 0 token online", async () => {
938
+ // Issuer owns neither row. The session points at the ownerless row.
939
+ // Epoch 0 keeps the ownerless-row compatibility; epoch 2 would first-bind.
940
+ const alias = `idvord${stamp}`;
941
+ const ownedId = `n_idv_orda_${stamp}`;
942
+ const ownerlessNode = `n_idv_ordb_${stamp}`;
943
+ db.run(
944
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
945
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
946
+ [ownedId, alias, NET, ownerId],
947
+ );
948
+ db.run(
949
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
950
+ VALUES (?1, ?2, ?2, ?3, NULL, datetime('now'))`,
951
+ [ownerlessNode, alias, NET],
952
+ );
953
+ db.run(
954
+ `INSERT INTO sessions (resume_id, alias, node_id, network_id, status, updated_at)
955
+ VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
956
+ [`sdk-${alias}`, alias, ownerlessNode, NET],
957
+ );
958
+ const tok = mint(outsiderId, NET, alias, 0, null);
959
+ const registered = await report(tok, alias, ownerlessNode, NET, { hostname: "idv-ord-reg" });
960
+ expect(registered.ok).toBe(true);
961
+ const heartbeat = await report(tok, alias, ownerlessNode, NET, { hostname: "idv-ord-beat" });
962
+ expect(heartbeat.ok).toBe(true);
963
+ expect(db.get<{ node_id: string | null }>(
964
+ "SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET,
965
+ )?.node_id).toBe(ownerlessNode);
966
+ expect(db.get<{ alias: string; owner_user_id: string | null; hostname: string | null }>(
967
+ "SELECT alias, owner_user_id, hostname FROM nodes WHERE node_id = ?1", ownerlessNode,
968
+ )).toEqual({ alias, owner_user_id: null, hostname: "idv-ord-beat" });
969
+ expect(db.get<{ alias: string; owner_user_id: string | null; hostname: string | null }>(
970
+ "SELECT alias, owner_user_id, hostname FROM nodes WHERE node_id = ?1", ownedId,
971
+ )).toEqual({ alias, owner_user_id: ownerId, hostname: null });
972
+ const task = `ownerless dup send ${stamp}`;
973
+ const sent = await tool(tok.token, "send_task", { alias: sinkAlias, task, network_id: NET });
974
+ expect(sent.ok).toBe(true);
975
+ expect(db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE content = ?1", task)?.from_name).toBe(alias);
976
+ const first = subscribe(`/events/${encodeURIComponent(alias)}`, tok.token, { "X-Anet-Instance-Id": `inst-ord-a-${stamp}` });
977
+ await first.ready;
978
+ expect(first.events.some((e) => e.type === "connected")).toBe(true);
979
+ expect(first.done).toBe(false);
980
+ const second = subscribe(`/events/${encodeURIComponent(alias)}`, tok.token, { "X-Anet-Instance-Id": `inst-ord-b-${stamp}` });
981
+ await second.ready;
982
+ await until(() => first.done, "the ownerless row's own second connection to close the first");
983
+ expect(first.events.some((e) => e.type === "node_connection_superseded")).toBe(true);
984
+ expect(second.done).toBe(false);
985
+ first.abort();
986
+ second.abort();
987
+ }, 15_000);
988
+
989
+
990
+ const leaseAlias = `leasebase${stamp}`, leaseId = `n_leasebase_${stamp}`;
991
+ let leaseTok: Tok;
992
+ beforeAll(() => { seedNode(leaseId,leaseAlias,NET,ownerId);seedSession(leaseId,leaseAlias,NET);leaseTok=mint(ownerId,NET,leaseAlias,2,leaseId); });
993
+
994
+ function activityTask(nodeId: string, alias: string, network = NET) {
995
+ const id = `task_activity_${crypto.randomUUID()}`;
996
+ db.run("INSERT INTO tasks (task_id, from_name, to_node_id, to_name, status, content, network_id) VALUES (?1, 'scheduler', ?2, ?3, 'running', 'activity fixture', ?4)", [id, nodeId, alias, network]);
997
+ return { task_id: id, thread_id: `thread_${id}`, turn_id: `turn_${id}` };
998
+ }
999
+ const activitySnap = (id: string) => db.get<any>("SELECT status, consumed_at, runtime_submitted_at, started_at, thread_id, turn_id, last_activity_at FROM tasks WHERE task_id = ?1", id);
1000
+ async function consumeActivityTask(context: {task_id: string; thread_id: string; turn_id: string}) {
1001
+ expect((await tool(leaseTok.token, "mark_tasks_consumed", { task_ids: [context.task_id], task_contexts: [context] })).ok).toBe(true);
1002
+ }
1003
+ const renewActivity = (token: string, contexts: Array<{task_id: string; thread_id: string; turn_id: string}>) => tool(token, "mark_tasks_consumed", { task_ids: contexts.map(c => c.task_id), task_contexts: contexts, activity_only: true });
1004
+
1005
+ test("activity lease: exact task renewal changes only the activity clock; duplicate consumption does not renew", async () => {
1006
+ const c = activityTask(leaseId, leaseAlias); await consumeActivityTask(c);
1007
+ db.run("UPDATE tasks SET last_activity_at = datetime('now', '-2 hours') WHERE task_id = ?1", [c.task_id]);
1008
+ const before = activitySnap(c.task_id);
1009
+ expect((await tool(leaseTok.token, "mark_tasks_consumed", { task_ids: [c.task_id], task_contexts: [c] })).ok).toBe(true);
1010
+ expect(activitySnap(c.task_id)).toEqual(before);
1011
+ const out = await renewActivity(leaseTok.token, [c]); expect(out.ok).toBe(true);
1012
+ const after = activitySnap(c.task_id);
1013
+ expect(after.last_activity_at).not.toBe(before.last_activity_at);
1014
+ expect({...after, last_activity_at: before.last_activity_at}).toEqual(before);
1015
+ expect(out.tasks[0].last_activity_at).toBe(after.last_activity_at);
1016
+ });
1017
+
1018
+ test("activity lease: bound node wins over a same-name session and cannot renew the other node", async () => {
1019
+ const alias = `leasebound${stamp}`, mine = `n_leasebound_${stamp}`, other = `n_leaseother_${stamp}`;
1020
+ seedNode(mine, alias, NET, ownerId); seedSession(mine, alias, NET);
1021
+ const tok = mint(ownerId, NET, leaseAlias, 2, mine), own = activityTask(mine, alias);
1022
+ expect((await tool(tok.token, "mark_tasks_consumed", {task_ids: [own.task_id], task_contexts: [own]})).ok).toBe(true);
1023
+ seedNode(other, alias, NET, ownerId); db.run("UPDATE sessions SET node_id=?1 WHERE alias=?2 AND network_id=?3",[other,alias,NET]);
1024
+ expect((await renewActivity(tok.token, [own])).ok).toBe(true);
1025
+ const foreign = activityTask(other, alias);
1026
+ db.run("UPDATE tasks SET consumed_at=datetime('now'),thread_id=?1,turn_id=?2 WHERE task_id=?3", [foreign.thread_id,foreign.turn_id,foreign.task_id]);
1027
+ expect((await renewActivity(tok.token,[foreign])).error).toBe("task_not_owned");
1028
+ expect(activitySnap(foreign.task_id).last_activity_at).toBeNull();
1029
+ });
1030
+
1031
+ for (const shape of ["wrong thread", "wrong turn", "foreign node", "foreign network", "legacy null node", "not consumed", "missing context", "user token"] as const) {
1032
+ test(`activity lease: rejects ${shape} without changing the task`, async () => {
1033
+ const c = activityTask(leaseId, leaseAlias); if(shape !== "not consumed") await consumeActivityTask(c);
1034
+ if(shape === "foreign node") db.run("UPDATE tasks SET to_node_id=?1 WHERE task_id=?2", [otherId,c.task_id]);
1035
+ if(shape === "foreign network") db.run("UPDATE tasks SET network_id=?1 WHERE task_id=?2", [NET2,c.task_id]);
1036
+ if(shape === "legacy null node") db.run("UPDATE tasks SET to_node_id=NULL WHERE task_id=?1",[c.task_id]);
1037
+ const before=activitySnap(c.task_id); const context={...c};
1038
+ if(shape === "wrong thread") context.thread_id="sibling_thread";
1039
+ if(shape === "wrong turn") context.turn_id="sibling_turn";
1040
+ const out=await tool(shape === "user token" ? ownerToken : leaseTok.token,"mark_tasks_consumed",{task_ids:[c.task_id],task_contexts:shape === "missing context" ? [] : [context],activity_only:true});
1041
+ expect(out.ok).toBe(false); expect(activitySnap(c.task_id)).toEqual(before);
1042
+ });
1043
+ }
1044
+ for(const status of ["replied","failed","cancelled","expired"]){
1045
+ test(`activity lease: ${status} terminal never receives activity or reopens`,async()=>{
1046
+ const c=activityTask(leaseId,leaseAlias);await consumeActivityTask(c);db.run("UPDATE tasks SET status=?1 WHERE task_id=?2",[status,c.task_id]);const before=activitySnap(c.task_id);
1047
+ expect((await renewActivity(leaseTok.token,[c])).error).toBe("task_activity_terminal");expect(activitySnap(c.task_id)).toEqual(before);
1048
+ });
1049
+ }
1050
+ test("activity lease: conflicting batch is rejected with zero partial writes",async()=>{
1051
+ const first=activityTask(leaseId,leaseAlias),second=activityTask(leaseId,leaseAlias);await consumeActivityTask(first);await consumeActivityTask(second);
1052
+ const before=activitySnap(first.task_id),before2=activitySnap(second.task_id);
1053
+ expect((await renewActivity(leaseTok.token,[first,{...second,turn_id:"old_turn"}])).ok).toBe(false);
1054
+ expect(activitySnap(first.task_id)).toEqual(before);expect(activitySnap(second.task_id)).toEqual(before2);
1055
+ });
1056
+
1057
+ test("activity lease: injected second-write failure rolls back the first write on the real backend",async()=>{
1058
+ const first=activityTask(leaseId,leaseAlias),second=activityTask(leaseId,leaseAlias);await consumeActivityTask(first);await consumeActivityTask(second);
1059
+ const trigger=`activity_fail_${stamp}`,fn=`activity_fn_${stamp}`;
1060
+ if(db.dialect === "postgres")db.exec(`CREATE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.task_id='${second.task_id}' THEN RAISE EXCEPTION 'test1113_injected_activity_failure'; END IF; RETURN NEW; END; $$; CREATE TRIGGER ${trigger} BEFORE UPDATE OF last_activity_at ON tasks FOR EACH ROW EXECUTE FUNCTION ${fn}()`);
1061
+ else db.exec(`CREATE TRIGGER ${trigger} BEFORE UPDATE OF last_activity_at ON tasks WHEN NEW.task_id='${second.task_id}' BEGIN SELECT RAISE(ABORT,'test1113_injected_activity_failure'); END`);
1062
+ const before=activitySnap(first.task_id),before2=activitySnap(second.task_id);let failed=false;
1063
+ try{await renewActivity(leaseTok.token,[first,second]);}catch(e){failed=String(e).includes("test1113_injected_activity_failure");}
1064
+ finally{if(db.dialect === "postgres")db.exec(`DROP TRIGGER ${trigger} ON tasks; DROP FUNCTION ${fn}()`);else db.exec(`DROP TRIGGER ${trigger}`);}
1065
+ expect(failed).toBe(true);expect(activitySnap(first.task_id)).toEqual(before);expect(activitySnap(second.task_id)).toEqual(before2);
1066
+ });
1067
+ test("activity lease: revoked credential cannot report activity",async()=>{
1068
+ const tok=mint(ownerId,NET,leaseAlias,2,leaseId),c=activityTask(leaseId,leaseAlias);await consumeActivityTask(c);const before=activitySnap(c.task_id);
1069
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1",[tok.tokenId]);
1070
+ const response=await fetch(`${base}/mcp`,{method:"POST",headers:{Authorization:`Bearer ${tok.token}`,"Content-Type":"application/json",Accept:"application/json, text/event-stream"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/call",params:{name:"mark_tasks_consumed",arguments:{task_ids:[c.task_id],task_contexts:[c],activity_only:true}}})});
1071
+ expect(response.status).toBe(401);expect(activitySnap(c.task_id)).toEqual(before);
1072
+ });
1073
+
1074
+ test("activity lease: real runtime reporter payload traverses authenticated HTTP into the exact task clock",async()=>{
1075
+ const c=activityTask(leaseId,leaseAlias);let clock=0;const errors:string[]=[];
1076
+ const reporter=createTaskRuntimeEvidenceReporter({taskId:c.task_id,now:()=>clock,
1077
+ report:(_level,id,context)=>tool(leaseTok.token,"mark_tasks_consumed",{task_ids:[id],task_contexts:[{task_id:id,thread_id:context!.threadId,turn_id:context!.turnId}]}),
1078
+ reportActivity:(id,context)=>tool(leaseTok.token,"mark_tasks_consumed",taskActivityPayload(id,context)),debug:message=>errors.push(message)});
1079
+ reporter.consumed({threadId:c.thread_id,turnId:c.turn_id});await until(()=>!!activitySnap(c.task_id).consumed_at,"consumption stamped");
1080
+ const before=activitySnap(c.task_id);clock=30_000;reporter.activity({taskId:"other_task",threadId:c.thread_id,turnId:c.turn_id});await new Promise(resolve=>setTimeout(resolve,5));expect(activitySnap(c.task_id)).toEqual(before);
1081
+ reporter.activity({taskId:c.task_id,threadId:c.thread_id,turnId:c.turn_id});await until(()=>!!activitySnap(c.task_id).last_activity_at,"runtime activity stamped");
1082
+ expect(errors).toEqual([]);expect(activitySnap(c.task_id).consumed_at).toBe(before.consumed_at);expect(activitySnap(c.task_id).status).toBe("running");
1083
+ });