@openparachute/agent 0.1.2 → 0.2.2

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 (608) hide show
  1. package/.parachute/module.json +124 -8
  2. package/LICENSE +2 -16
  3. package/README.md +118 -166
  4. package/package.json +35 -42
  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/dist/assets/index-C-iWdFFV.css +1 -0
  103. package/web/ui/dist/assets/index-VFETBk0a.js +60 -0
  104. package/web/ui/dist/index.html +15 -0
  105. package/web/ui/tsconfig.json +2 -1
  106. package/.claude/scheduled_tasks.lock +0 -1
  107. package/.claude/settings.json +0 -5
  108. package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
  109. package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
  110. package/.claude/skills/add-codex/SKILL.md +0 -161
  111. package/.claude/skills/add-dashboard/SKILL.md +0 -138
  112. package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
  113. package/.claude/skills/add-emacs/SKILL.md +0 -296
  114. package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
  115. package/.claude/skills/add-gchat/REMOVE.md +0 -6
  116. package/.claude/skills/add-gchat/SKILL.md +0 -92
  117. package/.claude/skills/add-gchat/VERIFY.md +0 -3
  118. package/.claude/skills/add-github/REMOVE.md +0 -6
  119. package/.claude/skills/add-github/SKILL.md +0 -148
  120. package/.claude/skills/add-github/VERIFY.md +0 -3
  121. package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
  122. package/.claude/skills/add-imessage/REMOVE.md +0 -6
  123. package/.claude/skills/add-imessage/SKILL.md +0 -113
  124. package/.claude/skills/add-imessage/VERIFY.md +0 -3
  125. package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
  126. package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
  127. package/.claude/skills/add-linear/REMOVE.md +0 -6
  128. package/.claude/skills/add-linear/SKILL.md +0 -168
  129. package/.claude/skills/add-linear/VERIFY.md +0 -3
  130. package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
  131. package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
  132. package/.claude/skills/add-matrix/REMOVE.md +0 -6
  133. package/.claude/skills/add-matrix/SKILL.md +0 -148
  134. package/.claude/skills/add-matrix/VERIFY.md +0 -3
  135. package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
  136. package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
  137. package/.claude/skills/add-opencode/SKILL.md +0 -229
  138. package/.claude/skills/add-parallel/SKILL.md +0 -290
  139. package/.claude/skills/add-resend/REMOVE.md +0 -6
  140. package/.claude/skills/add-resend/SKILL.md +0 -93
  141. package/.claude/skills/add-resend/VERIFY.md +0 -3
  142. package/.claude/skills/add-signal/REMOVE.md +0 -13
  143. package/.claude/skills/add-signal/SKILL.md +0 -318
  144. package/.claude/skills/add-signal/VERIFY.md +0 -5
  145. package/.claude/skills/add-slack/REMOVE.md +0 -6
  146. package/.claude/skills/add-slack/SKILL.md +0 -112
  147. package/.claude/skills/add-slack/VERIFY.md +0 -3
  148. package/.claude/skills/add-teams/REMOVE.md +0 -6
  149. package/.claude/skills/add-teams/SKILL.md +0 -207
  150. package/.claude/skills/add-teams/VERIFY.md +0 -3
  151. package/.claude/skills/add-vercel/SKILL.md +0 -147
  152. package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
  153. package/.claude/skills/add-webex/REMOVE.md +0 -6
  154. package/.claude/skills/add-webex/SKILL.md +0 -88
  155. package/.claude/skills/add-webex/VERIFY.md +0 -3
  156. package/.claude/skills/add-wechat/REMOVE.md +0 -49
  157. package/.claude/skills/add-wechat/SKILL.md +0 -170
  158. package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
  159. package/.claude/skills/add-whatsapp/SKILL.md +0 -264
  160. package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
  161. package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
  162. package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
  163. package/.claude/skills/claw/SKILL.md +0 -131
  164. package/.claude/skills/claw/scripts/claw +0 -374
  165. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
  166. package/.claude/skills/customize/SKILL.md +0 -110
  167. package/.claude/skills/debug/SKILL.md +0 -349
  168. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  169. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  170. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  171. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  172. package/.claude/skills/init-first-agent/SKILL.md +0 -120
  173. package/.claude/skills/init-onecli/SKILL.md +0 -270
  174. package/.claude/skills/manage-channels/SKILL.md +0 -87
  175. package/.claude/skills/manage-mounts/SKILL.md +0 -47
  176. package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
  177. package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
  178. package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
  179. package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
  180. package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
  181. package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
  182. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  183. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  184. package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
  185. package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
  186. package/.claude/skills/update-skills/SKILL.md +0 -130
  187. package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
  188. package/.claude/skills/x-integration/SKILL.md +0 -417
  189. package/.claude/skills/x-integration/agent.ts +0 -243
  190. package/.claude/skills/x-integration/host.ts +0 -155
  191. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  192. package/.claude/skills/x-integration/lib/config.ts +0 -62
  193. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  194. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  195. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  196. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  197. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  198. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  199. package/.github/CODEOWNERS +0 -10
  200. package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
  201. package/.github/workflows/bump-version.yml +0 -35
  202. package/.github/workflows/ci.yml +0 -39
  203. package/.github/workflows/label-pr.yml +0 -40
  204. package/.github/workflows/update-tokens.yml +0 -43
  205. package/.husky/pre-commit +0 -1
  206. package/.mcp.json +0 -3
  207. package/.nvmrc +0 -1
  208. package/.prettierrc +0 -4
  209. package/CHANGELOG.md +0 -263
  210. package/CLAUDE.md +0 -307
  211. package/CODE_OF_CONDUCT.md +0 -128
  212. package/CONTRIBUTING.md +0 -159
  213. package/CONTRIBUTORS.md +0 -26
  214. package/LICENSE-NANOCLAW-MIT +0 -21
  215. package/README_ja.md +0 -194
  216. package/README_zh.md +0 -194
  217. package/assets/nanoclaw-favicon.png +0 -0
  218. package/assets/nanoclaw-icon.png +0 -0
  219. package/assets/nanoclaw-logo-dark.png +0 -0
  220. package/assets/nanoclaw-logo.png +0 -0
  221. package/assets/nanoclaw-profile.jpeg +0 -0
  222. package/assets/nanoclaw-sales.png +0 -0
  223. package/assets/social-preview.jpg +0 -0
  224. package/config-examples/mount-allowlist.json +0 -25
  225. package/container/.dockerignore +0 -2
  226. package/container/CLAUDE.md +0 -21
  227. package/container/Dockerfile +0 -121
  228. package/container/agent-runner/bun.lock +0 -243
  229. package/container/agent-runner/package.json +0 -22
  230. package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
  231. package/container/agent-runner/src/config.ts +0 -55
  232. package/container/agent-runner/src/db/connection.ts +0 -267
  233. package/container/agent-runner/src/db/index.ts +0 -20
  234. package/container/agent-runner/src/db/messages-in.ts +0 -138
  235. package/container/agent-runner/src/db/messages-out.ts +0 -143
  236. package/container/agent-runner/src/db/session-routing.ts +0 -30
  237. package/container/agent-runner/src/db/session-state.test.ts +0 -100
  238. package/container/agent-runner/src/db/session-state.ts +0 -79
  239. package/container/agent-runner/src/destinations.ts +0 -135
  240. package/container/agent-runner/src/formatter.test.ts +0 -167
  241. package/container/agent-runner/src/formatter.ts +0 -260
  242. package/container/agent-runner/src/index.ts +0 -110
  243. package/container/agent-runner/src/integration.test.ts +0 -121
  244. package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
  245. package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
  246. package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
  247. package/container/agent-runner/src/mcp-tools/core.ts +0 -262
  248. package/container/agent-runner/src/mcp-tools/index.ts +0 -22
  249. package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
  250. package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
  251. package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
  252. package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
  253. package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
  254. package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
  255. package/container/agent-runner/src/mcp-tools/server.ts +0 -54
  256. package/container/agent-runner/src/mcp-tools/types.ts +0 -6
  257. package/container/agent-runner/src/poll-loop.test.ts +0 -248
  258. package/container/agent-runner/src/poll-loop.ts +0 -437
  259. package/container/agent-runner/src/providers/claude.ts +0 -379
  260. package/container/agent-runner/src/providers/factory.test.ts +0 -19
  261. package/container/agent-runner/src/providers/factory.ts +0 -13
  262. package/container/agent-runner/src/providers/index.ts +0 -6
  263. package/container/agent-runner/src/providers/mock.ts +0 -77
  264. package/container/agent-runner/src/providers/provider-registry.ts +0 -33
  265. package/container/agent-runner/src/providers/types.ts +0 -82
  266. package/container/agent-runner/src/scheduling/task-script.ts +0 -121
  267. package/container/agent-runner/src/timezone.test.ts +0 -93
  268. package/container/agent-runner/src/timezone.ts +0 -107
  269. package/container/agent-runner/tsconfig.json +0 -14
  270. package/container/build.sh +0 -48
  271. package/container/entrypoint.sh +0 -16
  272. package/container/skills/agent-browser/SKILL.md +0 -159
  273. package/container/skills/frontend-engineer/SKILL.md +0 -157
  274. package/container/skills/self-customize/SKILL.md +0 -87
  275. package/container/skills/slack-formatting/SKILL.md +0 -94
  276. package/container/skills/vercel-cli/SKILL.md +0 -111
  277. package/container/skills/welcome/SKILL.md +0 -85
  278. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  279. package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
  280. package/docs/README.md +0 -25
  281. package/docs/SDK_DEEP_DIVE.md +0 -643
  282. package/docs/SECURITY.md +0 -162
  283. package/docs/agent-runner-details.md +0 -749
  284. package/docs/api-details.md +0 -365
  285. package/docs/architecture-diagram.html +0 -422
  286. package/docs/architecture-diagram.md +0 -215
  287. package/docs/architecture.md +0 -751
  288. package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
  289. package/docs/build-and-runtime.md +0 -80
  290. package/docs/cross-mount-stress/README.md +0 -112
  291. package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
  292. package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
  293. package/docs/cross-mount-stress/container-writer.mjs +0 -47
  294. package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
  295. package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
  296. package/docs/cross-mount-stress/host-writer.mjs +0 -47
  297. package/docs/db-central.md +0 -316
  298. package/docs/db-session.md +0 -183
  299. package/docs/db.md +0 -119
  300. package/docs/design/2026-04-29-vault-management-ui.md +0 -231
  301. package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
  302. package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
  303. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
  304. package/docs/docker-sandboxes.md +0 -359
  305. package/docs/isolation-model.md +0 -88
  306. package/docs/ollama.md +0 -79
  307. package/docs/parachute-integration.md +0 -109
  308. package/docs/post-night-rebirth-reflections.md +0 -151
  309. package/eslint.config.js +0 -32
  310. package/pnpm-workspace.yaml +0 -8
  311. package/repo-tokens/README.md +0 -113
  312. package/repo-tokens/action.yml +0 -186
  313. package/repo-tokens/badge.svg +0 -23
  314. package/repo-tokens/examples/green.svg +0 -14
  315. package/repo-tokens/examples/red.svg +0 -14
  316. package/repo-tokens/examples/yellow-green.svg +0 -14
  317. package/repo-tokens/examples/yellow.svg +0 -14
  318. package/scripts/chat.ts +0 -101
  319. package/scripts/cleanup-sessions.sh +0 -150
  320. package/scripts/init-cli-agent.ts +0 -172
  321. package/scripts/init-first-agent.ts +0 -378
  322. package/scripts/parachute.ts +0 -158
  323. package/scripts/run-migrations.ts +0 -105
  324. package/scripts/sanity-live-poll.ts +0 -95
  325. package/scripts/seed-discord.ts +0 -80
  326. package/scripts/test-v2-agent.ts +0 -106
  327. package/scripts/test-v2-channel-e2e.ts +0 -265
  328. package/scripts/test-v2-host.ts +0 -184
  329. package/src/channels/adapter.ts +0 -214
  330. package/src/channels/api-translator.test.ts +0 -306
  331. package/src/channels/api-translator.ts +0 -214
  332. package/src/channels/ask-question.ts +0 -46
  333. package/src/channels/channel-registry.test.ts +0 -421
  334. package/src/channels/channel-registry.ts +0 -313
  335. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  336. package/src/channels/chat-sdk-bridge.ts +0 -652
  337. package/src/channels/cli.ts +0 -276
  338. package/src/channels/discord.ts +0 -90
  339. package/src/channels/index.ts +0 -17
  340. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  341. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  342. package/src/channels/telegram-pairing.test.ts +0 -254
  343. package/src/channels/telegram-pairing.ts +0 -339
  344. package/src/channels/telegram.ts +0 -279
  345. package/src/channels/trust-hint.test.ts +0 -48
  346. package/src/channels/trust-hint.ts +0 -75
  347. package/src/claude-md-compose.migrate.test.ts +0 -64
  348. package/src/claude-md-compose.ts +0 -205
  349. package/src/command-gate.ts +0 -63
  350. package/src/config.test.ts +0 -93
  351. package/src/config.ts +0 -128
  352. package/src/container-config.ts +0 -167
  353. package/src/container-runner.test.ts +0 -32
  354. package/src/container-runner.ts +0 -576
  355. package/src/container-runtime.test.ts +0 -269
  356. package/src/container-runtime.ts +0 -167
  357. package/src/db/_bun-sqlite-shim.ts +0 -88
  358. package/src/db/agent-activity.test.ts +0 -155
  359. package/src/db/agent-activity.ts +0 -121
  360. package/src/db/agent-groups.ts +0 -77
  361. package/src/db/connection.migrate.test.ts +0 -176
  362. package/src/db/connection.ts +0 -259
  363. package/src/db/db-v2.test.ts +0 -440
  364. package/src/db/dropped-messages.ts +0 -44
  365. package/src/db/index.ts +0 -40
  366. package/src/db/messaging-groups.ts +0 -252
  367. package/src/db/migrations/001-initial.ts +0 -112
  368. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  369. package/src/db/migrations/008-dropped-messages.ts +0 -27
  370. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  371. package/src/db/migrations/010-engage-modes.ts +0 -103
  372. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  373. package/src/db/migrations/012-channel-registration.ts +0 -48
  374. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  375. package/src/db/migrations/014-secrets.ts +0 -44
  376. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  377. package/src/db/migrations/016-secret-assignments.ts +0 -30
  378. package/src/db/migrations/017-agent-activity.ts +0 -40
  379. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  380. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  381. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  382. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  383. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  384. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  385. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  386. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  387. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  388. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  389. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  390. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  391. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  392. package/src/db/migrations/027-provider-credentials.ts +0 -41
  393. package/src/db/migrations/_test-helpers.ts +0 -41
  394. package/src/db/migrations/index.ts +0 -127
  395. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  396. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  397. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  398. package/src/db/schema.ts +0 -258
  399. package/src/db/session-db.test.ts +0 -93
  400. package/src/db/session-db.ts +0 -325
  401. package/src/db/sessions.ts +0 -241
  402. package/src/delivery.test.ts +0 -148
  403. package/src/delivery.ts +0 -445
  404. package/src/env.ts +0 -74
  405. package/src/group-folder.test.ts +0 -35
  406. package/src/group-folder.ts +0 -44
  407. package/src/group-init.ts +0 -92
  408. package/src/host-core.test.ts +0 -456
  409. package/src/host-sweep.test.ts +0 -146
  410. package/src/host-sweep.ts +0 -287
  411. package/src/index.ts +0 -232
  412. package/src/install-slug.ts +0 -33
  413. package/src/log.test.ts +0 -81
  414. package/src/log.ts +0 -117
  415. package/src/mcp/http.ts +0 -72
  416. package/src/mcp/server.ts +0 -92
  417. package/src/mcp/stdio.ts +0 -51
  418. package/src/mcp/tools/activity.ts +0 -88
  419. package/src/mcp/tools/agent-groups.ts +0 -183
  420. package/src/mcp/tools/approvals.ts +0 -122
  421. package/src/mcp/tools/channels.test.ts +0 -126
  422. package/src/mcp/tools/channels.ts +0 -134
  423. package/src/mcp/tools/index.ts +0 -27
  424. package/src/mcp/tools/oauth.ts +0 -48
  425. package/src/mcp/tools/secrets.ts +0 -169
  426. package/src/mcp/tools/sessions.ts +0 -135
  427. package/src/mcp/types.ts +0 -51
  428. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  429. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  430. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  431. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  432. package/src/modules/agent-to-agent/index.ts +0 -22
  433. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  434. package/src/modules/approvals/agent.md +0 -45
  435. package/src/modules/approvals/index.ts +0 -21
  436. package/src/modules/approvals/picks.test.ts +0 -291
  437. package/src/modules/approvals/primitive.ts +0 -279
  438. package/src/modules/approvals/project.md +0 -27
  439. package/src/modules/approvals/response-handler.ts +0 -87
  440. package/src/modules/index.ts +0 -24
  441. package/src/modules/interactive/agent.md +0 -21
  442. package/src/modules/interactive/index.ts +0 -69
  443. package/src/modules/interactive/project.md +0 -12
  444. package/src/modules/mount-security/expand-path.test.ts +0 -82
  445. package/src/modules/mount-security/index.ts +0 -459
  446. package/src/modules/mount-security/migrate.test.ts +0 -91
  447. package/src/modules/permissions/access.ts +0 -28
  448. package/src/modules/permissions/channel-approval.test.ts +0 -389
  449. package/src/modules/permissions/channel-approval.ts +0 -188
  450. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  451. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  452. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  453. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  454. package/src/modules/permissions/db/user-dms.ts +0 -58
  455. package/src/modules/permissions/db/user-roles.ts +0 -85
  456. package/src/modules/permissions/db/users.ts +0 -38
  457. package/src/modules/permissions/index.ts +0 -421
  458. package/src/modules/permissions/permissions.test.ts +0 -358
  459. package/src/modules/permissions/sender-approval.test.ts +0 -641
  460. package/src/modules/permissions/sender-approval.ts +0 -165
  461. package/src/modules/permissions/user-dm.ts +0 -200
  462. package/src/modules/provider-credentials/db.ts +0 -121
  463. package/src/modules/provider-credentials/index.ts +0 -12
  464. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  465. package/src/modules/provider-credentials/spawn.ts +0 -114
  466. package/src/modules/scheduling/actions.ts +0 -113
  467. package/src/modules/scheduling/db.test.ts +0 -282
  468. package/src/modules/scheduling/db.ts +0 -148
  469. package/src/modules/scheduling/index.ts +0 -34
  470. package/src/modules/scheduling/recurrence.test.ts +0 -98
  471. package/src/modules/scheduling/recurrence.ts +0 -54
  472. package/src/modules/self-mod/agent.md +0 -30
  473. package/src/modules/self-mod/apply.ts +0 -85
  474. package/src/modules/self-mod/index.ts +0 -30
  475. package/src/modules/self-mod/project.md +0 -39
  476. package/src/modules/self-mod/request.ts +0 -91
  477. package/src/modules/typing/index.ts +0 -165
  478. package/src/oauth/agent-app-connections.ts +0 -103
  479. package/src/oauth/app-configs.test.ts +0 -64
  480. package/src/oauth/app-configs.ts +0 -114
  481. package/src/oauth/app-connections.test.ts +0 -109
  482. package/src/oauth/app-connections.ts +0 -178
  483. package/src/oauth/crypto.ts +0 -56
  484. package/src/oauth/flow.ts +0 -104
  485. package/src/oauth/providers/google.test.ts +0 -38
  486. package/src/oauth/providers/google.ts +0 -46
  487. package/src/oauth/providers/index.ts +0 -48
  488. package/src/oauth/state-store.test.ts +0 -54
  489. package/src/oauth/state-store.ts +0 -93
  490. package/src/parachute/README.md +0 -27
  491. package/src/parachute/create-agent.test.ts +0 -83
  492. package/src/parachute/create-agent.ts +0 -122
  493. package/src/parachute/group-status.test.ts +0 -165
  494. package/src/parachute/group-status.ts +0 -136
  495. package/src/parachute/types.ts +0 -41
  496. package/src/parachute/vault-mcp.test.ts +0 -251
  497. package/src/parachute/vault-mcp.ts +0 -232
  498. package/src/platform-id.test.ts +0 -104
  499. package/src/platform-id.ts +0 -109
  500. package/src/providers/index.ts +0 -6
  501. package/src/providers/provider-container-registry.ts +0 -58
  502. package/src/response-registry.ts +0 -45
  503. package/src/router.ts +0 -530
  504. package/src/secrets/crypto.test.ts +0 -45
  505. package/src/secrets/crypto.ts +0 -55
  506. package/src/secrets/index.ts +0 -461
  507. package/src/secrets/master-key.ts +0 -70
  508. package/src/secrets/secrets.test.ts +0 -651
  509. package/src/session-manager.attachments.test.ts +0 -171
  510. package/src/session-manager.dup-skip.test.ts +0 -173
  511. package/src/session-manager.migrate.test.ts +0 -59
  512. package/src/session-manager.ts +0 -451
  513. package/src/startup-bootstrap.test.ts +0 -226
  514. package/src/startup-bootstrap.ts +0 -207
  515. package/src/state-sqlite.ts +0 -182
  516. package/src/timezone.test.ts +0 -64
  517. package/src/timezone.ts +0 -37
  518. package/src/types.ts +0 -233
  519. package/src/web/auth.test.ts +0 -335
  520. package/src/web/auth.ts +0 -214
  521. package/src/web/discord-validate.test.ts +0 -77
  522. package/src/web/discord-validate.ts +0 -88
  523. package/src/web/hub-discovery.test.ts +0 -98
  524. package/src/web/hub-discovery.ts +0 -69
  525. package/src/web/routes/activity.ts +0 -106
  526. package/src/web/routes/agent-provider.test.ts +0 -282
  527. package/src/web/routes/agent-provider.ts +0 -309
  528. package/src/web/routes/approvals.ts +0 -185
  529. package/src/web/routes/apps.ts +0 -434
  530. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  531. package/src/web/routes/channels-mga-detail.test.ts +0 -472
  532. package/src/web/routes/channels.ts +0 -311
  533. package/src/web/routes/oauth-providers.ts +0 -42
  534. package/src/web/routes/secrets.test.ts +0 -220
  535. package/src/web/routes/secrets.ts +0 -317
  536. package/src/web/routes/sessions.ts +0 -123
  537. package/src/web/routes/settings.test.ts +0 -106
  538. package/src/web/routes/settings.ts +0 -247
  539. package/src/web/routes/setup-status.ts +0 -205
  540. package/src/web/routes/vaults.test.ts +0 -389
  541. package/src/web/routes/vaults.ts +0 -225
  542. package/src/web/server-version.test.ts +0 -16
  543. package/src/web/server.ts +0 -1024
  544. package/src/web/services-manifest.test.ts +0 -148
  545. package/src/web/services-manifest.ts +0 -66
  546. package/src/web/static-serve.test.ts +0 -255
  547. package/src/web/static-serve.ts +0 -104
  548. package/src/web/telegram-validate.test.ts +0 -116
  549. package/src/web/telegram-validate.ts +0 -107
  550. package/src/web/vault-proxy.test.ts +0 -214
  551. package/src/web/vault-proxy.ts +0 -120
  552. package/src/web/wire-channel.ts +0 -181
  553. package/src/webhook-server.ts +0 -134
  554. package/vitest.config.ts +0 -18
  555. package/web/README.md +0 -63
  556. package/web/ui/index.html +0 -13
  557. package/web/ui/package.json +0 -35
  558. package/web/ui/pnpm-lock.yaml +0 -2164
  559. package/web/ui/scripts/verify-base.mjs +0 -31
  560. package/web/ui/src/App.tsx +0 -88
  561. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  562. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  563. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  564. package/web/ui/src/components/CredentialForm.tsx +0 -214
  565. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  566. package/web/ui/src/components/StatusDot.tsx +0 -43
  567. package/web/ui/src/components/VaultPicker.tsx +0 -127
  568. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  569. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  570. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  571. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  572. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  573. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  574. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  575. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  576. package/web/ui/src/components/setup/types.ts +0 -105
  577. package/web/ui/src/lib/api.test.ts +0 -410
  578. package/web/ui/src/lib/api.ts +0 -1248
  579. package/web/ui/src/lib/auth.test.ts +0 -352
  580. package/web/ui/src/lib/auth.ts +0 -405
  581. package/web/ui/src/lib/channel-adapters.ts +0 -136
  582. package/web/ui/src/main.tsx +0 -19
  583. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  584. package/web/ui/src/routes/Apps.tsx +0 -613
  585. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  586. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  587. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  588. package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
  589. package/web/ui/src/routes/GroupDetail.tsx +0 -880
  590. package/web/ui/src/routes/GroupList.tsx +0 -187
  591. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  592. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  593. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  594. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  595. package/web/ui/src/routes/SecretsList.tsx +0 -942
  596. package/web/ui/src/routes/SessionsList.tsx +0 -220
  597. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  598. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  599. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  600. package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
  601. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  602. package/web/ui/src/routes/VaultsList.tsx +0 -295
  603. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  604. package/web/ui/src/styles.css +0 -608
  605. package/web/ui/src/test/setup.ts +0 -23
  606. package/web/ui/src/vite-env.d.ts +0 -10
  607. package/web/ui/vite.config.ts +0 -34
  608. 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
+ });