@vellumai/assistant 0.8.8 → 0.8.9-dev.202606091853.fbaa2ae

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 (475) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/bun.lock +2 -2
  3. package/docs/activation-funnel-telemetry.md +310 -0
  4. package/examples/plugins/echo/README.md +61 -60
  5. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  6. package/examples/plugins/echo/hooks/stop.ts +16 -0
  7. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  8. package/examples/plugins/echo/package.json +1 -2
  9. package/examples/plugins/echo/src/emit.ts +19 -0
  10. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
  11. package/openapi.yaml +263 -121
  12. package/package.json +2 -2
  13. package/src/__tests__/activation-early-marking.test.ts +120 -0
  14. package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
  15. package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
  16. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
  17. package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
  18. package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
  19. package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
  20. package/src/__tests__/agent-loop-thinking.test.ts +36 -20
  21. package/src/__tests__/agent-loop.test.ts +441 -96
  22. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
  23. package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
  24. package/src/__tests__/anthropic-provider.test.ts +23 -8
  25. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  26. package/src/__tests__/app-control-flow.test.ts +1 -1
  27. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  28. package/src/__tests__/app-executors.test.ts +132 -0
  29. package/src/__tests__/approval-cascade.test.ts +6 -5
  30. package/src/__tests__/approval-routes-http.test.ts +4 -1
  31. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  32. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  33. package/src/__tests__/channel-approvals.test.ts +1 -1
  34. package/src/__tests__/compaction-circuit.test.ts +258 -0
  35. package/src/__tests__/compaction-direct.test.ts +146 -0
  36. package/src/__tests__/compaction-events.test.ts +7 -7
  37. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  38. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  39. package/src/__tests__/context-overflow-reducer.test.ts +265 -125
  40. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  41. package/src/__tests__/conversation-abort-tool-results.test.ts +7 -5
  42. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
  43. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
  44. package/src/__tests__/conversation-agent-loop-overflow.test.ts +158 -234
  45. package/src/__tests__/conversation-agent-loop.test.ts +342 -623
  46. package/src/__tests__/conversation-clean-command.test.ts +5 -2
  47. package/src/__tests__/conversation-confirmation-signals.test.ts +6 -5
  48. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  49. package/src/__tests__/conversation-error.test.ts +15 -1
  50. package/src/__tests__/conversation-history-web-search.test.ts +6 -1
  51. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  52. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +44 -4
  53. package/src/__tests__/conversation-process-callsite.test.ts +15 -15
  54. package/src/__tests__/conversation-provider-retry-repair.test.ts +66 -65
  55. package/src/__tests__/conversation-queue.test.ts +10 -10
  56. package/src/__tests__/conversation-runtime-assembly.test.ts +1059 -231
  57. package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
  58. package/src/__tests__/conversation-slash-queue.test.ts +7 -5
  59. package/src/__tests__/conversation-slash-unknown.test.ts +7 -5
  60. package/src/__tests__/conversation-speed-override.test.ts +11 -10
  61. package/src/__tests__/conversation-starter-routes.test.ts +14 -6
  62. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  63. package/src/__tests__/conversation-surfaces-app-control.test.ts +48 -1
  64. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  65. package/src/__tests__/conversation-undo.test.ts +2 -2
  66. package/src/__tests__/conversation-workspace-cache-state.test.ts +24 -21
  67. package/src/__tests__/conversation-workspace-injection.test.ts +69 -7
  68. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +15 -12
  69. package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
  70. package/src/__tests__/corrected-target.test.ts +93 -0
  71. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  72. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  73. package/src/__tests__/db-acp-history.test.ts +101 -0
  74. package/src/__tests__/dynamic-page-surface.test.ts +31 -0
  75. package/src/__tests__/empty-response-hook.test.ts +1 -1
  76. package/src/__tests__/file-write-tool.test.ts +63 -0
  77. package/src/__tests__/gateway-only-guard.test.ts +17 -2
  78. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  79. package/src/__tests__/guardian-routing-invariants.test.ts +93 -4
  80. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  81. package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
  82. package/src/__tests__/heartbeat-service.test.ts +1 -0
  83. package/src/__tests__/history-repair-hook.test.ts +5 -1
  84. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  85. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  86. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  87. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  88. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  89. package/src/__tests__/injector-background-turn.test.ts +13 -23
  90. package/src/__tests__/injector-chain.test.ts +268 -44
  91. package/src/__tests__/injector-disk-pressure.test.ts +210 -52
  92. package/src/__tests__/injector-document-comments.test.ts +97 -114
  93. package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
  94. package/src/__tests__/injector-v3-suppression.test.ts +4 -4
  95. package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
  96. package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
  97. package/src/__tests__/llm-resolver.test.ts +73 -0
  98. package/src/__tests__/memory-retrieval-hook.test.ts +156 -38
  99. package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
  100. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  101. package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
  102. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  103. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  104. package/src/__tests__/plugin-api-shim.test.ts +6 -9
  105. package/src/__tests__/plugin-bootstrap.test.ts +91 -81
  106. package/src/__tests__/plugin-registry.test.ts +3 -49
  107. package/src/__tests__/plugin-skill-contribution.test.ts +15 -14
  108. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  109. package/src/__tests__/plugin-types.test.ts +0 -70
  110. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  111. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  112. package/src/__tests__/published-app-updater.test.ts +138 -0
  113. package/src/__tests__/reaction-persistence.test.ts +1 -1
  114. package/src/__tests__/send-endpoint-busy.test.ts +4 -1
  115. package/src/__tests__/skill-feature-flags-integration.test.ts +31 -0
  116. package/src/__tests__/steer-tool-repair.test.ts +1 -1
  117. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  118. package/src/__tests__/subagent-detail.test.ts +25 -7
  119. package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
  120. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  121. package/src/__tests__/subagent-manager-notify.test.ts +1 -3
  122. package/src/__tests__/subagent-notify-parent.test.ts +1 -3
  123. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  124. package/src/__tests__/title-generate-hook.test.ts +5 -1
  125. package/src/__tests__/tool-error-hook.test.ts +1 -1
  126. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
  127. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  128. package/src/__tests__/web-fetch.test.ts +45 -0
  129. package/src/acp/__tests__/agent-process.test.ts +161 -0
  130. package/src/acp/__tests__/client-handler.test.ts +40 -0
  131. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  132. package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
  133. package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
  134. package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
  135. package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
  136. package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
  137. package/src/acp/agent-process.ts +61 -1
  138. package/src/acp/auto-install.test.ts +280 -0
  139. package/src/acp/auto-install.ts +232 -0
  140. package/src/acp/client-handler.ts +31 -0
  141. package/src/acp/prepare-agent-env.ts +80 -27
  142. package/src/acp/resolve-agent.test.ts +188 -28
  143. package/src/acp/resolve-agent.ts +119 -42
  144. package/src/acp/resume-hint.ts +23 -0
  145. package/src/acp/session-manager.ts +507 -73
  146. package/src/agent/compaction-circuit.ts +60 -102
  147. package/src/agent/loop.ts +403 -251
  148. package/src/api/responses/conversation-message.ts +22 -2
  149. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  150. package/src/approvals/guardian-request-resolvers.ts +27 -1
  151. package/src/background-wake/next-wake.ts +1 -0
  152. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  153. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  154. package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
  155. package/src/cli/commands/memory-v3.ts +57 -199
  156. package/src/cli/commands/plugins.ts +43 -37
  157. package/src/cli/lib/__tests__/install-from-github.test.ts +790 -111
  158. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
  159. package/src/cli/lib/__tests__/plugin-details.test.ts +381 -0
  160. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +270 -0
  161. package/src/cli/lib/__tests__/search-plugins.test.ts +233 -32
  162. package/src/cli/lib/install-from-github.ts +797 -60
  163. package/src/cli/lib/plugin-catalog-cache.ts +84 -0
  164. package/src/cli/lib/plugin-details.ts +416 -0
  165. package/src/cli/lib/plugin-marketplace.ts +215 -0
  166. package/src/cli/lib/search-plugins.ts +201 -29
  167. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  168. package/src/config/acp-defaults.test.ts +10 -0
  169. package/src/config/acp-defaults.ts +9 -3
  170. package/src/config/acp-schema.ts +1 -7
  171. package/src/config/bundled-skills/acp/SKILL.md +80 -41
  172. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  173. package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
  174. package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
  175. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
  176. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
  177. package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
  178. package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
  179. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  180. package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
  181. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  182. package/src/config/bundled-tool-registry.ts +4 -0
  183. package/src/config/call-site-defaults.ts +0 -3
  184. package/src/config/feature-flag-registry.json +10 -16
  185. package/src/config/llm-resolver.ts +39 -7
  186. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  187. package/src/config/schemas/call-site-catalog.ts +0 -21
  188. package/src/config/schemas/heartbeat.ts +9 -0
  189. package/src/config/schemas/llm.ts +17 -3
  190. package/src/config/schemas/memory-v3.ts +58 -8
  191. package/src/config/seed-inference-profiles.ts +18 -0
  192. package/src/context/compactor.ts +22 -4
  193. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  194. package/src/context/strip-injections.ts +8 -2
  195. package/src/daemon/conversation-agent-loop-handlers.ts +18 -36
  196. package/src/daemon/conversation-agent-loop.ts +308 -1297
  197. package/src/daemon/conversation-error.ts +31 -4
  198. package/src/daemon/conversation-history.ts +1 -1
  199. package/src/daemon/conversation-lifecycle.ts +11 -255
  200. package/src/daemon/conversation-media-retry.ts +19 -6
  201. package/src/daemon/conversation-messaging.ts +17 -0
  202. package/src/daemon/conversation-process.ts +22 -141
  203. package/src/daemon/conversation-queue-manager.ts +8 -0
  204. package/src/daemon/conversation-registry.ts +159 -0
  205. package/src/daemon/conversation-runtime-assembly.ts +361 -393
  206. package/src/daemon/conversation-store.ts +9 -90
  207. package/src/daemon/conversation-surfaces.ts +165 -11
  208. package/src/daemon/conversation-workspace.ts +17 -0
  209. package/src/daemon/conversation.ts +444 -62
  210. package/src/daemon/disk-pressure-policy.ts +0 -1
  211. package/src/daemon/external-plugins-bootstrap.ts +36 -46
  212. package/src/daemon/handlers/config-channels.ts +11 -2
  213. package/src/daemon/handlers/conversations.ts +3 -1
  214. package/src/daemon/handlers/skills.ts +4 -1
  215. package/src/daemon/host-app-control-proxy.ts +72 -57
  216. package/src/daemon/host-proxy-preactivation.ts +1 -3
  217. package/src/daemon/lifecycle.ts +21 -7
  218. package/src/daemon/persist-unsendable-image.ts +62 -25
  219. package/src/daemon/process-message.ts +1 -1
  220. package/src/daemon/server.ts +2 -0
  221. package/src/daemon/tool-side-effects.ts +15 -0
  222. package/src/daemon/wake-conversation-ops.ts +269 -0
  223. package/src/embedded/plugin-api.ts +2 -2
  224. package/src/export/__tests__/transcript-formatter.test.ts +5 -0
  225. package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
  226. package/src/heartbeat/heartbeat-run-store.ts +23 -1
  227. package/src/heartbeat/heartbeat-service.ts +26 -0
  228. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  229. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  230. package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
  231. package/src/ipc/skill-routes/memory.ts +4 -2
  232. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  233. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
  234. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  235. package/src/memory/activation-session-store.ts +43 -0
  236. package/src/memory/conversation-crud.ts +29 -19
  237. package/src/memory/conversation-starter-checkpoints.ts +1 -0
  238. package/src/memory/db-init.ts +6 -0
  239. package/src/memory/job-handlers/conversation-starters.ts +13 -2
  240. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
  241. package/src/memory/jobs-worker.ts +25 -1
  242. package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
  243. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  244. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  245. package/src/memory/migrations/index.ts +3 -0
  246. package/src/memory/onboarding-events-store.ts +66 -18
  247. package/src/memory/schema/acp.ts +4 -0
  248. package/src/memory/schema/infrastructure.ts +13 -0
  249. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  250. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  251. package/src/memory/v2/consolidation-job.ts +2 -63
  252. package/src/memory/v2/types.ts +5 -0
  253. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  254. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  255. package/src/notifications/access-request-copy.ts +298 -0
  256. package/src/notifications/adapters/slack.ts +3 -3
  257. package/src/notifications/adapters/telegram.ts +9 -2
  258. package/src/notifications/copy-composer.ts +49 -267
  259. package/src/notifications/decision-engine.ts +16 -35
  260. package/src/notifications/home-feed-side-effect.ts +1 -6
  261. package/src/plugin-api/constants.ts +4 -0
  262. package/src/plugin-api/index.ts +40 -11
  263. package/src/plugin-api/types.ts +108 -0
  264. package/src/plugins/defaults/compaction/compact.ts +106 -0
  265. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +250 -42
  266. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  267. package/src/plugins/defaults/compaction/manager-store.ts +57 -0
  268. package/src/plugins/defaults/compaction/package.json +1 -2
  269. package/src/{context → plugins/defaults/compaction}/window-manager.ts +95 -25
  270. package/src/plugins/defaults/empty-response/package.json +0 -1
  271. package/src/plugins/defaults/history-repair/package.json +0 -1
  272. package/src/plugins/defaults/index.ts +156 -26
  273. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
  274. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +285 -0
  275. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  276. package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
  277. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  278. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
  279. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +71 -0
  280. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  281. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  282. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +147 -134
  283. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +456 -0
  284. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +662 -0
  285. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  286. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +91 -0
  287. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  288. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  289. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  290. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +74 -19
  291. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  292. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +592 -0
  293. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -24
  294. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +91 -0
  295. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  296. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  297. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
  298. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
  299. package/src/plugins/defaults/memory-v3-shadow/injector.ts +76 -0
  300. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +555 -0
  301. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +266 -0
  302. package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
  303. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +71 -0
  304. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  305. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +16 -8
  306. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  307. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  308. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  309. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +156 -0
  310. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +382 -0
  311. package/src/plugins/defaults/memory-v3-shadow/types.ts +92 -0
  312. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +4 -1
  313. package/src/plugins/defaults/title-generate/package.json +0 -1
  314. package/src/plugins/defaults/tool-error/package.json +0 -1
  315. package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
  316. package/src/plugins/external-api.ts +12 -2
  317. package/src/plugins/pipeline.ts +6 -293
  318. package/src/plugins/registry.ts +9 -37
  319. package/src/plugins/types.ts +76 -381
  320. package/src/plugins/user-loader.ts +30 -127
  321. package/src/prompts/__tests__/system-prompt.test.ts +6 -0
  322. package/src/prompts/system-prompt.ts +61 -10
  323. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +71 -4
  324. package/src/providers/anthropic/client.ts +9 -10
  325. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  326. package/src/providers/inference/logit-bias.ts +55 -0
  327. package/src/providers/model-catalog.ts +34 -0
  328. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  329. package/src/providers/openai/chat-completions-provider.ts +44 -1
  330. package/src/providers/retry.ts +22 -0
  331. package/src/providers/types.ts +6 -0
  332. package/src/runtime/__tests__/agent-wake.test.ts +555 -691
  333. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  334. package/src/runtime/agent-wake.ts +108 -209
  335. package/src/runtime/assistant-event-hub.ts +1 -1
  336. package/src/runtime/channel-approvals.ts +1 -1
  337. package/src/runtime/interactive-ui.ts +1 -1
  338. package/src/runtime/routes/__tests__/acp-routes.test.ts +466 -55
  339. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  340. package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
  341. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  342. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
  343. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
  344. package/src/runtime/routes/acp-routes.test.ts +185 -32
  345. package/src/runtime/routes/acp-routes.ts +328 -30
  346. package/src/runtime/routes/app-management-routes.ts +3 -0
  347. package/src/runtime/routes/approval-routes.ts +1 -1
  348. package/src/runtime/routes/browser-routes.ts +1 -1
  349. package/src/runtime/routes/browser-tabs-routes.ts +6 -10
  350. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  351. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  352. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  353. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  354. package/src/runtime/routes/conversation-routes.ts +30 -2
  355. package/src/runtime/routes/conversation-starter-routes.ts +13 -7
  356. package/src/runtime/routes/conversations-import-routes.ts +24 -7
  357. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  358. package/src/runtime/routes/host-cu-routes.ts +1 -1
  359. package/src/runtime/routes/identity-routes.ts +18 -3
  360. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  361. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  362. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  363. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
  364. package/src/runtime/routes/memory-v3-routes.ts +65 -305
  365. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  366. package/src/runtime/routes/playground/helpers.ts +1 -1
  367. package/src/runtime/routes/plugins-routes.ts +337 -35
  368. package/src/runtime/routes/stt-routes.ts +45 -12
  369. package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
  370. package/src/runtime/routes/work-items-routes.ts +2 -4
  371. package/src/runtime/routes/workspace-routes.ts +50 -15
  372. package/src/runtime/services/conversation-serializer.ts +1 -1
  373. package/src/runtime/verification-outbound-actions.ts +147 -2
  374. package/src/runtime/verification-templates.ts +29 -3
  375. package/src/services/published-app-updater.ts +30 -8
  376. package/src/signals/cancel.ts +2 -4
  377. package/src/subagent/manager.ts +21 -5
  378. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  379. package/src/telemetry/activation-funnel.ts +167 -0
  380. package/src/telemetry/types.ts +13 -0
  381. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  382. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  383. package/src/tools/acp/context.ts +20 -0
  384. package/src/tools/acp/list-agents.test.ts +9 -19
  385. package/src/tools/acp/list-agents.ts +3 -15
  386. package/src/tools/acp/spawn.test.ts +173 -202
  387. package/src/tools/acp/spawn.ts +37 -172
  388. package/src/tools/acp/steer.test.ts +105 -8
  389. package/src/tools/acp/steer.ts +48 -17
  390. package/src/tools/apps/executors.ts +166 -50
  391. package/src/tools/browser/browser-execution.ts +12 -2
  392. package/src/tools/filesystem/write.ts +34 -0
  393. package/src/tools/network/web-fetch.ts +65 -24
  394. package/src/tools/skills/load.ts +1 -1
  395. package/src/tools/subagent/spawn.ts +2 -4
  396. package/src/tools/ui-surface/definitions.ts +32 -5
  397. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  398. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
  399. package/docs/plugins.md +0 -832
  400. package/examples/plugins/echo/register.ts +0 -143
  401. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
  402. package/src/__tests__/compaction-pipeline.test.ts +0 -210
  403. package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
  404. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
  405. package/src/__tests__/pipeline-runner.test.ts +0 -554
  406. package/src/daemon/wake-target-adapter.ts +0 -253
  407. package/src/memory/v3/__tests__/assign.test.ts +0 -242
  408. package/src/memory/v3/__tests__/capabilities.test.ts +0 -118
  409. package/src/memory/v3/__tests__/core.test.ts +0 -39
  410. package/src/memory/v3/__tests__/health.test.ts +0 -219
  411. package/src/memory/v3/__tests__/maintain-job.test.ts +0 -288
  412. package/src/memory/v3/__tests__/needle.test.ts +0 -107
  413. package/src/memory/v3/__tests__/orchestrate.test.ts +0 -436
  414. package/src/memory/v3/__tests__/provider-blocks.test.ts +0 -13
  415. package/src/memory/v3/__tests__/reconcile.test.ts +0 -274
  416. package/src/memory/v3/__tests__/render-injection.test.ts +0 -61
  417. package/src/memory/v3/__tests__/router.test.ts +0 -332
  418. package/src/memory/v3/__tests__/selector.test.ts +0 -470
  419. package/src/memory/v3/__tests__/shadow-plugin.test.ts +0 -432
  420. package/src/memory/v3/__tests__/snapshot.test.ts +0 -168
  421. package/src/memory/v3/__tests__/tree.test.ts +0 -192
  422. package/src/memory/v3/assign.ts +0 -268
  423. package/src/memory/v3/capabilities.ts +0 -124
  424. package/src/memory/v3/core.ts +0 -26
  425. package/src/memory/v3/health.ts +0 -0
  426. package/src/memory/v3/maintain-job.ts +0 -314
  427. package/src/memory/v3/needle.ts +0 -115
  428. package/src/memory/v3/orchestrate.ts +0 -126
  429. package/src/memory/v3/page-content.ts +0 -34
  430. package/src/memory/v3/provider-blocks.ts +0 -26
  431. package/src/memory/v3/reconcile.ts +0 -523
  432. package/src/memory/v3/router.ts +0 -190
  433. package/src/memory/v3/selection-log-store.ts +0 -84
  434. package/src/memory/v3/selector.ts +0 -226
  435. package/src/memory/v3/shadow-plugin.ts +0 -411
  436. package/src/memory/v3/snapshot.ts +0 -209
  437. package/src/memory/v3/tree.ts +0 -174
  438. package/src/memory/v3/types.ts +0 -59
  439. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  440. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
  441. package/src/plugins/defaults/circuit-breaker/package.json +0 -15
  442. package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
  443. package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
  444. package/src/plugins/defaults/compaction/register.ts +0 -35
  445. package/src/plugins/defaults/compaction/terminal.ts +0 -73
  446. package/src/plugins/defaults/empty-response/register.ts +0 -23
  447. package/src/plugins/defaults/history-repair/register.ts +0 -24
  448. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +0 -216
  449. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
  450. package/src/plugins/defaults/overflow-reduce/package.json +0 -15
  451. package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
  452. package/src/plugins/defaults/title-generate/register.ts +0 -35
  453. package/src/plugins/defaults/tool-error/register.ts +0 -23
  454. package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
  455. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  456. package/src/proactive-artifact/decision.test.ts +0 -226
  457. package/src/proactive-artifact/decision.ts +0 -165
  458. package/src/proactive-artifact/index.ts +0 -7
  459. package/src/proactive-artifact/job.test.ts +0 -962
  460. package/src/proactive-artifact/job.ts +0 -372
  461. package/src/proactive-artifact/message-copy.ts +0 -58
  462. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  463. package/src/proactive-artifact/trigger-state.ts +0 -123
  464. package/src/util/map-limit.ts +0 -27
  465. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
  466. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
  467. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
  468. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
  469. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
  470. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
  471. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
  472. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
  473. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
  474. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
  475. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Tests for {@link installPlugin}.
3
3
  *
4
- * Network is replaced with an in-memory fixture passed via the `fetch`
5
- * dependency — no globals are monkey-patched and no `--test-hook` exports
6
- * leak into production code.
4
+ * Two materialization paths are exercised without touching the network or
5
+ * spawning real subprocesses:
6
+ * - First-party plugins are fetched from the GitHub Contents API, replaced
7
+ * by an in-memory fixture passed via the `fetch` dependency.
8
+ * - External (whitelisted) plugins are shallow-cloned with `git`, replaced
9
+ * by a fake {@link GitRunner} that materializes a tree into the clone dir.
10
+ * No globals are monkey-patched and no `--test-hook` exports leak into
11
+ * production code.
7
12
  */
8
13
 
9
14
  import {
@@ -16,92 +21,98 @@ import {
16
21
  writeFileSync,
17
22
  } from "node:fs";
18
23
  import { tmpdir } from "node:os";
19
- import { join } from "node:path";
24
+ import { dirname, join } from "node:path";
20
25
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
26
 
22
27
  import {
23
28
  type FetchLike,
29
+ type GitRunner,
24
30
  installPlugin,
25
31
  InvalidPluginNameError,
26
32
  PluginAlreadyInstalledError,
27
33
  PluginNotFoundError,
34
+ PluginPostinstallError,
35
+ PluginSourceUnavailableError,
36
+ type PostinstallRunner,
28
37
  sanitizePluginName,
29
38
  } from "../install-from-github.js";
30
39
 
40
+ const CANON_REPO = "vellum-ai/vellum-assistant";
41
+ /** Synthetic host the fixtures use for Contents API `download_url`s. */
42
+ const DOWNLOAD_HOST = "https://files.test/";
43
+
31
44
  /**
32
- * Build a GitHub Contents API fixture from an in-memory file tree.
45
+ * Build a `fetch` that serves the GitHub Contents API from an in-memory tree.
33
46
  *
34
- * `tree` maps a path under the canonical prefix (e.g. `simple-memory`,
35
- * `simple-memory/hooks/init.ts`) to either:
36
- * - a `Uint8Array`/`string` → a file with that content
37
- * - `null` → a directory
47
+ * `tree` is keyed by full canonical-repo path (e.g.
48
+ * `experimental/plugins/simple-memory/package.json`) and maps each entry to a
49
+ * file's content (`string`/`Uint8Array`) or `null` for an explicit directory.
50
+ * Directory listings are derived from the key set; file `download_url`s point
51
+ * at {@link DOWNLOAD_HOST} and are served with the stored bytes.
38
52
  *
39
- * The fixture answers GET requests against
40
- * - `https://api.github.com/repos/vellum-ai/vellum-assistant/contents/...`
41
- * - any other URL we hand out as `download_url`
53
+ * The marketplace manifest lookup is answered with `manifest` (or a 404 when
54
+ * omitted, so resolution degrades to the first-party source).
42
55
  */
43
- function fixtureFetch(
44
- tree: Record<string, Uint8Array | string | null>,
45
- ): FetchLike {
46
- const PREFIX_API =
47
- "https://api.github.com/repos/vellum-ai/vellum-assistant/contents/experimental/plugins/";
48
- const PREFIX_RAW =
49
- "https://raw.githubusercontent.com/vellum-ai/vellum-assistant/main/experimental/plugins/";
50
-
51
- function listing(apiPath: string): unknown {
52
- const rel = apiPath.startsWith("experimental/plugins/")
53
- ? apiPath.slice("experimental/plugins/".length)
54
- : apiPath;
55
- const prefix = rel ? rel + "/" : "";
56
- const direct = new Map<string, "file" | "dir">();
56
+ function makeContentsFetch(opts: {
57
+ tree: Record<string, Uint8Array | string | null>;
58
+ manifest?: unknown;
59
+ /**
60
+ * HTTP status to answer the manifest fetch with. Defaults to 200 (when
61
+ * `manifest` is provided) or 404 (when omitted). Set to a transient status
62
+ * (e.g. 500) to simulate a marketplace lookup that fails rather than being
63
+ * absent — exercising the degrade-to-first-party path.
64
+ */
65
+ manifestStatus?: number;
66
+ }): FetchLike {
67
+ const MANIFEST_URL = `https://api.github.com/repos/${CANON_REPO}/contents/experimental/plugins/marketplace.json`;
68
+ const CONTENTS = `https://api.github.com/repos/${CANON_REPO}/contents/`;
69
+ const { tree } = opts;
70
+
71
+ function listing(apiPath: string):
72
+ | {
73
+ name: string;
74
+ path: string;
75
+ type: string;
76
+ download_url: string | null;
77
+ }[]
78
+ | null {
79
+ const prefix = apiPath ? `${apiPath}/` : "";
80
+ const direct = new Map<string, boolean>();
57
81
  for (const key of Object.keys(tree)) {
58
82
  if (!key.startsWith(prefix)) continue;
59
83
  const remainder = key.slice(prefix.length);
60
84
  if (!remainder) continue;
61
- const [head, ...rest] = remainder.split("/");
62
- if (rest.length === 0) {
63
- const isDir = tree[key] === null;
64
- if (!direct.has(head!)) direct.set(head!, isDir ? "dir" : "file");
65
- } else {
66
- if (!direct.has(head!)) direct.set(head!, "dir");
67
- }
85
+ const seg = remainder.split("/")[0]!;
86
+ const isDir = remainder.includes("/") || tree[`${prefix}${seg}`] === null;
87
+ direct.set(seg, (direct.get(seg) ?? false) || isDir);
68
88
  }
69
89
  if (direct.size === 0) return null;
70
- return Array.from(direct.entries()).map(([name, type]) => ({
71
- name,
72
- // GitHub returns `path` rooted at the repo, not relative to the queried
73
- // directory — mirror that so the recursive copy hits the same fixture
74
- // handler on the way down.
75
- path: `experimental/plugins/${prefix}${name}`,
76
- type,
77
- size: type === "file" ? (tree[`${prefix}${name}`] as string).length : 0,
78
- download_url:
79
- type === "file" ? `${PREFIX_RAW}${prefix}${name}` : null,
80
- }));
90
+ return Array.from(direct.entries()).map(([name, isDir]) => {
91
+ const path = `${prefix}${name}`;
92
+ return isDir
93
+ ? { name, path, type: "dir", download_url: null }
94
+ : { name, path, type: "file", download_url: `${DOWNLOAD_HOST}${path}` };
95
+ });
81
96
  }
82
97
 
83
- return (async (input: RequestInfo | URL, _init?: RequestInit) => {
98
+ return (async (input: RequestInfo | URL) => {
84
99
  const url = typeof input === "string" ? input : input.toString();
85
100
 
86
- if (url.startsWith(PREFIX_API)) {
87
- const after = url.slice(PREFIX_API.length).split("?")[0]!;
88
- const apiPath = `experimental/plugins/${decodeURIComponent(after)}`;
89
- const body = listing(apiPath);
90
- if (body === null) {
101
+ if (url.startsWith(MANIFEST_URL)) {
102
+ if (opts.manifestStatus !== undefined && opts.manifestStatus !== 200) {
103
+ return new Response("manifest unavailable", {
104
+ status: opts.manifestStatus,
105
+ });
106
+ }
107
+ if (opts.manifest === undefined) {
91
108
  return new Response("not found", { status: 404 });
92
109
  }
93
- return new Response(JSON.stringify(body), {
94
- status: 200,
95
- headers: { "content-type": "application/json" },
96
- });
110
+ return new Response(JSON.stringify(opts.manifest), { status: 200 });
97
111
  }
98
112
 
99
- if (url.startsWith(PREFIX_RAW)) {
100
- const key =
101
- "experimental/plugins/" +
102
- decodeURIComponent(url.slice(PREFIX_RAW.length));
103
- const rel = key.slice("experimental/plugins/".length);
104
- const file = tree[rel];
113
+ if (url.startsWith(DOWNLOAD_HOST)) {
114
+ const path = decodeURIComponent(url.slice(DOWNLOAD_HOST.length));
115
+ const file = tree[path];
105
116
  if (file === null || file === undefined) {
106
117
  return new Response("not found", { status: 404 });
107
118
  }
@@ -110,11 +121,109 @@ function fixtureFetch(
110
121
  return new Response(Buffer.from(bytes), { status: 200 });
111
122
  }
112
123
 
113
- return new Response("unexpected url: " + url, { status: 500 });
124
+ if (url.startsWith(CONTENTS)) {
125
+ const after = decodeURIComponent(
126
+ url.slice(CONTENTS.length).split("?")[0]!,
127
+ );
128
+ const body = listing(after);
129
+ if (body === null) {
130
+ return new Response("not found", { status: 404 });
131
+ }
132
+ return new Response(JSON.stringify(body), { status: 200 });
133
+ }
134
+
135
+ return new Response(`unexpected url: ${url}`, { status: 500 });
114
136
  }) as FetchLike;
115
137
  }
116
138
 
117
- describe("installPlugin", () => {
139
+ /** First-party fixture: keys are relative to `experimental/plugins/`. */
140
+ function fixtureFetch(
141
+ tree: Record<string, Uint8Array | string | null>,
142
+ ): FetchLike {
143
+ const full: Record<string, Uint8Array | string | null> = {};
144
+ for (const [key, value] of Object.entries(tree)) {
145
+ full[`experimental/plugins/${key}`] = value;
146
+ }
147
+ return makeContentsFetch({ tree: full });
148
+ }
149
+
150
+ /**
151
+ * Build a fake {@link GitRunner} that simulates a shallow clone.
152
+ *
153
+ * On `fetch`, the configured `tree` (repo-relative path → content, `null` =
154
+ * directory) plus a token `.git/config` are written into the clone `cwd`, so
155
+ * the subsequent copy can be asserted to include the tree and exclude `.git`.
156
+ * `rev-parse HEAD` returns `commit`. Optional hooks let a test record the git
157
+ * arguments or inject a fetch failure.
158
+ */
159
+ function fakeGitRunner(opts: {
160
+ tree: Record<string, string | null>;
161
+ commit?: string;
162
+ calls?: string[][];
163
+ fetchError?: Error;
164
+ }): GitRunner {
165
+ const commit = opts.commit ?? "abc123def4567890abc123def4567890abcdef12";
166
+ return async (args, { cwd }) => {
167
+ opts.calls?.push([...args]);
168
+ switch (args[0]) {
169
+ case "fetch": {
170
+ if (opts.fetchError) throw opts.fetchError;
171
+ mkdirSync(join(cwd, ".git"), { recursive: true });
172
+ writeFileSync(join(cwd, ".git", "config"), "[core]\n");
173
+ for (const [rel, content] of Object.entries(opts.tree)) {
174
+ if (content === null) {
175
+ mkdirSync(join(cwd, rel), { recursive: true });
176
+ continue;
177
+ }
178
+ const dest = join(cwd, rel);
179
+ mkdirSync(dirname(dest), { recursive: true });
180
+ writeFileSync(dest, content);
181
+ }
182
+ return { stdout: "" };
183
+ }
184
+ case "rev-parse":
185
+ return { stdout: `${commit}\n` };
186
+ default:
187
+ return { stdout: "" };
188
+ }
189
+ };
190
+ }
191
+
192
+ /** A git runner that fails the test if any git command is invoked. */
193
+ const unusedGitRunner: GitRunner = async (args) => {
194
+ throw new Error(
195
+ `git should not run for a first-party install: ${args.join(" ")}`,
196
+ );
197
+ };
198
+
199
+ /**
200
+ * Read the real, committed caveman adapter stub from the repo so the
201
+ * integration test exercises the adapter that ships rather than a fixture
202
+ * copy that could drift from it. Returns every stub file keyed by its
203
+ * Contents-API path (`experimental/plugins/caveman/<rel>`) so the fetch fake
204
+ * serves the whole stub — package.json, the adapter, and its templates — to
205
+ * the real postinstall runner. Resolves the stub relative to this test file.
206
+ */
207
+ function readRealCavemanStub(): Record<string, string> {
208
+ const repoRel = "experimental/plugins/caveman";
209
+ const stubDir = join(import.meta.dir, "../../../../../", repoRel);
210
+ const tree: Record<string, string> = {};
211
+ const walk = (relDir: string): void => {
212
+ const absDir = relDir ? join(stubDir, relDir) : stubDir;
213
+ for (const entry of readdirSync(absDir, { withFileTypes: true })) {
214
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
215
+ if (entry.isDirectory()) {
216
+ walk(rel);
217
+ continue;
218
+ }
219
+ tree[`${repoRel}/${rel}`] = readFileSync(join(stubDir, rel), "utf-8");
220
+ }
221
+ };
222
+ walk("");
223
+ return tree;
224
+ }
225
+
226
+ describe("installPlugin — first-party", () => {
118
227
  let ws: string;
119
228
  let pluginsDir: string;
120
229
 
@@ -128,7 +237,7 @@ describe("installPlugin", () => {
128
237
  rmSync(ws, { recursive: true, force: true });
129
238
  });
130
239
 
131
- test("copies the GitHub tree into <workspacePluginsDir>/<name>", async () => {
240
+ test("copies a first-party plugin into <workspacePluginsDir>/<name>", async () => {
132
241
  const result = await installPlugin(
133
242
  { name: "simple-memory", force: false, ref: "main" },
134
243
  {
@@ -149,6 +258,8 @@ describe("installPlugin", () => {
149
258
  expect(result.target).toBe(target);
150
259
  expect(result.fileCount).toBe(4);
151
260
  expect(result.ref).toBe("main");
261
+ // First-party installs have no clone, so no commit is resolved.
262
+ expect(result.commit).toBeNull();
152
263
  expect(existsSync(join(target, "package.json"))).toBe(true);
153
264
  expect(existsSync(join(target, "README.md"))).toBe(true);
154
265
  expect(existsSync(join(target, "hooks", "init.ts"))).toBe(true);
@@ -158,6 +269,33 @@ describe("installPlugin", () => {
158
269
  );
159
270
  });
160
271
 
272
+ test("writes a provenance manifest recording the source and ref", async () => {
273
+ // GIVEN a first-party install
274
+ const target = join(pluginsDir, "simple-memory");
275
+
276
+ // WHEN it completes
277
+ await installPlugin(
278
+ { name: "simple-memory", force: false, ref: "main" },
279
+ {
280
+ fetch: fixtureFetch({
281
+ "simple-memory/package.json": '{"name":"simple-memory"}',
282
+ }),
283
+ workspacePluginsDir: pluginsDir,
284
+ },
285
+ );
286
+
287
+ // THEN a hidden manifest records the resolved coordinates (no commit for
288
+ // first-party) and is not counted as a plugin file
289
+ const manifest = JSON.parse(
290
+ readFileSync(join(target, ".vellum-plugin.json"), "utf-8"),
291
+ );
292
+ expect(manifest.name).toBe("simple-memory");
293
+ expect(manifest.source.kind).toBe("first-party");
294
+ expect(manifest.source.repo).toBe("vellum-assistant");
295
+ expect(manifest.source.ref).toBe("main");
296
+ expect(manifest.commit).toBeUndefined();
297
+ });
298
+
161
299
  test("refuses to overwrite an existing install without --force", async () => {
162
300
  const target = join(pluginsDir, "simple-memory");
163
301
  mkdirSync(target, { recursive: true });
@@ -168,7 +306,6 @@ describe("installPlugin", () => {
168
306
  { name: "simple-memory", force: false, ref: "main" },
169
307
  {
170
308
  fetch: fixtureFetch({
171
- "simple-memory": null,
172
309
  "simple-memory/package.json": "{}",
173
310
  }),
174
311
  workspacePluginsDir: pluginsDir,
@@ -189,7 +326,6 @@ describe("installPlugin", () => {
189
326
  { name: "simple-memory", force: true, ref: "main" },
190
327
  {
191
328
  fetch: fixtureFetch({
192
- "simple-memory": null,
193
329
  "simple-memory/package.json": '{"name":"simple-memory"}',
194
330
  }),
195
331
  workspacePluginsDir: pluginsDir,
@@ -201,9 +337,9 @@ describe("installPlugin", () => {
201
337
  });
202
338
 
203
339
  test("--force preserves the existing install when the fetch fails", async () => {
204
- // Codex P1 from PR-5 review: a transient 5xx during a forced re-install
205
- // must NOT delete the previously working plugin. The fetch error
206
- // surfaces, but the existing tree on disk is untouched.
340
+ // A transient 5xx during a forced re-install must NOT delete the
341
+ // previously working plugin. The fetch error surfaces, but the existing
342
+ // tree on disk is untouched (all writes go through the staging dir).
207
343
  const target = join(pluginsDir, "simple-memory");
208
344
  mkdirSync(target, { recursive: true });
209
345
  writeFileSync(join(target, "marker"), "pre-existing");
@@ -219,12 +355,9 @@ describe("installPlugin", () => {
219
355
  ),
220
356
  ).rejects.toThrow(/HTTP 503/);
221
357
 
222
- // Marker is still there because the failed install never touched the
223
- // target — the staging dir handles all writes until the swap.
224
358
  expect(readFileSync(join(target, "marker"), "utf-8")).toBe("pre-existing");
225
359
  // And no staging dir leaks into the plugins directory.
226
- const remaining = readdirSync(pluginsDir);
227
- expect(remaining).toEqual(["simple-memory"]);
360
+ expect(readdirSync(pluginsDir)).toEqual(["simple-memory"]);
228
361
  });
229
362
 
230
363
  test("404 on the canonical path is reported as not-found", async () => {
@@ -259,63 +392,109 @@ describe("installPlugin", () => {
259
392
  expect(readdirSync(pluginsDir)).toEqual([]);
260
393
  });
261
394
 
262
- test("respects ref by forwarding to GitHub", async () => {
263
- let seenRef: string | undefined;
395
+ test("a rate-limited contents listing surfaces a retryable PluginSourceUnavailableError", async () => {
396
+ // GIVEN GitHub's unauthenticated rate limit is exhausted: the contents
397
+ // listing 403s with the remaining-quota header at zero
398
+ const rateLimited: FetchLike = (async (input: RequestInfo | URL) => {
399
+ const url = typeof input === "string" ? input : input.toString();
400
+ if (url.includes("marketplace.json")) {
401
+ return new Response("not found", { status: 404 });
402
+ }
403
+ if (url.includes("/contents/")) {
404
+ return new Response("rate limited", {
405
+ status: 403,
406
+ headers: { "x-ratelimit-remaining": "0" },
407
+ });
408
+ }
409
+ return new Response("not found", { status: 404 });
410
+ }) as FetchLike;
411
+
412
+ // WHEN we install
413
+ // THEN the failure is classified as transient (retryable), not a hard
414
+ // error, so the route can surface a 503 instead of a 500
415
+ await expect(
416
+ installPlugin(
417
+ { name: "demo", force: false, ref: "main" },
418
+ { fetch: rateLimited, workspacePluginsDir: pluginsDir },
419
+ ),
420
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
421
+
422
+ // AND no staging dir leaks behind on the transient failure.
423
+ expect(readdirSync(pluginsDir)).toEqual([]);
424
+ });
425
+
426
+ test("a forbidden contents listing with quota remaining stays a hard error", async () => {
427
+ // GIVEN a 403 that is NOT a rate-limit (quota header present and nonzero):
428
+ // a genuine authorization failure, not a transient one
429
+ const forbidden: FetchLike = (async (input: RequestInfo | URL) => {
430
+ const url = typeof input === "string" ? input : input.toString();
431
+ if (url.includes("marketplace.json")) {
432
+ return new Response("not found", { status: 404 });
433
+ }
434
+ if (url.includes("/contents/")) {
435
+ return new Response("forbidden", {
436
+ status: 403,
437
+ headers: { "x-ratelimit-remaining": "57" },
438
+ });
439
+ }
440
+ return new Response("not found", { status: 404 });
441
+ }) as FetchLike;
442
+
443
+ // WHEN we install
444
+ // THEN it surfaces as a hard error — NOT the retryable variant — so the
445
+ // route maps it to 500 rather than inviting an endless retry loop
446
+ await expect(
447
+ installPlugin(
448
+ { name: "demo", force: false, ref: "main" },
449
+ { fetch: forbidden, workspacePluginsDir: pluginsDir },
450
+ ),
451
+ ).rejects.not.toBeInstanceOf(PluginSourceUnavailableError);
452
+ expect(readdirSync(pluginsDir)).toEqual([]);
453
+ });
454
+
455
+ test("respects ref by forwarding it to the contents listing", async () => {
456
+ // The requested ref must reach the first-party contents listing request.
457
+ let listRef: string | undefined;
458
+ const base = fixtureFetch({ "demo/package.json": "{}" });
459
+ const fetch: FetchLike = async (input, init) => {
460
+ const url = typeof input === "string" ? input : input.toString();
461
+ const match = /\/contents\/experimental\/plugins\/demo\?ref=([^&]+)/.exec(
462
+ url,
463
+ );
464
+ if (match) listRef = decodeURIComponent(match[1]!);
465
+ return base(url, init);
466
+ };
467
+
264
468
  await installPlugin(
265
469
  { name: "demo", force: false, ref: "feat-branch" },
266
- {
267
- fetch: (async (input: RequestInfo | URL) => {
268
- const url = typeof input === "string" ? input : input.toString();
269
- if (url.includes("api.github.com")) {
270
- const m = /[?&]ref=([^&]+)/.exec(url);
271
- seenRef = m ? decodeURIComponent(m[1]!) : undefined;
272
- return new Response(
273
- JSON.stringify([
274
- {
275
- name: "package.json",
276
- path: "experimental/plugins/demo/package.json",
277
- type: "file",
278
- size: 2,
279
- download_url:
280
- "https://raw.githubusercontent.com/vellum-ai/vellum-assistant/feat-branch/experimental/plugins/demo/package.json",
281
- },
282
- ]),
283
- {
284
- status: 200,
285
- headers: { "content-type": "application/json" },
286
- },
287
- );
288
- }
289
- return new Response("{}", { status: 200 });
290
- }) as FetchLike,
291
- workspacePluginsDir: pluginsDir,
292
- },
470
+ { fetch, workspacePluginsDir: pluginsDir },
293
471
  );
294
472
 
295
- expect(seenRef).toBe("feat-branch");
473
+ expect(listRef).toBe("feat-branch");
296
474
  expect(existsSync(join(pluginsDir, "demo", "package.json"))).toBe(true);
297
475
  });
298
476
 
299
477
  test("rejects untrusted entry names from the GitHub response", async () => {
300
- // Devin P2 from PR-5 review: even though GitHub returns trustworthy data,
301
- // defense-in-depth requires us to validate `entry.name` before any
302
- // filesystem write. A malicious or buggy upstream that hands us
303
- // `../escape` must not be able to write outside the target.
478
+ // Even though GitHub returns trustworthy data, defense-in-depth requires
479
+ // us to validate every entry name before any filesystem write. A malicious
480
+ // or buggy upstream that hands us `../escape` must not write outside the
481
+ // target.
304
482
  const badFetch: FetchLike = (async (input: RequestInfo | URL) => {
305
483
  const url = typeof input === "string" ? input : input.toString();
306
- if (url.includes("api.github.com")) {
484
+ if (url.includes("marketplace.json")) {
485
+ return new Response("not found", { status: 404 });
486
+ }
487
+ if (url.includes("/contents/")) {
307
488
  return new Response(
308
489
  JSON.stringify([
309
490
  {
310
491
  name: "../escape",
311
492
  path: "experimental/plugins/demo/../escape",
312
493
  type: "file",
313
- size: 1,
314
- download_url:
315
- "https://raw.githubusercontent.com/vellum-ai/vellum-assistant/main/experimental/plugins/demo/escape",
494
+ download_url: `${DOWNLOAD_HOST}escape`,
316
495
  },
317
496
  ]),
318
- { status: 200, headers: { "content-type": "application/json" } },
497
+ { status: 200 },
319
498
  );
320
499
  }
321
500
  return new Response("x", { status: 200 });
@@ -335,6 +514,506 @@ describe("installPlugin", () => {
335
514
  });
336
515
  });
337
516
 
517
+ describe("installPlugin — marketplace resolution", () => {
518
+ let ws: string;
519
+ let pluginsDir: string;
520
+
521
+ beforeEach(() => {
522
+ ws = mkdtempSync(join(tmpdir(), "vellum-plugins-mkt-"));
523
+ pluginsDir = join(ws, "plugins");
524
+ mkdirSync(pluginsDir, { recursive: true });
525
+ });
526
+
527
+ afterEach(() => {
528
+ rmSync(ws, { recursive: true, force: true });
529
+ });
530
+
531
+ const CAVEMAN_MANIFEST = {
532
+ name: "vellum-assistant",
533
+ plugins: [
534
+ {
535
+ name: "caveman",
536
+ source: {
537
+ source: "github",
538
+ repo: "JuliusBrussee/caveman",
539
+ ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
540
+ },
541
+ description: "Ultra-compressed communication mode.",
542
+ },
543
+ ],
544
+ };
545
+
546
+ test("installs a whitelisted plugin by shallow-cloning its pinned repo + ref", async () => {
547
+ // GIVEN a marketplace whitelisting caveman at its repo root, pinned to a commit SHA
548
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
549
+ const calls: string[][] = [];
550
+ const runGit = fakeGitRunner({
551
+ tree: {
552
+ "package.json": '{"name":"caveman"}',
553
+ "README.md": "# caveman",
554
+ ".claude-plugin": null,
555
+ ".claude-plugin/plugin.json": "{}",
556
+ },
557
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
558
+ calls,
559
+ });
560
+
561
+ // WHEN we install by name (the install ref is ignored in favor of the
562
+ // manifest's pinned ref)
563
+ const result = await installPlugin(
564
+ { name: "caveman", ref: "main" },
565
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
566
+ );
567
+
568
+ // THEN the external tree is materialized under <pluginsDir>/caveman, the
569
+ // result reports the pinned ref and resolved commit, and `.git` is dropped
570
+ const target = join(pluginsDir, "caveman");
571
+ expect(result.target).toBe(target);
572
+ expect(result.ref).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
573
+ expect(result.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
574
+ expect(result.fileCount).toBe(3);
575
+ expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
576
+ '{"name":"caveman"}',
577
+ );
578
+ expect(existsSync(join(target, ".claude-plugin", "plugin.json"))).toBe(
579
+ true,
580
+ );
581
+ expect(existsSync(join(target, ".git"))).toBe(false);
582
+
583
+ // AND the clone fetched the pinned ref from the pinned repo URL.
584
+ const fetchCall = calls.find((c) => c[0] === "fetch");
585
+ expect(fetchCall).toContain("63a91ecadbf4c4719a4602a5abb00883f9966034");
586
+ const remoteCall = calls.find((c) => c[0] === "remote");
587
+ expect(remoteCall?.at(-1)).toBe(
588
+ "https://github.com/JuliusBrussee/caveman.git",
589
+ );
590
+
591
+ // AND a provenance manifest records the external source + commit.
592
+ const manifest = JSON.parse(
593
+ readFileSync(join(target, ".vellum-plugin.json"), "utf-8"),
594
+ );
595
+ expect(manifest.source.kind).toBe("external");
596
+ expect(manifest.source.owner).toBe("JuliusBrussee");
597
+ expect(manifest.source.ref).toBe(
598
+ "63a91ecadbf4c4719a4602a5abb00883f9966034",
599
+ );
600
+ expect(manifest.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
601
+ });
602
+
603
+ test("refuses to install when the checked-out commit differs from the pinned SHA", async () => {
604
+ // GIVEN a clone whose resolved HEAD does not match the manifest's pinned
605
+ // commit SHA — i.e. the upstream object served something unexpected
606
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
607
+ const runGit = fakeGitRunner({
608
+ tree: { "package.json": '{"name":"caveman"}' },
609
+ commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
610
+ });
611
+
612
+ // WHEN we install
613
+ // THEN the integrity check fails closed rather than materializing and
614
+ // later `import()`-ing code from an unexpected revision, and nothing lands
615
+ await expect(
616
+ installPlugin(
617
+ { name: "caveman", ref: "main" },
618
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
619
+ ),
620
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
621
+ expect(readdirSync(pluginsDir)).toEqual([]);
622
+ });
623
+
624
+ test("a missing remote ref surfaces a clean not-found", async () => {
625
+ // GIVEN a clone whose fetch fails because the ref doesn't exist
626
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
627
+ const runGit = fakeGitRunner({
628
+ tree: {},
629
+ fetchError: Object.assign(new Error("git fetch failed"), {
630
+ stderr:
631
+ "fatal: couldn't find remote ref 63a91ecadbf4c4719a4602a5abb00883f9966034",
632
+ }),
633
+ });
634
+
635
+ // WHEN we install
636
+ // THEN it is a hard not-found, not a retryable error
637
+ await expect(
638
+ installPlugin(
639
+ { name: "caveman", ref: "main" },
640
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
641
+ ),
642
+ ).rejects.toBeInstanceOf(PluginNotFoundError);
643
+ expect(readdirSync(pluginsDir)).toEqual([]);
644
+ });
645
+
646
+ test("a network failure during clone surfaces a retryable error", async () => {
647
+ // GIVEN a clone whose fetch fails with a transient network error
648
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
649
+ const runGit = fakeGitRunner({
650
+ tree: {},
651
+ fetchError: Object.assign(new Error("git fetch failed"), {
652
+ stderr: "fatal: unable to access: Could not resolve host: github.com",
653
+ }),
654
+ });
655
+
656
+ // WHEN we install
657
+ // THEN it surfaces as the retryable variant so the route maps it to 503
658
+ await expect(
659
+ installPlugin(
660
+ { name: "caveman", ref: "main" },
661
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
662
+ ),
663
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
664
+ expect(readdirSync(pluginsDir)).toEqual([]);
665
+ });
666
+
667
+ test("a name absent from the manifest falls back to the first-party source", async () => {
668
+ // GIVEN a manifest that does NOT whitelist "simple-memory", and no such
669
+ // first-party tree in the canonical repo
670
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
671
+
672
+ // WHEN we install a first-party name
673
+ // THEN resolution falls back to the first-party path and surfaces a clean
674
+ // not-found pointing at the first-party source (git is never invoked)
675
+ await expect(
676
+ installPlugin(
677
+ { name: "simple-memory", ref: "main" },
678
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
679
+ ),
680
+ ).rejects.toMatchObject({
681
+ constructor: PluginNotFoundError,
682
+ message: expect.stringContaining("vellum-ai/vellum-assistant"),
683
+ });
684
+ });
685
+
686
+ test("overlays a curated adapter stub and runs its postinstall transform", async () => {
687
+ // GIVEN caveman is whitelisted externally
688
+ // AND we curate an adapter stub for it at experimental/plugins/caveman —
689
+ // the real, committed stub (package.json + postinstall.ts + templates), so
690
+ // this test exercises the adapter that ships, not a fixture copy of it
691
+ const fetch = makeContentsFetch({
692
+ tree: readRealCavemanStub(),
693
+ manifest: CAVEMAN_MANIFEST,
694
+ });
695
+ // AND the upstream clone is a Claude Code plugin: a name-mismatched
696
+ // package.json, the `.claude-plugin/plugin.json` manifest, and the
697
+ // terse-mode ruleset in skills/caveman/SKILL.md
698
+ const runGit = fakeGitRunner({
699
+ tree: {
700
+ "package.json": JSON.stringify({
701
+ name: "caveman-installer",
702
+ version: "0.1.0",
703
+ description: "Caveman installer.",
704
+ license: "MIT",
705
+ }),
706
+ ".claude-plugin/plugin.json": JSON.stringify({
707
+ name: "caveman",
708
+ description: "Ultra-compressed communication mode.",
709
+ }),
710
+ "skills/caveman/SKILL.md":
711
+ "---\nname: caveman\ndescription: terse mode\n---\n\nCAVEMAN MODE. Drop filler words. Keep technical substance.",
712
+ },
713
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
714
+ });
715
+
716
+ // WHEN we install (real postinstall runner — no injected stub)
717
+ const result = await installPlugin(
718
+ { name: "caveman", ref: "main" },
719
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
720
+ );
721
+
722
+ // THEN the materialized tree is a valid Vellum plugin: package.json `name`
723
+ // matches the directory and declares the @vellumai/plugin-api peer dep
724
+ // (fixing both loader warnings), and the upstream installer name is gone
725
+ const target = join(pluginsDir, "caveman");
726
+ expect(result.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
727
+ const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8"));
728
+ expect(pkg.name).toBe("caveman");
729
+ expect(pkg.peerDependencies["@vellumai/plugin-api"]).toBeString();
730
+ expect(pkg.scripts?.postinstall).toBeUndefined();
731
+ // AND every other upstream manifest field is preserved verbatim — only
732
+ // `name` and the peer dep are touched, so the installed plugin reports the
733
+ // upstream `version` (and description, license, …) rather than the stub's
734
+ expect(pkg.version).toBe("0.1.0");
735
+ expect(pkg.description).toBe("Caveman installer.");
736
+ expect(pkg.license).toBe("MIT");
737
+
738
+ // AND a pre-model-call hook is synthesized carrying the upstream ruleset,
739
+ // so caveman actually runs (terse mode injected on the user-facing call)
740
+ const hook = readFileSync(
741
+ join(target, "hooks", "pre-model-call.ts"),
742
+ "utf-8",
743
+ );
744
+ expect(hook).toContain("PreModelCallContext");
745
+ expect(hook).toContain('ctx.callSite !== "mainAgent"');
746
+ expect(hook).toContain("CAVEMAN MODE. Drop filler words.");
747
+ // AND the ruleset is sourced verbatim — the YAML frontmatter is stripped
748
+ expect(hook).not.toContain("description: terse mode");
749
+ });
750
+
751
+ test("falls back to the stub manifest when the upstream ships no package.json", async () => {
752
+ // GIVEN caveman is whitelisted with the real curated adapter stub
753
+ const fetch = makeContentsFetch({
754
+ tree: readRealCavemanStub(),
755
+ manifest: CAVEMAN_MANIFEST,
756
+ });
757
+ // AND the upstream clone has NO package.json — only the Claude Code
758
+ // manifest and the terse-mode ruleset the adapter reads
759
+ const runGit = fakeGitRunner({
760
+ tree: {
761
+ ".claude-plugin/plugin.json": JSON.stringify({ name: "caveman" }),
762
+ "skills/caveman/SKILL.md":
763
+ "---\nname: caveman\n---\n\nCAVEMAN MODE. Drop filler words.",
764
+ },
765
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
766
+ });
767
+
768
+ // WHEN we install (real postinstall runner)
769
+ await installPlugin(
770
+ { name: "caveman", ref: "main" },
771
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
772
+ );
773
+
774
+ // THEN the overlaid stub is the only manifest available, so it becomes the
775
+ // base: the loader still gets a matching `name` and the peer dep, and the
776
+ // spent stub `postinstall` is dropped so no install machinery is shipped
777
+ const pkg = JSON.parse(
778
+ readFileSync(join(pluginsDir, "caveman", "package.json"), "utf-8"),
779
+ );
780
+ expect(pkg.name).toBe("caveman");
781
+ expect(pkg.peerDependencies["@vellumai/plugin-api"]).toBeString();
782
+ expect(pkg.scripts?.postinstall).toBeUndefined();
783
+ });
784
+
785
+ test("strips a clone-supplied bunfig.toml so its preload can't run before the adapter", async () => {
786
+ // GIVEN caveman is whitelisted with the real, committed adapter stub
787
+ const fetch = makeContentsFetch({
788
+ tree: readRealCavemanStub(),
789
+ manifest: CAVEMAN_MANIFEST,
790
+ });
791
+ // AND the upstream clone is a valid Claude Code plugin that ALSO smuggles a
792
+ // `bunfig.toml` whose `preload` points at attacker code in the clone. Bun
793
+ // loads `$cwd/bunfig.toml` and runs `preload` before the entry point, so an
794
+ // un-stripped config would execute `evil.ts` (writing a `pwned` sentinel)
795
+ // ahead of the curated adapter — arbitrary code execution.
796
+ const runGit = fakeGitRunner({
797
+ tree: {
798
+ "package.json": '{"name":"caveman-installer"}',
799
+ ".claude-plugin/plugin.json": JSON.stringify({
800
+ name: "caveman",
801
+ description: "Ultra-compressed communication mode.",
802
+ }),
803
+ "skills/caveman/SKILL.md":
804
+ "---\nname: caveman\ndescription: terse mode\n---\n\nCAVEMAN MODE. Drop filler words. Keep technical substance.",
805
+ "bunfig.toml": 'preload = ["./evil.ts"]\n',
806
+ "evil.ts":
807
+ 'import { writeFileSync } from "node:fs";\nwriteFileSync("pwned", "pwned");\n',
808
+ },
809
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
810
+ });
811
+
812
+ // WHEN we install with the real postinstall runner (real `bun` subprocess)
813
+ const result = await installPlugin(
814
+ { name: "caveman", ref: "main" },
815
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
816
+ );
817
+
818
+ // THEN the adapter still ran and produced a valid Vellum plugin
819
+ const target = join(pluginsDir, "caveman");
820
+ expect(result.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
821
+ expect(
822
+ readFileSync(join(target, "hooks", "pre-model-call.ts"), "utf-8"),
823
+ ).toContain("CAVEMAN MODE. Drop filler words.");
824
+ // AND the upstream bunfig.toml was dropped before `bun` ran, so its preload
825
+ // never fired — no sentinel was written — and the config never persists in
826
+ // the installed plugin
827
+ expect(existsSync(join(target, "pwned"))).toBe(false);
828
+ expect(existsSync(join(target, "bunfig.toml"))).toBe(false);
829
+ });
830
+
831
+ test("drops a clone-supplied bunfig.toml regardless of filename case", async () => {
832
+ // GIVEN a raw external clone (no adapter stub) that smuggles a Bun config
833
+ // under an uppercase name. The macOS install target is case-insensitive, so
834
+ // Bun would still open a `BUNFIG.TOML`; a case-sensitive skip would miss it.
835
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
836
+ const runGit = fakeGitRunner({
837
+ tree: {
838
+ "package.json": '{"name":"caveman"}',
839
+ "BUNFIG.TOML": 'preload = ["./evil.ts"]\n',
840
+ },
841
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
842
+ });
843
+
844
+ // WHEN we install
845
+ await installPlugin(
846
+ { name: "caveman", ref: "main" },
847
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
848
+ );
849
+
850
+ // THEN the uppercase Bun config was dropped while the plugin's own files
851
+ // were materialized
852
+ const target = join(pluginsDir, "caveman");
853
+ expect(existsSync(join(target, "BUNFIG.TOML"))).toBe(false);
854
+ expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
855
+ '{"name":"caveman"}',
856
+ );
857
+ });
858
+
859
+ test("stages the clone outside the served plugins/ directory", async () => {
860
+ // GIVEN caveman is whitelisted externally (raw clone, no adapter stub)
861
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
862
+ // AND a git runner that records the working directory it clones into
863
+ const cloneCwds: string[] = [];
864
+ const runGit: GitRunner = async (args, { cwd }) => {
865
+ if (args[0] === "fetch") {
866
+ cloneCwds.push(cwd);
867
+ mkdirSync(join(cwd, ".git"), { recursive: true });
868
+ writeFileSync(join(cwd, "package.json"), '{"name":"caveman"}');
869
+ return { stdout: "" };
870
+ }
871
+ if (args[0] === "rev-parse") {
872
+ return { stdout: "63a91ecadbf4c4719a4602a5abb00883f9966034\n" };
873
+ }
874
+ return { stdout: "" };
875
+ };
876
+
877
+ // WHEN we install
878
+ const result = await installPlugin(
879
+ { name: "caveman", ref: "main" },
880
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
881
+ );
882
+
883
+ // THEN the half-built clone was staged in a sibling directory, never inside
884
+ // the served plugins/ tree — so the daemon's source watcher and startup
885
+ // loader can't observe the un-adapted clone (wrong name, no peer dep) and
886
+ // log spurious name-mismatch / missing-peer-dependency warnings mid-install
887
+ expect(cloneCwds).toHaveLength(1);
888
+ expect(dirname(cloneCwds[0]!)).toBe(
889
+ join(dirname(pluginsDir), ".plugins-staging"),
890
+ );
891
+
892
+ // AND the finished plugin still lands inside plugins/, with no staging
893
+ // artifact left behind in the served directory
894
+ expect(result.target).toBe(join(pluginsDir, "caveman"));
895
+ expect(readdirSync(pluginsDir)).toEqual(["caveman"]);
896
+ });
897
+
898
+ test("does not run a postinstall for a raw clone without an adapter stub", async () => {
899
+ // GIVEN caveman is whitelisted but we curate NO adapter stub for it
900
+ // (the Contents API has no experimental/plugins/caveman directory)
901
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
902
+ const runGit = fakeGitRunner({
903
+ tree: { "package.json": '{"name":"caveman"}', "hooks/init.ts": "//" },
904
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
905
+ });
906
+ // AND a postinstall runner that fails the test if it is ever invoked
907
+ const runPostinstall: PostinstallRunner = async () => {
908
+ throw new Error("postinstall must not run for a stubless raw clone");
909
+ };
910
+
911
+ // WHEN we install
912
+ await installPlugin(
913
+ { name: "caveman", ref: "main" },
914
+ { fetch, runGit, runPostinstall, workspacePluginsDir: pluginsDir },
915
+ );
916
+
917
+ // THEN the clone is installed verbatim — no transform ran
918
+ const target = join(pluginsDir, "caveman");
919
+ expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
920
+ '{"name":"caveman"}',
921
+ );
922
+ });
923
+
924
+ test("aborts and rolls back when the adapter's postinstall fails", async () => {
925
+ // GIVEN a whitelisted plugin with a curated stub declaring a postinstall
926
+ const fetch = makeContentsFetch({
927
+ tree: {
928
+ "experimental/plugins/caveman/package.json": JSON.stringify({
929
+ name: "caveman",
930
+ scripts: { postinstall: "bun ./postinstall.ts" },
931
+ }),
932
+ "experimental/plugins/caveman/postinstall.ts": "// adapter",
933
+ },
934
+ manifest: CAVEMAN_MANIFEST,
935
+ });
936
+ const runGit = fakeGitRunner({
937
+ tree: { "package.json": '{"name":"caveman-installer"}' },
938
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
939
+ });
940
+ // AND its adapter exits non-zero
941
+ const runPostinstall: PostinstallRunner = async () => {
942
+ throw new Error("adapter blew up");
943
+ };
944
+
945
+ // WHEN we install
946
+ // THEN it surfaces a PluginPostinstallError and nothing is materialized —
947
+ // better to fail loudly than ship a half-transformed plugin
948
+ await expect(
949
+ installPlugin(
950
+ { name: "caveman", ref: "main" },
951
+ { fetch, runGit, runPostinstall, workspacePluginsDir: pluginsDir },
952
+ ),
953
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
954
+ expect(readdirSync(pluginsDir)).toEqual([]);
955
+ });
956
+
957
+ test("rejects a stub whose postinstall is not a single bun invocation", async () => {
958
+ // GIVEN a curated stub whose postinstall is an arbitrary shell command
959
+ // rather than the supported `bun <script>` adapter convention
960
+ const fetch = makeContentsFetch({
961
+ tree: {
962
+ "experimental/plugins/caveman/package.json": JSON.stringify({
963
+ name: "caveman",
964
+ scripts: { postinstall: "rm -rf /" },
965
+ }),
966
+ },
967
+ manifest: CAVEMAN_MANIFEST,
968
+ });
969
+ const runGit = fakeGitRunner({
970
+ tree: { "package.json": '{"name":"caveman-installer"}' },
971
+ commit: "63a91ecadbf4c4719a4602a5abb00883f9966034",
972
+ });
973
+
974
+ // WHEN we install (the default runner is never reached — resolution rejects
975
+ // the command before anything executes)
976
+ // THEN it surfaces a PluginPostinstallError and nothing is materialized
977
+ await expect(
978
+ installPlugin(
979
+ { name: "caveman", ref: "main" },
980
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
981
+ ),
982
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
983
+ expect(readdirSync(pluginsDir)).toEqual([]);
984
+ });
985
+
986
+ test("refuses to install an adapter stub as first-party when the marketplace lookup fails", async () => {
987
+ // GIVEN the marketplace lookup fails transiently (e.g. rate-limit / 5xx)
988
+ // rather than being absent, so resolution degrades to the first-party path
989
+ // AND the same-named in-repo directory is an adapter stub (a package.json
990
+ // with a postinstall + adapter script, but no hooks/tools of its own)
991
+ const fetch = makeContentsFetch({
992
+ tree: {
993
+ "experimental/plugins/caveman/package.json": JSON.stringify({
994
+ name: "caveman",
995
+ scripts: { postinstall: "bun ./postinstall.ts" },
996
+ }),
997
+ "experimental/plugins/caveman/postinstall.ts": "// adapter",
998
+ },
999
+ manifestStatus: 500,
1000
+ });
1001
+
1002
+ // WHEN we install the name (git never runs — there is no external source to
1003
+ // clone because the marketplace that would have named it was unreadable)
1004
+ // THEN it fails loudly with a PluginPostinstallError instead of
1005
+ // materializing the bare stub as a non-functional standalone plugin, and
1006
+ // nothing is left behind on disk
1007
+ await expect(
1008
+ installPlugin(
1009
+ { name: "caveman", ref: "main" },
1010
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
1011
+ ),
1012
+ ).rejects.toBeInstanceOf(PluginPostinstallError);
1013
+ expect(readdirSync(pluginsDir)).toEqual([]);
1014
+ });
1015
+ });
1016
+
338
1017
  describe("sanitizePluginName", () => {
339
1018
  test.each([
340
1019
  ["../escape"],