@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.
- package/ARCHITECTURE.md +6 -6
- package/bun.lock +2 -2
- package/docs/activation-funnel-telemetry.md +310 -0
- package/examples/plugins/echo/README.md +61 -60
- package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
- package/examples/plugins/echo/hooks/stop.ts +16 -0
- package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
- package/examples/plugins/echo/package.json +1 -2
- package/examples/plugins/echo/src/emit.ts +19 -0
- package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
- package/openapi.yaml +263 -121
- package/package.json +2 -2
- package/src/__tests__/activation-early-marking.test.ts +120 -0
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
- package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
- package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
- package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
- package/src/__tests__/agent-loop-thinking.test.ts +36 -20
- package/src/__tests__/agent-loop.test.ts +441 -96
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
- package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
- package/src/__tests__/anthropic-provider.test.ts +23 -8
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-control-flow.test.ts +1 -1
- package/src/__tests__/app-dir-path-guard.test.ts +1 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/approval-cascade.test.ts +6 -5
- package/src/__tests__/approval-routes-http.test.ts +4 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
- package/src/__tests__/channel-approval-routes.test.ts +1 -1
- package/src/__tests__/channel-approvals.test.ts +1 -1
- package/src/__tests__/compaction-circuit.test.ts +258 -0
- package/src/__tests__/compaction-direct.test.ts +146 -0
- package/src/__tests__/compaction-events.test.ts +7 -7
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +265 -125
- package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
- package/src/__tests__/conversation-abort-tool-results.test.ts +7 -5
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +158 -234
- package/src/__tests__/conversation-agent-loop.test.ts +342 -623
- package/src/__tests__/conversation-clean-command.test.ts +5 -2
- package/src/__tests__/conversation-confirmation-signals.test.ts +6 -5
- package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-history-web-search.test.ts +6 -1
- package/src/__tests__/conversation-media-retry.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +44 -4
- package/src/__tests__/conversation-process-callsite.test.ts +15 -15
- package/src/__tests__/conversation-provider-retry-repair.test.ts +66 -65
- package/src/__tests__/conversation-queue.test.ts +10 -10
- package/src/__tests__/conversation-runtime-assembly.test.ts +1059 -231
- package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
- package/src/__tests__/conversation-slash-queue.test.ts +7 -5
- package/src/__tests__/conversation-slash-unknown.test.ts +7 -5
- package/src/__tests__/conversation-speed-override.test.ts +11 -10
- package/src/__tests__/conversation-starter-routes.test.ts +14 -6
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +48 -1
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
- package/src/__tests__/conversation-undo.test.ts +2 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +24 -21
- package/src/__tests__/conversation-workspace-injection.test.ts +69 -7
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +15 -12
- package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
- package/src/__tests__/corrected-target.test.ts +93 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/cu-unified-flow.test.ts +36 -0
- package/src/__tests__/db-acp-history.test.ts +101 -0
- package/src/__tests__/dynamic-page-surface.test.ts +31 -0
- package/src/__tests__/empty-response-hook.test.ts +1 -1
- package/src/__tests__/file-write-tool.test.ts +63 -0
- package/src/__tests__/gateway-only-guard.test.ts +17 -2
- package/src/__tests__/guardian-grant-minting.test.ts +1 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +93 -4
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
- package/src/__tests__/heartbeat-service.test.ts +1 -0
- package/src/__tests__/history-repair-hook.test.ts +5 -1
- package/src/__tests__/host-app-control-proxy.test.ts +45 -0
- package/src/__tests__/host-app-control-routes.test.ts +1 -1
- package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
- package/src/__tests__/inference-profile-reaper.test.ts +62 -0
- package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
- package/src/__tests__/injector-background-turn.test.ts +13 -23
- package/src/__tests__/injector-chain.test.ts +268 -44
- package/src/__tests__/injector-disk-pressure.test.ts +210 -52
- package/src/__tests__/injector-document-comments.test.ts +97 -114
- package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
- package/src/__tests__/injector-v3-suppression.test.ts +4 -4
- package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
- package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
- package/src/__tests__/llm-resolver.test.ts +73 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +156 -38
- package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
- package/src/__tests__/notification-decision-strategy.test.ts +3 -3
- package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
- package/src/__tests__/persist-unsendable-image.test.ts +97 -1
- package/src/__tests__/plugin-api-shim.test.ts +6 -9
- package/src/__tests__/plugin-bootstrap.test.ts +91 -81
- package/src/__tests__/plugin-registry.test.ts +3 -49
- package/src/__tests__/plugin-skill-contribution.test.ts +15 -14
- package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
- package/src/__tests__/plugin-types.test.ts +0 -70
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/__tests__/published-app-updater.test.ts +138 -0
- package/src/__tests__/reaction-persistence.test.ts +1 -1
- package/src/__tests__/send-endpoint-busy.test.ts +4 -1
- package/src/__tests__/skill-feature-flags-integration.test.ts +31 -0
- package/src/__tests__/steer-tool-repair.test.ts +1 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
- package/src/__tests__/subagent-detail.test.ts +25 -7
- package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
- package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
- package/src/__tests__/subagent-manager-notify.test.ts +1 -3
- package/src/__tests__/subagent-notify-parent.test.ts +1 -3
- package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
- package/src/__tests__/title-generate-hook.test.ts +5 -1
- package/src/__tests__/tool-error-hook.test.ts +1 -1
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
- package/src/__tests__/user-plugin-loader.test.ts +54 -286
- package/src/__tests__/web-fetch.test.ts +45 -0
- package/src/acp/__tests__/agent-process.test.ts +161 -0
- package/src/acp/__tests__/client-handler.test.ts +40 -0
- package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
- package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
- package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
- package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
- package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
- package/src/acp/agent-process.ts +61 -1
- package/src/acp/auto-install.test.ts +280 -0
- package/src/acp/auto-install.ts +232 -0
- package/src/acp/client-handler.ts +31 -0
- package/src/acp/prepare-agent-env.ts +80 -27
- package/src/acp/resolve-agent.test.ts +188 -28
- package/src/acp/resolve-agent.ts +119 -42
- package/src/acp/resume-hint.ts +23 -0
- package/src/acp/session-manager.ts +507 -73
- package/src/agent/compaction-circuit.ts +60 -102
- package/src/agent/loop.ts +403 -251
- package/src/api/responses/conversation-message.ts +22 -2
- package/src/api/responses/memory-v3-selection-log.ts +19 -10
- package/src/approvals/guardian-request-resolvers.ts +27 -1
- package/src/background-wake/next-wake.ts +1 -0
- package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
- package/src/cli/commands/channel-verification-sessions.ts +6 -6
- package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
- package/src/cli/commands/memory-v3.ts +57 -199
- package/src/cli/commands/plugins.ts +43 -37
- package/src/cli/lib/__tests__/install-from-github.test.ts +790 -111
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +381 -0
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +270 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +233 -32
- package/src/cli/lib/install-from-github.ts +797 -60
- package/src/cli/lib/plugin-catalog-cache.ts +84 -0
- package/src/cli/lib/plugin-details.ts +416 -0
- package/src/cli/lib/plugin-marketplace.ts +215 -0
- package/src/cli/lib/search-plugins.ts +201 -29
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
- package/src/config/acp-defaults.test.ts +10 -0
- package/src/config/acp-defaults.ts +9 -3
- package/src/config/acp-schema.ts +1 -7
- package/src/config/bundled-skills/acp/SKILL.md +80 -41
- package/src/config/bundled-skills/acp/TOOLS.json +4 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
- package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
- package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
- package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
- package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
- package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
- package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
- package/src/config/bundled-tool-registry.ts +4 -0
- package/src/config/call-site-defaults.ts +0 -3
- package/src/config/feature-flag-registry.json +10 -16
- package/src/config/llm-resolver.ts +39 -7
- package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
- package/src/config/schemas/call-site-catalog.ts +0 -21
- package/src/config/schemas/heartbeat.ts +9 -0
- package/src/config/schemas/llm.ts +17 -3
- package/src/config/schemas/memory-v3.ts +58 -8
- package/src/config/seed-inference-profiles.ts +18 -0
- package/src/context/compactor.ts +22 -4
- package/src/context/post-turn-tool-result-truncation.ts +39 -1
- package/src/context/strip-injections.ts +8 -2
- package/src/daemon/conversation-agent-loop-handlers.ts +18 -36
- package/src/daemon/conversation-agent-loop.ts +308 -1297
- package/src/daemon/conversation-error.ts +31 -4
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-lifecycle.ts +11 -255
- package/src/daemon/conversation-media-retry.ts +19 -6
- package/src/daemon/conversation-messaging.ts +17 -0
- package/src/daemon/conversation-process.ts +22 -141
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-registry.ts +159 -0
- package/src/daemon/conversation-runtime-assembly.ts +361 -393
- package/src/daemon/conversation-store.ts +9 -90
- package/src/daemon/conversation-surfaces.ts +165 -11
- package/src/daemon/conversation-workspace.ts +17 -0
- package/src/daemon/conversation.ts +444 -62
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/external-plugins-bootstrap.ts +36 -46
- package/src/daemon/handlers/config-channels.ts +11 -2
- package/src/daemon/handlers/conversations.ts +3 -1
- package/src/daemon/handlers/skills.ts +4 -1
- package/src/daemon/host-app-control-proxy.ts +72 -57
- package/src/daemon/host-proxy-preactivation.ts +1 -3
- package/src/daemon/lifecycle.ts +21 -7
- package/src/daemon/persist-unsendable-image.ts +62 -25
- package/src/daemon/process-message.ts +1 -1
- package/src/daemon/server.ts +2 -0
- package/src/daemon/tool-side-effects.ts +15 -0
- package/src/daemon/wake-conversation-ops.ts +269 -0
- package/src/embedded/plugin-api.ts +2 -2
- package/src/export/__tests__/transcript-formatter.test.ts +5 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
- package/src/heartbeat/heartbeat-run-store.ts +23 -1
- package/src/heartbeat/heartbeat-service.ts +26 -0
- package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
- package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
- package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
- package/src/ipc/skill-routes/memory.ts +4 -2
- package/src/memory/__tests__/activation-session-store.test.ts +41 -0
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
- package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
- package/src/memory/activation-session-store.ts +43 -0
- package/src/memory/conversation-crud.ts +29 -19
- package/src/memory/conversation-starter-checkpoints.ts +1 -0
- package/src/memory/db-init.ts +6 -0
- package/src/memory/job-handlers/conversation-starters.ts +13 -2
- package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
- package/src/memory/jobs-worker.ts +25 -1
- package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
- package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
- package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/onboarding-events-store.ts +66 -18
- package/src/memory/schema/acp.ts +4 -0
- package/src/memory/schema/infrastructure.ts +13 -0
- package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
- package/src/memory/v2/__tests__/page-store.test.ts +22 -0
- package/src/memory/v2/consolidation-job.ts +2 -63
- package/src/memory/v2/types.ts +5 -0
- package/src/messaging/providers/telegram-bot/api.ts +14 -5
- package/src/notifications/__tests__/copy-composer.test.ts +244 -0
- package/src/notifications/access-request-copy.ts +298 -0
- package/src/notifications/adapters/slack.ts +3 -3
- package/src/notifications/adapters/telegram.ts +9 -2
- package/src/notifications/copy-composer.ts +49 -267
- package/src/notifications/decision-engine.ts +16 -35
- package/src/notifications/home-feed-side-effect.ts +1 -6
- package/src/plugin-api/constants.ts +4 -0
- package/src/plugin-api/index.ts +40 -11
- package/src/plugin-api/types.ts +108 -0
- package/src/plugins/defaults/compaction/compact.ts +106 -0
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +250 -42
- package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
- package/src/plugins/defaults/compaction/manager-store.ts +57 -0
- package/src/plugins/defaults/compaction/package.json +1 -2
- package/src/{context → plugins/defaults/compaction}/window-manager.ts +95 -25
- package/src/plugins/defaults/empty-response/package.json +0 -1
- package/src/plugins/defaults/history-repair/package.json +0 -1
- package/src/plugins/defaults/index.ts +156 -26
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +285 -0
- package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
- package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
- package/src/plugins/defaults/memory-retrieval/package.json +14 -0
- package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +71 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +147 -134
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +456 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +662 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +91 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +74 -19
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +592 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -24
- package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +91 -0
- package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
- package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
- package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
- package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +76 -0
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +555 -0
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +266 -0
- package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
- package/src/plugins/defaults/memory-v3-shadow/page-content.ts +71 -0
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +16 -8
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
- package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
- package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
- package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +156 -0
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +382 -0
- package/src/plugins/defaults/memory-v3-shadow/types.ts +92 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +4 -1
- package/src/plugins/defaults/title-generate/package.json +0 -1
- package/src/plugins/defaults/tool-error/package.json +0 -1
- package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
- package/src/plugins/external-api.ts +12 -2
- package/src/plugins/pipeline.ts +6 -293
- package/src/plugins/registry.ts +9 -37
- package/src/plugins/types.ts +76 -381
- package/src/plugins/user-loader.ts +30 -127
- package/src/prompts/__tests__/system-prompt.test.ts +6 -0
- package/src/prompts/system-prompt.ts +61 -10
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +71 -4
- package/src/providers/anthropic/client.ts +9 -10
- package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
- package/src/providers/inference/logit-bias.ts +55 -0
- package/src/providers/model-catalog.ts +34 -0
- package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
- package/src/providers/openai/chat-completions-provider.ts +44 -1
- package/src/providers/retry.ts +22 -0
- package/src/providers/types.ts +6 -0
- package/src/runtime/__tests__/agent-wake.test.ts +555 -691
- package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
- package/src/runtime/agent-wake.ts +108 -209
- package/src/runtime/assistant-event-hub.ts +1 -1
- package/src/runtime/channel-approvals.ts +1 -1
- package/src/runtime/interactive-ui.ts +1 -1
- package/src/runtime/routes/__tests__/acp-routes.test.ts +466 -55
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
- package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
- package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
- package/src/runtime/routes/acp-routes.test.ts +185 -32
- package/src/runtime/routes/acp-routes.ts +328 -30
- package/src/runtime/routes/app-management-routes.ts +3 -0
- package/src/runtime/routes/approval-routes.ts +1 -1
- package/src/runtime/routes/browser-routes.ts +1 -1
- package/src/runtime/routes/browser-tabs-routes.ts +6 -10
- package/src/runtime/routes/channel-verification-routes.ts +14 -5
- package/src/runtime/routes/conversation-cli-routes.ts +1 -1
- package/src/runtime/routes/conversation-list-routes.ts +1 -1
- package/src/runtime/routes/conversation-query-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +30 -2
- package/src/runtime/routes/conversation-starter-routes.ts +13 -7
- package/src/runtime/routes/conversations-import-routes.ts +24 -7
- package/src/runtime/routes/host-app-control-routes.ts +1 -1
- package/src/runtime/routes/host-cu-routes.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +18 -3
- package/src/runtime/routes/inbound-message-handler.ts +1 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
- package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
- package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
- package/src/runtime/routes/memory-v3-routes.ts +65 -305
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
- package/src/runtime/routes/playground/helpers.ts +1 -1
- package/src/runtime/routes/plugins-routes.ts +337 -35
- package/src/runtime/routes/stt-routes.ts +45 -12
- package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
- package/src/runtime/routes/work-items-routes.ts +2 -4
- package/src/runtime/routes/workspace-routes.ts +50 -15
- package/src/runtime/services/conversation-serializer.ts +1 -1
- package/src/runtime/verification-outbound-actions.ts +147 -2
- package/src/runtime/verification-templates.ts +29 -3
- package/src/services/published-app-updater.ts +30 -8
- package/src/signals/cancel.ts +2 -4
- package/src/subagent/manager.ts +21 -5
- package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
- package/src/telemetry/activation-funnel.ts +167 -0
- package/src/telemetry/types.ts +13 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
- package/src/telemetry/usage-telemetry-reporter.ts +26 -1
- package/src/tools/acp/context.ts +20 -0
- package/src/tools/acp/list-agents.test.ts +9 -19
- package/src/tools/acp/list-agents.ts +3 -15
- package/src/tools/acp/spawn.test.ts +173 -202
- package/src/tools/acp/spawn.ts +37 -172
- package/src/tools/acp/steer.test.ts +105 -8
- package/src/tools/acp/steer.ts +48 -17
- package/src/tools/apps/executors.ts +166 -50
- package/src/tools/browser/browser-execution.ts +12 -2
- package/src/tools/filesystem/write.ts +34 -0
- package/src/tools/network/web-fetch.ts +65 -24
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/subagent/spawn.ts +2 -4
- package/src/tools/ui-surface/definitions.ts +32 -5
- package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
- package/docs/plugins.md +0 -832
- package/examples/plugins/echo/register.ts +0 -143
- package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
- package/src/__tests__/compaction-pipeline.test.ts +0 -210
- package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
- package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
- package/src/__tests__/pipeline-runner.test.ts +0 -554
- package/src/daemon/wake-target-adapter.ts +0 -253
- package/src/memory/v3/__tests__/assign.test.ts +0 -242
- package/src/memory/v3/__tests__/capabilities.test.ts +0 -118
- package/src/memory/v3/__tests__/core.test.ts +0 -39
- package/src/memory/v3/__tests__/health.test.ts +0 -219
- package/src/memory/v3/__tests__/maintain-job.test.ts +0 -288
- package/src/memory/v3/__tests__/needle.test.ts +0 -107
- package/src/memory/v3/__tests__/orchestrate.test.ts +0 -436
- package/src/memory/v3/__tests__/provider-blocks.test.ts +0 -13
- package/src/memory/v3/__tests__/reconcile.test.ts +0 -274
- package/src/memory/v3/__tests__/render-injection.test.ts +0 -61
- package/src/memory/v3/__tests__/router.test.ts +0 -332
- package/src/memory/v3/__tests__/selector.test.ts +0 -470
- package/src/memory/v3/__tests__/shadow-plugin.test.ts +0 -432
- package/src/memory/v3/__tests__/snapshot.test.ts +0 -168
- package/src/memory/v3/__tests__/tree.test.ts +0 -192
- package/src/memory/v3/assign.ts +0 -268
- package/src/memory/v3/capabilities.ts +0 -124
- package/src/memory/v3/core.ts +0 -26
- package/src/memory/v3/health.ts +0 -0
- package/src/memory/v3/maintain-job.ts +0 -314
- package/src/memory/v3/needle.ts +0 -115
- package/src/memory/v3/orchestrate.ts +0 -126
- package/src/memory/v3/page-content.ts +0 -34
- package/src/memory/v3/provider-blocks.ts +0 -26
- package/src/memory/v3/reconcile.ts +0 -523
- package/src/memory/v3/router.ts +0 -190
- package/src/memory/v3/selection-log-store.ts +0 -84
- package/src/memory/v3/selector.ts +0 -226
- package/src/memory/v3/shadow-plugin.ts +0 -411
- package/src/memory/v3/snapshot.ts +0 -209
- package/src/memory/v3/tree.ts +0 -174
- package/src/memory/v3/types.ts +0 -59
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
- package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
- package/src/plugins/defaults/circuit-breaker/package.json +0 -15
- package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
- package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
- package/src/plugins/defaults/compaction/register.ts +0 -35
- package/src/plugins/defaults/compaction/terminal.ts +0 -73
- package/src/plugins/defaults/empty-response/register.ts +0 -23
- package/src/plugins/defaults/history-repair/register.ts +0 -24
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +0 -216
- package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
- package/src/plugins/defaults/overflow-reduce/package.json +0 -15
- package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
- package/src/plugins/defaults/title-generate/register.ts +0 -35
- package/src/plugins/defaults/tool-error/register.ts +0 -23
- package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
- package/src/util/map-limit.ts +0 -27
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
- /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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
45
|
+
* Build a `fetch` that serves the GitHub Contents API from an in-memory tree.
|
|
33
46
|
*
|
|
34
|
-
* `tree`
|
|
35
|
-
* `simple-memory/
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
*
|
|
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
|
|
44
|
-
tree: Record<string, Uint8Array | string | null
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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,
|
|
71
|
-
name
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
98
|
+
return (async (input: RequestInfo | URL) => {
|
|
84
99
|
const url = typeof input === "string" ? input : input.toString();
|
|
85
100
|
|
|
86
|
-
if (url.startsWith(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
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(
|
|
100
|
-
const
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
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
|
-
|
|
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("
|
|
263
|
-
|
|
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(
|
|
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
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
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("
|
|
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
|
-
|
|
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
|
|
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"],
|