@openparachute/agent 0.1.2 → 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 (605) 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 -263
  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 -172
  318. package/scripts/init-first-agent.ts +0 -378
  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 -80
  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/api-translator.test.ts +0 -306
  328. package/src/channels/api-translator.ts +0 -214
  329. package/src/channels/ask-question.ts +0 -46
  330. package/src/channels/channel-registry.test.ts +0 -421
  331. package/src/channels/channel-registry.ts +0 -313
  332. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  333. package/src/channels/chat-sdk-bridge.ts +0 -652
  334. package/src/channels/cli.ts +0 -276
  335. package/src/channels/discord.ts +0 -90
  336. package/src/channels/index.ts +0 -17
  337. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  338. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  339. package/src/channels/telegram-pairing.test.ts +0 -254
  340. package/src/channels/telegram-pairing.ts +0 -339
  341. package/src/channels/telegram.ts +0 -279
  342. package/src/channels/trust-hint.test.ts +0 -48
  343. package/src/channels/trust-hint.ts +0 -75
  344. package/src/claude-md-compose.migrate.test.ts +0 -64
  345. package/src/claude-md-compose.ts +0 -205
  346. package/src/command-gate.ts +0 -63
  347. package/src/config.test.ts +0 -93
  348. package/src/config.ts +0 -128
  349. package/src/container-config.ts +0 -167
  350. package/src/container-runner.test.ts +0 -32
  351. package/src/container-runner.ts +0 -576
  352. package/src/container-runtime.test.ts +0 -269
  353. package/src/container-runtime.ts +0 -167
  354. package/src/db/_bun-sqlite-shim.ts +0 -88
  355. package/src/db/agent-activity.test.ts +0 -155
  356. package/src/db/agent-activity.ts +0 -121
  357. package/src/db/agent-groups.ts +0 -77
  358. package/src/db/connection.migrate.test.ts +0 -176
  359. package/src/db/connection.ts +0 -259
  360. package/src/db/db-v2.test.ts +0 -440
  361. package/src/db/dropped-messages.ts +0 -44
  362. package/src/db/index.ts +0 -40
  363. package/src/db/messaging-groups.ts +0 -252
  364. package/src/db/migrations/001-initial.ts +0 -112
  365. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  366. package/src/db/migrations/008-dropped-messages.ts +0 -27
  367. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  368. package/src/db/migrations/010-engage-modes.ts +0 -103
  369. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  370. package/src/db/migrations/012-channel-registration.ts +0 -48
  371. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  372. package/src/db/migrations/014-secrets.ts +0 -44
  373. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  374. package/src/db/migrations/016-secret-assignments.ts +0 -30
  375. package/src/db/migrations/017-agent-activity.ts +0 -40
  376. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  377. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  378. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  379. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  380. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  381. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  382. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  383. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  384. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  385. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  386. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  387. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  388. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  389. package/src/db/migrations/027-provider-credentials.ts +0 -41
  390. package/src/db/migrations/_test-helpers.ts +0 -41
  391. package/src/db/migrations/index.ts +0 -127
  392. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  393. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  394. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  395. package/src/db/schema.ts +0 -258
  396. package/src/db/session-db.test.ts +0 -93
  397. package/src/db/session-db.ts +0 -325
  398. package/src/db/sessions.ts +0 -241
  399. package/src/delivery.test.ts +0 -148
  400. package/src/delivery.ts +0 -445
  401. package/src/env.ts +0 -74
  402. package/src/group-folder.test.ts +0 -35
  403. package/src/group-folder.ts +0 -44
  404. package/src/group-init.ts +0 -92
  405. package/src/host-core.test.ts +0 -456
  406. package/src/host-sweep.test.ts +0 -146
  407. package/src/host-sweep.ts +0 -287
  408. package/src/index.ts +0 -232
  409. package/src/install-slug.ts +0 -33
  410. package/src/log.test.ts +0 -81
  411. package/src/log.ts +0 -117
  412. package/src/mcp/http.ts +0 -72
  413. package/src/mcp/server.ts +0 -92
  414. package/src/mcp/stdio.ts +0 -51
  415. package/src/mcp/tools/activity.ts +0 -88
  416. package/src/mcp/tools/agent-groups.ts +0 -183
  417. package/src/mcp/tools/approvals.ts +0 -122
  418. package/src/mcp/tools/channels.test.ts +0 -126
  419. package/src/mcp/tools/channels.ts +0 -134
  420. package/src/mcp/tools/index.ts +0 -27
  421. package/src/mcp/tools/oauth.ts +0 -48
  422. package/src/mcp/tools/secrets.ts +0 -169
  423. package/src/mcp/tools/sessions.ts +0 -135
  424. package/src/mcp/types.ts +0 -51
  425. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  426. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  427. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  428. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  429. package/src/modules/agent-to-agent/index.ts +0 -22
  430. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  431. package/src/modules/approvals/agent.md +0 -45
  432. package/src/modules/approvals/index.ts +0 -21
  433. package/src/modules/approvals/picks.test.ts +0 -291
  434. package/src/modules/approvals/primitive.ts +0 -279
  435. package/src/modules/approvals/project.md +0 -27
  436. package/src/modules/approvals/response-handler.ts +0 -87
  437. package/src/modules/index.ts +0 -24
  438. package/src/modules/interactive/agent.md +0 -21
  439. package/src/modules/interactive/index.ts +0 -69
  440. package/src/modules/interactive/project.md +0 -12
  441. package/src/modules/mount-security/expand-path.test.ts +0 -82
  442. package/src/modules/mount-security/index.ts +0 -459
  443. package/src/modules/mount-security/migrate.test.ts +0 -91
  444. package/src/modules/permissions/access.ts +0 -28
  445. package/src/modules/permissions/channel-approval.test.ts +0 -389
  446. package/src/modules/permissions/channel-approval.ts +0 -188
  447. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  448. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  449. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  450. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  451. package/src/modules/permissions/db/user-dms.ts +0 -58
  452. package/src/modules/permissions/db/user-roles.ts +0 -85
  453. package/src/modules/permissions/db/users.ts +0 -38
  454. package/src/modules/permissions/index.ts +0 -421
  455. package/src/modules/permissions/permissions.test.ts +0 -358
  456. package/src/modules/permissions/sender-approval.test.ts +0 -641
  457. package/src/modules/permissions/sender-approval.ts +0 -165
  458. package/src/modules/permissions/user-dm.ts +0 -200
  459. package/src/modules/provider-credentials/db.ts +0 -121
  460. package/src/modules/provider-credentials/index.ts +0 -12
  461. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  462. package/src/modules/provider-credentials/spawn.ts +0 -114
  463. package/src/modules/scheduling/actions.ts +0 -113
  464. package/src/modules/scheduling/db.test.ts +0 -282
  465. package/src/modules/scheduling/db.ts +0 -148
  466. package/src/modules/scheduling/index.ts +0 -34
  467. package/src/modules/scheduling/recurrence.test.ts +0 -98
  468. package/src/modules/scheduling/recurrence.ts +0 -54
  469. package/src/modules/self-mod/agent.md +0 -30
  470. package/src/modules/self-mod/apply.ts +0 -85
  471. package/src/modules/self-mod/index.ts +0 -30
  472. package/src/modules/self-mod/project.md +0 -39
  473. package/src/modules/self-mod/request.ts +0 -91
  474. package/src/modules/typing/index.ts +0 -165
  475. package/src/oauth/agent-app-connections.ts +0 -103
  476. package/src/oauth/app-configs.test.ts +0 -64
  477. package/src/oauth/app-configs.ts +0 -114
  478. package/src/oauth/app-connections.test.ts +0 -109
  479. package/src/oauth/app-connections.ts +0 -178
  480. package/src/oauth/crypto.ts +0 -56
  481. package/src/oauth/flow.ts +0 -104
  482. package/src/oauth/providers/google.test.ts +0 -38
  483. package/src/oauth/providers/google.ts +0 -46
  484. package/src/oauth/providers/index.ts +0 -48
  485. package/src/oauth/state-store.test.ts +0 -54
  486. package/src/oauth/state-store.ts +0 -93
  487. package/src/parachute/README.md +0 -27
  488. package/src/parachute/create-agent.test.ts +0 -83
  489. package/src/parachute/create-agent.ts +0 -122
  490. package/src/parachute/group-status.test.ts +0 -165
  491. package/src/parachute/group-status.ts +0 -136
  492. package/src/parachute/types.ts +0 -41
  493. package/src/parachute/vault-mcp.test.ts +0 -251
  494. package/src/parachute/vault-mcp.ts +0 -232
  495. package/src/platform-id.test.ts +0 -104
  496. package/src/platform-id.ts +0 -109
  497. package/src/providers/index.ts +0 -6
  498. package/src/providers/provider-container-registry.ts +0 -58
  499. package/src/response-registry.ts +0 -45
  500. package/src/router.ts +0 -530
  501. package/src/secrets/crypto.test.ts +0 -45
  502. package/src/secrets/crypto.ts +0 -55
  503. package/src/secrets/index.ts +0 -461
  504. package/src/secrets/master-key.ts +0 -70
  505. package/src/secrets/secrets.test.ts +0 -651
  506. package/src/session-manager.attachments.test.ts +0 -171
  507. package/src/session-manager.dup-skip.test.ts +0 -173
  508. package/src/session-manager.migrate.test.ts +0 -59
  509. package/src/session-manager.ts +0 -451
  510. package/src/startup-bootstrap.test.ts +0 -226
  511. package/src/startup-bootstrap.ts +0 -207
  512. package/src/state-sqlite.ts +0 -182
  513. package/src/timezone.test.ts +0 -64
  514. package/src/timezone.ts +0 -37
  515. package/src/types.ts +0 -233
  516. package/src/web/auth.test.ts +0 -335
  517. package/src/web/auth.ts +0 -214
  518. package/src/web/discord-validate.test.ts +0 -77
  519. package/src/web/discord-validate.ts +0 -88
  520. package/src/web/hub-discovery.test.ts +0 -98
  521. package/src/web/hub-discovery.ts +0 -69
  522. package/src/web/routes/activity.ts +0 -106
  523. package/src/web/routes/agent-provider.test.ts +0 -282
  524. package/src/web/routes/agent-provider.ts +0 -309
  525. package/src/web/routes/approvals.ts +0 -185
  526. package/src/web/routes/apps.ts +0 -434
  527. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  528. package/src/web/routes/channels-mga-detail.test.ts +0 -472
  529. package/src/web/routes/channels.ts +0 -311
  530. package/src/web/routes/oauth-providers.ts +0 -42
  531. package/src/web/routes/secrets.test.ts +0 -220
  532. package/src/web/routes/secrets.ts +0 -317
  533. package/src/web/routes/sessions.ts +0 -123
  534. package/src/web/routes/settings.test.ts +0 -106
  535. package/src/web/routes/settings.ts +0 -247
  536. package/src/web/routes/setup-status.ts +0 -205
  537. package/src/web/routes/vaults.test.ts +0 -389
  538. package/src/web/routes/vaults.ts +0 -225
  539. package/src/web/server-version.test.ts +0 -16
  540. package/src/web/server.ts +0 -1024
  541. package/src/web/services-manifest.test.ts +0 -148
  542. package/src/web/services-manifest.ts +0 -66
  543. package/src/web/static-serve.test.ts +0 -255
  544. package/src/web/static-serve.ts +0 -104
  545. package/src/web/telegram-validate.test.ts +0 -116
  546. package/src/web/telegram-validate.ts +0 -107
  547. package/src/web/vault-proxy.test.ts +0 -214
  548. package/src/web/vault-proxy.ts +0 -120
  549. package/src/web/wire-channel.ts +0 -181
  550. package/src/webhook-server.ts +0 -134
  551. package/vitest.config.ts +0 -18
  552. package/web/README.md +0 -63
  553. package/web/ui/index.html +0 -13
  554. package/web/ui/package.json +0 -35
  555. package/web/ui/pnpm-lock.yaml +0 -2164
  556. package/web/ui/scripts/verify-base.mjs +0 -31
  557. package/web/ui/src/App.tsx +0 -88
  558. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  559. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  560. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  561. package/web/ui/src/components/CredentialForm.tsx +0 -214
  562. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  563. package/web/ui/src/components/StatusDot.tsx +0 -43
  564. package/web/ui/src/components/VaultPicker.tsx +0 -127
  565. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  566. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  567. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  568. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  569. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  570. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  571. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  572. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  573. package/web/ui/src/components/setup/types.ts +0 -105
  574. package/web/ui/src/lib/api.test.ts +0 -410
  575. package/web/ui/src/lib/api.ts +0 -1248
  576. package/web/ui/src/lib/auth.test.ts +0 -352
  577. package/web/ui/src/lib/auth.ts +0 -405
  578. package/web/ui/src/lib/channel-adapters.ts +0 -136
  579. package/web/ui/src/main.tsx +0 -19
  580. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  581. package/web/ui/src/routes/Apps.tsx +0 -613
  582. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  583. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  584. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  585. package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
  586. package/web/ui/src/routes/GroupDetail.tsx +0 -880
  587. package/web/ui/src/routes/GroupList.tsx +0 -187
  588. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  589. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  590. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  591. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  592. package/web/ui/src/routes/SecretsList.tsx +0 -942
  593. package/web/ui/src/routes/SessionsList.tsx +0 -220
  594. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  595. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  596. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  597. package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
  598. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  599. package/web/ui/src/routes/VaultsList.tsx +0 -295
  600. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  601. package/web/ui/src/styles.css +0 -608
  602. package/web/ui/src/test/setup.ts +0 -23
  603. package/web/ui/src/vite-env.d.ts +0 -10
  604. package/web/ui/vite.config.ts +0 -34
  605. 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
+ });