@openparachute/agent 0.1.1 → 0.2.0

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 (598) hide show
  1. package/.parachute/module.json +124 -8
  2. package/LICENSE +2 -16
  3. package/README.md +118 -166
  4. package/package.json +32 -43
  5. package/scripts/spawn-agent.ts +371 -0
  6. package/src/_parked/interactive-spawn.test.ts +324 -0
  7. package/src/_parked/interactive-spawn.ts +701 -0
  8. package/src/agent-defs.test.ts +1504 -0
  9. package/src/agent-defs.ts +1702 -0
  10. package/src/agent-mcp-config.test.ts +115 -0
  11. package/src/agent-mcp-config.ts +115 -0
  12. package/src/agents.test.ts +360 -0
  13. package/src/agents.ts +379 -0
  14. package/src/auth.test.ts +46 -0
  15. package/src/auth.ts +140 -0
  16. package/src/backends/attached-queue.test.ts +376 -0
  17. package/src/backends/attached-queue.ts +372 -0
  18. package/src/backends/programmatic.test.ts +1715 -0
  19. package/src/backends/programmatic.ts +927 -0
  20. package/src/backends/registry.test.ts +1494 -0
  21. package/src/backends/registry.ts +1202 -0
  22. package/src/backends/stream-json.test.ts +570 -0
  23. package/src/backends/stream-json.ts +392 -0
  24. package/src/backends/types.ts +223 -0
  25. package/src/bridge.ts +417 -0
  26. package/src/channel-backend-wiring.test.ts +237 -0
  27. package/src/credentials.test.ts +274 -0
  28. package/src/credentials.ts +380 -0
  29. package/src/cron.test.ts +342 -0
  30. package/src/cron.ts +380 -0
  31. package/src/daemon-agent-def-api.test.ts +166 -0
  32. package/src/daemon-agent-defs-api.test.ts +953 -0
  33. package/src/daemon-agent-env-api.test.ts +338 -0
  34. package/src/daemon-attached-queue-store.test.ts +65 -0
  35. package/src/daemon-config-api.test.ts +962 -0
  36. package/src/daemon-jobs-api.test.ts +271 -0
  37. package/src/daemon-vault-chat.test.ts +250 -0
  38. package/src/daemon.test.ts +746 -0
  39. package/src/daemon.ts +3314 -0
  40. package/src/def-vaults.test.ts +136 -0
  41. package/src/def-vaults.ts +165 -0
  42. package/src/delivery-state.test.ts +110 -0
  43. package/src/delivery-state.ts +154 -0
  44. package/src/effective-env.test.ts +114 -0
  45. package/src/effective-env.ts +184 -0
  46. package/src/env-compat.ts +39 -0
  47. package/src/grants.test.ts +638 -0
  48. package/src/grants.ts +675 -0
  49. package/src/hub-jwt.test.ts +161 -0
  50. package/src/hub-jwt.ts +182 -0
  51. package/src/jobs.test.ts +245 -0
  52. package/src/jobs.ts +266 -0
  53. package/src/mcp-http.test.ts +265 -0
  54. package/src/mcp-http.ts +771 -0
  55. package/src/mint-token.test.ts +152 -0
  56. package/src/mint-token.ts +139 -0
  57. package/src/module-manifest.test.ts +158 -0
  58. package/src/oauth-discovery.ts +134 -0
  59. package/src/programmatic-wiring.test.ts +838 -0
  60. package/src/registry.test.ts +227 -0
  61. package/src/registry.ts +228 -0
  62. package/src/resolve-port.test.ts +64 -0
  63. package/src/routing.test.ts +184 -0
  64. package/src/routing.ts +76 -0
  65. package/src/runner.test.ts +506 -0
  66. package/src/runner.ts +255 -0
  67. package/src/sandbox/config.test.ts +150 -0
  68. package/src/sandbox/config.ts +102 -0
  69. package/src/sandbox/egress.test.ts +113 -0
  70. package/src/sandbox/egress.ts +123 -0
  71. package/src/sandbox/index.ts +180 -0
  72. package/src/sandbox/live-seatbelt.test.ts +277 -0
  73. package/src/sandbox/mounts.test.ts +154 -0
  74. package/src/sandbox/mounts.ts +133 -0
  75. package/src/sandbox/sandbox.test.ts +168 -0
  76. package/src/sandbox/types.ts +382 -0
  77. package/src/services-manifest.test.ts +106 -0
  78. package/src/services-manifest.ts +95 -0
  79. package/src/spa-serve.test.ts +116 -0
  80. package/src/spa-serve.ts +116 -0
  81. package/src/spawn-agent-cli.test.ts +172 -0
  82. package/src/spawn-agent.test.ts +1218 -0
  83. package/src/spawn-agent.ts +569 -0
  84. package/src/spawn-deps.test.ts +54 -0
  85. package/src/spawn-deps.ts +166 -0
  86. package/src/telegram/api.ts +153 -0
  87. package/src/terminal-assets.test.ts +50 -0
  88. package/src/terminal-assets.ts +79 -0
  89. package/src/terminal-ui.ts +305 -0
  90. package/src/terminal.test.ts +530 -0
  91. package/src/terminal.ts +458 -0
  92. package/src/transport.ts +270 -0
  93. package/src/transports/http-ui.test.ts +455 -0
  94. package/src/transports/http-ui.ts +201 -0
  95. package/src/transports/telegram.test.ts +174 -0
  96. package/src/transports/telegram.ts +426 -0
  97. package/src/transports/vault.test.ts +2011 -0
  98. package/src/transports/vault.ts +1790 -0
  99. package/src/ui-kit.test.ts +178 -0
  100. package/src/ui-kit.ts +402 -0
  101. package/tsconfig.json +8 -14
  102. package/web/ui/tsconfig.json +2 -1
  103. package/.claude/scheduled_tasks.lock +0 -1
  104. package/.claude/settings.json +0 -5
  105. package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
  106. package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
  107. package/.claude/skills/add-codex/SKILL.md +0 -161
  108. package/.claude/skills/add-dashboard/SKILL.md +0 -138
  109. package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
  110. package/.claude/skills/add-emacs/SKILL.md +0 -296
  111. package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
  112. package/.claude/skills/add-gchat/REMOVE.md +0 -6
  113. package/.claude/skills/add-gchat/SKILL.md +0 -92
  114. package/.claude/skills/add-gchat/VERIFY.md +0 -3
  115. package/.claude/skills/add-github/REMOVE.md +0 -6
  116. package/.claude/skills/add-github/SKILL.md +0 -148
  117. package/.claude/skills/add-github/VERIFY.md +0 -3
  118. package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
  119. package/.claude/skills/add-imessage/REMOVE.md +0 -6
  120. package/.claude/skills/add-imessage/SKILL.md +0 -113
  121. package/.claude/skills/add-imessage/VERIFY.md +0 -3
  122. package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
  123. package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
  124. package/.claude/skills/add-linear/REMOVE.md +0 -6
  125. package/.claude/skills/add-linear/SKILL.md +0 -168
  126. package/.claude/skills/add-linear/VERIFY.md +0 -3
  127. package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
  128. package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
  129. package/.claude/skills/add-matrix/REMOVE.md +0 -6
  130. package/.claude/skills/add-matrix/SKILL.md +0 -148
  131. package/.claude/skills/add-matrix/VERIFY.md +0 -3
  132. package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
  133. package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
  134. package/.claude/skills/add-opencode/SKILL.md +0 -229
  135. package/.claude/skills/add-parallel/SKILL.md +0 -290
  136. package/.claude/skills/add-resend/REMOVE.md +0 -6
  137. package/.claude/skills/add-resend/SKILL.md +0 -93
  138. package/.claude/skills/add-resend/VERIFY.md +0 -3
  139. package/.claude/skills/add-signal/REMOVE.md +0 -13
  140. package/.claude/skills/add-signal/SKILL.md +0 -318
  141. package/.claude/skills/add-signal/VERIFY.md +0 -5
  142. package/.claude/skills/add-slack/REMOVE.md +0 -6
  143. package/.claude/skills/add-slack/SKILL.md +0 -112
  144. package/.claude/skills/add-slack/VERIFY.md +0 -3
  145. package/.claude/skills/add-teams/REMOVE.md +0 -6
  146. package/.claude/skills/add-teams/SKILL.md +0 -207
  147. package/.claude/skills/add-teams/VERIFY.md +0 -3
  148. package/.claude/skills/add-vercel/SKILL.md +0 -147
  149. package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
  150. package/.claude/skills/add-webex/REMOVE.md +0 -6
  151. package/.claude/skills/add-webex/SKILL.md +0 -88
  152. package/.claude/skills/add-webex/VERIFY.md +0 -3
  153. package/.claude/skills/add-wechat/REMOVE.md +0 -49
  154. package/.claude/skills/add-wechat/SKILL.md +0 -170
  155. package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
  156. package/.claude/skills/add-whatsapp/SKILL.md +0 -264
  157. package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
  158. package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
  159. package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
  160. package/.claude/skills/claw/SKILL.md +0 -131
  161. package/.claude/skills/claw/scripts/claw +0 -374
  162. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
  163. package/.claude/skills/customize/SKILL.md +0 -110
  164. package/.claude/skills/debug/SKILL.md +0 -349
  165. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  166. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  167. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  168. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  169. package/.claude/skills/init-first-agent/SKILL.md +0 -120
  170. package/.claude/skills/init-onecli/SKILL.md +0 -270
  171. package/.claude/skills/manage-channels/SKILL.md +0 -87
  172. package/.claude/skills/manage-mounts/SKILL.md +0 -47
  173. package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
  174. package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
  175. package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
  176. package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
  177. package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
  178. package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
  179. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  180. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  181. package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
  182. package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
  183. package/.claude/skills/update-skills/SKILL.md +0 -130
  184. package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
  185. package/.claude/skills/x-integration/SKILL.md +0 -417
  186. package/.claude/skills/x-integration/agent.ts +0 -243
  187. package/.claude/skills/x-integration/host.ts +0 -155
  188. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  189. package/.claude/skills/x-integration/lib/config.ts +0 -62
  190. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  191. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  192. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  193. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  194. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  195. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  196. package/.github/CODEOWNERS +0 -10
  197. package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
  198. package/.github/workflows/bump-version.yml +0 -35
  199. package/.github/workflows/ci.yml +0 -39
  200. package/.github/workflows/label-pr.yml +0 -40
  201. package/.github/workflows/update-tokens.yml +0 -43
  202. package/.husky/pre-commit +0 -1
  203. package/.mcp.json +0 -3
  204. package/.nvmrc +0 -1
  205. package/.prettierrc +0 -4
  206. package/CHANGELOG.md +0 -221
  207. package/CLAUDE.md +0 -307
  208. package/CODE_OF_CONDUCT.md +0 -128
  209. package/CONTRIBUTING.md +0 -159
  210. package/CONTRIBUTORS.md +0 -26
  211. package/LICENSE-NANOCLAW-MIT +0 -21
  212. package/README_ja.md +0 -194
  213. package/README_zh.md +0 -194
  214. package/assets/nanoclaw-favicon.png +0 -0
  215. package/assets/nanoclaw-icon.png +0 -0
  216. package/assets/nanoclaw-logo-dark.png +0 -0
  217. package/assets/nanoclaw-logo.png +0 -0
  218. package/assets/nanoclaw-profile.jpeg +0 -0
  219. package/assets/nanoclaw-sales.png +0 -0
  220. package/assets/social-preview.jpg +0 -0
  221. package/config-examples/mount-allowlist.json +0 -25
  222. package/container/.dockerignore +0 -2
  223. package/container/CLAUDE.md +0 -21
  224. package/container/Dockerfile +0 -121
  225. package/container/agent-runner/bun.lock +0 -243
  226. package/container/agent-runner/package.json +0 -22
  227. package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
  228. package/container/agent-runner/src/config.ts +0 -55
  229. package/container/agent-runner/src/db/connection.ts +0 -267
  230. package/container/agent-runner/src/db/index.ts +0 -20
  231. package/container/agent-runner/src/db/messages-in.ts +0 -138
  232. package/container/agent-runner/src/db/messages-out.ts +0 -143
  233. package/container/agent-runner/src/db/session-routing.ts +0 -30
  234. package/container/agent-runner/src/db/session-state.test.ts +0 -100
  235. package/container/agent-runner/src/db/session-state.ts +0 -79
  236. package/container/agent-runner/src/destinations.ts +0 -135
  237. package/container/agent-runner/src/formatter.test.ts +0 -167
  238. package/container/agent-runner/src/formatter.ts +0 -260
  239. package/container/agent-runner/src/index.ts +0 -110
  240. package/container/agent-runner/src/integration.test.ts +0 -121
  241. package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
  242. package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
  243. package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
  244. package/container/agent-runner/src/mcp-tools/core.ts +0 -262
  245. package/container/agent-runner/src/mcp-tools/index.ts +0 -22
  246. package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
  247. package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
  248. package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
  249. package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
  250. package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
  251. package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
  252. package/container/agent-runner/src/mcp-tools/server.ts +0 -54
  253. package/container/agent-runner/src/mcp-tools/types.ts +0 -6
  254. package/container/agent-runner/src/poll-loop.test.ts +0 -248
  255. package/container/agent-runner/src/poll-loop.ts +0 -437
  256. package/container/agent-runner/src/providers/claude.ts +0 -379
  257. package/container/agent-runner/src/providers/factory.test.ts +0 -19
  258. package/container/agent-runner/src/providers/factory.ts +0 -13
  259. package/container/agent-runner/src/providers/index.ts +0 -6
  260. package/container/agent-runner/src/providers/mock.ts +0 -77
  261. package/container/agent-runner/src/providers/provider-registry.ts +0 -33
  262. package/container/agent-runner/src/providers/types.ts +0 -82
  263. package/container/agent-runner/src/scheduling/task-script.ts +0 -121
  264. package/container/agent-runner/src/timezone.test.ts +0 -93
  265. package/container/agent-runner/src/timezone.ts +0 -107
  266. package/container/agent-runner/tsconfig.json +0 -14
  267. package/container/build.sh +0 -48
  268. package/container/entrypoint.sh +0 -16
  269. package/container/skills/agent-browser/SKILL.md +0 -159
  270. package/container/skills/frontend-engineer/SKILL.md +0 -157
  271. package/container/skills/self-customize/SKILL.md +0 -87
  272. package/container/skills/slack-formatting/SKILL.md +0 -94
  273. package/container/skills/vercel-cli/SKILL.md +0 -111
  274. package/container/skills/welcome/SKILL.md +0 -85
  275. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  276. package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
  277. package/docs/README.md +0 -25
  278. package/docs/SDK_DEEP_DIVE.md +0 -643
  279. package/docs/SECURITY.md +0 -162
  280. package/docs/agent-runner-details.md +0 -749
  281. package/docs/api-details.md +0 -365
  282. package/docs/architecture-diagram.html +0 -422
  283. package/docs/architecture-diagram.md +0 -215
  284. package/docs/architecture.md +0 -751
  285. package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
  286. package/docs/build-and-runtime.md +0 -80
  287. package/docs/cross-mount-stress/README.md +0 -112
  288. package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
  289. package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
  290. package/docs/cross-mount-stress/container-writer.mjs +0 -47
  291. package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
  292. package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
  293. package/docs/cross-mount-stress/host-writer.mjs +0 -47
  294. package/docs/db-central.md +0 -316
  295. package/docs/db-session.md +0 -183
  296. package/docs/db.md +0 -119
  297. package/docs/design/2026-04-29-vault-management-ui.md +0 -231
  298. package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
  299. package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
  300. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
  301. package/docs/docker-sandboxes.md +0 -359
  302. package/docs/isolation-model.md +0 -88
  303. package/docs/ollama.md +0 -79
  304. package/docs/parachute-integration.md +0 -109
  305. package/docs/post-night-rebirth-reflections.md +0 -151
  306. package/eslint.config.js +0 -32
  307. package/pnpm-workspace.yaml +0 -8
  308. package/repo-tokens/README.md +0 -113
  309. package/repo-tokens/action.yml +0 -186
  310. package/repo-tokens/badge.svg +0 -23
  311. package/repo-tokens/examples/green.svg +0 -14
  312. package/repo-tokens/examples/red.svg +0 -14
  313. package/repo-tokens/examples/yellow-green.svg +0 -14
  314. package/repo-tokens/examples/yellow.svg +0 -14
  315. package/scripts/chat.ts +0 -101
  316. package/scripts/cleanup-sessions.sh +0 -150
  317. package/scripts/init-cli-agent.ts +0 -171
  318. package/scripts/init-first-agent.ts +0 -377
  319. package/scripts/parachute.ts +0 -158
  320. package/scripts/run-migrations.ts +0 -105
  321. package/scripts/sanity-live-poll.ts +0 -95
  322. package/scripts/seed-discord.ts +0 -79
  323. package/scripts/test-v2-agent.ts +0 -106
  324. package/scripts/test-v2-channel-e2e.ts +0 -265
  325. package/scripts/test-v2-host.ts +0 -184
  326. package/src/channels/adapter.ts +0 -214
  327. package/src/channels/ask-question.ts +0 -46
  328. package/src/channels/channel-registry.test.ts +0 -421
  329. package/src/channels/channel-registry.ts +0 -313
  330. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  331. package/src/channels/chat-sdk-bridge.ts +0 -652
  332. package/src/channels/cli.ts +0 -276
  333. package/src/channels/discord.ts +0 -90
  334. package/src/channels/index.ts +0 -17
  335. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  336. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  337. package/src/channels/telegram-pairing.test.ts +0 -254
  338. package/src/channels/telegram-pairing.ts +0 -339
  339. package/src/channels/telegram.ts +0 -279
  340. package/src/channels/trust-hint.test.ts +0 -48
  341. package/src/channels/trust-hint.ts +0 -75
  342. package/src/claude-md-compose.migrate.test.ts +0 -64
  343. package/src/claude-md-compose.ts +0 -205
  344. package/src/command-gate.ts +0 -63
  345. package/src/config.test.ts +0 -93
  346. package/src/config.ts +0 -108
  347. package/src/container-config.ts +0 -167
  348. package/src/container-runner.test.ts +0 -32
  349. package/src/container-runner.ts +0 -576
  350. package/src/container-runtime.test.ts +0 -169
  351. package/src/container-runtime.ts +0 -92
  352. package/src/db/_bun-sqlite-shim.ts +0 -88
  353. package/src/db/agent-activity.test.ts +0 -155
  354. package/src/db/agent-activity.ts +0 -121
  355. package/src/db/agent-groups.ts +0 -77
  356. package/src/db/connection.migrate.test.ts +0 -143
  357. package/src/db/connection.ts +0 -224
  358. package/src/db/db-v2.test.ts +0 -440
  359. package/src/db/dropped-messages.ts +0 -44
  360. package/src/db/index.ts +0 -40
  361. package/src/db/messaging-groups.ts +0 -252
  362. package/src/db/migrations/001-initial.ts +0 -112
  363. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  364. package/src/db/migrations/008-dropped-messages.ts +0 -27
  365. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  366. package/src/db/migrations/010-engage-modes.ts +0 -103
  367. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  368. package/src/db/migrations/012-channel-registration.ts +0 -48
  369. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  370. package/src/db/migrations/014-secrets.ts +0 -44
  371. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  372. package/src/db/migrations/016-secret-assignments.ts +0 -30
  373. package/src/db/migrations/017-agent-activity.ts +0 -40
  374. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  375. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  376. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  377. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  378. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  379. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  380. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  381. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  382. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  383. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  384. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  385. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  386. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  387. package/src/db/migrations/027-provider-credentials.ts +0 -41
  388. package/src/db/migrations/_test-helpers.ts +0 -41
  389. package/src/db/migrations/index.ts +0 -127
  390. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  391. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  392. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  393. package/src/db/schema.ts +0 -258
  394. package/src/db/session-db.test.ts +0 -93
  395. package/src/db/session-db.ts +0 -325
  396. package/src/db/sessions.ts +0 -241
  397. package/src/delivery.test.ts +0 -148
  398. package/src/delivery.ts +0 -445
  399. package/src/env.ts +0 -74
  400. package/src/group-folder.test.ts +0 -35
  401. package/src/group-folder.ts +0 -44
  402. package/src/group-init.ts +0 -92
  403. package/src/host-core.test.ts +0 -456
  404. package/src/host-sweep.test.ts +0 -146
  405. package/src/host-sweep.ts +0 -287
  406. package/src/index.ts +0 -227
  407. package/src/install-slug.ts +0 -33
  408. package/src/log.test.ts +0 -81
  409. package/src/log.ts +0 -117
  410. package/src/mcp/http.ts +0 -72
  411. package/src/mcp/server.ts +0 -92
  412. package/src/mcp/stdio.ts +0 -51
  413. package/src/mcp/tools/activity.ts +0 -88
  414. package/src/mcp/tools/agent-groups.ts +0 -183
  415. package/src/mcp/tools/approvals.ts +0 -122
  416. package/src/mcp/tools/channels.ts +0 -199
  417. package/src/mcp/tools/index.ts +0 -27
  418. package/src/mcp/tools/oauth.ts +0 -48
  419. package/src/mcp/tools/secrets.ts +0 -169
  420. package/src/mcp/tools/sessions.ts +0 -135
  421. package/src/mcp/types.ts +0 -51
  422. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  423. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  424. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  425. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  426. package/src/modules/agent-to-agent/index.ts +0 -22
  427. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  428. package/src/modules/approvals/agent.md +0 -45
  429. package/src/modules/approvals/index.ts +0 -21
  430. package/src/modules/approvals/picks.test.ts +0 -291
  431. package/src/modules/approvals/primitive.ts +0 -279
  432. package/src/modules/approvals/project.md +0 -27
  433. package/src/modules/approvals/response-handler.ts +0 -87
  434. package/src/modules/index.ts +0 -24
  435. package/src/modules/interactive/agent.md +0 -21
  436. package/src/modules/interactive/index.ts +0 -69
  437. package/src/modules/interactive/project.md +0 -12
  438. package/src/modules/mount-security/index.ts +0 -448
  439. package/src/modules/mount-security/migrate.test.ts +0 -91
  440. package/src/modules/permissions/access.ts +0 -28
  441. package/src/modules/permissions/channel-approval.test.ts +0 -389
  442. package/src/modules/permissions/channel-approval.ts +0 -188
  443. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  444. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  445. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  446. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  447. package/src/modules/permissions/db/user-dms.ts +0 -58
  448. package/src/modules/permissions/db/user-roles.ts +0 -85
  449. package/src/modules/permissions/db/users.ts +0 -38
  450. package/src/modules/permissions/index.ts +0 -421
  451. package/src/modules/permissions/permissions.test.ts +0 -358
  452. package/src/modules/permissions/sender-approval.test.ts +0 -470
  453. package/src/modules/permissions/sender-approval.ts +0 -165
  454. package/src/modules/permissions/user-dm.ts +0 -200
  455. package/src/modules/provider-credentials/db.ts +0 -121
  456. package/src/modules/provider-credentials/index.ts +0 -12
  457. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  458. package/src/modules/provider-credentials/spawn.ts +0 -114
  459. package/src/modules/scheduling/actions.ts +0 -113
  460. package/src/modules/scheduling/db.test.ts +0 -282
  461. package/src/modules/scheduling/db.ts +0 -148
  462. package/src/modules/scheduling/index.ts +0 -34
  463. package/src/modules/scheduling/recurrence.test.ts +0 -98
  464. package/src/modules/scheduling/recurrence.ts +0 -54
  465. package/src/modules/self-mod/agent.md +0 -30
  466. package/src/modules/self-mod/apply.ts +0 -85
  467. package/src/modules/self-mod/index.ts +0 -30
  468. package/src/modules/self-mod/project.md +0 -39
  469. package/src/modules/self-mod/request.ts +0 -91
  470. package/src/modules/typing/index.ts +0 -165
  471. package/src/oauth/agent-app-connections.ts +0 -103
  472. package/src/oauth/app-configs.test.ts +0 -64
  473. package/src/oauth/app-configs.ts +0 -114
  474. package/src/oauth/app-connections.test.ts +0 -109
  475. package/src/oauth/app-connections.ts +0 -178
  476. package/src/oauth/crypto.ts +0 -56
  477. package/src/oauth/flow.ts +0 -104
  478. package/src/oauth/providers/google.test.ts +0 -38
  479. package/src/oauth/providers/google.ts +0 -46
  480. package/src/oauth/providers/index.ts +0 -48
  481. package/src/oauth/state-store.test.ts +0 -54
  482. package/src/oauth/state-store.ts +0 -93
  483. package/src/parachute/README.md +0 -27
  484. package/src/parachute/create-agent.test.ts +0 -83
  485. package/src/parachute/create-agent.ts +0 -122
  486. package/src/parachute/group-status.test.ts +0 -165
  487. package/src/parachute/group-status.ts +0 -136
  488. package/src/parachute/types.ts +0 -41
  489. package/src/parachute/vault-mcp.test.ts +0 -251
  490. package/src/parachute/vault-mcp.ts +0 -232
  491. package/src/platform-id.test.ts +0 -104
  492. package/src/platform-id.ts +0 -109
  493. package/src/providers/index.ts +0 -6
  494. package/src/providers/provider-container-registry.ts +0 -58
  495. package/src/response-registry.ts +0 -45
  496. package/src/router.ts +0 -530
  497. package/src/secrets/crypto.test.ts +0 -45
  498. package/src/secrets/crypto.ts +0 -55
  499. package/src/secrets/index.ts +0 -355
  500. package/src/secrets/master-key.ts +0 -70
  501. package/src/secrets/secrets.test.ts +0 -354
  502. package/src/session-manager.migrate.test.ts +0 -59
  503. package/src/session-manager.ts +0 -433
  504. package/src/startup-bootstrap.test.ts +0 -226
  505. package/src/startup-bootstrap.ts +0 -207
  506. package/src/state-sqlite.ts +0 -182
  507. package/src/timezone.test.ts +0 -64
  508. package/src/timezone.ts +0 -37
  509. package/src/types.ts +0 -230
  510. package/src/web/auth.test.ts +0 -335
  511. package/src/web/auth.ts +0 -214
  512. package/src/web/discord-validate.test.ts +0 -77
  513. package/src/web/discord-validate.ts +0 -88
  514. package/src/web/hub-discovery.test.ts +0 -98
  515. package/src/web/hub-discovery.ts +0 -69
  516. package/src/web/routes/activity.ts +0 -106
  517. package/src/web/routes/agent-provider.test.ts +0 -282
  518. package/src/web/routes/agent-provider.ts +0 -309
  519. package/src/web/routes/approvals.ts +0 -185
  520. package/src/web/routes/apps.ts +0 -434
  521. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  522. package/src/web/routes/channels-mga-detail.test.ts +0 -425
  523. package/src/web/routes/channels.ts +0 -489
  524. package/src/web/routes/oauth-providers.ts +0 -42
  525. package/src/web/routes/secrets.test.ts +0 -175
  526. package/src/web/routes/secrets.ts +0 -282
  527. package/src/web/routes/sessions.ts +0 -123
  528. package/src/web/routes/settings.test.ts +0 -106
  529. package/src/web/routes/settings.ts +0 -247
  530. package/src/web/routes/setup-status.ts +0 -205
  531. package/src/web/routes/vaults.test.ts +0 -389
  532. package/src/web/routes/vaults.ts +0 -225
  533. package/src/web/server-version.test.ts +0 -16
  534. package/src/web/server.ts +0 -1003
  535. package/src/web/services-manifest.test.ts +0 -120
  536. package/src/web/services-manifest.ts +0 -61
  537. package/src/web/static-serve.test.ts +0 -255
  538. package/src/web/static-serve.ts +0 -104
  539. package/src/web/telegram-validate.test.ts +0 -116
  540. package/src/web/telegram-validate.ts +0 -107
  541. package/src/web/vault-proxy.test.ts +0 -214
  542. package/src/web/vault-proxy.ts +0 -120
  543. package/src/web/wire-channel.ts +0 -181
  544. package/src/webhook-server.ts +0 -134
  545. package/vitest.config.ts +0 -18
  546. package/web/README.md +0 -63
  547. package/web/ui/index.html +0 -13
  548. package/web/ui/package.json +0 -35
  549. package/web/ui/pnpm-lock.yaml +0 -2164
  550. package/web/ui/scripts/verify-base.mjs +0 -31
  551. package/web/ui/src/App.tsx +0 -88
  552. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  553. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  554. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  555. package/web/ui/src/components/CredentialForm.tsx +0 -214
  556. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  557. package/web/ui/src/components/StatusDot.tsx +0 -43
  558. package/web/ui/src/components/VaultPicker.tsx +0 -127
  559. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  560. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  561. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  562. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  563. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  564. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  565. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  566. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  567. package/web/ui/src/components/setup/types.ts +0 -105
  568. package/web/ui/src/lib/api.test.ts +0 -410
  569. package/web/ui/src/lib/api.ts +0 -1210
  570. package/web/ui/src/lib/auth.test.ts +0 -139
  571. package/web/ui/src/lib/auth.ts +0 -348
  572. package/web/ui/src/lib/channel-adapters.ts +0 -136
  573. package/web/ui/src/main.tsx +0 -19
  574. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  575. package/web/ui/src/routes/Apps.tsx +0 -613
  576. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  577. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  578. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  579. package/web/ui/src/routes/GroupDetail.tsx +0 -755
  580. package/web/ui/src/routes/GroupList.tsx +0 -187
  581. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  582. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  583. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  584. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  585. package/web/ui/src/routes/SecretsList.tsx +0 -921
  586. package/web/ui/src/routes/SessionsList.tsx +0 -220
  587. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  588. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  589. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  590. package/web/ui/src/routes/VaultDetail.test.tsx +0 -361
  591. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  592. package/web/ui/src/routes/VaultsList.tsx +0 -295
  593. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  594. package/web/ui/src/styles.css +0 -608
  595. package/web/ui/src/test/setup.ts +0 -23
  596. package/web/ui/src/vite-env.d.ts +0 -10
  597. package/web/ui/vite.config.ts +0 -34
  598. package/web/ui/vitest.config.ts +0 -25
@@ -0,0 +1,1504 @@
1
+ /**
2
+ * Unit tests for vault-native agent definitions (design
3
+ * 2026-06-17-vault-native-agents, Phase 4a).
4
+ *
5
+ * Three layers, all deterministic — `fetch` is stubbed (restored in afterEach, NO
6
+ * global mock.module leak) and the registry's side-effects are INJECTED so the
7
+ * lifecycle is exercised without a daemon, a vault, a sandbox, or tmux:
8
+ * - parseAgentDef: note (body + metadata) → AgentSpec, defaults + validation;
9
+ * - DefVaultClient: the def query encoding + the status PATCH;
10
+ * - AgentDefRegistry: instantiate / reload (update + delete) / deregister, with a
11
+ * recorder for ensureChannel / setupAndRegister / deregister / removeChannel.
12
+ */
13
+
14
+ import { describe, test, expect, afterEach } from "bun:test";
15
+ import {
16
+ parseAgentDef,
17
+ resolveDefStatus,
18
+ DefVaultClient,
19
+ AgentDefRegistry,
20
+ AgentDefParseError,
21
+ AgentDefWriteError,
22
+ type DefVaultBinding,
23
+ type InstantiateDeps,
24
+ } from "./agent-defs.ts";
25
+ import { GrantsClient, connectionKey, type ConnectionSpec } from "./grants.ts";
26
+ import type { AgentSpec } from "./sandbox/types.ts";
27
+
28
+ const realFetch = globalThis.fetch;
29
+ afterEach(() => {
30
+ globalThis.fetch = realFetch;
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // parseAgentDef — note → AgentSpec
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe("parseAgentDef", () => {
38
+ test("maps body → systemPrompt, metadata → spec; defaults backend=programmatic, own-vault binding", () => {
39
+ const def = parseAgentDef(
40
+ {
41
+ id: "Agents/uni-dev",
42
+ content: "You are uni-dev, the development agent for the Parachute project.",
43
+ metadata: { name: "uni-dev" },
44
+ },
45
+ { vault: "default" },
46
+ );
47
+ expect(def.noteId).toBe("Agents/uni-dev");
48
+ expect(def.name).toBe("uni-dev");
49
+ const spec = def.spec;
50
+ expect(spec.name).toBe("uni-dev");
51
+ // Wake channel = the agent name (agent ≡ channel).
52
+ expect(spec.channels).toEqual(["uni-dev"]);
53
+ expect(spec.backend).toBe("programmatic");
54
+ // Own-vault binding (4a): the def-vault, write-scoped.
55
+ expect(spec.vault).toEqual({ name: "default", access: "write" });
56
+ // The note BODY is the system prompt.
57
+ expect(spec.systemPrompt).toBe(
58
+ "You are uni-dev, the development agent for the Parachute project.",
59
+ );
60
+ // No declared connections → resolves enabled.
61
+ expect(def.declaredConnections).toEqual([]);
62
+ expect(resolveDefStatus(def)).toEqual({ status: "enabled" });
63
+ });
64
+
65
+ test("parses the full config knobs (backend, mode, workspace, filesystem, network, egress)", () => {
66
+ const def = parseAgentDef(
67
+ {
68
+ id: "n1",
69
+ content: "role prose",
70
+ metadata: {
71
+ name: "builder",
72
+ backend: "programmatic",
73
+ systemPromptMode: "replace",
74
+ workspace: "/Users/me/code/proj",
75
+ filesystem: "full",
76
+ network: "restricted",
77
+ egress: "api.github.com, registry.npmjs.org",
78
+ },
79
+ },
80
+ { vault: "default" },
81
+ );
82
+ const spec = def.spec;
83
+ expect(spec.systemPromptMode).toBe("replace");
84
+ expect(spec.workspace).toBe("/Users/me/code/proj");
85
+ expect(spec.filesystem).toBe("full");
86
+ expect(spec.network).toBe("restricted");
87
+ expect(spec.egress).toEqual(["api.github.com", "registry.npmjs.org"]);
88
+ });
89
+
90
+ test("metadata.model → spec.model (alias or full id); absent → undefined", () => {
91
+ const withModel = parseAgentDef(
92
+ { id: "n1", content: "x", metadata: { name: "a", model: "opus" } },
93
+ { vault: "default" },
94
+ );
95
+ expect(withModel.spec.model).toBe("opus");
96
+
97
+ const fullId = parseAgentDef(
98
+ { id: "n1", content: "x", metadata: { name: "a", model: "claude-opus-4-8" } },
99
+ { vault: "default" },
100
+ );
101
+ expect(fullId.spec.model).toBe("claude-opus-4-8");
102
+
103
+ const noModel = parseAgentDef(
104
+ { id: "n1", content: "x", metadata: { name: "a" } },
105
+ { vault: "default" },
106
+ );
107
+ expect(noModel.spec.model).toBeUndefined();
108
+ });
109
+
110
+ test("a malformed model (spaces/control chars) is a parse error, not a silent passthrough", () => {
111
+ expect(() =>
112
+ parseAgentDef(
113
+ { id: "n", content: "x", metadata: { name: "a", model: "opus 4.8" } },
114
+ { vault: "v" },
115
+ ),
116
+ ).toThrow(/not a valid model name/);
117
+ });
118
+
119
+ test("a blank body → no systemPrompt (CC default untouched), no mode flag", () => {
120
+ const def = parseAgentDef(
121
+ { id: "n1", content: " \n ", metadata: { name: "a", systemPromptMode: "replace" } },
122
+ { vault: "default" },
123
+ );
124
+ expect("systemPrompt" in def.spec).toBe(false);
125
+ expect("systemPromptMode" in def.spec).toBe(false);
126
+ });
127
+
128
+ test("parses `uses` connections (NOT granted in 4a) → status pending listing them", () => {
129
+ const def = parseAgentDef(
130
+ {
131
+ id: "n1",
132
+ content: "role",
133
+ metadata: { name: "researcher", uses: "github, vault:research:read" },
134
+ },
135
+ { vault: "default" },
136
+ );
137
+ expect(def.declaredConnections).toEqual(["github", "vault:research:read"]);
138
+ expect(resolveDefStatus(def)).toEqual({
139
+ status: "pending",
140
+ pending: ["github", "vault:research:read"],
141
+ });
142
+ });
143
+
144
+ test("parses an array-valued `uses` field too", () => {
145
+ const def = parseAgentDef(
146
+ { id: "n1", content: "role", metadata: { name: "x", uses: ["github", "cloudflare"] } },
147
+ { vault: "default" },
148
+ );
149
+ expect(def.declaredConnections).toEqual(["github", "cloudflare"]);
150
+ });
151
+
152
+ test("parses the structured `wants:` field into connection specs (4b)", () => {
153
+ const def = parseAgentDef(
154
+ {
155
+ id: "n1",
156
+ content: "role",
157
+ metadata: {
158
+ name: "researcher",
159
+ wants: "vault:research:read#published, env:github, mcp:github, mcp:https://remote/mcp",
160
+ },
161
+ },
162
+ { vault: "default" },
163
+ );
164
+ expect(def.wants).toEqual([
165
+ { kind: "vault", target: "research", access: "read", tags: ["#published"] },
166
+ { kind: "service", target: "github", inject: ["env", "mcp"] }, // merged
167
+ { kind: "mcp", target: "https://remote/mcp" },
168
+ ]);
169
+ // No grants client wired (pure resolveDefStatus) → pending listing the conn keys.
170
+ expect(resolveDefStatus(def)).toEqual({
171
+ status: "pending",
172
+ pending: def.wants.map((c) => connectionKey(c)),
173
+ });
174
+ });
175
+
176
+ test("a def with no `wants:` → wants is [] (own-vault only → enabled)", () => {
177
+ const def = parseAgentDef(
178
+ { id: "n1", content: "role", metadata: { name: "x" } },
179
+ { vault: "default" },
180
+ );
181
+ expect(def.wants).toEqual([]);
182
+ expect(resolveDefStatus(def)).toEqual({ status: "enabled" });
183
+ });
184
+
185
+ test("a MALFORMED `wants:` makes the WHOLE def a parse error (no half-instantiate)", () => {
186
+ expect(() =>
187
+ parseAgentDef(
188
+ { id: "n1", content: "role", metadata: { name: "x", wants: "vault:research" } },
189
+ { vault: "default" },
190
+ ),
191
+ ).toThrow(AgentDefParseError);
192
+ expect(() =>
193
+ parseAgentDef(
194
+ { id: "n1", content: "role", metadata: { name: "x", wants: "smtp:server" } },
195
+ { vault: "default" },
196
+ ),
197
+ ).toThrow(/unknown kind/);
198
+ });
199
+
200
+ test("parses JSON-array mounts; ignores malformed entries", () => {
201
+ const def = parseAgentDef(
202
+ {
203
+ id: "n1",
204
+ content: "role",
205
+ metadata: {
206
+ name: "x",
207
+ mounts: JSON.stringify([
208
+ { hostPath: "/data", mountPath: "/data", mode: "ro" },
209
+ { hostPath: "relative", mountPath: "/x", mode: "ro" }, // dropped (not absolute)
210
+ { hostPath: "/y", mountPath: "/y", mode: "bogus" }, // dropped (bad mode)
211
+ ]),
212
+ },
213
+ },
214
+ { vault: "default" },
215
+ );
216
+ expect(def.spec.mounts).toEqual([{ hostPath: "/data", mountPath: "/data", mode: "ro" }]);
217
+ });
218
+
219
+ test("rejects a note with no metadata.name", () => {
220
+ expect(() => parseAgentDef({ id: "n1", content: "x", metadata: {} }, { vault: "default" })).toThrow(
221
+ AgentDefParseError,
222
+ );
223
+ });
224
+
225
+ test("rejects a non-slug name", () => {
226
+ expect(() =>
227
+ parseAgentDef({ id: "n1", content: "x", metadata: { name: "has spaces" } }, { vault: "default" }),
228
+ ).toThrow(/slug/);
229
+ });
230
+
231
+ test("rejects a bad backend / filesystem / network value", () => {
232
+ expect(() =>
233
+ parseAgentDef({ id: "n", content: "x", metadata: { name: "a", backend: "weird" } }, { vault: "v" }),
234
+ ).toThrow(/backend/);
235
+ expect(() =>
236
+ parseAgentDef({ id: "n", content: "x", metadata: { name: "a", filesystem: "weird" } }, { vault: "v" }),
237
+ ).toThrow(/filesystem/);
238
+ expect(() =>
239
+ parseAgentDef({ id: "n", content: "x", metadata: { name: "a", network: "weird" } }, { vault: "v" }),
240
+ ).toThrow(/network/);
241
+ });
242
+
243
+ test("rejects backend:interactive (retired — design 2026-06-18)", () => {
244
+ expect(() =>
245
+ parseAgentDef({ id: "n", content: "x", metadata: { name: "a", backend: "interactive" } }, { vault: "v" }),
246
+ ).toThrow(/interactive/);
247
+ });
248
+
249
+ test("accepts backend:attached (design 2026-06-18-channel-backend), threads it onto the spec", () => {
250
+ const def = parseAgentDef(
251
+ { id: "Agents/laptop", content: "You are the laptop agent.", metadata: { name: "laptop", backend: "attached" } },
252
+ { vault: "default" },
253
+ );
254
+ expect(def.spec.backend).toBe("attached");
255
+ expect(def.name).toBe("laptop");
256
+ // The body is still the system prompt (the session adopts it on next-message).
257
+ expect(def.spec.systemPrompt).toBe("You are the laptop agent.");
258
+ // Wake channel = the agent name (agent ≡ channel) — same collapse as programmatic.
259
+ expect(def.spec.channels).toEqual(["laptop"]);
260
+ });
261
+
262
+ test("DUAL-READ: a persisted def with the legacy backend value \"channel\" normalizes to \"attached\"", () => {
263
+ // The backend VALUE was renamed `channel` → `attached`. An already-authored def note
264
+ // (or spec.json) carrying the legacy `metadata.backend: "channel"` must still LOAD —
265
+ // normalized to the canonical `attached` on read, no operator-facing break, no
266
+ // migration. (The routing key `channel` — metadata.channel / the `/mcp/<channel>`
267
+ // segment — is a SEPARATE concept and is deliberately unchanged.)
268
+ const def = parseAgentDef(
269
+ { id: "Agents/laptop", content: "You are the laptop agent.", metadata: { name: "laptop", backend: "channel" } },
270
+ { vault: "default" },
271
+ );
272
+ expect(def.spec.backend).toBe("attached"); // normalized, NOT the legacy "channel".
273
+ expect(def.name).toBe("laptop");
274
+ expect(def.spec.channels).toEqual(["laptop"]); // routing key unchanged.
275
+ });
276
+
277
+ // --- execution-lifecycle mode (the Phase-3 prerequisite) ---
278
+
279
+ test("mode defaults to single-threaded when omitted (= today's behavior)", () => {
280
+ const def = parseAgentDef(
281
+ { id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } },
282
+ { vault: "default" },
283
+ );
284
+ expect(def.spec.mode).toBe("single-threaded");
285
+ // The def note id is threaded onto the spec as provenance (for the `#agent/thread` note).
286
+ expect(def.spec.definition).toBe("Agents/uni-dev");
287
+ });
288
+
289
+ test("accepts mode:single-threaded explicitly", () => {
290
+ const def = parseAgentDef(
291
+ { id: "n1", content: "role", metadata: { name: "a", mode: "single-threaded" } },
292
+ { vault: "v" },
293
+ );
294
+ expect(def.spec.mode).toBe("single-threaded");
295
+ });
296
+
297
+ test("accepts mode:multi-threaded, threads it onto the spec", () => {
298
+ const def = parseAgentDef(
299
+ { id: "Agents/digest", content: "Run the daily digest.", metadata: { name: "digest", mode: "multi-threaded" } },
300
+ { vault: "default" },
301
+ );
302
+ expect(def.spec.mode).toBe("multi-threaded");
303
+ expect(def.spec.definition).toBe("Agents/digest");
304
+ });
305
+
306
+ test("rejects an UNKNOWN mode value with AgentDefParseError", () => {
307
+ expect(() =>
308
+ parseAgentDef({ id: "n", content: "x", metadata: { name: "a", mode: "weird" } }, { vault: "v" }),
309
+ ).toThrow(/mode must be "single-threaded" or "multi-threaded"/);
310
+ });
311
+
312
+ test("DUAL-ACCEPTs the legacy aliases (resident→single, one-shot/per-thread→multi)", () => {
313
+ const resident = parseAgentDef(
314
+ { id: "n1", content: "x", metadata: { name: "a", mode: "resident" } },
315
+ { vault: "v" },
316
+ );
317
+ expect(resident.spec.mode).toBe("single-threaded");
318
+
319
+ const oneShot = parseAgentDef(
320
+ { id: "n2", content: "x", metadata: { name: "b", mode: "one-shot" } },
321
+ { vault: "v" },
322
+ );
323
+ expect(oneShot.spec.mode).toBe("multi-threaded");
324
+
325
+ const perThread = parseAgentDef(
326
+ { id: "n3", content: "x", metadata: { name: "c", mode: "per-thread" } },
327
+ { vault: "v" },
328
+ );
329
+ expect(perThread.spec.mode).toBe("multi-threaded");
330
+ });
331
+
332
+ test("rejects a relative workspace path", () => {
333
+ expect(() =>
334
+ parseAgentDef({ id: "n", content: "x", metadata: { name: "a", workspace: "rel/path" } }, { vault: "v" }),
335
+ ).toThrow(/absolute/);
336
+ });
337
+
338
+ test("does NOT read any secret field off the note (only references)", () => {
339
+ // A note that tries to smuggle a token must NOT end up on the spec.
340
+ const def = parseAgentDef(
341
+ { id: "n", content: "x", metadata: { name: "a", token: "sekret", CLAUDE_CODE_OAUTH_TOKEN: "sekret2" } },
342
+ { vault: "v" },
343
+ );
344
+ expect(JSON.stringify(def.spec)).not.toContain("sekret");
345
+ });
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // DefVaultClient — the def query + the status PATCH
350
+ // ---------------------------------------------------------------------------
351
+
352
+ const binding: DefVaultBinding = {
353
+ vault: "default",
354
+ vaultUrl: "http://127.0.0.1:1940",
355
+ token: "write-token",
356
+ };
357
+
358
+ describe("DefVaultClient", () => {
359
+ test("listDefNotes queries by the EXACT #agent/definition tag (encoded) with Bearer", async () => {
360
+ const urls: string[] = [];
361
+ let auth = "";
362
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
363
+ urls.push(String(url));
364
+ auth = (init?.headers as Record<string, string> | undefined)?.authorization ?? "";
365
+ return new Response(
366
+ JSON.stringify([
367
+ { id: "Agents/uni-dev", content: "role A", metadata: { name: "uni-dev" } },
368
+ { id: "Agents/researcher", content: "role B", metadata: { name: "researcher" } },
369
+ { id: "", content: "no id", metadata: { name: "skip" } }, // dropped (no id)
370
+ ]),
371
+ { status: 200, headers: { "content-type": "application/json" } },
372
+ );
373
+ }) as typeof fetch;
374
+
375
+ const client = new DefVaultClient(binding);
376
+ const notes = await client.listDefNotes();
377
+ expect(urls).toHaveLength(1);
378
+ // `#agent/definition` → `%23agent%2Fdefinition` (both `#` and `/` encoded).
379
+ expect(urls[0]).toContain("tag=%23agent%2Fdefinition");
380
+ expect(urls[0]).toContain("include_content=true");
381
+ expect(auth).toBe("Bearer write-token");
382
+ expect(notes.map((n) => n.id)).toEqual(["Agents/uni-dev", "Agents/researcher"]);
383
+ });
384
+
385
+ test("listDefNotes throws on a non-ok vault response", async () => {
386
+ globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch;
387
+ const client = new DefVaultClient(binding);
388
+ await expect(client.listDefNotes()).rejects.toThrow(/list defs failed \(500\)/);
389
+ });
390
+
391
+ test("patchStatus PATCHes status + clears pending when enabled", async () => {
392
+ const calls: { url: string; init: RequestInit }[] = [];
393
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
394
+ calls.push({ url: String(url), init: init ?? {} });
395
+ return new Response(null, { status: 200 });
396
+ }) as typeof fetch;
397
+ const client = new DefVaultClient(binding);
398
+ await client.patchStatus("Agents/uni-dev", "enabled");
399
+ expect(calls).toHaveLength(1);
400
+ expect(calls[0]!.init.method).toBe("PATCH");
401
+ expect(calls[0]!.url).toContain("/api/notes/");
402
+ const body = JSON.parse(String(calls[0]!.init.body));
403
+ expect(body.metadata.status).toBe("enabled");
404
+ // Always sets pending (empty here) so a prior list doesn't go stale.
405
+ expect(body.metadata.pending).toBe("");
406
+ // MUST carry the vault mutation precondition or the PATCH 428s (the real-vault
407
+ // bug this guards): `force: true` since status is the module's own derived field.
408
+ expect(body.force).toBe(true);
409
+ });
410
+
411
+ test("patchStatus writes the pending list joined when pending", async () => {
412
+ let captured: Record<string, string> = {};
413
+ globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
414
+ captured = JSON.parse(String(init?.body)).metadata;
415
+ return new Response(null, { status: 200 });
416
+ }) as typeof fetch;
417
+ const client = new DefVaultClient(binding);
418
+ await client.patchStatus("n", "pending", ["github", "vault:research:read"]);
419
+ expect(captured.status).toBe("pending");
420
+ expect(captured.pending).toBe("github, vault:research:read");
421
+ });
422
+
423
+ test("getNote returns null on 404", async () => {
424
+ globalThis.fetch = (async () => new Response("no", { status: 404 })) as unknown as typeof fetch;
425
+ const client = new DefVaultClient(binding);
426
+ expect(await client.getNote("gone")).toBeNull();
427
+ });
428
+
429
+ test("createNote POSTs body + the def tag + metadata, returns the created note", async () => {
430
+ let captured: { url: string; method: string; body: Record<string, unknown> } | null = null;
431
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
432
+ captured = { url: String(url), method: init?.method ?? "GET", body: JSON.parse(String(init?.body)) };
433
+ return new Response(JSON.stringify({ id: "Agents/newbot", content: "P", metadata: { name: "newbot" } }), {
434
+ status: 200,
435
+ headers: { "content-type": "application/json" },
436
+ });
437
+ }) as typeof fetch;
438
+ const client = new DefVaultClient(binding);
439
+ const created = await client.createNote({
440
+ content: "P",
441
+ metadata: { name: "newbot", backend: "programmatic" },
442
+ path: "Agents/newbot",
443
+ });
444
+ expect(created.id).toBe("Agents/newbot");
445
+ expect(captured!.method).toBe("POST");
446
+ expect(captured!.url).toContain("/vault/default/api/notes");
447
+ expect(captured!.body.tags).toEqual(["#agent/definition"]);
448
+ expect(captured!.body.content).toBe("P");
449
+ expect((captured!.body.metadata as Record<string, string>).name).toBe("newbot");
450
+ expect(captured!.body.path).toBe("Agents/newbot");
451
+ });
452
+
453
+ test("createNote throws on a non-ok vault response", async () => {
454
+ globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch;
455
+ const client = new DefVaultClient(binding);
456
+ await expect(
457
+ client.createNote({ content: "x", metadata: { name: "x" } }),
458
+ ).rejects.toThrow(/create def failed \(500\)/);
459
+ });
460
+
461
+ test("patchNote sends content/metadata with force:true (the 428 guard)", async () => {
462
+ let body: Record<string, unknown> = {};
463
+ let method = "";
464
+ globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
465
+ method = init?.method ?? "GET";
466
+ body = JSON.parse(String(init?.body));
467
+ return new Response(null, { status: 200 });
468
+ }) as typeof fetch;
469
+ const client = new DefVaultClient(binding);
470
+ await client.patchNote("Agents/newbot", { content: "new", metadata: { wants: "vault:r:read" } });
471
+ expect(method).toBe("PATCH");
472
+ expect(body.content).toBe("new");
473
+ expect((body.metadata as Record<string, string>).wants).toBe("vault:r:read");
474
+ expect(body.force).toBe(true); // satisfies the vault's mutation precondition.
475
+ });
476
+
477
+ test("deleteNote DELETEs the note; a 404 is OK (gone is gone)", async () => {
478
+ let method = "";
479
+ globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
480
+ method = init?.method ?? "GET";
481
+ return new Response("no", { status: 404 });
482
+ }) as typeof fetch;
483
+ const client = new DefVaultClient(binding);
484
+ await client.deleteNote("Agents/gone"); // must NOT throw on 404.
485
+ expect(method).toBe("DELETE");
486
+ });
487
+
488
+ test("deleteNote throws on a non-404 error", async () => {
489
+ globalThis.fetch = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch;
490
+ const client = new DefVaultClient(binding);
491
+ await expect(client.deleteNote("n")).rejects.toThrow(/delete def n failed \(500\)/);
492
+ });
493
+ });
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // AgentDefRegistry — reactive lifecycle (instantiate / reload / deregister)
497
+ // ---------------------------------------------------------------------------
498
+
499
+ /** A recorder for the injected instantiate side-effects. */
500
+ function recorderDeps() {
501
+ const calls = {
502
+ ensured: [] as string[],
503
+ registered: [] as AgentSpec[],
504
+ deregistered: [] as string[],
505
+ removed: [] as string[],
506
+ };
507
+ const deps: InstantiateDeps = {
508
+ ensureChannel: async (name) => {
509
+ calls.ensured.push(name);
510
+ },
511
+ setupAndRegister: async (spec) => {
512
+ calls.registered.push(spec);
513
+ },
514
+ deregister: async (name) => {
515
+ calls.deregistered.push(name);
516
+ return true;
517
+ },
518
+ removeChannel: async (name) => {
519
+ calls.removed.push(name);
520
+ return true;
521
+ },
522
+ };
523
+ return { deps, calls };
524
+ }
525
+
526
+ /** A fetch that serves a def list + records PATCHes, keyed by query. */
527
+ function vaultFetch(opts: {
528
+ defs?: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>;
529
+ byId?: Record<string, { id: string; content?: string; metadata?: Record<string, unknown> } | null>;
530
+ patches?: Array<{ id: string; status?: string; pending?: string }>;
531
+ }): typeof fetch {
532
+ return (async (url: string | URL | Request, init?: RequestInit) => {
533
+ const u = String(url);
534
+ const method = init?.method ?? "GET";
535
+ if (method === "PATCH") {
536
+ const id = decodeURIComponent(u.split("/api/notes/")[1]!);
537
+ const meta = JSON.parse(String(init?.body)).metadata as Record<string, string>;
538
+ opts.patches?.push({ id, status: meta.status, pending: meta.pending });
539
+ return new Response(null, { status: 200 });
540
+ }
541
+ if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
542
+ return new Response(JSON.stringify(opts.defs ?? []), {
543
+ status: 200,
544
+ headers: { "content-type": "application/json" },
545
+ });
546
+ }
547
+ // GET one note by id (reload path).
548
+ const m = u.match(/\/api\/notes\/([^?]+)/);
549
+ if (m) {
550
+ const id = decodeURIComponent(m[1]!);
551
+ const note = opts.byId?.[id] ?? null;
552
+ if (!note) return new Response("no", { status: 404 });
553
+ return new Response(JSON.stringify(note), { status: 200 });
554
+ }
555
+ return new Response("[]", { status: 200 });
556
+ }) as typeof fetch;
557
+ }
558
+
559
+ describe("AgentDefRegistry — lifecycle", () => {
560
+ test("loadAll instantiates each def: ensureChannel + setupAndRegister + status stamp", async () => {
561
+ const { deps, calls } = recorderDeps();
562
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
563
+ const fetchFn = vaultFetch({
564
+ defs: [
565
+ { id: "Agents/uni-dev", content: "role A", metadata: { name: "uni-dev" } },
566
+ { id: "Agents/researcher", content: "role B", metadata: { name: "researcher", uses: "github" } },
567
+ ],
568
+ patches,
569
+ });
570
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
571
+ const n = await reg.loadAll();
572
+ expect(n).toBe(2);
573
+ expect(calls.ensured).toEqual(["uni-dev", "researcher"]);
574
+ expect(calls.registered.map((s) => s.name)).toEqual(["uni-dev", "researcher"]);
575
+ // Both agents bind their own vault, write-scoped (own-vault, 4a).
576
+ expect(calls.registered[0]!.vault).toEqual({ name: "default", access: "write" });
577
+ expect(calls.registered[0]!.backend).toBe("programmatic");
578
+ // Status stamped: uni-dev enabled (no connections), researcher pending (declares github).
579
+ const uni = patches.find((p) => p.id === "Agents/uni-dev")!;
580
+ const res = patches.find((p) => p.id === "Agents/researcher")!;
581
+ expect(uni.status).toBe("enabled");
582
+ expect(uni.pending).toBe("");
583
+ expect(res.status).toBe("pending");
584
+ expect(res.pending).toBe("github");
585
+ // The live set reflects both.
586
+ expect(reg.list().map((d) => d.name).sort()).toEqual(["researcher", "uni-dev"]);
587
+ });
588
+
589
+ test("a malformed def is skipped (status error) and does NOT abort the others", async () => {
590
+ const { deps, calls } = recorderDeps();
591
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
592
+ const fetchFn = vaultFetch({
593
+ defs: [
594
+ { id: "bad", content: "x", metadata: {} }, // no name → parse error
595
+ { id: "Agents/ok", content: "role", metadata: { name: "ok" } },
596
+ ],
597
+ patches,
598
+ });
599
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
600
+ const n = await reg.loadAll();
601
+ expect(n).toBe(1); // only the good one
602
+ expect(calls.registered.map((s) => s.name)).toEqual(["ok"]);
603
+ expect(patches.find((p) => p.id === "bad")!.status).toBe("error");
604
+ });
605
+
606
+ test("an instantiate failure stamps error and does not record a live def", async () => {
607
+ const { deps } = recorderDeps();
608
+ // Make registration fail (e.g. missing Claude credential at setup).
609
+ deps.setupAndRegister = async () => {
610
+ throw new Error("CredentialNotConfigured: set the Claude credential");
611
+ };
612
+ const patches: Array<{ id: string; status?: string }> = [];
613
+ const fetchFn = vaultFetch({
614
+ defs: [{ id: "Agents/x", content: "role", metadata: { name: "x" } }],
615
+ patches,
616
+ });
617
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
618
+ const n = await reg.loadAll();
619
+ expect(n).toBe(0);
620
+ expect(reg.list()).toHaveLength(0);
621
+ expect(patches.find((p) => p.id === "Agents/x")!.status).toBe("error");
622
+ });
623
+
624
+ test("reload(updated) re-instantiates the changed def (idempotent replace)", async () => {
625
+ const { deps, calls } = recorderDeps();
626
+ const fetchFn = vaultFetch({
627
+ byId: { "Agents/uni-dev": { id: "Agents/uni-dev", content: "NEW role", metadata: { name: "uni-dev" } } },
628
+ });
629
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
630
+ const result = await reg.reload("default", "Agents/uni-dev", "updated");
631
+ expect(result).toBe("instantiated");
632
+ expect(calls.registered).toHaveLength(1);
633
+ expect(calls.registered[0]!.systemPrompt).toBe("NEW role");
634
+ expect(reg.list().map((d) => d.name)).toEqual(["uni-dev"]);
635
+ });
636
+
637
+ test("reload(deleted) deregisters + removes the channel without a fetch", async () => {
638
+ const { deps, calls } = recorderDeps();
639
+ // Seed a live def first via loadAll, then delete it.
640
+ const fetchFn = vaultFetch({
641
+ defs: [{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }],
642
+ });
643
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
644
+ await reg.loadAll();
645
+ expect(reg.list()).toHaveLength(1);
646
+
647
+ const result = await reg.reload("default", "Agents/uni-dev", "deleted");
648
+ expect(result).toBe("deregistered");
649
+ expect(calls.deregistered).toEqual(["uni-dev"]);
650
+ expect(calls.removed).toEqual(["uni-dev"]);
651
+ expect(reg.list()).toHaveLength(0);
652
+ });
653
+
654
+ test("reload of a note that re-reads as gone (no event) deregisters", async () => {
655
+ const { deps, calls } = recorderDeps();
656
+ const fetchFn = vaultFetch({
657
+ defs: [{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }],
658
+ byId: { "Agents/uni-dev": null }, // a later GET says it's gone
659
+ });
660
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
661
+ await reg.loadAll();
662
+ const result = await reg.reload("default", "Agents/uni-dev"); // no event → fetch → 404
663
+ expect(result).toBe("deregistered");
664
+ expect(calls.deregistered).toEqual(["uni-dev"]);
665
+ });
666
+
667
+ test("loadAll TEARS DOWN a removed def — deregister + removeChannel (the no-delete-trigger path)", async () => {
668
+ // There is no vault `deleted` trigger (the hub maps only created/updated), so a def
669
+ // deleted out-of-band never fires the reactive teardown — the poll is the ONLY
670
+ // convergence path and must deregister, not just prune grants. Regression for the
671
+ // orphan-agent bug (a deleted agent kept answering until the daemon restarted).
672
+ const { deps, calls } = recorderDeps();
673
+ const present: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [
674
+ { id: "Agents/uni", content: "role", metadata: { name: "uni" } },
675
+ { id: "Agents/researcher", content: "role", metadata: { name: "researcher" } },
676
+ ];
677
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
678
+ const u = String(url);
679
+ const method = init?.method ?? "GET";
680
+ if (method === "PATCH") return new Response(null, { status: 200 });
681
+ if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
682
+ return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
683
+ }
684
+ return new Response("[]", { status: 200 });
685
+ }) as typeof fetch;
686
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
687
+ await reg.loadAll(); // both live
688
+ expect(calls.deregistered).toEqual([]); // nothing torn down on a clean load
689
+ present.splice(1, 1); // delete researcher out-of-band (no delete trigger fires)
690
+ await reg.loadAll(); // confident read now sees only uni → researcher is a confirmed removal
691
+ expect(calls.deregistered).toEqual(["researcher"]);
692
+ expect(calls.removed).toEqual(["researcher"]);
693
+ expect(reg.list().map((d) => d.name)).toEqual(["uni"]); // gone from the live set
694
+ });
695
+
696
+ test("loadAll SKIPS removed-def teardown on a truncated (page-cap) read — no spurious deregister", async () => {
697
+ // A list at the page cap may be partial. Since the removed-def diff now does a
698
+ // DESTRUCTIVE teardown, a truncated read that omits the tail must NOT be mistaken for
699
+ // deletions — the guard defers the diff rather than tearing down live agents.
700
+ const { deps, calls } = recorderDeps();
701
+ const initial: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [
702
+ { id: "Agents/uni", content: "role", metadata: { name: "uni" } },
703
+ { id: "Agents/researcher", content: "role", metadata: { name: "researcher" } },
704
+ ];
705
+ // A full page (>= the 500 cap) that omits both originals — a truncated page, NOT a
706
+ // signal that both were deleted.
707
+ const truncated = Array.from({ length: 500 }, (_, i) => ({
708
+ id: `Agents/filler-${i}`,
709
+ content: "role",
710
+ metadata: { name: `filler-${i}` },
711
+ }));
712
+ let current = initial;
713
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
714
+ const u = String(url);
715
+ const method = init?.method ?? "GET";
716
+ if (method === "PATCH") return new Response(null, { status: 200 });
717
+ if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
718
+ return new Response(JSON.stringify(current), { status: 200, headers: { "content-type": "application/json" } });
719
+ }
720
+ return new Response("[]", { status: 200 });
721
+ }) as typeof fetch;
722
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
723
+ await reg.loadAll(); // confident: uni + researcher live
724
+ expect(calls.deregistered).toEqual([]);
725
+ current = truncated; // the next poll returns a truncated page
726
+ await reg.loadAll();
727
+ // Guard tripped → NO teardown despite the originals being absent from the page.
728
+ expect(calls.deregistered).toEqual([]);
729
+ expect(calls.removed).toEqual([]);
730
+
731
+ // The guard DEFERS the decision, it doesn't LOSE it: the truncated pass left the
732
+ // seen-set intact (it skipped rebuildSeenDefs), so a later CONFIDENT pass that
733
+ // genuinely drops researcher still catches it as a removal and tears it down.
734
+ current = [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }];
735
+ calls.deregistered.length = 0;
736
+ calls.removed.length = 0;
737
+ await reg.loadAll();
738
+ expect(calls.deregistered).toContain("researcher");
739
+ expect(calls.removed).toContain("researcher");
740
+ });
741
+
742
+ test("reload for an unknown def-vault is a safe skip", async () => {
743
+ const { deps } = recorderDeps();
744
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn: vaultFetch({}) });
745
+ expect(await reg.reload("ghost-vault", "n")).toBe("skipped");
746
+ });
747
+
748
+ test("a def-vault list failure does not sink the others (best-effort per vault)", async () => {
749
+ const { deps, calls } = recorderDeps();
750
+ const b2: DefVaultBinding = { vault: "research", vaultUrl: "http://127.0.0.1:1940", token: "t2" };
751
+ // vault `default` 500s its list; `research` serves one def.
752
+ const fetchFn = (async (url: string | URL | Request) => {
753
+ const u = String(url);
754
+ if (u.includes("/vault/default/")) return new Response("boom", { status: 500 });
755
+ if (u.includes("/vault/research/") && u.includes("tag=%23agent%2Fdefinition")) {
756
+ return new Response(JSON.stringify([{ id: "r1", content: "role", metadata: { name: "r" } }]), {
757
+ status: 200,
758
+ });
759
+ }
760
+ return new Response(null, { status: 200 }); // PATCHes etc.
761
+ }) as typeof fetch;
762
+ const reg = new AgentDefRegistry(deps, { bindings: [binding, b2], fetchFn });
763
+ const n = await reg.loadAll();
764
+ expect(n).toBe(1);
765
+ expect(calls.registered.map((s) => s.name)).toEqual(["r"]);
766
+ });
767
+
768
+ test("findLiveByNote returns the single match (vault + detail)", async () => {
769
+ const { deps } = recorderDeps();
770
+ const fetchFn = vaultFetch({
771
+ defs: [{ id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }],
772
+ });
773
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
774
+ await reg.loadAll();
775
+ const found = reg.findLiveByNote("Agents/uni-dev");
776
+ expect(found).not.toBeNull();
777
+ expect(found!.vault).toBe("default");
778
+ expect(found!.detail.name).toBe("uni-dev");
779
+ expect(reg.findLiveByNote("Agents/ghost")).toBeNull();
780
+ });
781
+
782
+ test("findLiveByNote throws 409 when the SAME noteId is live in two def-vaults (#106 ambiguity)", async () => {
783
+ const { deps } = recorderDeps();
784
+ const b2: DefVaultBinding = { vault: "research", vaultUrl: "http://127.0.0.1:1940", token: "t2" };
785
+ // The vaultFetch helper serves the same def list for ANY vault → both `default` and
786
+ // `research` vend a def at the SAME note path, so two live entries share the noteId.
787
+ const fetchFn = vaultFetch({
788
+ defs: [{ id: "Agents/shared", content: "role", metadata: { name: "shared" } }],
789
+ });
790
+ const reg = new AgentDefRegistry(deps, { bindings: [binding, b2], fetchFn });
791
+ await reg.loadAll();
792
+ // The note id is live in BOTH vaults — picking one is non-deterministic, so it throws
793
+ // a 409-class AgentDefWriteError rather than silently mutating an arbitrary one.
794
+ let caught: unknown;
795
+ try {
796
+ reg.findLiveByNote("Agents/shared");
797
+ } catch (err) {
798
+ caught = err;
799
+ }
800
+ expect(caught).toBeInstanceOf(AgentDefWriteError);
801
+ expect((caught as AgentDefWriteError).status).toBe(409);
802
+ expect((caught as AgentDefWriteError).message).toContain("ambiguous");
803
+ // The PATCH/DELETE write paths surface the same 409 (they resolve via findLiveByNote).
804
+ await expect(reg.editDef("Agents/shared", { systemPrompt: "x" })).rejects.toMatchObject({ status: 409 });
805
+ await expect(reg.deleteDef("Agents/shared")).rejects.toMatchObject({ status: 409 });
806
+ });
807
+
808
+ test("listDetailed carries the def mode (default single-threaded; multi-threaded when declared)", async () => {
809
+ const { deps } = recorderDeps();
810
+ const fetchFn = vaultFetch({
811
+ defs: [
812
+ { id: "Agents/uni-dev", content: "role", metadata: { name: "uni-dev" } }, // no mode → default
813
+ { id: "Agents/digest", content: "role", metadata: { name: "digest", mode: "multi-threaded" } },
814
+ ],
815
+ });
816
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
817
+ await reg.loadAll();
818
+ const byName = new Map(reg.listDetailed().map((d) => [d.name, d]));
819
+ expect(byName.get("uni-dev")!.mode).toBe("single-threaded");
820
+ expect(byName.get("digest")!.mode).toBe("multi-threaded");
821
+ });
822
+
823
+ test("getFullDef returns the FULL system prompt + mode/backend/wants (not the preview)", async () => {
824
+ const { deps } = recorderDeps();
825
+ const longPrompt = "P".repeat(500); // longer than the 200-char preview cap.
826
+ const fetchFn = vaultFetch({
827
+ defs: [
828
+ {
829
+ id: "Agents/uni-dev",
830
+ content: longPrompt,
831
+ metadata: { name: "uni-dev", backend: "attached", mode: "multi-threaded", wants: "vault:research:read" },
832
+ },
833
+ ],
834
+ byId: {
835
+ "Agents/uni-dev": {
836
+ id: "Agents/uni-dev",
837
+ content: longPrompt,
838
+ metadata: { name: "uni-dev", backend: "attached", mode: "multi-threaded", wants: "vault:research:read" },
839
+ },
840
+ },
841
+ });
842
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
843
+ await reg.loadAll();
844
+ const full = await reg.getFullDef("Agents/uni-dev");
845
+ expect(full).not.toBeNull();
846
+ expect(full!.noteId).toBe("Agents/uni-dev");
847
+ expect(full!.name).toBe("uni-dev");
848
+ expect(full!.backend).toBe("attached");
849
+ expect(full!.mode).toBe("multi-threaded");
850
+ expect(full!.vault).toBe("default");
851
+ expect(full!.wants).toEqual(["vault:research:read"]);
852
+ // The FULL body, NOT the truncated preview.
853
+ expect(full!.systemPrompt).toBe(longPrompt);
854
+ expect(full!.systemPrompt.length).toBe(500);
855
+ });
856
+
857
+ test("getFullDef returns null for a note that isn't a live def", async () => {
858
+ const { deps } = recorderDeps();
859
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn: vaultFetch({}) });
860
+ expect(await reg.getFullDef("Agents/ghost")).toBeNull();
861
+ });
862
+
863
+ test("listDetailed/getFullDef surface per-connection {key,status,grantId} from the hub grants", async () => {
864
+ const { deps } = recorderDeps();
865
+ // The hub grants client's fetch: PUT /admin/grants echoes a grant record with the
866
+ // hub-assigned id + current status (the id we surface — never derived client-side);
867
+ // POST .../reconcile is the grant-GC (no-op here). One mcp connection, one vault.
868
+ const grantFetch = (async (url: string | URL | Request, init?: RequestInit) => {
869
+ const u = String(url);
870
+ const method = init?.method ?? "GET";
871
+ if (method === "PUT" && u.endsWith("/admin/grants")) {
872
+ const { connection } = JSON.parse(String(init?.body)) as { connection: ConnectionSpec };
873
+ const key = connectionKey(connection);
874
+ // The mcp connection is awaiting consent; the vault one is approved.
875
+ const status = connection.kind === "mcp" ? "needs_consent" : "approved";
876
+ return new Response(
877
+ JSON.stringify({ id: `grant_${key}`, agent: "uni-dev", connection, status }),
878
+ { status: 200, headers: { "content-type": "application/json" } },
879
+ );
880
+ }
881
+ if (method === "POST" && u.endsWith("/admin/grants/reconcile")) {
882
+ return new Response(JSON.stringify({ pruned: 0 }), { status: 200 });
883
+ }
884
+ return new Response("{}", { status: 200 });
885
+ }) as typeof fetch;
886
+ const grants = new GrantsClient({
887
+ hubOrigin: "http://127.0.0.1:1939",
888
+ managerBearer: "host-admin",
889
+ fetchFn: grantFetch,
890
+ });
891
+ const note = {
892
+ id: "Agents/uni-dev",
893
+ content: "role",
894
+ metadata: { name: "uni-dev", wants: "mcp:https://remote/mcp, vault:research:read" },
895
+ };
896
+ const fetchFn = vaultFetch({ defs: [note], byId: { "Agents/uni-dev": note } });
897
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
898
+ await reg.loadAll();
899
+
900
+ const detail = reg.listDetailed().find((d) => d.name === "uni-dev")!;
901
+ const byKey = new Map(detail.connections.map((c) => [c.key, c]));
902
+ const mcp = byKey.get("mcp:https://remote/mcp")!;
903
+ expect(mcp).toMatchObject({
904
+ kind: "mcp",
905
+ target: "https://remote/mcp",
906
+ status: "needs_consent",
907
+ grantId: "grant_mcp:https://remote/mcp",
908
+ });
909
+ const vault = byKey.get("vault:research:read")!;
910
+ expect(vault).toMatchObject({ kind: "vault", status: "approved", grantId: "grant_vault:research:read" });
911
+ // The FULL def carries the same connections (so the edit view needs no second fetch).
912
+ const full = await reg.getFullDef("Agents/uni-dev");
913
+ expect(full!.connections.map((c) => c.key).sort()).toEqual(
914
+ ["mcp:https://remote/mcp", "vault:research:read"],
915
+ );
916
+ });
917
+
918
+ test("without a grants client, connections are surfaced display-only (status pending, no grant id)", async () => {
919
+ const { deps } = recorderDeps();
920
+ const note = {
921
+ id: "Agents/uni-dev",
922
+ content: "role",
923
+ metadata: { name: "uni-dev", wants: "mcp:https://remote/mcp" },
924
+ };
925
+ const fetchFn = vaultFetch({ defs: [note], byId: { "Agents/uni-dev": note } });
926
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn }); // no grants
927
+ await reg.loadAll();
928
+ const detail = reg.listDetailed().find((d) => d.name === "uni-dev")!;
929
+ expect(detail.connections).toEqual([
930
+ { key: "mcp:https://remote/mcp", kind: "mcp", target: "https://remote/mcp", status: "pending" },
931
+ ]);
932
+ // No grant id → the panel can't offer Connect (shows the degraded hint instead).
933
+ expect(detail.connections[0]!.grantId).toBeUndefined();
934
+ });
935
+
936
+ test("soleVaultName resolves the single binding (the reload-webhook default)", () => {
937
+ const { deps } = recorderDeps();
938
+ const reg = new AgentDefRegistry(deps, { bindings: [binding] });
939
+ expect(reg.soleVaultName()).toBe("default");
940
+ expect(reg.vaultCount).toBe(1);
941
+ reg.addVault({ vault: "research", token: "t" });
942
+ expect(reg.soleVaultName()).toBeUndefined();
943
+ expect(reg.vaultCount).toBe(2);
944
+ });
945
+ });
946
+
947
+ // ---------------------------------------------------------------------------
948
+ // AgentDefRegistry — 4b grant registration + status (design 2026-06-17-agent-connectors-4b)
949
+ // ---------------------------------------------------------------------------
950
+
951
+ /** A fake GrantsClient that records PUTs + reconcile POSTs and returns a configurable
952
+ * per-connection status. The hub isn't deployed in the test env — this mocks its
953
+ * grants API (register PUT + reconcile POST, #96 grant-GC). */
954
+ function fakeGrantsClient(opts: {
955
+ /** connectionKey → the status the hub returns on register. Default "pending". */
956
+ statusByKey?: Record<string, string>;
957
+ /** Record each registered (agent, connection). */
958
+ registered?: Array<{ agent: string; connection: ConnectionSpec }>;
959
+ /** Record each reconcile (agent, liveConnections) — the #96 grant-GC call. */
960
+ reconciled?: Array<{ agent: string; liveConnections: ConnectionSpec[] }>;
961
+ /** How many grants the hub reports pruned per reconcile (default 0). */
962
+ prunedPerReconcile?: number;
963
+ /** Make reconcile POSTs 500 (to assert the failure is swallowed). */
964
+ reconcileFails?: boolean;
965
+ }): GrantsClient {
966
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
967
+ const u = String(url);
968
+ if (u.endsWith("/admin/grants/reconcile") && (init?.method ?? "GET") === "POST") {
969
+ const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
970
+ opts.reconciled?.push({ agent: body.agent, liveConnections: body.liveConnections });
971
+ if (opts.reconcileFails) return new Response("boom", { status: 500 });
972
+ return new Response(JSON.stringify({ pruned: opts.prunedPerReconcile ?? 0, prunedIds: [] }), {
973
+ status: 200,
974
+ headers: { "content-type": "application/json" },
975
+ });
976
+ }
977
+ if (u.endsWith("/admin/grants") && (init?.method ?? "GET") === "PUT") {
978
+ const body = JSON.parse(String(init?.body)) as { agent: string; connection: ConnectionSpec };
979
+ opts.registered?.push({ agent: body.agent, connection: body.connection });
980
+ const key = connectionKey(body.connection);
981
+ const status = opts.statusByKey?.[key] ?? "pending";
982
+ return new Response(
983
+ JSON.stringify({ id: `g-${key}`, agent: body.agent, connection: body.connection, status }),
984
+ { status: 200, headers: { "content-type": "application/json" } },
985
+ );
986
+ }
987
+ return new Response("{}", { status: 200 });
988
+ }) as typeof fetch;
989
+ return new GrantsClient({ hubOrigin: "https://hub.example.com", managerBearer: "MGR", fetchFn });
990
+ }
991
+
992
+ describe("AgentDefRegistry — grant registration + status (4b)", () => {
993
+ test("registers each `wants:` connection as a pending grant on instantiate", async () => {
994
+ const { deps } = recorderDeps();
995
+ const registered: Array<{ agent: string; connection: ConnectionSpec }> = [];
996
+ const grants = fakeGrantsClient({ registered }); // all default "pending"
997
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
998
+ const fetchFn = vaultFetch({
999
+ defs: [
1000
+ {
1001
+ id: "Agents/researcher",
1002
+ content: "role",
1003
+ metadata: { name: "researcher", wants: "vault:research:read, env:github" },
1004
+ },
1005
+ ],
1006
+ patches,
1007
+ });
1008
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1009
+ await reg.loadAll();
1010
+
1011
+ // Both connections were registered for the agent.
1012
+ expect(registered.map((r) => r.connection.target).sort()).toEqual(["github", "research"]);
1013
+ expect(registered.every((r) => r.agent === "researcher")).toBe(true);
1014
+ // None approved → status pending listing the connection keys.
1015
+ const p = patches.find((x) => x.id === "Agents/researcher")!;
1016
+ expect(p.status).toBe("pending");
1017
+ expect(p.pending).toContain("vault:research:read");
1018
+ expect(p.pending).toContain("env:github");
1019
+ });
1020
+
1021
+ test("status = enabled only once EVERY connection is approved", async () => {
1022
+ const { deps } = recorderDeps();
1023
+ const vaultConn: ConnectionSpec = { kind: "vault", target: "research", access: "read" };
1024
+ const svcConn: ConnectionSpec = { kind: "service", target: "github", inject: ["env"] };
1025
+ const grants = fakeGrantsClient({
1026
+ statusByKey: {
1027
+ [connectionKey(vaultConn)]: "approved",
1028
+ [connectionKey(svcConn)]: "approved",
1029
+ },
1030
+ });
1031
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
1032
+ const fetchFn = vaultFetch({
1033
+ defs: [
1034
+ { id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read, env:github" } },
1035
+ ],
1036
+ patches,
1037
+ });
1038
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1039
+ await reg.loadAll();
1040
+ const p = patches.find((x) => x.id === "Agents/r")!;
1041
+ expect(p.status).toBe("enabled");
1042
+ expect(p.pending).toBe("");
1043
+ expect(reg.list().find((d) => d.name === "r")!.status).toBe("enabled");
1044
+ });
1045
+
1046
+ test("partial approval → pending listing only the UNAPPROVED connection keys", async () => {
1047
+ const { deps } = recorderDeps();
1048
+ const vaultConn: ConnectionSpec = { kind: "vault", target: "research", access: "read" };
1049
+ const grants = fakeGrantsClient({
1050
+ statusByKey: { [connectionKey(vaultConn)]: "approved" }, // github stays pending
1051
+ });
1052
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
1053
+ const fetchFn = vaultFetch({
1054
+ defs: [
1055
+ { id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read, env:github" } },
1056
+ ],
1057
+ patches,
1058
+ });
1059
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1060
+ await reg.loadAll();
1061
+ const p = patches.find((x) => x.id === "Agents/r")!;
1062
+ expect(p.status).toBe("pending");
1063
+ expect(p.pending).toBe("env:github"); // only the unapproved one
1064
+ // The agent STILL instantiated (own-vault runs regardless of grant approval).
1065
+ expect(reg.list().find((d) => d.name === "r")).toBeDefined();
1066
+ });
1067
+
1068
+ test("an mcp-kind want registers + stays pending (parsed, not granted in 4b-1)", async () => {
1069
+ const { deps } = recorderDeps();
1070
+ const registered: Array<{ agent: string; connection: ConnectionSpec }> = [];
1071
+ const grants = fakeGrantsClient({ registered }); // mcp stays "pending"
1072
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
1073
+ const fetchFn = vaultFetch({
1074
+ defs: [
1075
+ { id: "Agents/r", content: "role", metadata: { name: "r", wants: "mcp:https://remote/mcp" } },
1076
+ ],
1077
+ patches,
1078
+ });
1079
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1080
+ await reg.loadAll();
1081
+ expect(registered).toHaveLength(1);
1082
+ expect(registered[0]!.connection).toEqual({ kind: "mcp", target: "https://remote/mcp" });
1083
+ const p = patches.find((x) => x.id === "Agents/r")!;
1084
+ expect(p.status).toBe("pending");
1085
+ expect(p.pending).toBe("mcp:https://remote/mcp");
1086
+ });
1087
+
1088
+ test("a malformed `wants:` stamps status error (does not register or instantiate)", async () => {
1089
+ const { deps, calls } = recorderDeps();
1090
+ const registered: Array<{ agent: string; connection: ConnectionSpec }> = [];
1091
+ const grants = fakeGrantsClient({ registered });
1092
+ const patches: Array<{ id: string; status?: string }> = [];
1093
+ const fetchFn = vaultFetch({
1094
+ defs: [{ id: "Agents/bad", content: "role", metadata: { name: "bad", wants: "vault:research" } }],
1095
+ patches,
1096
+ });
1097
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1098
+ const n = await reg.loadAll();
1099
+ expect(n).toBe(0);
1100
+ expect(calls.registered).toHaveLength(0); // never instantiated
1101
+ expect(registered).toHaveLength(0); // never registered a grant
1102
+ expect(patches.find((p) => p.id === "Agents/bad")!.status).toBe("error");
1103
+ });
1104
+
1105
+ test("a grant-registration FAILURE is non-fatal → connection counts as pending", async () => {
1106
+ const { deps, calls } = recorderDeps();
1107
+ // A grants client whose PUT 500s.
1108
+ const fetchFn500 = (async (url: string | URL | Request, init?: RequestInit) => {
1109
+ if (String(url).endsWith("/admin/grants") && init?.method === "PUT") {
1110
+ return new Response("boom", { status: 500 });
1111
+ }
1112
+ return new Response("{}", { status: 200 });
1113
+ }) as typeof fetch;
1114
+ const grants = new GrantsClient({ hubOrigin: "https://hub.example.com", managerBearer: "MGR", fetchFn: fetchFn500 });
1115
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
1116
+ const fetchFn = vaultFetch({
1117
+ defs: [{ id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read" } }],
1118
+ patches,
1119
+ });
1120
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1121
+ const count = await reg.loadAll();
1122
+ // The agent STILL instantiated (own-vault) — a hub blip never blocks it.
1123
+ expect(count).toBe(1);
1124
+ expect(calls.registered.map((s) => s.name)).toEqual(["r"]);
1125
+ const p = patches.find((x) => x.id === "Agents/r")!;
1126
+ expect(p.status).toBe("pending");
1127
+ expect(p.pending).toBe("vault:research:read");
1128
+ });
1129
+
1130
+ test("setGrantsClient(null) → falls back to the pure status (no registration)", async () => {
1131
+ const { deps } = recorderDeps();
1132
+ const patches: Array<{ id: string; status?: string; pending?: string }> = [];
1133
+ const fetchFn = vaultFetch({
1134
+ defs: [{ id: "Agents/r", content: "role", metadata: { name: "r", wants: "vault:research:read" } }],
1135
+ patches,
1136
+ });
1137
+ // No grants client at all → resolveDefStatus fallback (pending listing conn keys).
1138
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
1139
+ reg.setGrantsClient(null);
1140
+ await reg.loadAll();
1141
+ const p = patches.find((x) => x.id === "Agents/r")!;
1142
+ expect(p.status).toBe("pending");
1143
+ expect(p.pending).toBe("vault:research:read");
1144
+ });
1145
+ });
1146
+
1147
+ // ---------------------------------------------------------------------------
1148
+ // AgentDefRegistry — grant garbage-collection / reconcile (#96)
1149
+ // ---------------------------------------------------------------------------
1150
+
1151
+ describe("AgentDefRegistry — grant-GC reconcile (#96)", () => {
1152
+ test("a successful load reconciles with the def's CURRENT live connection specs", async () => {
1153
+ const { deps } = recorderDeps();
1154
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1155
+ const grants = fakeGrantsClient({ reconciled });
1156
+ const fetchFn = vaultFetch({
1157
+ defs: [
1158
+ {
1159
+ id: "Agents/researcher",
1160
+ content: "role",
1161
+ metadata: { name: "researcher", wants: "vault:research:read, env:github, mcp:github" },
1162
+ },
1163
+ ],
1164
+ });
1165
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1166
+ await reg.loadAll();
1167
+
1168
+ expect(reconciled).toHaveLength(1);
1169
+ expect(reconciled[0]!.agent).toBe("researcher");
1170
+ // The SPECS sent MUST equal the parsed wants (env:github + mcp:github MERGE to one
1171
+ // service connection with inject ["env","mcp"]). The hub re-derives the keys.
1172
+ const wants: ConnectionSpec[] = [
1173
+ { kind: "vault", target: "research", access: "read" },
1174
+ { kind: "service", target: "github", inject: ["env", "mcp"] },
1175
+ ];
1176
+ expect(reconciled[0]!.liveConnections).toEqual(wants);
1177
+ });
1178
+
1179
+ test("a def with NO wants still reconciles with empty liveConnections (prunes any leftover)", async () => {
1180
+ const { deps } = recorderDeps();
1181
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1182
+ const grants = fakeGrantsClient({ reconciled });
1183
+ const fetchFn = vaultFetch({
1184
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1185
+ });
1186
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1187
+ await reg.loadAll();
1188
+ expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
1189
+ });
1190
+
1191
+ test("a REMOVED def (present in a prior load, gone now) → reconcile(agent, []) + teardown", async () => {
1192
+ const { deps, calls } = recorderDeps();
1193
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1194
+ const grants = fakeGrantsClient({ reconciled });
1195
+ // Two notes present at first; the second load drops "researcher".
1196
+ const present: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [
1197
+ { id: "Agents/uni", content: "role", metadata: { name: "uni" } },
1198
+ { id: "Agents/researcher", content: "role", metadata: { name: "researcher", wants: "vault:research:read" } },
1199
+ ];
1200
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
1201
+ const u = String(url);
1202
+ const method = init?.method ?? "GET";
1203
+ if (method === "PATCH") return new Response(null, { status: 200 });
1204
+ if (u.endsWith("/admin/grants/reconcile") || u.endsWith("/admin/grants")) {
1205
+ // delegate to the fake grants client's fetch by re-issuing through it isn't
1206
+ // possible here; instead record reconcile directly.
1207
+ if (u.endsWith("/admin/grants/reconcile") && method === "POST") {
1208
+ const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
1209
+ reconciled.push({ agent: body.agent, liveConnections: body.liveConnections });
1210
+ return new Response(JSON.stringify({ pruned: 0, prunedIds: [] }), { status: 200 });
1211
+ }
1212
+ if (method === "PUT") {
1213
+ const body = JSON.parse(String(init?.body)) as { agent: string; connection: ConnectionSpec };
1214
+ return new Response(
1215
+ JSON.stringify({ id: "g", agent: body.agent, connection: body.connection, status: "pending" }),
1216
+ { status: 200 },
1217
+ );
1218
+ }
1219
+ }
1220
+ if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
1221
+ return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
1222
+ }
1223
+ return new Response("[]", { status: 200 });
1224
+ }) as typeof fetch;
1225
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1226
+ await reg.loadAll(); // first confident read — both present
1227
+ // Drop researcher; second confident read sees only uni.
1228
+ present.splice(1, 1);
1229
+ reconciled.length = 0; // ignore the first-load reconciles; focus on the removal
1230
+ await reg.loadAll();
1231
+
1232
+ // The removed agent gets a prune-ALL reconcile.
1233
+ const removal = reconciled.find((r) => r.agent === "researcher");
1234
+ expect(removal).toEqual({ agent: "researcher", liveConnections: [] });
1235
+ // uni (still present, no wants) reconciles with [] too — that's its clean-load prune,
1236
+ // NOT a removal; distinguished by the agent name.
1237
+ expect(reconciled.find((r) => r.agent === "uni")).toEqual({ agent: "uni", liveConnections: [] });
1238
+ // AND it's torn down (not just grant-pruned): the only auto path for a delete.
1239
+ expect(calls.deregistered).toEqual(["researcher"]);
1240
+ expect(calls.removed).toEqual(["researcher"]);
1241
+ });
1242
+
1243
+ test("a delete reload → reconcile(agent, []) (confirmed removal)", async () => {
1244
+ const { deps } = recorderDeps();
1245
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1246
+ const grants = fakeGrantsClient({ reconciled });
1247
+ const fetchFn = vaultFetch({
1248
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1249
+ });
1250
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1251
+ await reg.loadAll();
1252
+ reconciled.length = 0; // drop the clean-load reconcile; focus on the delete
1253
+ const result = await reg.reload("default", "Agents/uni", "deleted");
1254
+ expect(result).toBe("deregistered");
1255
+ expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
1256
+ });
1257
+
1258
+ test("a reload that re-reads as GONE (404) → reconcile(agent, []) (confirmed removal)", async () => {
1259
+ const { deps } = recorderDeps();
1260
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1261
+ const grants = fakeGrantsClient({ reconciled });
1262
+ const fetchFn = vaultFetch({
1263
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1264
+ byId: { "Agents/uni": null }, // a later GET says it's gone
1265
+ });
1266
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1267
+ await reg.loadAll();
1268
+ reconciled.length = 0;
1269
+ const result = await reg.reload("default", "Agents/uni"); // no event → GET → 404
1270
+ expect(result).toBe("deregistered");
1271
+ expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
1272
+ });
1273
+
1274
+ test("SAFETY: a PARSE-FAILING def NEVER reconciles (no prune from an error)", async () => {
1275
+ const { deps } = recorderDeps();
1276
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1277
+ const grants = fakeGrantsClient({ reconciled });
1278
+ const fetchFn = vaultFetch({
1279
+ defs: [
1280
+ { id: "Agents/bad", content: "role", metadata: { name: "bad", wants: "vault:research" } }, // malformed wants
1281
+ { id: "Agents/noname", content: "role", metadata: {} }, // no name → parse error
1282
+ ],
1283
+ });
1284
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1285
+ const n = await reg.loadAll();
1286
+ expect(n).toBe(0); // nothing instantiated
1287
+ // NEITHER parse-failing def reconciled — a transient parse error must not nuke grants.
1288
+ expect(reconciled).toEqual([]);
1289
+ });
1290
+
1291
+ test("SAFETY: a parse-failing def is NOT later flagged removed (its grants survive)", async () => {
1292
+ const { deps } = recorderDeps();
1293
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1294
+ const grants = fakeGrantsClient({ reconciled });
1295
+ // First load: a CLEAN def. Second load: the SAME note now parse-fails (a transient
1296
+ // bad edit). It must NOT be treated as a removal (it's still present in the vault).
1297
+ const note: { id: string; content?: string; metadata?: Record<string, unknown> } = {
1298
+ id: "Agents/uni",
1299
+ content: "role",
1300
+ metadata: { name: "uni" },
1301
+ };
1302
+ const present = [note];
1303
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
1304
+ const u = String(url);
1305
+ const method = init?.method ?? "GET";
1306
+ if (method === "PATCH") return new Response(null, { status: 200 });
1307
+ if (u.endsWith("/admin/grants/reconcile") && method === "POST") {
1308
+ const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
1309
+ reconciled.push({ agent: body.agent, liveConnections: body.liveConnections });
1310
+ return new Response(JSON.stringify({ pruned: 0 }), { status: 200 });
1311
+ }
1312
+ if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
1313
+ return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
1314
+ }
1315
+ return new Response("[]", { status: 200 });
1316
+ }) as typeof fetch;
1317
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1318
+ await reg.loadAll(); // clean → reconcile(uni, [])
1319
+ // Now make the same note malformed (remove its name) and reload all.
1320
+ note.metadata = { name: "uni", wants: "vault:research" }; // malformed wants → parse error
1321
+ reconciled.length = 0;
1322
+ await reg.loadAll();
1323
+ // The note is STILL present (just unparseable) → NOT a removal → no reconcile at all.
1324
+ expect(reconciled).toEqual([]);
1325
+ });
1326
+
1327
+ test("SAFETY: a vault LIST failure does NOT prune (no confident read)", async () => {
1328
+ const { deps } = recorderDeps();
1329
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1330
+ const grants = fakeGrantsClient({ reconciled });
1331
+ let listShouldFail = false;
1332
+ const present = [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }];
1333
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
1334
+ const u = String(url);
1335
+ const method = init?.method ?? "GET";
1336
+ if (method === "PATCH") return new Response(null, { status: 200 });
1337
+ if (u.endsWith("/admin/grants/reconcile") && method === "POST") {
1338
+ const body = JSON.parse(String(init?.body)) as { agent: string; liveConnections: ConnectionSpec[] };
1339
+ reconciled.push({ agent: body.agent, liveConnections: body.liveConnections });
1340
+ return new Response(JSON.stringify({ pruned: 0 }), { status: 200 });
1341
+ }
1342
+ if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
1343
+ if (listShouldFail) return new Response("boom", { status: 500 });
1344
+ return new Response(JSON.stringify(present), { status: 200, headers: { "content-type": "application/json" } });
1345
+ }
1346
+ return new Response("[]", { status: 200 });
1347
+ }) as typeof fetch;
1348
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1349
+ await reg.loadAll(); // confident → seen set = {uni}
1350
+ reconciled.length = 0;
1351
+ listShouldFail = true;
1352
+ await reg.loadAll(); // list 500s → NOT a confident read → no removal diff, no prune
1353
+ expect(reconciled).toEqual([]);
1354
+ });
1355
+
1356
+ test("a reconcile HTTP failure is swallowed — the load does NOT throw / still instantiates", async () => {
1357
+ const { deps, calls } = recorderDeps();
1358
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1359
+ const grants = fakeGrantsClient({ reconciled, reconcileFails: true }); // reconcile POST 500s
1360
+ const patches: Array<{ id: string; status?: string }> = [];
1361
+ const fetchFn = vaultFetch({
1362
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1363
+ patches,
1364
+ });
1365
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1366
+ // Must not throw out of loadAll despite the 500.
1367
+ const n = await reg.loadAll();
1368
+ expect(n).toBe(1);
1369
+ expect(calls.registered.map((s) => s.name)).toEqual(["uni"]); // still instantiated
1370
+ expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]); // it was attempted
1371
+ });
1372
+
1373
+ test("no grants client → reconcile is a no-op (the vault-native path still runs)", async () => {
1374
+ const { deps, calls } = recorderDeps();
1375
+ const fetchFn = vaultFetch({
1376
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni", wants: "vault:research:read" } }],
1377
+ });
1378
+ // No grants client → no reconcile attempted; the agent still instantiates own-vault.
1379
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
1380
+ const n = await reg.loadAll();
1381
+ expect(n).toBe(1);
1382
+ expect(calls.registered.map((s) => s.name)).toEqual(["uni"]);
1383
+ });
1384
+ });
1385
+
1386
+ // ---------------------------------------------------------------------------
1387
+ // FIX 4 (delete ordering: vault-delete first, then deregister) + FIX 5 (grant-GC
1388
+ // failure on delete is surfaced, not swallowed) — PR #3.
1389
+ // ---------------------------------------------------------------------------
1390
+
1391
+ /**
1392
+ * A fetch that serves the def list + by-id GET (so an agent instantiates) and routes
1393
+ * a DELETE to a configurable outcome (`deleteStatus`). Records DELETEs so a test can
1394
+ * assert the note-delete was attempted. Reconcile/PATCH succeed by default.
1395
+ */
1396
+ function vaultFetchWithDelete(opts: {
1397
+ defs: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>;
1398
+ deleteStatus?: number; // the status the DELETE returns (default 204 = success)
1399
+ deletes?: string[]; // record each DELETEd note id
1400
+ }): typeof fetch {
1401
+ return (async (url: string | URL | Request, init?: RequestInit) => {
1402
+ const u = String(url);
1403
+ const method = init?.method ?? "GET";
1404
+ if (method === "DELETE") {
1405
+ const id = decodeURIComponent(u.split("/api/notes/")[1]!);
1406
+ opts.deletes?.push(id);
1407
+ const status = opts.deleteStatus ?? 204;
1408
+ return new Response(status >= 400 ? "delete failed" : null, { status });
1409
+ }
1410
+ if (method === "PATCH") return new Response(null, { status: 200 });
1411
+ if (u.includes("/api/notes?") && u.includes("tag=%23agent%2Fdefinition")) {
1412
+ return new Response(JSON.stringify(opts.defs), {
1413
+ status: 200,
1414
+ headers: { "content-type": "application/json" },
1415
+ });
1416
+ }
1417
+ return new Response("[]", { status: 200 });
1418
+ }) as typeof fetch;
1419
+ }
1420
+
1421
+ describe("AgentDefRegistry — deleteDef ordering + grant-GC surfacing (FIX 4/5, PR #3)", () => {
1422
+ test("FIX 4: a vault-delete failure leaves the def REGISTERED (not orphaned)", async () => {
1423
+ const { deps, calls } = recorderDeps();
1424
+ const deletes: string[] = [];
1425
+ const fetchFn = vaultFetchWithDelete({
1426
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1427
+ deleteStatus: 502, // the vault note delete 502s
1428
+ deletes,
1429
+ });
1430
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
1431
+ await reg.loadAll();
1432
+ expect(reg.findLiveByNote("Agents/uni")).not.toBeNull(); // live before delete.
1433
+
1434
+ // The delete throws (the vault note delete failed) BEFORE any deregister.
1435
+ await expect(reg.deleteDef("Agents/uni")).rejects.toThrow(/delete def Agents\/uni failed \(502\)/);
1436
+
1437
+ // FIX 4 invariant: the agent is STILL registered (the in-memory def was NOT torn down
1438
+ // on a failed vault delete) — it re-converges on the next poll rather than orphaning.
1439
+ expect(reg.findLiveByNote("Agents/uni")).not.toBeNull();
1440
+ expect(calls.deregistered).toEqual([]); // nothing was deregistered.
1441
+ expect(deletes).toEqual(["Agents/uni"]); // the delete WAS attempted (and failed).
1442
+ });
1443
+
1444
+ test("FIX 4: a successful vault-delete deregisters cleanly", async () => {
1445
+ const { deps, calls } = recorderDeps();
1446
+ const deletes: string[] = [];
1447
+ const fetchFn = vaultFetchWithDelete({
1448
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1449
+ deleteStatus: 204,
1450
+ deletes,
1451
+ });
1452
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn });
1453
+ await reg.loadAll();
1454
+
1455
+ const removed = await reg.deleteDef("Agents/uni");
1456
+ expect(removed.name).toBe("uni");
1457
+ expect(removed.grantsReconciled).toBe(true); // no grants client → nothing to reconcile = ok.
1458
+ // Now deregistered + removed from the live set.
1459
+ expect(reg.findLiveByNote("Agents/uni")).toBeNull();
1460
+ expect(calls.deregistered).toEqual(["uni"]);
1461
+ expect(deletes).toEqual(["Agents/uni"]);
1462
+ });
1463
+
1464
+ test("FIX 5: a grant-reconcile failure on delete is SURFACED (grantsReconciled:false) — the note-delete still completes", async () => {
1465
+ const { deps, calls } = recorderDeps();
1466
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1467
+ const grants = fakeGrantsClient({ reconciled, reconcileFails: true }); // reconcile POST 500s
1468
+ const deletes: string[] = [];
1469
+ const fetchFn = vaultFetchWithDelete({
1470
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1471
+ deleteStatus: 204,
1472
+ deletes,
1473
+ });
1474
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1475
+ await reg.loadAll();
1476
+ reconciled.length = 0;
1477
+
1478
+ // The delete must NOT throw (grant GC is best-effort) but MUST report the partial
1479
+ // success so the caller doesn't claim a clean full success (orphaned grants).
1480
+ const removed = await reg.deleteDef("Agents/uni");
1481
+ expect(removed.name).toBe("uni");
1482
+ expect(removed.grantsReconciled).toBe(false); // FIX 5: the failure is surfaced, not swallowed.
1483
+ // The note-delete + deregister STILL completed (the def IS gone).
1484
+ expect(reg.findLiveByNote("Agents/uni")).toBeNull();
1485
+ expect(calls.deregistered).toEqual(["uni"]);
1486
+ expect(deletes).toEqual(["Agents/uni"]);
1487
+ // The reconcile WAS attempted (prune-all on the removed agent) — it just failed on the hub.
1488
+ expect(reconciled).toEqual([{ agent: "uni", liveConnections: [] }]);
1489
+ });
1490
+
1491
+ test("FIX 5: a SUCCESSFUL grant-reconcile on delete reports grantsReconciled:true", async () => {
1492
+ const { deps } = recorderDeps();
1493
+ const reconciled: Array<{ agent: string; liveConnections: ConnectionSpec[] }> = [];
1494
+ const grants = fakeGrantsClient({ reconciled }); // reconcile succeeds
1495
+ const fetchFn = vaultFetchWithDelete({
1496
+ defs: [{ id: "Agents/uni", content: "role", metadata: { name: "uni" } }],
1497
+ deleteStatus: 204,
1498
+ });
1499
+ const reg = new AgentDefRegistry(deps, { bindings: [binding], fetchFn, grants });
1500
+ await reg.loadAll();
1501
+ const removed = await reg.deleteDef("Agents/uni");
1502
+ expect(removed.grantsReconciled).toBe(true);
1503
+ });
1504
+ });